Skip to content

Qwen3.5-397B RL trainer on 128 v7x: pad-first sequence packing, fused sharded log-prob head VJP, and the tuned trainer recipe - #2913

Open
parambole wants to merge 4 commits into
atwigg/mlperffrom
parambole/rl-trainer-4600
Open

parambole wants to merge 4 commits into
atwigg/mlperffrom
parambole/rl-trainer-4600

Conversation

@parambole

@parambole parambole commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Companion to AI-Hypercomputer/maxtext#5758. Stacked on atwigg/mlperf (affb1c643, after #2878).

Two opt-in speed options for the Qwen3.5-397B-A17B RL trainer, plus the recipe that turns on the fastest trainer cell measured without the follow-up key moe_wo_gmm_fused_dswiglu (see Not in this PR), with every numerics gate passing. On the RL trainer bench (AI-Hypercomputer/maxtext#5701, 128 TPU v7x), this recipe on both PR heads (#5758 20c877aa1 and this PR's bee63532f), with no environment switches, takes 82.18 s per step instead of 106.30 s: 2,774 -> 3,589 tok/s/chip (two runs, 82.200 and 82.158 s; row 4 of Results). The six-gate numerics A/B passes for an overlay build of the same cell (82.67 s = 3,567 tok/s/chip, row 3) and for #5758's pre-review code (row 2); it has not yet run on the exact head build. The 106.30 s cell lacked the already-merged staged step boundary (AI-Hypercomputer/maxtext#5718); per regular micro-batch, which the boundary does not change, the time goes from 10.77 to 8.95 s (row 4). The 82.67 s cell ran the MaxText changes as an overlay build (environment switches) on the bench image's MaxText, without the merged AI-Hypercomputer/maxtext#5568, with one attention change that follows in a separate PR, and with moe_route=device, which the recipe leaves at its default (see Results). Both options are off by default in the library: with them unset, the packer and the sharded log-prob head run the same program as before. The recipe commit turns both on in mlperf_gbs1024_submission.sh, whose TRAINER_TUNED_FLAGS defaults to true.

What changes

Blue: changed by this PR, labelled with its option. Gray: unchanged.

flowchart TB
  classDef pr fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#0b1f4d
  classDef same fill:#f3f4f6,stroke:#9ca3af,color:#6b7280

  REC["launch recipe<br/>TRAINER_TUNED_FLAGS=true"]:::pr
  subgraph STEP["trainer step"]
    direction LR
    PACK["sequence packer<br/>pack_pad_position=start"]:::pr --> MT["MaxText model<br/>keys set by the recipe"]:::same --> HEAD["sharded log-prob head<br/>TUNIX_LOGPS_SHARDED_HEAD=fused"]:::pr --> LOSS["GRPO loss"]:::same
  end
  REC -. sets .-> STEP
  style STEP fill:none,stroke:#9ca3af
Loading

Changes

option what it does gain numerics
pack_pad_position=start puts each packed row's padding first (in the orchestrator's packer; pack_sequences in tunix/rl/utils.py still pads at the end), aligned to 4096 tokens, so every load-balanced context rank skips the padding's key tiles in Splash Attention (tokamax 0.0.16.dev20261008 or later; an older tokamax masks segment ids element by element and gains nothing) -1.19 s/step (stack 3, cross-pod); -1.242 s/step same pod (stack 9*) PASS 6/6 (nxs3-pf)
TUNIX_LOGPS_SHARDED_HEAD=fused fused custom-VJP scoring in the sharded log-prob head: one pass per chunk, and for GRPO the logit cotangent without a scatter. Temporaries of the head alone at chunk 4096 (deviceless TPU compile, 16,384 tokens per device): 11.87 -> 4.35 GiB -0.545 s/step same pod (stack 9*) PASS 6/6 (nxc9pfxhv, nxc9pfxall)
TRAINER_TUNED_FLAGS=true (recipe) the measured cell: sixteen MaxText keys, the fused head, log-prob chunks of 4096 and pad first the whole cell, see Results PASS 6/6 (nxc9pfxhv)

* Stack 9 is the recipe's MaxText side on the overlay build, without pad first and the fused head, plus moe_wo_gmm_fused_dswiglu, which is in neither PR because it computed a wrong gradient next to the fused SiLU, so these two arms are speed measurements only. Stack 3 is an earlier bench stack, the pad-first control. On the recipe stack, across pods, the fused head measured 83.316 -> 82.671 s per step.

Results

RL trainer bench, 128 TPU v7x. Seconds per optimizer step, mean of steps 1-4.

configuration s per step tok/s/chip regular micro-batch numerics
before this work (o275x-hd) 106.30 2,774 10.77 s reference
#5758's code, recipe minus three changes (o275x-prc-l3-prof and its repeat o275x-prc-l3-r, one pod) 85.295 / 85.251 3,458 9.30 s PASS 6/6 (nxprc)
full recipe, overlay build (o275x-c9pfxhv-l3, two pods) 82.671 / 82.670 3,567 9.01 s PASS 6/6 (nxc9pfxhv)
full recipe on both PR heads, no overlay (o275x-prf-l3-prof and its repeat o275x-prf-l3-r, one pod) 82.200 / 82.158 3,588 / 3,590 8.95 s not yet run on this build

How to turn it on

# The measured cell, the default of mlperf_gbs1024_submission.sh:
TRAINER_TUNED_FLAGS=true          # false renders the trainer as before

# Each option on its own:
PACK_PAD_POSITION=start           # or --pack_pad_position=start; default end
TUNIX_LOGPS_SHARDED_HEAD=fused    # local or 1: the sharded head without fused scoring
COMPUTE_LOGPS_CHUNK_SIZE=4096     # the recipe's value with the sharded head; 1024 without it

