Backfill async rollouts on sample completions#1673
Conversation
Add an opt-in --rollout-sample-completion-backfill mode that submits one replacement prompt group after n_samples_per_prompt individual samples finish, instead of waiting for an entire group task to return. With long-horizon agentic rollouts (SWE-agent style, 20-120 min per trial), the legacy group-level scheduler leaves engine capacity idle while the slowest sibling of each n-sample group finishes; sample-completion credits keep the pipeline saturated. The default path remains the legacy group-level scheduler. Wire the sample completion callback through both inference rollout implementations and the fully-async example worker, and add focused CPU tests for the default-disabled routing and replacement scheduling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013vyY58s2TFpCzqEP4KaSxC
There was a problem hiding this comment.
Code Review
This pull request introduces a sample-completion backfill feature for training rollouts, allowing new prompt groups to be scheduled as individual samples complete rather than waiting for entire groups. Feedback on the changes highlights several concurrency and robustness issues, including potential event loop blocking when using synchronous queues, deadlocks upon data source exhaustion, incorrect completion credits for failed or cancelled tasks, and lost concurrency slots when group tasks raise exceptions.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| def task_done_callback(done_task): | ||
| result = done_task.result() | ||
| self.output_queue.put((gid, result)) |
There was a problem hiding this comment.
Using self.output_queue.put directly inside the asyncio task done callback can block the asyncio event loop thread if the queue is full. Since self.output_queue is a synchronous queue.Queue, blocking here will freeze the entire event loop, preventing other rollout tasks from running. To avoid this, try a non-blocking put_nowait first, and fallback to running the blocking put in an executor.
| def task_done_callback(done_task): | |
| result = done_task.result() | |
| self.output_queue.put((gid, result)) | |
| def task_done_callback(done_task): | |
| result = done_task.result() | |
| try: | |
| self.output_queue.put_nowait((gid, result)) | |
| except queue.Full: | |
| asyncio.get_running_loop().run_in_executor( | |
| None, self.output_queue.put, (gid, result) | |
| ) |
There was a problem hiding this comment.
This pattern is pre-existing on main (the same output_queue.put in the task callback, this PR only moved it into submit_one_group()), and the full-queue condition is unreachable in practice: in-flight groups are capped at rollout_batch_size (typically 32-64) while the queue's maxsize is 1000 and the consumer drains continuously. Happy to switch to put_nowait with an executor fallback in a follow-up if maintainers prefer, but keeping this PR's surface minimal.
| while len(data) < target_data_size: | ||
| if not pendings: | ||
| # Defensive fallback for group-level task exceptions. Normal flow keeps | ||
| # pending sample slots replenished from sample completion credits. | ||
| submit_groups(max(1, target_data_size - len(data))) |
There was a problem hiding this comment.
If the data source is exhausted, submit_groups will return 0 and pendings will remain empty. Without breaking out of the loop, the code will proceed to wait on sample_done_queue.get(), which will block forever since no more tasks are running to populate the queue. Breaking when no new groups can be submitted prevents this deadlock.
| while len(data) < target_data_size: | |
| if not pendings: | |
| # Defensive fallback for group-level task exceptions. Normal flow keeps | |
| # pending sample slots replenished from sample completion credits. | |
| submit_groups(max(1, target_data_size - len(data))) | |
| while len(data) < target_data_size: | |
| if not pendings: | |
| # Defensive fallback for group-level task exceptions. Normal flow keeps | |
| # pending sample slots replenished from sample completion credits. | |
| if submit_groups(max(1, target_data_size - len(data))) == 0: | |
| break |
There was a problem hiding this comment.
Good catch — fixed in f814b44: submit_groups returning 0 with nothing in flight now breaks out of the loop instead of blocking on the sample-completion queue.
| if sample_done_callback is not None: | ||
| task.add_done_callback(lambda _task: sample_done_callback()) |
There was a problem hiding this comment.
Only trigger the sample_done_callback if the sample task completed successfully (i.e., was not cancelled and did not raise an exception). This ensures that failed or cancelled samples do not incorrectly contribute to completion credits, which could otherwise lead to premature or double backfilling when combined with group-level failure recovery.
| if sample_done_callback is not None: | |
| task.add_done_callback(lambda _task: sample_done_callback()) | |
| if sample_done_callback is not None: | |
| task.add_done_callback( | |
| lambda t: sample_done_callback() if not t.cancelled() and t.exception() is None else None | |
| ) |
There was a problem hiding this comment.
This is deliberate, and conditioning on success would introduce a subtler bug: the callback fires exactly once per submitted sample task (success, exception, or cancellation), so completion credits conserve in-flight sample concurrency exactly — every n-credit batch corresponds to exactly one freed group slot. If failed samples were skipped, credits would leak and concurrency would decay monotonically with every failure (unless paired with group-level replenish, which then over-submits on partial group failures — see the reply on the other thread). A failed sample does free engine capacity, so counting it is the semantically correct choice for a throughput scheduler. Documented the invariant at this call site in f814b44.
| if sample_done_callback is not None: | ||
| task.add_done_callback(lambda _task: sample_done_callback()) |
There was a problem hiding this comment.
Only trigger the sample_done_callback if the sample task completed successfully (i.e., was not cancelled and did not raise an exception). This ensures that failed or cancelled samples do not incorrectly contribute to completion credits.
| if sample_done_callback is not None: | |
| task.add_done_callback(lambda _task: sample_done_callback()) | |
| if sample_done_callback is not None: | |
| task.add_done_callback( | |
| lambda t: sample_done_callback() if not t.cancelled() and t.exception() is None else None | |
| ) |
There was a problem hiding this comment.
Same rationale as in inference_rollout_common.py: credits deliberately fire for every sample outcome so that in-flight concurrency is conserved exactly; conditioning on success would leak credits. Comment added in f814b44.
| except Exception as e: | ||
| logger.error(f"[rollout] Task raised exception: {e!r}", exc_info=True) | ||
| continue |
There was a problem hiding this comment.
When a group task fails with an exception, its samples do not complete successfully and won't trigger on_sample_done. This causes the pending slot to be permanently lost, reducing the rollout concurrency until pendings is completely empty. Replenish the failed group immediately by calling submit_groups(1) to maintain saturated concurrency.
| except Exception as e: | |
| logger.error(f"[rollout] Task raised exception: {e!r}", exc_info=True) | |
| continue | |
| except Exception as e: | |
| logger.error(f"[rollout] Task raised exception: {e!r}", exc_info=True) | |
| submit_groups(1) | |
| continue |
There was a problem hiding this comment.
The premise doesn't hold for the current implementation: when a group task raises, its per-sample done callbacks still fire (they're attached to the individual sample tasks, which run to completion independently of the group-level gather), so the failed group's n credits still arrive and trigger exactly one replacement group — the slot is not lost. Adding submit_groups(1) here on top of that would double-replenish: e.g. a group where 1 of 8 samples raises would emit 7-8 credits and an immediate extra group, over-submitting by up to ~2x per failed group. Keeping replenishment purely credit-driven preserves the exact conservation invariant.
If the data source returns no groups while nothing is in flight, break out of the collection loop instead of blocking forever on the sample-completion queue (review feedback). Also document the credit-conservation invariant at the sample_done_callback call sites: the callback deliberately fires for every sample task regardless of outcome, so completion credits exactly conserve in-flight sample concurrency -- conditioning on success would leak scheduling credits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013vyY58s2TFpCzqEP4KaSxC
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013vyY58s2TFpCzqEP4KaSxC
|
This looks like a really neat way to keep things running smoothly! ✨ 😄 Maintainer? Turn off weaves from non-maintainers → |
Motivation
In group-based RL (GRPO-style,
n_samples_per_promptsamples per prompt) with long-horizon agentic rollouts, the async schedulers currently backfill at group granularity: a replacement group is only submitted when an entire group task returns. When individual trials run 20-120 minutes (e.g. SWE-agent tasks at 64K context), every group is gated by its slowest sibling, and engine capacity idles while stragglers finish. In our production runs (n=8, 32 concurrent groups on SWE tasks) this straggler gating was a primary rollout-throughput limiter.What this PR does
--rollout-sample-completion-backfill(default off — the legacy group-level scheduler remains the default path and behavior is unchanged unless the flag is set).n_samples_per_promptcredits accumulate, one replacement prompt group is submitted immediately — keeping in-flight sample concurrency saturated instead of waiting for whole groups to return.sample_done_callbackthroughgenerate_and_rm_groupand both inference-rollout implementations, plus the fully-async example worker loop (refactored around asubmit_one_group()helper).Testing
tests/fast/rollout/inference_rollout/test_sample_completion_backfill.py):n_samples_per_promptsample completions.tests/fast/rollout/inference_rolloutsuite: 45 passed.🤖 Generated with Claude Code
https://claude.ai/code/session_013vyY58s2TFpCzqEP4KaSxC