Skip to content

Performance measurements

This page records experiments on one RTX 4080, at the revisions and settings stated in each section. For architecture comparisons, see step benchmarks. For how distributed training is configured today, see distributed training. A result at one shape and one revision does not settle a default for every case, and it says nothing about TPUs.

The timeline busy percentages below were taken before e5ee70d, which fixed the measurement window for nested kernel intervals. Before you reuse those percentages, replay the original traces. The synchronized wall-clock step times are separate measurements, and that arithmetic bug does not affect them.

These are command templates. Replace the angle-bracket fields with the architecture, kernel and data of your experiment:

python tools/benchmark_attention.py --json-out attention.json
python tools/benchmark_step.py --preset small --architectures <arch> \
--attention-impl <kernel> --warmup 3 --steps 10
XLA_FLAGS=<flags> python tools/benchmark_step.py --preset small \
--architectures <arch> --warmup 3 --steps 10
python tools/optimizer_curve.py --dataset <tokens> --optimizer <name> \
--learning-rate <lr> --out <json>

Measured on jax 0.11.1 / jaxlib 0.11.1 / jax_cuda12_plugin 0.11.1, driver 595.84, RTX 4080 16 GiB, single device, bf16 compute, adam, 3 warmup and 10 measured steps, one architecture per process. The card was idle before each measurement: nvidia-smi --query-compute-apps=process_name showed only gnome-remote-desktop-daemon, which is the desktop itself. The card ran at 210 MHz and 30 W at rest and at 2760 MHz and 120-220 W under load. XLA reads a flag once, when a backend opens, so every flag configuration ran in a fresh process.

JAX_PLATFORMS=cuda XLA_PYTHON_CLIENT_PREALLOCATE=false XLA_PYTHON_CLIENT_MEM_FRACTION=0.8 \
python tools/benchmark_step.py --preset small --architectures <arch> \
--warmup 3 --steps 30 --profile-dir /tmp/dew-trace --profile-steps 5

tools/benchmark_step.py reads the traced window back itself. Busy time is the union of every kernel interval on the device’s streams. Kernels are counted per step, and kernel time is summed per category, where the category comes from the kernel name. Dew was at 9886c20, the tree before the cudnn padding described below.

architecturems/stepdevice busykernels/stepgemmelementwisereduceconvertattentioncopy
simple_dit7.0100% in steady state5323.290.691.190.790.690.16
causal_transformer88.8100%28263.313.37.60.81.80.7

The trace reports 81.6% busy for the DiT over its five steps. The profiler’s start puts a 3 ms gap into each of the first two steps. After that, the interval from one step to the next settles at 6.9 ms, which equals the kernel time. Once the loop is running, the device does not sit idle between steps.

The DiT’s reductions are the bias gradients of every Dense layer and the norm statistics. The 6-by-64 biases of the q, k and v projections cost three full passes over the activation gradient per layer, 0.25 ms a step. Its converts are XLA’s own split-K partial sums in fp32 and the per-use casts of the fp32 parameters to bf16 (0.15 ms of the 0.79). The decoder’s gemm time is the fp32 (TF32) vocabulary head. It runs as two cutlass s1688gemm kernels at 12.9 and 12.5 ms and four Triton tiles of 3.2 ms for the third product. The floor per product is 12.8 ms at the 49.5 TFLOP/s TF32 ceiling measured in docs/research/benchmark-parity.md.

This table shows what the host spends per step on simple_dit. The numbers come from the trace’s host plane and from timing the dispatch loop while the device was deliberately left behind. The device step is 6.9 ms.

host work per stepmshow measured
XLA thunk execution inside PjRt Execute3.3GpuExecutable::ExecuteThunks on the host plane; 2.4 of it is three CUDA-graph launches
Python in jax.stages.Compiled.__call__ before Execute1.8$stages.py __call__ 6.7 ms against PjRtCApiLoadedExecutable::Execute 5.0 ms. The $ events come from JAX’s Python tracer, which adds time to every Python and C call, so 1.8 is an upper bound on the untraced cost
placing a fresh batch (shard_batch)0.25200 calls timed in isolation, image plus tokens
the loop with a fixed device batch5.0dispatch loop time, 100 steps, device 27 steps behind
the loop with a fresh batch per step6.5same, device 7 steps behind
the loop with XLA command buffers off7.3--xla_gpu_enable_command_buffer=; wall 7.46 ms/step, the host is now the step

On the smallest step, the host takes 94% of the device’s time with a fresh batch every step, and 106% without command buffers. Two conclusions follow.

First, the Python in Compiled.__call__ costs at most 4.5 us per leaf, and this state has 396 leaves (more on a mesh). That is why Trainer.compile returns the jitted step, starting with de6b22c. Since the sequence axis landed, the jitted step is wrapped in the mesh context, and a dispatch costs 32 us on the i9-12900K with or without that wrapper. On this card the wall time stays the same, and host time drops by 1.8 ms a step.

Second, a fresh batch costs 1.5 ms more than a fixed one, because the command buffer has to be updated for the new buffer addresses. That cost limits how far the loop runs ahead (7 steps against 27). On a faster card or a smaller model it would set the wall clock. The placement itself (0.25 ms) is not the cause. Freeing the consumed batch is not the cause either: keeping every batch alive changes nothing under the default preallocation. Prefetch depths 2, 8 and 32 measure the same. I adopted no fix, because the runtime owns the addresses.

The other way to lose this time is to wait on the device every step, and that costs 45%. The same simple_dit loop with block_until_ready after each step runs at 10.3 ms against 7.1. The trainer’s loop does not wait between logging ticks. The peak allocation does not grow with how far the loop runs ahead (0.823 GiB at 27 steps ahead, 0.819 in lockstep; 3.499 against 3.495 GiB for hierarchical_mmdit).

I read through src/dew and measured on the small preset. The nine classes are the ones the owner listed. Each row names the cost it found and what was done about it.

