Async RL: enforce staleness by pacing sampling instead of discarding rollouts#822
Async RL: enforce staleness by pacing sampling instead of discarding rollouts#822joschu wants to merge 13 commits into
Conversation
…ollouts
Previously, do_async_training discarded any completed trajectory group whose
sampler version was more than max_steps_off_policy steps behind the current
training step, and requeued its problem. When rollout durations vary (e.g.
multi-turn agentic tasks), slow rollouts systematically exceed the bound: their
sampling compute is wasted on endless regeneration, and the surviving batches
are biased toward fast-completing (typically easier) problems.
The staleness bound is now enforced without discarding any data:
- Admission control: at most groups_per_batch * (max_steps_off_policy + 1)
group rollouts may be outstanding (started but not yet trained on), so
sampling pauses instead of racing ahead when the trainer stalls.
- The training loop waits for rollouts at the staleness deadline instead of
dropping them, and forms batches stalest-first.
Together with one rollout worker per batch slot, this guarantees every
trajectory group is trained on exactly once, within the staleness bound.
Also adds async/staleness_{mean,min,max} and straggler-wait metrics, and
disallows combining async_config with stream_minibatch_config (the streaming
path has no staleness enforcement).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Runs do_async_training end-to-end against in-process fakes of the Tinker training/sampling clients, with adversarial rollout latency patterns. The fake sampling client stamps its weight version into the sampled tokens, so the tests verify from the data itself (not the loop's own bookkeeping) that: - every problem is sampled exactly once and trained on exactly once, even when its rollout takes many training steps of wall-clock time - staleness never exceeds max_steps_off_policy, including under bursts of slow rollouts that pile up at a single staleness deadline - max_steps_off_policy=0 is exactly synchronous - async training is substantially faster than synchronous on a straggler-heavy workload - shutdown is clean when groups are filtered to None (constant reward) and when the dataset holds more groups than the training loop consumes These tests caught a real race during development: admission slots were released at batch selection, before the new sampling client was in place, letting freed workers start rollouts on the previous weights and exceed the staleness bound by one step. Slots are now released after the client swap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ccuracy - Raise ConfigurationError for async_config + stream_minibatch_config in do_async_training itself, not only in main() - When the training loop exits, workers skip (rather than roll out) leftover problems whose results could never be trained on, and the loop logs how many completed groups it dropped - Define staleness in training iterations (published sampler versions) and say so consistently; note the num_substeps interaction - Qualify the exactly-once guarantee with the final-partial-batch exception (matching previous behavior), and fix the slot-release comment to acknowledge rollouts that start on already-free slots - Tests: sampler versions now counted by client publications (correct for num_substeps > 1); new tests for num_substeps=2, resume from start_batch > 0, and the async+streaming ConfigurationError Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From an adversarial review with mutation testing (14 mutations; 3 initially survived the suite): - Encapsulate rollout completion + result enqueue in _InFlightGroupTracker.record_completed_and_enqueue() so the no-await atomicity invariant is structural rather than guarded by a comment (a yield between the two was shown to break the staleness bound) - Log a warning every 2 minutes while the training loop is blocked waiting for rollouts, so a hung rollout is a diagnosable stall instead of a silent one - Attribute straggler wait time whenever a deadline rollout is in flight (was under-reported when the straggler was also needed to fill the batch) - Validate AsyncConfig fields (max_steps_off_policy >= 0, groups_per_batch > 0) with a clear error - Fix the capacity-guarantee docstring: the per-version drain argument was unsound; state the outstanding-after-release argument that actually holds - Qualify the exactly-once docstring (None-filtered groups, final partial batch, dataset batch size != groups_per_batch) - Tests: constant-reward test now uses more filtered groups than groups_per_batch * max_steps_off_policy, so a leaked admission slot deadlocks it (previously this mutation survived); assert the logged async/staleness_* metrics against staleness measured from the data (previously unasserted); tracker unit test covers the None slot release Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ersion accounting Follow-ups from the review verification pass: the dataset-outlives-training test now fails if leftover problems get rolled out instead of skipped (allowing one in-flight rollout per worker), and the fake training client documents why create_sampling_client does not bump the published version. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Fold staleness stats into compute_sampling_client_metrics (staleness is an affine transform of the sampling-client step stats it already computes); drop the separate compute_staleness_metrics and the redundant per-batch passes - Single _validate_async_config helper called from both main() and do_async_training, matching the kl_reference_config validation pattern and keeping the two call sites from drifting - Fold the training-done worker-stop signal into the tracker: acquire_slot() returns False after stop(), replacing the separate training_loop_done_event (and closing the gap where a worker already blocked on admission would run one wasted leftover rollout) - Share the queue-item ingestion between collect_batch's wait and drain loops; compute the deadline-straggler count once per iteration Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With blocking enforcement, typical staleness approaches max_steps_off_policy whenever sampling outpaces training (the pipeline fills its budget). A/B experiments on GSM8K show the default unclipped importance_sampling loss destabilizing under that regime, while loss_fn="ppo" is stable; say so in the docstring. (The old discard behavior masked this by silently keeping training near-on-policy at the cost of survivorship bias and wasted sampling.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
max_steps_off_policy was playing two roles: the hard staleness bound AND the admission capacity (groups_per_batch * (K+1)), so whenever sampling outpaced training the pipeline filled its whole budget and typical staleness sat AT the bound — turning a worst-case limit into the operating point, and feeding the loss uniformly stale data. New AsyncConfig.pipeline_depth controls how far sampling may run ahead (capacity = groups_per_batch * (pipeline_depth + 1)); typical staleness tracks it when sampling is fast. max_steps_off_policy remains the pure bound that only slow-rollout stragglers approach — it can now be generous without dragging bulk staleness up. Default depth is min(1, max_steps_off_policy): near-on-policy bulk data with a generous straggler allowance; pipeline_depth=max_steps_off_policy reproduces the previous behavior; depth 0 is synchronous. Values above the bound are rejected (they would violate it). Tests: steady-state mean staleness tracks the pipeline depth (contrast test at depth 1 vs 3 under a trainer-bound workload), validation coverage; the adversarial-latency tests pin depth=bound to keep exercising the full-budget regime. Also adds a TODO for refreshing the sampling client between turns of multi-turn rollouts (later turns from newer weights; the version at rollout start remains the min, so staleness bookkeeping is unchanged). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… pipeline depth A depth-1 confirming run (unclipped importance_sampling, K=3) stayed healthy substantially longer than the depth-3 run but still destabilized late in training at the recommended LoRA learning rate; the PPO runs were stable even at full pipeline depth. So the docstring now recommends a trust-region loss for async training generally, with pipeline depth controlling off-policyness rather than serving as a stability fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Surface the guidance where users will see it: at the top of the AsyncConfig docstring, on Config.loss_fn / Config.async_config, and on the recipes' max_steps_off_policy CLI fields. In the A/B experiments, the default unclipped importance_sampling loss eventually destabilized at every pipeline depth, while ppo was stable even at maximum staleness. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop the repeated per-recipe comments; AsyncConfig's docstring is the canonical statement, with a one-line pointer on Config.loss_fn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From a second adversarial review pass (Claude with mutation testing + Codex): - On the final training iteration, stop admission control immediately after releasing the batch's slots — before the rolling-checkpoint await — so freed workers cannot start rollouts on the final published weights that nothing will ever train on (previously possible when a rolling checkpoint was pending, wasting sampler compute and delaying shutdown). Regression test creates that await window with a genuinely slow pending save and asserts no sampling ever happens at the final published version. - Test the documented default pipeline_depth (a mutant resolving the default to max_steps_off_policy instead of min(1, K) previously survived), plus pipeline_depth=0 with a generous bound (exactly synchronous) and the stall-warning path while blocked on a deadline straggler. - Doc accuracy: default is min(1, max_steps_off_policy), not always 1; validation range stated fully; CHANGELOG capacity formula updated to the two-knob form and exactly-once wording qualified. Both reviewers otherwise verdict merge-ready: the staleness proof generalizes to all 0 <= pipeline_depth <= max_steps_off_policy (checked including the depth-0 edge and liveness), and the prior mutation battery still passes against the final tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| break | ||
| t_start = time.time() | ||
| try: | ||
| item = await asyncio.wait_for( |
There was a problem hiding this comment.
A single permanently-hung rollout now wedges the whole run here, with no escape hatch. A rollout that never completes (stuck sandbox, hung sampling request — the exact failure mode RetryOnFailure.per_rollout_timeout's docstring says exists) never leaves _in_flight_by_version; once i_batch ≥ hung_version + K this wait can never satisfy num_deadline_stragglers == 0, and the shutdown sentinel needs the hung worker to exit. Since slots are only released by training, admission control then freezes all sampling too. The default config (rollout_error_tolerance=False → FailFast; per_rollout_timeout=0 disabled) has no mitigation, and multi-turn/agentic envs — this PR's target workload — are where hangs live.
Verified by execution with this PR's own fakes (48 problems, B=4, K=2, 12 batches, problem 0 hung):
| this branch | main (discard) | |
|---|---|---|
| steps trained before stalling | 2 / 12 (wedged at hung_version + K) | 11 / 12 (only the final batch, one group short) |
| problems ever sampled | 16 / 48 (admission control frozen) | 48 / 48 |
So on main a hang costs one worker and the last step; here it halts all progress within K steps. It's loud (the warning below fired correctly, showing 7/4 groups collected, 1 in-flight rollouts at the staleness deadline) and arguably the intended no-data-loss tradeoff — but the warning doesn't tell the user what to do about it. Cheap fix: point the straggler warning text and the AsyncConfig docstring at RetryOnFailure(per_rollout_timeout=...) as the escape hatch. (The wall-clock-budget follow-up in the description would be the real fix.)
Review assisted by Claude.
| ) | ||
| continue | ||
| finally: | ||
| if num_deadline_stragglers > 0: |
There was a problem hiding this comment.
time/waiting_for_stragglers over-attributes: it charges the full duration of every queue.get() to stragglers whenever ≥1 deadline straggler is in flight — even when len(completed_groups_buffer) < groups_per_batch, i.e. the trainer would have blocked on batch-fill regardless (a wait that exists at any K, even in synchronous training). With K=0 it degenerates: the deadline is the current step, so every in-flight rollout counts as a straggler and the metric reports the entire collection wait. Someone tuning from this metric would raise K/pipeline_depth to fix a bottleneck that isn't staleness.
Since the loop can only reach the wait with a full buffer when a deadline straggler exists, restricting the attribution to that case makes the metric the marginal cost its name promises:
finally:
# Only count waits where the deadline straggler was the sole blocker;
# otherwise this was ordinary batch-fill waiting.
if len(completed_groups_buffer) >= groups_per_batch:
t_straggler_wait += time.time() - t_startReview assisted by Claude.
| # the previous weights using slots that were already free — that is | ||
| # within the bound, which the capacity accounts for.) | ||
| in_flight_tracker.release_slots(len(wrapped_trajectory_groups)) | ||
| metrics["async/outstanding_groups"] = in_flight_tracker.num_outstanding |
There was a problem hiding this comment.
Nit: the three async gauges in one metrics row are snapshots from different instants — async/in_flight_groups and async/completed_buffer_size are captured in collect_batch before the train step, async/outstanding_groups here after release_slots. Fine once you know it, but the row can look self-inconsistent when debugging staleness (e.g. outstanding < in_flight + buffer). Maybe worth a one-line comment or capturing them at one point.
Review assisted by Claude.
| log_dir: str | ||
|
|
||
|
|
||
| async def _run_async_training( |
There was a problem hiding this comment.
The torture tests are excellent for the happy-path scheduling space. Four gaps in the new machinery still worth covering, since the staleness guarantee depends on bookkeeping that only these tests protect:
- Failing/raising rollouts: no test injects a rollout failure into
do_async_training. An exception betweenrecord_startedandrecord_completed_and_enqueueleaks a slot and an in-flight record; today that's masked by fail-fast crash semantics, but a future catch-and-log hardening would deadlock the deadline wait in every flaky-env run — with this suite still green (rollout_error_resilience_test.pynever touches the async path). save_every > 0(the Config default is 20): all tests use 0, andFakeTrainingClient.create_sampling_client's NOTE says the fake can't model the periodic-checkpoint path. That path (save_periodic_async+create_sampling_client) is a different client-minting route than the one every staleness assertion validates.evaluation_loop: never exercised (all tests seteval_every=0), including its interaction with the reworked shutdown cascade.groups_per_batch=1: single worker, capacity = depth+1 — the tightest admission geometry.
Related nit: of the new metrics only async/staleness_max is cross-checked against ground truth; staleness_min/mean and the three gauges are unasserted.
Review assisted by Claude.
| - **PR**: Link to the pull request | ||
|
|
||
| --- | ||
| ### [cookbook] Async RL no longer discards stale rollouts — staleness is enforced by pacing sampling ([#822](https://github.com/thinking-machines-lab/tinker-cookbook/pull/822)) |
There was a problem hiding this comment.
| groups_per_batch: int | ||
| # How far sampling may run ahead of training (in iterations); typical | ||
| # staleness under a fast sampler. None means min(1, max_steps_off_policy). | ||
| pipeline_depth: int | None = None |
There was a problem hiding this comment.
Recipes can't reach this knob, but their existing flag silently changed meaning: five recipes (math_rl, code_rl, harbor_rl, rubric ×2) construct AsyncConfig with only the two old fields. A user re-running e.g. math_rl with their existing max_steps_off_policy=4 drops from an effectively 4-deep sampling pipeline to depth 1 with no CLI way back — and "set pipeline_depth=max_steps_off_policy to reproduce the previous behavior" is advice no recipe user can follow. Suggest plumbing pipeline_depth through the recipe CLIs (or at least the ones people use for long-rollout async runs).
Review assisted by Claude.
| generated from a sampler that is too many steps behind the current | ||
| training step are discarded (or requeued) to limit off-policy staleness. | ||
| In async mode, sampling and training run concurrently. **Use a | ||
| trust-region loss with async training** (e.g. ``Config.loss_fn="ppo"``): |
There was a problem hiding this comment.
Since this guidance is the PR's headline recommendation: the shipped research skill still teaches the opposite — skills/research/references/rl.md:304-308 recommends AsyncConfig(max_steps_off_policy=4, groups_per_batch=8) with the default importance_sampling loss and no mention of pipeline_depth, i.e. the configuration these experiments found destabilizes. Worth updating alongside this PR (out of this diff, so flagging it here).
Review assisted by Claude.
Async RL: enforce staleness by pacing sampling instead of discarding rollouts
Problem
do_async_trainingenforcedmax_steps_off_policyby discarding any completed trajectory group whose sampler version was too old and requeuing its problem. When rollout durations vary (multi-turn/agentic tasks), this systematically starves slow rollouts:max_steps_off_policysteps of wall-clock is never trained on.This matches a failure mode reported by an external user running multi-turn tasks with 30–60 min rollouts ("longer rollouts... are constantly regenerated and rarely make it to a batch"). No published async RL system uses discard-and-requeue; the standard design (e.g. AReaL, arXiv:2505.24298) bounds staleness by admission control and trains oldest-first. Magistral (arXiv:2506.10910) states the invariant directly: the completion-length distribution of trained data must match the data distribution "even though shorter completions finish more quickly."
New semantics
AsyncConfig(max_steps_off_policy=K, groups_per_batch=B, pipeline_depth=D)— no data loss, and staleness is controlled by two separate knobs (max_steps_off_policykeeps its meaning;pipeline_depthis new, default 1):B·(D+1)group rollouts may be outstanding (started but not yet trained on), so sampling runs at mostDiterations ahead —Dis the typical staleness whenever sampling outpaces training (D=0is synchronous;D ≤ Kenforced). When the trainer stalls, rollout starts pause instead of racing ahead. Slots are released only after the optimizer step and sampling-client swap, so newly admitted rollouts sample from the new weights.t, the loop waits for every in-flight rollout with sampler version ≤t − K— its last chance to be trained within the bound — instead of dropping it.Kis the worst-case staleness that only slow-rollout stragglers approach; it can be generous without dragging bulk staleness up.Together with one rollout worker per batch slot, these guarantee: every collected trajectory group is trained on exactly once, with staleness ≤ K (measured in training iterations = published sampler versions).
K=0reduces to exactly synchronous training. If the loop must wait a long time for a straggler, it logs a diagnostic warning every 2 minutes rather than stalling silently.Removed: the discard/requeue path (per review, nobody should use it).
async_config+stream_minibatch_confignow raisesConfigurationError(the streaming path had no staleness enforcement of its own; previously the combination silently used discard semantics).New metrics:
async/staleness_{max,min,mean},async/in_flight_groups,async/outstanding_groups,async/completed_buffer_size,time/waiting_for_stragglers.Tests
tinker_cookbook/rl/async_training_test.py(~11 s, no network) runs the realdo_async_trainingagainst in-process fakes with adversarial latency patterns. The fake sampling client stamps its weight version into the sampled tokens and the fake training client records when each datum is trained, so the staleness bound and exactly-once are verified from the training data itself, not the loop's bookkeeping. Covered: slow rollouts spanning many steps (30×/150× latency outliers), K=0 ⇒ fully synchronous, async beats sync wall-clock on straggler workloads, constant-reward (None) groups, datasets holding more groups than training consumes (no deadlock; leftovers skipped),num_substeps>1, resume fromstart_batch>0, config validation, logged metrics cross-checked against data.These tests caught two real bugs during development (a slot-release race that let rollouts start on about-to-be-stale weights, and an off-by-one in the original capacity analysis), and were themselves hardened by mutation testing — 14 hand-written mutations of the staleness machinery; the suite now catches all of them.
Experiments
A/B on GSM8K (Qwen3.5-9B-Base, B=32, G=8, K=3, 40 steps) with difficulty-correlated env latency (problems with ≥5 reference-solution steps sleep 60–300 s per rollout — ~25% of data, disproportionately the hard problems), old semantics (main @ fad40e0) vs this branch, plus a sharper faster-step regime (B=16, K=2):
The experiments also motivated the
pipeline_depthsplit: with a single knob, the pipeline filled its wholeB·(K+1)budget whenever sampling outpaced training, so typical staleness sat at K rather than merely bounded by it (the old code masked this by discarding). Under the default unclippedimportance_samplingloss, training uniformly at K=3 off-policy destabilized (sampler-trainer KL grew ~30×, entropy collapse); the same A/B withloss_fn="ppo"is stable — 94.5% (new) vs 94.0% (old) held-out accuracy after 40 steps, with the new arm training on every offered group and discarding nothing while the old arm still burned 105 group rollouts. A depth-1 rerun with the unclipped loss stayed healthy substantially longer (92.7% at step 20, measured mean staleness 0.97 vs 2.49 fully pipelined) but still destabilized late at this learning rate, so the recommendation is unconditional: prefer a trust-region loss for async training;pipeline_depth(default 1) controls how much off-policyness and trainer-waiting you trade for throughput, not stability. Follow-up controls and 3-seed replications strengthen this: a fully on-policy control with the unclipped loss is stable at the same LR (the instability is staleness-caused); withppo, blocking (92.4 ± 1.8%) and discard (93.7 ± 0.5%) are statistically indistinguishable on held-out accuracy while blocking removes all waste and bias in every seed; one-sidedcispo(caps 4.0 and 2.0) also destabilizes at staleness ≈ 2.5; and with a generous bound (K=8) the staleness deadline rarely binds, giving 1.8× faster wall-clock at lower realized staleness. Guidance in the docstring: prefer a trust-region loss for async training, and setmax_steps_off_policyto comfortably cover your slowest rollouts.Review
Adversarially reviewed by two independent reviewers (one with mutation testing — findings included a leaked-slot deadlock class the tests initially missed, an atomicity hazard now made structural, and an unsound proof sketch in a docstring, all fixed and re-verified; plus a GPT-5.6 review that caught the
num_substepsstaleness-unit ambiguity and the leftover-rollout waste at shutdown).Follow-ups (out of scope here)
trajectory_group_worker_loop).🤖 Generated with Claude Code
https://claude.ai/code/session_0133j6tAVqf1Zd7nVeBVtKZQ