Skip to main content

Junyi's Lab

deciax: Running a Decision Model on TPU with JAX and Pallas

Table of Contents

Routing a support ticket, checking whether it asks for a refund, and scoring its urgency all call for structured decisions. A model can return choices and probabilities directly, without generating a textual answer token by token.

Laya is a Decision Model that handles choice, score, and yes/no questions in a single forward pass. I ported its inference path to JAX and built deciax to run these models on TPU, then optimized the computation with Pallas.

In this benchmark1, a single TPU v6e chip reached a single-question p50 of 2.18 ms for the English model and 1.55 ms for the multilingual model. The fastest measured results on A100 SXM4, H100 PCIe, and H200 NVL were 6.4 / 4.0 ms, 4.8 / 3.2 ms, and 5.8 / 4.1 ms, respectively (English / multilingual).

At 50 questions per call, v6e Pallas reached 33.69 / 17.83 ms, roughly 2.3× faster than plain JAX. The fastest measured A100, H100, and H200 results were 68.8 / 36.2 ms, 44.0 / 23.2 ms, and 39.8 / 21.2 ms, respectively.

# One input, several decisions

deciax takes an input and a set of questions, then returns structured results. Here is a duplicate-charge ticket:

import deciax

decider = deciax.load("laya")
result = decider.decide(
    {"user": "I was charged twice this month. Please refund the duplicate."},
    {
        "department": {
            "type": "choice",
            "instructions": "Which department should handle this?",
            "criteria": {
                "billing": "payments, invoices, refunds",
                "technical": "bugs, outages, system errors",
                "other": "everything else",
            },
        },
        "urgency": {
            "type": "score",
            "instructions": "How urgent is this?",
            "criteria": ["not urgent", "soon", "blocking"],
        },
        "refund_requested": {
            "type": "noul",
            "instructions": "Does the user ask for a refund?",
        },
    },
)
print(result)

# Move the inference path to JAX

deciax preserves Laya’s input construction, ModernBERT encoder, and decision heads while moving inference to JAX. Its runtime does not require PyTorch, Transformers, or Laya; the checkpoints and tokenizer still come from the original model. It also offers a /v1/systemone HTTP endpoint. The project documentation lists its current scope.

# Start with the baseline: PyTorch eager and JAX

Measurements ran on Oct 12, 2026, across RTX 3090, A100 SXM4, RTX A6000, H100 PCIe, and H200 NVL hosts, plus single-chip TPU v5e and v6e runs.

Latency figures show warmed in-process p50 per call2, including input encoding, inference, and result handling. Solid bars show p50; dashed markers show p95.

First, compare PyTorch eager on GPU with ordinary JAX on TPU:

Baseline p50 and p95 latency for PyTorch eager and plain JAX, with 1 and 50 questions on English and multilingual models
Baseline latency in ms; lower is better. Solid bars show p50, dashed markers show p95, and labels read p50 / p95. Asterisks mark shared hosts.

For English single-question calls, GPU eager p50 ranged from 13.3 to 25.8 ms, while v6e JAX took 2.27 ms. At 50 questions, v6e JAX took 76.79 ms, compared with the lowest GPU eager result of 57.5 ms on H200 NVL. Multilingual followed a similar pattern: v6e JAX grew from 1.61 ms to 41.24 ms, while H200 eager took 34.0 ms at 50 questions.

# Where time goes: dispatch overhead and model computation

Next, break down baseline calls using the English model3:

English baseline time breakdown: substantial dispatch and remaining processing time at one question, with device computation dominating at 50 questions
Blue is profiled device busy time; the hatched segment is total latency minus that estimate. Labels show independently measured p50. These come from separate measurement series, so the split is approximate. The remainder includes dispatch, Python, input/output handling, and idle gaps; it is not CPU self time.

Single-question GPU eager calls issue many small kernels: the English model used 1,090–1,213 cudaLaunchKernel calls per inference. The large remainder outside device execution points toward both kernel fusion and reducing dispatch overhead.

At 50 questions, computation accounts for most of the call. English v6e JAX took 76.79 ms end to end, with separately measured forward-only time of 71.27 ms and encoding near 3 ms. In XProf, dot_general and sub accounted for about 38% and 21% of this device time, directing attention to matrix computation and data movement in elementwise operations.

After the port, JAX operations serve as the baseline for exploring how to fuse computation on TPU. At the measured shapes, Pallas fuses multiple computations within each layer into one kernel4.

# Optimized: the fastest measured implementation on each platform

GPU testing covers TileLang, CUDA Graph, and torch.compile. We select the fastest measured implementation for each host, model, and question count to make the comparison as fair as possible. The TPU path uses v6e Pallas.

Optimized single-question latency: each GPU's fastest measured implementation versus v6e Pallas, with p50 bars and p95 markers
One question per call, in ms; lower is better. Solid bars show p50, dashed markers show p95, and labels read p50 / p95. Asterisks mark shared hosts. GPU testing covers TileLang, CUDA Graph, and torch.compile; each card uses its fastest measured implementation to make the comparison as fair as possible.