classsitewhat was measuredverdict
1 sync in hot pathstraining/trainer.py fit, per-step loss.astype, interval_loss + loss, jnp.where(finite, ...), bad_run + 1, jnp.maximumjax_log_compiles over a 50-step fit: five one-op executables compiled and dispatched eagerly every step, no host sync; 176 us a step on the CPU backend of the i9-12900Kfixed on systems/parallelism: one jitted bookkeep, 37 us a step, a Regression fit from 756 to 702 us a step
1 sync in hot pathsjax.stages.Compiled.__call__ in Trainer.compile1.8 ms/step of Python at 396 leaves (table above)fixed on main in de6b22c (jit dispatch)
2 recompilationTrainer.fit with evaluation every 25 of 50 steps, diffusion and LM objectivesone jit(step), one jit(initial_state), one evaluation executable (_sample_impl, scored); no per-step or per-eval retracenone found
3 baked constantsthe compiled step’s optimized HLOsimple_dit: 20 constants, 0.19 MiB, the largest the 2D sincos table bf16[256, 384]; causal_transformer: nonenone found; the encoder’s table moved into the state before this pass
4 dtype churnHLO dots by output dtype and the trace’s convert kernelssimple_dit: 65 bf16 dots, 40 fp32 outputs that are XLA split-K partials and the fp32 final_proj; parameter casts 0.15 ms/step; decoder: the fp32 head by designnone found in dew’s code; XLA’s split-K choice is the card’s
5 redundant worknn/attention.py odd-length routing to the xla kernelhierarchical_mmdit 33.9 to 20.9 ms, simple_mmdit 12.9 to 11.0, peaks 3.50 to 1.85 and 1.43 to 1.08 GiBfixed, 3b67135
5 redundant workobjectives/lm head chunking at its default of 41.9 ms/step (2.2%) against one chunk, for 1.2 GiBreported to the LM lane with the sweep in docs/benchmarks.md
5 redundant workobjectives/diffusion/objective.py:141, null = self.encode(...) every stepa frozen encoder’s forward on the unconditional tokens, once per step, inside the step; free with the table encoder used here, a text tower’s forward at batch 1 with CLIP; not measured with CLIPreported to the diffusion lane
6 data pathDevicePrefetchIterator depth, shard_batch cost, main-thread placement0.15 to 0.25 ms/step waiting for a batch at depth 2, 8 and 32; placement 0.25 ms; the loop is bounded by dispatch, not the transfernone found
7 shardingthe compiled step’s input_output_aliasevery state leaf aliased (396 of 396 on simple_dit, 143 of 143 on the decoder): donation happensnone found; collectives on a mesh not measured this pass
8 compile timeTrainer.compileone compile per fit (class 2 row); the FLOP count reads the same executablenone found; the persistent cache was not timed this pass
9 memorypeak against the state, run-ahead against lockstepsimple_dit 0.82 GiB peak on a 303 MiB state, unchanged by run-ahead; hierarchical_mmdit 3.50 GiB on 847 MiB, 1.65 GiB of it the xla attention’s fp32 logitsfixed by the class-5 row

This pass did not run the following, so I claim nothing about them: the jax_default_matmul_precision settings, remat on a step that fits in memory, XLA flags other than command buffers, and the cost of the class-1 eager scalars. (bfloat16 matmul precision would change the numerics of the fp32 head, and the precision rule refuses it in any case.)

Correction, 2026-09-22, checked against current main. The jitted bookkeep from the first class-1 row is on main (src/dew/training/trainer.py:150, called at :770). The class-5 row about objective.py:141 no longer matches the code. The diffusion objective encodes the unconditional prompt once, when it is built, and each step only casts that stored encoding to the batch’s dtypes (blank_conditions, src/dew/objectives/diffusion/objective.py:142-149, used at :204-207).

I reran tools/benchmark_torch.py in a fresh venv with torch 2.14.0+cu130 and cuDNN 9.24. The run the week before used 2.11.0+cu128 with cuDNN 9.19. The flags were --mode compile --warmup 20 --steps 100, with the small presets and one process per row. The dew columns are the rows of docs/benchmarks.md and the table above:

casedew ms/steptorch compile, reference attentiontorch compile, SDPA cudnndew against the best torch row
simple_dit7.029.288.391.19x faster
causal_transformer88.7881.5072.460.82x, torch faster by 18%

The week before, the decoder read 0.95x. That figure compared the parity benchmark’s fixed-batch decoder row (75.70) with torch’s 72.18. The dew row here is the benchmark’s own prefetching loop, at 88.78. The two dew numbers are 13 ms apart. Of that, 1.9 ms is the chunked head and 3.8 ms is the decoder’s own changes since 6b0f119. The remaining 7.6 ms is the gap between the fixed-batch row and this tool’s loop at the same commit (6b0f119 reruns at 83.26 here on the same day). The DiT gained, from 1.16x the week before to 1.19x. On the newer torch, torch’s SDPA row is 0.4 ms slower than the week before.

tools/benchmark_attention.py, bf16. The batch is chosen so that query tokens times heads is 524288 in every row. The table shows the forward pass alone, and the forward pass with the gradient with respect to q, k and v, in milliseconds.

SDcausalreference fwdxla fwdcudnn fwdreference bwdxla bwdcudnn bwd
25664no4.363.990.628.529.503.95
25664yes4.644.110.639.4410.243.97
256128no6.7710.431.2218.3111.9510.33
256128yes7.497.432.1320.4715.0913.62
102464no19.6521.671.6337.9135.2410.35
102464yes16.3619.571.1534.6231.065.89
1024128no13.8813.273.2428.6432.5615.42
1024128yes13.9013.432.2229.0533.5110.57
409664nooomoom6.53oomoom24.31
409664yesoomoom3.93oomoom14.87
4096128nooomoom12.12oomoom46.89
4096128yesoomoom6.96oomoom26.38

The reference and xla paths materialize the S x S logits. They run out of 16 GiB at S=4096, and wherever they fit they are 3 to 12 times slower than the fused kernel. cudnn is the kernel to use for a GPU run, forward and backward, and 'auto' picks it wherever it can.

Head dimension 256 through tokamax’s Triton flash attention

Section titled “Head dimension 256 through tokamax’s Triton flash attention”

Before Hopper, cudnn refuses head dimensions above 128. So a Gemma 3 4B or 12B shape (heads of 256) trains through the xla path on every Ampere and Ada card, and that path materializes the S x S logits. tokamax 0.0.13 ships a Pallas-Triton flash attention for compute capability 8.0 and up. It has a forward and a backward, and it takes grouped query heads, causal masks, windows and any power-of-two head dimension. JAX 0.11.1 deprecates its own jax.experimental.pallas.ops.gpu.attention in favour of it.

Measured on the RTX 4080 (compute capability 8.9, 99 KiB of shared memory per block), driver 595.84, jax/jaxlib 0.11.1, tokamax 0.0.13. Inputs were bf16, with 8 query and 4 key/value heads, causal, 3 warmup and 20 timed calls. The error is against the fp32 reference einsum on the same values. The command was tools/benchmark_attention.py with --implementations xla triton --head-dims 256 --kv-groups 2 --reference-error --triton-device-kind "NVIDIA GeForce RTX 4090". The device kind is needed because JAX’s Pallas-Triton backend compiles for a table of named cards, and that table lists the 4090 but not the 4080.

