From 9b706a398188b78ce88a880ce875119de6df8a51 Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Tue, 13 Oct 2026 18:37:23 +0000 Subject: [PATCH 1/4] Add an RL trainer bench under experimental/rl_trainer_bench 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. --- .../experimental/rl_trainer_bench/README.md | 260 +++++++++ .../experimental/rl_trainer_bench/__init__.py | 14 + .../experimental/rl_trainer_bench/aot_e2e.sh | 46 ++ .../rl_trainer_bench/aot_summarize.py | 61 +++ .../rl_trainer_bench/aot_trainer.py | 125 +++++ .../rl_trainer_bench/check_numerics_cpu.py | 198 +++++++ .../rl_trainer_bench/check_payload_routing.py | 112 ++++ .../rl_trainer_bench/diff_config.py | 43 ++ .../rl_trainer_bench/e2e_driver.py | 504 ++++++++++++++++++ .../hlo_collective_schedule.py | 142 +++++ .../rl_trainer_bench/render_e2e.py | 468 ++++++++++++++++ .../rl_trainer_bench/render_e2e.sh | 61 +++ .../rl_trainer_bench/structdiff_render.py | 95 ++++ .../rl_trainer_bench/tests/__init__.py | 14 + .../rl_trainer_bench/tests/payload_test.py | 218 ++++++++ .../rl_trainer_bench/tests/render_e2e_test.py | 204 +++++++ .../tests/testdata/e2e_dryrun_fixture.yaml | 167 ++++++ 17 files changed, 2732 insertions(+) create mode 100644 src/maxtext/experimental/rl_trainer_bench/README.md create mode 100644 src/maxtext/experimental/rl_trainer_bench/__init__.py create mode 100755 src/maxtext/experimental/rl_trainer_bench/aot_e2e.sh create mode 100644 src/maxtext/experimental/rl_trainer_bench/aot_summarize.py create mode 100644 src/maxtext/experimental/rl_trainer_bench/aot_trainer.py create mode 100644 src/maxtext/experimental/rl_trainer_bench/check_numerics_cpu.py create mode 100644 src/maxtext/experimental/rl_trainer_bench/check_payload_routing.py create mode 100644 src/maxtext/experimental/rl_trainer_bench/diff_config.py create mode 100644 src/maxtext/experimental/rl_trainer_bench/e2e_driver.py create mode 100644 src/maxtext/experimental/rl_trainer_bench/hlo_collective_schedule.py create mode 100644 src/maxtext/experimental/rl_trainer_bench/render_e2e.py create mode 100755 src/maxtext/experimental/rl_trainer_bench/render_e2e.sh create mode 100644 src/maxtext/experimental/rl_trainer_bench/structdiff_render.py create mode 100644 src/maxtext/experimental/rl_trainer_bench/tests/__init__.py create mode 100644 src/maxtext/experimental/rl_trainer_bench/tests/payload_test.py create mode 100644 src/maxtext/experimental/rl_trainer_bench/tests/render_e2e_test.py create mode 100644 src/maxtext/experimental/rl_trainer_bench/tests/testdata/e2e_dryrun_fixture.yaml diff --git a/src/maxtext/experimental/rl_trainer_bench/README.md b/src/maxtext/experimental/rl_trainer_bench/README.md new file mode 100644 index 0000000000..a8d468a70e --- /dev/null +++ b/src/maxtext/experimental/rl_trainer_bench/README.md @@ -0,0 +1,260 @@ + + +# RL trainer bench + +This directory times the RL trainer program of a Tunix GRPO e2e run on its own. The bench runs the exact trainer +program the e2e run compiles (the same MaxText flags, libtpu flags, mesh, GRPO loss and chunked log-prob pass, hence +the same compile key) 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. A +variant (a MaxText flag, a libtpu flag, a slice size) can therefore be A/B tested in one short job on one slice, and +its profile read, without the rest of the RL system. + +## Why a separate harness + +MaxText's standalone engine script (`python3 -m maxtext.experimental.maxtext_engine.train`) runs MaxText's +pre-training cross-entropy on MaxText's synthetic loader. That is a different program from the RL trainer: the GRPO +loss and its vocabulary-chunked `compute_per_token_logps` pass are missing, the batch is laid out differently, and the +optimizer step is driven by MaxText rather than by Tunix's `TrainerWorker`. Timings from it do not transfer to the RL +step, and a variant that compiles there may not compile in the RL program (or the other way round). + +The bench therefore drives the trainer through Tunix's own code paths. `e2e_driver.py` builds the trainer worker with +`run_trainer_node._parse_args` and `_create_maxtext_trainer_factory` from the e2e trainer's argv, builds the +`GRPOAdapter`, `BatchConfig` and batch assembler with `run_deepswe_dist._parse_args` and `_build_algo` from the e2e +orchestrator's argv, applies `configure_worker`'s loss and model-input functions, compiles against Tunix's +TRAINER_WARMUP_COMPILE payload, and then runs `STEPS` optimizer steps of `MICRO_STEPS` `fwd_bwd` calls plus one +`update`, as `DistributedRLEngine.train_step` drives them. + +## Requirements + +The driver imports Tunix (`tunix.experimental.examples.common.run_trainer_node`, +`tunix.experimental.examples.deepswe_dist.run_deepswe_dist`, `trainer_warmup`, `batch_assembly`, `trainer_worker`) +and MaxText, so it must run in an image whose Tunix and MaxText match the e2e run you want to reproduce; by default +the bench keeps the client image of the e2e render. The renderer and the unit tests need only Python 3 with PyYAML +and NumPy. Since most of what the driver touches is Tunix code, it may belong in Tunix in the long run; we leave that +to the team. + +## Files + +| file | what it does | +|---|---| +| `e2e_driver.py` | the bench itself; runs as the Pathways client of the trainer slice | +| `render_e2e.py`, `render_e2e.sh` | turn an e2e DRY_RUN render into the bench JobSet, with variant knobs | +| `structdiff_render.py` | structured diff of two bench renders (YAML leaves, MaxText flags, libtpu flags, argv) | +| `diff_config.py` | diff of the resolved MaxText `Config param` lines of two logs (e2e trainer vs bench) | +| `aot_trainer.py`, `aot_e2e.sh`, `aot_summarize.py` | ahead-of-time compile and memory report on a CPU host | +| `check_payload_routing.py` | CPU check of the realistic payload at the 397B production shapes (needs Tunix) | +| `check_numerics_cpu.py` | CPU check of the numerics knobs with Tunix's GRPO loss (needs Tunix and MaxText) | +| `hlo_collective_schedule.py` | static overlap summary of the collectives in a scheduled HLO dump | +| `tests/` | CPU unit tests of the renderer and the payload builder | + +## How to use it + +### 1. Render your own e2e launch with DRY_RUN + +Start from the launch you want to reproduce, with the environment you would launch it with, and ask the team launcher +for a dry run. For the MLPerf recipe in Tunix this is + +```bash +DRY_RUN=true bash tunix/experimental/examples/recipes/mlperf_gbs1024_submission.sh start > e2e-dryrun.yaml +``` + +The output is a stream of JobSets (orchestrator, trainer, one per rollout engine), possibly interleaved with +launcher log lines. The renderer picks the trainer and the orchestrator JobSet by the `--process_main=` of their +`main` container and ignores everything else. If you already have the two JobSets as separate files, pass them with +`--train-yaml` and `--orch-yaml` instead. The DRY_RUN output describes your environment (namespace, images, +buckets), so keep it local. + +### 2. Render the bench JobSet + +```bash +E2E_RENDER=e2e-dryrun.yaml NAME=tb-base K8S_NAMESPACE= OUTPUT_PREFIX= \ + bash render_e2e.sh # writes rendered/tb-base.yaml and nothing else +``` + +The render keeps every scheduling field of the e2e trainer JobSet (Kueue labels and annotations, node selectors, +tolerations, reservations, priority class, service account, the Pathways resource manager, proxy, worker and sidecar +containers with their images, args and env). It changes the names, sets `failurePolicy.maxRestarts` to 0 (so a +failure does not rerun the compile), and replaces the client command: the same inline env prefix as the e2e trainer +(MAXTEXT_EXTRA_FLAGS, LIBTPU_INIT_ARGS and the rest) runs `e2e_driver.py`, written into the pod by the command itself, +with the e2e trainer argv and orchestrator argv as `E2E_TRAINER_ARGV` and `E2E_ORCH_ARGV`. In the trainer argv, +`--maxtext_ckpt_path` is cleared (random initialization is the one intended difference), `--maxtext_output_directory` +becomes `/`, `--worker_id` becomes `NAME`, and the gRPC and discovery flags are dropped. The +Tunix fail-fast wrapper is dropped as well, since it rewrites a clean exit into a restart. + +The knobs, all environment variables of `render_e2e.sh` (the Python script takes the same as flags): + +`IMAGE` replaces the client image; the Pathways containers keep the e2e render's. `STEPS` (default 3) is the number of +optimizer steps and `MICRO_STEPS` the `fwd_bwd` calls per step. Set `MICRO_STEPS` to what your e2e run does per +optimizer step (the orchestrator logs one train call per micro-batch); the default of 9 (5 with +`TRAINER_SLICE=4x8x8`) is an example calibration from the 397B recipe, where about 1,152 trajectories per step packed +into 9 micro-batches of 64 rows on a 4x4x8 trainer. + +`EXTRA_MAXTEXT_FLAGS="k=v ..."` appends MaxText flags (MaxText keys are last-wins), and +`REPLACE_MAXTEXT_FLAGS="k=v ..."` replaces an existing key in place (it fails unless the key is there exactly once), +which keeps one value per key in the rendered flag set, e.g. `REPLACE_MAXTEXT_FLAGS="context=remat"`. +`SET_TRAINER_FLAGS` and `SET_ORCH_FLAGS` do the same for the trainer and orchestrator argv, e.g. +`train_micro_batch_size=128` in both. `COMPUTE_LOGPS_CHUNK_SIZE` sets the trainer's `--compute_logps_chunk_size`. + +`LIBTPU_EXTRA="--flag=v ..."` adds or overrides libtpu flags and `LIBTPU_DROP="--flag ..."` removes them by name. Both +are written to the three places the e2e render carries libtpu flags: the client's LIBTPU_INIT_ARGS, the +`pathways-proxy` `--xla_*` args (where the programs are compiled) and the `pw-node` workers' LIBTPU_INIT_ARGS (which +keep their worker-only extras). + +`TRAINER_SLICE=` (e.g. `4x8x8`) re-targets the trainer at another slice of the same accelerator and fills +it with FSDP, keeping expert, context and tensor parallelism. It changes the resource manager's `--instance_type`, +the `pw-node` completions and parallelism (hosts = chips / `google.com/tpu` per pod), the slice-topology annotation, +the trainer's `--mesh_fsdp` and the orchestrator's `--trainer_fsdp`. The last one sets the packed micro-batch rows +(fsdp x expert), so the number of micro-steps for the same token mass changes too; set `MICRO_STEPS` and +`FINAL_PAD_FRACTION` to keep the real rows per step comparable. + +`PROFILER_SKIP= PROFILER_STEPS=` sets the trainer's `--skip_first_n_profiler_steps` and `--profiler_steps`, +which Tunix maps to MaxText's `profiler=xplane`, `skip_first_n_steps_for_profiler` and `profiler_steps`. MaxText's +`training_engine/micro_step_profiler.py` counts `fwd_bwd` calls from 0 over the whole run (the compile does not +count), opens the trace before call `n` and closes it after call `n + m - 1`. So with `MICRO_STEPS=9`, +`PROFILER_SKIP=16 PROFILER_STEPS=3` traces calls 16, 17 and 18: the second-to-last and last micro-batch of step 1, +step 1's `update`, and the first micro-batch of step 2. That is the window to use for the last micro-batch and the +optimizer update; `STEPS` must be large enough for the window to close. Profiling options (chips and SparseCores to +trace) are MaxText flags and go in `EXTRA_MAXTEXT_FLAGS`. + +`DRIVER_ENV="K=V ..."` passes knobs to the driver; see the next section. + +To compare two renders, or a render with the e2e trainer JobSet it came from: + +```bash +python3 structdiff_render.py rendered/tb-base.yaml rendered/tb-variant.yaml +``` + +It prints only differences, by YAML leaf and by flag (MaxText flags, libtpu flags, trainer argv, orchestrator argv, +driver env), so it is quick to confirm that a variant differs in the intended fields only. + +### 3. Apply + +```bash +kubectl --context -n apply --dry-run=server -f rendered/tb-base.yaml # optional check +kubectl --context -n apply -f rendered/tb-base.yaml +kubectl --context -n logs -f -c main \ + -l jobset.sigs.k8s.io/jobset-name=tb-base,jobset.sigs.k8s.io/replicatedjob-name=proc 2>&1 | tee -a tb-base.log +kubectl --context -n delete jobset tb-base # when done +``` + +### 4. Read the result + +The client log carries one line per optimizer step: + +``` +completed step: , seconds: , micro_steps: , seconds/micro_step: , fwd_bwd dispatch s: [...] +``` + +The step time runs from the first `fwd_bwd` dispatch until the step's device work has drained (the driver waits on +the engine's throttler), and includes the `update`. Step 0 includes first execution, and any step that overlaps the +profiler window carries the profiler's start and stop cost, so compare steps that are neither. After each step the +driver also logs `STEP_LOSS` (pooled and per-micro-batch loss and denominators, gradient norm) and `STEP_METRICS` +(every trainer metric, per-micro-batch ones as lists), read after the step's timing so that they do not affect it. + +`python3 diff_config.py ` compares the resolved MaxText `Config param` lines of the two +runs and prints the keys that differ; we expect only the run name, the output directory and the checkpoint path to +differ. + +The profile goes under `//tensorboard/` in the usual `plugins/profile//` layout of +`jax.profiler`. + +## The payload + +Tunix's TRAINER_WARMUP_COMPILE payload fixes the shapes, dtypes, fields and per-row segment count of the packed +micro-batch, which is what the compile needs. As a timing payload it is degenerate for a MoE model: at the 397B shapes +it is about 97% padding, every token carries the same id and every routed expert is UNSET, so with router replay every +token falls back to the router with identical inputs and lands on the same experts. + +`PAYLOAD=realistic` (the default) keeps the warm-up payload's structure and rebuilds its contents with +`realistic_payload`. Real tokens get seeded ids in [256, vocab). Each row's tail is padding, a fraction +`PAD_FRACTION` (default 0.07) of the row, written as the packer writes it (segment id 0, pad id, zero masks, UNSET +routing). Real tokens get replayed top-k routing with distinct experts, so MaxText replays them rather than routing +them. With `ROUTING=probe` (the default) each layer draws from a pool whose max expert-shard load ratio (under the +EP=8 cp-as-ep layout) is calibrated by bisection to a per-layer target, with a seeded hot shard per layer. + +The default targets, `PROBE_REGULAR_MEAN_RATIO` in `e2e_driver.py`, were calibrated from a Qwen3.5-397B-A17B GRPO +production run (the per-layer mean over its 65 regular micro-batches). For another model or another phase of +training, pass your own table with `ROUTING_TARGETS="r0,r1,..."` (one ratio per MoE layer, in the same probe order), +or stretch the default with `ROUTING_SCALE` (targets become `1 + scale * (target - 1)`). `ROUTING=uniform` draws +uniform top-k instead. `EP_SIZE` (default 8) sets the expert-shard count the calibration assumes. + +The step's last micro-batch gets the same treatment plus `FINAL_PAD_FRACTION` (default 0.25) of its rows entirely +padded, since the e2e run's last micro-batch of a step is a partially filled tail, and its `fwd_bwd` is called with +`apply_optimizer=True` as in `DistributedRLEngine.train_step`. `PAYLOAD_SEED` seeds the regular payload and seed + 1 +the final one. Before the steps the driver logs one `PAYLOAD_ROUTING` line per payload with the offline per-layer +ratio of the replayed tokens; the runtime ratio, which includes the padding's router-chosen experts, is in the +engine's `MOE_DROP_AUDIT` lines with the MaxText flag `log_moe_drops=true`. Building the two payloads at the 397B +shapes takes a couple of minutes of host time before the first step. `PAYLOAD=warmup` restores the warm-up payload. + +## Numerics mode + +With the e2e orchestrator flags, the GRPO loss gates sequences on the gap between the rollout log-probs and the +trainer's (`--seq_logprob_error_threshold`, `--truncated_importance_sampling_*`). The payload's rollout log-probs are +a synthetic constant, far from what a randomly initialized trainer assigns, so both gates drop every sequence, the +loss denominator is 0 and the gradient is exactly 0 (with `step_skipped` 0). This does not affect timing, but it hides +numerics differences. `LOGP_GATES=off` strips the gate flags from the orchestrator argv, so every sequence stays in +the loss, and `ADVANTAGES=random` gives each segment a seeded N(0, 1) advantage while keeping token ids, segment cuts +and routing byte-identical. With these two, the gradient norm of two variants (e.g. a MoE padding flag on and off) is +a meaningful comparison; the loss value itself is a function of the payload alone when the ratio is 1. + +`check_numerics_cpu.py --e2e-render e2e-dryrun.yaml` reproduces this on CPU with Tunix's GRPO loss and a +one-parameter stand-in for the model. The stand-in replaces `compute_per_token_logps`, so it is tied to that +function's signature in the Tunix version it runs against. + +## Ahead-of-time memory check + +```bash +E2E_RENDER=e2e-dryrun.yaml AOT_IMAGE= [TRAINER_SLICE=4x8x8] bash aot_e2e.sh +python3 aot_summarize.py logs/aot-e2e-