Skip to content

fix(orchestrator): enforce target lag strictly at ship time#3050

Open
mikasenghaas wants to merge 24 commits into
mainfrom
fix/strict-target-lag
Open

fix(orchestrator): enforce target lag strictly at ship time#3050
mikasenghaas wants to merge 24 commits into
mainfrom
fix/strict-target-lag

Conversation

@mikasenghaas

@mikasenghaas mikasenghaas commented Jul 16, 2026

Copy link
Copy Markdown
Member

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 by v{k} shipping in batch N is (N-1) - k versions 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.

  • Ship: batch N only ships once the trainer has published v{N-1-TARGET_LAG} (hold in finalize_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 the STABLENCCL_READY handshake only the watcher can serve. Never waits on an unpublishable version (under NCCL the trainer's last broadcast is v{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.
  • Dispatch: demand-driven scheduling replaces the lead-based dispatch gate. The orchestrator injects a train_needed predicate — true while the batch being collected isn't covered by buffered + partial-group + in-transit rollouts (incl. the out_q backlog, so ship-holds don't over-provision) — re-evaluated per scheduled group. The coverage target is max(batch_size, max_inflight_rollouts) plus up to TARGET_LAG batches 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 / raised max_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.
  • Drop (max_off_policy_steps, now consumption lag): rollouts already past the bound are discarded wherever they are — in-flight groups cancelled pre-pause via cancel_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 below group_size.
  • Defensive fix surfaced by the pacing: the multi-run trainer crashed touching a token-export STABLE marker inside a run dir the integration test had already deleted (FileNotFoundError → trainer death → the resumed orchestrator's ship-hold waited forever). mark_stable now 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.
  • Integration-test recalibrations for honest pacing: orchestrators now ride the trainer's real speed instead of finishing early on stale buffers, so wall time and reward-vs-step-index shift by the (bounded, visible) 1-2 version lag. Budgets: multi-run 300→900s outer + 600s inner waits, alphabet_sort 1200s, rl_sft 900s. Floors: reverse_text mismatch-KL 0.02, multi-run step-7 reward 0.1 and final reward 0.6 (siblings clear 0.69-0.75; a broken run reads ~0.1-0.3).
  • max_off_policy_steps now 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_factor unset → 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, set oversampling_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 by max_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):

  • Before (main): orchestrator ships all 10 batches in ~40s and exits while the trainer is at step 6; trainer wedges silently in the step-7 broadcast handshake; launcher hangs until killed (confirmed 120s+ after orchestrator exit).
  • After: batches ship paced to the trainer (Holding batch N until the trainer publishes policy vK, 8 holds incl. the final batch waiting for v8 = 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 main is merged in (the post-merge e2e runs used --trainer.model.attn flash_attention_2 on this Blackwell dev box — since made unnecessary by the attn=auto fallbacks 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 from v{k} that ships in batch N is (N-1)-k versions behind—including time spent buffered. Staleness is no longer tied only to weight updates during generation.

Ship pacing: finalize_train_batch blocks sending batch N until the trainer has produced v{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_allowed lead gate is removed. The orchestrator injects train_needed, which tracks buffered, partial-group, and in-transit rollouts (including out_q during ship holds) plus bounded lookahead so inference stays busy without over-running max_off_policy_steps. Fresh groups only open when demand exists; in-progress groups always finish.

Drop: cancel_stale_train_groups (pre weight pause) and TrainSink.drop_stale / group finalize drop rollouts whose generation policy is below the cutoff. off_policy_steps is stamped at ship time on each rollout. max_off_policy_steps minimum is 1 (final NCCL batch is necessarily one version off).

Other: GPU CI uploads **/tmp/outputs/**/*.log on failure; pytest keeps CI output dirs for artifacts; integration timeouts/thresholds adjusted for paced multi-run training; token export mark_stable tolerates deleted run dirs.

Reviewed by Cursor Bugbot for commit 9897ad7. Bugbot is set up for automated code reviews on this repo. Configure here.

mikasenghaas and others added 6 commits July 16, 2026 01:02
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>
@mikasenghaas
mikasenghaas requested a review from samsja July 17, 2026 19:15
@mikasenghaas
mikasenghaas marked this pull request as ready for review July 17, 2026 19:15
samsja
samsja previously approved these changes Jul 17, 2026

@samsja samsja left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

…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>
mikasenghaas and others added 2 commits July 17, 2026 22:17
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>
Comment thread src/prime_rl/orchestrator/train_sink.py
mikasenghaas and others added 3 commits July 17, 2026 23:04
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>
Comment thread src/prime_rl/orchestrator/train_sink.py
mikasenghaas and others added 7 commits July 18, 2026 01:27
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a0c857f. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/prime_rl/orchestrator/train_sink.py
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 81e0e6e. Configure here.

Comment thread src/prime_rl/orchestrator/dispatcher.py
mikasenghaas and others added 2 commits July 20, 2026 20:06
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

❌ 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9897ad7. Configure here.

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.

2 participants