fix(orchestrator): enforce target lag strictly at ship time#3050
fix(orchestrator): enforce target lag strictly at ship time#3050mikasenghaas wants to merge 24 commits into
Conversation
The dispatch gate only stops new rollouts; on tiny fast envs the sink
keeps forming batches from buffered rollouts, so the orchestrator ships
past TARGET_LAG, finishes, and tears down the watcher while the trainer
still has pending NCCL broadcasts. Those broadcasts then block forever
in the STABLE->NCCL_READY handshake (no watcher to trigger the inference
receive) and the launcher never exits.
hold_for_target_lag blocks shipping batch N until the trainer has
published v{N-1-TARGET_LAG}, bounding in-queue staleness and keeping the
orchestrator alive for every broadcast the trainer will make. Under NCCL
the last published version is max_steps-2, exactly what the final batch
requires, so the hold never waits on an unpublishable version.
Also logs a per-batch staleness decomposition (pre-queue = versions
elapsed during generation, in-queue = versions spent waiting in the
sink) under staleness/*.
Fixes RES-1077
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Measure a rollout's off-policy lag against the batch that consumes it —
batch N trains on policy v{N-1}, so lag = (N-1) - generation version,
queue time included. The old off_policy_steps counter only counted
weight updates during generation, so a rollout that finished fast but
sat buffered across steps trained arbitrarily stale while reporting 0.
- max_off_policy_steps now bounds consumption lag: in-flight groups are
cancelled pre-pause (cancel_stale_train_groups) and buffered sink
survivors are swept whenever the cutoff moves (ship, weight update).
Groups share one generation version, so stale groups drop whole.
- Dispatch is demand-driven: the orchestrator injects train_demand
(batch shortfall minus buffered + partial-group + in-transit
rollouts), re-evaluated per scheduled group, replacing the lead-based
dispatch gate and the #2990 final-batch NCCL exemption it needed.
out_q backlog counts as coverage so ship-holds don't over-provision.
- Max Off-Policy in logs is now the honest consumption lag, stamped at
ship (reverse_text: steady-state max 3 vs a dishonest 0 before).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e single-use helpers - train_demand -> train_needed: the dispatcher only thresholds the value, and the magnitude isn't even unit-stable across batch modes, so the injected contract is a bool predicate. - Demand target is max(batch_size, max_inflight_rollouts): explicit over-provisioning (oversampling_factor > 1 or a raised max_inflight) keeps racing batch stragglers instead of being silently capped at one batch; capacities below the batch keep a batch_size target so the batch can still complete. - Reject max_off_policy_steps=0 with NCCL broadcast + max_steps: the final batch necessarily trains one version off-policy (the trainer never broadcasts the last versions), so a zero budget would hang the run collecting the final batch. - Inline single-call helpers (hold_for_target_lag, stale_cutoff, the dispatcher lag gauges) into their call sites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last NCCL batch necessarily trains one version off-policy (the trainer never broadcasts the final versions), so a zero budget can never be met and would hang the run collecting the final batch. A field floor covers this without a conditional validator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ht applies Two regressions surfaced by the PR's GPU CI run: - alphabet_sort/rl_opd deadlocked at 126/128 (+3 buffered, 0 inflight): demand hit zero mid-group, so an open group's remaining rollouts were never scheduled — its buffered members count as batch coverage but the group can never finalize. Already-open groups now keep scheduling regardless of demand (try_schedule allow_fresh); only fresh groups are demand-gated. - reverse_text timed out (~26s/step vs ~19s on main): the ship hold waited on the applied version, serializing every inference weight reload into the ship path. Filesystem broadcast decouples publish from apply and the trainer never blocks on the watcher there, so the hold now releases on the published version (tracked in on_version_pending). NCCL keeps waiting for the apply — the trainer blocks in the receive handshake until then, so releasing on publish could strand its final handshake at teardown. Verified: reverse_text e2e clean on both broadcast types (filesystem 6-7s steps, NCCL exits clean), 485 unit tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resumed orchestrators now pace to the shared trainer — a batch only ships once the trainer has produced the required policy version — so with three runs round-robining one trainer, beta_resume's tail (steps 21-25) can legitimately exceed the old 300s wait while its versions queue behind the other runs. Matches the 600s budget of the other RL integration tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
drop_stale now mirrors what entering pending_batch added to _payload_total/_payload_count, keeping the token-mode demand estimate consistent with the buffered set. Also expand the allow_fresh comment with the group-finalization invariant it protects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The multi-run test's failures only surface a sampled tail of one orchestrator's output; the copied per-run orchestrator/trainer/inference logs are what's needed to debug them (currently: beta_resume wedging on a policy version the shared trainer never publishes after resume). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The output_dir fixture rmtree'd the whole outputs dir at session teardown, so the upload step found nothing. Skip the cleanup under CI (the workspace is discarded with the job) and match log files directly instead of a directory glob. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Exact-one-batch demand synchronized dispatch into one 128-rollout wave per batch: each ship opened demand 0->128 at once, the wave stampeded the env server together (MCP timeouts + backoff retries in CI alphabet_sort), and inference idled ~60s per cycle while the whole wave sat in env round-trips — tripling the step cadence vs main (artifact logs: vLLM alternating 30s busy / 60s '0 running, 0 waiting'). With up to TARGET_LAG batches of lookahead — allowed only while a rollout landing that far ahead still fits max_off_policy_steps — steady-state dispatch is continuous (each arrival frees demand for the next start), keeping the engine fed through env-phase gaps. Costs one version of staleness on the lookahead batch (reverse_text plateau 3->4, within the default cap of 8). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mismatch KL compares generation-time inference logprobs against the training weights, so it includes async training's policy-version gap on top of trainer/inference numerics. With strict pacing + dispatch lookahead the steady-state consumption lag is 2-3 versions (visible and bounded, vs unmeasured before), which puts the last-steps average at ~0.011-0.012 against the old 0.01 budget calibrated for main's profile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Artifacts from the failed runs show no wedges — every process was mid-progress at the deadline: alphabet_sort cycles at ~70s on slow vm runners (33s on fast ones; trainer MFU varies 2-6% across the pool) and the multi-run trainer was mid-micro-step serving three runs round-robin. With strict pacing the orchestrators ride the trainer's pace instead of finishing early on stale buffers, so wall time tracks the slowest component. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wait_for_log/wait_for_file 300s defaults were calibrated for orchestrators that sprint ahead of the shared trainer on buffered batches; with strict pacing three orchestrators ride the trainer's actual speed, so every internal milestone (alpha steps, checkpoint STABLE markers, resume completion) lands slower and a different 300s wait tripped on each CI round. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| # Eval rollouts in the backlog undercount train demand briefly — | ||
| # conservative, and it self-corrects as the queue drains. | ||
| in_transit = self.dispatcher.inflight_train_count + self.dispatcher.out_q.qsize() | ||
| return self.train_sink.remaining_rollout_demand(in_transit, lookahead) > 0 |
There was a problem hiding this comment.
Ship-hold undercounts batch coverage
High Severity
During the new ship hold, train_needed still treats the batch being shipped as unfilled. process_batch already removed that cohort from pending_batch while progress.step has not advanced yet, so demand only sees overflow, out_q, and in-flight work. Dispatch then schedules a full replacement set on every hold, undoing the lag control this change is meant to enforce—especially on fast envs where holds are long.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit a0c857f. Configure here.
There was a problem hiding this comment.
The mechanics are right but this is deliberate, not a lag-control leak: dispatch during a hold is the freshness-bounded pipelining that keeps inference busy through trainer-bound waits (it's what fixed the alphabet_sort engine-idle waves). Lag control is enforced at ship (hold on the trainer's version) and on data (staleness cutoff drops); dispatch-ahead is bounded by lookahead = min(TARGET_LAG, max_off_policy_steps - lag) plus the permit cap, and measured consumption lag plateaus at 4 vs the default cap of 8 with zero drops. Counting the held batch as coverage would re-idle the engine for the length of every hold. Documented at the site in 81e0e6e.
buffered_count hides group-scoring envs' partial groups for the pipeline log's honesty, but demand must count them — otherwise members sitting between arrival and group completion invite bounded over-dispatch. Also document why a held batch intentionally does not count as coverage (dispatch-ahead keeps inference busy through trainer-bound holds; staleness stays bounded by lookahead + cutoff). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| f"Holding batch {step} until the trainer publishes policy v{required_version} " | ||
| f"(currently v{version})" | ||
| ) | ||
| await self.version_advanced.wait() |
There was a problem hiding this comment.
NCCL ship-hold deadlock on cancel
High Severity
Under NCCL, the ship hold waits on the applied policy.version, but on_version_pending only publishes then awaits cancel_stale_train_groups, which emits Cancelled markers into the bounded out_q. During a hold the main loop is not draining that queue, so it fills and the cancel put blocks; apply never runs, the hold never sees an applied version, and the trainer stays stuck in the NCCL handshake.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 81e0e6e. Configure here.
The multi-run trainer crashed with FileNotFoundError touching
run_gamma/token_exports/step_20/STABLE: the multi-run integration test
rmtree's a run's dir once its orchestrator finishes, while the trainer
is still flushing that run's tail steps. The broadcast paths already
skip deleted runs on FileNotFoundError; mark_stable now does the same.
This was the root cause of the recurring multi_run CI failure: the
crashed trainer never published the resumed run's next version, so
beta_resume's ship-hold waited forever ('failed with code None'). On
main the same crash risk exists but the orchestrator never waited on
the trainer, so nothing surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Batch N's data now comes from v{N-2}..v{N-4} instead of main's
effectively-fresher buffer profile, so reward-at-step-7 reads 1-2
policy versions staler (observed 0.14-0.20 across four CI rounds vs the
0.2 floor). The final-reward thresholds still enforce learning quality;
the early floor drops to 0.1 — comfortably above a broken run's flat
~0.05-0.08.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same class as the other RL integration budgets: the trainer was SIGTERM'd at step 14 with the orchestrator already finished — killed by the 600s deadline steps from completion on a slow runner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| # sweep buffered survivors that can no longer ship in time. In-flight | ||
| # groups are swept on the next weight update instead (their aborts | ||
| # must land while the engine is stepping, see ``on_version_pending``). | ||
| self.train_sink.drop_stale((self.progress.step - 1) - self.config.max_off_policy_steps) |
There was a problem hiding this comment.
Bench lag gates ignore frozen policy
Medium Severity
Bench skips the ship hold because no trainer versions are published, but train_needed and drop_stale still apply consumption-lag against a frozen policy.version. After a few ships, min_policy_version advances while new rollouts are still born at v0, so they are dropped as stale (or dispatch pauses when lag exceeds max_off_policy_steps), and the run cannot finish. This bites orchestrator-only --bench when max_off_policy_steps is near the new floor of 1.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 67ec049. Configure here.
With strict pacing the reward-vs-step-index curve sits 1-2 policy versions lower throughout; gamma — the most trainer-contended of the five runs — landed at 0.627 while its siblings cleared 0.69-0.75. A broken run still reads ~0.1-0.3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9897ad7. Configure here.
| rollouts started now would already exceed ``max_off_policy_steps``.""" | ||
| lag = (self.progress.step - 1) - self.policy.version | ||
| if lag > self.config.max_off_policy_steps: | ||
| return False |
There was a problem hiding this comment.
Final-step NCCL drain deadlock
High Severity
After shipping the final batch under NCCL with max_off_policy_steps=1, lag is permanently 2 because the trainer never broadcasts past v{max_steps-2}. train_needed then stays false and drop_stale discards every reachable rollout, so the overflow batch that alone sets draining never forms and the orchestrator hangs. The removed final-batch NCCL dispatch exemption previously kept that path alive.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 9897ad7. Configure here.


Summary
Strict off-policy lag control across all three places a rollout can go stale, measured with one consistent quantity: batch N trains on policy
v{N-1}, so a rollout generated byv{k}shipping in batch N is(N-1) - kversions off-policy — queue time included. Previously staleness was only counted as weight updates during generation, so a rollout that finished fast but sat buffered across steps trained arbitrarily stale while reporting 0.v{N-1-TARGET_LAG}(hold infinalize_train_batch). Fixes RES-1077: on tiny fast envs the orchestrator shipped buffer-fed batches past the trainer, finished, and tore down the watcher while the trainer still had pending NCCL broadcasts — which then hung forever in theSTABLE→NCCL_READYhandshake only the watcher can serve. Never waits on an unpublishable version (under NCCL the trainer's last broadcast isv{max_steps-2}, exactly what the final batch needs); bench runs skip the hold. Under NCCL the hold waits for the applied version (the trainer blocks in the receive handshake until the watcher applies, so this costs nothing and releasing earlier could strand the final handshake at teardown); under filesystem broadcast it releases on the published version, keeping inference weight reloads out of the ship path.train_neededpredicate — true while the batch being collected isn't covered by buffered + partial-group + in-transit rollouts (incl. theout_qbacklog, so ship-holds don't over-provision) — re-evaluated per scheduled group. The coverage target ismax(batch_size, max_inflight_rollouts)plus up toTARGET_LAGbatches of lookahead (only as far as the staleness budget allows), so steady-state dispatch stays continuous — one-batch-exact demand synchronized dispatch into per-batch waves that stampeded the env server and idled inference through env round-trips (root cause of the CI alphabet_sort timeouts). Configured over-provisioning (oversampling_factor> 1 / raisedmax_inflight_rollouts) still races batch stragglers at full concurrency. Only fresh groups are demand-gated: already-open groups always finish scheduling (their buffered members count as coverage, so an unfinishable partial group would deadlock the batch a few rollouts short). This deletes the fix(orchestrator): allow final nccl batch through dispatch gate #2990 final-batch NCCL exemption along with the gate that needed it.max_off_policy_steps, now consumption lag): rollouts already past the bound are discarded wherever they are — in-flight groups cancelled pre-pause viacancel_stale_train_groups(aborts must land while the engine is stepping), buffered sink survivors swept whenever the cutoff moves (ship, weight update). Groups share one generation version, so stale groups drop whole rather than shrinking belowgroup_size.STABLEmarker inside a run dir the integration test had already deleted (FileNotFoundError→ trainer death → the resumed orchestrator's ship-hold waited forever).mark_stablenow skips deleted runs like the broadcast paths do. On main the same crash risk existed but nothing waited on the trainer, so it never surfaced.max_off_policy_stepsnow has a field floor of 1 — the final NCCL batch necessarily trains one version off-policy (the trainer never broadcasts the last versions), so a zero budget could never be met and would hang the run collecting the final batch.Performance
Default configs (
oversampling_factorunset →max_inflight = batch_size) keep the same concurrency budget as main; the difference is that completed-but-buffered rollouts now count against it, so in-flight tapers toward the end of each batch instead of continuously over-generating rollouts that would train stale. For rollout-bound (slow) envs where inference saturation matters more than freshness, setoversampling_factor(e.g. 1.5–2) — the demand target scales with it, extra rollouts race stragglers at full concurrency, and the surplus spills exactly one batch (+1 lag, bounded bymax_off_policy_steps).Verification
uv run rl @ examples/reverse_text/rl.toml --max-steps 10 --no-wandb(2 GPUs, NCCL, ~1-5s buffer-fed steps), before and after the main merge (v0 bridge and v1 taskset paths):Holding batch N until the trainer publishes policy vK, 8 holds incl. the final batch waiting forv8 = max_steps-2);Training finished!, launcher exit 0, 10/10 batch dirs, Max Off-Policy flat at 4 (one version from the dispatch lookahead), zero staleness drops at the default cap.pytest tests/unit -m "not gpu": 487 passed.Latest
mainis merged in (the post-merge e2e runs used--trainer.model.attn flash_attention_2on this Blackwell dev box — since made unnecessary by theattn=autofallbacks in #3076/#3077).🤖 Generated with Claude Code
Note
High Risk
Changes core orchestrator pacing, dispatch, and staleness semantics that coordinate trainer, NCCL weight broadcast, and inference lifecycle—incorrect timing could deadlock training or strand broadcasts.
Overview
Strict off-policy control now uses one metric everywhere: batch N trains on policy
v{N-1}, so a rollout fromv{k}that ships in batch N is(N-1)-kversions behind—including time spent buffered. Staleness is no longer tied only to weight updates during generation.Ship pacing:
finalize_train_batchblocks sending batch N until the trainer has producedv{N-1-TARGET_LAG}(skipped in bench). Under NCCL it waits on the applied policy version so the watcher stays alive for broadcast handshakes; filesystem mode can release on publish. This fixes orchestrators finishing ahead of the trainer and hanging NCCL teardown.Dispatch: The
dispatch_allowedlead gate is removed. The orchestrator injectstrain_needed, which tracks buffered, partial-group, and in-transit rollouts (includingout_qduring ship holds) plus bounded lookahead so inference stays busy without over-runningmax_off_policy_steps. Fresh groups only open when demand exists; in-progress groups always finish.Drop:
cancel_stale_train_groups(pre weight pause) andTrainSink.drop_stale/ group finalize drop rollouts whose generation policy is below the cutoff.off_policy_stepsis stamped at ship time on each rollout.max_off_policy_stepsminimum is 1 (final NCCL batch is necessarily one version off).Other: GPU CI uploads
**/tmp/outputs/**/*.logon failure; pytest keeps CI output dirs for artifacts; integration timeouts/thresholds adjusted for paced multi-run training; token exportmark_stabletolerates deleted run dirs.Reviewed by Cursor Bugbot for commit 9897ad7. Bugbot is set up for automated code reviews on this repo. Configure here.