Needs a MaxText with #5758. The recipe passes nine MaxText keys that this tree's pin (e526ea02) does not define: moe_routing_maps and moe_gmm_v2_dlhs_transpose_rhs from AI-Hypercomputer/maxtext#5698, and seven of #5758's nine keys (its recommended set). MaxText refuses an undefined key when the trainer starts, so requirements/maxtext_requirements.txt must move to a MaxText with #5758 before an image built from this branch runs TRAINER_TUNED_FLAGS=true. TRAINER_TUNED_FLAGS=false and the other 397B v7x recipes pass none of the nine.

Testing

  • Pad first: tests/rl/packing_test.py, tests/experimental/orchestrator/batch_assembly_test.py, tests/experimental/orchestrator/grpo_recipe_wiring_test.py, tests/experimental/examples/deepswe_dist/mlperf_submission_launcher_test.py.
  • Fused head: tests/rl/sharded_logps_head_test.py (ShardedLogpsHeadTest, FusedScoringTest), shown to fail on 15 deliberately wrong copies. Its sharded cases run in an 8-device child process: 40 tests OK on base, 62 on this branch.
  • Recipe: mlperf_submission_launcher_test.py 41 of 41 pass (38 of 38 on affb1c643); 15 deliberately wrong copies of the recipes each fail at least one test.
  • Against the measured arm: the rendered default launch matches what o275x-c9pfxhv-l3 ran on 24 of 25 MaxText settings: 17 of the 18 keys that arm passed (the 18th is moe_route, left out on purpose) and the seven #5758 keys, which that arm set through the overlay build's environment switches. The chunk size 4096, the fused head (in that arm local plus the overlay's head switch), pad first (in that arm the bench driver's PAD_POSITION=start, the same layout as --pack_pad_position=start, checked field by field) and the SparseCore copy flag also match.
  • Regressions (22 files, CPU, base vs branch): no test that passes on base fails on this branch.
  • TPU numerics: the bench's six-gate A/B passes for the recipe (nxc9pfxhv), #5758's keys on #5758's pre-review code (nxprc), pad first on stack 3 (nxs3-pf) and the recipe plus two levers in neither PR (nxc9pfxall). These gates compare scalars of the first of three steps (the later two are logged, not gated), so they cannot see a wrong gradient direction: they passed the wrong moe_wo_gmm_fused_dswiglu gradient, which was found by reading the source.

Not in this PR (follow-up PRs)

  • Attention's load-balancing permute hoist: on in the measured 3,567 build (alone, against another pod's control, 99.504 -> 99.267 s per step). It follows as a separate MaxText PR with its own key, plus a separate recipe change that turns it on.
  • moe_wo_gmm_fused_dswiglu: not in the recipe.
    • The unfixed version computed a wrong SwiGLU gradient next to the fused SiLU GMM at these tiles.
    • Its fix passes a one-chip TPU kernel check (relative L2 1.658e-3 against a float32 reference, 1.068 for the old reader) and the bench's six-gate numerics A/B (nxc9ypfckhv, PASS 6/6). These gates also passed the unfixed version, so the kernel check is the evidence for the fix.
    • It follows in a separate MaxText PR, together with its fix.
  • moe_route=device: the measured arms set it, but no code in #5698 or #5758 tags a tensor with that name, so it does nothing. The recipe leaves it at its default.
The recipe commit: every setting it changes

TRAINER_TUNED_FLAGS=false and the other 397B v7x recipes render exactly as before.