Swindowxla fwdtriton fwdxla temptriton tempxla fwd+bwdtriton fwd+bwd
1024 (B=2)none0.4680.21096 MiB01.47fails, shared memory
2048 (B=1)none1.190.380192 MiB02.66fails, shared memory
4096 (B=1)none3.971.03768 MiB010.5fails, shared memory
4096 (B=1)10244.100.540768 MiB010.7fails, shared memory

The Triton forward is 2.2 to 7.6 times faster than xla, uses no temporary memory, and has a smaller error (0.0081 against xla’s 0.0112, on outputs of size 3.4). The backward does not run. tokamax’s Triton VJP uses one fixed tiling for every card (pallas_triton_vjp.py carries a TODO: Implement heuristics). At head dimension 256 that tiling asks for 102784 bytes of shared memory, and the card has 101376, so it fails with RESOURCE_EXHAUSTED: Shared memory size limit exceeded.

I probed other tilings through tokamax’s private classes. A 32x32 tiling with one stage fits and is correct (gradient error 0.031, the same as xla). It runs forward and backward in 1.80 ms against xla’s 2.66 at S=2048. Two 16-row tilings compile and run at the same speed, but they return wrong gradients (error 6.6 on gradients of size 6.3). tokamax’s autotuner picks a tiling by its time on random inputs and never compares numerics, so I cannot trust autotuning to find the correct one. At head dimension 128 the Triton kernel ties cudnn (0.235 against 0.236 ms forward, 0.75 against 0.78 forward and backward at S=2048), so it gains nothing where cudnn already runs.

Two other features are still missing. The first is Gemma 2’s logit softcap. The Triton forward takes it (0.35 against xla’s 1.01 ms at S=2048, head dimension 256), but the VJP raises NotImplementedError: logits_soft_cap unsupported. tokamax also applies the cap after adding the bias, while Gemma applies it before (1.4e-2 apart on CPU with a bias, identical without one). The second is attention sinks, which no tokamax implementation takes.

I added no Dew route for this kernel. A forward-only kernel cannot serve training, and the only backward tiling that works is reachable through private tokamax classes. The route needs an upstream tokamax release whose VJP picks a tiling that fits the card, or a public tiling setting, with a correctness check next to it. Installing tokamax 0.0.13 next to Dew also pins typeguard==2.13.3, while tyro 1.0.16 requires typeguard>=4.0.0. That breaks the command line of every recipe, so I ran the tool through its main function in a separate environment.

cudnn’s fused kernel has no backward pass for an odd query or key length. The forward pass takes any length, so the problem only appeared at the first training step, as NotImplementedError: Unsupported sequence length Q 333, KV 333 from jax. 77 CLIP text tokens are an odd length, and so is 256+77 concatenated.

Until 2026-09-05, 'auto' sent those shapes to the xla kernel. That kernel materializes the [B, H, Q, K] logits and their probabilities in fp32 and keeps them for the backward pass. cudnn_attention pads an odd length to an even one instead. It adds one zero row to the query and slices it off the output. It adds one zero key and hides it with the kernel’s own padding mask (key_value_seq_lengths), so every real query attends to exactly the keys it had. On a GPU, 'auto' picks cudnn at any sequence length, and an explicit 'cudnn' also takes any length.

tests/test_kernels.py::test_cudnn_trains_odd_lengths_and_agrees_with_xla checks this at q1024/kv77, q9/kv7 and q333/kv333 causal. The outputs and the three input gradients agree with the xla kernel to within two bf16 ulps of their scale. The two kernels sit the same distance apart at an even length (q256: 1.6e-2 at scale 2.9 on the output, 7.8e-2 at scale 15.6 on the gradients, both one ulp). If the pad key is left unmasked, the q9/kv7 output moves by 0.26 at scale 2.4 and the test fails. If the pad query row is left in, the shape changes and the test fails.

To see what the padding is worth, I ran --warmup 3 --steps 50 on the small preset with 'xla' (the kernel these shapes ran on before the padding) against 'auto':

architectureshapesxla ms/stepcudnn ms/stepxla peak GiBcudnn peak GiBloss at the end, xla / cudnn
hierarchical_mmditq141, q333, q110133.8620.863.501.850.551035 / 0.551038
simple_mmditq333/kv33312.8611.011.431.080.584398 / 0.584407
unetq256/kv77, q1024/kv7716.3016.130.780.710.597518 / 0.597516

The xla attention on the 1101-token stage kept its fp32 logits and probabilities for the backward pass. That is where the 1.65 GiB and the 13 ms went. Attention is a small part of the unet’s step, so the unet gains little. The losses are after 103 steps on one fixed batch and differ in the sixth digit. That difference is the two kernels’ bf16 rounding, compounded by Adam. Decoding asks for one query position at a time, which is an odd length. It runs on cudnn with the cache mask as an additive bias. I did not measure its speed.

Attention metadata and the masked conv, 2026-09-07

Section titled “Attention metadata and the masked conv, 2026-09-07”

Before 14622ba, any AttentionMetadata cost the fused kernel, whatever the metadata said. The mixer built its [B, 1, S, S] mask and forced the xla path as soon as any metadata arrived. A batch that only spelled out rotary positions, or one whose validity marked every slot as real, paid for a mask that excluded nothing. At 14622ba the mixer checks what the metadata restricts: key validity, or image groups on a bidirectional-image layer. A validity array is opaque at trace time, so an all-true array still builds the mask. The host producers that used to emit one leave it out when they know the rows are whole: pad_token_rows, the processor’s from_hf, generation’s input validation, the rollout collector and episode cohorts, the PPO critic without lengths, and every MTP depth.

The Gated DeltaNet short conv had the same kind of problem inside it. _masked_conv1d convolved one token per scan step to keep a paused row’s history still. At 14622ba it compacts each row’s real tokens by cumsum(valid) - 1 and calls the same fp32 causal_conv1d once.

Conditions: one RTX 4080, bf16 compute with fp32 master parameters, one fresh process per case, XLA_PYTHON_CLIENT_PREALLOCATE=false, no XLA flags, 5 warmups then 3 windows of 50 calls. The numbers are medians of the time from dispatch to block_until_ready, at 14622ba against 83f08e5:

