Skip to content

chore: merge upstream PrimeIntellect-ai/prime-rl main - #2

Merged
mannuch merged 38 commits into
mainfrom
chore/merge-upstream-2026-10-10
Oct 11, 2026
Merged

mannuch merged 38 commits into
mainfrom
chore/merge-upstream-2026-10-10

Conversation

@mannuch

@mannuch mannuch commented Oct 11, 2026

Copy link
Copy Markdown
Owner

Merges 37 new commits from PrimeIntellect-ai/prime-rl main (through 73140de, PrimeIntellect-ai#4011) into the fork.

Notable upstream changes:

Conflict resolution

  • docs/training.md: in the [orchestrator.train.algo] row, kept the fork's temporal_grpo entry and added upstream's new trainer.model.freeze_moe_router row.

The temporal_grpo code merged cleanly. uv run pytest tests/unit/orchestrator/test_temporal_grpo.py tests/unit/orchestrator/test_algorithms.py passes on the merged tree (37 passed).

🤖 Generated with Claude Code

faresobeid and others added 30 commits October 7, 2026 19:15
…3797)

Make `trainer.model.freeze_moe_router` default to on for RL.

## What changes
- `model.freeze_moe_router` is now `bool | "auto"`, default `"auto"`. It
resolves to `True` on the RL `TrainerConfig` and to `False` on
`SFTConfig`, following the per-trainer pattern of `moe_router_dtype`. An
explicit `true`/`false` still wins.
- `freeze_moe_router()` no longer raises on non-MoE models; it does
nothing, so dense RL runs keep working with the new default.
- `docs/training.md` knobs table lists the field.

## Why
In RL, router updates amplify the train/inference routing mismatch: the
trainer's expert choices drift away from the ones inference sampled. So
RL keeps the pretrained router by default. SFT is unchanged.

What gets frozen: the router gate parameters (`mlp.router.gate.weight`,
plus `gate.bias` on gpt-oss). The aux-loss-free `selection_bias` buffer
is not a parameter, and nothing on main updates it. Shared-expert gates
(Qwen3.5) and Nemotron-H latent projections are not touched. Frozen
routers get no optimizer state.

## Breaking changes
- RL runs on MoE models no longer train the router. All MoE RL examples
are affected (glm-5.3, glm-4.5-air, intellect-3.1, qwen3-30b-a3b,
minimax-m2.5, nemotron-3-super). Set `trainer.model.freeze_moe_router =
false` for the old behavior.
- An explicit `freeze_moe_router = true` on a non-MoE model is now a
no-op instead of an error.
- Resuming: a checkpoint saved before this change (trainable router)
loads into a frozen-router run; the extra router optimizer state is
ignored. The reverse, resuming a frozen-router checkpoint with
`freeze_moe_router = false`, fails on missing DCP keys (noted in the
field docstring).
- With LoRA, a router listed in `lora.modules_to_save` is frozen again
by the new default unless `freeze_moe_router = false` is set.

## GPU check
One node (8×H200), FSDP over 8 ranks, `samsja/mini-glm-moe` (23 MoE
layers), SFT with fake data, `freeze_moe_router = true`,
`moe_router_dtype = "float32"` (so each router is its own fp32 FSDP unit
whose params are all frozen), 5 steps with a checkpoint every step:
- Log: "Froze 23 MoE router parameters", "Running 23 MoE router gates in
fp32"; all 5 steps ran with finite loss and grad norm.
- DCP checkpoints step 1 vs step 5: all 23 fp32 router gate weights are
bitwise unchanged; expert weights changed; no optimizer state exists for
any router parameter.
…eIntellect-ai#3843)

## Summary
Add `iter_moe_blocks(model)` in `trainer/model.py`. It yields the `MoE`
MLP of each decoder layer. It replaces the five copies of the
decoder-layer MoE lookup in `freeze_moe_router`,
`apply_fp32_moe_router`, `get_full_offload_dtype_policy`,
`apply_force_balanced_routing` and `get_load_balance_stats`.

## What changed
- New `iter_moe_blocks` next to the helpers that use it. The helpers
stay in `model.py`, so no imports change.
- The four `layer.mlp if hasattr(...) else layer.feed_forward ...`
lookups and the `getattr(mlp)` + `hasattr(tokens_per_expert)` scan in
`get_load_balance_stats` now call `iter_moe_blocks`.
- Removed the stale "HF impl" comment in `get_expert_load_stats`. The HF
fallback was removed in PrimeIntellect-ai#3773.

## Behavior change
None. The `feed_forward` fallback is gone. Every prime-rl decoder layer
uses `mlp`. `get_load_balance_stats` already looked only at `mlp`. Only
`MoE` registers `tokens_per_expert`, so it selects the same layers as
before.