where setting before after
mlperf_397b_256_v7x.sh (MaxText keys) wi_tile_dlhs_mlp_dim 1024 2048
decoder_layer_input offload device
moe_routing_maps (#5698) remat (default) device
moe_gmm_v2_dlhs_transpose_rhs (#5698) false (default) true
moe_routing_index_math (#5758) sort (default) count
moe_unsplit_prefused_wi (#5758) false (default) true
moe_roe_packed_routing_gather (#5758) false (default) true
moe_router_grad_from_wo_input (#5758) false (default) true
moe_wi_gmm_fused_swiglu (#5758) false (default) true
gdn_conv1d_input_in_qkv_dtype (#5758) false (default) true
gdn_conv1d_dense_token_stack (#5758) false (default) true
wi_tile_{fwd,drhs}_batch_seq, wo_tile_{fwd,dlhs,drhs}_batch_seq 256 512 (wi_tile_dlhs_batch_seq stays 256 next to its 2048 K tile)
sa_use_base2_exp false (the block overrode the recipe) true (the block no longer overrides it)
mlperf_gbs1024_submission.sh COMPUTE_LOGPS_CHUNK_SIZE 1024 4096; 1024 when TUNIX_LOGPS_SHARDED_HEAD turns the head off
TUNIX_LOGPS_SHARDED_HEAD local fused (TRAINER_TUNED_FLAGS=false keeps local)
PACK_PAD_POSITION unset (end) start

The recipe's comment lists the measurement of every key.

Why padding first is faster

Each packed row of 65,536 tokens is split over 4 context ranks with load balancing (dual chunk swap): 8 chunks, and rank r holds chunks r and 7-r, so causal-attention work is even across ranks.

  • Padding at the end (before): the trailing padding sits in the last chunk, which is rank 0's. Real queries never attend to it (it comes after them), so it saves work only for its own queries, on rank 0. Ranks 1-3 still do the full causal work and set the pace.
  • Padding first (pack_pad_position=start): every real query would attend to the padding keys before it, but they belong to another segment, so Splash Attention (tokamax 0.0.16.dev20261008 or later) skips those tiles. By tile count, ranks 1-3, which set the pace, drop from 136 to 120 forward tiles; rank 0 rises from 80 to 125, so the busiest rank does about 8% fewer (136 -> 125).

The padding moves by a multiple of pack_pad_align (default 4096).

How the fused log-prob head works

TUNIX_LOGPS_SHARDED_HEAD=fused runs the same sharded head as local, with fused scoring of each chunk of hidden states:

  • Forward: each device scores its own tokens against the full vocabulary, with the head table all-gathered once per call. One pass over each chunk's logits takes the target logit by a one-hot select-sum, and the max and the exp-sum (entropy from the same exps), with no take_along_axis gather; each token's log-prob is target - (log exp-sum + max), with no combine across devices.
  • Backward, when only the log-probs are differentiated (GRPO): one elementwise pass over the recomputed logits writes the logit cotangent.
  • Temporaries of the head alone at chunk 4096 (deviceless TPU compile, 16,384 tokens per device): 11.87 -> 4.35 GiB.

local and 1 keep the previous head unchanged.

Measurement, logs and profiles
  • Bench: Add an RL trainer bench under experimental/rl_trainer_bench AI-Hypercomputer/maxtext#5701's RL trainer bench, Qwen3.5-397B-A17B, 128 TPU v7x. Mesh fsdp 32 x context 4 x expert 2; 9 micro-batches per step; 294,912 tokens per chip per step.
  • Dropping: ragged_buffer_factor 2.75, with 0 dropped tokens in every arm.
  • Timing: s per step is the mean of steps 1-4; tok/s/chip = 294,912 / s per step. "Regular micro-batch" is the median of micro-batches 3-9.
  • The intermediate arm: + gdn_conv1d_dense_token_stack + pad first, without the fused head (o275x-c9pfxctl-l3): 83.316 s per step, regular micro-batch 9.085 s, 3,540 tok/s/chip; numerics not run alone, but its supersets nxc9pfxhv (the recipe) and nxc9pfxall pass 6/6.
  • Same-pod gains (both on stack 9, speed only): pad first 83.2855 -> 82.043 s per step (o275x-s9p-l3-prof vs o275x-s9pf-l3-prof), fused head 83.253 -> 82.708 s per step (o275x-c9ctl-l3 vs o275x-c9hv-l3).
  • Logs: gs://parambole-397b-replay-nap/win-tpu/sched/<booking>/<job>/<arm>.log, for example:
    • full recipe: r-5f2bf/parambole-bvx2-5f2bf/o275x-c9pfxhv-l3.log, replicate r-5f2bf/parambole-bvx4-5f2bf/o275x-c9pfxhv-l3-r.log, third pod r-2d4c2/parambole-bn3s-2d4c2/o275x-c9ypfxhv-l3.log; numerics r-5f2bf/parambole-bvx3-5f2bf/nxc9pfxhv.log
    • #5758's code: r-5f2bf/parambole-bpr-5f2bf/o275x-prc-l3-prof.log and o275x-prc-l3-r.log; numerics r-5f2bf/parambole-bnpr-5f2bf/nxprc.log
    • before: r-4e5a9/parambole-bxa-4e5a9/o275x-hd.log
  • Profiles:
    • #5758's code at stack 8T: o275x-prc-l3-prof.
    • The recipe plus two levers in neither PR (82.787 s per step; the recipe arm itself ran without a profiler): gs://parambole-397b-replay-nap/win-tpu/sched/r-5f2bf/parambole-bvx-5f2bf/o275x-c9pfxall-l3-prof/parambole-bvx-5f2bf-o275x-c9pfxall-l3-prof/tensorboard/plugins/profile/2026_10_15_14_43_36/server-gke-tpu-174635e9-sj65.0.0.xplane.pb.
  • Numerics setup: the bench's gates-off A/B. Log-prob gates off, random advantages, three steps, against two reference runs whose gradient norms differ by 1.9e-5 relative. Six checks: every arm keeps its tokens; loss; mean log-prob difference; last-micro-batch log-prob difference; entropy; gradient norm.
Tests: details of the recipe test and the regression subset

All run in the bench image on CPU (JAX_PLATFORMS=cpu, --network none), comparing per-test outcomes from junit XML.

The recipe test, mlperf_submission_launcher_test.py:

  • The exact-set test expects the 23 keys that change; the switch-off and other-recipe tests check that none of the nine new keys is passed.
  • The default launch has base-2 exponentials on, chunks of 4096, the fused head and --pack_pad_position=start. TRAINER_TUNED_FLAGS=false keeps the local head and end padding, and explicit values win. TUNIX_LOGPS_SHARDED_HEAD=0|false|OFF|No gives chunks of 1024.
  • The 15 deliberately wrong copies of the recipes: a key dropped from the block, given the wrong value, or one 512 tile left out; wi_tile_dlhs_batch_seq raised to 512; sa_use_base2_exp=false kept; moe_wo_gmm_fused_dswiglu or moe_route=device added; a new key passed outside the block at its default; the local head under the switch; the chunk fallback removed or made case-sensitive; chunks of 1024 by default; pad first outside the switch; pack_pad_align passed; both recipes as they were before.

Regression subset (22 files: the 20 the pad-first review used, plus tests/rl/common_test.py and tests/rl/sharded_logps_head_test.py), base affb1c643 against this branch:

  • No test that passes on base fails on this branch. The differences are the new tests of the three commits, which all pass, and one timing assertion in rl_program_stage_before_update_test.py that failed on base and passed here, under CPU load.
  • Failures present on base and this branch alike (pre-existing, not touched here): 5 in grpo_recipe_wiring_test.py (PaddedBatchAssemblerTest); 6 in cpu_distributed_rl_integration_test.py; 2 in yaml_generator_test.py; 1 each in cluster_reaper_test.py, run_trainer_node_test.py, deepswe_mllog_utils_test.py and rl_program_pipelined_boundary_test.py.
  • router_replay_end_to_end_test.py does not finish without network, on either tree.

256-chip trainer meshes (8c75ae30d)

With TRAINER_TPU_SLICE=tpu7x:4x8x8 and TRAINER_MESH_FSDP/EXPERT/CONTEXT set to 32/2/8 or 64/1/8, TRAINER_TUNED_FLAGS=true adds the settings of Rohan Bierneni's 256-chip trainer runs:

trainer mesh (fsdp x expert x context) MaxText keys added
32x2x8 query_proj, key_proj, value_proj, out_proj and qkv_proj = device
64x1x8 the same five keys, plus one MoE token chunk (NUM_MOE_TOKEN_CHUNKS defaults to 1; an explicit value wins), and so moe_chunk_pipeline=false and moe_roe_packed_routing_gather=false
  • One MoE token chunk now turns moe_chunk_pipeline off at any mesh, and the tuned moe_roe_packed_routing_gather with it. MaxText at #5758 refuses the chunk pipeline with one chunk, and the packed routing gather without the pipeline, so 64x1x8 as listed (one chunk, pipeline off) on top of the tuned keys would not start.
  • qkv_proj also names the GDN layers' fused qkvz projection in MaxText, so qkv_proj=device keeps those outputs too.
  • Every existing render is byte-identical to bee63532f. That covers 10 launcher renders: the submission launcher at its default, at the bench cell, with tuned flags off, with buffer factor -1 and with the capacity-factor trainer, plus the 256/512/1024-device and GBS-512 recipes. Only the two new meshes and a one-chunk setting change.
  • Checked:
    • The launcher test file passes: 46 of 46 tests, 5 of them new.
    • MaxText's config check at #5758 (20c877aa1) was run on the trainer argument list of a bench run, with each rendered cell's keys and mesh. It accepts 32x2x4, 32x2x8, 64x1x8, and 64x1x8 with an explicit 2 chunks.
    • It rejects the two broken variants: the 64x1x8 keys as listed on top of the tuned keys (moe_roe_packed_routing_gather=True requires: moe_chunk_pipeline=True), and one chunk with the pipeline on (moe_chunk_pipeline=True requires num_moe_token_chunks > 1).
  • Not measured here: the 256-chip step times (Rohan's runs), and these meshes run end to end from this recipe on TPU.

…ition)

The packer writes a row's segments from position 0 and leaves its padding
at the end. Under load-balanced context parallelism (MaxText's
DUAL_CHUNK_SWAP: context rank r holds sequence chunks r and 2 * cp - 1 - r)
the last chunk of a row is rank 0's, so trailing padding makes only rank 0
lighter, while ranks 1..cp-1 still attend every real query to every
earlier position. tokamax's Splash (0.0.16.dev20261008 or later) skips the
tiles whose query and key segment-id ranges do not overlap, so once the
padding comes first every real query skips the padding's key tiles; an
older tokamax masks segment ids element by element, and there "start"
gains nothing. On the RL trainer benchmark's 60,948-token rows at context
4, counted with that rule and the benchmark's blocks (forward 1024 x 4096,
dkv 2048 x 2048), the forward tiles per rank go from 80/136/136/136 to
125/120/120/120 and the dkv tiles from 74/132/132/132 to 122/116/116/116:
the busiest rank, which sets the step time, does about 8% fewer.

pad_position="start" (pack_chunk, pack_bin, pack_core) lays a row out
exactly as "end" does, then writes it further right by its trailing
padding, counted from its last token and rounded down to a multiple of
pad_align (default 4096, the largest Splash block of the Qwen3.5-397B-A17B
recipe), so every segment start keeps its place relative to the kernel's
tiles and its segment alignment. Every per-token array moves with its
token: ids, masks, advantages, segment ids, in-segment positions,
per-token values and routed experts, so a model whose attention,
convolutions and recurrences respect segment ids computes the same values.
A row with no tokens, or with less trailing padding than pad_align, does
not move, so the gain depends on how much padding the rows carry. "end"
stays the default and packs byte for byte as before; pad_align is not read
with "end".

The option is configuration, threaded through the orchestrator the way
segment_align_multiple is: BatchConfig.pack_pad_position / pack_pad_align
-> create_batch_assembler -> SequencePackedBatchAssembler -> pack_chunk,
so the program and its trainer warm-up payload pack alike.
run_deepswe_dist.py takes --pack_pad_position / --pack_pad_align (main()
builds its BatchConfig with _batch_config, so a test can check that the
flags reach it), and k8s_launcher.sh passes them from PACK_PAD_POSITION /
PACK_PAD_ALIGN only when they are set. Only the orchestrator is wired:
pack_sequences in tunix/rl/utils.py, which rl_learner and
agentic_rl_learner use, still puts the padding at the end.

pack_chunk, pack_bin and pack_core raise on an unknown position and, with
"start", on a pad_align that is not a positive multiple of
segment_align_multiple; they leave a row that cannot move as it is. The
orchestrator also refuses settings under which the option would do
nothing, so a run cannot measure the default by accident: "start" with the
padded (unpacked) assembler, a pad_align of at least the row length, or a
non-default pad_align with "end".

The overlay that the measurements below ran with read the switch from the
environment inside packing.py (TUNIX_PACK_PAD_POSITION /
TUNIX_PACK_PAD_ALIGN). Nothing reads those two variables any more: set
PACK_PAD_POSITION / PACK_PAD_ALIGN, or the flags. Nothing in packing.py
reads the environment and every other packer option is an argument, so
this one is too.

Measured on Qwen3.5-397B-A17B, 128 TPU v7x, on the RL trainer benchmark
(AI-Hypercomputer/maxtext#5701 driver, mesh fsdp 32 x context 4 x expert
2, ragged_buffer_factor 2.75, 9 micro-batches per step), on stack 3. The
benchmark builds its own rows and its driver's PAD_POSITION=start moves
them; the overlay's switch changed only the trainer warm-up payload (same
shapes and dtypes). So the numbers below measure the driver's layout,
which is the layout this packer produces (checked field by field on the
same rows). Every regular benchmark row carried 4,588 tokens of padding
and moved by 4,096; rows packed from real rollouts may carry less padding
and move less, or not at all. Regular micro-batch 10.033 -> 9.876 s and
99.50 -> 98.32 s per step, mean of steps 1-4 (o275x-s3-pf-prof against the
stack 3 control o275x-gdn-ctl, run in another pod). Numerics (gates off,
random advantages, against two reference runs whose gradient norms differ
by 1.9e-5 relative; nxs3-pf): all six gates pass, loss 2.7e-7 relative,
log-prob difference 7.0e-6, entropy 8.0e-8 relative, gradient norm 2.3e-4
relative (pass limit 2e-3). Tile boundaries move, so reductions round
differently.

Tests:
- packing_test.py: "start" rows written out by hand (the shift, its
  rounding, segment alignment, routing and per-token values moving with
  their tokens, the default pad_align, and a shift counted from the last
  token when the budget is not a multiple of the segment alignment); 200
  random packings in which "start" is the "end" row rotated right by its
  rounded trailing padding in every array (segment alignments 1 to 128,
  budgets that are and are not multiples of them, every per-token field,
  items without routing, empty items and bins); every token keeps its id,
  segment id, in-segment position, masks, advantage, per-token values and
  routing, checked against the items; "end" is unchanged by any pad_align,
  also at segment alignments that do not divide 4096; invalid settings
  raise.
- batch_assembly_test.py: the assembler puts the padding first; the
  options reach it from BatchConfig on both packed paths and are refused
  on the padded one; settings that would do nothing raise.
- grpo_recipe_wiring_test.py, mlperf_submission_launcher_test.py: the
  flags parse and reach BatchConfig; the default command line gives the
  previous BatchConfig field for field; rows packed from the parsed flags
  by the assembler put their padding first, at the default and at a given
  pad_align; main() hands the program the BatchConfig from _batch_config;
  the launcher passes the flags only when set, changing only the
  orchestrator's JobSet.
Forty-two deliberately broken copies each fail at least one of these
tests: an array left unshifted, given absolute positions or one token
long; the shift unrounded, off by one, a float, blind to the segment
alignment or rounded up to it; segment ids widened to int64; pad_align
parsed or defaulted as a float; the option dropped, or taken from the
wrong variable, at any hop from the launcher to pack_chunk, including
main() building its own BatchConfig; a check removed, loosened or applied
to "end"; "start" as the default or always passed.
…EAD=fused)

With TUNIX_LOGPS_SHARDED_HEAD=local the sharded head scores each chunk with
selective_log_softmax (and compute_entropy_from_logits), and its backward
differentiates that with jax.vjp. At the 248,320-entry vocabulary of
Qwen3.5-397B-A17B two parts of that cost more than the arithmetic. On TPU,
the target logit's take_along_axis makes XLA write an f32 copy of the
[chunk, vocab] logits for a SparseCore gather, and its transpose is a
scatter-add: an f32 [chunk, vocab] cotangent, a flattened copy of it, a sort
of the indices, a SparseCore scatter and a copy back to bf16, per chunk. And
the logsumexp's max and exp-sum are computed again for the entropy in the
forward, with one more exp pass, and once more in the backward.

TUNIX_LOGPS_SHARDED_HEAD=fused runs the same sharded head with fused scoring:
- The forward makes one pass over each chunk's logits (_head_logps_stats):
  the target logit by a one-hot select-sum, the max and exp-sum once, the
  entropy from the same exponentials, and the max and exp-sum kept for the
  backward (8 bytes per token).
- When only the log-probs have a cotangent, as in GRPO, whose loss stops the
  entropy's gradient, the backward forms the logits' cotangent in one
  elementwise pass over the recomputed logits (_head_logps_cotangent): no
  f32 [chunk, vocab] intermediate, no scatter and no reductions. An
  optimization barrier makes both gradient matmuls read that one cotangent.
- A loss that differentiates the entropy takes the "local" backward.
Unset, 0 and local trace the same program as before.

The option is a value of the switch that turns the head on, because that
switch is the head's only setting: the head has no config field or
argument, and k8s_launcher.sh already passes TUNIX_LOGPS_SHARDED_HEAD to the
trainer as it is set.

Numerics: the log-probs and the logits' cotangent are the f32 operations of
"local" in the same order, but the exp-sum is reduced in another fusion, so
results can differ in their last bits. On CPU (8 devices; a head with a
248,320-entry vocabulary called directly, and the test head through
compute_per_token_logps; 12 cases), "fused" against "local": log-probs equal
or 1 ulp apart, entropy 3 to 7 ulps apart, and gradients apart by at most
2.6e-8 of their largest entry.

Measured on Qwen3.5-397B-A17B, 128 TPU v7x, on the RL trainer benchmark
(AI-Hypercomputer/maxtext#5701 driver, staged step boundary, ragged buffer
factor 2.75, log-prob chunks of 4,096, 9 micro-batches per step), against a
control run in the same pod (o275x-c9hv-l3 against o275x-c9ctl-l3): 83.253
-> 82.708 s per optimizer step, mean of steps 1-4, and 9.084 -> 9.035 s per
regular micro-batch. The trainer logged the head engaged with 16,384 local
tokens per device in 4 steps of 4,096.

Numerics on TPU, measured in the same benchmark together with two MaxText
changes (deferred small all-reduces, and the shared expert applied first;
nxc9pfxall against two reference runs whose gradient norms differ by 1.9e-5
relative): all six gates pass, loss 2.7e-7 relative, log-prob difference
3.1e-7 (the references' own: 3.6e-7), entropy 5.1e-7 relative, gradient
norm 1.6e-4 relative (pass limit 2e-3).

A deviceless TPU compile of the head alone, 16,384 tokens per device:
temporaries 11.87 -> 4.35 GiB at chunks of 4,096 and 4.65 -> 2.75 GiB at
chunks of 1,024, transcendentals 5.09e9 -> 2.03e9 and 1.27e9 -> 0.51e9, and
no gather, sort or scatter left.

The build measured on TPU switched this scoring with its own environment
variable (PARAMBOLE_HEAD_VJP=1) in a copy of tunix/rl/common.py based on an
earlier version of the head (md5 01eaca3f). On CPU, this file with "fused"
lowers to the same StableHLO and gives bit-identical log-probs, entropy and
gradients as that file with the variable set, and with "local" or unset the
same as the previous commit, in all 12 cases (two meshes, f32 and bf16
logits, temperatures 1 and 0.7, padded chunks, the entropy off, returned or
differentiated, linear and GRPO-shaped losses, forward only).

Tests (tests/rl/sharded_logps_head_test.py):
- ShardedLogpsHeadTest: "fused" against "local" through
  compute_per_token_logps with the log-probs alone differentiated (as in
  GRPO), with the entropy differentiated, with chunks that do not divide the
  local sequence and one chunk for all of it, at temperatures 0.7 and 1,
  with non-finite hidden states at masked positions, in bf16, with one
  output, forward only, and against the unsharded head; the program has no
  gather, no scatter and one optimization barrier when only the log-probs
  are differentiated, and the autodiff backward's scatter when the entropy
  is; "fused" parses and near misses raise.
- FusedScoringTest (one device): _target_hit by hand, and _head_logps_stats
  and _head_logps_cotangent against selective_log_softmax,
  compute_entropy_from_logits and jax.vjp on logits with inf, -inf and NaN
  entries and a row of no finite logit, with negative ids and ids outside
  the vocabulary.
Fifteen deliberately broken copies of tunix/rl/common.py each fail at least
one of these tests, and each new test fails on at least one of them: the
cotangent's sign flipped, the backward's temperature division dropped, the
log-prob cotangent read from the first chunk, the max and exp-sum swapped,
the max dropped from the log-probs, the entropy's sign flipped, an entropy
cotangent sent to the one-pass backward, the barrier removed, the wrap of
negative ids removed, the vocabulary bound off by one, the isfinite mask
dropped from the cotangent, and the option ignored, always on, parsed as
"local" or matched loosely.
… join TRAINER_TUNED_FLAGS

TRAINER_TUNED_FLAGS=true now selects the trainer cell that measured fastest on
the RL trainer bench with every numerics gate passing.

In mlperf_397b_256_v7x.sh the block of MaxText keys (part 2) gains sixteen keys
and loses one:

- wi_tile_dlhs_mlp_dim=2048, decoder_layer_input=device, moe_routing_maps=device
  and moe_gmm_v2_dlhs_transpose_rhs=true: the K tile of wi's input-gradient GMM
  matched to the fused gate and up weights, each decoder layer's input kept in
  HBM, the backward reusing the forward's MoE routing maps, and the
  input-gradient GMMs reading the expert weights through the kernel's
  transpose_rhs.
- moe_routing_index_math=count, moe_unsplit_prefused_wi=true,
  moe_roe_packed_routing_gather=true, moe_router_grad_from_wo_input=true and
  moe_wi_gmm_fused_swiglu=true: the MoE keys of AI-Hypercomputer/maxtext#5758 in
  the set its commits recommend (the routing index maps by counting instead of
  argsorts, the fused wi kept whole up to the GEMM, one packed routing
  all-gather per layer, the routing-weight gradient from the down projection's
  input, the SwiGLU in the wi GEMM's epilogue).
- gdn_conv1d_input_in_qkv_dtype=true and gdn_conv1d_dense_token_stack=true: the
  GDN Conv1D backward without its f32 copy of the input, and the forward
  kernel's Conv1D output built from dense per-token views.
- wi_tile_fwd_batch_seq, wi_tile_drhs_batch_seq, wo_tile_fwd_batch_seq,
  wo_tile_dlhs_batch_seq and wo_tile_drhs_batch_seq=512 (from 256): row tiles
  of 512 for the five MoE GMMs with 1024 tiles. wi_tile_dlhs_batch_seq stays 256
  next to its 2048 K tile.
- sa_use_base2_exp=false leaves the block, so the trainer keeps the recipe's
  sa_use_base2_exp=true: base-2 exponentials measured 0.025 s faster per
  micro-batch (o275x-gx-b2e), and their numerics run passes (nxgx-b2e).

In mlperf_gbs1024_submission.sh, which turns TRAINER_TUNED_FLAGS on, the switch
also sets three defaults, each still overridable:
- COMPUTE_LOGPS_CHUNK_SIZE=4096 (was 1024), and 1024 when
  TUNIX_LOGPS_SHARDED_HEAD turns the sharded head off (0, false, off or no, in
  any case): chunks of 4096 were measured with the head only, and without it
  the log-prob program failed to compile on TPU at chunks of 2048 and 4096.
- TUNIX_LOGPS_SHARDED_HEAD=fused (was local). TRAINER_TUNED_FLAGS=false keeps
  local.
- PACK_PAD_POSITION=start: each packed row puts its padding first.

Bench (AI-Hypercomputer/maxtext#5701 driver, Qwen3.5-397B-A17B, 128 TPU v7x,
mesh fsdp 32 x context 4 x expert 2, ragged_buffer_factor 2.75, 9 micro-batches
per step, mean of steps 1 to 4, no token dropped in any arm):
- before these keys, the fused head and pad first: 106.30 s per step
  (o275x-hd), 2,774 tokens/s/chip;
- this cell: 82.671 and 82.670 s per step in two pods (o275x-c9pfxhv-l3),
  3,567 tokens/s/chip. Part of the difference is the staged step boundary
  (AI-Hypercomputer/maxtext#5718), which this recipe already turns on and
  o275x-hd did not run; per regular micro-batch (the median of micro-batches 3
  to 9), which the boundary does not change, about 10.77 -> 9.01 s;
- within it, each against a control in the same pod: padding first 83.285 ->
  82.043 s per step (-1.242 s) and the fused head 83.253 -> 82.708 s per step
  (-0.545 s).
The recipe lists the measurement of each key. The measured build switched the
MaxText changes with environment variables, and it also had one attention
change on that neither MaxText PR carries (attention's load-balancing permute
hoisted; alone 99.504 -> 99.267 s per step). The keys themselves, on the MaxText
change's own code (2d82c5199) without gdn_conv1d_dense_token_stack, the fused
head and pad first, measured 85.295 and 85.251 s per step (o275x-prc-l3-prof,
o275x-prc-l3-r), within 0.23 s of what the environment-switch arms predicted.
Both arms set moe_route=device, which nothing in #5698 or #5758 tags, so
moe_route stays out of the block as before; and the stop of the frozen router's
gradient, an environment switch in the measured build, is unconditional in
#5758, with no key.

Numerics (loss gates off, random advantages, three steps, against two reference
runs whose gradient norms differ by 1.9e-5 relative): all six gates pass for
this cell (nxc9pfxhv) and for the keys on the MaxText change's own code (nxprc).

moe_wo_gmm_fused_dswiglu stays out: next to the fused SiLU GMM at these tiles it
computed a wrong SwiGLU gradient, and its fix is still being verified on TPU.

moe_routing_maps and moe_gmm_v2_dlhs_transpose_rhs exist in
AI-Hypercomputer/maxtext#5698, and the seven other new MaxText keys in the
MaxText change of AI-Hypercomputer/maxtext#5758 (on top of #5698); none of the
nine is defined at this tree's pin (e526ea02), and MaxText refuses a key it does
not define when the trainer starts. TRAINER_TUNED_FLAGS=true, the default of
mlperf_gbs1024_submission.sh, therefore needs requirements/maxtext_requirements.txt
moved to a MaxText with #5758 before an image built from this tree runs it.
TRAINER_TUNED_FLAGS=false and the other 397B v7x recipes pass none of the nine.

Tests (mlperf_submission_launcher_test.py): the exact-set test expects 23 keys,
_MAXTEXT_DEFAULTS has the nine new keys' defaults, and the switch-off and
other-recipe tests check that none of the nine is passed; the default launch
has base-2 exponentials on, chunks of 4096, the fused head and
--pack_pad_position=start; TRAINER_TUNED_FLAGS=false keeps the local head and
the padding at the end; an explicit pad position, head or chunk size wins;
TUNIX_LOGPS_SHARDED_HEAD=0, false, OFF or No gives chunks of 1024; the other
397B v7x recipes pass no pad position. Fifteen deliberately wrong copies of the
recipes each fail at least one of these tests: a key dropped from the block or
given the wrong value, one tile left out, wi_tile_dlhs_batch_seq raised to 512,
sa_use_base2_exp=false kept, moe_wo_gmm_fused_dswiglu or moe_route=device added,
a new key passed outside the block at its default, the local head under the
switch, the chunk fallback removed or made case-sensitive, chunks of 1024 by
default, pad first outside the switch, pad_align passed, and both recipes as
they were before this change.
@parambole parambole changed the title Qwen3.5-397B RL trainer on 128 v7x: pad-first sequence packing and fused sharded log-prob head VJP Qwen3.5-397B RL trainer on 128 v7x: pad-first sequence packing, fused sharded log-prob head VJP, and the tuned trainer recipe Oct 15, 2026
@parambole
parambole marked this pull request as ready for review October 15, 2026 17:42
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This PR introduces performance optimizations for the Qwen3.5-397B RL trainer on TPU v7x infrastructure. By implementing pad-first sequence packing and a fused sharded log-prob head, the trainer achieves significant speed improvements while maintaining numerical stability. A new tuned trainer recipe is provided to easily enable these optimizations.

Highlights

  • Sequence Packing Optimization: Added pack_pad_position=start to align padding at the beginning of packed rows, improving load balancing in Splash Attention.
  • Fused Sharded Log-Prob Head: Introduced TUNIX_LOGPS_SHARDED_HEAD=fused to optimize custom-VJP scoring, reducing memory overhead and improving performance.
  • Tuned Trainer Recipe: Added TRAINER_TUNED_FLAGS=true to enable a suite of performance optimizations for the Qwen3.5-397B RL trainer.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a fused sharded log-prob head (TUNIX_LOGPS_SHARDED_HEAD=fused) to optimize chunk scoring and backward passes, alongside a new "start" padding position option in sequence packing to balance attention workloads under context parallelism. The changes are accompanied by extensive unit tests and recipe updates. The reviewer feedback suggests adding strict type annotations to the shape parameter of _target_hit and a return type annotation to _head_logps_stats in tunix/rl/common.py to comply with the repository's strict typing guidelines.

Comment thread tunix/rl/common.py
# and differs in its last bits.


def _target_hit(ids: jax.Array, shape) -> tuple[jax.Array, jax.Array]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Please add strict type annotations to the shape parameter of _target_hit to adhere to the repository style guide's typing requirements.

Suggested change
def _target_hit(ids: jax.Array, shape) -> tuple[jax.Array, jax.Array]:
def _target_hit(ids: jax.Array, shape: tuple[int, ...]) -> tuple[jax.Array, jax.Array]:
References
  1. Always annotate function signatures, attributes, and data structures with concrete, specific types rather than Any or untyped dict/object. (link)

Comment thread tunix/rl/common.py
return hit, (ids >= 0) & (ids < vocab)


def _head_logps_stats(logits: jax.Array, ids: jax.Array, with_entropy: bool):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Please add a strict return type annotation to _head_logps_stats to adhere to the repository style guide's typing requirements.

Suggested change
def _head_logps_stats(logits: jax.Array, ids: jax.Array, with_entropy: bool):
def _head_logps_stats(logits: jax.Array, ids: jax.Array, with_entropy: bool) -> tuple[jax.Array, jax.Array | None, jax.Array, jax.Array]:
References
  1. Always annotate function signatures, attributes, and data structures with concrete, specific types rather than Any or untyped dict/object. (link)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have we verified the correctness impact of this? I am a little concerned it might introduce convergence regression, but nothing pops out right away. ideally we would do a real run to compare. especially less confident on the bwd pass.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also some findings from my AI agent:

4 subtle edge cases / hazards identified:

  • Next-token shift boundary bleed on completion-only (len(prompt_ids) == 0) items: Shifting segment 1 to offset = shift > 0 causes its first token (t = shift, where completion_mask == 1 when p == 0) to be scored from hidden_states[:, shift - 1] (a leading segment_ids == 0 padding token) instead of 0.0, corrupting log-probs and leaking gradients into padding.
  • Catastrophic MoE token dropping if pack_pad_position="start" is used without moe_exclude_padding_from_routing=true: All shift leading padding tokens (0..shift-1) have identical embeddings, route to the same top_k experts, and occupy the first buffer slots in stable ragged sort—evicting real tokens when expert capacity is finite.
  • Split segment_ids == 0 across start, 64-token alignment gaps, and end in Splash Attention: Because Splash Attention masks via q_ids == kv_ids, trailing pad queries ([shift + used, budget)) attend to leading pad keys ([0, shift)) and inter-segment gap keys; moreover, if tokamax's [min, max] tile-skipping does not ignore 0, any 64-token alignment gap (segment_ids == 0) inside a Q tile lowers q_min to 0 and defeats leading-pad KV tile skipping.
  • Default-on TRAINER_TUNED_FLAGS=true in
    mlperf_gbs1024_submission.sh
    passes 9 MaxText config keys that do not exist at the MaxText commit (60425bfbd) currently pinned in
    requirements/maxtext_requirements.txt
    .

Rohan-Bierneni added a commit to Rohan-Bierneni/tunix that referenced this pull request Oct 15, 2026
…oundary masking, and add 256-chip 64x1x8 recipe
Rohan-Bierneni added a commit to Rohan-Bierneni/tunix that referenced this pull request Oct 15, 2026
…oundary masking, and add 256-chip 64x1x8 recipe
Rohan-Bierneni added a commit to Rohan-Bierneni/tunix that referenced this pull request Oct 15, 2026
…oundary masking, and add 256-chip 64x1x8 recipe
…shes 32x2x8 and 64x1x8

With TRAINER_TPU_SLICE=tpu7x:4x8x8 and TRAINER_MESH_FSDP/EXPERT/CONTEXT 32/2/8 or 64/1/8,
TRAINER_TUNED_FLAGS=true adds query_proj, key_proj, value_proj, out_proj and qkv_proj=device,
the settings of Rohan Bierneni's 256-chip trainer runs. At 64x1x8, NUM_MOE_TOKEN_CHUNKS defaults
to 1; an explicit value wins.

One MoE token chunk now turns moe_chunk_pipeline off, and with it the tuned
moe_roe_packed_routing_gather: MaxText refuses the chunk pipeline with one chunk, and the packed
routing gather without the pipeline. Every existing render is unchanged.
Rohan-Bierneni pushed a commit to Rohan-Bierneni/tunix that referenced this pull request Oct 16, 2026
…shes 32x2x8 and 64x1x8

Cherry-picked from google#2913 (8c75ae3) onto this PR's 256-chip recipe.

With TRAINER_TPU_SLICE=tpu7x:4x8x8 and TRAINER_MESH_FSDP/EXPERT/CONTEXT 32/2/8 or 64/1/8,
TRAINER_TUNED_FLAGS=true adds query_proj, key_proj, value_proj, out_proj and qkv_proj=device.
At 64x1x8 (the default 4x8x8 mesh with tuned flags), NUM_MOE_TOKEN_CHUNKS defaults to 1; an
explicit value wins.

One MoE token chunk turns moe_chunk_pipeline off, and with it the tuned
moe_roe_packed_routing_gather: MaxText refuses the chunk pipeline with one chunk, and the packed
routing gather without the pipeline. This replaces the previous 4x8x8 block, which forced
moe_chunk_pipeline=false at every 256-chip mesh while leaving moe_roe_packed_routing_gather=true
(rejected by MaxText config validation) and only added the projection keys at EP=1.

Measured on the RL trainer bench (9 micro-batches of 64 rows, steps 1-4 average):
32x2x8 60.62 s/step, 64x1x8 55.97 s/step, vs 82.17 s/step for 32x2x4 on 128 chips.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants