Repository navigation
feat(checkpoint): recover active vLLM generation prefixes - #4506
macandro96 wants to merge 29 commits into
Conversation
a5073d8 to
f0eafb4
Compare
f0eafb4 to
96a25ea
Compare
96a25ea to
0c54ff7
Compare
0c54ff7 to
834241a
Compare
1 similar comment
terrykong
left a comment
There was a problem hiding this comment.
Thanks for reshaping #4319 into this PR. Several things got clearly better since the earlier rounds:
- The config is now
rollout_recovery.target_level: prefix, which matches the target-level ask on #4266. - Gym's wire models are imported instead of copied.
- The enum properties
requires_gym_checkpoint/preserves_generation_prefixreplace the scatteredis TURNchecks. - The CPU tests are thorough: all ~2,300 CPU-runnable tests this PR touches pass locally.
Correctness. Two inline comments are about restored output that does not match what the same call returns without a restart: stop strings and presence/frequency penalties across a cut (on to_sampling_params), and a stop string added back to a call that was cut after it finished (on _restore_response_prefix). Please fix the first one in this PR, since it breaks the PR's "a missing cut costs time, never correctness" promise.
Size. This PR adds 3,752 lines: 2,007 source, 1,150 unit tests, 581 functional tests, 14 docs. The whole feature stack is about 21k lines: #4500 8,171, #4501 1,362, #4506 3,752, #4507 2,098, #4583 1,131, #4612 913, #4508 2,786, and about 893 of #4655's own (GitHub shows +17,969 for #4655 only because its base is stale). The rework from #4319 cut tests, not source: the cut module went from 904 to 821 lines and the worker changes from 786 to 761. Together, the size comments on this PR remove about 300 source lines (about 15%) plus about 55 test lines: the two output accumulators, the capture buffers, the controller fence setup, the fallback ack, the repeated snapshot checks, the dead compatibility code, the duplicate logprob loop, and the copied functional-test helpers. Please apply them before the next PRs in the stack build on this code.
Design question. The snapshot fence (~250 source lines) and #4655's 253-line cleanup module both exist so the terminal path can delete chunk rows while a checkpoint runs. Was moving that delete to a point outside the checkpoint considered? If so, please say in the PR body why it does not work.
Follow-up, not for this PR. GenerationCutCaptureMixin holds its own closed state, two executors and the gate, and borrows 11 worker attributes (3 declared). A GenerationCutCapture object built in setup_token_capture, like Gym's RolloutTokenCapture, could be tested on its own. It gets smaller if the buffer simplification and the design question above land.
PR description.
- The follow-up "A
turnoverride under globalprefixis accepted but has no effect" is not true: the validator raises (config.py L757-L764). Please drop that line. - Please state that no CI has run on #4506 itself (only bot checks so far).
Stack-wide merge blocker. The Gym pin 120d516f is not on Gym main (81 commits ahead, 140 behind, and Gym main has no nemo_gym/_checkpoint/). #4500 sets it, so it blocks the whole stack, not only this PR.
Linter: ruff and ruff-format pass; pyrefly could not run locally.
Visual explainer: https://terrykong.github.io/gh-pages-poc/terryk/pr-4506-prefix-vs-turn.html
Visual explainer: https://terrykong.github.io/gh-pages-poc/terryk/pr-4506-size-and-reuse.html
Generated by Claude Code
834241a to
6da5c10
Compare
…helper Four helpers were copied across the Gym checkpoint tests: - a staging-key lister that returns nothing (3 copies); - a rollout-manager stand-in that pauses request deadlines (2 copies); - a GymEnvironmentCheckpoint builder (4 variants); - a context manager that yields one snapshot's Gym commit (2 copies). They now live once in tests/unit/environments/gym_checkpoint_fakes.py, next to the fake Gym actor they wrap, as no_staging_keys, deadline_pausing_manager, gym_environment_checkpoint and snapshot_commit. A change to GymEnvironmentCheckpoint's constructor now touches one test helper. Line-neutral (tests +145 / -148). No test changed what it checks. Unit: 2053 passed (default lane), 98 passed (Gym lane). ruff clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
GymCheckpointCoordinator retired the restored attempts and reopened Gym in two places: restore(), when the restore itself fails on any instance, and restored(), when the body after a successful restore fails. restore() hand-copied retire_restored() to do it. Both now call _discard_restore, which calls retire_restored() and then resume(), adding any cleanup failure as a note on the original error. Differences, all in restore()'s failure path: - a failed cleanup step is caught as Exception, as restored() already did, instead of BaseException: a cancellation during cleanup now propagates rather than being recorded as a note; Gym's checkpoint lease reopens a deployment left parked; - a failed retire reports the operation as "retire", not "restore_retire", and the notes name which cleanup step failed. gym_checkpoint_coordinator.py: +24 / -45. Unit: 2053 passed (default lane), 98 passed (Gym lane). ruff and pyrefly clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
GymCheckpointCoordinator.commit repeated three checks made elsewhere: - that the commit scope and the restarts do not overlap. Its only caller, GymEnvironmentCheckpoint.snapshot, puts each episode on one side or the other; Gym refuses such a commit; and GymCheckpointManifest raises the same error when the manifest is built. - that each instance exported only the episodes it was asked for. The adapter checks every participant's reply against the requested episodes and builds the exported list only from those replies. - that the Ray reply is a GymCheckpointCommitSummary, which is what the actor method returns from the adapter. The coordinator test that faked an out-of-scope export covered only the second copy; the adapter keeps its test of the real check. gym_checkpoint_coordinator.py: +3 / -26. Tests: -18. Unit: 2053 passed (default lane), 97 passed (Gym lane; one test removed). ruff and pyrefly clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
c6f7d21 to
8387e0b
Compare
The run logs only averages and counts for rollout recovery, and the recovery ledger drops each group's history when the group finishes, so a restored run cannot be checked rollout by rollout. rollout_checkpointing.trace (default false) appends one JSON line per event to rollout_trace.jsonl in the log directory. Every line has the wall-clock time, a run ID that is new per process, and the event: - the recovery ledger reports every transition through an optional event sink: group reserved, admitted, sealed and discarded; attempt dispatched, sealed, refused, resumed, and abandoned with its reason (dropped, failed, cancelled or restart, and whole_group for a prompt-group restart). The rollout manager now tells a cancelled rollout, such as one the run's end stops, from a failed one; - the controller reports each trained step with its group IDs, and each weight sync; - the snapshotter reports each published trainer checkpoint and rollout snapshot, with when the snapshot's state was captured; - the restorer reports each restore and the unfinished groups it loaded. Events are queued and a background thread appends them, so tracing never blocks the controller's event loop, where many of them are raised under a data-plane mutation cut; a killed run loses only events not yet written. A failed write warns once and stops tracing without failing the run. A disabled trace holds no queue or thread, so the controller class that defaults to one still pickles for Ray. Rows match across runs by prompt_id (the dataset index) and generation_index. Restoring the ledger reports nothing; the restart after it reports which interrupted attempts resume and which start over. The switch is under rollout_checkpointing, which the bootstrap fingerprint already excludes. Nothing a checkpoint saves changes. The design doc lists the events and how a stop's thrown-away time and lost progress follow from them. Source: +311 / -15 (trace.py 126 lines). Tests: +350 (13 new tests). Unit: 2109 passed (default lane), 121 passed (Gym lane). ruff and pyrefly clean. The Gym turn-recovery functional test passes with the trace on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
Batch age is the token-weighted number of policy updates between generating a token and training on it. With token capture and the rollout trace on: - the reassembler returns each finalized group's tokens by the weight version each trained call began on; - the controller writes them in a group_published event, and step_trained also records the policy version the step updated. batch_age_by_step in rollout_checkpointing/trace.py computes each step's batch age, and `python -m nemo_rl.algorithms.rollout_checkpointing.trace <file>` prints it. A call that runs across a refit counts under the version it began on, so it counts as slightly older than it is. Source: +80 / -1. Tests: +67 (2 new tests, and the 20-turn capture pipeline test checks the token counts). Docs: +12 / -1. Unit: 2111 passed (default lane), 121 passed (Gym lane). ruff and pyrefly clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
Token capture stamps each model call with the weight version it began on, but vLLM keeps running calls across a refit, so a call's later tokens come from the new weights. Batch age counted them under the call's start version. With the rollout trace and token capture on: - each refit, while generation is paused, writes a refit_cut event: for every running captured call, how many tokens it has generated on the weights being replaced. The vLLM worker links each engine request to its captured call through a context variable set when capture begins the call, and reads the token count from vLLM's output-processor request state, which has no public API; an engine without that state, or another backend, reports nothing; - the reassembler returns each trained call's ID, start version and tokens, and group_published sums them by version but lists on their own the calls a refit paused; - tokens_by_version splits those calls at their refits, and batch_age_by_step uses the split. Nothing Gym stores or a checkpoint saves changes. Source: +196 / -41. Tests: +280 / -56 (vLLM wrapper tests and refit-split tests). Docs: +10 / -5. Unit: 2116 passed (default lane), 121 passed (Gym lane); the vLLM wrapper tests pass in vLLM 0.29's venv. ruff and pyrefly clean. On a GPU Gym run, a call a refit paused before its first token was counted under the new weights. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
When checkpointing.checkpoint_must_save_by fired after a train step, the controller saved a trainer checkpoint and stopped at once. A trainer checkpoint holds no environment state, and stopping cancelled the snapshot pump before it could save one, so a turn-level run lost every unfinished turn at the deadline. Rollout snapshots are now on when snapshot_attempt_interval_s is set or rollout_recovery.target_level is "turn". With turn-level recovery: - The trainer checkpoint the deadline triggers is followed by a forced rollout snapshot before the run stops. A failure that leaves checkpoint state consistent only warns; the trainer checkpoint is still durable. - New rollout_checkpointing.snapshot_on_trainer_checkpoint (default true) keeps today's snapshot after every other trainer checkpoint. false with no interval saves environment state only at the deadline. - snapshot_attempt_interval_s is no longer required. Setup instead rejects a turn-level config that would never save. - The pump waits only for wakeups when no interval is set, and before the first step also for the deadline. Each snapshot also records how long it took, and the deadline is moved earlier by that much, so the trainer checkpoint and the snapshot both fit. Deferred checkpoint finalize stays rejected with an interval or with a snapshot after every trainer checkpoint; deadline-only is allowed, because the deadline publishes step_N before its snapshot. Unit: 563 passed (checkpointing, setup, timer and trace tests), Gym lane 7 passed. ruff and pyrefly clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
Signed-off-by: Anish Mahishi <amahishi@nvidia.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NeMo Gym renamed two generation-cut wire fields so every model names the same thing the same way: - GenerationCutPrefix names its attempt attempt, as EpisodeId does (it was attempt_index). - GenerationCutContinuation names the staged prefix prefix_token_count and prefix_digest, as the worker's acknowledgement already does (they were generation_token_count and digest). The vLLM worker reads and checks the renamed fields. NeMo RL's own names for a restored prefix's length follow them: the output-limit helper's argument, _RestoredPrefixTerminal and its classifier, and the inherited count in the capture state. A call's total output keeps the name generation_token_count, because it is a different count. The prefix recovery snapshot reads the renamed keys from Gym's checkpoint records. Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Correctness: - Replay a restored prefix through vLLM's own incremental detokenizer, primed on the original prompt, so SentencePiece keeps its leading space and a stopped call drops its stop token or stop string exactly as vLLM did. - Keep calls using stop strings, bad_words, or presence/frequency penalties out of generation cuts; they regenerate after a restore. - Apply a restored call's remaining budget even when the request set neither max_tokens nor max_completion_tokens. - Abort a failed request's capture off the event loop when a cut or terminal write holds its lifecycle lock. Simplification: - Request CUMULATIVE vLLM outputs and drop the two delta accumulators. - Replace the active/frozen/sealed capture buffers with one token list and a staged count. - Move the snapshot fence into a context manager. - Remove the no-op completed-ack fallback, the snapshot checks Gym repeats in begin_call, the unshipped staging-key compatibility branch, and the duplicate selected-token logprob loop. - Use Gym's GENERATION_CUT_ROUTE (pinned by a test) and EpisodeId.capture_key; pass generation_prefix_cuts_enabled as an explicit keyword. - Share the functional-test snapshot helpers between turn and prefix recovery. Observability: log the fence hold time and Gym's cut/failed/skipped counts per rollout checkpoint, and restored calls/prefix tokens per training step. Tests cover the replay, uncuttable calls, abort, cut route, fence error paths, setup guards, and metrics; docs describe the prefix target's requirements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
- Keep a call with a per-request seed out of generation cuts when it samples: vLLM seeds its own generator at admission, so a restored call would reuse the draws that chose its prefix. - Clear the chunk rows of an aborted call, and of a call whose terminal staging fails, through the token-capture snapshot gate; the abort hands the delete to a cleanup thread so it never blocks the serving loop. - Acknowledge a failed cut per call instead of failing the receipt for every call on the worker, roll back its frozen buffer, and check the effective output limit before freezing. - Regenerate a restored call from its turn boundary when its prefix cannot be fetched or does not match its admission, instead of failing the rollout; its restored rows are retired through the same cleanup. - Report a resumed call's restored prefix as completion tokens, as the uninterrupted call would, not only for a terminal prefix. - Observe only the tokens vLLM has published with their logprobs. The HTTP thread reads the engine's live lists while its loop appends, so a token read before its logprob waits for the next output instead of making the call uncuttable. - Log why a cut fails where it was silent: a call with no tokens yet (debug), a prefix ending inside a character, a completed call whose result cannot be used, and the calls a late cut skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
Captured calls no longer switch vLLM to CUMULATIVE outputs or copy each decode step's tokens across threads. A generation cut reads every inventoried call's new tokens from vLLM's request state in one engine-loop pass and reads their logprobs off the loop; a call's finished output records the rest. This removes the token/logprob race the per-step observer had to work around. Also rebuild the restored prefix snapshot with Gym's StagedCallRecord.from_components, acknowledge uncut calls with GenerationCutPrefixAck.failure, and share the vLLM snapshot-fence fan-out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
vLLM bans a one-token bad word at every step whatever precedes it, so a restored tail honors it exactly as the original call did; only a bad word whose leading tokens could sit in the restored prefix stops a cut. Expand the words with vLLM's own SamplingParams.update_from_tokenizer on a clone and block the cut only when some enforced form spans more than one token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
Every failed generation-cut acknowledgement now names one fixed reason (no record on the worker, unstaged or incomplete finished call, uncuttable sampling settings, recording error, no output limit, no generated tokens, a prefix ending mid-character, a per-call cut error, or the control deadline) and bumps its counter. Workers also count finished calls a cut kept whole and calls that finished while being cut. The counts ride the renamed get_generation_prefix_counts and appear as generation_prefix/* step metrics next to the restore counts. A call Gym names before its request reaches the worker now logs "this worker has no record of it" rather than claiming its result aged out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
A generation cut queued its progress read onto vLLM's engine loop, where it waited behind everything else on that loop; the same pattern for router-replay route reads measured 52 ms median, 588 ms p95 and 40 s worst, against 1-4 ms read directly. Each OutputProcessor.process_outputs call now runs under a worker lock, and a cut takes that lock once per inventory to copy every call's new token IDs at one output boundary, reading logprobs outside it. vLLM drops a request's state as soon as it finishes, so a call it had just finished was read as empty and regenerated. _finish_request now keeps that state, with its finish reason, until the request's consumer has taken the final output, which is recorded before the consumer asks for the next one. Setup refuses anything but GIL CPython and vLLM 0.29.x, the layout these private reads were checked on. Lock waits and holds are counted under generation_prefix/progress_read_*. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
Remove checks that Gym's models, setup or a sole caller already make: the doubled continuation and staging-source guards on restore (now one _resolve_generation_cut), the None output limit, the observe() gap branch (start is always read from the recorded count), the duplicate vLLM-backend check in setup, the worker's repeat of the setup-time media and top-k rejections, the inner token-capture check, the classifier's repeats of Gym's validators, and the hasattr scan the pinned vLLM version check covers. Share the inventory-identity check and the partial-cut rollback, skip recording finished outputs when prefix cuts are off, and fix two comments that still described per-step observation. Tests drop setup the cut never reads, a strict subset of the fence abort test, the RequestOutputKind fakes nothing imports, and rebuild the restore test from the existing helpers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
…e's link The batch-age trace (#5049) already maps each vLLM request ID to its captured model call, through a context variable set when capture begins the call and the HTTP client's _captured_requests. Generation cuts kept a second link for the same thing: a context variable set when sampling params are built, an on_generate callback writing engine_request_id onto the capture state, and a _serving_request_ids set. Read progress by model call ID through the trace's map instead, and keep finished-request state for the requests it lists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
8387e0b to
b6aff18
Compare
The class-qualified calls only existed so a SimpleNamespace test fake could run them. #4506 replaced that fake with a real worker; the two setup tests that build the prefix reader now use one too (_real_worker), so production code calls the method on self. Signed-off-by: Anish Mahishi <amahishi@nvidia.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With flat_logprobs, indexing a FlatLogprobs position builds a dict of Logprob objects only for the cut to pick one value out of it, for every token a cut reads and for every token of each captured call's final output, which runs on the worker's HTTP event loop. Read the sampled token's value straight from the position's candidate range instead, keeping vLLM's mapping behavior (the last repeated token wins) and the logprob floor. On vLLM 0.29 FlatLogprobs with 16k generated tokens this takes 0.9 ms instead of 6.6 ms with one stored logprob per position, and 2.7 ms instead of 23.9 ms with five. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
What does this PR do ?
Adds
rollout_recovery.target_level: prefix: at each coordinated Gym checkpoint, every active vLLM model call stages the tokens it has decoded so far, and after a restart the restored call resumes from that prefix and generates only its remaining tail.Stacked on #5056 (deadline snapshots), which sits on #5049 (batch age), #5048 (rollout trace) and #5011 (rollout checkpointing refactor). The base branch is
amahishi/rollout-deadline-snapshot, so this diff shows only the prefix-recovery commits. It is retargeted as that stack merges. The parity test that validates it end to end is in #4507, stacked on this one.Issues
How it works
Save side. A Gym checkpoint already parks rollouts and commits participant state (turn level). With
prefix, the controller also fences terminal token writes on every vLLM worker, and Gym asks each worker to cut its admitted calls:sequenceDiagram autonumber participant SC as SingleController participant W as vLLM worker participant G as Gym participant TQ as TransferQueue SC->>W: begin fence (terminal writes wait, decoding continues) SC->>G: prepare + commit checkpoint G->>W: POST /ng-control/v1/generation-cut (frozen inventory) W->>W: read every inventory call's new tokens from vLLM, under its output lock W->>TQ: stage each call's new tokens as a chunk row W-->>G: receipt (durable_prefix | durable_failure per call) SC->>TQ: save data-plane snapshot SC->>W: end fenceReading decode progress. Captured calls keep vLLM's own output kind (
FINAL_ONLYfor non-streaming chat); nothing is copied per decode step. At setup the worker wraps vLLM'sOutputProcessor.process_outputsso each output chunk runs under one worker lock. A cut finds each inventory call's vLLM request through the map #5049 keeps from engine request ID to captured model call, takes that lock once per inventory, copies every call's tokens past what it has already staged, all at the same output boundary, and reads their logprobs outside the lock. Reading this way instead of queuing a read onto vLLM's engine loop matters at scale: the same pattern for router-replay route reads measured 52 ms median, 588 ms p95 and 40.68 s worst through the engine loop, against 1.36 ms / 4.24 ms / 15.3 ms read directly (10-node A/B runs). vLLM drops a request's state when it finishes, so_finish_requestis also wrapped to keep that state, with its finish reason, until the request's consumer has taken the final output; a call vLLM just finished is still cut whole. Withvllm_cfg.flat_logprobs, each sampled token's logprob is read straight fromFlatLogprobs' arrays instead of building a dict ofLogprobobjects per position; for 16k generated tokens that is 0.9 ms instead of 6.6 ms, and the final-output read runs on the worker's HTTP event loop.Restore side. The restored call's admission carries its chunk keys. The worker rebuilds the prefix, splices it into the prompt, shrinks
max_tokens/min_tokensby the prefix length, and prepends the decoded prefix to vLLM's tail text before the response is parsed, so Gym, parsers, and verifiers see one uninterrupted completion. On success the chunk rows are replaced by the canonical terminal row.Safety properties
token_capture.control_timeout_s. If vLLM holds its output lock past it, each call is cut at the tokens it had already recorded; calls the cut does not reach in time are acknowledged as failures and regenerate.bad_words(one-token bad words are banned at every step regardless of context, so they stay cuttable), presence/frequency penalties, and a per-request seed.Changes
target_level: prefixrequires the turn-level prerequisites and the vLLM async engine. Megatron inference, multimodal capture, router replay and sampler top-k capture (policy.generation.top_logprobs > 0, used byloss_fn.score_centering) are rejected at setup; top-k is only staged from a call's final response, so a restored prefix would have none. Workers receive the control bearer token and timeout.vllm_worker_async.py, newgeneration_cut_capture.py)GENERATION_CUT_ROUTE), direct progress reads under vLLM's output lock with finished-request retention, prefix rebuild/resume, the restored prefix replayed through vLLM's own detokenizer, and restored-response parsing. Every uncut call is acknowledged with one fixed failure reason and counted.single_controller.py,rollout_checkpointing/)environments/checkpointing/nemo_gym.py)preserves_generation_prefixrecovery level.tests/functional/grpo_async_gym_single_controller_prefix_recovery.sh(full tier in L1).Commits, in order: the feature, Gym's generation-cut wire names (Ananth), two review rounds, reading progress from vLLM at cut time, single-token bad words, failure-reason counters, direct reads under vLLM's output lock, a cleanup of checks that Gym, setup or a sole caller already make, finding a cut call's vLLM request through #5049's link instead of a second one of our own, and reading sampled logprobs from vLLM's flat arrays.
Lines: source +2789 / −151, unit tests +3121 / −97, functional tests +615 / −9, docs/config +24 / −12.
Usage
Testing
Local, in the CI container image, with no packages installed, on this head:
--nemo-gym-only:test_vllm_token_capture_hosting.pyandtest_captured_media.py, 195 passed.test_vllm_captured_call_progress.py,test_rollout_trace.py,test_vllm_utils.py,test_vllm_chat_template_wiring.py,test_vllm_restored_prefix_replay.py,test_gym_environment_checkpoint.py,test_gym_prefix_recovery_parity.py,test_timer.py(237 passed, 1 skipped);test_checkpointing.py(180 passed);test_setup.pyprefix/turn/top-k/score-centering/deadline cases (62 passed);test_vllm_generation.pycapture and generation-cut route cases (18 passed);test_single_controller_actor.pytrain-pump cases (37 passed).--vllm-only --nemo-gym-only: the bad-words check against real vLLMSamplingParamspasses.--vllm-only: the flat-array logprob read matches vLLM's own per-position mappings on a realFlatLogprobs; the default-lanetest_vllm_utils.py(77 passed) covers repeated tokens, the floor and unpublished positions.grpo_async_gym_single_controller_prefix_recovery.shwith test(checkpoint): add Workplace prefix-recovery parity #4507's harness passes on this head (Qwen3-1.7B, 1 node): withflat_logprobs: true(452 s), 8 calls cut and restored, token/mask/reward parity exact, generation logprobs 99.11% bit-identical, max |diff| 0.212; on the commit before the flat read with the defaultflat_logprobs: false(461 s), 99.10%, max |diff| 0.162. Every regular snapshot cut 8 of 8 calls with 0 failed and 0 skipped, and everyfailed_cuts_*count stayed 0.CI status
No CI has run on #4506 itself yet (bot checks only).
Metrics
Per rollout checkpoint (
timing/rollout_checkpoint/*):generation_cut_fence_hold_seconds, and Gym's cut outcome asgeneration_cut_calls,generation_cut_failed_calls,generation_cut_skipped_calls.Per training step (
generation_prefix/*, totals since the vLLM workers started):restored_calls,restored_prefix_tokensfailed_cuts,failed_cuts_<reason>no_capture_or_completed_state,completed_not_staged,completed_missing_output_limit,completed_missing_finish_reason,uncuttable_request,observation_error,missing_output_limit,zero_generated_tokens,incomplete_utf8_boundary,cut_error,control_deadlinecompleted_cuts_kept,cut_lifecycle_racesprogress_reads,progress_read_calls,progress_read_wait_us,progress_read_hold_usFence hold time in the latest functional run (Qwen3-1.7B, 4 prompts × 2 generations, 5 rollout checkpoints): 0.13–0.21 s; total rollout-checkpoint save at most 0.31 s.
Why the snapshot fence
The fence exists only to keep the terminal path from deleting chunk rows that the in-progress Gym commit references. From fence begin (before Gym prepares and cuts) until the TQ snapshot is saved, terminal staging waits, so no terminal PUT or chunk-row delete reaches TQ between the cut and the snapshot. A call that finishes decoding inside the window is cut as an already-terminal prefix, and its response is withheld until the fence is released.
Of the two writes the fence holds back, only the delete is unsafe:
Moving the delete to the controller does not work: a still-running call's next cut references its earlier chunks, and only the worker knows them. Deferring deletes on the worker and pausing only that queue during the checkpoint window does work. #4655 implements that queue; the follow-up there makes deferred deletion the only
prefixpath and removes the PUT gate, so this fence goes away and completions no longer wait on a checkpoint.Known follow-ups
dtype mismatch: existing=torch.int64, incoming=torch.float32once forrollout_staging. It appears in every functional run on refactor(checkpoint): move rollout checkpointing out of the single controller #5011 and later and does not fail the run; not yet investigated.generation_prefix/progress_read_*); the functional test kills phase 1 before its first training step, so it never logs them.async_rl.generation_fleet_health, the fence fans out to every worker, so a dead shard fails the snapshot (and the run aftermax_consecutive_failures).GenerationCutCaptureMixininto a standaloneGenerationCutCaptureobject built insetup_token_capture, like Gym'sRolloutTokenCapture.🤖 Generated with Claude Code