casefwd beforefwd afterfwd+bwd beforefwd+bwd afterpeak MiB before / afterkernels a call before / after
attention, no metadata0.3780.3801.1981.200169 / 16943 / 43
attention, opaque all-true validity1.0301.0482.9602.960496 / 49650 / 50
attention, canonical metadata1.0170.3782.9591.175496 / 16950 / 43
attention, packed segments1.0401.0462.9742.956496 / 49650 / 50
GDN, no mask4.3824.40416.1416.121460 / 14601882 / 1882
GDN, all-true dynamic mask16.154.91554.7918.561923 / 154436700 / 1884
GDN, lengths 2048 and 153716.094.87654.7018.461923 / 154436700 / 1884

The attention cases use batch 1, 2048 tokens, and 8 query and 4 key heads of 128. The GDN cases use batch 2, 2048 tokens, 8 key and 16 value heads, and conv kernel 4. Peaks and kernel counts are the forward+backward figures.

The canonical row is the same batch as the opaque row with the redundant validity left out, so it has the shape of a real unpadded request. Its before column is that same call measured at 83f08e5. The compiled HLO holds a __cudnn$fmhaSoftmax custom call after the change and none before, which shows that the route changed and the gain is not clock noise. The opaque and packed rows are unchanged by design, and their spread across windows covers the difference. The peaks the process allocator reports move by up to 20 MiB between identical runs. The packed forward gave 496.02 and 476.02 MiB on two repeats of the same executable, whose own memory_analysis is byte-identical. Read the peak column at that resolution.

Case by case: canonical metadata runs the plain call exactly. Its outputs are bitwise equal to the no-metadata forward, and its parameter gradients are within 2.4e-06 of it. The opaque all-true mask stays on the xla kernel at its old cost, because the shape of a validity array does not say that its contents are all true. The GDN rows time the whole mixer (projections, gates, rule and norm), and the masked conv is the only part that changed. With a mask, the mixer is 3.3 times faster forward and 3.0 times faster with the gradient. Its kernel launches drop 22.8 times forward (10707 to 470 a call) and 19.5 times with the gradient (36700 to 1884). The scan’s while loop is gone from the HLO, and the __cudnn$convForward of the unmasked path takes its place. The result is exact where it has to be. On lengths 2048 and 1537 the outputs agree with row-by-row evaluation to 2.4e-04 (the layer’s bound is 5e-4). The padded row’s input gradients and outputs are exactly zero. Against the token scan on CPU at fp32, the largest difference over left, right, interior and paused padding at kernels 2, 4 and 8 is 4.8e-07.

Leaving the field out changes the batch’s pytree, so every process in a pool has to agree on it. Whether a process’s own rows needed padding is known only to that process. If one process leaves the field out while another carries it, the same step gets two different pytrees. A generation request first agrees on the signature that ignores validity, then on one fixed-size presence vector. Every process runs the same collectives in the same order, whatever it holds, and materializes the field wherever any process carries it. Where no process carries it, the field stays out and the call keeps the fused kernel. shard_batch cannot run that agreement. Placement runs on the worker thread of DevicePrefetchIterator, while the step’s collectives run on the caller’s thread. So in a pool, every ModelInputs of a training batch that lacks the field gets it materialized. Single-process runs, which is what the table measures, are untouched. So are batches of plain token arrays, which carry no validity anywhere.

I did not rerun the head-chunk and head-dimension-256 cases, because nothing in this change reaches them. To reproduce, run run_batch.sh in .cache/dew/mask-routing-83f08e5. Its kernel_cases.py holds the case definitions, the allocator and HLO capture, and the correctness groups.

TrainerConfig.xla_flags appends to XLA_FLAGS. prepare_process applies it before JAX opens a backend. The default is None, and this sweep is the reason. It covers three architectures, with one fresh process per configuration. Each cell is the median of the runs, with the range and count where I repeated a configuration.

configurationsimple_ditcausal_transformerunet
baseline7.01 [6.96-7.53] n=575.7017.38 [17.10-17.50] n=4
--xla_gpu_triton_gemm_any=true7.4375.6417.05 [16.78-17.43] n=4
--xla_gpu_autotune_level=47.02 [6.95-7.48] n=575.5817.36 [16.88-17.58] n=4
--xla_gpu_enable_latency_hiding_scheduler=true7.3375.6017.08
--xla_gpu_enable_command_buffer= (off)7.4976.1417.90 [17.45-18.15] n=4
--xla_gpu_enable_command_buffer=FUSION,CUBLAS,CUBLASLT,CUDNN,CUSTOM_CALL,WHILE7.03 [6.95-7.42] n=575.7816.94
--xla_gpu_enable_while_loop_double_buffering=true6.95 [6.93-7.09] n=575.7317.30
the two above with any signal, together7.00 [6.99-7.02] n=275.75 [75.67-75.84] n=217.09 [16.93-17.26] n=2

I adopted no flag, and the noise band is the reason. Four repeats of the same configuration on simple_dit spread from 6.97 to 7.53 ms, or 8%, because each fresh process autotunes again. Against that spread, every simple_dit number in the table comes from one distribution. The causal_transformer is the quiet measurement, with a spread of 0.7%, and no flag moves it by more than 0.2%. The unet is the only architecture where a flag shows an effect: --xla_gpu_triton_gemm_any=true takes the median from 17.38 to 17.05 ms, or 1.9%, over four runs each.

So the unet gains 2%, the decoder is unchanged, and simple_dit cannot tell the difference. The adoption rule asks for a flag to be faster on all three architectures and outside the noise on each, so the default stays None. A run that wants the unet flag can pass --trainer.xla-flags.

Two flags stand out for other reasons:

  • --xla_gpu_autotune_level=4 changes nothing on any architecture, because it is already the default in this build.
  • --xla_gpu_enable_command_buffer= (command buffers off) is the only configuration that is reliably slower: 17.90 against 17.38 on the unet over four runs, and slower on the other two as well. Command buffers are on by default and save 3% on the launch-heavy architecture. Passing a longer type list than the default adds nothing to that.

None of the candidate flags changes numerics. The sweep covered only kernel selection and scheduling. I tested no flag that relaxes precision, and none would be adopted, because an adopted change has to keep a fixed-seed 20-step loss trajectory within 1e-5.

I measured these numbers and adopted nothing from them. They show where the remaining room is on the architecture whose step is least sensitive to batch.

python tools/benchmark_step.py --preset small --architectures unet \
--batch-size 16 --warmup 3 --steps 10

I ran this once per batch size, and again with --xla-flags=--xla_gpu_enable_command_buffer=FUSION,CUBLAS,CUBLASLT,CUDNN,CUSTOM_CALL,WHILE for the extended rows.

runbatchms/step
unet1617.37
unet6457.59
unet, command buffers extended1617.12
unet, command buffers extended6457.94