Single-question v6e Pallas p50 was 2.18 ms for English and 1.55 ms for multilingual. The lowest English GPU result was 4.8 ms on H100 PCIe using torch.compile with cudagraph; the lowest multilingual result was 3.2 ms. Pallas makes little difference relative to plain JAX at this shape.

Optimized 50-question calls: each GPU's fastest implementation versus v6e Pallas, with English p50 of 33.69 ms and multilingual p50 of 17.83 ms
50 questions per call. Solid bars show p50, dashed markers show p95, and labels read p50 / p95. Every GPU's fastest variant here is torch.compile with cudagraph. v6e Pallas p50 is 33.69 ms / 17.83 ms, versus 39.8 ms / 21.2 ms for the fastest GPU (English / multilingual).

At 50 questions, English v6e latency fell from 76.79 ms with JAX to 33.69 ms with Pallas; multilingual fell from 41.24 ms to 17.83 ms. These are speedups of 2.28× and 2.31×. English forward-only time fell from 71.27 ms to 28.25 ms, and multilingual from 36.54 ms to 13.36 ms.

# Latency as the question count grows

Connect the measurements at 1, 5, 10, and 50 questions to compare latency on H100, H200, and v6e Pallas:

p50 latency curves for 1, 5, 10, and 50 questions on H100 PCIe, H200 NVL, and v6e Pallas
The x-axis is questions per call and the y-axis is p50 in ms; both use log scales. H100 PCIe and H200 NVL use their fastest measured implementation at each question count, alongside v6e Pallas. Each GPU curve corresponds to one fixed card.

# HTTP serving and installation

We also measured deciax.serve. For single-question calls over loopback with keep-alive, v6e Pallas HTTP p50 was 3.0 ms for English and 2.4 ms for multilingual. This includes the local HTTP round trip, but no external network latency or concurrent load test.

Use Python 3.14 or newer. Install it on a TPU host with:

python -m pip install "deciax[tpu]==0.1.1"

This release supports the Python interface example above. To inspect the Pallas implementation measured here, check out the corresponding code from the repository:

git clone https://github.com/Junyi-99/deciax.git
cd deciax
git checkout 6f3a930
uv sync --extra tpu

# Cost: Million model input tokens per US dollar

Cost compares only optimized English-model implementations: the fastest measured GPU variant and v6e Pallas on TPU. We calculate both on-demand and preemptible / spot scenarios.

Performance and price data were collected on Oct 12, 2026.

Prices are USD per GPU-hour or TPU chip-hour.

HardwareProviderOn-demandPreemptible / Spot5
A100 SXM4 80GBVast.ai$1.176$0.727
H100 (SXM pricing)Nebius$4.500$0.790
H200 (SXM pricing)Nebius$5.400$0.790
TPU v6eGoogle Cloud$2.700$0.970

The calculation is6:

Million model input tokens per US dollar
  = model input tokens per second × 3600 / hourly price / 1,000,000

Model input tokens include the ticket text repeated for each question.

Million model input tokens per US dollar for optimized English-model inference on A100, H100, and TPU v6e Pallas, at on-demand and preemptible/spot rates
Million model input tokens per dollar; higher is better. English only, comparing the fastest measured A100 and H100 implementations with v6e Pallas. The top panel uses one question per call; the bottom uses 50. Prices, dates, and suppliers are listed above; preemptible / spot retains the report's pricing assumptions.

# Conclusion

deciax brings Laya’s structured decisions to TPU. In this experiment, plain JAX already handled single-question calls in about 2 ms, with little additional benefit from Pallas at that shape. At 50 questions per call, the fused Pallas path was roughly 2.3× faster than plain JAX. The question count changes both the dominant overhead and the benefit of optimization.

For the inputs, models, and implementations tested here, v6e Pallas had lower latency than the compared GPU implementations at both one and 50 questions.

You can try deciax in Colab or explore the code in the GitHub repository.


  1. Performance data in this post comes from development commit 6f3a930. ↩︎

  2. Inputs use upstream’s Payout failing ticket with alternating noul and choice questions. Each series uses 20 warmups followed by 20 timed calls with device synchronization. ↩︎

  3. GPU p50 in the breakdown comes from the profiling companion timing series, separate from the preceding baseline figure. ↩︎

  4. The Pallas path in this report fuses normalization, QKV projection, RoPE, attention, output projection, and the MLP. For the path with sequence length at most 512, each layer uses one pallas_call. ↩︎

  5. Nebius Spot uses the starting price; TPU preemptible uses the report snapshot. ↩︎

  6. English calls contain 194 model input tokens at one question and 9,975 at 50 questions. We use the report’s recorded input tokens/s, derived from token counts divided by the original p50 latency. ↩︎