Repository navigation
perf(checkpoint): batch generation prefix TQ I/O - #4508
macandro96 wants to merge 5 commits into
Conversation
e4dc44d to
079933e
Compare
079933e to
7eb9a01
Compare
7eb9a01 to
a82e2a2
Compare
a82e2a2 to
5498a0a
Compare
terrykong
left a comment
There was a problem hiding this comment.
Thanks for this. The write-side batching has a good shape: pulling out _encode_record so single-row and batch writes share one encoder, and the freeze → yield → seal generator, are both the right moves. These hold up: partial-PUT rollback, ack-before-seal, the deadline release, the oversized row written alone, lock order, dtype grouping, and the fingerprint exclusion (a mutation test confirms the test catches it). All the PR's CPU tests pass.
Before merge
- Rebase: GitHub shows conflicts with the base branch.
- The description says "Stacked on #4507", but the base is #4612's branch (
amahishi/r3-prefix-recovery). Please update it. - This is a perf PR with no numbers. Please add
timing/rollout_checkpoint/total_save_secondsbefore vs after to the description, from a run with hundreds of active calls per DP leader. It is already logged, and dispatch is blocked during the save, so it is the time the run loses. - Please close #4389 as superseded. It is the older copy of this PR, and its
tools/benchmark_tq_prefix_writes.pyis byte-for-byte the same.
Size. This PR is +2,802 lines, on a feature that is already over 20k lines across the stack. With the inline asks it drops to about +1,000 (≈ +500 product):
- one cut path (comment at L1399): ≈ −75 product, −30 to −40 test
- no copied config defaults or limits class (comment at L647): ≈ −30
- benchmark tools out (comment on the writes tool): −1,235
- no per-row results in the batch write (comment at L741): ≈ −20 to −30
- read side to its own PR (comment on
PrefixReadBatcher): ≈ −440
Smaller and optional: put could call put_many (−8), and a shared "rolled back" assert helper (first copy) plus pytest-asyncio in the batcher tests would save ≈ 23.
Open #4506 threads that also apply here
- r4262413829 (abort on the event loop waits on
lifecycle_lock): please fix it in #4506; this PR then needs no change. Until then, batching widens the stall from one call to up to 256 calls held across one put. - r4262414074 / r4262414095 (call on
self, not through the class, for the test fake): this PR adds 3 more class-qualified calls (generation_cut_capture.py L577, vllm_worker_async.py L903, L964) and grows the fake's name list from 21 to 24 (test L649-L652). - r4262413947 (simplify the frozen/sealed buffer): this PR adds one more rollback (L1096) and widens the rollback
excepttoBaseException(L1188).
Question, no change needed. Cut batches count tokens only (L1340). With MoE router replay, each token also carries layers × top-k int16 routes: about 3.2 GB at 48 layers / top-8 for a full 4M-token batch (arithmetic only; each chunk holds only the tokens since the last cut). What is the largest single put you have seen with routes on?
Visual explainer: https://terrykong.github.io/gh-pages-poc/terryk/pr-4508-batched-prefix-io.html
Visual explainer: https://terrykong.github.io/gh-pages-poc/terryk/pr-4508-size-and-reuse.html
Generated by Claude Code
5498a0a to
acb663b
Compare
|
Thanks for the thorough review. Status of the summary items: Before merge
Size: the inline asks are in, except moving the read side to its own PR. I kept the batcher here because the mass-resume numbers back it, and I noted the one-GET-at-a-time head-of-line trade-off in the description. The benchmark tools now live on #4506 threads: all three are fixed in #4506, which this stack includes:
Route bytes: I haven't measured the largest put with routes on yet. Your bound is right: the cut limit counts token rows only, and a full 4M-token batch would carry about 3.2 GB of routes at 48 layers / top-8. The realistic worst case is a worker's first cut, since each call's first chunk includes its prompt. I'll measure it on an R3 run and report back. |
|
/ok to test def6e59 |
def6e59 to
c3eb2e4
Compare
Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
…refix I/O tests NeMo Gym's GenerationCutPrefix names its attempt attempt, as EpisodeId does; it was attempt_index. Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
- PrefixReadBatcher: a failed multi-key GET re-reads each key alone, once, for any error, so one bad row fails only its own callers and a TQ outage costs N+1 GETs instead of 2N-1. - Reads are capped by rows only. Every key was charged max_model_len tokens, so the token budget silently acted as a ~32-key cap tied to the write knob. - One cut path: every cut goes through the batched transaction loop (batch size 1 writes one row per put). Remove _checkpoint_active_generation_cut and TQTokenSink.stage_generation_prefix. - stage_generation_prefix_batch raises on the first failure and returns the written keys. A failed put discards every key of the batch; flush() clears written rows whose transaction never sealed. The transaction receives the staged key instead of a StageResult. - Config defaults live only on RolloutRecoveryConfig: setup_token_capture and PrefixReadBatcher take the limits without defaults, and GenerationPrefixBatchLimits with its helpers is gone (Field(gt=0) already validates). - Drop the benchmark tools and their test; results go in the PR description and the tools live on amahishi/ckpt-v2-prefix-io-benchmarks. Signed-off-by: Anish Mahishi <amahishi@nvidia.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The base now acknowledges a call that cannot be cut as a durable_failure and lets it regenerate after a restore, instead of failing the receipt for every call on the worker. Do the same in the batched path: - A failed freeze, acknowledgement or seal fails only its own call. A cut frozen before the transaction's rollback scope begins is released by the caller, as the base's per-call loop did. - A failed batch put is retried one row per put (the sink has already discarded the batch), so one bad row fails only its own call. - Rows written but never handed to a call, which only an interruption can leave, are still cleared. Restores take the base's regenerate-on-failure path: the worker's batched reader fetches the chunks and _resolve_usable_generation_cut uses them, reading the rows alone if the shared read failed. Signed-off-by: Anish Mahishi <amahishi@nvidia.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c3eb2e4 to
6c72a0f
Compare
What does this PR do ?
Batches the TransferQueue I/O behind generation-prefix recovery: a checkpoint cut stages all of a worker's active prefixes in a few bounded PUTs instead of one PUT per call, and resumed requests share coalesced GETs for their restored prefixes.
On 8,192 rows across 32 TQ clients, a cut's writes finish 23× faster at 1k-token prefixes (10.1 s → 0.44 s) and 2.7× faster at 16k (10.2 s → 3.75 s). A mass resume of 8,192 restores across 128 workers finishes 6–13× faster, with p50 restore latency down from 6.9 s to 1.3 s. See Benchmarks.
Stacked on #4612 (router-replay prefix recovery). The diff shows only this PR's commits on top of the prefix-recovery stack.
Issues
How it works
flowchart LR subgraph Cut["Checkpoint cut (per vLLM DP leader)"] I[Gym inventory<br/>active calls] --> F[Freeze each call<br/>lifecycle lock held] F --> B[Group rows into batches<br/>≤ batch_size rows, ≤ max_tokens] B --> P[One TQ PUT per batch] P --> V[Validate every ack] --> S[Seal all calls] end subgraph Restore["Resume after restore"] R1[request A] --> Q[PrefixReadBatcher] R2[request B] --> Q Q --> G[Coalesced TQ GET<br/>≤ batch_size keys, deduplicated,<br/>chain order kept] endWrites (
generation_cut_capture.py,tq_token_sink.py): each active call becomes a generator transaction that freezes its buffer under its lifecycle lock, yields its prefix record, and seals only after every acknowledgement in the batch validates. Every cut takes this path;generation_prefix_batch_size: 1writes each call in its own PUT.stage_generation_prefix_batchgroups rows by field dtype/shape, writes oneput_manyper group, and returns the written keys; on a failed put it discards every key of the batch, since earlier groups may already be written, and raises. A row larger than the token limit is written alone and never split.Failure handling: as in the base stack, failures are per call: a call that cannot be cut is acknowledged as
durable_failureand counted under the base's failure reasons (cut_error, orcontrol_deadlinepast the deadline), its frozen cut rolls back, and it regenerates after a restore, while the other calls keep their cuts. A failed batched PUT is retried one row per PUT, so one bad row fails only its own call. A failed acknowledgement or seal fails only its call, whose transaction clears its own row. Rows written but never handed to a call, which only an interruption can leave, are cleared too, so no unsealed key is ever advertised to Gym.Cut deadline: the base stack bounds a cut by Gym's control timeout. Past the deadline no new TQ write starts. Rows frozen for the next batch roll back, and they and every call not yet reached are acknowledged as
durable_failure; they regenerate after restore.Reads (
prefix_read_batcher.py,vllm_worker_async.py):PrefixReadBatcheris an event-loop-local coalescer for restore-time GETs. Concurrent requests share GETs of at mostgeneration_prefix_batch_sizedistinct keys, with keys deduplicated and each request's chain order preserved. Cancelling one request does not cancel the shared GET for others. If a multi-key GET fails for any reason, each key is re-read alone once, so a bad row fails only the requests that asked for it, and an outage costs N+1 GETs. The rows go to the base stack's_resolve_usable_generation_cut, which regenerates the call if its prefix still cannot be used.Config: two new knobs under
rollout_recovery. Their only defaults are theRolloutRecoveryConfigfields;setup_single_controllerpasses both toGenerationInterface.setup_token_captureand on to the vLLM workers. Megatron generation accepts and ignores them.Both are runtime knobs, excluded from the rollout-checkpoint bootstrap fingerprint, so changing them does not invalidate an existing checkpoint.
Benchmarks
One 128-CPU node, TQ
simplebackend, synthetic rows (no routes or media). The tools call the production sink andPrefixReadBatcherdirectly; they are kept off this PR on branchamahishi/ckpt-v2-prefix-io-benchmarks, with the exact commands in its commit message.Cut-time writes
8,192 rows from 32 process-isolated TQ clients sharing one deployment. Batch size 1 is one PUT per call, as before this PR.
A row waits longer for its own PUT, but a cut returns its receipt only after every row is written, so wall time is what the cut deadline sees.
Restore reads
"per-request" is the old path: each restore reads its keys in its own
asyncio.to_threadGET. "coalesced" isPrefixReadBatcher(256 keys/GET, 2 ms coalescing window). Each mode ran twice, in alternating order; the table shows the slower run.TQTokenSource.fetchrather thanfetch_for_finalization. A restore'sstaging_chainreads throughChainPrefixCacheare not coalesced in either mode.Changes
nemo_rl/models/generation/generation_cut_capture.py,nemo_rl/data_plane/tq_token_sink.pynemo_rl/models/generation/prefix_read_batcher.py(new),nemo_rl/models/generation/vllm/vllm_worker_async.pysingle_controller_utils/{config,setup,rollout_checkpoint}.py,generation/{interfaces,vllm/vllm_generation,megatron/megatron_generation}.py, example SC configstest_prefix_read_batcher.py(new);test_vllm_token_capture_hosting.py,test_megatron_token_capture_hosting.py,test_interfaces.py,test_tq_token_sink.py,test_captured_media.py,test_setup.py,test_rollout_checkpoint.pyLines: +1758 / −307 (product +767 / −270, tests +991 / −37).
Testing
On this branch (
6c72a0f19), with Gym at the pinned5911d628:--nemo-gym-only):test_vllm_token_capture_hosting.py+test_megatron_token_capture_hosting.py+test_prefix_read_batcher.py+test_tq_token_sink.py+test_captured_media.py: 407 passed.test_interfaces.py+tests/unit/single_controller/test_setup.py+test_rollout_checkpoint.py: 381 passed.tests/functional/grpo_async_gym_single_controller_prefix_recovery.shpasses locally on perf(checkpoint): batch obsolete prefix cleanup in background #4655's head (fd86c61e7), which is this branch plus background prefix cleanup, run with that cleanup enabled: 8/8 rows match the uninterrupted run, no restored row whose phase-1 prefix differed from the reference, 8 restored prefixes (148–229 tokens), 99.09% bit-identical generation logprobs over 9,312 tokens (max |diff| 0.18),invalid_row_rateandcapture_poisoned_rolloutsboth 0, and no failed cut, fallback regeneration or failed clear in the log.Additional Information
timing/rollout_checkpoint/total_save_secondsbefore vs after, from a run with hundreds of active calls per DP leader. The microbenchmarks above isolate TQ I/O; that run will show the end-to-end save time the training loop loses.dtype mismatch: existing=torch.int64, incoming=torch.float32for partitionrollout_stagingright after the native TQ checkpoint restore. The batched writer groups rows by field dtype, and all checks passed; I have not yet confirmed whether the same line appears on the base stack.main, not from this stack:test_single_controller_actor.py::test_opd_advantage_stage_pools_raw_gap_separately_under_tropdfails withAttributeError: ... '_train_data_dump'(reproduces on main at19b245a8d).🤖 Generated with Claude Code