Four times the batch costs 3.3 times the step. So about 4 ms of the 17.4 ms step (23%) does not scale with the batch, and 0.84 ms per sample does. Command buffers save 1.4% at batch 16 and nothing at batch 64.

When I measured these rows they had a utilisation column that read 1.7%. That number was wrong because of the counter. XLA’s cost_analysis() cannot see inside the cuDNN convolution calls the backend emits, and it undercounted this model 22.5 times. Counted off the optimized HLO, the unet runs at 40.5% of peak, as docs/benchmarks.md reports.

These are the only CPU rows in this file. A loss curve at equal tokens does not depend on the card’s kernels, and the run is small enough that one workstation CPU does nine of them in under an hour.

curl -o data/shakespeare.txt --create-dirs \
https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt
python tools/tokenize_text.py --input data/shakespeare.txt \
--out data/shakespeare-byte --tokenizer byte --val-fraction 0.02
JAX_PLATFORMS=cpu taskset -c 0-5 python tools/optimizer_curve.py \
--dataset data/shakespeare-byte --optimizer muon --learning-rate 3e-3 \
--steps 2000 --emb-features 128 --num-layers 2 --num-heads 2 --seed 0 \
--out /tmp/muon-3e-3.json

The first command downloads the corpus, which is not in the repository. I ran the last command once per arm, learning rate and seed.

Conditions: causal_transformer, 128 wide, 2 layers, 2 heads, tied head, byte vocabulary of 256, sequence length 128, batch 16, 557,952 parameters, bf16 compute, weight decay 0.1 on both groups, no schedule, no clipping. 2000 steps is 4,096,000 tokens, which is 3.75 passes over the 1,093,086 training tokens of the Shakespeare corpus. 12th Gen i9-12900K, jax 0.11.1, JAX_PLATFORMS=cpu, six cores pinned per run, three runs at a time on disjoint cores. Every arm sees the same batches in the same order at the same seed, so a difference between two arms comes from the solver.

There are three arms. adamw is AdamW. muon is Muon as this branch builds it. muon-unsplit is optax.contrib.muon with its own ndim == 2 rule, which is how the ‘muon’ entry worked before the parameter groups. Final loss is the mean over the last 50 steps.

armlr 1e-3lr 3e-3lr 1e-2
adamw1.47231.48421.5885
muon1.52291.44381.4713
muon-unsplit1.57621.45981.4916

This table shows each arm at its own best learning rate, averaged over seeds 0, 1 and 2, as the loss at five token counts:

arm0.51M1.02M2.05M3.07M4.10M
adamw, lr 1e-32.01361.73761.57371.50151.4764
muon, lr 3e-31.98851.67441.51791.45721.4386
muon-unsplit, lr 3e-32.24541.76461.55591.48121.4561

Muon with the parameter groups reaches 1.4386 where AdamW reaches 1.4764, 0.038 nats lower at the same tokens. The three seeds of an arm spread 0.007 to 0.013, so the gap to AdamW is three times that noise. The gap to unsplit Muon is 0.018, one and a half times the noise, and the split version is ahead on each of the three seeds, by 0.016, 0.020 and 0.017. Muon also holds its loss at ten times its best learning rate: it loses 0.028, where AdamW loses 0.116. That matches the tolerance the labs report (docs/research/frontier-training.md:183).

These numbers say nothing about 0.4B parameters. That is the run section 4.9 of docs/design/plan.md asks for, and it needs a v5e-16. The wall-clock times are not comparable either, because the runs shared a machine.

The fp8 trunk compiles and runs on the card, but the step does not get faster. Conditions: RTX 4080 16 GiB, driver 595.84, jax/jaxlib 0.11.1, Qwix 0.1.8, JAX_PLATFORMS=cuda, one process, one device, bf16 compute with the xla attention kernel, Quantization(dtype="fp8") over the whole trunk, adamw, 3 warmup and 10 measured steps. I ran two sizes, each in its own process: 8 layers of width 256 (mlp 512) and 8 layers of width 1024 (mlp 2048), both with 8 heads, vocabulary 512, sequence 64 and batch 8.

widthbf16 compilebf16 ms/stepfp8 compilefp8 ms/step
2567.95 s2.015.58 s2.18
10248.36 s9.596.29 s9.75

The compiled fp8 step holds f8e4m3fn converts (146 mentions in the HLO at width 256, against 12 GPU gemm calls), so the quantization reaches the device. At these sizes the converts cost more than the gemms save, and nothing raises an error. The losses go down (2.44 bf16 against 2.68 fp8 at width 256, 0.009 against 0.011 at width 1024, each after 14 steps from the same init). On this card, at these sizes, fp8 gives no speedup to adopt.

Each choice below is made in one place per kernel and keyed by hardware generation (dew.nn.kernels.device_generation: sm80, sm86, sm89, v5e, v6e, …); a generation without a measurement here runs the XLA path. tools/benchmark_kernels.py and tools/benchmark_lm_head.py reproduce the rows. The measurements are one process per row, jax 0.11.1, bf16 compute: a Colab NVIDIA L4 (the RTX 4080’s architecture, sm_89), a Colab TPU v6e-1, and the local RTX 4080 for the kernel-level rows. Step rows are tools/benchmark_kernels.py step (built on tools/benchmark_step.py’s trainer), 30 timed steps after 5 warmup; lm-moe is 321.8M parameters, 8 experts top-2, lm-dense 359.8M, both at sequence 1024. Batch is 4 (moe) and 1 (dense) on the L4, 8 and 8 on the v6e. “before” is main at c1f7e2dd.

The MoE grouped matmul: GROUPED_MATMUL_BY_GENERATION

Section titled “The MoE grouped matmul: GROUPED_MATMUL_BY_GENERATION”
devicepathms/stepp50 mspeak GiB
L4lm-moe before (xla)601.55610.8412.46
L4lm-moe after, auto = pallas213.14216.458.15
L4lm-moe after, xla598.54609.0712.46
v6elm-moe before (xla)76.0476.435.05
v6elm-moe after, auto = xla74.6875.255.05
v6elm-moe after, tokamax (mosaic_tpu_v2)75.4076.045.05

Rerun on the final branch, jax 0.11.2, one Colab L4 session (2026-09-22 19:55 to 20:13 CDT), tools/benchmark_kernels.py step --path lm-moe --batch 4: auto (pallas) 224.90 ms, 8.15 GiB peak; --implementation xla 602.36 ms, 12.46 GiB. projection: Pallas 3.37 ms, XLA 25.19 ms forward plus backward.