## Checks
- CPU equivalence vs `main` (throwaway script, not in the repo). Tiny
Qwen3-MoE (`mlp_only_layers=[1]`) and GLM4-MoE
(`first_k_dense_replace=1`) models with fixed router-count buffers.
Compared `is_tt_moe_model`, `get_load_balance_stats`,
`get_global_moe_stats`, `compute_expert_load_stats`,
`get_expert_load_stats` and `get_full_offload_dtype_policy`, plus
parameter dtype and `requires_grad`, and router
`fp32_gate`/`force_balanced` after the three router helpers. All 16
outputs were bit-identical.
- `pytest tests/unit/train tests/unit/orchestrator
tests/unit/test_configs* -m "not gpu"`: 450 passed, 1 failed. The
failure is
`test_cpu_adam.py::test_native_cpu_adamw_matches_fused_torch_and_preserves_gradients`
(ninja build), which fails the same way on `main`.
- `ruff check` and `ruff format --check`.
- GPU: reverse-text MoE RL smoke vs `main` on one node, running.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Mechanical refactor of MoE traversal with no intended behavior change;
only risk is if a model relied on the removed feed_forward path (none in
this codebase).
> 
> **Overview**
> Introduces **`iter_moe_blocks`**, a single iterator over decoder
layers whose `mlp` is a **`MoE`**, and rewires MoE
router/offload/metrics helpers to use it instead of repeated per-layer
`mlp` / `feed_forward` lookups.
> 
> **`freeze_moe_router`**, **`apply_fp32_moe_router`**,
**`get_full_offload_dtype_policy`**, **`apply_force_balanced_routing`**,
and **`get_load_balance_stats`** now loop over that iterator; the unused
**`feed_forward`** fallback is removed. **`get_expert_load_stats`** only
drops an outdated comment on the empty tensor guard.
> 
> Intended as a no-op for supported models: same router freezing, fp32
gates, FSDP dtype policy, debug balanced routing, and load-balance /
expert-load metrics.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
2cf0399. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
… and activation checkpointing (PrimeIntellect-ai#3925)

With `moe_router_dtype="float32"` (the RL default) and activation
checkpointing on, every MoE block ran eager under `torch.compile`. Two
things combine to cause it:

- The fp32 router is its own FSDP2 unit, and FSDP2 hooks are
dynamo-disabled, so the router always breaks the graph.
- Under `compile(AC(block))` the checkpoint compiles as a higher-order
op, which forbids breaks, so Dynamo ran the whole block eagerly.

This PR compiles inside the checkpoint instead: for any checkpointed
block that holds a nested FSDP unit (today, the fp32 router), an eager
checkpoint now wraps the compiled block (`AC(compile(block))`), so the
nested unit's break only splits the block into compiled graphs. To see
those units, compile now runs after FSDP. Compiling in place keeps the
same module object, so the order changes nothing at runtime.

The reorder is safe. The old `AC -> Compile -> FSDP` comment was ported
from TorchTitan in PrimeIntellect-ai#923, when `apply_compile` still wrapped each block
with `torch.compile(block)`. PrimeIntellect-ai#957 switched to in-place `.compile()` two
days later without revisiting it. With in-place compile, both orders
trace the same frames: graph breaks, loss and step time match on both
workloads, and `fullgraph=True` (with a bf16 router) still compiles with
zero graph breaks.

| Workload (8x H200, EP 8, full AC) | before (s/step) | after (s/step) |
MFU |
|---|---|---|---|
| GLM-4.5-Air, 16 layers, real data | 6.21, 6.17 | 5.22, 5.21 | 35% to
42% |
| Qwen3-30B-A3B, fake data | 5.96, 5.96 | 4.51, 4.51 | 32% to 42% |

Two runs per arm, each the median `time/step` over steps 5 to 20, no
offloading. Peak memory is unchanged, and loss and grad norm track the
baseline within run-to-run noise.

![Qwen3-30B-A3B MoE layer forward, before and
after](https://raw.githubusercontent.com/PrimeIntellect-ai/prime-rl/09e64ed1afc27e97fda6080556171522e29c9a0e/pr-figures/fp32-router-compile/qwen_layer_forward.png)

**Figure:** one MoE layer's forward on Qwen3-30B-A3B, before and after
this PR. The orange eager elementwise kernels collapse into a few pink
compiled Triton kernels, so the layer's forward drops from 16.4 to 11.3
ms.

<details><summary>Same view on GLM-4.5-Air</summary>

![GLM-4.5-Air MoE layer forward, before and
after](https://raw.githubusercontent.com/PrimeIntellect-ai/prime-rl/09e64ed1afc27e97fda6080556171522e29c9a0e/pr-figures/fp32-router-compile/glm_layer_forward.png)

</details>

## Alternative considered

Keeping recompute inside the compiler: drop the block-level checkpoint
and instead checkpoint two compiled regions that exclude the router, the
attention module and the post-router MoE work. It beat the baseline but
was slower than this PR (Qwen3-30B 5.50 vs 4.51 s/step, GLM-4.5-Air 5.85
vs 5.22 s/step) and used more memory, because the norms and the router's
fp32 activations stay outside any checkpoint.

## North star: fullgraph fp32 router support

Fullgraph compile with an fp32 router stays unsupported, and
`model.compile.fullgraph=true` still raises for it. The blocker is the
router's FSDP unit itself: FSDP2 applies one mixed-precision policy per
unit, so the fp32 router cannot share the block's bf16 unit, and its
dynamo-disabled hooks are the graph break above. PyTorch
[#196626](pytorch/pytorch#196626) adds
per-parameter mixed precision, which lets the router stay in the block's
unit. That removes the break, enables fullgraph, and makes this PR's
compile-inside-AC path unnecessary, so it should be reverted then.
#196626 is on PyTorch `main` but not yet in a release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
## Summary

Raises the `prime-runs[train]` floor from `>=0.1.3` to `>=0.1.4` and
updates `uv.lock` to match.

## Why

`prime-runs` 0.1.4 lets a run opt out of Prime Traces and upload its
samples to the legacy sample tables instead
(PrimeIntellect-ai/prime#998):

- `PRIME_TRACES_OPT_OUT=true`
- `prime config set-traces-opt-out true`, which sets `traces_opt_out` in
`~/.prime/config.json`
- `prime_runs.init(traces_opt_out=True)`

`prime-runs` 0.1.3 doesn't know about any of these. With 0.1.3
installed, which is what `uv sync` gives today, a user who sets the
environment variable or the config key gets no error and no warning, and
their runs keep uploading to Prime Traces. Requiring 0.1.4 makes the
setting take effect.

Nothing changes for runs that don't opt out. The Prime monitor calls
`prime_runs.init()` without the new argument, so the default (upload to
Prime Traces) is unchanged.

A training run that opts out needs pyarrow for the legacy sample
encoder. prime-rl already depends on `prime-runs[train]`, which includes
it.

## Changes

- `pyproject.toml`: `prime-runs[train]>=0.1.3` →
`prime-runs[train]>=0.1.4`.
- `uv.lock`: `prime-runs` 0.1.3 → 0.1.4. No other package moves;
`prime-traces` stays at 0.0.7.

The `deps/verifiers` submodule is not changed. Its own floor is raised
separately in PrimeIntellect-ai/verifiers#2805, and this PR doesn't
depend on it.

`uv lock --check` passes. I did not run the test suite.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Dependency floor bump only; no application code changes and default
trace upload behavior stays the same.
> 
> **Overview**
> Raises the **`prime-runs[train]`** minimum from **`>=0.1.3`** to
**`>=0.1.4`** in `pyproject.toml` and pins **`prime-runs` 0.1.4** in
`uv.lock`.
> 
> This ensures installs pick up **Prime Traces opt-out** behavior
(`PRIME_TRACES_OPT_OUT`, `prime config set-traces-opt-out`, or
`prime_runs.init(traces_opt_out=True)`), which 0.1.3 silently ignored so
opted-out runs could still upload to Prime Traces. Default runs that do
not opt out are unchanged; `prime-rl` still depends on the **`[train]`**
extra for legacy sample encoding when opting out.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
06caa96. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…training (PrimeIntellect-ai#3809)

Keep successful empty environment outcomes in evaluation results. A
trace-less episode or clean zero-turn trace retains the verdict returned
by Verifiers, while training and curriculum logic exclude zero-turn
traces.

## Before

The dispatcher changed successful trace-less episodes into
`EmptyEpisode` failures. Clean, non-timeout zero-turn traces became
`EmptyTrajectory` failures, marking both the trace and episode
unsuccessful. Evaluation therefore recorded a failure even when the
environment had returned success, while training and curriculum filters
relied on those injected errors to exclude empty traces.

```mermaid
flowchart TD
    Env["Verifiers returns an empty successful outcome"] --> Dispatch["Prime-RL dispatcher"]
    Dispatch --> Episode["No traces: inject EmptyEpisode"]
    Dispatch --> Trace["Clean zero-turn trace: inject EmptyTrajectory"]
    Episode --> Failed["Rewrite outcome as failed"]
    Trace --> Failed
    Failed --> Eval["Evaluation records a failure"]
    Failed --> Training["Training and curriculum filter injected errors"]
```

## After

```mermaid
flowchart TD
    Env["Verifiers environment returns an episode"] --> Dispatch["Prime-RL dispatcher preserves empty outcome verdicts"]
    Dispatch --> Eval["Evaluation records and metrics"]
    Dispatch --> Filter["Training trace eligibility"]
    Filter --> Empty["Zero turns: no training sample"]
    Filter --> Sample["Eligible trace with sampled tokens: training sample"]
    Dispatch --> Curriculum["Curriculum ignores zero-turn traces and admits only trainable groups"]
```

Remove the synthetic `EmptyEpisode` and `EmptyTrajectory` errors.
`iter_trainable_traces()` filters empty traces alongside failed and
untrainable traces before advantages are assigned, so the advantage gate
needs no separate zero-turn check. Difficulty sampling also excludes
zero-turn traces. Empty groups finish without producing a trainer
payload, and mixed groups retain their eligible traces. Curriculum
admission also requires a trainable trace, so groups without training
samples cannot inflate admission metrics. Sampler observations of
completed turns are retained even when token metadata is unavailable.
Verifiers' no-agent validation and training timeout handling remain in
place.

[PrimeIntellect-ai#3824](PrimeIntellect-ai#3824) applies
the same ownership rule to partially failed episodes: preserve
environment results, then decide training eligibility explicitly.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes evaluation outcome recording and which rollouts feed
training/curriculum admission, which can shift eval metrics and training
batch composition without touching auth or data stores.
> 
> **Overview**
> Stops the dispatcher from **injecting** `EmptyEpisode` /
`EmptyTrajectory` errors on successful trace-less or zero-turn outcomes,
so **evaluation keeps Verifiers’ success verdict** instead of recording
synthetic failures.
> 
> Training and curriculum now **exclude zero-turn traces explicitly**:
`iter_trainable_traces()` skips `num_turns == 0`, difficulty-pool reward
updates require `num_turns > 0`, and curriculum admission requires
**both** passing gates **and** at least one trainable trace via
`iter_trainable_traces()`. Scored rollouts without token/advantage
metadata can still update sampler difficulty; groups with no trainable
samples no longer count as admitted.
> 
> Tests cover mixed groups with non-trainable traces, empty-only groups,
and reward-only observations for the sampler.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
8582fb8. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…meIntellect-ai#3825)

Apply a policy-version cache salt only when inference weights can change
during a run. Standalone evaluations and frozen-model requests omit the
training salt; live RL rollouts and online evaluation retain it so
caches distinguish policy versions.

## Before

The dispatcher added the policy version as `cache_salt` to every request
using the live policy source. Standalone evaluation used that same
source path, so fixed-weight evaluations received a training cache salt
even though no weight updates occurred. Frozen-model requests already
omitted it.

```mermaid
flowchart TD
    Standalone["Standalone evaluation with fixed weights"] --> Live["Live policy source"]
    Training["RL or online evaluation with changing weights"] --> Live
    Live --> Salt["Always add policy-version cache_salt"]
    Salt --> Request["Verifiers provider request"]
    Frozen["Frozen model source"] --> Plain["Omit training cache_salt"]
    Plain --> Request
```

## After

```mermaid
flowchart TD
    Request["Prime-RL dispatcher builds request sampling"] --> Mode{"Live policy source with changing weights?"}
    Mode -->|"Yes: RL or online evaluation"| Salt["Set cache_salt to policy version"]
    Mode -->|"No: standalone evaluation or frozen model"| Plain["Omit training cache_salt"]
    Salt --> Client["Verifiers client builds provider request"]
    Plain --> Client
    Client --> Provider["Inference provider"]
```

The dispatcher records whether policy weights can change. The standalone
evaluator disables version salting, SFT online evaluation enables it,
and RL orchestration keeps it enabled. The request decision also checks
whether the source uses the live policy.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Narrow behavior change to cache salting on eval paths; RL and online
eval keep prior salting via the default flag.
> 
> **Overview**
> Adds a **`policy_weights_change`** flag on `Dispatcher` so inference
**`cache_salt`** (policy version) is only attached when weights can
actually change during the run.
> 
> **Standalone eval** (`EvalConfig`) sets the flag to **false**, so
fixed-weight evaluation no longer invalidates prefix caches on every
training policy version. **SFT online eval** and **RL orchestration**
(default **true**) still salt live-sourced requests so caches stay
correct across weight updates.
> 
> The cache decision is now **`live_sourced and policy_weights_change`**
instead of salting all live policy paths.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
5a2d7d9. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…ect-ai#3838)

## Summary
Fold `GenerationSource` into `TrainEnv`, its only owner. Move
`connect_frozen_client` from `algo/base.py` to `clients.py`, so `algo`
no longer imports `clients` lazily. Give `TrainEnv` `setup()`/`aclose()`
so orchestrator setup and teardown no longer reach into its parts.

## What changed
- `orchestrator/generation_source.py` is deleted. `TrainEnv` (`envs.py`)
now holds `clients`, `renderer_config`, `connected` (the frozen source
clients it connected) and `uses_live_policy`. Dropping `logprobs` for
frozen sources happens in `TrainEnv.__init__`.
- `TrainEnv.setup()` connects the frozen generation source and runs
`algorithm.setup()` concurrently. `TrainEnv.aclose()` closes `connected`
and `algorithm.connected`, never the shared policy clients.
- `connect_frozen_client` is a plain function in `clients.py`, next to
`check_inference_ready`. `algo/base.py` imports it at module level. It
is no longer re-exported from `prime_rl.orchestrator.algo`.
- `dispatcher.py` reads the `TrainEnv` directly at the four former
`generation_source` call sites. `orchestrator.py` calls `env.setup()`
and `env.aclose()`.
- `docs/algorithms.md` and the `algo/__init__.py` docstrings refer to
`TrainEnv`.

## Behavior change
None.

## Checks run
- `pytest tests/unit/orchestrator tests/unit/test_configs* -m "not
gpu"`: 297 passed.
- `ruff check`, `ruff format --check`: clean.
- Equivalence script, old `GenerationSource` (`origin/main`) vs new
`TrainEnv`, on 4 configs: policy, policy with `top_p=0.9`, sft with a
frozen `sampling.source`, opd with a frozen teacher. Identical
`sampling_args`, `requires_sampling_masks` and `uses_live_policy`. After
`setup()`, the same generation clients (policy object, or the frozen
endpoint with the same train client config) and the same algorithm
clients. Teardown closes the same clients.
- GPU smoke on one node (vLLM 0.31): reverse-text RL (15 steps), sft
with a frozen `sampling.source` (5 steps), opd with a frozen teacher (5
steps). All three ran to "Training finished" and exited 0.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Internal orchestrator refactor with stated behavioral equivalence and
no changes to training algorithms or dispatch semantics beyond
indirection.
> 
> **Overview**
> **Refactors train rollout sourcing** by deleting `GenerationSource`
and moving its responsibilities onto `TrainEnv`: inference `clients`,
live-vs-frozen policy detection (`uses_live_policy`), sampling-arg
tweaks (e.g. dropping `logprobs` for frozen endpoints), and frozen-pool
connection in `setup()`.
> 
> `TrainEnv.setup()` now connects frozen `sampling.source` endpoints and
runs `algorithm.setup()` together; `TrainEnv.aclose()` closes only
env-owned clients (`connected` + algorithm pools), not the shared policy
pool. The orchestrator and dispatcher call these surfaces instead of
reaching into a nested `generation_source`.
> 
> `connect_frozen_client` moves from `algo/base.py` to `clients.py`
(next to `check_inference_ready`); it is no longer re-exported from
`prime_rl.orchestrator.algo`. Docs and package docstrings now describe
`TrainEnv` as owning the sampling half of the algorithm config.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
e89ca1a. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…llect-ai#3877)

Processes that share `~/.triton` on a shared filesystem can hang or
crash (seen with Nemotron-H, where many ranks compile the Mamba Triton
kernels at once; reproduced below with Qwen3.5 and GLM-4.5-Air).

This PR adds `TRITON_CACHE_DIR = /tmp/triton-<uid>` to
`DEFAULT_COMMON_ENV_VARS`. It is per-user because `/tmp` is shared by
all users on a node. Like the other launcher defaults, it reaches every
launched component: the local `rl`/`sft`/`inference` launchers (also
used by the single-node sbatch scripts) and the trainer, orchestrator,
inference (incl. prefill/decode), and online-eval env blocks of the
multi-node and inference sbatch scripts. `[env_vars]` (or a component's
`env_vars`) overrides it; a value exported in the shell or `.env` does
not. It also removes the `TRITON_CACHE_DIR` overrides from six example
configs (glm-4.5-air swe/swe-budget/search/terminal, glm-5.3 and
qwen3-30b-a3b sft h200 base): they pointed every user at the same `/tmp`
dir and are now covered by the default. `docs/scaling.md` says so in one
paragraph.

vLLM 0.31's own default: when `TRITON_CACHE_DIR` is unset, its Inductor
adaptor points it at
`$VLLM_CACHE_ROOT/torch_compile_cache/<hash>/rank_<i>_<j>/triton_cache`
(`~/.cache/vllm` by default, so usually on the shared home). That only
covers Triton kernels compiled after torch.compile initializes, and it
is still on the shared filesystem. vLLM keeps a `TRITON_CACHE_DIR` that
is already set, so with this PR vLLM uses `/tmp/triton-<uid>` too.

### Measured cost (one H200 node, vLLM 0.31 venv, Triton 3.7.1)

Each run starts from a fresh process with the same config. "Ready" is
launch → first trainer step logged (Qwen) or launch → `/health` OK
(GLM). For GLM, the first generated token came 1.3–2 s after ready in
every run that came up. Inductor's cache dir was emptied before each run
except the warm node-local one.

| Triton cache | Qwen3.5-4B SFT, 8 GPUs, compiled blocks (step 1 time) |
GLM-4.5-Air vLLM TP8 |
|---|---|---|
| shared, cold (today on a new cluster / after a Triton or torch update)
| **failed 2/2**: `FileNotFoundError` on a cache file that another rank
was writing (`causal_conv1d_fwd_kernel.llir`, `l2norm_fwd_kernel.ptx`) |
**failed 2/2**: 4 of 8 ranks hit `FileNotFoundError` in the cache during
Inductor autotune, the other 4 hung in a collective until killed (1st
run). The 2nd run crashed instead of hanging. |
| shared, warm | not measured cleanly (see below) | 344 s |
| node-local, cold (this PR, first job on a node) | 233 s (step 1: 91
s), 164 kernels compiled | 375 s with vLLM's compile cache warm (106
kernels); 465 s if vLLM's cache is also cold (87 kernels) |
| node-local, warm (this PR, later jobs on the same node) | 138 s (step
1: 8.3 s) | 344 s |

- A cold node-local cache costs about **95 s per node for Qwen3.5** (FLA
+ Inductor kernels) and about **31 s for GLM-4.5-Air inference** when
vLLM's own compile cache is warm. Warm node-local and warm shared take
the same time.
- The rest of the GLM startup does not depend on the Triton cache: about
3.5 min of Python imports and process spawn from the shared venv, 13 s
to load weights.
- vLLM: `VLLM_CACHE_ROOT` (`~/.cache/vllm`, shared home) holds vLLM's
AOT/Inductor artifacts. With this PR it stays shared. On a new node,
vLLM loads those artifacts (`torch.compile` 0.6 s instead of 30 s), and
the Triton binaries are compiled into `/tmp/triton-<uid>` (106 kernels,
+31 s). `TORCHINDUCTOR_CACHE_DIR` (default `/tmp/torchinductor_<user>`,
already node-local) stayed empty in these runs. Inductor's Triton
kernels go to `TRITON_CACHE_DIR`.
- A crash on the shared cache can also break later starts. After the
first failed GLM run, the shared vLLM cache only had AOT artifacts from
the 4 ranks that got through. On the next start, those 4 ranks loaded
the artifacts and the other 4 recompiled, so the ranks diverged and hung
(Triton cache warm-ish, 60 kernels). For the same reason the Qwen
"shared, warm" run started from a partial cache and failed again. The
warm-shared GLM number comes from a shared dir seeded with a complete
cache.

### Possible middle ground (not implemented)

Because the cold cost is above 1 min per node for FLA-heavy models, a
run could seed the node-local cache from a shared read-only copy at job
start. For example, on each node before the components start:
`[ -n "$TRITON_CACHE_SEED" ] && cp -rn "$TRITON_CACHE_SEED"/.
"$TRITON_CACHE_DIR"/`
The seed would be a warm `/tmp/triton-<uid>` copied once to the shared
FS. Triton keys entries by source, compiler, and GPU, so stale entries
are never reused, only skipped. Only reads hit the shared FS, so the
concurrent-write race does not occur. I left it out to keep this PR
minimal.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Launch-time env default only; behavior is overridable via config and
fixes known multi-rank Triton cache races, with a one-time cold-compile
cost on each node.
> 
> **Overview**
> **Sets a global launcher default** so every trainer, orchestrator, and
inference process gets `TRITON_CACHE_DIR=/tmp/triton-<uid>` via
`DEFAULT_COMMON_ENV_VARS`, avoiding concurrent Triton kernel writes
under `~/.triton` on a shared filesystem (hangs / `FileNotFoundError`
under multi-rank compile).
> 
> **Removes redundant `[env_vars]` overrides** from six GLM-4.5-Air RL
and GLM-5.3 / Qwen3-30B SFT example configs that previously pinned
Triton cache paths under `/tmp`; those runs now inherit the per-user
node-local default (still overridable through config `env_vars`).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f216420. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Signed-off-by: faresobeid <111092724+faresobeid@users.noreply.github.com>
…ool (PrimeIntellect-ai#3858)

## Summary
- Delete `trainer/models/layers/rms_norm.py`. It only re-exported
`RMSNorm`/`RMSNormConfig` from `layers/norms.py`, and nothing imported
it.
- Move `quantize_to_fp8_blockwise` from `trainer/models/fp8.py` into
`tools/convert_bf16_to_fp8.py`, its only user.
`tools/convert_dcp_to_fp8.py` still imports it through
`convert_bf16_to_fp8`. While moving it, the 9-line zero-pad block
becomes `F.pad(weight, (0, pad_cols, 0, pad_rows)).contiguous()`; the
output is bitwise identical to the old function (bf16/fp16/fp32, padded
and unpadded shapes, contiguous and transposed inputs).

## Behavior change
None.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Dead-code removal and relocating a tool-only quantizer; no trainer
inference or training path changes.
> 
> **Overview**
> Removes dead trainer surface area and keeps FP8 checkpoint conversion
self-contained in the tooling layer.
> 
> **`trainer/models/layers/rms_norm.py`** is deleted (it only
re-exported `RMSNorm` / `RMSNormConfig` with no importers).
**`trainer/models/fp8.py`** is removed and
**`quantize_to_fp8_blockwise`** now lives in
**`tools/convert_bf16_to_fp8.py`**, which was its only consumer;
**`convert_dcp_to_fp8.py`** continues to pull quantization helpers from
that script. Padding inside the moved helper uses **`F.pad`** instead of
manual zero-fill, with the same blockwise e4m3 logic otherwise
unchanged.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
c35ca75. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…ill (PrimeIntellect-ai#3841)

## Summary
Two bugs in how `rl` sets `weight_broadcast.inference_world_size` on
main:

1. **World size computed before the DP fill.**
`auto_setup_weight_broadcast` set
`trainer`/`orchestrator.weight_broadcast.inference_world_size = dp * tp`
before `auto_setup_deployment` fills the single-node inference GPUs with
DP ranks. With `num_infer_gpus = 4` and `data_parallel_size` unset, the
NCCL group was sized for 1 inference worker while vLLM started 4, so the
first weight update cannot form the group.
2. **A single-node SLURM job cannot re-parse its `rl.json`** (since
PrimeIntellect-ai#3788). The resolved `rl.json` always contains
`weight_broadcast.inference_world_size` (default 1), and PrimeIntellect-ai#3788's guard
rejects any config that sets it. So `rl @ rl.json` in the single-node
sbatch fails validation for every NCCL config.

## Fix
- Single-node: set
`trainer`/`orchestrator.weight_broadcast.inference_world_size = dp * tp`
right after the DP fill in `auto_setup_deployment` (non-filesystem
only). Multi-node already sets it there.
- `auto_setup_weight_broadcast` copies `weight_broadcast` to the trainer
and orchestrator without touching the world size, and the guard is
removed. The top-level value is now only passed through: `rl` overwrites
it whenever it knows the inference size.

This also fixes what PrimeIntellect-ai#3924 fixes.

## Behavior change
Yes (bug fixes).
- Resolved configs for every TOML in `configs/`, `examples/`, `k8s/`
that parses as `RLConfig` (41 of 78) are identical before and after,
except `trainer`/`orchestrator.weight_broadcast.inference_world_size`
goes from 1 to 4 in `examples/basic/hendrycks-sanity/rl.toml` and
`configs/ci/nightly/multimodal_color_codeword.toml` (4 infer GPUs, DP
unset).
- Re-parsing the dumped config (as the single-node sbatch does) fails on
main for all 18 single-node NCCL configs and passes for all of them
here.
- With `inference = None` and NCCL against external servers, a user-set
`weight_broadcast.inference_world_size` is now passed through instead of
rejected.

## Checks
- `pytest tests/unit/test_configs.py tests/unit/orchestrator -m "not
gpu"`: 298 passed
- `ruff check`, `ruff format --check`: clean
- New test `test_single_node_nccl_resolved_json_roundtrips`: dumps a
single-node NCCL config like `rl`'s `write_config`, re-parses it, and
checks it is equal. It fails on main with the guard error.
- `rl @ examples/basic/reverse-text/rl.toml --deployment.num-infer-gpus
2 --slurm.partition all --dry-run`, then re-parse the written `rl.json`:
fails on main, passes here (world size 2).
- GPU (one node): running: `rl @ examples/basic/hendrycks-sanity/rl.toml
--max-steps 3` on main and on this branch, plus the rendered single-node
sbatch for reverse-text (2 infer GPUs) run end to end.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes when NCCL weight broadcast groups are sized for single-node
runs; mis-sizing previously caused deadlocks or init failures during
weight updates.
> 
> **Overview**
> Fixes incorrect **NCCL/NIXL** `inference_world_size` on single-node RL
runs when vLLM **data parallel size** is inferred from `num_infer_gpus`
rather than set explicitly.
> 
> **`auto_setup_weight_broadcast`** no longer derives
`trainer`/`orchestrator.weight_broadcast.inference_world_size` from
`data_parallel_size × tensor_parallel_size` (which ran before DP was
filled) and no longer rejects a user-set `inference_world_size`.
**`auto_setup_deployment`** (single-node) now sets
`inference_world_size` to `dp × tensor_parallel_size` immediately after
the DP/`api_server_count` fill, so the in-memory transfer group matches
every inference GPU that joins NCCL.
> 
> Unit tests assert the resolved world size (2 infer GPUs → size 2) and
that a dumped `rl.json` round-trips unchanged.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f5553f5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…rimeIntellect-ai#3917)

This is an alternative to PrimeIntellect-ai#3907. The RL trainer decoded and preprocessed
each micro batch's images on the main thread right before its forward
pass, leaving the GPU idle. `WorkerPool`, a small ordered thread pool
configured by `data.num_workers` like torch's DataLoader, now prepares
micro batches ahead of the loop through a single `prepare_micro_batch`
hook.

- `num_workers` defaults to 0, which prepares on the main thread as
before, and one thread gives the full speedup. Here it counts threads,
unlike SFT's DataLoader processes.
- An earlier version used forked worker processes. They pickle data
through `/dev/shm`, risk copy-on-write memory, and were 0.6 s/step
slower than a thread.
- Pinned memory and `non_blocking` copies are left to a follow-up that
first removes the per-micro-step CUDA syncs.

Qwen3.5-9B trainer-only on 4 H200s, mock micro batches of 1024 px JPEGs
(about 1 s of CPU decoding each), median s/step, lower is better
(benchmark code added in
[`331fc83fe`](PrimeIntellect-ai@331fc83)
and removed in the next commit):

| Arm | main thread | prepared ahead | time saved |
|---|---|---|---|
| this PR, 1 thread | 24.98 | 18.86 | 24.5% |
| this PR, 1 thread, optimizer CPU offload | 26.56 | 20.24 | 23.8% |
| earlier process version, 2 workers | 24.79 | 19.02 | 23.3% |
| PrimeIntellect-ai#3907 (thread with pinned memory) | 24.98 | 18.63 | 25.4% |

Every arm matches the main-thread baseline bit for bit in loss and grad
norm. Host memory stays flat over 30 steps, 0.3 GiB above the baseline
for the prefetched images.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rimeIntellect-ai#3941)

Hosted runs with online evals score real rewards but the run dashboard
shows no `eval` metric group (ENG-6573).

The dashboard's `eval` group is built from the run's own metric keys
(`eval/{env}/...`). The orchestrator's in-process evals reach it through
the train monitor, but when evals run in a process of their own (SFT
online evals) the metrics are dropped: `PrimeEvalMonitor.log_metrics`
was a deliberate no-op.

- `PrimeEvalMonitor` now attaches to the parent training run via
`$RUN_ID` and forwards each epoch's `eval/...` metrics there.
- The eval process never reports the parent run's status: the SDK's
atexit crash hook is unregistered and `finalize()` only drains the
queue. Without that, an eval process exiting while the trainer is still
going would mark the whole run failed.
- No parent attached without `$RUN_ID`, so a standalone `prime eval` is
unchanged.

Note the ticket proposes exposing `environmentshub/resolve` on the
platform's internal API instead. That's a separate gap: it silences the
`EnvironmentResolutionError` warning and would create per-epoch
Evaluation records, but it does not produce the `eval` metric group, and
it needs four more `/evaluations` routes that the internal surface also
lacks.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes how eval processes interact with the parent platform run
(metrics + SDK lifecycle hooks); mistakes could misreport run status or
drop metrics, but behavior is gated on $RUN_ID and explicitly avoids
finishing the parent.
> 
> **Overview**
> Fixes hosted runs where **SFT online evals** compute rewards but the
run dashboard never shows the **`eval`** metric group (keys like
`eval/{env}/...`).
> 
> **`PrimeEvalMonitor`** now attaches a metrics-only handle to the
parent training run when **`$RUN_ID`** is set, and **`log_metrics`**
forwards sanitized epoch aggregates there instead of being a no-op.
Standalone **`prime eval`** (no **`$RUN_ID`**) is unchanged.
> 
> Lifecycle is guarded so the eval subprocess cannot poison the parent
run: the SDK **atexit** hook on the attached parent is **unregistered**,
and **`finalize()`** only **`flush`**es the upload queue—never finishes
or reports status on the trainer’s run.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
8920356. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…PrimeIntellect-ai#3947)

On torch 2.13.0 with compile and router replay, some MoE layers silently
replay layer 0's routing instead of their own from the second training
step on. This PR calls `.contiguous()` on each layer's routed-experts
slice, which removes the bug.

Each decoder layer `l` receives `routed_experts[:, :, l]`, a view into
one shared int32 tensor with axes (batch, sequence, layer, expert slot).
When `top_k` is not a multiple of 4, that view starts at a byte offset
that is not 16-byte aligned for some layers (odd layers on DeepSeek V4,
`top_k = 6`).

Inductor copies a misaligned input into a fresh aligned buffer, which
starts at offset 0. Once automatic dynamic shapes recompile the layer,
AOTAutograd mistakenly applies that offset 0 to the original shared
tensor when it rebuilds the layer's routed-experts output. Offset 0 of
the shared tensor holds layer 0's experts. A contiguous slice is its own
aligned tensor, so Inductor never makes the copy.

- Applied to the eight models that passed the raw view. `glm4_moe`
already called `.contiguous()`.
- Fixed upstream in pytorch/pytorch#191002 (torch 2.14.0 and 2.14.1, no
2.13.x release). The copy is cheap, so it can stay after an upgrade.
- Models with `top_k = 8` (Qwen3-30B-A3B, Trinity-Mini, GLM-4.5-Air)
were unaffected.

DeepSeek V4 Flash RL probe (compile on, router replay on, lr 0),
mismatch KL mean, lower is better:

| trainer | step 1 | steps 2-10 |
|---|---|---|
| compiled, strided slice (main) | 0.0008 | 0.022-0.11 |
| compiled, contiguous slice (this PR) | 0.0009 | 0.0007-0.0025 |

With compile off, the same probe spans 0.0008-0.0032 over steps 1-10.

## Verification

A standalone torch reproducer was added in
66641b0 and removed again at the end of
this branch, so main never carries it. Run it on one CUDA device from
that commit with `TORCHINDUCTOR_FORCE_DISABLE_CACHES=1 uv run python
tools/repro_misaligned_replay_alias.py`. In earlier runs it printed `BUG
REPRODUCED` on torch 2.13.0 and was clean on the 2.14.0.dev20260809 and
2.16.0.dev20261008 nightlies.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
## Summary

- Bump `deps/verifiers` from `1fa2bbc42` to main `da75527ca`. This pulls
in the Harbor adapter changes from PrimeIntellect-ai/verifiers#2808:
  - Honor reward-file precedence and select named rewards explicitly.
  - Resolve separate verifier definitions by Harbor image precedence.
  - Align the adapter with Harbor 0.24.0 and reject multi-step tasks.
- Move the `harbor` upload cutoff in `[tool.uv.exclude-newer-package]`
from 2026-08-11 to 2026-10-08, which matches `deps/verifiers`. verifiers
now pins `harbor==0.24.0`, which the old cutoff excluded.
- Relock: `harbor` 0.21.0 → 0.24.0, and `semver` 3.1.0 is new.

`deps/prime-envs` is already at its main head (`89dcc7ffc`). The current
verifiers pin already has the network notice
(PrimeIntellect-ai/verifiers#2769), so this bump supersedes PrimeIntellect-ai#3734.

## Verification

- `uv sync --all-extras --all-packages --locked` succeeds.
- `uv run pytest -m "not gpu" tests/unit`: 663 passed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Lockfile and uv policy changes only; risk is mainly behavioral shifts
from Harbor 0.24.0 and updated verifiers Harbor adapter usage in
eval/training paths.
> 
> **Overview**
> Updates dependency resolution so **`harbor` 0.24.0** (via the
`verifiers[harbor]` pin) can install under uv’s rolling
**exclude-newer** policy by moving the bounded **`harbor` cutoff** from
`2026-08-11` to **`2026-10-08`** in `pyproject.toml`, mirrored in
`uv.lock`.
> 
> The lockfile refresh bumps **`harbor` 0.21.0 → 0.24.0**, records new
transitive deps **`jsonschema`** and **`semver`**, and aligns the
verifiers harbor extra specifier to **`==0.24.0`**. This pairs with the
verifiers main bump described in the PR (Harbor adapter alignment and
reward/verifier precedence behavior), even though those code changes
live outside this diff.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
69e62c1. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…out (PrimeIntellect-ai#3909)

## Why

Since **Sep 2** the `4xa6000` runner has been offline. Since then,
`benchmark_regression` has queued on a dead label on **every single GPU
Tests run**, hit GitHub's 24h self-hosted queue timeout, and failed the
workflow. There has not been one green GPU Tests run in over a month, so
the per-PR GPU check is permanently red and real regressions hide in the
noise. Measured over Sep 29-Oct 6: all 28 completed runs failed at
exactly 1440 +/- 1 min, and 20+ phantom `benchmark_regression` jobs were
sitting in the queue at any time.

## What changes

1. **uv cache on the GPU jobs** (`enable-cache: true`, keyed on
`uv.lock`, mirroring `cpu_tests.yaml`) — each h200 pod currently
re-resolves and re-downloads the full dependency set from PyPI on every
job.
2. **`benchmark_regression` removed from the PR matrix.** It cannot
simply move to `prime-rl-2xh200`: the test unconditionally runs a 1-GPU
**and** a 4-GPU benchmark (a 2-GPU pod cannot host the latter), and it
asserts against A6000-captured baselines with a 15% tolerance (H200
results would blow through it). It belongs on benchmark hardware
(`benchmarks.yaml`) or behind a 4-GPU scale set when one exists — happy
to re-add once either exists.
3. **`max-parallel: 5` on the integration matrix** — a concrete per-PR
ceiling:
- 9 integration jobs + unit-tests = **6 h200 pods peak per PR**, 2 waves
- at current job durations (~10-15 min each) a PR finishes in ~25-35 min
- with 2-3 PRs in flight: <= ~18 pods steady-state; the ARC scale set
`maxRunners` (suggest 16-20) is the complementary global cap
4. `docs/development.md` CI table updated to match (also adds
`gsm8k_eval`, which was missing, and the current runner).

## Result

GPU Tests runs **only** on the `prime-rl-2xh200` scale set, and every PR
gets a real green or red within ~30-60 min instead of a guaranteed 24h
failure.

## Not in this PR (follow-ups)

- Bake git/uv into the CI image to kill the per-job `apt-get install
git`
- PVC-backed `~/.cache/uv` mount to avoid per-pod cache archive
downloads
- Nightly Tests still target a `research-cluster` runner that does not
exist — needs the same treatment or a cron disable

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> CI workflow and documentation only; no runtime or auth changes.
Benchmark regression no longer runs on every PR until re-homed to
suitable hardware.
> 
> **Overview**
> **GPU Tests** is adjusted so PR runs can finish on live hardware
instead of timing out on a dead runner label.
> 
> Both GPU jobs now enable **uv dependency caching** keyed on `uv.lock`
(same pattern as `cpu_tests.yaml`), cutting repeated PyPI
resolves/downloads on each H200 pod. The integration matrix **drops
`benchmark_regression`** (it targeted offline `4xa6000` hardware and
cannot run meaningfully on 2×H200), and adds **`max-parallel: 5`** so at
most five integration jobs run at once per PR. **`docs/development.md`**
updates the CI table to list the current integration scenarios
(`gsm8k_eval` included), `max-parallel`, and **`prime-rl-2xh200`** as
the runner.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
8207cc0. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…ct-ai#3950)

SFT samples can contain prefilled assistant turns that must remain in
context without becoming training targets. Read an optional
`message_loss_mask` dataset column and pass it directly to
`renderers.build_training_sample`; for a user/assistant/user/assistant
conversation, `[0, 0, 0, 1]` trains only the final assistant turn.

The renderer owns token attribution and mask validation, including GLM's
assistant-generated closing markers. prime-rl performs no owner walking
or template-specific mask reconstruction. Selection only removes
existing loss targets and preserves input/target token IDs. Missing or
null masks preserve normal supervision; fully masked samples follow the
existing skip behavior.

`data.columns.message_loss_mask` supports renamed columns. For
prompt/completion rows, the mask covers their concatenated message list.
This API reads a separate column; datasets with inline
`messages[].trainable_mask` need conversion before use.

Pins `deps/renderers` to upstream main commit
`e3ace557545274385abb30435975c134c7c2429d`, which merges
PrimeIntellect-ai/renderers#181. The renderer
dependency is now merged.

Validation:
- Signed pin update `838828713` points to the merged renderer commit.
Its Git tree is identical to the previously tested renderer head; no
source changes accompany the pin update.
- The following tests and checks passed on signed implementation commit
`7e0800051`:
- 73 SFT dataset tests passed, including real GLM-5 and Qwen3
tokenizers, both dataset layouts, custom column names,
missing/null/all-enabled masks, excluded initial/final turns, fully
masked samples, and invalid values/lengths.
- Ruff 0.13.0 lint and formatting checks passed for the changed Python
files; `git diff --check` passed.
- CPU tests ran in an isolated Python 3.12 environment with Transformers
5.6.2 and PyTorch 2.14.1. No GPU training run performed.
- Commit signature verified locally.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes which tokens receive loss during SFT when datasets supply
masks; incorrect masks could silently train the wrong turns, though
behavior is opt-in and heavily tested against real renderers.
> 
> **Overview**
> Adds **optional per-message SFT loss selection** via a dataset
`message_loss_mask` column (0/1 or booleans, one entry per resolved
message—including prompt+completion rows). The trainer forwards the
column to `build_training_sample` so renderers drop supervision on
excluded turns **without changing token IDs** (e.g. `[0, 0, 0, 1]` keeps
context but trains only the last assistant reply). Missing/null masks
behave as today; all-zero masks still skip the sample.
> 
> **Config and docs:** `SFTColumnsConfig.message_loss_mask` defaults to
`"message_loss_mask"` and can be remapped (e.g. `"selection"`); dataset
init errors if a remap points at a missing column. `docs/training.md`
documents the format and semantics.
> 
> **Tests:** Coverage for GLM-5 and Qwen3, both dataset layouts, custom
column names, partial masks, fully masked rows, and invalid mask
values/lengths.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
8388287. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…rimeIntellect-ai#3948)

SFT now pads packed rows only to a multiple of cp, as RL does, instead
of to `seq_len`. The fp8 indexer drops its autotuning, which
re-benchmarked configs on every new row length and made unpadded rows
slower than padded ones on DeepSeek-V4 (PrimeIntellect-ai#3726).

- The indexer pads keys to a multiple of 16 and runs one fixed config,
within 1.05x of the best.
- Every rank, including RL on DeepSeek-V4 and GLM-5, runs that config
with bitwise-identical top-k.
- SFT and RL throughput count trained tokens, not `seq_len` × rows or
the first row × count.
- The SFT `seq_len % (2 * cp)` check is gone: CP only needs `seq_len %
cp`, which configs enforce.

Same samples per arm, time summed over steps 5 to 20, one H200 node:

| Workload | Padding on main | main | This PR | Speedup |
|---|---|---|---|---|
| DeepSeek-V4-Flash 6L, scaleswe, 256K rows, cp 8 | 19.8% | 61.2 s |
38.2 s | 1.60x |
| GLM-4.5-Air 8L, math, 131K rows, dp 4 cp 2 | 7.3% | 223.8 s | 200.1 s
| 1.12x |
| GLM-4.5-Air 8L, scaleswe, 131K rows, dp 4 cp 2 | 19.7% | 701.0 s |
623.0 s | 1.13x |

On DeepSeek-V4, the 1.60x exceeds the 1.25x that removing 19.8% of
tokens predicts because two effects multiply: main's token-0 pads crowd
onto the same experts, and the padding itself costs compute. The
DeepSeek-V4 ablation section below separates the two.

On GLM with scaleswe, removing 19.7% of the tokens would predict about
1.25x, but it measured 1.13x. Its 4 data-parallel ranks now get rows of
different lengths, and each step likely waits for the rank with the
longest rows.

Loss matches main to within 1e-3 per step. This overlaps
PrimeIntellect-ai#3932 in
`CatDataset._finalize_pack`. Harness:
[`benchmarks/scripts/sft_pad_to_cp/`](https://github.com/PrimeIntellect-ai/prime-rl/tree/0ed0e78b33e8533d7fd1e0d603759dc2ab50e5dd/benchmarks/scripts/sft_pad_to_cp)
(removed before merge).

## DeepSeek-V4 ablation

Each row changes one thing on the DeepSeek-V4 workload above, to
separate the cost of the padding length, of token-0 pads crowding onto
the same experts, and of indexer re-tuning:

| Arm | Time | Speedup vs main |
|---|---|---|
| main: pads every row to `seq_len` with token 0 | 61.2 s | 1.00x |
| main, but pads with the row's own tokens (PrimeIntellect-ai#3932) | 49.6 s | 1.23x |
| pads only to a multiple of cp, indexer still autotuned | 73.6 s |
0.83x |
| pads only to a multiple of cp, fixed-config indexer (this PR) | 38.2 s
| **1.60x** |

## Alternatives considered

- Bucketing the autotune key on `next_power_of_2(S_q)` reached 1.47x on
DeepSeek-V4. Each bucket kept its first row's config (up to 1.44x slower
later), short rows paid extra tunes, and each rank still tuned on its
own rows, so ranks could run different kernels.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Reduce CI time and GPU usage by selecting the relevant test suites for
each PR, moving hosted eval to CPU, and running independent setup steps
in parallel.

- Select GPU unit tests, SFT, RL, and hosted eval from changed paths
before allocating runners. Shared runtime and dependency changes retain
broad coverage; pushes to `main` and manual runs execute the full suite.
- Keep RL/SFT coverage for checkpoint converters, dashboard code, and
the shared dashboard smoke helper.
- Run `gsm8k_eval` on `ubuntu-latest` because it uses hosted inference.
- Run public submodule initialization and `uv` setup concurrently, then
wait for both before installing dependencies. Keep dependency caching
keyed on `uv.lock` and the five-job limit on GPU integration tests.
- Cancel superseded CPU and style runs on the same PR.
- Give the GPU workflow read-only contents access and the test selector
PR read access.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Path-based GPU job gating can skip integration coverage on PRs if
filters miss a dependency; full suite still runs on main and manual
dispatch.
> 
> **Overview**
> **GPU CI now picks which jobs to run from the PR diff**, instead of
always running the full GPU unit + integration matrix on every non-draft
PR. A new **Select tests** job uses path filters for unit, SFT, RL, and
eval; pushes to `main` and `workflow_dispatch` still run everything.
RL/SFT integration scenarios are built dynamically (still capped at
`max-parallel: 5` on `prime-rl-2xh200`).
> 
> **`gsm8k_eval` is split out** into a dedicated job on `ubuntu-latest`
(hosted inference), so it no longer consumes GPU runners. Integration
pytest invocations use `tests/integration/test_${{ matrix.test }}.py`
from the dynamic matrix rather than per-row `pytest_args`.
> 
> **Setup and queueing tweaks:** submodule init and `setup-uv` run in
parallel in CPU/GPU jobs; CPU and style workflows cancel in-progress
runs per PR; GPU workflow gets `workflow_dispatch`, read-only
`contents`, and PR-scoped concurrency cancel. `docs/development.md`
documents the new triggers and runner split.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
20da35f. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…#3953)

Warn when SFT constructs a renderer that does not guarantee prefix
stability. The warning explains that each conversation is rendered once
into one training sample, that N assistant turns are not expanded into N
samples, and that earlier reasoning may be omitted by the template.
Cached renderers warn only on construction; stable renderers stay quiet.
Custom renderers without the capability are conservatively treated as
unknown.

Pin `deps/renderers` to merged main revision `41f9290`, which includes
PrimeIntellect-ai/renderers#184. It provides the
resolved capability and the manual, opt-in witness audit. Sample
construction is unchanged.

Validation: all **73 SFT dataset tests passed**, including
stable/unstable renderer selection and cache-warning behavior. Prime-rl
unit-test CI passed on the preceding revision; this update changes only
the renderer gitlink to the merged revision. Dependency requirements are
unchanged apart from renderer Python >=3.11, compatible with prime-rl
Python 3.12. The manually invoked renderer audit passes **59 tests** and
is excluded from normal CI discovery.
…ldcache (CVE-2026-93318) (PrimeIntellect-ai#3889)

## security(ci): pin BuildKit builder to v0.33.1 and rotate the GHCR
registry buildcache (CVE-2026-93318)

### What changed (rebased onto current main)
`.github/workflows/build_image.yaml`:
1. **Builder pin** — `docker/setup-buildx-action@v4` now sets
`driver-opts: image=moby/buildkit:v0.33.1`. Without it the job starts
the rolling `moby/buildkit:buildx-stable-1` tag, and because these are
persistent self-hosted runners (`image-builder`,
`image-builder-arm-2204`), the builder version is not even guaranteed to
follow the tag — a stale local image can keep a pre-patch builder alive
indefinitely. The pin makes the builder version deterministic.
2. **Registry cache rotation** — both shared cache refs rotated:
- `:dependency-buildcache-<suffix>` ->
`:dependency-buildcache-v2-<suffix>`
- `:application-buildcache-<suffix>` ->
`:application-buildcache-v2-<suffix>`

### Why rotation (evidence)
- The registry-backed cache was introduced in d8f3d01 (2026-08-16,
PrimeIntellect-ai#3279) and has been written on nearly every run since — including all
runs before the patch was published.
- BuildKit v0.33.1 (the CVE fix) was released **2026-09-30T17:08Z**; the
rolling `moby/buildkit:buildx-stable-1` tag only moved to that digest at
**2026-09-30T17:32Z**. Every cache write before that timestamp came from
a builder older than v0.33.1, on runners that may also hold stale local
images.
- Per the CVE-2026-93318 playbook: stop importing a shared cache that
pre-fix builders wrote, rebuild from trusted inputs, and pin the patched
builder so every future write is attributable to a fixed version.

### Expected cost
The first run per platform suffix after merge does a cold build of the
dependency layer + application layer (no cache import). The old
`*-buildcache-*` tags can be deleted from the `prime-rl` GHCR package as
cleanup — they are cache-only refs, never deployed.

### Out of scope / notes
- `docker-push.yaml` (old Docker Hub publisher) no longer exists on main
— nothing to pin there.
- No GHA-scope (`type=gha`) caches exist in this repo's workflows
(verified by grep), so no Actions-cache cleanup applies.
- Build image runs are frequent and mostly manual dispatch; this PR
should merge before the next `main` image build.

### Security review requested
- Cache invalidation: confirm the `*-v2-*` refs fully replace the old
buildcache lineage.
- Published-image provenance: GHCR `prime-rl` images built before
2026-09-30T17:32Z were produced by a pre-patch builder; assess whether a
clean rebuild of the current release tags is warranted.
- No applicable CODEOWNERS entry exists in this repo; CI/build owner
please review.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> CI-only security remediation with intentional cache busting (slower
first builds); no application runtime code changes, but GHCR images
built before the fix may need a separate provenance/rebuild decision.
> 
> **Overview**
> Remediates **CVE-2026-93318** in the GHCR image build workflow by
making the BuildKit builder version fixed and breaking reuse of
artifacts produced before the patch.
> 
> **BuildKit pin:** `docker/setup-buildx-action` now sets `driver-opts`
to `moby/buildkit:v0.33.1` (digest-pinned) so self-hosted
`image-builder` runners do not keep serving a stale local
`buildx-stable-1` builder.
> 
> **Cache and deps lineage:** Shared registry cache refs move to
`dependency-buildcache-v2-*` and `application-buildcache-v2-*` so layers
written by pre-v0.33.1 builders are not imported. Dependency base tags
gain a `-v2-` epoch (`deps-${key}-v2-${suffix}`) so existing
`deps-${key}` images are not reused as the runtime base when only
Dockerfile/lock inputs are unchanged.
> 
> The first build per platform after merge is expected to be cold (no
old cache import); published runtime tags are unchanged—only CI
remediation naming and builder configuration.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
0310e6d. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…f 1s (PrimeIntellect-ai#3952)

## Summary
- `WeightWatcher.poll_interval` default: 1.0s → 0.1s
- `WeightReceiver.wait_published` sleep: 0.2s → 0.1s

## Why
The trainer blocks in `WeightSender._wait_for_receiver_ready` until the
orchestrator notices `.sender_ready`. With a 1s poll, that adds 0–1s
(~0.5s avg) of idle trainer time on every step. NVIDIA flagged this
while benchmarking the ModelExpress broadcast (PrimeIntellect-ai#3890).

A poll is one `glob("step_*")` on the broadcast dir (≤2 entries thanks
to `_clean`) plus a few `exists()` calls, so 10Hz is negligible.
Correctness relies on the marker handshake + `update_lock`, not on the
poll timing.

Caveat: these are sync FS calls on the orchestrator event loop. On a
very slow shared FS, that might show up in `EventLoopLagMonitor`. If it
does, we can move them to `asyncio.to_thread`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Timing-only tuning on poll loops; no change to weight handshake logic,
though more frequent sync FS calls could add event-loop lag on very slow
filesystems.
> 
> **Overview**
> **Faster weight-version discovery** so the trainer spends less time
blocked waiting for the orchestrator to see `.sender_ready`.
> 
> The orchestrator `WeightWatcher` default `poll_interval` drops from
**1.0s to 0.1s**, cutting typical idle wait on each broadcast handshake
from ~0.5s toward ~0.05s. `WeightReceiver.wait_published` uses the same
**0.1s** sleep (was 0.2s) while waiting for the sender marker.
> 
> Handshake correctness is unchanged (markers + `update_lock`); cost is
slightly more frequent lightweight FS checks on the orchestrator event
loop.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
5c825d0. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ng (PrimeIntellect-ai#3782)

Use Verifiers' shared sampling config and resolution path for evaluation
and rollout dispatch. Parameter names, provider options, source
defaults, and agent overrides follow the same rules in native Verifiers
and Prime-RL. Training retains its strict config for the distribution
the trainer replays.

## Sampling flow

```mermaid
flowchart TD
    T["Train source and agent config<br/>TrainSamplingConfig and replay bounds"] --> V["Validate live-policy sampling<br/>Source owns the distribution"]
    V --> S["Shared Verifiers SamplingConfig<br/>Normalize aliases and extra_body"]
    V --> A["AgentConfig.sampling<br/>Sparse overrides across config transport"]
    E["Eval source and agent config"] --> S
    E --> A
    S --> R["RunRequest to env server"]
    A --> M["Verifiers resolve_agent<br/>Shared merge_defaults"]
    R --> M
    M --> P["Provider request<br/>Non-null resolved parameters"]
    M --> C["Trace<br/>Complete resolved sampling"]
```

## Shared parameters

Verifiers normalizes `max_tokens` / `max_completion_tokens` and flattens
`extra_body` before merging each override layer. Source values replace
group defaults; agent values replace inherited sampling. An explicit
null clears an inherited parameter.

Within one sampling block, top-level values take precedence over
`extra_body`. A typed training `top_k=20`, or the automatic replay bound
of `512`, therefore takes precedence over `extra_body.top_k=-1`. With no
typed `top_k`, the disabled sentinel remains effective.

Prime-RL carries `vf.SamplingConfig` through dispatch and converts
`TrainSamplingConfig` once. Source defaults and agent overrides use
Verifiers' `merge_defaults`; duplicate eval schemas and
sampling-dictionary converters are removed. Request-specific cache salts
are copied onto the shared config.

Unresolved agent overrides remain sparse across env-server config
boundaries. `resolve_agent` materializes the sampling used for
generation, and traces record the complete resolved config. Provider
dialects and the renderer client omit nulls when constructing requests.

## Training ownership

Live-policy sources validate their sampling when the config loads.
Unsupported source parameters, including `frequency_penalty`, are
rejected before generation.

The source owns the distribution replayed by the trainer. Every agent in
a live-policy source, including non-trainable roles, may override only
`max_tokens`, `reasoning_effort`, and `chat_template_kwargs`.
Distribution parameters belong on the source, even when an agent would
repeat the same value. Policy logprobs remain enabled. Evaluation and
frozen-model sources retain general agent overrides.

This removes the post-rollout sampling comparison from `TrainSink`.

## Dependency

Pins Verifiers `2cb0a6d`, the merged commit for [Verifiers
PrimeIntellect-ai#2762](PrimeIntellect-ai/verifiers#2762), for
shared sampling normalization and resolution. This includes merging
per-call judge sampling overrides before constructing SDK requests,
preserving unrelated configured provider parameters. The lockfile
updates `prime-tunnel` to `0.1.14` to meet the merged dependency's
minimum.

Before publishing packages outside this source checkout, release that
Verifiers change and raise the `verifiers` minimum in both `prime-rl`
and `prime-rl-configs`. The current `>=0.3.2.dev158` package constraints
do not guarantee these semantics.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes how sampling is resolved and validated for all rollouts;
mis-merged configs or rejected agent overrides could break existing
TOMLs, though unit tests cover the main inheritance and policy rules.
> 
> **Overview**
> **Unifies rollout sampling** on Verifiers' shared `SamplingConfig` and
merge rules instead of Prime-RL–specific eval schemas and
`to_sampling_args()` dict builders.
> 
> Eval sources and group defaults now use `vf.SamplingConfig`;
train/eval env wrappers carry a typed `SamplingConfig` through to the
env server (including per-request `cache_salt` via `model_copy`). Policy
train sources get **load-time validation**: the source owns distribution
parameters (`temperature`, `top_p`, etc.), while agents may only
override `max_tokens`, `reasoning_effort`, and
`chat_template_kwargs`—frozen-model and eval paths stay permissive.
`TrainSink` reads temperature from `env.config.sampling` instead of a
resolved args dict.
> 
> **Docs** describe alias normalization (`max_tokens` /
`max_completion_tokens`), `extra_body` merging, and live-policy override
limits. **Tests** cover inherited sampling merges and policy sampling
rejection cases. Lockfile bumps `prime-tunnel` to `0.1.14`.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
671a5aa. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…rimeIntellect-ai#3968)

Sets `dsa_backend = "cudnn_flashmla"` for the trainer in the GLM-5.3 RL
example (`examples/advanced/glm-5.3/swe.toml`). The SFT example already
uses it (PrimeIntellect-ai#3829); the RL example still ran the TileLang forward and
backward.

Measured on 1x8 H200 with the trainer settings of `swe.toml` (131k seq,
cp 4, ep 8, fp8 + `deepgemm_fp8`, full AC + offload, compile). Random
init, 8 layers (3 dense + 5 MoE), fake data, 2 micro-batches per dp
rank, balanced routing:

| `dsa_backend` | step 2 / 3 | sparse attn fwd per call | sparse attn
bwd per call |
|---|---|---|---|
| `tilelang` | 16.6 s / 15.5 s | 35 ms | 190 ms |
| `cudnn_flashmla` | 12.8 s / 12.1 s | 12 ms | 53 ms |

- Entropy and mismatch KL are identical to 4 decimals.
- With a 4-layer run (5.7 s), one MoE layer costs 1.6 s instead of 2.07
s. Extrapolated to 78 layers at this shape: ~124 s vs ~160 s per step
(-22%).
- Kernel check at the CP shape (8k local queries vs 32k keys, cp rank 0
and 3, top-k 2048, sentinel slots): out / dq / dkv relative error vs
TileLang 5e-4 to 9e-4, LSE 7e-8.
- Needs the `flash-mla` extra, which the README's `uv sync --all-extras`
installs.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Single example TOML knob with no production code changes; requires the
optional flash-mla dependency for the new backend.
> 
> **Overview**
> Aligns the GLM-5.3 RL example trainer config with the SFT example by
setting **`dsa_backend = "cudnn_flashmla"`** in
`examples/advanced/glm-5.3/swe.toml`, so sparse DSA attention uses the
cuDNN/FlashMLA path instead of the default TileLang forward/backward.
> 
> This is a **performance-only** example change for long-context RL
training (131k seq, CP/EP, fp8 MoE); behavior should match TileLang
within measured numerical tolerance when the `flash-mla` extra is
installed.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
e2135c3. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…t-ai#3982)

## What
Drop recipe lines that only repeat what the config already resolves to.
The resolved configs do not change, except for `skip_optimizer` (see
below).
- `[weight_broadcast] type = "nccl"`: `rl` already picks NCCL when
inference is managed and LoRA is off. Removed from minimax-m2.5 swe,
intellect-3.1 rl, and qwen3-30b-a3b swe/math. The block stays in
examples/extra/dynamo/rl.toml (no managed inference, so the default
there is filesystem) and in glm-5.3 (it also sets port and timeout).
- `ac.freq = 1` (the default) is removed from 8 recipes. The
`[*.model.ac]` table header stays, matching the GLM-4.5-Air recipes.
- `trainer.dist_timeout_seconds = 3600` / `dist_timeout_seconds = 3600`
(the default) is removed from 5 RL recipes and vlm/sft-moe.
- `inference.vllm.enable_return_routed_experts = true` is removed where
`trainer.enable_router_replay = true` already sets it (glm-4.5-air ×4,
nemotron-3-super).
- `[trainer.ckpt] skip_optimizer = true` is removed from glm-4.5-air
swe-budget/search/terminal and nemotron-3-super swe.
  - It only affects loading: on resume, it drops the AdamW/Muon state.
- It came from the PrimeIntellect-ai#2862/PrimeIntellect-ai#2915 ablation configs. None of these recipes
warm-starts from another run's checkpoint. They train from the HF model
and resume their own checkpoints, which include optimizer state.
- configs/ci/integration/reverse-text-sft/full-offload-resume.toml keeps
it on purpose: it tests that path.
- docs/scaling.md: the `fused_lm_head_token_chunk_size` default is 8192
(since PrimeIntellect-ai#3302), not 1024.

## Not touched
- `fused_lm_head_token_chunk_size = 1024` pins (7 recipes) and `attn =
"flash_attention_3"` pins (10). Should they stay as memory/hardware
choices?
- `[ac_offloading] max_inflight_activations` lines (a separate PR
removes the field).
- The `[weight_broadcast]` blocks of the GLM-4.5-Air and nemotron
recipes (handled in the weight_broadcast.timeout PR, to avoid a
conflict).

## Test plan
- `pytest tests/unit/orchestrator tests/unit/test_configs.py -m "not
gpu"`: 334 passed (validates every TOML under configs/ and examples/).
- Resolved configs of all 14 edited recipes, main vs this branch
(RLConfig/SFTConfig `model_dump`):
- identical, except `trainer.ckpt.skip_optimizer` True → False in the 4
recipes above;
- the checkout path in `template_path` also differs (main checkout vs
this worktree), which is not a recipe change.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Mostly documentation and deduplication; the only resolved-config
change is `skip_optimizer` false on four recipes that resume with full
optimizer state, which matches their intended training flow.
> 
> **Overview**
> Trims RL/SFT example and advanced recipe TOMLs by deleting settings
that already match framework defaults, so recipes only spell out
non-default choices.
> 
> Removed redundant **`[weight_broadcast] type = "nccl"`**,
**`trainer.model.ac` `freq = 1`**, **`dist_timeout_seconds = 3600`**,
and **`inference.vllm.enable_return_routed_experts`** where NCCL
broadcast, AC frequency, dist timeout, and routed-expert return are
implied (e.g. managed inference + **`enable_router_replay`**). Dropped
**`[trainer.ckpt] skip_optimizer = true`** from four GLM-4.5-Air and
Nemotron SWE/search/terminal recipes so resume loads optimizer state
from their own checkpoints instead of discarding it.
> 
> **`docs/scaling.md`** now documents
**`fused_lm_head_token_chunk_size`** default **8192** (was incorrectly
**1024**). Explicit pins like **`fused_lm_head_token_chunk_size =
1024`** in memory-tight recipes are unchanged.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
c87c43d. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…ts (PrimeIntellect-ai#3986)

Drops seven typed `[inference.vllm]` fields: `seed`, `enforce_eager`,
`dtype`, `trust_remote_code`, `enable_dbo`, `max_loras`,
`all2all_backend`, and the `All2AllBackend` alias. Each one only
repeated vLLM 0.31's own default, and nothing in prime-rl reads it.
`VllmConfig` is `extra="allow"`, so all seven keys still pass through to
vLLM unchanged. The `VllmConfig` docstring says to type only fields that
prime-rl types, defaults or reads back; these were none of those.

`all2all_backend` also suggested control it doesn't have for P/D: the
sbatch templates set the per-role backend in `ROLE_EXTRA`
(`inference.sbatch.j2:261,276`, `multi_node_rl.sbatch.j2:405,419`). That
stays as is.

Behavior: I resolved the vLLM serve args (`to_namespace` →
`make_arg_parser().parse_args`) on main and on this branch for the
default config and for `--vllm.enforce-eager`, `--vllm.enforce-eager
true`, `--vllm.max-loras 2`, `--vllm.dtype bfloat16`, `--vllm.seed 7`,
`--vllm.all2all-backend deepep_low_latency`, `--vllm.enable-dbo true`,
`--vllm.trust-remote-code true`, `@
examples/advanced/glm-5.3/infer/pd.toml`. All seven values are
identical. The nested rl form `--inference.vllm.enforce-eager
--inference.vllm.max-loras 2` also resolves to `True, 2`. `"enable_dbo"
= true` in `examples/advanced/glm-5.3/swe.toml` sits under
`prefill_vllm_overrides` and is unaffected.

Differences:
- `--no-vllm.enforce-eager` (the negated bool flag) only exists for
typed fields. Use `--vllm.enforce-eager false`. All four removed bools
default to False, so this only matters when undoing a toml that set one
to true.
- `dtype` now accepts every value vLLM accepts (for example `half`)
instead of prime-rl's narrower Literal.

`skills/configs/SKILL.md` used `enforce_eager = true` as its TOML
boolean example. It now uses `enable_prefix_caching = true`, which is
still typed, so `--no-flag` applies to it.

## Test plan
- `pytest tests/unit/orchestrator tests/unit/test_configs.py
tests/unit/test_parsers.py -m "not gpu"`: 489 passed
- Parsed all 78 tomls under `configs/` and `examples/` with every config
class on main and on this branch: same results
- The resolved-args comparison above
- `ruff check` / `ruff format --check` on the changed file
…rimeIntellect-ai#3978)

Under `rl`, `inference.deployment.gpus_per_node` was never set from
`deployment.gpus_per_node`. `InferenceConfig.auto_setup_disaggregated`
sizes `data_parallel_size_local`, `data_parallel_size` and
`api_server_count` from the nested copy, which defaults to 8. The
launcher, sbatch `dp_per_node` and weight-broadcast world size use the
top-level copy. So a P/D run on nodes that do not have 8 GPUs got
mismatched engine counts.

Example: `examples/advanced/glm-5.3/swe.toml` with
`deployment.gpus_per_node = 4` resolved to `data_parallel_size_local =
8` and `api_server_count = 8`, while the template launched 4 engines per
node.

Changes:
- `propagate_shared_fields` now fills
`inference.deployment.gpus_per_node` from `deployment.gpus_per_node`. As
with the other shared fields, setting both to different values raises.
- `RLConfig.validate_shared_configs` requires the two to be equal. This
catches a nested-only override that leaves the top-level value at its
default.

## Test plan
- `pytest tests/unit/orchestrator tests/unit/test_configs.py -m "not
gpu"`: 335 passed (includes the new
`test_disaggregated_inference_inherits_deployment_gpus_per_node`)
- Parsed all 78 tomls under `configs/` and `examples/` with every config
class on main and on this branch: same results, none fail
- glm-5.3 `swe.toml` with `deployment.gpus_per_node = 4` now resolves
`inference.deployment.gpus_per_node = 4`, `dp_local = 4`,
`api_server_count = 4`
- `ruff check` / `ruff format --check` on the changed files

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Mis-sized inference parallelism and launcher/engine counts on
multi-node disaggregated runs; the change is narrow config propagation
plus validation with low blast radius beyond P/D sizing.
> 
> **Overview**
> Fixes a config bug where **`deployment.gpus_per_node`** at the RL top
level did not flow into **`inference.deployment.gpus_per_node`**, so
disaggregated P/D runs could size **`data_parallel_size_local`**,
**`api_server_count`**, and related fields from the nested default (8)
while SLURM/launchers used the top-level value (e.g. 4).
> 
> **`propagate_shared_fields`** now copies top-level
**`deployment.gpus_per_node`** into
**`inference.deployment.gpus_per_node`** (with the same
shared-vs-sub-config conflict rules as other shared fields).
**`RLConfig.validate_shared_configs`** additionally requires the two
values to match when inference is enabled, catching nested-only
overrides that disagree with the top-level deployment.
> 
> A unit test asserts inheritance and that mismatched nested/top-level
**`gpus_per_node`** fails validation.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
863bbf6. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
faresobeid and others added 8 commits October 10, 2026 19:18
…el config (PrimeIntellect-ai#3965)

vLLM 0.31's routed-experts capturer already returns uint8 or uint16,
chosen from the model's expert count (`uint8 if num_experts <= 256 else
uint16`). `serialize_routed_experts` cast every request back to uint8
whenever its ids happened to be below 256. On a >256-expert model
(NemotronH Super/Ultra: 512 experts), a request whose ids all fall below
256 therefore came out uint8 while the others were uint16. Packing two
such samples into one micro batch trips `assert routed_experts.dtype ==
sample.routed_experts.dtype` in `orchestrator/batch.py`. Multi-turn
traces hit the same mix: verifiers concatenates the per-turn arrays, and
NumPy widens the result.

`serialize_routed_experts` now keeps the capturer's dtype and asserts
that it is uint8 or uint16. Every request of a model then has the same
dtype. Models with 256 or fewer experts are unchanged (uint8).

Tests: the existing serialize test now passes a uint16 array with ids
below 256 and checks that it stays uint16. `tests/unit/orchestrator`,
`tests/unit/inference`, `tests/unit/utils`, `tests/unit/test_configs.py`
pass with `-m "not gpu"`; `ruff check` and `ruff format --check` are
clean.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Narrow inference serialization fix; ≤256-expert models stay uint8, and
behavior only changes where mixed dtypes previously caused orchestrator
batch asserts.
> 
> **Overview**
> **Fixes inconsistent routed-experts dtypes** that broke micro-batch
packing on models with more than 256 experts (e.g. NemotronH 512).
> 
> `serialize_routed_experts` no longer re-encodes expert indices to the
smallest integer width based on per-request max values. It keeps the
array dtype from vLLM’s capturer (`uint8` or `uint16` from model expert
count), with assertions that the input is 3-D and one of those two
types. The serialized payload’s `"dtype"` field reflects that array
unchanged.
> 
> Unit tests now decode using `encoded["dtype"]` and assert a `uint16`
capture with all ids &lt; 256 stays `uint16` through serialize/decode.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
e4a8e9e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…t-ai#3984)

Removes `model.index_cache` (`IndexCacheConfig.topk_freq` and
`.topk_pattern`). The trainer still turns on IndexShare from the model's
own `indexer_types` schedule (`src/prime_rl/trainer/model.py`), as
before.

Why: the override changes the trainer only. No inference-side config
matches it, so setting it makes the trainer pick different top-k indices
than vLLM unless the user also passes the same vLLM `hf_overrides` by
hand. The setting appears in no config, example or test. The HF-level
`use_index_cache` / `index_topk_freq` / `index_topk_pattern` attributes
on `GlmMoeDsaConfig` stay, so a checkpoint's own `config.json` still
drives them.

Breaking: `[trainer.model.index_cache]` / `[model.index_cache]` now
fails validation.

Overlap with open PRs:
- PrimeIntellect-ai#3833 rewrites the same `index_cache` lines in `model.py`
(`set_model_options(model_config, "index_cache", ...)`). Whichever lands
second drops or rebases that block; the conflict is small.
- PrimeIntellect-ai#3879 is based on that code too.
- PrimeIntellect-ai#3616 already leaves `trainer.model.index_cache` unset for the reason
above: a trainer frequency override replaces the checkpoint schedule and
requests weights the checkpoint does not have.

Test plan
- `pytest tests/unit/orchestrator tests/unit/test_configs.py -m "not
gpu"`: 334 passed.
- `pytest tests/unit/utils/test_weights.py` (includes
`GlmMoeDsaForCausalLM`): 7 passed.
- `ruff check` and `ruff format --check` on the changed Python files:
clean.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Breaking config validation for anyone using `index_cache`, and it
changes sparse-attention index scheduling to follow the checkpoint
only—important for trainer/inference logprob alignment on DSA models.
> 
> **Overview**
> **Removes the trainer-only `[trainer.model.index_cache]` override**
(`topk_freq` / `topk_pattern`). Configs that still set it will fail
validation.
> 
> IndexShare for GLM-5.2-style DSA models is unchanged in behavior: when
the checkpoint’s `indexer_types` includes `"shared"` layers, the trainer
still sets `use_index_cache` on the HF config and logs the full/shared
layer counts. It no longer applies manual `index_topk_freq` /
`index_topk_pattern` or clears `indexer_types`, which could diverge from
vLLM rollouts.
> 
> Docs no longer mention manually overriding the schedule via
`[trainer.model.index_cache]`.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
bf75c92. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…eIntellect-ai#3991)

## What

Renames `tasks_per_minute` to `dispatch_per_minute` on `orchestrator`,
the `eval` entrypoint and the SFT online `[eval]` block. This uses the
same name and wording as PrimeIntellect-ai#3652.

The old name was wrong. The dispatcher takes one rate-limiter token per
**episode** it starts (`Dispatcher.acquire`, called once per scheduled
episode), not one per task. With `group_size = 8`, `tasks_per_minute =
80` started only 10 tasks per minute. The docstrings now say what is
counted: at most N episodes start per minute, and a group counts
`group_size` times.

Breaking: configs that set `tasks_per_minute` now fail validation
(`extra="forbid"`). No shipped config, doc, test or skill sets it or
mentions it.

## Overlap
PrimeIntellect-ai#3652 (draft, orchestrator as hook-wired components) makes the same
rename. This PR lands it on its own and does not depend on that
refactor.

## Test plan
- `pytest tests/unit/orchestrator tests/unit/eval
tests/unit/test_configs.py -m "not gpu"`: 357 passed.
- `ruff check` and `ruff format --check` on the changed files.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Breaking config field rename only; runtime rate limiting behavior is
unchanged aside from clearer documentation.
> 
> **Overview**
> **Breaking rename:** `tasks_per_minute` → `dispatch_per_minute` on
orchestrator config, eval/SFT online eval config (`ServedEvalConfig`),
and everywhere it is passed into `Dispatcher` (orchestrator + eval
runner).
> 
> The limit semantics are unchanged in code (still `AsyncLimiter` with
one token per scheduled episode in `Dispatcher.acquire`), but the name
and docstrings now match behavior: the cap is **episodes started per
minute**, and a rollout group with `group_size` N consumes N tokens.
Configs that still use `tasks_per_minute` will fail validation under
`extra="forbid"`.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
0436304. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…Intellect-ai#3979)

Multi-node RL jobs that crash currently sit idle for an hour.

On any non-zero exit (crash, OOM, SIGTERM, wall-time),
`multi_node_rl.sbatch.j2` ran an unconditional `sleep {{
cleanup_grace_period }}` (default 3600 s, PrimeIntellect-ai#2670) before tearing down.
The sleep keeps the task alive so `--kill-on-bad-exit` does not reap
trainers that are mid-checkpoint. But it waits the full hour even when
no checkpoint is being written, so a crashed N-node job idles for 1 h.

Now the node waits only while the newest `checkpoints/step_*/trainer`
has no DCP `.metadata` yet, i.e. a trainer save is still in flight. It
polls every 10 s, with `cleanup_grace_period` as the cap, and tears down
as soon as that save finishes or there is none. Runs without `[ckpt]`
tear down immediately. The checkpoint dir follows `ckpt.output_dir` when
it is set. The default stays 3600 s; it is now only an upper bound.

Limitation: if the crash happens right before a save starts (step dir
not created yet), we no longer wait for that save. Only that save is
lost; the previous checkpoint is intact.

**Interaction with PrimeIntellect-ai#3875 (requeue + step watchdog):** before this
change, every requeue of a crashed multi-node RL job first waited the
full grace period. Now it waits only for an in-flight checkpoint.
`.metadata` is the same completeness marker that PrimeIntellect-ai#3906 uses for "latest
complete checkpoint". The SFT and inference templates do not use
`cleanup_grace_period` and are unchanged.

Test plan (all run):
- `pytest tests/unit/orchestrator tests/unit/test_configs.py -m "not
gpu"`: 334 passed.
- `rl @ examples/advanced/glm-4.5-air/swe.toml --dry-run`: the rendered
sbatch has the wait loop with the run's `checkpoints/` path and passes
`bash -n`. With `--no-ckpt` the loop is not rendered.
- Ran the rendered teardown snippet in bash against a temp checkpoint
dir:
  - empty dir: no wait;
  - newest step complete: no wait;
  - newest step in flight: waits until the cap;
  - older incomplete step but newest complete: no wait;
  - in-flight save that completes after 1 s: waits ~1 s.
- `ruff check` and `ruff format --check` on the changed Python files.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes multi-node job teardown timing and checkpoint detection logic;
incorrect polling could tear down early or delay allocation release, but
scope is limited to SLURM RL launcher scripts.
> 
> **Overview**
> Multi-node SLURM RL jobs no longer **sleep the full
`cleanup_grace_period` (default 1h) on every failure**. Teardown after a
non-zero exit now waits only while an **in-flight trainer checkpoint**
is detected.
> 
> The `multi_node_rl.sbatch.j2` teardown block is gated on both
`cleanup_grace_period` and a rendered **`ckpt_dir`** (from
`get_ckpt_dir` when `[ckpt]` is enabled). Instead of a fixed `sleep`, it
polls every 10s: if the newest `step_*/trainer` directory lacks DCP
**`.metadata`**, it keeps the task alive (preserving
`--kill-on-bad-exit` behavior for peer trainers); otherwise it tears
down immediately. **`cleanup_grace_period` is documented as an upper
bound**; runs without checkpoints skip the wait entirely.
> 
> `write_slurm_script` in `rl.py` passes `ckpt_dir` into the multi-node
template renders.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
8168264. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…t-ai#3980)

## What
- `weight_broadcast.timeout` default goes from 1200 s to 3600 s
(`configs/shared.py`).
- It now also bounds the orchestrator's wait for the trainer's startup
broadcast. `orchestrator.ckpt.wait_for_weights_timeout` and the
hardcoded `STARTUP_WEIGHT_WAIT_TIMEOUT_S = 1200` fallback are removed.
Configs that set the old field fail validation (`extra="forbid"`); no
in-repo config sets it.
- The `[weight_broadcast]` blocks in five recipes only repeated `type =
"nccl"` (already the `rl` default when inference is managed and LoRA is
off) and `timeout = 3600`. They are removed: glm-4.5-air
swe/swe-budget/search/terminal and nemotron-3-super swe. glm-5.3 keeps
its `timeout = 12000`.
- Orchestrator `ckpt` and `resume` docstrings: `resume` had two
docstrings and `ckpt` had none. The stray one now documents `ckpt`.

## Why
- Every large multi-node recipe raises the timeout: 3600 s in five
recipes, 12000 s in glm-5.3.
- Measured startups come close to 1200 s or pass it. GLM-4.5-Air TP8
cold start takes 465-623 s. GLM-5.3 and DeepSeek-V4 inference startup
with cudagraph capture took about 16 min.
- vLLM's own engine-ready timeout is already 4200 s
(`VLLM_ENGINE_READY_TIMEOUT_S`).
- The startup wait and the per-step waits had two knobs with the same
1200 s default. The startup one was under `ckpt`, but it applies without
`ckpt` too.
- Trade-off: a consumer that dies is detected after 60 min instead of
20.

## Overlap
- PrimeIntellect-ai#3652 (mikasenghaas, draft) reformats the `wait_timeout = ...` line
that this PR removes. Whichever lands second has a one-line conflict.
- Rebased over PrimeIntellect-ai#3983, which removed the neighbouring orchestrator
`ckpt.keep_last`/`keep_interval` fields. The orchestrator
`CheckpointConfig` now has only `interval`.

## Test plan
- Rebased onto origin/main 32c48f5. `pytest tests/unit/orchestrator
tests/unit/test_configs.py -m "not gpu"`: 335 passed. `test_configs`
validates every TOML under `configs/` and `examples/`.
- `ruff check` and `ruff format --check` on the changed files: clean.
- Resolved `RLConfig` of the 5 edited recipes, main vs this branch:
identical (apart from the checkout path in `template_path`).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes orchestrator startup behavior and timeout defaults for
trainer–inference weight sync; mis-tuned timeouts could delay failure
detection (up to 60 min) or still timeout on very slow large-model
startups.
> 
> **Overview**
> **Unifies weight-broadcast timing** so one knob
(`weight_broadcast.timeout`) covers both the trainer handshake and the
orchestrator’s wait for the trainer’s **startup** broadcast. The default
rises from **1200 s to 3600 s**, and docs note raising it for large
models on slow filesystems.
> 
> **Removes** `orchestrator.ckpt.wait_for_weights_timeout` and the
orchestrator’s `STARTUP_WEIGHT_WAIT_TIMEOUT_S` fallback; startup sync
now uses `config.weight_broadcast.timeout` in `sync_startup`.
**Orchestrator config** docstrings are fixed so `ckpt` is documented on
the right field.
> 
> **Five example/advanced recipes** drop redundant `[weight_broadcast]`
blocks that only repeated NCCL type and `timeout = 3600` (now defaults).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
e318f1c. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…Intellect-ai#3988)

Replaces the three switches `model.fsdp_cpu_offload`,
`model.optim_cpu_offload` and `model.full_offload` with one field:

```toml
[model]
offload = "optimizer"   # default; also "full", "none"
```

This applies to both the RL trainer and SFT. The `fsdp` switch is not
migrated: the mode is removed outright (rationale below). The
`[model.full_offload] numa_bind` knob is dropped: no recipe, deployment,
template or doc sets it. Full offload now always pins each rank's CPUs
to its GPU's NUMA node. That was already the default. Ranks whose GPU
reports no NUMA node still skip binding with a warning. On hosts without
GPU sysfs topology (`/sys/bus/pci/devices/<bus>/numa_node`), binding is
no longer optional: full offload fails there instead of being switched
off.

Why: the three switches could only be used one at a time, enforced by
`cpu_offload_mutual_exclusion`. Because `optim_cpu_offload` defaulted to
`true`, enabling full offload also meant writing `optim_cpu_offload =
false`, otherwise validation failed (see the old
`configs/advanced/deepseek-v4-flash/sft.toml` comment). With one value
the conflicting combinations cannot be expressed, so that validator is
deleted. The default behaviour does not change: optimizer-state offload
stays on.

Why drop `fsdp`: the mode was the stock torch FSDP2 `CPUOffloadPolicy`
with no in-tree config, example, or test user; Muon rejected it and it
broke NCCL weight broadcast. It was kept only for long-context runs
whose fp32 masters/moments no longer fit on GPU (the reason it was
restored in PrimeIntellect-ai#3052); the `full` mode added in PrimeIntellect-ai#3234 keeps FP32 masters,
moments, and gradients in host RAM, covering that memory pressure
instead. Gone with it: the `CPUOffloadPolicy` selection in `setup_fsdp`,
CPU-side model init in `setup_model`/`load_dcp_from_hf`,
`_move_buffers_to_cuda`, and the fsdp leg of the Gloo special case.

Changes:
- `ModelConfig.offload: Literal["none", "optimizer", "full"] =
"optimizer"` replaces the three fields.
`OptimizerInBackwardOffloadConfig` (and with it `numa_bind`) and its
bool-normalizing `BeforeValidator` are gone.
- Validators in `TrainerConfig` and `SFTConfig`:
- full offload accepts only AdamW or SignSGD and disables clipping, now
reading `offload == "full"`;
- the `Muon optimizer does not support FSDP CPU offload` validators are
deleted with the mode.
- Runtime:
- `setup_optimizer(..., offload=...)` replaces `cpu_offload` +
`full_offload_config`, and its "both enabled" check is gone.
- `FullCPUOffloadOptimizer` drops its unused `offload_config` argument.
- `setup_full_cpu_optimizer_offload()` takes no arguments and always
binds.
- `model.py`: FSDP always uses the default `OffloadPolicy()`; CPU-side
model init in `setup_model`/`load_dcp_from_hf` and
`_move_buffers_to_cuda` are gone.
- Tomls: `glm-4.5-air/swe-budget.toml` and
`ci/integration/reverse-text-sft/full-offload-resume.toml` → `offload =
"full"`; `glm-5.3/sft/h200/16k-12-node.toml` → `offload = "none"`;
`glm-5.3/sft/h200/base.toml` → `offload = "optimizer"`. Also updated
`docs/scaling.md`, the `deepseek-v4-flash/sft.toml` comment, and
`skills/training/start-run/SKILL.md`, which documented the removed
fields.

Breaking: the old keys now fail with `extra_forbidden`, and `offload =
"fsdp"` fails the `Literal`. There is no shim.

Interaction with PrimeIntellect-ai#3849 (one trainer-common config base): PrimeIntellect-ai#3849 moves the
full-offload and Muon/FSDP validators from `TrainerConfig`/`SFTConfig`
onto `BaseTrainerConfig`. This PR edits the full-offload validator's
condition and deletes the Muon/FSDP one, so the conflict is textual
only: keep PrimeIntellect-ai#3849's single copy of the full-offload validators with
`self.model.offload == "full"` and drop its Muon/FSDP copy. Whichever
lands second takes a small rebase.

Other open PRs that set or read the old keys need a rebase:
- tomls: PrimeIntellect-ai#3883, PrimeIntellect-ai#3879, PrimeIntellect-ai#3766, PrimeIntellect-ai#3765, PrimeIntellect-ai#3729, PrimeIntellect-ai#3629, PrimeIntellect-ai#3627, PrimeIntellect-ai#3616, PrimeIntellect-ai#3544
- code: PrimeIntellect-ai#3885 (`optim_cpu_offload`), PrimeIntellect-ai#3739 (`full_offload` validators),
PrimeIntellect-ai#3724, PrimeIntellect-ai#3719, PrimeIntellect-ai#3699, PrimeIntellect-ai#3610, PrimeIntellect-ai#3487 (`fsdp_cpu_offload` / `enable_gloo`)

Test plan
- `pytest tests/unit/orchestrator tests/unit/test_configs.py
tests/unit/train/test_cpu_adam.py tests/unit/train/test_state_offload.py
-m "not gpu"`: 336 passed, 1 skipped (Qwen3-VL snapshot not cached on
this host), 4 deselected (GPU; this host has no GPU). The existing
offload config tests are updated to the new field. `test_load_configs`
covers the 4 edited tomls.
- `cli(TrainerConfig|SFTConfig, ...)`: `--model.offload full|none` and
the default (`optimizer`) resolve as expected; `optim_cpu_offload` and
`offload = "fsdp"` are rejected.
- `import prime_rl.trainer.rl.train, prime_rl.trainer.sft.train` works.
- `ruff check` and `ruff format --check` on the changed Python files:
clean.
- Not run: GPU or multi-node training with `offload = "full"`.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Breaking TOML/API surface touches distributed training memory paths
(FSDP offload, optimizer-in-backward, Gloo); behavior should match prior
modes when mapped correctly, but mis-migrated configs will fail at parse
time.
> 
> **Overview**
> **Breaking config change:** `model.fsdp_cpu_offload`,
`model.optim_cpu_offload`, and `model.full_offload` are replaced by a
single **`model.offload`** setting (`"optimizer"` default, or `"full"`,
`"fsdp"`, `"none"`). RL and SFT share the same field; example TOMLs and
scaling docs are updated accordingly.
> 
> The mutual-exclusion validator between the old flags is removed
because only one mode can be expressed. **`[model.full_offload]` /
`numa_bind` is dropped** — `"full"` always runs NUMA binding and CPU
optimizer thread setup via parameterless
`setup_full_cpu_optimizer_offload()`. Runtime paths (`setup_optimizer`,
FSDP CPU offload in `model.py`, Gloo when offload is `"full"` or
`"fsdp"`) key off the new enum; `FullCPUOffloadOptimizer` no longer
takes an offload config object.
> 
> Default training behavior is unchanged: optimizer-state CPU offload
remains the default (`"optimizer"`). Old TOML keys are not aliased and
should fail validation.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
35da73a. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: sami jaghouar <sami.jaghouar@gmail.com>
…4011)

Qwen3.5 training with context parallelism can fail on the first forward
after PrimeIntellect-ai#3925 places compilation inside activation checkpointing. FLA's CP
setup reads tensor-valued sequence boundaries and calls
`torch.searchsorted`; Inductor fails to specialize the resulting scalar:

```
GuardOnDataDependentSymNode:
Could not extract specialized integer from data-dependent expression (u0//4)
```

Wrap `build_cp_context` with `torch.compiler.disable`, alongside the
existing eager CP convolution wrapper. CP setup runs eagerly while the
surrounding block remains eligible for compilation. CP calculations,
model parameters and checkpointing settings are unchanged.

Validation ran on an allocated India H200 compute node with PyTorch
2.13.0+cu130 and FLA 0.5.2. A bounded CUDA/FSDP/checkpoint reproducer
used two sequence-boundary fixtures and logical CP size 4:

| Layout | Result | Maximum absolute
output/input-gradient/parameter-gradient difference vs eager |
| --- | --- | --- |
| Earlier outer compilation | Pass | 0 |
| Inner compilation introduced by PrimeIntellect-ai#3925 | Same `(u0//4)` compiler
failure | — |
| Inner compilation with eager FLA CP metadata | Pass |
1.9073486328125e-6 |

This reproducer uses one CUDA/FSDP rank; it does not exercise real
four-rank CP collectives or a full Qwen training step. Full distributed
training validation remains outstanding. Ruff and `git diff --check`
passed for the patch. No test or experiment files are included in this
diff.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Narrow trainer workaround for a known torch.compile + FLA CP setup
issue; behavior matches eager CP setup with negligible numeric drift in
repro.
> 
> **Overview**
> Fixes Qwen3.5 **context-parallel (CP)** training failures when the
block is compiled inside activation checkpointing (PrimeIntellect-ai#3925). Inductor
cannot specialize scalars from FLA’s `build_cp_context`
(`torch.searchsorted` on tensor sequence boundaries), which triggers
`GuardOnDataDependentSymNode`.
> 
> The change wraps **`build_cp_context`** with
**`torch.compiler.disable`** (same pattern as the existing eager CP
**`causal_conv1d`** wrapper) and calls **`build_cp_context_eager`** in
the forward path when CP is enabled. CP math, parameters, and
checkpointing are unchanged; only CP metadata setup runs outside the
compiled graph.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
c192fc9. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
# Conflicts:
#	docs/training.md
@mannuch
mannuch marked this pull request as ready for review October 11, 2026 05:37
@mannuch
mannuch merged commit 72713d7 into main Oct 11, 2026
10 checks passed
@mannuch
mannuch deleted the chore/merge-upstream-2026-10-10 branch October 11, 2026 05:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.