Skip to content

Async RL: enforce staleness by pacing sampling instead of discarding rollouts#822

Open
joschu wants to merge 13 commits into
mainfrom
async-rl-blocking-staleness
Open

Async RL: enforce staleness by pacing sampling instead of discarding rollouts#822
joschu wants to merge 13 commits into
mainfrom
async-rl-blocking-staleness

Conversation

@joschu

@joschu joschu commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Async RL: enforce staleness by pacing sampling instead of discarding rollouts

Problem

do_async_training enforced max_steps_off_policy by 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:

  • slow (usually harder) problems are regenerated over and over and rarely make it into a batch — the trained data distribution is silently biased toward fast-completing problems;
  • every discard throws away completed sampler work;
  • a problem whose rollout reliably takes more than max_steps_off_policy steps 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_policy keeps its meaning; pipeline_depth is new, default 1):

  1. Admission control: at most B·(D+1) group rollouts may be outstanding (started but not yet trained on), so sampling runs at most D iterations ahead — D is the typical staleness whenever sampling outpaces training (D=0 is synchronous; D ≤ K enforced). 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.
  2. Deadline waiting: before training step 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. K is the worst-case staleness that only slow-rollout stragglers approach; it can be generous without dragging bulk staleness up.
  3. Stalest-first batches from a buffer of completed groups.

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=0 reduces 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_config now raises ConfigurationError (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 real do_async_training against 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 from start_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):

blocking (new) discard (old)
groups trained / sampled 1280 / 1280 1248 / 1360
slow-problem coverage 100% 92% (sharp regime: 92%)
slow share of trained batches (offered 0.25) 0.25 0.23 (sharp: 0.21)
max staleness 3 = K ≤ K by discarding
sampled tokens discarded 0 ~143k (8%) (sharp: ~56k, 13%)

The experiments also motivated the pipeline_depth split: with a single knob, the pipeline filled its whole B·(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 unclipped importance_sampling loss, training uniformly at K=3 off-policy destabilized (sampler-trainer KL grew ~30×, entropy collapse); the same A/B with loss_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); with ppo, 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-sided cispo (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 set max_steps_off_policy to 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_substeps staleness-unit ambiguity and the leftover-rollout waste at shutdown).

Follow-ups (out of scope here)

  • Refresh the sampling client between turns of multi-turn rollouts (each turn is an independent sample call), so later turns of long rollouts come from newer weights — the turn-level analog of in-flight weight updates. Per-token sampling logprobs already make mixed-version trajectories valid for the loss, and the start version remains the staleness minimum, so bookkeeping is unchanged (TODO in trajectory_group_worker_loop).
  • Wall-clock rollout budgets: on timeout, grade the partial trajectory and train on it with reward clamped ≤ 0 (needs Env-protocol support for partial-trajectory grading).
  • Streaming-minibatch + async support (reserving per-minibatch slots for deadline stragglers).
  • Async checkpoint/resume does not persist in-flight rollouts (pre-existing TODO).

🤖 Generated with Claude Code

https://claude.ai/code/session_0133j6tAVqf1Zd7nVeBVtKZQ

joschu and others added 8 commits July 13, 2026 02:00
…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>
@joschu joschu marked this pull request as draft July 13, 2026 04:03
joschu and others added 4 commits July 13, 2026 04:19
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>
@joschu joschu marked this pull request as ready for review July 13, 2026 20:35
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>
@joschu joschu requested a review from YujiaBao July 13, 2026 20:57
break
t_start = time.time()
try:
item = await asyncio.wait_for(

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.

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:

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.

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_start

Review 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

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.

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(

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.

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:

  1. Failing/raising rollouts: no test injects a rollout failure into do_async_training. An exception between record_started and record_completed_and_enqueue leaks 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.py never touches the async path).
  2. save_every > 0 (the Config default is 20): all tests use 0, and FakeTrainingClient.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.
  3. evaluation_loop: never exercised (all tests set eval_every=0), including its interaction with the reworked shutdown cascade.
  4. 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.

Comment thread CHANGELOG.md
- **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))

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.

Nit: spacing deviates from the file's convention — the other entries have a blank line between the --- separator and the ### heading, and this entry adds a doubled blank line after its closing --- (#802/#803 were exactly this class of cleanup).

Review assisted by Claude.

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

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.

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"``):

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.

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.

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