On a mesh, KernelReview’s 2x RTX 3090 (jax 0.11.2, one bf16 ExpertMLP layer forward plus backward, XLA against Pallas): fsdp 1 expert 1 141.9 against 64.4 ms; fsdp 2 83.3 against 98.1 ms, where the Pallas path all-gathers the fsdp-sharded expert kernel (128 MiB of temporaries against XLA’s 3120); expert 2 global 174.0 against 102.7; expert 2 exchange 93.0 against 14.9. Whole lm-moe steps: data 2 1024.0 against 578.3 ms, expert 2 exchange 780.9 against 537.1, same losses. That made auto take XLA where fsdp alone sharded the experts, until the dispatch moved every routed layer inside its row map, where both kernels see gathered experts, and there the Pallas kernels win on the RTX 3090 (expert parallelism, below).

KernelMatrix’s rows, forward plus backward, jax 0.11.2, checked against float64: at lm-moe’s up projection the Pallas kernels take 1.21 ms against XLA’s 6.25 on an A100, 3.15 against 25.9 on an L4 and 1.59 against 15.7 on the RTX 4080; at 128 experts 0.43 against 15.7 (A100), 1.38 against 77.9 (L4) and 0.55 against 33.5 (RTX 4080). On a TPU v5e and v6e XLA wins at 128 experts (v6e 0.346 ms against mosaic_tpu_v2’s 0.408). DistExpert’s RTX 3090 (sm86, jax 0.11.2): 2.54 ms against 17.40 up and 2.37 against 16.97 down, same forward error. An sm75 card (T4) cannot compile the Triton kernels, and no sm90 or sm120 card was available, so those run XLA.

expert_projection alone, 8192 rows, 768 to 2048, 8 experts, forward plus backward: XLA 26.21 ms and Pallas 3.38 ms on the L4; XLA 14.84 ms and Pallas 1.86 ms on the RTX 4080. Errors against a float64 oracle of the rounded operands are the same or lower for Pallas (kernel gradient 4.2e-6 against 6.8e-6 relative). The L4 step is 2.82x faster; JAX’s stock Pallas lowering with an out-sharding fix measured 1.97x on the same step, because its tangents run in fp32 and Dew’s backward multiplies the bf16 cotangent.

Rejected: a pure-JAX loop of dense per-tile products. On the RTX 4080 it was 2.2x faster than XLA for the projection alone (6.67 ms), but it doubled the step’s temporaries (4.22 GiB against 2.17 at batch 1), and on the v6e it was 2.2x slower than XLA (1.71 ms against 0.79). The Pallas kernels are JAX’s own gmm and tgmm from the jax-v0.11.2 source tree, vendored because no wheel ships them, and called through a custom VJP. jax 0.11.2 deprecates the Pallas Triton backend they run on and warns at every lowering. They stay the sm80 to sm89 path: JAX’s Mosaic GPU grouped matmul (pallas/ops/gpu/ragged_dot_mgpu.py) uses wgmma and fails to compile on the RTX 4080, and tokamax’s sm80 Mosaic config exceeds Ada’s shared memory. Dew does not silence the warning: it is the user’s to filter, and moving this path to Mosaic GPU on sm90 and later is an open item that waits for Hopper hardware to measure on. Under a mesh the kernels run inside shard_map on each device’s share of the sorted rows; that path is checked for parity on an 8-device CPU mesh and not measured on multiple GPUs.

On TPU, tokamax’s mosaic_tpu_v2 is within 1% of XLA on the step; tokamax’s default dispatch picks its v1 kernel there, 13x slower, so Dew names the kernel.

devicemeasurementfp32 statebf16 state, hash roundingbf16 state, threefry rounding
L4one AdamW update, lm-dense tree49.10 ms37.20 ms51.54 ms
v6eone AdamW update, lm-dense tree11.45 ms8.89 ms20.07 ms
L4lm-dense step138.72 ms, 7.34 GiB126.45 ms, 5.89 GiB
L4lm-moe step224.90 ms, 8.15 GiB218.15 ms, 6.92 GiB
v6elm-dense step123.85 ms, 5.77 GiB124.94 ms, 4.50 GiB
v6elm-moe step74.68 ms, 5.05 GiB71.96 ms, 3.87 GiB

The two L4 step rows are jax 0.11.2, from the final branch’s Colab session (2026-09-22, 19:55 to 20:13 CDT); the update rows and the v6e rows are jax 0.11.1. The rounding noise is a counter hash of the step, the leaf and the element index. threefry noise (jax.random.bits) makes the update slower than fp32 state on both devices. The saving is memory everywhere; on the v6e lm-dense step it costs 0.9% instead of saving time, so the option stays off by default.

The vocabulary head: the compute dtype’s product

Section titled “The vocabulary head: the compute dtype’s product”

The head’s product follows the compute dtype, as torch autocast and MaxText (logits_dot_in_fp32=False) run it: under bf16 compute both operands multiply as bf16 with fp32 accumulation, in the model’s head and the chunked loss alike, and the softmax and the loss stay fp32; an fp32 model keeps its fp32 head. Forward plus backward of the chunked head alone, 8 x 1024 tokens, 1024 features, vocabulary 50304:

devicefp32 operands (before)bf16 operands, with argmaxbf16 operands, no argmaxfused linear cross entropy (Pallas port of Liger)
L4206.16 ms134.02 ms133.91 ms142.63 ms
v6e8.04 ms8.03 ms7.26 msnot run

On the v6e the fp32 operands already multiplied in one bf16 pass, so only skipping the argmax (token_accuracy=False) moves the head. On the L4 the argmax fuses into the head’s own kernels. The lm-dense step on the L4 went from 138.72 ms to 129.36 ms with the bf16 product; on the RTX 4080 (jax 0.11.2) the head at 4 x 1024 tokens went from 45.25 ms to 28.00 ms.

The bf16 product changes the loss by less than its own rerun spread. tools/lm_step_parity.py, 100 steps of the 39M-parameter decoder on the RTX 4080, twice each way: two fp32-head runs differ by at most 2.3e-4 relative at any step, two bf16-head runs by 7.7e-4, and a bf16-head run differs from an fp32-head run by 3.4e-4 and 7.2e-4, within the bf16 head’s own rerun spread. Final losses 0.0078378 and 0.0078376 (fp32 head), 0.0078368 and 0.0078387 (bf16 head). Rejected: the fused Pallas kernel, 6% slower than the chunked head on the L4, and tokamax’s mosaic_tpu head, 2.24x slower on the v6e (kernel catalog, 2026-09-22).

