Skip to content

feat(checkpoint): recover active vLLM generation prefixes - #4506

Open
macandro96 wants to merge 29 commits into
amahishi/rollout-deadline-snapshotfrom
amahishi/ckpt-v2-prefix-recovery-pr1
Open

macandro96 wants to merge 29 commits into
amahishi/rollout-deadline-snapshotfrom
amahishi/ckpt-v2-prefix-recovery-pr1

Conversation

@macandro96

@macandro96 macandro96 commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

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 fence
Loading

Reading decode progress. Captured calls keep vLLM's own output kind (FINAL_ONLY for non-streaming chat); nothing is copied per decode step. At setup the worker wraps vLLM's OutputProcessor.process_outputs so 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_request is 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. With vllm_cfg.flat_logprobs, each sampled token's logprob is read straight from FlatLogprobs' arrays instead of building a dict of Logprob objects 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_tokens by 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

  • Any call that cannot be cut regenerates from its previous boundary. A missing cut costs time, never correctness.
  • Fence epochs: a fence close that runs after the driver already timed out and released it is a no-op, so the gate cannot stick closed. Fence closes run on their own executor, never queued behind a slow cut.
  • Cut deadline: a cut is bounded by 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.
  • Character boundaries: a cut that would stop inside a multi-byte character is refused; vLLM's tail detokenizer would otherwise echo the prompt into the response.
  • Sampling settings that a resumed tail cannot honor keep the call uncut: stop strings, multi-token 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.
  • The direct reads touch private vLLM state, so setup refuses anything but GIL-enabled CPython and vLLM 0.29.x (the pinned release).

Changes

Area What it does
Config / setup target_level: prefix requires 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 by loss_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 (vllm_worker_async.py, new generation_cut_capture.py) Per-call token list with a staged-token count, snapshot gate, generation-cut endpoint (route pinned to Gym's 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.
Controller (single_controller.py, rollout_checkpointing/) Fence around the Gym checkpoint; Gym-referenced chunk rows count as expected staging rows; snapshot cadence keeps cutting while a dispatched prefix grows; cut metrics per checkpoint and prefix metrics per step.
Gym environment checkpoint (environments/checkpointing/nemo_gym.py) Holds the generation fence across the snapshot and reports Gym's cut counts.
TQ sink / ledger Chunk-row staging keys; preserves_generation_prefix recovery level.
Tests Worker cut/resume/fence/progress-read unit tests (fence races, deadline, mid-character prefixes, finished-request retention, uncuttable settings) and 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

token_capture:
  enabled: true
  control_timeout_s: 60.0
rollout_recovery:
  target_level: prefix
rollout_checkpointing:
  restore_mode: latest
  # As with turn level (#5056): a snapshot follows every trainer checkpoint and
  # the checkpoint_must_save_by deadline; set an interval for more.
  snapshot_attempt_interval_s: 5.0
checkpointing:
  save_data_plane: true

Testing

Local, in the CI container image, with no packages installed, on this head:

  • Unit, --nemo-gym-only: test_vllm_token_capture_hosting.py and test_captured_media.py, 195 passed.
  • Unit, default lane: 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.py prefix/turn/top-k/score-centering/deadline cases (62 passed); test_vllm_generation.py capture and generation-cut route cases (18 passed); test_single_controller_actor.py train-pump cases (37 passed).
  • Unit, --vllm-only --nemo-gym-only: the bad-words check against real vLLM SamplingParams passes.
  • Unit, --vllm-only: the flat-array logprob read matches vLLM's own per-position mappings on a real FlatLogprobs; the default-lane test_vllm_utils.py (77 passed) covers repeated tokens, the floor and unpublished positions.
  • Functional: grpo_async_gym_single_controller_prefix_recovery.sh with test(checkpoint): add Workplace prefix-recovery parity #4507's harness passes on this head (Qwen3-1.7B, 1 node): with flat_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 default flat_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 every failed_cuts_* count stayed 0.
  • Not re-run on this base: the turn-recovery functional test and full unit lanes.

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 as generation_cut_calls, generation_cut_failed_calls, generation_cut_skipped_calls.

Per training step (generation_prefix/*, totals since the vLLM workers started):

Metric Meaning
restored_calls, restored_prefix_tokens Calls resumed from a restored prefix, and their prefix tokens
failed_cuts, failed_cuts_<reason> Uncut calls, by 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_deadline
completed_cuts_kept, cut_lifecycle_races Finished calls a cut kept whole; calls that finished while being cut
progress_reads, progress_read_calls, progress_read_wait_us, progress_read_hold_us Direct progress reads, the calls they covered, and the total time spent waiting for and holding vLLM's output lock

Fence 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:

  • Delete (unsafe): without the fence, a call cut in this checkpoint that finishes before the TQ save would delete the chunk rows Gym's commit references, and the save fails with "Gym checkpoint references staging rows missing from live TQ state".
  • Terminal PUT (safe): Gym does not reference that row, restore clears unreferenced staging rows, and turn-level checkpoints already accept terminal PUTs during the save.

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 prefix path and removes the PUT gate, so this fence goes away and completions no longer wait on a checkpoint.

Known follow-ups

  • After phase 2 restores the TQ checkpoint, TQ's controller logs dtype mismatch: existing=torch.int64, incoming=torch.float32 once for rollout_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.
  • Measure the direct progress reads on a large run (generation_prefix/progress_read_*); the functional test kills phase 1 before its first training step, so it never logs them.
  • Prefix recovery with score centering: stage sampler top-k with each prefix chunk and prepend it at the final write.
  • With async_rl.generation_fleet_health, the fence fans out to every worker, so a dead shard fails the snapshot (and the run after max_consecutive_failures).
  • Chunk rows from aborted calls stay in TQ until the next restore clears them.
  • Replace this fence with perf(checkpoint): batch obsolete prefix cleanup in background #4655's deferred delete queue (see "Why the snapshot fence").
  • Move GenerationCutCaptureMixin into a standalone GenerationCutCapture object built in setup_token_capture, like Gym's RolloutTokenCapture.

🤖 Generated with Claude Code

@copy-pr-bot

copy-pr-bot Bot commented Oct 5, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions github-actions Bot added the Documentation Improvements or additions to documentation label Oct 5, 2026
@macandro96
macandro96 added this pull request to stack #4509 October 5, 2026 17:00
@macandro96
macandro96 requested a review from terrykong October 5, 2026 17:00
@macandro96
macandro96 force-pushed the amahishi/ckpt-v2-prefix-recovery-pr1 branch from a5073d8 to f0eafb4 Compare October 6, 2026 02:47
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

✅ Submodule Fast-Forward Check Results

Check based on commit: f0eafb4 (PR #4506 from amahishi/ckpt-v2-prefix-recovery-pr1)

✅ Submodules that are properly updated:

Gym: ✅ PR branch is ahead of main branch (fast-forward)

All submodule changes look good! ✨

@macandro96
macandro96 force-pushed the amahishi/ckpt-v2-prefix-recovery-pr1 branch from f0eafb4 to 96a25ea Compare October 7, 2026 04:02
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

✅ Submodule Fast-Forward Check Results

Check based on commit: 96a25ea (PR #4506 from amahishi/ckpt-v2-prefix-recovery-pr1)

✅ Submodules that are properly updated:

Gym: ✅ PR branch is ahead of main branch (fast-forward)

All submodule changes look good! ✨

@github-actions

Copy link
Copy Markdown

✅ Submodule Fast-Forward Check Results

Check based on commit: 0c54ff7 (PR #4506 from amahishi/ckpt-v2-prefix-recovery-pr1)

✅ Submodules that are properly updated:

Gym: ✅ PR branch is ahead of main branch (fast-forward)

All submodule changes look good! ✨

@macandro96
macandro96 removed this pull request from stack #4509 October 12, 2026 23:15
@macandro96
macandro96 added this pull request to stack #4685 October 12, 2026 23:16
@macandro96
macandro96 removed this pull request from stack #4685 October 12, 2026 23:30
@macandro96
macandro96 added this pull request to stack #4686 October 12, 2026 23:32
@macandro96
macandro96 force-pushed the amahishi/ckpt-v2-prefix-recovery-pr1 branch from 0c54ff7 to 834241a Compare October 15, 2026 02:17
@github-actions

Copy link
Copy Markdown

✅ Submodule Fast-Forward Check Results

Check based on commit: 834241a (PR #4506 from amahishi/ckpt-v2-prefix-recovery-pr1)

✅ Submodules that are properly updated:

Gym: ✅ PR branch is ahead of main branch (fast-forward)

All submodule changes look good! ✨

1 similar comment
@github-actions

Copy link
Copy Markdown

✅ Submodule Fast-Forward Check Results

Check based on commit: 834241a (PR #4506 from amahishi/ckpt-v2-prefix-recovery-pr1)

✅ Submodules that are properly updated:

Gym: ✅ PR branch is ahead of main branch (fast-forward)

All submodule changes look good! ✨

@terrykong terrykong left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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_prefix replace the scattered is TURN checks.
  • 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 turn override under global prefix is 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

Comment thread docs/guides/single-controller.md Resolved
Comment thread nemo_rl/data_plane/tq_token_sink.py Resolved
@macandro96
macandro96 force-pushed the amahishi/ckpt-v2-prefix-recovery-pr1 branch from 834241a to 6da5c10 Compare October 18, 2026 03:50
macandro96 and others added 3 commits November 2, 2026 14:52
…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>
@github-actions github-actions Bot removed the CI Relating to CI label Nov 2, 2026
@macandro96
macandro96 force-pushed the amahishi/ckpt-v2-prefix-recovery-pr1 branch from c6f7d21 to 8387e0b Compare November 3, 2026 00:59
macandro96 and others added 3 commits November 3, 2026 10:10
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>
@macandro96
macandro96 removed this pull request from stack #5032 November 3, 2026 23:39
macandro96 and others added 10 commits November 3, 2026 16:15
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>
@macandro96
macandro96 force-pushed the amahishi/ckpt-v2-prefix-recovery-pr1 branch from 8387e0b to b6aff18 Compare November 4, 2026 19:37
@macandro96
macandro96 changed the base branch from amahishi/rollout-checkpointer to amahishi/rollout-deadline-snapshot November 4, 2026 19:37
@macandro96
macandro96 added this pull request to stack #5057 November 4, 2026 19:47
macandro96 added a commit that referenced this pull request Nov 5, 2026
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>
@terrykong terrykong added CI:Lfast Runs a fast test suite and re-use nightly `main` container (but sync dependencies to PRs version) and removed CI:L1 Run doctests, unit tests, and functional tests labels Nov 5, 2026
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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI:Lfast Runs a fast test suite and re-use nightly `main` container (but sync dependencies to PRs version) Documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants