Repository navigation
Add an RL trainer bench under experimental/rl_trainer_bench - #5701
Conversation
A standalone timing harness for the RL trainer program of a Tunix GRPO e2e run: the same MaxText and libtpu flags, mesh, GRPO loss and chunked log-prob pass on one trainer slice, driven through Tunix's TrainerWorker with a synthetic micro-batch whose MoE routing load is calibrated to a production run, and without the orchestrator or rollout engines. render_e2e.py turns the DRY_RUN output of the e2e launcher into the bench JobSet, keeping the trainer JobSet's scheduling fields and Pathways containers and replacing only names, maxRestarts and the client command, with knobs for MaxText flags, libtpu flags, trainer and orchestrator argv, trainer slice and profiler window. Also included: an AOT memory check, a structured render diff, a MaxText config diff, CPU checks of the payload and the numerics knobs, a static HLO collective-overlap summary, and CPU unit tests of the renderer (on a hand-written fixture) and the payload builder.
…e DRY_RUN context switch
There was a problem hiding this comment.
Code Review
This pull request introduces a standalone benchmark harness for the Tunix GRPO RL trainer, featuring a driver, a JobSet renderer, ahead-of-time memory and CPU verification tools, and corresponding unit tests. The code review feedback highlights several critical robustness and portability improvements, including resolving potential runtime errors in 'rng.choice' and '.index()' calls, preventing resource leaks by using 'with' statements for file operations, and explicitly specifying UTF-8 encoding to avoid platform-dependent file decoding issues.
| real_mask = np.zeros((rows, budget), dtype=bool) | ||
| for r in range(rows - n_pad_rows): | ||
| n_seg = max(1, len(np.unique(seg0[r][seg0[r] > 0]))) | ||
| cuts = np.sort(rng.choice(np.arange(64, real_len, 64), size=n_seg - 1, replace=False)) if n_seg > 1 else [] |
There was a problem hiding this comment.
Using rng.choice with replace=False on np.arange(64, real_len, 64) can raise a ValueError if the population size (the number of 64-token chunks) is smaller than n_seg - 1. This can happen with smaller test shapes or custom configurations where real_len is small.
Consider adding a fallback (such as using np.linspace or a smaller step size) when the population size is insufficient.
| cuts = np.sort(rng.choice(np.arange(64, real_len, 64), size=n_seg - 1, replace=False)) if n_seg > 1 else [] | |
| pop = np.arange(64, real_len, 64) | |
| if len(pop) >= n_seg - 1: | |
| cuts = np.sort(rng.choice(pop, size=n_seg - 1, replace=False)) if n_seg > 1 else [] | |
| else: | |
| cuts = np.sort(rng.choice(np.arange(1, real_len), size=n_seg - 1, replace=False)) if n_seg > 1 and real_len > n_seg else np.linspace(0, real_len, n_seg + 1, dtype=int)[1:-1] |
| """The JobSet-like documents of a DRY_RUN output: YAML documents split on `---`; chunks that do not parse as a | ||
| mapping (launcher log lines, kubectl output) are skipped.""" | ||
| docs = [] | ||
| with open(path) as f: |
There was a problem hiding this comment.
| i = cmd.index("(\n") + 2 | ||
| j = cmd.index(RUNTIME_MAIN) | ||
| k = cmd.index("\n)", j) |
There was a problem hiding this comment.
Using .index() directly can raise a generic ValueError: substring not found if the input command format deviates from the expected structure (e.g., missing (\n or \n)). Wrapping this in a try-except block with a more descriptive error message will improve debuggability and maintainability.
try:
i = cmd.index("(\n") + 2
j = cmd.index(RUNTIME_MAIN)
k = cmd.index("\n)", j)
except ValueError as e:
raise ValueError(
f"Failed to parse command. Expected to find '(\\n', '{RUNTIME_MAIN}', and '\\n)' in: {cmd}"
) from e| md.setdefault("labels", {})["scheduling.x-k8s.io/gang-id"] = a.name | ||
| js["spec"].setdefault("failurePolicy", {})["maxRestarts"] = 0 | ||
|
|
||
| with open(a.driver) as f: |
There was a problem hiding this comment.
| extras = [f for f in env["value"].split() if f.split("=", 1)[0] not in base_keys] | ||
| env["value"] = " ".join(libtpu + extras) | ||
|
|
||
| with open(a.out, "w") as f: |
There was a problem hiding this comment.
| for line in open(path): | ||
| if cur is None: | ||
| m = re.match(r"^(ENTRY )?%([\w.\-]+) .*\{\s*$", line) # headers start at column 0, instructions are indented | ||
| if m: | ||
| cur = m.group(2) | ||
| comps[cur] = [] | ||
| continue | ||
| if line.rstrip() == "}": # multi-line backend configs also start lines with "}}" | ||
| cur = None | ||
| continue | ||
| m = re.match(r"^\s*(ROOT )?%([\w.\-]+) = (.*)$", line) | ||
| if m: | ||
| comps[cur].append((m.group(2), m.group(3))) |
There was a problem hiding this comment.
Opening a file without a with statement can lead to resource leaks as the file descriptor is not guaranteed to be closed promptly. Additionally, omitting the encoding parameter can cause UnicodeDecodeError on platforms where the default system encoding is not UTF-8. Use a with statement and specify encoding="utf-8".
| for line in open(path): | |
| if cur is None: | |
| m = re.match(r"^(ENTRY )?%([\w.\-]+) .*\{\s*$", line) # headers start at column 0, instructions are indented | |
| if m: | |
| cur = m.group(2) | |
| comps[cur] = [] | |
| continue | |
| if line.rstrip() == "}": # multi-line backend configs also start lines with "}}" | |
| cur = None | |
| continue | |
| m = re.match(r"^\s*(ROOT )?%([\w.\-]+) = (.*)$", line) | |
| if m: | |
| comps[cur].append((m.group(2), m.group(3))) | |
| with open(path, encoding="utf-8") as f: | |
| for line in f: | |
| if cur is None: | |
| m = re.match(r"^(ENTRY )?%([\w.\-]+) .*\{\s*$", line) # headers start at column 0, instructions are indented | |
| if m: | |
| cur = m.group(2) | |
| comps[cur] = [] | |
| continue | |
| if line.rstrip() == "}": # multi-line backend configs also start lines with "}}" | |
| cur = None | |
| continue | |
| m = re.match(r"^\s*(ROOT )?%([\w.\-]+) = (.*)$", line) | |
| if m: | |
| comps[cur].append((m.group(2), m.group(3))) |
| for line in open(path, errors="replace"): | ||
| m = re.search(r"Config param ([A-Za-z0-9_]+): (.*)$", line.rstrip("\n")) | ||
| if m: | ||
| out[m.group(1)] = m.group(2).strip() |
There was a problem hiding this comment.
Opening a file without a with statement can lead to resource leaks as the file descriptor is not guaranteed to be closed promptly. Additionally, omitting the encoding parameter can cause UnicodeDecodeError on platforms where the default system encoding is not UTF-8. Use a with statement and specify encoding="utf-8".
| for line in open(path, errors="replace"): | |
| m = re.search(r"Config param ([A-Za-z0-9_]+): (.*)$", line.rstrip("\n")) | |
| if m: | |
| out[m.group(1)] = m.group(2).strip() | |
| with open(path, encoding="utf-8", errors="replace") as f: | |
| for line in f: | |
| m = re.search(r"Config param ([A-Za-z0-9_]+): (.*)$", line.rstrip("\n")) | |
| if m: | |
| out[m.group(1)] = m.group(2).strip() |
|
|
||
|
|
||
| def parse(path): | ||
| with open(path) as f: |
There was a problem hiding this comment.
|
|
||
|
|
||
| def summarize(path, cap_gib): | ||
| with open(path, errors="replace") as f: |
There was a problem hiding this comment.
Omitting the encoding parameter when opening files can cause UnicodeDecodeError on platforms where the default system encoding is not UTF-8. It is recommended to explicitly specify encoding="utf-8".
| with open(path, errors="replace") as f: | |
| with open(path, encoding="utf-8", errors="replace") as f: |
The bench is standalone; the e2e names only meant "derived from the e2e
launch's DRY_RUN render". Renames, no behaviour change:
- E2E_RENDER -> LAUNCH_RENDER; --e2e-render -> --launch-render
(render_bench.py and check_numerics_cpu.py)
- render_e2e.{sh,py} -> render_bench.{sh,py}
- e2e_driver.py -> bench_driver.py (also /tmp/bench_driver.py in the pod)
- E2E_TRAINER_ARGV / E2E_ORCH_ARGV -> TRAINER_ARGV / ORCH_ARGV
- aot_e2e.sh -> aot_bench.sh (default log logs/aot-bench-<time>.log)
- tests/render_e2e_test.py -> tests/render_bench_test.py;
tests/testdata/e2e_dryrun_fixture.yaml -> launch_dryrun_fixture.yaml
- heredoc E2E_DRIVER_EOF -> BENCH_DRIVER_EOF; log tag [E2EBench] ->
[TrainerBench]; RenderE2ETest -> RenderBenchTest
README states that the bench is standalone and that LAUNCH_RENDER is the
full launcher's DRY_RUN output. Prose "e2e" meaning the full RL run is kept.
TRAINER_TUNED_FLAGS=true now also appends gdn_cp_matmul_precision=high and cast_grads_after_all_reduce=true to the trainer's MaxText flags, in the same block as the seven keys: - gdn_cp_matmul_precision=high: under gdn_cp_mode=seq, the float32 matmuls that compose the GDN state across the sequence shards run 3 bf16 passes instead of 6. - cast_grads_after_all_reduce=true: a gradient that the forward/backward pass sums by an all-reduce alone, and that is not already float32, leaves it in the parameter's dtype and is cast to float32 after the all-reduce. Both keys have these meanings at this branch's MaxText pin (60425bf) and in the bench image's MaxText (3792fdef8). Bench (AI-Hypercomputer/maxtext#5701, 128 v7x chips), measured with the sharded log-prob head of #2873 on in both arms: 107.59 -> 106.24 s/step in one pod, and 107.71 -> 106.41 in a second pod that trained on real gradients. In that pod the gradient norm moved 1.47e-5 relative (two runs with the keys differ by 1.92e-5), the forward log-prob metric moved 1.1e-6 (3.6e-7), and the loss was identical as printed. Four MoE settings measured alongside them (prefuse_moe_weights=false, moe_accumulate_wi_dlhs=true, moe_accumulate_chunk_wgrad=true, decoder_layer_input=device) moved the gradient norm by -0.80% and the forward metric by 9.7e-4, so they are left out. Tests: the exact-set test expects nine keys, and _MAXTEXT_DEFAULTS has the two keys' MaxText defaults.
…_HEAD) compute_chunked_logps scans the sequence under GSPMD. On the MLPerf Qwen3.5-397B trainer cell (fsdp32 x cp4 x ep2 on 256 devices, 64 x 65,536-token packed micro-batches, compute_logps_chunk_size 1024) the compiled program all-gathers the 2 GB vocab table in every scan step (forward and rematerialized backward) and all-reduces a 2 GB table gradient in every backward step: 129 table all-gathers and 65 table all-reduces per micro-batch. Because the scan runs over a context-sharded sequence, every context rank also computes every chunk, so the vocab matmuls are 4x redundant. With TUNIX_LOGPS_SHARDED_HEAD=local (or 1, true, on, yes), packed rows go through a shard_map'ed head that runs the same per-chunk math: - each head parameter is all-gathered once per pass; - each device scans only the tokens it holds (dynamic slices, no reshape of the sequence); - the table gradient is accumulated per device in float32 and reduce-scattered once; - the one-token shifts (targets left, outputs right) are explicit collective-permutes inside the shard_map, so they do not depend on how XLA partitions a shift of a sequence-sharded array. The head falls back to compute_chunked_logps, with a log line, when there is no mesh in context, when some mesh axis shards neither the batch nor the sequence, or when a head parameter does not divide over the mesh. Sequence axes are used in mesh order, the order in which jax.lax.ppermute numbers shards, so a logical-axis rule that lists them in another order reshards the input instead of shifting tokens across the wrong shards. Unset (the default), nothing changes. k8s_launcher.sh passes TUNIX_LOGPS_SHARDED_HEAD to the trainer process when it is set. TRAINER_EXTRA_ENV cannot carry it from the 397B v7x recipes: a value set there replaces mlperf_397b_256_v7x.sh's default TRAINER_EXTRA_ENV, including the trainer's LIBTPU_INIT_ARGS. Measured on the RL trainer bench (AI-Hypercomputer/maxtext#5701, 128 v7x chips), same pod, only the switch differs: 122.00 -> 107.59 s per optimizer step. Real-gradient A/B on TPU (reference, head, reference): loss bit-identical; grad_norm +2.8e-4 relative to the references' mean, whose own difference is 5.3e-5. Tests: tests/rl/sharded_logps_head_test.py on an 8-device CPU mesh (under pytest it runs in a child process unless the process already has 8 CPU devices): parity with compute_chunked_logps with the head engaged, token layouts, fallbacks, the legacy `with mesh:` fallback, the switch's scope and parsing, sequence axes listed out of mesh order, non-finite hidden states at masked positions, and GRPO's pattern of differentiating the log-probs only (and the entropy only). Launcher: unset, the variable appears nowhere in the render; set, it reaches the trainer JobSet only. The file measured on TPU is #2873 at a29991d. This version adds the mesh-order line, docstrings and tests; for in-order layouts the traced value_and_grad program is unchanged (same jaxpr hash, head on and off).
…AGE_NEXT_MICROBATCH) Between optimizer steps the trainer chips sat idle while the orchestrator closed step k (metrics, logging, checkpoint, weight sync) and then packed and shipped step k+1's first micro-batch: ~7.7 s per step on the 397B v7x trainer. - --pipeline_step_boundary (PIPELINE_STEP_BOUNDARY): pack the next step's first micro-batch while this step's last ones train, send it as soon as the checkpoint, weight sync (its staging, with --async_weight_sync) and commit are done, and run step k's metrics and logging once that call has returned, while the device computes it. The trainer gets the same calls in the same order. - --stage_next_microbatch (STAGE_NEXT_MICROBATCH, requires the above): send the next step's first micro-batch to the trainer host while the device still computes this step's last one (TrainerWorker.stage_fwd_bwd), so its train call carries no data. - --stage_before_update (default on): stage between the step's last micro-batch and its update, which the trainer defers (fwd_bwd(apply_optimizer=True, defer_update=True), then apply_deferred_update()). Under MaxText's retry_when_tokens_dropped, update() waits for the last micro-batch's overflow flag, so a stage after the update's dispatch would find the device idle and latch staging off. Staging needs MaxText's prepare_fwd_bwd_input and wait_for_device (AI-Hypercomputer/maxtext#5718). Without them, or when a stage measures no overlap, staging latches off and the step runs as before. While an update is deferred the worker refuses whatever needs the step closed (another micro-batch, update, get_metrics, a checkpoint, weight sync, actor log-probs, eval). A stage that fails on its way to the trainer falls back: the micro-batch is sent whole in the next step's first call and staging stays off for the rest of the run. compile and restore_checkpoint drop a stale stage. Measured on the RL trainer bench (AI-Hypercomputer/maxtext#5701) through the equivalent bench-driver switch, same pod, fix off vs on: 105.97 -> 98.31 s per optimizer step on 128 v7x chips. This orchestrator code also ran on TPU in a reduced rig: the real StandardRLProgram and TrainerWorker over gRPC, driving the team cell's MaxText trainer on Pathways with synthetic groups instead of rollouts, 3 micro-batches per step. The exact-order checks were clean, and the step-boundary idle time fell from 12.46 s to 0.02-0.03 s. Tests: rl_program_pipelined_boundary_test.py, rl_program_pipelined_boundary_robustness_test.py, rl_program_stage_before_update_test.py, distributed_rl_engine_staging_test.py, distributed_rl_engine_deferred_update_test.py, trainer_worker_staging_test.py, trainer_worker_deferred_update_test.py, trajectory_queue_manager_test.py, and the submission-launcher test (both flags off by default; STAGE_NEXT_MICROBATCH without PIPELINE_STEP_BOUNDARY is refused).
…e staged step boundary mlperf_gbs1024_submission.sh now sets, unless already set: - TUNIX_LOGPS_SHARDED_HEAD=local: the trainer computes log-probs with the sharded head (k8s_launcher.sh passes it to the trainer); 0 turns it off. - PIPELINE_STEP_BOUNDARY=1 and STAGE_NEXT_MICROBATCH=1: the orchestrator sends the next step's first micro-batch ahead of this step's bookkeeping and stages it on the trainer before this step's deferred update. Staging needs AI-Hypercomputer/maxtext#5718. STAGE_NEXT_MICROBATCH follows PIPELINE_STEP_BOUNDARY unless set, so PIPELINE_STEP_BOUNDARY=false alone turns both off. The other 397B v7x recipes keep all three off. The recipe comments name the code instead of pull request numbers. With these and the trainer settings of the first two commits, the trainer of the RL trainer bench (AI-Hypercomputer/maxtext#5701, 128 v7x chips) measured 98.31 s per optimizer step, against 130.80 s for the team recipe. Tests: the head reaches the trainer JobSet only and 0 turns it off; the staged boundary is on by default here and PIPELINE_STEP_BOUNDARY=false turns both flags off; the refusal test now sets PIPELINE_STEP_BOUNDARY=false; the other 397B v7x recipes keep the head and the boundary off.
…ext with #5759 TRAINER_TUNED_FLAGS (on in mlperf_gbs1024_submission.sh) now also sets, each measured on the 128-chip v7x RL trainer bench (AI-Hypercomputer/maxtext#5701 harness): - --xla_tpu_enable_sparse_core_collective_offload_nd_reduce_scatter=true: 97.24 -> 97.15 s/step. - --xla_jf_rematerialization_percent_shared_memory_limit=99: 10.226 -> 10.197 s per micro-batch. - moe_route=device: keeps the ragged sort's routing integers instead of replaying both argsorts in the backward (now tagged at the pin), 10.226 -> 10.184 s per micro-batch; +4.3 GiB compile temp, runs within the trainer's HBM. - wi_tile_dlhs_mlp_dim=2048 wi_tile_dlhs_embed_dim=2048: the wi input-gradient GEMM tiles the full contraction once. - REMAT_DECODER_LAYER_INPUT=device in the submission's tuned cell: the layer inputs stay in HBM, as in every bench run. MaxText pin: atwigg/mlperf plus AI-Hypercomputer/maxtext#5759 (GDN kernel, MoE routing and engine speedups) and #5741 (router and query kernels gathered once). With these flags the bench runs 9.796 s per micro-batch (89.77 s/step, 3,285 tok/s/chip) against 10.616 s (97.15 s/step) for atwigg/mlperf with #5741. Tests: mlperf_submission_launcher_test expects the new tuned keys and libtpu flags.
…ews (gdn_conv1d_dense_token_stack) The Pallas GDN forward kernel (kernels/gdn/wrapper.py fused_conv1d_gdn) convolves a chunk one token at a time and stacks the per-token [1, 1, dim] float32 rows with jnp.stack(axis=1) (kernels/gdn/compute_conv1d.py causal_conv1d). The stacked [1, chunk, 1, dim] value has a second-minor dimension of 1, and the TPU compiler of the October 2026 Pathways build (and of the libtpu 0.0.51 nightlies) lays it out in (8, 128) tiles padded from 1 to 8 sublanes. The SiLU applied next inherits that layout, so its exp and reciprocal run on 8x as many vector registers as the data fills. Older compilers keep the value dense. With gdn_conv1d_dense_token_stack=True each row is viewed as [dim / 1024, 8, 128], which is free because the row already fills dim / 1024 float32 registers; the views are concatenated along the leading axis and the result is reshaped to [1, chunk, 1, dim] (compute_conv1d.stack_conv1d_tokens). The same values in the same order; the compiler keeps the value dense on old and new builds alike. The option reaches the kernel through gdn_decoupled_conv1d's new static argument conv_dense_token_stack. Its primal and forward rule pass it to every forward-kernel call: the local call, both sequence-sharded context-parallel passes, and the forward rule's replay under remat (the backward rule never runs the forward kernel). fused_conv1d_gdn hands it to the per-sequence kernel only: its batched (decode) kernel, which convolves one token per sequence, keeps jnp.stack for those one-token rows, and the tokamax Op wrapper (kernels/gdn/pallas_mosaic_tpu.py) does not take the option. MaxText's GDN layer runs neither, since gdn_decoupled_conv1d calls fused_conv1d_gdn prefill-only. The option is left out of the kernel name and metadata, so outside the kernel body the lowered program is unchanged and profiles keep the kernel name. Numerics: every element lands where jnp.stack puts it, so the kernel's outputs do not change. On CPU, causal_conv1d and the SiLU after it give the same bits with and without the option (the kernel itself does not run on CPU). The TPU numerics A/B of the measured build below passed all six gates (nxs6gr). The validator requires a decoder with Gated DeltaNet layers (qwen3_next or qwen3_5), use_gdn_kernel=True, and a width that each forward-kernel call convolves (2 * key heads * key head dim + value heads * value head dim; one rank's heads under head-sharded GDN context parallelism) that is a multiple of 1024. Like the existing sequence-length check, it counts a context parallelism left at -1 (filled in when the mesh is built) as 1; fused_conv1d_gdn checks the per-call width again when the layer is traced, which catches such a config. Where the width is not a multiple of 1024 the measured build below silently kept jnp.stack; this change rejects the config instead. Default off. Measured on Qwen3.5-397B-A17B, 128 TPU v7x, on the RL trainer benchmark (#5701 driver, mesh fsdp 32 x context 4 x expert 2, 9 micro-batches per step, ragged_buffer_factor 2.75, staged step boundary), with the same change behind an environment switch (MAXTEXT_GDN_CONV_DENSE_STACK=1) on the stack-6 base: 85.731 s per step, regular micro-batch 9.3552 s (o275x-s6gr-l3-prof), against 86.926 s / 9.4910 s for the same-pod arm o275x-s6n2-l3-prof: -1.195 s per step. That arm's only change, a 2D GDN out_proj, is a no-op (XLA already canonicalises the 4D out_proj into the same 2D dot; TensorCore busy time -2 ms per micro-batch). The stack-6 control in another pod measured 86.970 s / 9.494 s (o275x-s6c-l3). Equivalence to the measured build, on CPU: lowered for a TPU7x target (no TPU needed), the GDN layer's forward and backward program with the key on is the measured build's program with its switch on, and with the key off is the base program (StableHLO and every Mosaic kernel body, compared without source locations). 18 configurations: no context parallelism, head-sharded context parallelism 2, sequence-sharded context parallelism 4 (also at the 397B's 16 key and 64 value heads), with and without packing and remat, and gdn_conv1d_input_in_qkv_dtype off and on. In each, the key changes only the forward-kernel bodies. Outside that layer the two differ: the measured build's switch also regrouped the batched kernel's one-token rows (at a decode tile of 1) and reached the tokamax Op wrapper. Tests (gdn_conv1d_dense_token_stack_test.py, new), on CPU: - stack_conv1d_tokens(dense=True) equals jnp.stack(axis=1) bit for bit, and dense=False is jnp.stack itself (the same jaxpr); causal_conv1d and the SiLU after it give the same bits with the option at widths 1024 and 12288, packed or not, with and without conv bias, for full and partial chunks; a token-minor regrouping is caught. - Traced and lowered for a TPU7x target on the CPU host, every forward-kernel call assembles its conv output densely exactly when the option is on: fused_conv1d_gdn; the custom VJP's primal, forward rule and remat replay; both sequence-sharded CP passes, under the forward rule and, forward only, under the primal; head-sharded CP; and model_runner reading the config key. The batched (decode) kernel is the same program with and without the option. The Mosaic lowering keeps the kernel name and changes only the kernel body. The option composes with gdn_conv1d_input_in_qkv_dtype, and the backward rule accepts every positional form. An option the conv ignores, one the forward rule drops and one the primal drops each fail every check they break. - The width check and the validator reject the option where it would do nothing, including one rank's width under head-sharded CP. The validator's copy of model_runner's head-vs-sequence CP rule agrees with model_runner at every GDN CP mode, degrees 1-8 on each context axis, nine head-count pairs and head dims 128 and 256. The kernel name and metadata do not change.
… 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.
…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.
…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.
… TRAINER_STEP_TIMING, fix lint - bench_driver: finish_step() calls update() only when the last fwd_bwd did not already apply the optimizer (google/tunix#2719 reports `updated` in the response metadata); log_step_timing() logs the worker's get_step_timing() in the orchestrator's TRAINER_STEP_TIMING format. Both have CPU tests in tests/step_test.py. - README: describe both. - The DRY_RUN fixture interleaves launcher log lines with YAML documents, so it is now launch_dryrun_fixture.txt (yamllint no longer parses it as YAML). - pre-commit clean (codespell, pylint, pyink): docstrings, explicit encodings, dict literals, f-strings, unused imports; the deliberate function-level imports are documented with a module-level disable.
|
🤖 Hi @ultrons, I'm running an automatic MaxText review of this pull request. You can track my progress in the logs. To request another review later, add the |
Summary of ChangesHello, 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 pull request introduces a standalone benchmarking harness for the RL trainer program used in Tunix GRPO runs. By isolating the trainer on a single slice with a synthetic, calibrated workload, this harness enables efficient A/B testing and profiling of MaxText and libtpu configurations without the overhead of the full RL system. The PR includes comprehensive tooling for rendering benchmark JobSets, validating numerics, and performing static memory checks. Highlights
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 AssistThe 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
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 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
|
There was a problem hiding this comment.
This pull request introduces src/maxtext/experimental/rl_trainer_bench/, a standalone benchmarking and verification harness for the RL trainer component in Tunix GRPO pipelines without requiring live rollout engines or orchestrators. The implementation is comprehensive, well-structured, thoroughly documented in the accompanying README, and accompanied by CPU-runnable unit tests.
🔍 General Feedback
- Architecture & Design: The harness cleanly isolates the RL training step, using realistic synthetic payloads calibrated to production MoE routing distributions while maintaining parity with e2e runtime configurations.
- Robustness: A few edge-case refinements are suggested inline to prevent unexpected failures when handling empty/missing CLI flags, heredoc generation, or timing serialization.
- Previous Review Follow-up: Note that the existing comment on
bench_driver.pyregardingnp.arange(64, real_len, 64)raising aValueErrorfor small sequences/populations whenreplace=Falseremains relevant for smaller test shapes or custom batch configs.
| if not hasattr(worker, "get_step_timing"): | ||
| return | ||
| try: | ||
| timing = json.dumps(worker.get_step_timing(), separators=(",", ":")) |
There was a problem hiding this comment.
| timing = json.dumps(worker.get_step_timing(), separators=(",", ":")) | |
| raw_timing = worker.get_step_timing() | |
| if raw_timing is None: | |
| return | |
| timing = json.dumps(raw_timing, separators=(",", ":")) |
| trainer_flags = set_flag(trainer_flags, "maxtext_output_directory", f"{a.output_prefix}/{a.name}") | ||
| trainer_flags = set_flag(trainer_flags, "worker_id", a.name) | ||
| if a.compute_logps_chunk_size: | ||
| trainer_flags = set_in_place(trainer_flags, [f"compute_logps_chunk_size={int(a.compute_logps_chunk_size)}"]) |
There was a problem hiding this comment.
| trainer_flags = set_in_place(trainer_flags, [f"compute_logps_chunk_size={int(a.compute_logps_chunk_size)}"]) | |
| trainer_flags = upsert_flag(trainer_flags, "compute_logps_chunk_size", str(int(a.compute_logps_chunk_size))) |
| costs = [ast.literal_eval(m) for m in re.findall(r"Cost analysis: (\{.*\})", s)] | ||
| mems = re.findall(r"Memory analysis: CompiledMemoryStats\(([^)]*)\)", s) | ||
| for name, c, m in zip(("fwd_bwd", "accumulate", "update"), costs, mems): | ||
| d = dict(kv.split("=") for kv in m.split(", ")) |
There was a problem hiding this comment.
| d = dict(kv.split("=") for kv in m.split(", ")) | |
| d = dict(kv.split("=", 1) for kv in m.split(", ")) |
| return f"""# PID 1 wrapper: forwards SIGTERM to the benchmark so the grace period shuts it down cleanly. | ||
| echo "Program Start: $(date)" | ||
| cat > /tmp/bench_driver.py <<'BENCH_DRIVER_EOF' | ||
| {driver_src}BENCH_DRIVER_EOF |
There was a problem hiding this comment.
| {driver_src}BENCH_DRIVER_EOF | |
| {driver_src.rstrip('\n')} | |
| BENCH_DRIVER_EOF |
| "compile_engine_loss=grpo", | ||
| f"compile_engine_grpo_config={grpo}", | ||
| # Match the trainer's --compute_logps_chunk_size. | ||
| f"compile_engine_logps_chunk_size={int(os.environ.get('AOT_LOGPS_CHUNK_SIZE', '512'))}", |
There was a problem hiding this comment.
| f"compile_engine_logps_chunk_size={int(os.environ.get('AOT_LOGPS_CHUNK_SIZE', '512'))}", | |
| f"compile_engine_logps_chunk_size={int(os.environ.get('AOT_LOGPS_CHUNK_SIZE') or '512')}", |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
c847919
into
AI-Hypercomputer:atwigg/mlperf
Description
This PR adds a standalone bench for the RL trainer program of a Tunix GRPO e2e run, under
src/maxtext/experimental/rl_trainer_bench/. It runs the exact trainer program the e2e run compiles (same MaxText flags, libtpu flags, mesh, GRPO loss and chunked log-prob pass) on one trainer slice under the same Pathways setup, with a synthetic micro-batch whose MoE routing load is calibrated to a production run. There is no orchestrator, no rollout engines and no weight sync, so a MaxText flag, libtpu flag or slice-size variant can be A/B tested and profiled in one short job on one slice.We built it because
maxtext.experimental.maxtext_engine.trainruns MaxText's pre-training loss on MaxText's synthetic loader, which is a different program from the RL trainer (no GRPO loss, no vocabulary-chunkedcompute_per_token_logps, a different batch layout), so its timings and compile results did not transfer to the RL step.How it works
render_bench.pytakes the DRY_RUN output of the e2e launcher (e.g.DRY_RUN=true mlperf_gbs1024_submission.sh start), picks the trainer and orchestrator JobSets by their--process_main, and writes a bench JobSet that keeps every scheduling field and the Pathways containers of the e2e trainer. Only the names,maxRestartsand the client command change. The client runsbench_driver.py, which builds the trainer worker, GRPO algorithm,BatchConfigand assembler through Tunix's own parsers and factories from the e2e argv, compiles against Tunix's TRAINER_WARMUP_COMPILE payload, and timesSTEPSoptimizer steps ofMICRO_STEPSfwd_bwdcalls. Knobs cover MaxText flags (append or replace in place), libtpu flags (written to client, proxy and workers), trainer and orchestrator argv, the trainer slice (FSDP fills it), the logps chunk and the profiler window (infwd_bwdcalls). Everything environment-specific (namespace, output prefix, image, the e2e render itself) is an input; the README uses placeholders.The driver calls the trainer worker as the orchestrator does. When the worker applies the optimizer inside the last
fwd_bwd(apply_optimizer=True)and reportsupdated(google/tunix#2719), the driver does not callupdate()again; an older worker gets the explicit call. When the worker hasget_step_timing, the driver logs oneTRAINER_STEP_TIMINGline per step in the orchestrator's format, so the per-micro-batch device timeline of a bench run reads the same as an e2e run's.The payload keeps the warm-up payload's shapes and fields and replaces its contents (which at production shapes are mostly padding with a single token id and no replayed routing) with varied token ids, tail padding and replayed top-k routing calibrated per layer to a measured load profile. The default table comes from a Qwen3.5-397B-A17B production run and can be overridden with
ROUTING_TARGETSor stretched withROUTING_SCALE.Also included: an AOT memory check that takes its flags and mesh from the same e2e render, a structured diff of two renders, a diff of resolved MaxText configs, CPU checks of the payload and of the numerics knobs (
LOGP_GATES=off,ADVANTAGES=random, which make gradient norms comparable between variants on random weights), and a static summary of collective overlap in a scheduled HLO dump.Placement
We used
experimental/because it already holds the standalone engine script and the RL experiments, whereas the top-levelbenchmarks/directory holds launch recipes for MaxText's own training loop. Most of what the driver calls is Tunix code (run_trainer_node,run_deepswe_dist,trainer_warmup,TrainerWorker), so a reviewer may prefer it next to the Tunix recipes; we are happy to move it.Caveats
Random weights, a synthetic (calibrated) payload, a static AOT check, and a host-side gap after each step that the e2e trainer does not have; the README has the details.
Tests
python3 -m pytest src/maxtext/experimental/rl_trainer_bench/testsruns 26 CPU-only tests that need PyYAML and NumPy alone (all pass). They exercise the renderer on a hand-written DRY_RUN fixture (JobSet selection, names, libtpu flags in all three places, in-place replacement and its failures, the trainer-slice fields checked with the structured diff, profiler and driver knobs, the AOT env), the payload builder on a small stand-in payload (shapes and dtypes, padding, distinct replayed experts, per-layer load targets, final micro-batch padding, seeded advantages, metric summaries), and the end-of-step helpers (no secondupdate()after a fused one, the explicitupdate()for older workers, theTRAINER_STEP_TIMINGline and its failure path). They live next to the code, so the defaulttestpaths = testsdoes not collect them.pre-commit run --files <changed files>passes (codespell, pylint, pyink, yamllint).We also rendered three configurations from a real DRY_RUN output with this code and got files byte-identical to those of our previous internal version (up to the renamed driver file and argv variable names), and
check_numerics_cpu.py --launch-render <render>passes in a trainer image whose Tunix matches the one it was written for. The driver, including the fused-update and step-timing handling, has run on TPU in its internal form; this generalized version has not been run on a cluster. The generalized AOT path (aot_bench.shtaking its flags and mesh from a real DRY_RUN render, thenaot_summarize.py) ran to completion on a CPU host.Checklist
Before submitting this PR, please make sure (put X in square brackets):
gemini-reviewlabel.I have run end-to-end tests tests and provided workload links above if applicable.Not applicable: a standalone bench underexperimental/that changes no training code; the internal form of the driver ran on TPU (see Tests).