A T4 (sm75) rejects the BF16_BF16_F32 dot algorithm at run time (“UNIMPLEMENTED: Unsupported algorithm on the current device(s): ALG_DOT_BF16_BF16_F32”), cuDNN’s fused attention refuses bf16 there (“SDPA FP16/BF16 requires SM80”), and Triton does not compile for it. dew.nn.kernels.generation.bf16_dot_runs is the one test: below sm80 bf16 attention takes the reference path for auto and xla, the bf16 operand precision keeps the caller’s precision, and the grouped matmul runs XLA.

KernelMatrix, 2026-09-22, jax 0.11.2, forward plus backward medians, every cell checked against float64 (~/.cache/dew/verification-evidence/kernel-matrix/MATRIX.md). Each needs a kernel or a dependency Dew does not carry yet:

opwhere it winsnumberswhy not yet
tokamax ragged_dot mosaic_tpu_v2 with tokamax’s own VJPTPU v5e and v6e, 8 expertslm-moe up 0.899 ms against XLA’s 1.05 (v5e), 0.395 against 0.48 (v6e); down 0.859 against 1.19 (v5e), 0.345 against 0.383 (v6e)tokamax 0.0.14 pins typeguard==2.13.3 and tyro needs >=4; its flax.nnx import fails on jax 0.11.2. At 128 experts XLA wins.
tokamax triton attentionsm80, sm89, no sliding windowcausal 1k: 0.423 ms (A100), 1.06 (L4), 0.570 (RTX 4080); 0-20% faster than cuDNNtokamax dependency, as above. cuDNN stays faster with a sliding window.
tokamax triton RMSNormsm80, sm891.07x (A100), 1.53x (L4), 1.57x (RTX 4080) over XLAtokamax dependency.
fused-weight SwiGLU (tokamax xla formulation, one contraction for gate and up)every GPU1.47x (A100), 1.47x (L4), 1.55x (RTX 4080), 1.21x (T4) over two XLA matmuls; no gain on TPUa change to the MLP’s parameter layout.
tokamax xla head plus cross entropysm89 speed73.0 ms (L4) and 36.8 ms (RTX 4080), 1.55x and 1.58x over Dew’s chunked headtokamax dependency, and it holds 1.6-3.2 GiB where the chunked head holds 131-355 MiB.
JAX’s Pallas GPU paged_attentionsm80 and later, decode1.75-1.84x (A100), 1.85-2.1x (L4), 2.1-2.8x (RTX 4080) over the XLA gathera decode-path change; on TPU the XLA gather wins at batch 8 and up to 2k context.

tools/benchmark_ssd.py, forward plus backward, batch 1, 8 heads of 64, state 128, jax 0.11.2:

devicechunklengthXLAPallas kernel
TPU v6e25640960.915 ms0.634 ms
TPU v6e256163844.505 ms1.911 ms
TPU v6e2566553617.245 ms7.122 ms
TPU v6e12840960.632 ms0.705 ms
RTX 40802564096 to 655362.28 to 33.63 msdoes not compile: 590 KB of shared memory asked, 101 KB available

On the RTX 4080 the Triton kernel ran 6x to 12x slower than XLA wherever it compiled (chunk 64, width 32: 1.39 against 0.22 ms; at batch 8 and 16 heads, 22.7 against 2.2 ms), and every chunk of 128 or 256 asked for 131 to 590 KB of shared memory. The scan takes the kernel on TPU only.

On an A100, ReferenceRuns measured the fp32 head at 38 ms a step, 21% of a Qwen3-0.6B bf16 fine-tune’s busy time, as TF32 GEMMs that torch autocast runs in bf16; that and the rows above made the bf16 product the default.

Packed sliding-window attention on GPU: local_attention

Section titled “Packed sliding-window attention on GPU: local_attention”

A packed batch with a sliding window has no fused-kernel flag on a GPU before Hopper: jax.nn.dot_product_attention takes no segment ids beside local_window_size, cuDNN’s packed layout (q_offsets) raises “Packed layout requires a GPU with at least Hopper architecture” on sm89, and JAX’s Pallas GPU mha takes segment ids but no window (and was 4-9% off in the gradient at this shape). local_attention therefore builds its [W, 2W] band mask and, where cuDNN runs, hands it to cuDNN as the additive bias; elsewhere xla takes it. Colab L4, jax 0.11.2, bf16, 16 query heads of 64 over 4 key heads, window 4096, 5 packed documents, forward plus backward (~/.cache/dew/verification-evidence/packed-window/bench.py):

tokensbefore (band on xla)after (band on cuDNN)
2048, window 5126.15 ms, 0.32 GiB1.37 ms, 0.05 GiB
8192out of memory (10.0 GiB requested)40.0 ms, 0.24 GiB
16384out of memory76.9 ms, 0.60 GiB
32768out of memory156.5 ms, 1.19 GiB
65536out of memory316.7 ms, 2.38 GiB

Against a float64 oracle at 2048 tokens the output error is 2.7e-3 relative and the gradients 3.3e-3 to 6.6e-3, the same as the xla path’s. A dense [S, S] document mask on cuDNN is faster at 32768 tokens on an RTX 4080 (63.7 against 78.1 ms) but grows with the square of the length and ran out of memory at 65536, so the band is the path.

Expert parallelism on 4x RTX 3090, 2026-09-23

Section titled “Expert parallelism on 4x RTX 3090, 2026-09-23”

The machine is one host with four RTX 3090s. GPU0 and GPU1 are joined by NVLink (NV4), GPU2 and GPU3 share a PCIe host bridge, and every other pair crosses the two sockets. jax 0.11.2, bf16 compute, the Pallas grouped matmul, tools/benchmark_step.py with 12 timed steps after 3 warmup and 3 traced. Each row’s attribution is benchmark_step’s reading of its trace, in milliseconds per device per step: compute kernels, each collective, and the communication no compute kernel overlapped. The model is a causal_transformer of 8 layers, width 1024, 16 heads and vocabulary 50304, with 32 experts of width 1024 and top-4 routing on every layer, at 4096 tokens a device (batch 16 of 1024 on four GPUs, 8 on two).

The links first, as JAX collectives of 128 MB of bf16 a device: all_to_all moves 33.4 GB/s over the NVLink pair, 6.9 over the PCIe pair and 6.7 across the sockets, and all_gather and psum follow (31.0, 5.8, 5.8 and 33.4, 5.7, 6.3). The PCIe pair is no faster than a cross-socket pair, so GPU0 and GPU1 are the only fast pair on this box.

meshexpert axis joinsdispatchms/steptokens/scomputeexposed commall-to-allall-gatherreduce-scatterall-reducepeak GiB
fsdp 4-global904.017562199.1725.10373.7375.10.57.9
expert 40123global925.517113179.2774.60341.5335.198.08.9
expert 40123exchange634.026232272.4352.1271.10099.513.5
expert 40123exchange, capacity 1.25550.628829163.6422.2253.800168.39.3
expert 2 x fsdp 202, 13exchange788.120916248.6537.4281.397.7102.258.910.5
expert 2 x fsdp 201, 23exchange800.320598247.8544.5109.2203.7212.322.210.5
data 2 x expert 201, 23exchange753.821912252.1501.9118.100421.614.0
data 2 x expert 202, 13exchange702.924176254.6442.0211.800306.114.0

One pair at a time, MeshSpec(expert=2) at the same 4096 tokens a device:

pairdispatchms/stepcomputeexposed commall-to-allall-gatherreduce-scatterall-reduce
GPU0-1, NVLinkexchange295.8245.646.940.70017.8
GPU0-1, NVLinkexchange, capacity 1.25212.3179.927.321.30025.5
GPU0-1, NVLinkglobal298.8190.4103.2044.746.911.7
GPU2-3, PCIeexchange456.2240.6214.4169.90080.1
GPU2-3, PCIeexchange, capacity 1.25332.4175.5162.7100.40081.2
GPU2-3, PCIeglobal780.4188.0710.40309.5312.488.5
GPU0-2, cross-socketexchange454.9241.4210.3172.60073.7
GPU0-2, cross-socketglobal795.5188.0620.90279.9273.867.2

What the traces say:

  • The exchange beats the global dispatch wherever the link is slow: 1.46x on four GPUs, 1.71x on the PCIe pair and 1.75x across the sockets. On the NVLink pair the two tie, because the global dispatch’s expert all-gather and gradient reduce-scatter cost 92 ms there, against 622 ms on the PCIe pair.
  • Communication is mostly exposed. Four-way exchange spends 272 ms computing and 352 ms waiting on collectives that no compute overlaps, most of it the all-to-all. Capacity 1.25 bounds the buckets and drops the later rounds, which takes the step to 550.6 ms.
  • Placement. Under data x expert the gradient all-reduce over the data axis moves more bytes than the token exchange, so the expert axis belongs across the sockets and the data axis on the pairs: 702.9 ms against 753.8. Under expert x fsdp the two placements tie (788.1 against 800.3), since fsdp’s all-gather and reduce-scatter trade places with the all-to-all.

Changes, each measured before and after in one hold:

  • Expert parameters enter the dispatch’s shard_map in their stored shards and are gathered inside it, so their gradient is reduce-scattered rather than all-reduced whole. fsdp 4 goes from 1157.1 to 904.0 ms, where a 614 ms all-reduce becomes a 375 ms reduce-scatter; expert 2 x fsdp 2 goes from 856.0 to 788.1 ms.
  • The exchange’s first round runs outside the checkpointed scan that holds the later rounds, so the backward keeps its intermediates instead of recomputing them: expert 4 goes from 705.9 to 634.0 ms, and compute from 307.0 to 272.4.
  • The exchange gathers each bucket’s rows through the sort’s index and scatters what returns straight to its slots, two row copies fewer a round. On the NVLink pair, dropless goes from 300.5 to 295.8 ms and capacity 1.25 from 218.5 to 212.3, with peak memory from 14.1 to 13.7 GiB and 12.6 to 12.0.

One bf16 ExpertMLP layer under MeshSpec(fsdp=2) on the NVLink pair (8192 tokens, 32 experts, top 4), forward plus backward: the Pallas kernels inside the dispatch’s map take 26.2 ms (Pallas under main’s older row map took 25.1), and jax.lax.ragged_dot inside the map takes 271.9 ms with 6.5 GiB of temporaries, since XLA runs it as a product over every expert. The same layer through the global path outside any map, where main’s selector had sent fsdp-only meshes to XLA, ran out of memory on the 24 GiB cards.

A model’s remat is where its step starts, and the trainer moves it up one rung whenever the compiled step does not fit its devices’ memory (dew.training.trainer.step_fits: the step’s temporaries and the outputs that do not reuse the donated state, against each device’s bytes_limit less what the resident state and batch already use; each process reads its own devices and the pool takes the tightest): a decoder from none to 'minimal' (MaxText’s name: every projection output kept) to 'full', a diffusion backbone from False to 'dots' (matmul outputs and the attention forward kept) to 'full'. Each rung is slower and smaller, so the first that fits is the fastest that runs. The rung a step compiled under is the remat of the run’s StepCompiled record and of tools/benchmark_step.py’s rows. Forward plus backward plus AdamW, bf16 compute, 10 timed steps, tools/benchmark_kernels.py step --remat:

devicemodel, batch x tokensnoneminimal / dotsfull
L4359.8M decoder, 4 x 1024334.7 ms, 9.93 GiB356.4 ms, 8.00 GiB401.3 ms, 6.29 GiB
L4359.8M decoder, 8 x 1024676.3 ms, 13.39 GiB719.6 ms, 10.00 GiB815.9 ms, 6.57 GiB
L4359.8M decoder, 16 x 1024out of memoryout of memory1671.1 ms, 7.22 GiB
L4321.8M MoE decoder, 4 x 1024213.2 ms, 8.15 GiB235.4 ms, 6.54 GiB251.7 ms, 6.13 GiB
L4321.8M MoE decoder, 8 x 1024396.0 ms, 10.33 GiB420.1 ms, 7.82 GiB454.5 ms, 6.69 GiB
L4DiT-L/2, 16 x 64x64out of memoryout of memory1377.8 ms, 8.81 GiB
L4DiT-L/2, 32 x 64x64out of memoryout of memory2671.8 ms, 10.16 GiB
RTX 3090359.8M decoder, 4 x 1024225.2 ms, 9.70 GiB237.9 ms, 7.67 GiB270.0 ms, 6.06 GiB
RTX 3090359.8M decoder, 8 x 1024420.9 ms, 13.16 GiB438.3 ms, 9.77 GiB505.1 ms, 6.33 GiB
RTX 3090321.8M MoE decoder, 4 x 1024126.5 ms, 7.82 GiB133.6 ms, 6.26 GiB150.2 ms, 6.02 GiB

'minimal' costs 3-11% over no recomputation and 'full' 17-24%, so a model that fits runs without either. The L4 rows are jax 0.11.2 on Colab (2026-09-23), the RTX 3090 rows one GPU of the box.