File size: 82,567 Bytes
d909bd8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 | # align architecture v2: proposal
Status: proposal only. No code was moved or edited to write it. Date: 2026-10-14. Scope: `/apps/development/rlhf/align`
at HEAD `1c483cc`, compared with `reference-repos/AgileRL` at `4f07424` (Apache-2.0).
How to read the evidence labels used below.
- **[read]** read in source; the path and line are given and can be re-checked with `sed -n`.
- **[calc]** arithmetic from numbers that are themselves labelled. The arithmetic is shown or reproducible in five lines.
- **[cpu-probe]** a small CPU run done for this document (the probe is described where it is used; nothing was left in the repo).
- **[assumed]** a number taken from a datasheet or a typical value, not measured on this box. Every one is listed in 5.1.
- **[paper]** arXiv id plus section or equation, checked against the text fetched into the hyperresearch vault.
Independent verification pass (2026-10-14, builder A2): the freeze closure, the layer assignment, the shim mechanics, the
source-pinning tests and the Section 5 arithmetic were re-derived from the repo; corrections are folded into the text below and
marked **[A2]**.
Nothing here was measured on a GPU (the P2 chain owns all four). Section 5 is therefore a model with stated assumptions and a
measurement plan, not a benchmark. The CPU test suite was not run for this document either; the three docs that quote a test
count disagree (1089 in DEPENDENCY_POLICY, 1107 in REFERENCE_GAP_ANALYSIS, 1608 in README), so step M0 records the real baseline
first.
---
## 0. Summary of decisions
1. **Adopt from AgileRL: interfaces and contracts, not structure.** Seven ideas are worth taking (1.3): a typed, validated experience
container; algorithm variants as a thin switch over one implementation (CISPO and GSPO are `GRPO` with a fixed `loss_type`);
importance-sampling level and advantage granularity as two independent axes; a text-env protocol separated from a token-level
rollout harness; an async collector; a documented-constructor AST test; a shard-runtime seam for dense versus FSDP2.
2. **Do not adopt its structure or its dependencies.** The 6,398-line `agilerl/algorithms/core/base.py` makes the algorithm own
generation, the optimizer step, vLLM colocation, FSDP and LoRA plumbing. align's `Trainer` / `TrainModule` split is already
better. accelerate, transformers, peft, vLLM, liger-kernel, pydantic, hydra, wandb, dill-pickled checkpoints and the
evolutionary HPO stack are all out (1.4).
3. **The one structural gap in align is the environment.** `GrpoBatchSource` is hard-wired to `{problem, answer}` rows plus an
outcome verifier; the multi-turn Lean path is a subclass that re-implements `bind`. A single `Env` protocol removes the fork.
4. **Layering is currently violated in 5 places by lazy imports** (2.1; **[A2]** one is an artefact of putting `eval` at L5, two are
missed ones). They are cheap to fix and a 40-line AST test keeps them fixed.
5. **Migration is facade-first.** New packages (`algorithms/`, `envs/`, `recipes/`, `optim/`) are added as thin layers over the old
modules. Old modules move only after the P2 chain exits, with identity-preserving `sys.modules` shims (verified in 4.2).
**[A2]** 105 of the 129 modules in `align/` are in the import closure of the running P2 scripts and of the GPU-validation steps
the chain scripts launch; they cannot be touched for now (4.1).
6. **Kernels: only one custom Triton kernel pays a lot, and it is not one of the three the task listed first.**
- Paged-attention decode (`TritonPagedAttention`, an interface with no kernel today): about 2x to 4x on decode steps at batch
6 to 64 [calc, 5.4-C; **[A2]** rows that exceed the KV capacity of the card were removed]. This is the big one, because rollout generation dominates an RL iteration (5.2).
- A fused head + policy-gradient loss pays about 4x on the head (about 13% of an RL *training* step) but the gain comes from
precision path and GEMM count (4 fp32 GEMMs to 2 bf16 GEMMs), **not** from fusing elementwise ops. The elementwise part is
about 4% of today's head time and 10% to 16% of the fused head's time **[A2]**. Do it as a chunked `autograd.Function` first; Triton is optional there (5.4-A, 5.4-B).
- Fusing only the per-token CISPO/GRPO loss arithmetic is worth nothing (64 KB tensors).
---
## 1. AgileRL: what to adopt and what not to
### 1.1 What AgileRL is
An evolutionary-HPO RL library (populations, mutation, tournament selection) that grew an LLM fine-tuning half. Size by directory
[read, `find ... | xargs cat | wc -l`]:
| dir | lines | relevance to align |
|---|---|---|
| `algorithms/` | 27,282 | `core/base.py` 6,398; `grpo.py` 2,471; `ppo_llm.py` 1,415; `reinforce_llm.py` 1,125; `dpo.py` 692; `sft.py` 410; `cispo.py` 26; `gspo.py` 23. The rest is classic RL (DQN, TD3, MADDPG, bandits) |
| `utils/` | 17,179 | `llm_utils.py` 3,035, `algo_utils.py` 2,771, probe envs, mutation utils |
| `modules/` + `networks/` + `wrappers/` + `vector/` + `hpo/` + `population.py` | 15,612 | evolvable networks and populations; none of it applies |
| `llm_envs/` + `training/` + `strategies/` + `builders/` + `models/` | 11,496 | the environment and trainer side; partly relevant |
| `distributed/` | 3,112 | FSDP2 runtime, process helpers |
| `algorithms/core/llm_ops/` | fused ops | `fused_logprobs.py`, `fused_loss.py` (Liger), `fused_lora.py`, `moe_lora.py` |
Roughly 40% of the code is classic-RL or evolution machinery that an LLM post-training library does not need. That is the
first reason not to copy its layout wholesale.
### 1.2 How its LLM algorithms are built
**Class tree** [read]. `EvolvableAlgorithm` (`algorithms/core/base.py:436`, metaclass `RegistryMeta` at `:312` runs
`_registry_init` after every constructor) holds mutation registry, metrics, accelerator and device. `LLMAlgorithm`
(`base.py:2687`, about 3,700 lines of the file) adds: actor/reference/critic adapters, vLLM colocation and weight sync
(`_sync_actor_to_vllm` `:5466`, `_generate_with_vllm_colocate` `:5484`), FSDP shard runtime, fused logprob forward
(`_fused_forward` `:4844`, `_fused_packed_forward` `:4915`), LoRA attach (`_attach_lora_adapters` `:4341`), backward and grad clip
(`_backward_pass` `:5283`), checkpointing (`save_checkpoint` `:3157`).
**Algorithms**. `GRPO(LLMAlgorithm)` (`grpo.py:170`; ctor `:398` with about 50 parameters; `get_action` `:541` generates; `learn`
`:634` runs the epoch/minibatch loop, loss, backward and step). `CISPO(GRPO)` (`cispo.py:15`) and `GSPO(GRPO)` (`gspo.py:15`) are
about 25 lines each: they call `super().__init__(..., loss_type="cispo")`. The objective is chosen in `_setup_objective`
(`grpo.py:1071`): `loss_type in {grpo, gspo, cispo}`, `importance_sampling_level in {token, turn, trajectory}` (gspo forces
`trajectory`), `advantage_granularity` separately, `loss_norm in {micro_batch, accumulation_window}`. `DPO` (`dpo.py:44`) and `SFT`
(`sft.py:42`) follow the same `get_action` / `learn(experiences)` shape with a typed `experiences` argument.
**ILQL and BC_LM are a second generation.** `ilql.py:42` is `class ILQL(nn.Module)` of 2,256 lines, a port of an older
offline-RL codebase with its own `Language_Environment` / `RL_Dataset` types, and `import wandb` at module top (`ilql.py:15`). It
does not use `LLMAlgorithm`. So AgileRL itself carries two incompatible LLM-algorithm architectures. That is the cost of
growing a base class instead of defining a contract first.
**Experience container** [read, `components/llm_rollout_data.py`]. `Trajectory` (frozen pydantic model: `token_ids` `(1,T)`,
`action_masks`/`turn_ids` `(1,T-1)`, `rewards` per turn, optional `sampling_logps`, `pixel_values`) with a `model_validator` that
fails when per-token fields are not `T-1` long. `RolloutGroup` requires exactly `group_size` trajectories. They are packed into
`LLMExperienceBatch`. The label-aligned `T-1` convention is the same one align states in `rl/types.py`.
**Environments** [read]. `protocols.py:602` `TextEnvProtocol` (`reset(...)`, `step(action_text)`, `system_prompt`) and `:624`
`EnvClientProtocol` (Gym five-tuple, `dataset_size`, `tools`, `rubric_components`, `eval_mode()`). `RolloutHarness`
(`llm_envs/harness.py:44`, 857 lines) does tokenisation, chat-template boundaries and the turn loop over a client.
`RolloutCollector` (`collector.py:28`) runs many harnesses; `TaskAssigner` (`task_assigner.py:27`) assigns dataset rows to slots;
`AsyncBatchCollector` (`async_collector.py:24`) overlaps collection with learning; `rubrics.py` composes reward components.
**Training loop and config** [read]. `training/llm/rollout.py:100` `train_llm_rollout(pop, max_turns, env_factory, ...)`. Config is
YAML under `configs/training/llm_finetuning/` (cispo, dpo, grpo, gspo, ppo_llm, reinforce_llm, sft) parsed into pydantic
`*Spec` models (`models/`, `builders/llm.py:46` `LLMBuilder`).
**Checkpointing** [read, `base.py:3157-3250`]. A directory with PEFT adapter dirs plus `attributes.pt`, a pickle written with
`dill` (`base.py:33`, `:1366`, `:1381`). FSDP2 state is gathered to full tensors before saving, so checkpoints are rank-count
independent. Only rank 0 writes.
**Distributed** [read]. `accelerate` for process setup, plus a native FSDP2 layer: `distributed/runtime.py` defines a shard
runtime (`prepare_actor`, `export_model_state`, `export_optimizer_state`) with dense and FSDP2 implementations.
**Tests and docs** [read]. `tests/` mirrors the package (`test_algorithms/test_llms`, `test_llm_ops`, `test_llm_envs`, ...).
`test_algorithms/test_param_docstrings.py` is an AST test: every constructor parameter of an audited class has a `:param:` entry
and no stale entries remain. Sphinx docs under `docs/llm_finetuning/` (batch sizing, fused logprobs, checkpoints, environments).
**Convergent design worth noting.** AgileRL's `fused_logprobs.py` (`fused_linear_logprobs_chunked` `:135`,
`FusedLinearLogProbsFunction` `:199`) is the same chunked head + log-softmax + gradient-checkpoint idea as align's
`kernels/fused_ce.py` and `rl/cispo.py::fp32_head_logprobs`. AgileRL's own GRPO docstring says its Liger GRPO kernel "shows no
speedup over AgileRL's already memory-bounded standard path" (`grpo.py`, `use_liger_loss` parameter doc). That matches the
roofline result in 5.4-A: chunking removes the memory problem, and a fused elementwise kernel adds almost nothing on top.
### 1.3 Ideas to adopt
| # | Idea | AgileRL reference | align today | Concrete action |
|---|---|---|---|---|
| A1 | Typed, validated experience batch with a shape-contract validator | `components/llm_rollout_data.py` `Trajectory`, `RolloutGroup` | `rl/types.py` `RolloutBatch.__post_init__` (`:51`) already validates; `TokenAdvRolloutBatch` lives in `rl/grpo_loss.py:301`; `DpoBatch` in `train/dpo_stage.py:818`; SFT batch is an untyped dict from `collate_sft` | One module `align/batch.py` holding `SFTBatch`, `PreferenceBatch`, `RolloutBatch`, `TokenAdvRolloutBatch`, `PPORolloutBatch`, each with `validate()`. Dataclasses, not pydantic |
| A2 | Variants as a thin switch, not copies | `cispo.py` / `gspo.py` (25 lines each) | `GRPOLossType.{dapo,cispo}` in `rl/grpo_loss.py:41` already does it for the loss; GSPO absent | `algorithms/grpo/` owns one implementation; `algorithms/cispo.py` and `dapo.py` are presets (2.3). Add the trajectory-level ratio as a third option only when needed (GSPO, arXiv:2507.18071 Sec. 4.1 Eq. 5-7: `s_i = exp(mean_t log(pi/pi_old))`) |
| A3 | Two independent axes: importance-sampling level and advantage granularity | `grpo.py:1071` `_setup_objective`, `:1493` `_resolve_advantage_granularity` | `rl/multiturn_source.py` computes per-turn advantages, `expand_advantages` (`grpo_loss.py:234`) broadcasts; ratio level is token only | Name both axes in `GRPOConfig` (`ratio_level`, `advantage_level`) and refuse the mismatched pair that AgileRL only warns about (`grpo.py` warns on `turn` advantage with `trajectory` ratio) |
| A4 | Text-env protocol separate from the token-level harness | `protocols.py:602`, `llm_envs/harness.py:44` | `rl/envs/lean_multiturn.py` fuses both; `LeanMultiTurnEnv.run_group(specs, generate)` already takes the generator as a callable, which is the right seam | `align/envs/` (2.4): `Env` protocol over text, one `Harness` for tokenisation and per-turn masks, `MathEnv`, `LeanEnv`, `CodeEnv` |
| A5 | Async collection overlapping learning | `llm_envs/async_collector.py:24` | rollout is synchronous (`rl/batch_source.py` `_produce_step`); REFERENCE_GAP_ANALYSIS 1.3 already lists this | Defer. Design the `RolloutBackend.submit/collect` protocol (already present in `rollout/protocol.py:95`) so a one-step-stale pipeline is a source-level change, not an engine change |
| A6 | Documented-constructor AST test | `tests/test_algorithms/test_param_docstrings.py` | align documents fields as `#:` comments on dataclasses (`config.py`) but nothing checks it | `tests/contract/test_config_docs.py`: every field of every registered algorithm Config has a doc comment. Stdlib `ast`, runs in the firewall lane |
| A7 | Shard-runtime seam (dense vs FSDP2 vs TP) | `distributed/runtime.py` | `TrainModule.full_model_state_dict`, `optim_state_dict`, `load_*` already are this seam (`train/train_module.py`), plus `model/tp.py` for inference TP | Name the seam `ShardRuntime` in `align/protocols.py` and make `TrainModule` and `TPPagedEngine` implement it; no code change to the Trainer |
Also adopted as a habit rather than code: AgileRL keeps fused ops in their own package with their own tests
(`algorithms/core/llm_ops/`, `tests/test_algorithms/test_llm_ops/`). align already does this in `kernels/`.
### 1.4 Ideas not to adopt
| Idea | AgileRL reference | Why not |
|---|---|---|
| A 6,398-line base class that owns generation, vLLM colocation, FSDP, LoRA and the optimizer step | `algorithms/core/base.py` | The algorithm cannot be tested without those. align's `TrainModule` owns model+loss+optimizer, `Trainer` owns loop and state, the batch source owns rollouts. Each is testable on CPU alone (1,000+ CPU tests prove it) |
| `get_action` generates inside the algorithm | `grpo.py:541` | Couples the algorithm to HF `generate` or colocated vLLM. align's `RolloutBackend` (`rollout/protocol.py:95`) keeps the engine replaceable (native, paged, spool/vLLM) |
| Replacing `lm_head` with a pass-through `HiddenStateHead` (documented as `nn.Identity`) by monkeypatch to get hidden states | `base.py:6314` `_patch_lm_head_to_identity` | Mutates the module tree under a context manager. align passes `hidden_consumer(hidden, lm_head_weight)` into `forward` (`model/qwen3.py:467`, `mistral.py:597`): explicit, and it fails loudly on a model without the kwarg (`rl/cispo.py` `fp32_head_forward`) |
| accelerate, transformers, peft, bitsandbytes, liger-kernel, vLLM as hard or core dependencies | `pyproject.toml` `dependencies`, `cpu-llm` extra | Violates `docs/DEPENDENCY_POLICY.md` and `tests/test_dependency_firewall.py`. align already has the replacements (own LoRA, own 8-bit AdamW, own tokenizer, own paged engine) |
| pydantic spec models and hydra/omegaconf | `models/`, `builders/` | `align/config.py` is dataclass + `tomllib` with struct mode and typed dotted overrides, no dependency. Adopt only the idea that the algorithm's config is its own dataclass |
| dill-pickled `attributes.pt` as the checkpoint container | `base.py:33`, `:1366` | Pickle loads arbitrary code. align reads `resume.pt` with `weights_only=True` and has fsync, partial-staging and completeness checks (`train/trainer.py`) AgileRL lacks |
| Evolutionary HPO: population, mutation, tournament, `EvolvableModule` networks | `hpo/`, `population.py`, `modules/`, `networks/` | Irrelevant to single-run post-training, and it shapes the base class (`MutationRegistry`, `_registry_init` metaclass hook) |
| The older `nn.Module` ILQL/BC_LM generation | `algorithms/ilql.py`, `bc_lm.py` | Shows the failure mode of not fixing the contract first (1.2) |
| Arena/cloud manifests | `models/manifest.py`, `agilerl-arena` | Not a use case here |
| Warn-and-continue on conflicting options | many `warnings.warn` in `_setup_objective` | align's convention is a launch refusal (`assert_a3_conformant`, `assert_p4_cispo_conformant`). Keep refusing |
### 1.5 Where align is already ahead (keep as is)
- The Trainer / TrainModule split with `extra_state()` hooks and a documented resume flow (`docs/ARCHITECTURE.md`, "Trainer /
TrainModule split").
- The control plane: coordinated stop, MAX-reduce decisions, exit codes 80 to 83, `HALT.json`, supervisor
(`align/exit_codes.py`, `utils/supervisor.py`).
- Host safety: preflight, heavy-job flock, D-state gate (`utils/guard.py`, `utils/host_state_gate.py`).
- The dependency firewall as an executable test.
- Launch-refusal gates per stage with a dated rationale in each docstring.
- Bit-invariance as a first-class engine mode (`TorchPagedAttention(mode="row")`).
None of this exists in AgileRL, and none of it should be traded for its structure.
Licence note: AgileRL is Apache-2.0. Adopting ideas needs no attribution; copying code needs the header and a NOTICE entry. This
proposal assumes ideas only.
---
## 2. Target package layout
### 2.1 Layer rules, and where the current code breaks them
Imports flow downward only. A layer may import its own layer only through an explicit allow-list in the test.
```
L0 root modules: config, exceptions, exit_codes, registry(banner), protocols (new), batch (new)
runtime/ (was utils/)
L1 kernels, tokenizer, model, optim (new), functional (new: pure tensor/scalar math, no align imports)
L2 verify, data, rollout, inference, envs (new)
L2b eval (imports only L1/L2: data, verify, inference, model, tokenizer; measured, see below)
L3 train (the engine: Trainer, TrainModule, checkpoint formats, FSDP, stage gates and smoke)
L4 algorithms (new) (losses, batch contracts, modules, presets; subclass TrainModule)
L5 recipes (new), cli (new)
```
**[A2] Why `eval` is L2b and not L5.** The first draft put `eval` at L5 and then listed `train -> eval` as an upward import. An AST
scan of `align/eval/*.py` shows its only `align` imports are `config`, `data.schema`, `data.tokenization`, `inference.engine`,
`model.*`, `tokenizer`, `verify` and itself, so `eval` sits below `train`. With `eval` at L5 the proposed fixes created two new
violations (moving `train/stage_gates.py` to `recipes/` makes `dpo_stage`, now L4, import L5; moving `ref_cache` to
`algorithms/dpo/` leaves `ref_cache -> eval.metrics.wilson_interval`, L4 to L5) and moved a 955-line, 12-import-site, frozen file
for no gain. With `eval` at L2b, `stage_gates.py`, `stage_smoke.py` and `ref_cache.py` need no layering fix. The `align/__init__.py`
lazy public API is exempt from the layer rule (it names L2 modules from L0 through `__getattr__`).
Edges measured with an AST scan of every `import` including lazy ones [read, script run for this document]:
| Violation | Evidence | Fix |
|---|---|---|
| ~~`train` imports `eval`~~ **[A2] not a violation** once `eval` is L2b (see above) | `train/stage_smoke.py:369,370,453,463`, `train/ref_cache.py:594`, `train/stage_gates.py:351,685` | none: `stage_gates`, `stage_smoke` stay in `train/`; `ref_cache` moves to `algorithms/dpo/` and may import `eval` |
| `train` imports `ppo` (upward) | `train/qwen3_stage.py:171,189,209,224,258` (`ppo.lora`) and `:333` (`ppo.trainer.AdapterProbeView`) **[A2: the first draft listed only 171,189,209 and missed the `AdapterProbeView` edge]** | LoRA is a model concern: `ppo/lora.py` to `model/lora.py`; `AdapterProbeView` is a parameter view for `train.probes.TrunkMovementProbe`, so it moves next to it, `ppo/trainer.py::AdapterProbeView` to `train/probes.py` |
| **[A2] new:** `rl/envs/lean_multiturn.py` (becomes `envs/`, L2) imports `rl.advantages` and `rl.rewards` (would be `algorithms/`, L4) | `lean_multiturn.py:100,101` (`compute_group_advantages`, `soft_overlong_punishment`) | `rl/advantages.py`, `rl/rewards.py`, `rl/ops.py`, `ppo/gae.py` have no `align` imports (checked); they become `functional/{advantages,rewards,ops,gae}.py` at L1 instead of `algorithms/common/` |
| `data` imports `eval` while `eval` imports `data` (cycle) | `data/outcome_agreement.py:275,306` imports `eval.verifier`; `eval` has 3 edges into `data` | `Outcome`/`OutcomeVerifier` to `verify/outcome.py`; `outcome_agreement.py` to `eval/` |
| `verify` imports `eval` while `eval` imports `verify` (cycle) | `verify/lean/reward.py:116` imports `eval.verifier.Outcome` | same move |
| The library loads code from `scripts/` and `extras/` by file path | `rl/envs/lean_multiturn.py:108-123` (`_load_by_path("...", "extras/vllm_rollout/lean_prompts.py")`, `"scripts/p2/p2_common.py"`) | Vendor the prompt module as `envs/lean/prompts.py` with a sha256 identity test against the `extras/` copy; extract the one function used from `p2_common` (`displayed_statement_matches`) |
| Hard-coded absolute paths in library code | `config.py:40,335,339`, `kernels/gate.py:36`, `kernels/_toolchain.py:42`, `utils/guard.py:41,42,43` (`PROJECT_ROOT = "/apps/development/rlhf/align"`); `eval/golden.py:84,123,128,129,374`; `verify/lean/toolchain.py:39`; `rollout/client.py:135`; `data/hub.py:54`, `data/sources.py:37`, `data/ingest_parquet.py:36`; `train/dpo_stage.py:142,149` (**[A2]** the last three groups were missing; `config.py:335,339` too) | M8; blockers for open-sourcing. **Editing `eval/golden.py` changes `eval_source_hash` (4.5)** |
The enforcing test (`tests/test_layering.py`, about 40 lines) builds the same graph, maps each module to a layer from a table in
the test, and fails on an upward edge or on a cycle between two same-layer packages. **[A2]** It scans only real modules: files
listed in the shim manifest (4.2) are skipped, and the legacy packages `ppo`, `rl`, `rl.envs`, `utils` are treated as aliases of their
targets, otherwise every shim would be reported as an upward edge. It starts in report-only mode (M0) so the
current violations are recorded, then becomes strict as each is fixed.
### 2.2 Tree
```
align/
__init__.py lazy public API (existing pattern; new names added in M7)
config.py exceptions.py exit_codes.py registry.py unchanged location (L0)
protocols.py NEW Algorithm, Env, RolloutBackend, Verifier, ShardRuntime, WeightFormat, LossAdapter
batch.py NEW SFTBatch, PreferenceBatch, RolloutBatch, TokenAdvRolloutBatch, PPORolloutBatch
([A2] both files import torch only under TYPE_CHECKING or inside functions; neither is imported by
`align/__init__`, and `test_public_api`'s heavy-module list gains `align.batch`, `align.protocols`)
runtime/ dist.py guard.py host_state_gate.py supervisor.py tracking.py (from utils/)
__init__.py stays empty: `import align.runtime` must not import torch (tests/test_public_api.py)
functional/ NEW (L1) advantages.py rewards.py ops.py gae.py kl.py pure math, no align imports
kernels/ unchanged + fused_policy_loss.py (NEW), paged_attention.py (NEW, Triton)
tokenizer/ unchanged
model/ unchanged + lora.py (from ppo/lora.py), heads.py (from ppo/value_head.py),
build.py (arch registry: mistral | qwen3 | olmoe)
optim/ adamw8bit.py quant8.py offload_adamw.py factory.py (make_optimizer)
verify/ unchanged + outcome.py (from eval/verifier.py), code/ (NEW)
data/ unchanged (minus outcome_agreement.py)
rollout/ unchanged + backends.py (NEW registry: native | paged | tp | spool)
inference/ unchanged (eval-time contiguous-KV engine; candidate to be replaced by PagedEngine later)
envs/ NEW base.py harness.py math.py lean/{env.py,prompts.py} code.py
train/ engine: trainer.py train_module.py fsdp.py probes.py (+AdapterProbeView) sharded_ckpt.py ckpt_format.py
moe_module.py stage_gates.py stage_smoke.py ([A2] the last two stay)
algorithms/ NEW base.py registry.py
sft.py dpo/ ppo/ grpo/ cispo.py dapo.py expert_iteration.py
eval/ unchanged + outcome_agreement.py (M7 only: see 4.5, changes eval_source_hash)
recipes/ NEW sft.py dpo.py grpo.py ppo.py expert_iteration.py
cli.py NEW python -m align {sft,dpo,grpo,ppo,ei,eval}
```
Not moved on purpose: `config.py`, `exceptions.py`, `exit_codes.py`, `registry.py` (55 test import sites for `config.py` alone, all in
the freeze closure, and nothing is gained by a `core/` directory that the layer table does not already give); `model/`, `kernels/`,
`tokenizer/`, `rollout/`, `inference/`, `data/` names (97 test imports use `align.model` alone). Tests (121 files) stay flat in `tests/`; a
new `tests/contract/` holds the contract suites (2.3).
### 2.3 The algorithm contract
Today the pieces of an algorithm are spread over four places with no common name: a loss adapter `(model, batch) -> LossOutput`
(`train/train_module.py` `LossAdapter`), a stage config parsed by its own loader (`rl/stage_config.py:181`,
`ppo/stage_config.py:313`, `train/dpo_stage.py:285`), a launch-refusal function (`assert_a3_conformant` at
`rl/grpo_loss.py:337`, `assert_p4_cispo_conformant` at `rl/cispo.py:295`), and a `TrainModule` subclass. The contract names them.
```python
# align/protocols.py (sketch)
@dataclass(frozen=True)
class Needs:
reference: Literal["none", "adapters_off", "cached_logprobs", "model"]
rollouts: bool
critic: bool
class Algorithm(Protocol):
name: ClassVar[str]
Config: ClassVar[type] # frozen dataclass, struct-mode loadable by align.config
Batch: ClassVar[type] # batch contract in align/batch.py, with .validate()
needs: ClassVar[Needs]
def __init__(self, cfg: "Config"): ...
def validate(self, run: RunConfig, world_size: int) -> list[str]:
"""Launch refusals. Empty list = may launch. Replaces assert_a3_conformant et al."""
def loss_adapter(self) -> LossAdapter: # (model, batch) -> LossOutput
def train_module(self, model, optimizer, run: RunConfig) -> TrainModule: ...
def make_source(self, data_cfg, *, env=None, engine=None, model=None, tokenizer=None): ...
def extra_state(self) -> dict: ... # rides resume.pt["extra_state"] (exists)
def load_extra_state(self, state: dict) -> None: ...
def banner(self) -> dict: # keys registered with align.registry
```
Rules the contract encodes, each already true somewhere in align and now stated once: `train_batch` is exactly
`(loss / grad_accum).backward()`; per-token tensors are label-aligned `[B, T-1]`; extra state loads with `weights_only=True`;
undefined metrics are NaN, never 0.0.
Mapping of the seven algorithms (current locations [read]):
| Algorithm | Loss today | Batch contract today | Config today | Extra state | Needs |
|---|---|---|---|---|---|
| `sft` | `make_sft_loss_adapter` `train/sft_loss.py:54` over `fused_linear_cross_entropy` (`kernels/fused_ce.py:259`) | dict from `data.loader.collate_sft` | `RunConfig` sections only | none | none |
| `dpo` | `make_dpo_loss_adapter` `train/dpo_loss.py:115` over `fused_dpo_loss` (`kernels/fused_dpo.py:545`) | `DpoBatch` `train/dpo_stage.py:818` | `DpoStageConfig` `:118` | tripwire response (`:680`) | `cached_logprobs` (`train/ref_cache.py`) |
| `ppo` | `ppo_loss` `ppo/loss.py:83`, `value_warmup_loss` `:163`, `PpoTrainModule` `ppo/trainer.py` | `PPORollout` `ppo/rollout.py:40` | `PPOStageConfig` `ppo/stage_config.py:110` | ratio tripwire (`ppo/health.py`) | `adapters_off` reference, critic, rollouts |
| `grpo` | `compute_grpo_loss` `rl/grpo_loss.py:179`, `GRPOTrainModule` `rl/grpo_module.py:53` | `RolloutBatch` `rl/types.py:21`, `TokenAdvRolloutBatch` `rl/grpo_loss.py:301` | `GrpoStageConfig` `rl/stage_config.py:92`, `GRPOLossConfig` `rl/grpo_loss.py:47` | `CollapseTripwire` state, `GrpoBatchSource` cursor (see "Still open" in DEPENDENCY_POLICY) | rollouts; reference only if `beta > 0` |
| `dapo` | `GRPOLossType.dapo` branch (`grpo_loss.py:194`) + dead-group filter (`rl/advantages.py:50`) + soft overlong (`rl/rewards.py:22`) | as `grpo` | as `grpo` | as `grpo` | as `grpo` |
| `cispo` | `cispo_policy_loss` `rl/cispo.py:110`, `fp32_head_forward` `:255` | as `grpo` | `GRPOLossConfig(loss_fn=cispo)` + `assert_p4_cispo_conformant` | as `grpo` | as `grpo`, `beta = 0` |
| `expert_iteration` | inner loss is `sft`; outer is an 11-stage driver `scripts/p2/expert_iteration.py` (850 lines) with chain-hash markers | stage markers (`RUN/round_NN/stages/<stage>.done.json`) | argparse in the script | round manifests | none (subprocess stages) |
**Two honest wrinkles in this table.**
- *"DAPO" in align is not DAPO's objective.* `GRPOLossType.dapo` is the clip-higher surrogate (DAPO Sec. 3.1, arXiv:2503.14476,
`eps_high = 0.272` in `grpo_loss.py:54`), but the A3 gate refuses DAPO's own token-mean denominator
(`grpo_loss.py:56` comment "A3 requires a float (Dr.GRPO)"; refusal text at `:356`, "not the DAPO token-mean 'token' default") in favour of a constant denominator
and centred advantages without std division (Dr. GRPO, arXiv:2503.20783, Sec. 3.1 on the length and std normalisation biases).
Dynamic sampling (DAPO Sec. 3.2) is the dead-group filter; soft overlong punishment (DAPO Sec. 3.4) is `rl/rewards.py:22`.
The presets should say what they are: `dapo` (upstream-faithful, token mean, for comparison), `a3` (the pre-registered
clip-higher + constant denominator), `cispo_p4` (CISPO, arXiv:2506.13585 Sec. 3.1 Eq. 3-5, no lower IS bound, `beta = 0`,
fp32 head per Sec. 3.2). The A3 and P4 gates stay as `validate()` and keep refusing.
- *`expert_iteration` is an outer loop, not a per-step loss.* The contract has two kinds: a `LossAlgorithm` (everything above
except this one) and an `OuterLoop` (a stage graph whose stages are subprocesses of other recipes). Do not force it into
`loss_adapter`. It implements `validate`, `banner` and a `run(stage_graph)`; its stages call `recipes.sft`, `recipes.eval`.
**Contract test** (`tests/contract/test_algorithm_contract.py`, parametrised over the registry): (1) `Config` round-trips through
`dumps_toml` and `load_config` with struct mode; (2) `loss_adapter()` returns a finite `LossOutput` on a tiny fp64 model and
synthetic batch; (3) the gradient equals an eager fp64 reference to a stated tolerance (the "oracle tier" pattern already used
for model parity); (4) `extra_state()` survives `torch.save` / `torch.load(weights_only=True)`; (5) `Batch.validate()` rejects an
off-by-one label alignment; (6) `validate()` fires on each known-bad config in a table; (7) every `Config` field has a doc
comment (A6 above).
### 2.4 The environment contract
```python
# align/envs/base.py (sketch)
GenerateFn = Callable[[list[GenRequest]], list[Generation]] # the rollout-engine seam; exists in lean_multiturn.py:152,160
class Env(Protocol):
name: ClassVar[str]
def specs(self, split: str) -> Sequence[EpisodeSpec]: ...
def run_group(self, specs: Sequence[EpisodeSpec], generate: GenerateFn) -> list[EpisodeRecord]:
"""Run all episodes of one prompt-group in lock step so the engine can batch across them."""
def rows(self, ep: EpisodeRecord) -> list[TrainingRow]:
"""response_mask is 1 only on tokens the generator returned; turn_ids labels each token."""
def reward_components(self) -> tuple[str, ...]: ...
```
- `MathEnv` (single turn): extract answer, `OutcomeVerifier`, `CompositeReward`; absorbs `rl/assembly.py:57` `assemble_rollout`'s
scoring half.
- `LeanEnv(max_turns=K)`: `rl/envs/lean_multiturn.py` unchanged in behaviour; `max_turns=1` is the single-turn Lean prover.
Layout `per_turn` or `concatenated` stays an env option; `PrefixBreak` stays.
- `CodeEnv`: new. Run candidate code against tests in a subprocess with the same constraints as `verify/lean/sandbox.py`
(own session, `prlimit`, minimal environment, memory watchdog). Counts as a heavy job for the host preflight when pooled.
`GrpoBatchSource` becomes `RolloutSource(env, engine, algorithm_assembler)`; `MultiTurnBatchSource(GrpoBatchSource)` (a subclass
whose `bind` is "deliberately narrower than the parent's", `rl/multiturn_source.py:418`) disappears, because the parent no longer
assumes `problem/answer` rows.
### 2.5 Exact mapping, current to new
`F` = in the import closure of the running P2 scripts (4.1): do not edit or move until the chain exits. For an `F` file the new
path is created first as a facade that imports from the old path; the physical move and shim-direction flip happen in M7.
Files not marked `F` can move in M3 to M5 with an identity shim.
| Current | New | F |
|---|---|---|
| `align/__init__.py` | stays; new lazy names in M7 | F |
| `align/config.py`, `exceptions.py`, `exit_codes.py`, `registry.py` | stay | F |
| (new) | `align/protocols.py`, `align/batch.py` | |
| `utils/dist.py`, `utils/guard.py`, `utils/tracking.py` | `runtime/dist.py`, `runtime/guard.py`, `runtime/tracking.py` | F |
| `utils/host_state_gate.py`, `utils/supervisor.py` | `runtime/host_state_gate.py`, `runtime/supervisor.py` | |
| `utils/__init__.py` | stays (shim package) | F |
| `kernels/*` | stay; new `fused_policy_loss.py`, `paged_attention.py` | mixed |
| `tokenizer/*` | stay | F |
| `model/{attention,checkpoint,hf_config,mistral,moe,moe_ep,olmoe,olmoe_checkpoint,qwen3,qwen3_checkpoint}.py` | stay | F |
| `model/{gates,weight_average}.py` | stay | |
| `model/tp.py` **[A2]** | stay | F (imported by `tests/test_tp_bench.py`) |
| `ppo/lora.py` | `model/lora.py` | F |
| `ppo/value_head.py` | `model/heads.py` | F |
| `train/qwen3_stage.py` (`build_qwen3_stage_model`, `resolve_qwen3`, `validate_qwen3_cfg`, `qwen3_trainer_extras`, `qwen3_entry_banner`) | `model/build.py` (builders) and `recipes/sft.py` (banner, extras) | F |
| `train/qwen3_stage.py` `LoRASFTModule` | `algorithms/sft.py` (`LoRASFTModule`) | F |
| `train/qwen3_stage.py` `Qwen3Format` | `train/ckpt_format.py` (next to `DenseFormat`, `OlmoeFormat`) | F |
| `train/{adamw8bit,quant8,offload_adamw}.py` | `optim/{adamw8bit,quant8,offload_adamw}.py`; `make_optimizer` to `optim/factory.py` | F |
| `train/{trainer,train_module,fsdp,probes,sharded_ckpt,ckpt_format,moe_module}.py` | stay (the engine); `probes.py` gains `AdapterProbeView` from `ppo/trainer.py` | F |
| `train/stage_gates.py`, `train/stage_smoke.py` | **[A2] stay** (they import only `eval`, which is L2b; moving them to `recipes/` made `dpo_stage` import L5 and touched 12 frozen import sites for nothing). `recipes/` imports them | F |
| `train/__init__.py`, `eval/__init__.py`, `model/__init__.py`, `rollout/__init__.py`, `kernels/__init__.py`, `verify/__init__.py`, `tokenizer/__init__.py`, `data/__init__.py`, `inference/__init__.py` **[A2]** | stay | F |
| `ppo/__init__.py`, `rl/__init__.py`, `rl/envs/__init__.py` **[A2: had no destination]** | stay as legacy alias packages. `ppo/__init__` and `rl/__init__` import 9 and 10 submodules eagerly (names in `__all__`), so after M7 each re-export line goes through a shim: they are rewritten as lazy `__getattr__` re-exports to the new locations, in M7, with a test that `from align.rl import GRPOTrainModule` still works and that `import align.rl` imports no more than it does today | F (`rl/envs/__init__` free) |
| `train/sft_loss.py` | `algorithms/sft.py` | F |
| `train/dpo_loss.py` | `algorithms/dpo/loss.py` | |
| `train/dpo_stage.py` | `algorithms/dpo/{config,gates,source,adapter}.py`; `DpoBatch` to `batch.py` | |
| `train/ref_cache.py` | `algorithms/dpo/ref_cache.py` | |
| `rl/types.py`, `TokenAdvRolloutBatch` from `rl/grpo_loss.py` | `batch.py` | F |
| `rl/grpo_loss.py` | `algorithms/grpo/loss.py` | F |
| `rl/grpo_module.py` | `algorithms/grpo/module.py` | F |
| `rl/advantages.py`, `rl/rewards.py`, `rl/ops.py` | `functional/{advantages,rewards,ops}.py` **[A2: were `algorithms/common/` and `algorithms/grpo/`, which put an L4 module under the L2 `envs/lean`]** | F |
| `rl/tripwire.py`, `rl/canary.py` | `algorithms/grpo/{tripwire,canary}.py` | F |
| `rl/cispo.py` | `algorithms/cispo.py` (loss and conformance); `fp32_head_*` stay as the reference path for `kernels/fused_policy_loss.py` | F |
| `rl/stage_config.py` | `algorithms/grpo/config.py` | |
| `rl/batch_source.py`, `rl/assembly.py`, `rl/format_instrument.py` | `algorithms/grpo/{source,assembly,format_instrument}.py` | |
| `rl/multiturn_source.py` | `algorithms/grpo/multiturn.py` | |
| `rl/envs/lean_multiturn.py` | `envs/lean/env.py`; prompts vendored to `envs/lean/prompts.py` | |
| `ppo/gae.py` | `functional/gae.py` | F |
| `ppo/{loss,rewards,rollout,health}.py` | `algorithms/ppo/{loss,rewards,rollout,health}.py` | F |
| `ppo/trainer.py` | split: `algorithms/ppo/module.py` (`PpoTrainModule`), `algorithms/ppo/source.py` (`PPOBatchSource`), `AdapterProbeView` to `train/probes.py` | F |
| `ppo/stage_config.py` | `algorithms/ppo/config.py` | |
| `scripts/p2/expert_iteration.py` | library form `algorithms/expert_iteration.py`; script stays and later becomes a wrapper | frozen |
| `eval/verifier.py` | `verify/outcome.py` (old path becomes a shim). **[A2] changes `eval_source_hash`, see 4.5** | F |
| `eval/{golden,harness,metrics,proof_stats,roundtrip}.py` | stay | F |
| `data/outcome_agreement.py` | `eval/outcome_agreement.py` (**[A2]** adds a file to `align/eval/`, so it changes `eval_source_hash` too: M7 only) | F |
| `data/*` (rest) | stay | mixed |
| `verify/{equiv,expr,extract,normalize}.py`, `verify/lean/*` | stay | F (lean frozen by instruction) |
| `rollout/protocol.py` | stays | F |
| `rollout/{engine,paged,tp_engine}.py` **[A2]** | stay; `paged.py` gets `TritonPagedAttention` in M6 | F (imported by `tests/test_tp_bench.py`, which the chain launches) |
| `rollout/{client,native}.py` | stay; new `rollout/backends.py` | |
| `inference/{engine,bucketed}.py`, `inference/receipts.py` | stay | F / free |
| `scripts/{sft,dpo,grpo,ppo}.py` | stay; `recipes/*` hold the same flows; scripts become 30-line wrappers after M7 | F (sft) |
---
## 3. Minimal public API
Ten call sites. Names marked `exists` are in the repo today; `new` are the facade layer from M1/M2. The point is that
`scripts/sft.py` (261 lines), `scripts/grpo.py` (412) and `scripts/ppo.py` (482) are each one use of these ten.
```python
import align
# 1. config (exists: align.config.load_config). The loader splits [algo], [env], [rollout] before struct-mode parsing, so
# config.py itself is not edited during the freeze.
run = align.load_run("configs/p4_cispo_lean.toml", overrides=["optimizer.lr=1e-6"]) # new, wraps load_config
# 2. host guards, before torch (exists: align.utils.guard.apply_resource_guards)
align.runtime.apply_guards(cpu_threads=8, heavy=True, label="cispo-lean")
# 3. model + tokenizer, family chosen by run.model.arch through a registry (new; today scripts/sft.py::build_model
# and train/qwen3_stage.py::build_qwen3_stage_model)
model, tok = align.model.build(run)
# 4. algorithm by name or preset (new; the classes wrap today's GRPOTrainModule / DpoStage / PpoTrainModule)
algo = align.algorithms.create(run.algo) # run.algo = {name="grpo", preset="cispo_p4", eps_high=...}
refusals = algo.validate(run, world_size=3) # launch refusals as data; recipes raise if non-empty
# 5. environment (new; wraps rl/envs/lean_multiturn.py)
env = align.envs.make("lean/multiturn", run.env, verifier=align.verify.lean.StrictVerifier(...)) # StrictVerifier exists
# 6. rollout engine (exists: align.rollout.client.make_rollout_client; new name and registry)
engine = align.rollout.make_engine(run.rollout, model=model, tokenizer=tok) # "native" | "paged" | "tp" | "spool"
# 7. batch source: algorithm-specific, built from env + engine (new; GrpoBatchSource is today's equivalent)
source = algo.make_source(run.data, env=env, engine=engine, model=model, tokenizer=tok)
# 8. trainer: the existing Trainer, built from the algorithm (new classmethod over Trainer.__init__)
trainer = align.train.Trainer.from_algorithm(run, model, algo, source, run_dir=run.paths.run_dir)
trainer.resume_from_latest() # exists
summary = trainer.train() # exists; honours HALT codes and coordinated stop
# 9. one-liner for the common case (new)
summary = align.fit(run) # steps 2-8 in order, with the stage gates from train/stage_gates.py
# 10. kernels as a plain function, for people who only want the kernel (new)
loss, metrics = align.kernels.fused_policy_loss(
hidden, lm_head_weight, labels, advantages, old_logprobs,
objective="cispo", eps_high=4.0, beta=0.0, temperature=1.0, chunk_tokens=2048,
)
```
Evaluation is the eleventh and only other entry: `align.eval.run(model, tasks=["minif2f"], n=32, verifier=...)`, which wraps
`eval/harness.py::run_eval`.
CLI: `python -m align grpo configs/p4_cispo_lean.toml --smoke` calls `recipes.grpo.run(...)`, so the existing torchrun command
lines in the docs keep their shape.
What is deliberately **not** public: `Trainer` internals, `TrainModule.optim_step`, shard layouts, `_`-prefixed helpers. The lazy
`align/__init__.py` pattern is kept (`import align` must not import torch; `tests/test_public_api.py` pins it).
---
## 4. Migration plan
### 4.1 The freeze closure
The task rule is: live GPU jobs run about two days; do not edit `scripts/p2/*`, `extras/vllm_rollout/*`, `align/verify/lean/*`,
`align/utils/guard.py`, `align/train/trainer.py`. The P2 chain also launches new Python processes every round, and each one
imports `align.*` fresh. A half-written or moved module breaks the next round even if the file itself is not on the list.
So I computed the static transitive import closure of the P2 entry points (`scripts/p2/*.py`, `extras/vllm_rollout/*.py`,
`scripts/sft.py`, `scripts/qwen3_export_check.py`, 20 files), following every `import` including lazy ones and every parent
package `__init__` [read, AST script run for this document]. Result for those entry points alone: 101 of 129 `align/` modules.
It is a conservative over-approximation (a lazy import on a path a P2 round never executes still counts), which is the safe direction.
**[A2] The first draft stopped there, but the chain scripts launch more than the P2 entry points.** `.tmp/p2/r1/chain2_gpu_validation.sh`
and `chain_after_baseline.sh` run `tests/test_tp_bench.py`, `tests/test_moe_ep_bench.py`, `scripts/olmoe_sft_smoke.py` and
`scripts/ckpt_convert.py` as `torchrun` steps. Adding those four entry points pulls in four more modules
(`model/tp.py`, `rollout/engine.py`, `rollout/paged.py`, `rollout/tp_engine.py`). Re-running the same AST closure gives
**105 of 129 reachable, 24 free**. This matters because M6 edits `rollout/paged.py` and M6/M7 both touch the TP engine. Rule: the
entry-point list of the closure is every `python`/`torchrun` target named in any `.tmp/p2/r1/chain*.sh` that is still pending, and
the closure test (`test_frozen_surface.py`) takes that list from a checked-in manifest, not from this document.
The 24 modules **not** in the closure, which may be edited or moved during the freeze:
```
align/data: pretrain_corpus.py stageb_sources.py
align/inference: receipts.py
align/kernels: _fa2_reference_upload.py fused_dpo.py ops.py
align/model: gates.py weight_average.py
align/ppo: stage_config.py
align/rl: assembly.py batch_source.py format_instrument.py multiturn_source.py stage_config.py
align/rl/envs: __init__.py lean_multiturn.py
align/rollout: client.py native.py
align/train: dpo_loss.py dpo_stage.py ref_cache.py
align/utils: host_state_gate.py supervisor.py
align/verify/lean: sandbox.py
```
The names the frozen scripts and files import today (the contract the shims must keep) [read, AST]:
```
align.config: load_config align.registry: render_banner
align.data: hub; .ingest_parquet: iter_parquet_rows; .loader: collate_sft; .store: ShardDataset, ShardWriter
align.eval: proof_stats; .verifier: Outcome align.exit_codes: PreflightRefused, run_with_exit_codes
align.model: qwen3, qwen3_checkpoint; .checkpoint: load_hf_safetensors
align.model.qwen3_checkpoint: align_to_hf, hf_to_align, read_qwen3_config, save_hf_qwen3
align.rollout.protocol: RolloutResult, read_request, spool_dirs, touch_heartbeat, write_result
align.train.qwen3_stage: build_qwen3_stage_model, qwen3_entry_banner, qwen3_trainer_extras
align.train.sft_loss: make_sft_loss_adapter align.train.trainer: Trainer
align.train.stage_gates: GateLedger, assemble_sft_banner, assert_build_is_current, assert_distinct_devices,
assert_probe_resolved, eval_source_hash, evaluate_and_gate, parse_args, record_golden_from_report,
require_golden_receipt, run_entry_gates
align.train.stage_smoke: build_stage_model, build_training_sampler
align.utils: guard; .dist: barrier, init_distributed; .guard: apply_resource_guards, preflight_host_check
align.verify.lean: ReplPool, StrictConfig, StrictVerifier, find_toolchain, strict;
.repl_pool: ReplError, ReplLimits, ReplProtocolError; .strict: AUDIT_PRELUDE, REF_NAME, lex, parse_statement
```
Names added by the four chain-launched entry points **[A2]**:
```
align.model: tp, moe, moe_ep, olmoe, olmoe_checkpoint; .qwen3: Qwen3Config
align.rollout.engine: PagedEngineConfig, SamplingParams align.rollout.paged: kv_bytes_per_token, plan_capacity
align.rollout.tp_engine: TPPagedEngine
align.train: fsdp, sharded_ckpt; .ckpt_format: OlmoeFormat; .moe_module: build_moe_train_module; .offload_adamw: make_optimizer
align.train.trainer: MANDATORY_METRICS, Trainer, checkpoint_problem
align.utils.dist: local_rank, rank, seed_everything, world_size; .guard: assert_paths_off_root, host_state; .tracking: Tracker
align.data.store: ShardDataset align.tokenizer: Tokenizer align.model.olmoe_checkpoint: read_olmoe_config
```
`tests/test_frozen_surface.py` (M0) re-derives this list with the same AST scan on every run and asserts each name resolves, so
a refactor that drops one fails in CI rather than in the middle of round 3. **[A2]** The scan's entry-point list is a checked-in
manifest (`tests/frozen_entrypoints.txt`) that must contain every Python target named in a pending `.tmp/p2/r1/chain*.sh`.
### 4.2 Shim mechanics (verified)
For a file that moves, the old path becomes:
```python
# align/rl/grpo_loss.py (after the move)
import sys
from align.algorithms.grpo import loss as _m
sys.modules[__name__] = _m
```
Checked with a throw-away package outside the repo [cpu-probe, deleted afterwards]: after `import old.impl`, `old.impl is new.impl`
is `True`; `from old.impl import _private_name, SomeClass` returns the same objects; `from old import impl` returns the same
module; `unittest.mock.patch("old.impl.f", ...)` changes the behaviour seen through the new path. So `isinstance` checks, private
imports and monkeypatches in the distinct `align.*` import paths the tests use all keep working. (No test uses
`mock.patch("align...")`, `monkeypatch.setattr("align...")` or `import_module("align...")` on a moved path today; 0 hits. **[A2]**
re-checked by grep.) Pickle compatibility is not a concern: align checkpoints use safetensors and `weights_only=True`.
**[A2] What the identity shim does NOT preserve (found by a second probe, same method).**
1. **`python -m old.path` silently does nothing and exits 0.** The runner imports the shim as `__main__`; the shim replaces
`sys.modules["__main__"]` with the real module and the real module's `if __name__ == "__main__":` block never runs (its
`__name__` is the new dotted path). Probe: `python -m pk.old.gate` printed nothing and exited 0, `python -m pk.new.gate`
printed `REAL MAIN RAN` and exited 3. `align/utils/host_state_gate.py:31` documents `python -m align.utils.host_state_gate` and
that module is the D-state safety gate (it must return `EXIT_PREFLIGHT_REFUSED` when the host is wedged); a silent exit 0 would
turn the gate into a pass. **Rule 3:** a moved module that has an `if __name__ == "__main__"` block gets a shim that forwards
it: `if __name__ == "__main__": raise SystemExit(_m.main())`. The manifest records which moved modules have a `main`, and
`test_legacy_shims.py` runs `python -m <old>` in a subprocess for each and compares the exit code with `python -m <new>`.
Today only `host_state_gate.py` among the moved modules has such a block (`model/weight_average.py` has a documented CLI but
does not move).
2. **Tests that read a module's source text now read the shim.** `tests/test_dpo_loss.py:190` greps `align/train/dpo_loss.py`
for `from_pretrained`, `load_state_dict`, `AutoModel` and `NmoMistralForCausalLM`; `tests/test_h2_review_items.py:675-679`
parses `align/utils/host_state_gate.py` for non-stdlib imports and reads `align/utils/guard.py`. After the shim they still
pass, vacuously, because a 3-line shim contains none of those strings. See 4.6.
3. **Partial-initialisation cycles.** `ppo/__init__.py` and `rl/__init__.py` import their submodules eagerly. An old-path shim
executes `from align.algorithms... import ...`, which runs `algorithms/__init__` and `algorithms/<x>/__init__`; if any of
those import `align.rl` / `align.ppo`, the import order decides whether it works. The layering test cannot see this (shims are
exempt). `test_legacy_shims.py` therefore imports every old and every new path in a **fresh interpreter, new path first and
old path first**, and asserts both orders succeed.
Rules 1 and 2: shims warn only under `ALIGN_WARN_LEGACY_IMPORTS=1` (a default warning would add stderr noise to every P2 round);
and `tests/test_legacy_shims.py` asserts, from a manifest, that `import_module(old) is import_module(new)` for every moved path.
For an `F` file the direction is reversed first: the new module is a facade that imports from the old one. When the chain exits
(M7) the real code moves and the old path becomes the shim above. The shim stays for at least one release because
`scripts/p2/*` and `extras/` import the old paths and are not ours to edit.
### 4.5 [A2] `eval_source_hash`: moving anything in or out of `align/eval/` invalidates every gate ledger
`train/stage_gates.py::eval_source_hash` (`:94-113`) hashes the relative path and bytes of every `.py` under `align/eval/`. A
`GateLedger` records that hash; on load, if the hash differs, `_load` deletes every gate in `EVAL_DEPENDENT_GATES` (the golden
regression among them) and `require_golden_receipt` then refuses to train (`stage_gates.py:739-753`). Three planned moves change the
hash: `eval/verifier.py` becoming a shim, `data/outcome_agreement.py` arriving in `eval/`, and the M8 path-hygiene edit of
`eval/golden.py`. The first draft treated these as free once the chain had exited. They are not: every run directory that is
resumed after M7 needs the golden regression re-run (a 7B checkpoint job; `scripts/eval.py --mode=golden`).
Plan: (a) none of the three edits happens before the P2 chain exits (already true for M7, now stated for M8); (b) M7 and M8 land
as **one hash epoch**, i.e. adjacent commits with a single golden re-run after the second; (c) `tests/test_layering.py` and
`test_legacy_shims.py` print the new `eval_source_hash` so the epoch boundary is visible in CI; (d) the alternative that avoids
the epoch keeps `align/eval/` byte-identical: leave `Outcome`/`OutcomeVerifier` in `eval/verifier.py`; cut `verify -> eval`
(`verify/lean/reward.py:116`) by typing the result against a small Protocol in `verify/`; and put `outcome_agreement.py` in
`recipes/` (L5, which may import `eval`) instead of `eval/`, with `align.data.__getattr__` serving the old name lazily. It costs a
slightly less tidy layout and saves a 7B golden re-run, so it is the cheaper choice unless the owner wants the tidier layout. The
owner decides (open question 11).
### 4.6 [A2] Tests that pin source text at old paths
Reading file text of a library module or of `scripts/*.py` is an established pattern here (the CPU suite cannot execute the GPU
scripts, so it pins them by structure). Found by grep: `test_dpo_resume_wiring.py` (string-matches `scripts/dpo.py`:
`'"--resume"'`, `os.environ.get("ALIGN_RESUME") == "1"`, the rotation guard), `test_grpo_resume.py:293` (AST of `scripts/grpo.py`:
`resume_from_latest` call ordering), `test_dpo_stage.py:1566` (`assert_launch_topology(cfg)` before `run_entry_gates(`),
`test_ref_cache.py:179,300` (`scripts/dpo.py`, `scripts/precompute_ref_logprobs.py`), `test_h2_review_items.py:374,383`
(`train()` in `scripts/sft.py`, `pretrain.py`), `test_qwen3_stage.py:301`, `test_g0_integration.py:38`, `test_olmoe_scripts.py`
(several), `test_verifier.py:371` (`scripts/eval.py`), plus the library-text pins in 4.2 item 2.
Consequences for the plan: (1) **M7 "make `scripts/*` 30-line wrappers" cannot keep the baseline green** unless each pin is
retargeted to the `recipes/` file that now holds the flow, with the same assertion, in the same commit. M2's recipes are copies,
so the pins should be written against both files from M2 on (a parametrised `[script, recipe]` pin), and the script pin is
dropped only at M7. (2) M3's move of `train/dpo_loss.py` (and `utils/host_state_gate.py`) must retarget the two library-text
pins to the new path in the same commit, and the shim manifest records the pin so `test_legacy_shims.py` fails if a pinned
path is a shim (a vacuous pin is a lost guard, not a pass). (3) The gate "same passed counts" cannot hold literally once
contract tests are added; it is restated in 4.3 as "every baseline test id still passes".
### 4.3 Steps
Every step has the same gate: `pytest -m "not gpu and not oracle and not slow"` where **every test id in the M0 junit baseline
still passes or is skipped for the same reason** (**[A2]** not "equal counts": M1 onward adds contract tests, so the totals must
grow, while a baseline id going missing or silently skipping must fail), `tests/test_dependency_firewall.py` green,
`tests/test_public_api.py` green (import stays cheap), `tests/test_frozen_surface.py` green, and, from M3, `tests/test_legacy_shims.py`
(identity, `-m` forwarding, both import orders, no vacuous source pin: 4.2 and 4.6). All GPU-free. All run with `CUDA_VISIBLE_DEVICES=`.
**[A2]** The suite shares a 48-core box with the live chain: run it under `nice -n 10`, `OMP_NUM_THREADS=4`, `-p no:cacheprovider`,
and only after the host preflight in `/apps/CLAUDE.md` (`free -g; uptime`) passes; `conftest.py` already keeps `tmp_path` off `/tmp`.
| Step | What | Effort | Touches `F` files? | Extra gate |
|---|---|---|---|---|
| **M0** | Safety net, no code moves: record the real CPU baseline (junit file); add `test_frozen_surface.py`, `test_legacy_shims.py` (empty manifest), `test_layering.py` (report-only, lists today's violations from 2.1) | 0.5 d | no | baseline numbers committed in the doc |
| **M1** | Facade layer, additive: `protocols.py`, `batch.py` (re-exporting the existing batch classes), `algorithms/{base,registry}.py` and `algorithms/{sft,dpo,ppo,grpo,cispo,dapo}.py` as classes that wrap the existing loss adapters and `TrainModule` subclasses; contract test suite 2.3 | 1.5 d | no (imports only) | contract tests pass for all six |
| **M2** | `recipes/` and `cli.py`: the `main()` flows of `scripts/{sft,dpo,grpo,ppo}.py` as functions, written as new files (the scripts are not edited). `align.load_run` splits `[algo]/[env]/[rollout]` before `load_config`. | 3 d | no | tiny-model parity: `recipes.sft` and `scripts/sft.py --smoke` produce identical `history.jsonl` losses on CPU/gloo (`train/stage_smoke.py` already builds the model); same for grpo and dpo smoke |
| **M3** | Move the non-`F` modules with identity shims: `dpo_*`, `ref_cache`, `rl/{stage_config,batch_source,assembly,format_instrument,multiturn_source}`, `ppo/stage_config`, `utils/{host_state_gate,supervisor}`. Fix cycles that need only free files. **[A2]** Retarget the two library-text pins (`test_dpo_loss.py:190`, `test_h2_review_items.py:675`) in the same commit; `host_state_gate` gets the `__main__`-forwarding shim | 2 d | no | `test_legacy_shims.py` manifest grows by exactly the moved paths; `python -m align.utils.host_state_gate` exit code equals the pre-move exit code |
| **M4** | `envs/`: `Env` protocol, `MathEnv`, `LeanEnv` from `rl/envs/lean_multiturn.py`; vendor `lean_prompts.py` as `envs/lean/prompts.py` with a sha256 identity test against `extras/vllm_rollout/lean_prompts.py`; extract `displayed_statement_matches`; collapse `MultiTurnBatchSource` into `RolloutSource(env=...)` | 3 d | no | `tests/test_lean_multiturn_env.py`, `test_multiturn_source.py`, `test_lean_gen_contract.py` unchanged and green; GRPO smoke bit-equal before and after |
| **M5** | Kernel A/B: `kernels/fused_policy_loss.py` as a chunked `autograd.Function` (5.4-A) with an eager fp64 oracle test; `mm(out_dtype=float32)` probe on GPU after the freeze | 3 d CPU + 0.5 d GPU | no | fp64 gradient oracle (max abs error recorded); GPU parity vs `fp32_head_logprobs` |
| **M6** | Kernel C: Triton paged-attention decode (5.4-C) behind `TritonPagedAttention`, `fast` and `invariant` modes | 8 d (CPU interpreter tests 3 d; GPU validation and tuning 5 d) | **[A2] yes: `rollout/paged.py` is in the closure of `tests/test_tp_bench.py`; starts only after the chain exits** | bit-invariance test: row output independent of batch composition; `tests/test_paged_engine*.py` green; decode benchmark vs `TorchPagedAttention` |
| **M7** | After the P2 chain has exited (gate **[A2]**: `pgrep -f '\.tmp/p2/r1/chain'` and `pgrep -f 'scripts/p2/'` both empty, not only `chain_after_baseline`: `chain4.sh` and `chain5.sh` are live today; `nvidia-smi` idle, as in `docs/P3_ENGINE.md` GPU plan): move the `F` files, flip each facade to a shim, rewrite the three legacy `__init__`s as lazy re-exports, add the new lazy names to `align/__init__.py`, retarget the script-text pins (4.6) and make `scripts/*` 30-line wrappers, sweep path mentions in `docs/` (README, `ARCHITECTURE.md`, `DEPENDENCY_POLICY.md` and 6 more name `align/rl/`, `align/ppo/`, `align/utils/`, `align/train/...`) | **3 d** (was 1.5: about 55 `F` modules, ten-odd pins, three `__init__`s, docs) | yes | full suite and frozen-surface test after **each** moved file, not at the end; then one golden re-run (4.5) |
| **M8** | Open-source hygiene: remove the hard-coded paths (2.1), `Nmo*` names (aliases kept; 38 files mention `nmo`/`NMO`), accept both `ALIGN_*` and `NMO_*` env vars, add a LICENSE (none exists in the repo root), relax `torch==2.11.0` / `triton==3.6.0` / `requires-python <3.13` pins to tested ranges, CI lane with ruff + firewall + layering, README rewritten around the ten call sites | 3 d | yes | clean checkout test: `uv venv && uv pip install -e .[dev]` and the CPU suite pass with no `/apps` path present |
Total about 27.5 working days (about 5.5 weeks for one engineer; **[A2]** M7 re-estimated from 1.5 to 3 d). M1 to M5 are all runnable
during the freeze because they only add files or move non-`F` ones. M6 (it edits the frozen `rollout/paged.py`) and the GPU halves
of M5 wait for free GPUs.
### 4.4 Risks
| Risk | Mitigation |
|---|---|
| A new file in an existing package changes import order or a `__init__` side effect | Only new subpackages are added during the freeze; no existing `__init__.py` is edited until M7 |
| The facade and the old class drift while both exist | Facades hold no logic; contract tests call both paths and compare |
| `sys.modules` shim hides a circular import | `test_layering.py` is strict before M7 flips anything |
| M2 recipes diverge from scripts that P2 still runs | Parity test on tiny model; the scripts are the source of truth until M7; the source pins of 4.6 are parametrised over `[script, recipe]` from M2 |
| Test counts in docs are stale, so "stay green" has no baseline | M0 records it |
| ~~Moving `train/stage_gates.py`~~ **[A2]** | No longer moved (2.1). `eval/` changes still invalidate the ledger, see 4.5 |
| **[A2]** `python -m <old.path>` through a `sys.modules` shim silently does nothing and exits 0 | `host_state_gate.py` is a safety gate with a documented `python -m align.utils.host_state_gate`. Shim rule 3 in 4.2 plus a subprocess test |
| **[A2]** Source-pinning tests read old file paths and script text (4.6) | Retarget each pin in the same commit as the move, and fail the gate if a pin goes vacuous |
| **[A2]** Eval source hash epoch (4.5) | Plan one golden re-run in M7 |
---
## 5. Where custom Triton kernels pay off
### 5.1 Method and constants
For each candidate the question is: **is the op bound by bytes or by FLOPs, and how much of the iteration is it?** A kernel can
only recover the gap between what the code does today and the roofline. Constants:
| Constant | Value | Source |
|---|---|---|
| A10G HBM bandwidth | 600 GB/s | **[assumed]** datasheet, same value used in `docs/P3_ENGINE.md` |
| bf16 tensor-core peak / best measured | about 70 / 68 TFLOP/s (one `8192x4096 @ 4096x4096`); 44 to 53 TFLOP/s in-layer | [read] `docs/MEASUREMENTS.md:102-105,254` |
| fp32 CUDA-core peak, sustained | 31.2 peak, 25 sustained (sensitivity 20 to 31) | **[assumed]** datasheet; not measured here. TF32 is disabled in `rl/cispo.py::_strict_fp32_matmul` |
| Qwen3-8B (Goedel-Prover-V2-8B) | hidden 4096, 36 layers, 32 q heads / 8 kv heads, head_dim 128, vocab 151,936, intermediate 12,288, untied head | [read] model `config.json` |
| KV bytes per token | 2 x 36 x 8 x 128 x 2 B = 147,456 B (144 KiB) | [calc]; matches `docs/P3_ENGINE.md` (3.60 GiB / 26,192 tokens) |
| Weights bf16 | 15.26 GiB = 16.39 GB; 27.3 ms to read once at 600 GB/s | [calc]; 15.26 GiB is `docs/P3_ENGINE.md` |
| Eager launch cost | 6 us per op | **[assumed]**, typical 5 to 10 us |
### 5.2 Where the time goes in one RL iteration (why decode dominates)
Illustration with stated knobs, not a measurement. 16 prompts x 8 samples, mean completion 4,096 tokens (cap 16,384): 524k
generated tokens per iteration.
- Generation at vLLM-class throughput (about 876 tok/s end to end, the vLLM figure quoted in `docs/P3_ENGINE.md`): about 600 s. At the
pure-torch engine's modelled tp=4 rate (218 to 386 tok/s, rows 3 to 5 of the table in 5.4-C; **[A2]** the first draft's low end,
100 tok/s, came from a tp=1 batch of 16 that does not fit in memory, see 5.4-C): 1,360 to 2,400 s.
- Training on the same tokens, LoRA with activation checkpointing: body forward `2 x 6.95e9 x tokens` (the 36 blocks hold 6.95 B
parameters [calc]); with recompute and a dX-only backward, about 3x forward = 41.7 GFLOP/token; 524k tokens = 21.8 PFLOP;
on 3 train GPUs at 45 TFLOP/s with no comm: about 160 s. FSDP over host shared memory is comm-bound on this box
(`docs/MEASUREMENTS.md` section (h), 92% of a micro-step), so 160 s is a floor.
Reading: with the native engine, generation is about 8x to 15x the training body compute (the 160 s above excludes the head,
which adds about 35 s on 3 GPUs: 32 micro-batches x 3.3 s / 3, see 5.4-A); even against vLLM it is the larger term. The
decode path is where a kernel changes the iteration time; the head is a second-order term. This ordering drives 5.5.
### 5.3 What exists
- Triton: `kernels/flash_attention.py` (FA2 forward and backward, adapted from the user's `Flash-Attention-101/triton/flash_attention.py`,
kept as `_fa2_reference_upload.py`), `kernels/ops.py` (RMSNorm/RoPE/SwiGLU, pedagogy, off by default). The FA2 backend is not
selectable at HEAD: no gate artifact at the gate path (REFERENCE_GAP_ANALYSIS 1.1 item 9); SDPA is the working backend.
- **Not Triton, despite the name:** `kernels/fused_ce.py` and `kernels/fused_dpo.py` are chunked `torch.autograd.Function`s. They
implement the Liger FusedLinearCrossEntropy design (arXiv:2410.10989, "FusedLinearCrossEntropy" paragraph, Eq. 15-17: chunk the
head, write `dh = W dlogits` and `dW += h dlogits^T` during forward) in torch matmuls.
- `rollout/paged.py::TritonPagedAttention` is an interface with `raise NotImplementedError`. Its docstring already states the
kernel contract, including bit-invariance ("fixed KV-split count per context length, no atomics").
### 5.4 Candidates
#### A. Fused head + policy-gradient token loss (with logprob gather)
**Today** (`rl/cispo.py::fp32_head_logprobs`, `:202`): per chunk of 2,048 tokens, `h.float() @ W32.T` in strict fp32, `logsumexp`,
`gather`; `torch.utils.checkpoint` per chunk, so backward recomputes the GEMM; a separate no-grad pass for entropy.
**GEMM count** at `2*H*V = 1.245 GFLOP/token` (20.4 TFLOP per 16,384-token micro-batch [calc]), frozen head (LoRA):
| Path | GEMMs | Arithmetic | Time per 16k tokens |
|---|---|---|---|
| Today: fwd + checkpoint recompute + `dh` + entropy pass | 4, strict fp32 | 4 x 20.4 / 25 | **3.3 s** (2.6 to 4.1 for 31 to 20 TFLOP/s) |
| Fused: fwd with `dlogits` formed in-chunk, then `dh = dlogits @ W`; entropy from the same sweep | 2, bf16 operands, fp32 output | 2 x 20.4 / 50 | **0.82 s** (0.68 to 0.91 for 60 to 45) |
Ratio about 4x [calc, with the assumed fp32 rate]. If the head is trainable (full FT), add one `dW` GEMM to each side
(5 vs 3): the ratio is the same.
**Why the in-chunk form works for this loss.** For CISPO the per-token gradient **of the objective J** with respect to the logit vector is
`c_t (softmax(z) - onehot(y))` with `c_t = -A_t * sg(clip(r_t, ., 1+eps_high))` (CISPO Eq. 4-5, arXiv:2506.13585 Sec. 3.1;
the weight is a stop-gradient constant). **[A2]** The sign is that of the ascent direction: the loss `-J` that the optimizer
minimises has `c_t = +A_t * sg(clip(r_t, ., 1+eps_high))`. Checked in fp64 autograd on a 11-way softmax: both signs reproduce
the autograd gradient to 7e-18, and the two differ only by the overall sign. The kernel must use the loss sign. `r_t` needs the new logprob, which the same chunk has just computed, so `c_t` is known
before `dlogits` is formed. The KL term adds a closed-form function of the same logprob. This is exactly the structure of
`fused_ce._FusedLinearCrossEntropy` (gradient written in forward, backward is a scalar rescale), extended with the token
coefficient `c_t`. For the clipped-surrogate objectives (DAPO/A3) `c_t = -A_t r_t 1[not clipped]`, also known in-chunk.
**The precision insight (the real reason the fp32 head matters).** MiniMax (arXiv:2506.13585 Sec. 3.2) attribute the
train/inference probability mismatch to "high-magnitude activations in the LM head" and fix it with an FP32 head (correlation
about 0.987 to 0.997, their Fig. 3). In align the head weights and the final hidden state are bf16. A bf16 product is exact in
fp32 (8+8 mantissa bits < 24), and both a bf16 tensor-core GEMM and an fp32 GEMM accumulate in fp32. So an "fp32 head" built
from bf16 operands differs from a bf16 GEMM **only in whether the output logits are rounded to bf16**. At `|z| ~ 16..32` the bf16
spacing is 0.125, so a logit is off by up to 0.06 and so is a logprob.
[cpu-probe] N=512, H=1024, V=16,384, random bf16 `h` and `W` with logits up to `|z| = 50.6`; fp64 reference of the same bf16
operands. fp32-head max abs logprob error **7.1e-6**; bf16-output matmul max abs logprob error **0.145** (mean 0.049). That is
the whole effect, reproduced on CPU, with operands identical in both arms.
Consequence: the head does not need fp32 *compute*; it needs fp32 *output*. `torch.mm(a_bf16, b_bf16, out_dtype=torch.float32)`
exists in torch 2.11 (the op `aten::mm.dtype` is registered; on CPU it reports "could not run on the CPU backend", so it is
CUDA-only and **unverified on a GPU here**). The current `fused_ce.py:` line `logits = torch.matmul(h_chunk, weight.t()).to(cdtype)`
rounds to bf16 first and upcasts after, so the SFT loss carries this rounding noise too (harmless for SFT gradients, which are
unbiased in aggregate; it matters for ratios). The same trick makes the P4 fp32 head about 3x to 4x cheaper than strict-fp32
and also saves the fp32 weight copy (`weight.float()` is 2.49 GB, against 1.25 GB bf16 [calc]) on a 23 GB card.
Caveat to test on GPU: tensor-core accumulation is not IEEE fp32 (reduced alignment and truncation inside the MMA). The size of
that error against bf16 output rounding must be measured with the probe above run on CUDA.
**[A2] The backward GEMM is not "free precision" either.** The second fused GEMM, `dh = dlogits @ W`, only runs at the bf16
tensor-core rate if `dlogits` is cast to bf16 first (a strict-fp32 `dlogits @ W` is exactly the cost being removed). The forward
logprob keeps the 7e-6 accuracy above, but `dh` does not. [cpu-probe] N=512, H=1024, V=16,384, random bf16 `h`, `W`, token
coefficients `c_t ~ N(0,1)`: relative L2 error of `dh` against fp64 is **3e-7** with an fp32 `dlogits` and **1.8e-3** with a
bf16-rounded `dlogits` (max abs 1.8e-3 against `max|dh|` 0.68). That is ordinary bf16 backward noise, but it means the fused
path is not bit-comparable with the strict-fp32 path, the M5 oracle test needs a tolerance of about 5e-3 relative on `dh` (and
1e-4 on logprobs), and "fp32 head" in the P4 pre-registration should be read as "fp32 logits", not "fp32 gradient". The cast adds one
read and one write of the tile (about 1.9 GB per 2,048-row chunk, 25 ms per 16k tokens), already inside the 10% to 16% range below.
**Share of a training step.** Body about 3 x 228 TFLOP at 45 TFLOP/s is about 15 s per 16,384-token micro-batch [calc]; head 3.3 s
today versus 0.82 s fused: a saving of 2.5 s of about 18.3 s, **about 13% of the training half of an RL iteration**, and about
**[A2]** 1% to 3% of the iteration end to end (32 micro-batches x 2.5 s / 3 GPUs = 27 s against an iteration of about 1,550 to 2,600 s
with the native engine, about 800 s against vLLM; 5.2). It is worth doing because it is cheap and also removes 1.2 GB of resident memory, not because
it moves the iteration.
**Is Triton needed?** No. Elementwise traffic per 2,048-row chunk: fp32 logits are 1.245 GB; today about 8 passes (GEMM write,
`logsumexp` read, recompute write and read, softmax write and read, `dlogits` read) is 10 GB, or 16.6 ms at 600 GB/s,
133 ms per 16k tokens [calc]. Against today's 3.3 s that is 4%. **[A2] The denominator that matters for the Triton decision is
the fused path's, not today's.** After A the head costs 0.82 s, and its elementwise tail is about 7 passes (logits write,
`logsumexp` read, softmax read, `dlogits` write, bf16 cast read and write, `dh` GEMM read, about 7.5 GB per chunk, 100 ms per 16k
tokens): **10% to 16% of the fused head**, 0.5% to 0.7% of the 18.3 s training step. A 3x faster fused epilogue (the Liger
figure below) would recover about two thirds of that, at most 0.07 s per micro-batch. The conclusion (do not write Triton for it)
stands, but at the stated 4% the reasoning understated the residual by 3x. Fusing it (Liger reports a 3x faster *cross-entropy kernel* in isolation,
arXiv:2410.10989 Fig. 2a and 3a, at vocab 163,840, benchmarking the kernel alone with the GEMM excluded) would recover at most about 0.1 s per micro-batch. AgileRL's
own note that its Liger GRPO kernel gives no speedup (1.2) is the same fact. Ship it as a chunked `autograd.Function`
(`kernels/fused_policy_loss.py`), tested against `fp32_head_logprobs` and an fp64 oracle.
#### B. fp32-head `logsumexp` as a standalone kernel
Does not pay. After A it is an epilogue over a `[chunk, V]` fp32 tile, about 10% to 16% of the fused head's time (4% of today's), and a Triton GEMM on sm_86
(99 KB shared memory, `docs/MEASUREMENTS.md:124`) is unlikely to beat cuBLAS on these shapes. The same document reached the same
conclusion for the grouped-GEMM kernel (P1-12 fails its own entry condition). The only reason to write it later is to avoid
materialising the `[chunk, V]` tile at all (memory, not time), and chunk size already bounds that: at 512 rows the tile is 311 MB.
#### C. Paged-attention decode (the one that pays)
**Today** (`rollout/paged.py::TorchPagedAttention`, `'segment'` mode, read above): for every decode segment, every layer:
`cache.gather` copies K and V into a contiguous strip (read L + write L), a second copy into a zero-filled padded buffer
(zero fill `L_max`, copy read L + write L), then one SDPA over `L_max` with a boolean mask. Per sequence per layer per tensor
(K or V) the traffic is about `4L + 2 L_max`, against `L` for a fused kernel that reads blocks once through the block table.
With `rho = L_max / L_mean`, the multiplier is `4 + 2 rho`: 8x at `rho = 2`. The length distribution of a prover RL batch is
heavy-tailed (most stop in a few thousand tokens, a few reach the 16k to 40k cap, `rollout/paged.py` docstring), so `rho >= 2` is
the normal case, and `docs/P3_ENGINE.md` already flags the padded transient (1.17 GiB per layer call at 64 sequences, 4.7 GiB if SDPA
falls back to the math backend with GQA expansion).
On top of bytes there is launch overhead: about 12 eager ops per segment per layer is `B x 36 x 12` launches per step
(27,648 at B = 64), about 166 ms at 6 us [assumed]; the GPU is idle while Python issues them.
Model (all [calc] from 5.1; `tp` as in `docs/P3_ENGINE.md`; comm term is that doc's assumed 20 to 26 ms for B = 64 at tp = 4 and
about 10 ms for B = 16; fused kernel at 85% of HBM peak [assumed]; step = max(GPU time, CPU launch time) for today):
| Setting | KV tokens (capacity) | Weights ms | KV ideal ms | Today step ms (tok/s) | Fused step ms (tok/s) | Speedup |
|---|---|---|---|---|---|---|
| tp=1, B=6, ctx 4,096, rho=2 | 24,576 (26,192) | 27.3 | 6.0 | 76 (79) | 34 (174) | 2.2x |
| tp=4, B=16, ctx 4,096, rho=2 | 65,536 (438,048) | 6.8 | 4.0 | 49 (326) | 22 (743) | 2.3x |
| tp=4, B=64, ctx 4,096, rho=2 | 262,144 (438,048) | 6.8 | 16.1 | 166 (386) | 52 (1,240) | 3.2x |
| tp=4, B=48, ctx 8,192, rho=2 | 393,216 (438,048) | 6.8 | 24.2 | 220 (218) | 55 (870) | 4.0x |
**[A2] Rows removed from the first draft because they do not fit in the card.** The first draft listed tp=1 B=16 at ctx 4,096 and
8,192 (3.4x and 4.4x) and tp=4 B=64 at ctx 8,192 (4.2x). The KV pool the native engine can allocate is in `docs/P3_ENGINE.md`
("Native engine, bf16 KV" table): 26,192 tokens at tp=1 (weights 15.26 GiB leave 3.60 GiB), 163,472 at tp=2, 438,048 at tp=4 (util
0.90). tp=1 B=16 at ctx 4,096 needs 65,536 tokens (2.5x over) and at 8,192 needs 131,072 (5x over); tp=4 B=64 at 8,192 needs
524,288 (1.2x over). Those speedups were computed correctly for the stated inputs but describe a batch the engine cannot hold, and
the 3.4x to 4.4x figures were the top of the quoted range. Replacement rows use the largest batch that fits (B=6 at tp=1, B=48 at
tp=4 with ctx 8,192; 20 ms comm for B=48). Row arithmetic, checked: KV ideal = tokens x 147,456 B / tp / 600 GB/s; today = weights
+ (4+2rho) x KV ideal + comm (launch-bound at B=64: 27,648 x 6 us = 166 ms); fused = weights + KV ideal / 0.85 + comm.
Expected: **2x to 4x on decode steps at the largest batch that fits (6 to 64) and rho about 2**; larger with a larger `rho` or longer context; at batch 1
the step is weight-bound and the kernel changes little (it still removes the launch overhead, 2.6 ms vs 27 ms). Amdahl: this
speeds the generation half, which 5.2 shows is the larger term. The bar in `docs/P3_ENGINE.md` (native at least 0.5x vLLM
throughput) is the acceptance test, and the stated fallback there ("the Triton kernel is the next step, not TP") is consistent
with this analysis. The vLLM kernels in the PagedAttention paper (arXiv:2309.06180 Sec. 5.1, "Kernel-level Optimization") are three fusions:
fused reshape-and-block-write ("to minimize kernel launch overheads"), fused block read and attention (adapted from the FasterTransformer
attention kernel, reading the KV cache through the block table), and fused block copy.
**Design constraint from RL, not performance:** the per-row key reduction must not depend on batch composition (the
`invariant` mode of `TorchPagedAttention`, `rollout/paged.py`). Use a split-KV (flash-decoding style) kernel with a **fixed split
size per context length** (e.g. 512 keys per split, so the split count is `ceil(L / 512)`, a function of `L` only), a second
reduce kernel in a fixed order, no atomics. That keeps `rho` (the train/infer logprob ratio) unaffected by who shares the batch.
It is the user's FA2 forward loop (`Flash-Attention-101/triton/flash_attention.py`, online softmax per KV block) with `Lq = 1`,
the KV loop split across programs, and the block table as an extra indirection; the numbered-note house style applies
(one note on split-KV online softmax merging, one on block-table indexing, each with a notebook that checks the kernel against
the naive version).
**Validation plan (needs free GPUs):** (1) bitwise: same sequence, run alone and inside batches of 2, 16, 64 with different
neighbours: identical logits. (2) numeric: against `TorchPagedAttention(mode="row")` at bf16, max abs logit error stated.
(3) throughput: `tests/test_paged_engine_bench.py --gpu` with the same prompt ids as the vLLM manifest. (4) peak memory with
`--reserve-gib` reduced, since the padded transient goes away.
#### D. Candidates that do not pay
| Candidate | Estimate | Verdict |
|---|---|---|
| Per-token CISPO/GRPO loss arithmetic alone | tensors of `B x T` fp32: 64 KB at 16k tokens; about 10 elementwise ops is below 1 MB of traffic, about 50 us of launch time against a multi-second step | Do not write a kernel |
| `adamw8bit` Triton step (open item in DEPENDENCY_POLICY) | ideal about 18 GB of traffic for 1.8 B local elements [calc, about 10 B/element] = 30 ms; runs once per optimizer step, and a step is 8 to 85 micro-steps of seconds each | Under 1% of a step. No |
| Fused LoRA add (`W x + B A x`) | one saved read+write of each `[T, out]` bf16 activation per projection: 2.55 GB per layer at 16k tokens = 4.3 ms, 153 ms per forward over 36 layers [calc] | About 1% of a 15 s body pass; no |
| FA2 Triton as default | Gate exists and is not enabled (no artifact at the gate path); attention is about 4% of layer FLOPs at seq 1k (`kernels/fused_ce.py` docstring), more at 16k | Finish the equivalence gate first; a measurement task, not a kernel task |
| RMSNorm/RoPE/SwiGLU Triton (`kernels/ops.py`) | kept flag-gated and off; `torch.compile` fuses them | Keep as pedagogy |
### 5.5 Ranking
1. **C, paged-attention decode.** About 2x to 4x on decode (3x central), on the largest term. 8 days including GPU tuning. Needs free GPUs.
2. **A, fused head + policy loss as an `autograd.Function` with `out_dtype=float32`.** About 4x on the head, about 13% of the
training half, 1.2 GB less resident memory, and it fixes the SFT output-rounding as a side effect. 3 days plus 0.5 day GPU.
No Triton.
3. Everything else in 5.4-D: no.
---
## 6. Open questions for the owner
1. **Name.** `align` is a common PyPI name. An open-source release needs a distinct distribution name; the import name can stay.
Renaming `Nmo*` (38 files) is mechanical; do you want the old names kept as aliases for one release?
2. **Licence.** The repo has no LICENSE. AgileRL is Apache-2.0, Liger-Kernel is BSD-2-Clause, open-instruct (vendored GRPO loss,
`rl/grpo_loss.py`) is Apache-2.0: Apache-2.0 for align is compatible. Confirm.
**[A2]** The vendored open-instruct code (`rl/__init__.py` says "vendored verbatim") also needs its upstream copyright
notices kept in the files and a `NOTICE` entry before release; M8 does not list that, add it.
3. **Is `expert_iteration` an algorithm or a recipe for you?** The proposal makes it an `OuterLoop` with its own small interface
(2.3). If you would rather keep it a script, drop it from the registry and keep only `recipes.sft` and `recipes.eval`.
4. **A10G constants.** Section 5 rests on 600 GB/s and a 25 TFLOP/s sustained fp32 rate, both assumed, plus 6 us eager launch
cost. Three 2-minute microbenchmarks (a bandwidth copy, an fp32 and a bf16 GEMM at the head shape, an empty-op loop) on a free
GPU would turn every [assumed] in 5.1 into a measurement. They are GPU jobs and must take the heavy-job lock.
5. **`mm(out_dtype=float32)` on CUDA.** Not runnable here. If it is absent or slow for the head shape the fallback is a Triton
GEMM with fp32 epilogue, which moves A from 3 days to about 6 and changes the 5.5 ranking only slightly.
6. **Tensor-core accumulation error versus bf16 output rounding** for the head (5.4-A caveat): needs the CPU probe repeated on a GPU.
7. **Test layout.** AgileRL mirrors the package in `tests/`. This proposal keeps the 121 flat files and adds `tests/contract/`.
Mirroring would change documented `pytest tests/test_x.py` commands in several docs; say if you want it.
8. **Which `[algo]` name for GSPO-style trajectory ratios.** Not implemented in align. Add it in M1 or leave it out until a run needs it?
9. **`inference/engine.py` (contiguous KV) versus `PagedEngine`.** After M6 the paged engine can serve eval too, and
`inference/` could be deleted (962 + 202 + 220 lines). Not proposed now because eval receipts and golden regression depend on its exact numerics.
10. **Test baseline.** README says 1608 passed, DEPENDENCY_POLICY 1089, REFERENCE_GAP_ANALYSIS 1107. M0 resolves it; until then no
"stays green" claim here has a number behind it.
11. **[A2] Eval hash epoch.** Moving `Outcome` into `verify/` and `outcome_agreement.py` into `eval/`, and the M8 edit of
`eval/golden.py`, change `eval_source_hash` and invalidate every gate ledger, so each run dir resumed afterwards needs a golden
re-run (4.5). Accept one re-run at M7/M8, or take the alternative that keeps `align/eval/` byte-identical?
12. **[A2] Chain manifest.** The freeze closure is only as complete as the list of entry points. Should the repo carry
`tests/frozen_entrypoints.txt`, maintained by whoever writes a new `.tmp/p2/r1/chain*.sh`, or should the closure scan parse the
chain scripts directly?
13. **[A2] `CodeEnv` binaries.** `prlimit` and whatever interpreter runs candidate code are external binaries; DEPENDENCY_POLICY
requires a dated entry for those (as for Lean, 2026-10-12). Which languages should `CodeEnv` support first?
|