[#13318][fix] Gracefully fit token budget at prep boundary - #15187
[#13318][fix] Gracefully fit token budget at prep boundary#15187thorjohnsen wants to merge 37 commits into
Conversation
The micro-batch scheduler's per-step token-budget estimate can diverge from the tokens actually materialized by _prepare_tp_inputs -- e.g. when a reuse-discounted last context chunk lands next to a near-full generation batch. That over-admission tripped the `total_num_tokens <= max_num_tokens` assert in _prepare_tp_inputs, which killed the background executor loop and wedged the server (health checks kept returning 200). Re-validate the budget in KVCacheManager.prepare_resources, before any KV cache is allocated: keep in-flight generation requests, and defer or re-chunk context requests so the batch can never overshoot. Re-chunking only reduces compute tokens (KV is allocated for the full prompt regardless) and is skipped for bidirectional-multimodal requests. A generation-only batch that still overflows raises a clear error instead of corrupting state. Adds GPU-free unit tests covering the upper-bound cost math, re-chunk, defer, multimodal safety, defer-the-rest ordering, and the generation-overflow error path. Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
|
/bot run --disable-fail-fast |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
Walkthrough
ChangesToken budget constraint and scheduling
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant PyExecutor
participant ResourceManager
participant KVCacheManager
participant ScheduledRequests
participant KVConnector
PyExecutor->>ScheduledRequests: record added inflight request IDs
PyExecutor->>ResourceManager: prepare scheduled batch
ResourceManager->>KVCacheManager: prepare resources
ResourceManager->>KVCacheManager: fit token budget
KVCacheManager->>ScheduledRequests: compute and trim eligible context chunks
ResourceManager->>KVConnector: publish trimmed scheduler output
PyExecutor->>ScheduledRequests: remove recorded inflight IDs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tensorrt_llm/_torch/pyexecutor/resource_manager.py`:
- Around line 1052-1059: The disaggregated generation-init requests are being
passed into token accounting; change the logic so that when deferring and
req.is_disagg_generation_init_state is true you treat the request as cost-free
and keep it unconditionally (append to kept and continue) instead of calling
self._request_forward_tokens and decrementing remaining; apply this same
early-return/keep pattern to the other similar block that currently computes
cost (the block around the other occurrence of self._request_forward_tokens /
remaining / kept).
- Around line 1071-1088: In _fit_token_budget, when you reassign
req.context_chunk_size (inside the loop that builds kept), mark that a re-chunk
occurred (e.g., set a local rechunked flag) and then call
scheduled_batch.reset_context_requests(kept) whenever rechunked is true (in
addition to the existing len(kept) mismatch check); this ensures
ScheduledRequests' chunk/last-chunk partition is rebuilt after any re-chunking
even if no requests were deferred. Use the existing symbols
req.context_chunk_size, kept, scheduled_batch.reset_context_requests, and the
_fit_token_budget function to locate and implement the change.
In `@tests/unittest/_torch/executor/test_token_budget_fallback.py`:
- Around line 86-101: Add a test variant that includes draft tokens so the
re-chunk path must update last-chunk bookkeeping: create a second scenario in
test_overshoot_rechunks_context where the context request (FakeRequest with
is_last_context_chunk=True and context_chunk_size=64) is accompanied by a
generator request that has non-zero draft tokens (set the FakeRequest attribute
draft_tokens > 0) before calling mgr._fit_token_budget(batch); after calling
_fit_token_budget assert the request is no longer treated as the last-chunk path
(check ctx.is_last_context_chunk is False and/or batch.num_context_requests
unchanged) in addition to the existing assertions on ctx.context_chunk_size and
total token requests computed via mgr._request_forward_tokens.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: b853fbd4-3c6e-4218-8c50-07ee5d795e03
📒 Files selected for processing (2)
tensorrt_llm/_torch/pyexecutor/resource_manager.pytests/unittest/_torch/executor/test_token_budget_fallback.py
|
PR_Github #53182 [ run ] triggered by Bot. Commit: |
|
PR_Github #53182 [ run ] completed with state
|
|
/bot run |
|
PR_Github #53360 [ run ] triggered by Bot. Commit: |
…t fallback When _fit_token_budget absorbs a token-budget overshoot by re-chunking the last context request (rather than deferring one), len(kept) is unchanged, so the previous code skipped reset_context_requests and left the request in the last-chunk bin. Because is_last_context_chunk is a computed property that flips to False once context_chunk_size shrinks, downstream then treated a non-last chunk as final and appended generation/draft tokens to it, producing empty query tensors (q.numel()==0) and invalid attention-kernel arguments. Track whether the batch was modified at all (re-chunk or defer) and re-bin in every modified case. Add a regression test and a docstring for _has_mm_bidirectional_block. Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
|
/bot run |
|
PR_Github #53365 [ run ] triggered by Bot. Commit: |
|
PR_Github #53360 [ run ] completed with state |
|
PR_Github #53365 [ run ] completed with state
|
|
/bot run |
|
PR_Github #53367 [ run ] triggered by Bot. Commit: |
|
PR_Github #53367 [ run ] completed with state
|
|
/bot run --disable-fail-fast |
|
PR_Github #53691 [ run ] triggered by Bot. Commit: |
|
PR_Github #53691 [ run ] completed with state
|
…prefill is enabled KVCacheManager._fit_token_budget re-chunked an over-budget context request even when chunked prefill was disabled. The non-chunked attention backend is not set up to consume a partial context chunk, so shrinking context_chunk_size produced an invalid forward pass -- manifesting across models/backends as q.numel()>0 asserts, "Separate quantized buffer is not provided", or cudaErrorInvalidValue. Because the fallback runs on every non-draft prepare_resources call, this broke a broad set of accuracy tests (DeepSeekV3Lite, Llama3 fp8, Qwen3, GPT-OSS) once the scheduler's reuse-discounted token estimate diverged from the materialized token count (the NVIDIA#13318 condition this guard targets) on a batch whose requests were not chunkable. Gate the re-chunk branch on chunked prefill being enabled; otherwise defer the request whole (deferral is always safe -- it drops the request from this iteration's batch and reschedules it later). The flag is threaded into KVCacheManager (default False, the safe defer-only behavior) and set from the finalized attn_runtime_features.chunked_prefill in _create_kv_cache_manager, which runs after py_executor_creator applies its SM-version / attention-backend overrides. Add a regression unit test covering the chunked-prefill-disabled deferral. Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
|
/bot run |
|
PR_Github #53972 [ run ] triggered by Bot. Commit: |
Add TorchLlmArgs.enable_token_budget_fallback (default True) so the prep-boundary token-budget fallback (KVCacheManager._fit_token_budget) can be disabled to restore the pre-fallback behavior. The flag is threaded through _create_kv_cache_manager onto the KVCacheManager and gates the call site in prepare_resources. Update the api_stability reference (references/llm.yaml) for the new beta field and add unit tests for the disabled gate and the opt-out default. Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
|
PR_Github #53972 [ run ] completed with state
|
…agers The prep-boundary token-budget fallback (NVIDIA#13318) defers/re-chunks context requests in `_fit_token_budget`, mutating `scheduled_batch` in place. It was invoked from `KVCacheManager.prepare_resources`, but the target KV cache manager is deliberately moved to the END of the resource-manager dict (`_util.py` `move_to_end(KV_CACHE_MANAGER)`). Under MTP with a separate draft KV cache manager, that draft manager's `prepare_resources` runs FIRST and adds C++ KV sequences for every context request in the batch -- including ones the fallback then defers. The deferred requests never complete, so their draft-side sequences are never freed; when those requests reschedule on a later iteration the draft manager adds them again, tripping `Assertion failed: emplaceDone (kvCacheManager.cpp)`. Token-budget fitting is a batch-level scheduling decision, not a per-pool one. Hoist it into `ResourceManager.prepare_resources` so it runs once, up front, before any manager allocates -- every manager (draft KV cache, MTP slot manager, etc.) then observes the same deferred batch. Reproduced on H100 with DeepSeek-V3-Lite + MTP(2) + chunked-prefill-off and verified the crash is gone. Adds a regression test asserting a manager registered before the target KV cache manager observes the already-deferred batch. Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
|
/bot run |
|
PR_Github #54315 [ run ] triggered by Bot. Commit: |
|
PR_Github #63075 [ kill ] triggered by Bot. Commit: |
|
PR_Github #63073 [ run ] completed with state |
|
PR_Github #63075 [ kill ] completed with state |
…atch Driving the token-budget fallback from _prepare_and_schedule_batch gave that method a new dependency on self.resource_manager, but unit tests exercise it on partially-constructed executors built with object.__new__(PyExecutor) that set only the attributes under test. Six cases in tests/unittest/_torch/executor/test_benchmark_disagg.py raised AttributeError: 'PyExecutor' object has no attribute 'resource_manager'. Guard the lookup the same way model_engine is guarded a few lines above, and for the same reason. A real executor always has a resource manager -- __init__ assigns it unconditionally -- so this only affects the partially-constructed executors the tests build. Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
|
/bot run --disable-fail-fast |
|
PR_Github #63079 [ run ] triggered by Bot. Commit: |
|
PR_Github #63079 [ run ] completed with state
|
|
/bot run --disable-fail-fast |
|
PR_Github #63161 [ run ] triggered by Bot. Commit: |
|
PR_Github #63161 [ run ] completed with state
|
|
/bot run --disable-fail-fast |
1 similar comment
|
/bot run --disable-fail-fast |
|
/bot run --disable-fail-fast |
|
PR_Github #63781 [ run ] triggered by Bot. Commit: |
|
PR_Github #63782 [ run ] triggered by Bot. Commit: |
|
PR_Github #63781 [ run ] completed with state |
|
PR_Github #63782 [ run ] completed with state
|
The token-budget guard ran before ResourceManager.prepare_resources, where
context_chunk_size still spans the reusable KV prefix and is not a forward-pass
token count. Reading it as one charges a request for tokens the forward pass
never computes: measured on H100, a request with chunk=19212 and
estimated_reusable_tokens=19200 has a true cost of 12 tokens but was charged
19212 against an 8192 budget, so the guard deferred requests that already fit.
That divergence is not knowable any earlier. The micro-batch scheduler admits a
batch on estimated_reusable_tokens, a radix-tree guess made during capacity
scheduling; the real figure is prepopulated_prompt_len, computed later inside
addSequence. This issue is precisely the case where the two disagree.
Move the trim to the end of ResourceManager.prepare_resources, after every
manager has prepared, where context_current_position and context_chunk_size are
final and the cost model is exact. The trim is shrink-only: blocks are allocated
for the whole prompt rather than for the chunk, so trimming a chunk changes no
block accounting and the tokens simply move to the next iteration. Nothing
leaves the batch, so the attention-DP _can_queue vote, the pipeline-parallel
inflight set and every earlier manager's per-request state stay consistent.
Validated on H100 at this base:
- test_token_budget_fallback.py: 23 passed
- tests/unittest/_torch/executor: 1357 passed
- shared-prefix reuse repro: keep=15, rechunk=0, defer=0
- defaults repro: keep=49 then keep=97, defer=0
- fault-injection A/B: with the trim, max forward tokens 2048 == budget and
all requests complete; without it, total_num_tokens (3606) > max_num_tokens
(2048) trips the _prepare_tp_inputs assert and kills the executor loop.
Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
|
/bot run |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
tensorrt_llm/_torch/pyexecutor/resource_manager.py (1)
397-402: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUpdate the stale comment for the renamed method and the new behavior.
The comment names
_fit_token_budgetand states the fallback is "defer instead of re-chunk". The method is nowfit_token_budget(Line 835) and it only shrinks chunks; it never defers.📝 Proposed comment fix
- # Whether chunked prefill is enabled for this engine. Gates the re-chunk - # path in _fit_token_budget: a context request may only be shrunk into a - # partial chunk when the attention backend is set up for chunked context. - # Defaults to False (safe: defer instead of re-chunk) and is set to the - # finalized value by _create_kv_cache_manager. + # Whether chunked prefill is enabled for this engine. Gates the shrink + # path in fit_token_budget: a context request may only be shrunk into a + # partial chunk when the attention backend is set up for chunked context. + # Defaults to False (safe: skip the shrink) and is set to the finalized + # value by _create_kv_cache_manager.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tensorrt_llm/_torch/pyexecutor/resource_manager.py` around lines 397 - 402, Update the comment above enable_chunked_prefill to reference fit_token_budget instead of _fit_token_budget and describe that this flag gates shrinking a context request into a partial chunk. Remove the inaccurate “defer instead of re-chunk” fallback wording while preserving the explanation that the finalized value is set by _create_kv_cache_manager.tensorrt_llm/_torch/pyexecutor/_util.py (1)
2438-2447: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winPass
enable_chunked_prefillthrough the V1 manager constructor.Add it to
manager_extra_kwargsforKVCacheManagersubclasses. UseFalsewhenmodel_engineisNonefor the one-model draft path.PyTorchModelEngineinitializesattn_runtime_featuresfor target and draft engines, so the guarded access is safe during estimation and draft-manager creation.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tensorrt_llm/_torch/pyexecutor/_util.py` around lines 2438 - 2447, Update the KVCacheManager construction flow to pass enable_chunked_prefill through manager_extra_kwargs for KVCacheManager subclasses, using model_engine.attn_runtime_features.chunked_prefill when available and False when model_engine is None. Remove the separate post-construction assignment and preserve the finalized target/draft engine value during manager creation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@tensorrt_llm/_torch/pyexecutor/_util.py`:
- Around line 2438-2447: Update the KVCacheManager construction flow to pass
enable_chunked_prefill through manager_extra_kwargs for KVCacheManager
subclasses, using model_engine.attn_runtime_features.chunked_prefill when
available and False when model_engine is None. Remove the separate
post-construction assignment and preserve the finalized target/draft engine
value during manager creation.
In `@tensorrt_llm/_torch/pyexecutor/resource_manager.py`:
- Around line 397-402: Update the comment above enable_chunked_prefill to
reference fit_token_budget instead of _fit_token_budget and describe that this
flag gates shrinking a context request into a partial chunk. Remove the
inaccurate “defer instead of re-chunk” fallback wording while preserving the
explanation that the finalized value is set by _create_kv_cache_manager.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 6ad4df2a-3dca-49c4-9d9e-1e13093eb8aa
📒 Files selected for processing (5)
tensorrt_llm/_torch/pyexecutor/_util.pytensorrt_llm/_torch/pyexecutor/py_executor.pytensorrt_llm/_torch/pyexecutor/resource_manager.pytensorrt_llm/_torch/pyexecutor/scheduler/scheduler.pytests/unittest/_torch/executor/test_token_budget_fallback.py
The comment still described the pre-rebase behaviour: it named the method _fit_token_budget (now fit_token_budget) and said the flag defaults to deferring instead of re-chunking. The trim no longer defers anything -- it shrinks context chunks and never changes batch membership -- so with chunked prefill disabled the chunk is simply left at its scheduled size. Comment only; no behaviour change. Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
|
/bot run |
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
|
/bot run --disable-fail-fast |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
tests/unittest/_torch/executor/test_token_budget_fallback.py (1)
1-16: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a residual-overshoot assertion to
test_nothing_is_ever_dropped.Test coverage summary
- 23 test functions were added. None were modified or removed.
tests/unittest/_torch/executor/test_token_budget_fallback.pyis covered by directory entries inl0_cpu.yml,l0_b300.yml,l0_h100.yml,l0_gb300_multi_gpus.yml, andl0_dgx_b300.yml.- Coverage is insufficient. After trimming,
test_nothing_is_ever_droppedleaves 148 forward-pass tokens against a budget of 128. Assert this residual overshoot to protect the one-block floor behavior.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/unittest/_torch/executor/test_token_budget_fallback.py` around lines 1 - 16, Update test_nothing_is_ever_dropped to assert that the post-trim forward-pass token total remains 148 against the 128-token budget, preserving coverage of the one-block floor behavior and residual overshoot.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/unittest/_torch/executor/test_token_budget_fallback.py`:
- Around line 330-340: Strengthen test_disagg_gen_init_requests_are_left_alone
by creating a genuine token-budget overshoot with a shrinkable non-disagg
request, ensuring fit_token_budget reaches its reduction loop. Keep the disagg
request large enough to expose accidental inclusion in the cost sum and verify
its context_chunk_size remains unchanged while the peer is reduced, covering
both the cost-sum filter and in-loop disagg guard.
---
Nitpick comments:
In `@tests/unittest/_torch/executor/test_token_budget_fallback.py`:
- Around line 1-16: Update test_nothing_is_ever_dropped to assert that the
post-trim forward-pass token total remains 148 against the 128-token budget,
preserving coverage of the one-block floor behavior and residual overshoot.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 5f50d6c7-5cd1-4405-942c-a2b24ca4c0d4
📒 Files selected for processing (5)
tensorrt_llm/_torch/pyexecutor/_util.pytensorrt_llm/_torch/pyexecutor/py_executor.pytensorrt_llm/_torch/pyexecutor/resource_manager.pytensorrt_llm/_torch/pyexecutor/scheduler/scheduler.pytests/unittest/_torch/executor/test_token_budget_fallback.py
🚧 Files skipped from review as they are similar to previous changes (3)
- tensorrt_llm/_torch/pyexecutor/scheduler/scheduler.py
- tensorrt_llm/_torch/pyexecutor/_util.py
- tensorrt_llm/_torch/pyexecutor/py_executor.py
| def test_disagg_gen_init_requests_are_left_alone(self): | ||
| # They only allocate/transfer KV cache and contribute no compute tokens. | ||
| mgr = _make_manager(max_num_tokens=128, tokens_per_block=16) | ||
| disagg = _FakeRequest(context_chunk_size=4096, is_disagg_generation_init_state=True) | ||
| ctx = _FakeRequest(context_chunk_size=16, prompt_len=16) | ||
| batch = _make_batch([disagg, ctx], []) | ||
|
|
||
| mgr.fit_token_budget(batch) | ||
|
|
||
| self.assertEqual(disagg.context_chunk_size, 4096) | ||
| self.assertEqual(ctx.context_chunk_size, 16) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Strengthen the disagg test so it discriminates the exclusion logic.
This test passes through the early return, not through the disagg guards. The totals are 16 tokens against a 128-token budget, so fit_token_budget returns at the excess <= 0 check and never reaches the loop.
Trace the two guards it is meant to cover:
- If the
not req.is_disagg_generation_init_statefilter in the cost sum were removed,totalwould become 4112 andexcess3984. - The loop would then visit
ctxfirst, which cannot shrink (a 16-token chunk at position 0 is already one block), and skipdisaggat the in-loop guard. - Both assertions would still hold. The test cannot fail for either defect.
Add a case with a real overshoot so the disagg request is reachable in the loop and a shrinkable peer exists.
As per path instructions, tests/**: "Act as a QA engineer reviewing test changes and coverage for TensorRT-LLM" and "Keep feedback actionable".
💚 Proposed additional test
def test_disagg_gen_init_requests_are_left_alone(self):
# They only allocate/transfer KV cache and contribute no compute tokens.
mgr = _make_manager(max_num_tokens=128, tokens_per_block=16)
disagg = _FakeRequest(context_chunk_size=4096, is_disagg_generation_init_state=True)
ctx = _FakeRequest(context_chunk_size=16, prompt_len=16)
batch = _make_batch([disagg, ctx], [])
mgr.fit_token_budget(batch)
self.assertEqual(disagg.context_chunk_size, 4096)
self.assertEqual(ctx.context_chunk_size, 16)
+
+ def test_disagg_gen_init_is_neither_costed_nor_shrunk_under_overshoot(self):
+ # A real overshoot, so the loop is reached. The disagg chunk must stay
+ # out of the cost sum and must never be shrunk, even though it is the
+ # largest shrinkable-looking chunk in the batch.
+ mgr = _make_manager(max_num_tokens=128, tokens_per_block=16)
+ disagg = _FakeRequest(
+ context_chunk_size=4096, prompt_len=4096, is_disagg_generation_init_state=True
+ )
+ ctx = _FakeRequest(context_chunk_size=64, prompt_len=64)
+ gen = _FakeRequest(py_beam_width=100) # leaves a 28-token budget
+ batch = _make_batch([disagg, ctx], [gen])
+
+ # The disagg chunk contributes nothing: 100 gen + 64 ctx, not 4260.
+ self.assertEqual(_forward_tokens(mgr, batch), 164)
+
+ mgr.fit_token_budget(batch)
+
+ self.assertEqual(disagg.context_chunk_size, 4096, "disagg must never be shrunk")
+ self.assertEqual(ctx.context_chunk_size, 16, "the real context chunk absorbs the excess")
+ self.assertEqual(batch.num_context_requests, 2)
+ self.assertIn(disagg, batch.context_requests)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/unittest/_torch/executor/test_token_budget_fallback.py` around lines
330 - 340, Strengthen test_disagg_gen_init_requests_are_left_alone by creating a
genuine token-budget overshoot with a shrinkable non-disagg request, ensuring
fit_token_budget reaches its reduction loop. Keep the disagg request large
enough to expose accidental inclusion in the cost sum and verify its
context_chunk_size remains unchanged while the peer is reduced, covering both
the cost-sum filter and in-loop disagg guard.
Source: Path instructions
build_scheduler_output ran at the end of KVCacheManager.prepare_resources,
i.e. before the token-budget trim, and handle_metadata() consumes its output
afterwards. So the connector was handed a SchedulerOutput describing the
untrimmed batch. RequestData.num_scheduled_tokens is documented as "the number
of scheduled tokens for the upcoming forward pass" and is built from
context_chunk_size, so every chunk the trim shrinks was over-reported. A
connector that decides what to save or offload from that count would publish KV
for tokens the forward pass never computed.
Move the call into KVCacheManager.publish_connector_scheduler_output, driven by
ResourceManager.prepare_resources after maybe_fit_token_budget. The hasattr gate
keeps KVCacheManagerV2 (which defines neither hook) on its existing path.
The disagg generation-init path calls the KV cache manager's prepare_resources
directly on its own mini-batch and does not go through the trim, so it publishes
explicitly and its behaviour is unchanged.
Measured on H100 with a recording connector that moves no KV, over 12 compared
context requests:
- publishing after the trim: 0 mismatches between what the connector was
told and what the forward pass computed;
- publishing before it (the previous ordering): 1 mismatch -- the connector
was told 1442 tokens for a request the forward pass computed 512 on.
Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
|
PR_Github #64394 [ run ] triggered by Bot. Commit: |
Description
Fixes #13318.
What goes wrong
The micro-batch scheduler admits a batch against an estimate of how much KV
cache each context request will reuse.
microBatchScheduler.cppchargesreuse_adjusted_compute(chunk, estimated_reusable_tokens, remaining), whereestimated_reusable_tokensis a radix-tree guess made during capacityscheduling. The number that actually governs the forward pass is
prepopulated_prompt_len, and that is not computed untiladdSequencerunsinside resource preparation — after the batch has been admitted.
When the real reuse comes in lower than the guess, the forward pass materializes
more tokens than were ever charged, and
_prepare_tp_inputstripsOn
mainthat assert is caught by the generic handler in_forward_stepandrouted to
_handle_errors, which fails every request in the batch andcharges the executor's error budget. The user-visible symptom is intermittent
request failures under load and, if the pattern repeats enough to exhaust the
error budget, a fatal shutdown. The reporter saw it under multi-turn chat replay
at ~7 QPS, with overshoots from +1 to +2890 tokens.
What this PR adds
Graceful handling of the case where the token budget is genuinely exceeded
because the scheduler mis-estimated reuse. This is not a tightening of the
estimate and it does not try to predict the divergence — the whole point is that
the estimate was already wrong and only
addSequenceknows by how much. Insteadthe batch is re-measured once the true numbers exist, and any overshoot is
absorbed by shrinking context chunks so the forward pass cannot exceed
max_num_tokens.Concretely,
KVCacheManager.fit_token_budgetruns at the end ofResourceManager.prepare_resources, after every resource manager hasprepared. At that point
context_current_positionandcontext_chunk_sizearefinal, so
_request_forward_tokensis an exact count of the position ids eachrequest will contribute, mirroring
_prepare_tp_inputs:min(context_chunk_size, context_remaining_length), plus drafttokens on the last chunk only;
beam_width * (1 + draft_len);If the total exceeds
max_num_tokens, context chunks are shrunk from the backuntil it fits. Trimming runs back-to-front because that is where the overshoot
comes from: only a last chunk carries a reuse discount and draft tokens, so it
is the request whose cost the scheduler can have under-charged. Shrinking it
converts it back into a chunking request, which is exactly the repair;
mid-prefill chunks are touched only if that is not enough.
Why running here is the only correct point. An earlier revision of this PR
re-validated the budget before allocation, from the executor loop. That cannot
work, and measurably did not: before
setPrepopulatedPromptLenruns,context_chunk_sizestill spans the reusable prefix and is not a token count atall. Reading it as one charges a request for tokens the forward pass never
computes. Measured on an H100 with the earlier revision applied: a request with
context_chunk_size = 19212andestimated_reusable_tokens = 19200has a trueforward cost of 12 tokens but was charged 19212 against an 8192 budget —
so the guard deferred requests that already fit, and did so repeatedly.
Why shrinking is safe, and why nothing needs to be deferred
Shrinking is close to free and — importantly — it never changes the membership
of the batch:
not for the chunk (
_collect_context_sequencessizesadd_sequence_batchfrom
prompt_len), so trimming a chunk moves compute to the next iterationand touches nothing the KV cache manager has already done.
setPrepopulatedPromptLenasserts thatposition + chunklands on a block boundary for every non-last chunk, so thenew chunk is the largest block-aligned size that sheds the excess.
scheduled but computing nothing, which never terminates; shrinking floors at
one block of forward progress.
is_last_context_chunkto False, so the batch is re-binned viareset_context_requests; otherwise downstream would still treat the requestas a final chunk and append generation/draft tokens to it.
bidirectional multimodal block silently breaks attention (mirrors the gate in
scheduler_v2._align_chunk_to_mm_block), and disagg generation-init requestshave no compute tokens to shed.
Because no request leaves the batch, none of the invariants that a deferring
trim would break are even reachable: no manager's per-request state is orphaned,
no sequence is added without a matching batch entry, and no rank can shed its
way to an empty batch.
The KV connector is told the trimmed batch
build_scheduler_outputran at the end ofKVCacheManager.prepare_resources,i.e. before the trim, and
handle_metadata()consumes its output afterwards.So the connector was handed a
SchedulerOutputdescribing the untrimmed batch.RequestData.num_scheduled_tokensis documented as "the number of scheduledtokens for the upcoming forward pass" and is built from
context_chunk_size, soevery chunk the trim shrinks was over-reported — and a connector that decides
what to save or offload from that count would publish KV for tokens the forward
pass never computed.
The call moved into
KVCacheManager.publish_connector_scheduler_output, drivenby
ResourceManager.prepare_resourcesaftermaybe_fit_token_budget. Thehasattrgate keepsKVCacheManagerV2(which defines neither hook) on itsexisting path. The disagg generation-init path calls the KV cache manager's
prepare_resourcesdirectly on its own mini-batch and does not go through thetrim, so it publishes explicitly and its behaviour is unchanged.
Measured on H100 with a recording connector that moves no KV, over 12 compared
context requests: publishing after the trim gives 0 mismatches between what
the connector was told and what the forward pass computed; publishing before it
(the previous ordering) gives 1 mismatch — the connector told 1442 tokens for
a request the forward pass computed 512 on.
How this behaves under each parallelism mode
Single GPU (TP=1, no attention DP). The trim is a local, deterministic
function of the batch and the budget. No collective is involved.
_can_queuereduces to
batch_size > 0on the one rank.Tensor parallel (attention DP off). Every TP rank schedules the same request
set and therefore holds the same batch, and
fit_token_budgetis a purefunction of
(batch, max_num_tokens)with no rank-dependent input. All ranksshrink identically, so the batches stay bit-identical going into the forward
pass.
_can_queueisbatch_size > 0evaluated locally and identically —there is no cross-rank vote to invalidate.
Attention DP. Each rank schedules its own batch, so the trim decision is
rank-local by construction. This is safe because the trim only changes
chunk sizes, never batch membership. The
_can_queuevote —can_queue = 0 not in tp_allgather(scheduled_batch.batch_size)— is taken beforeprepare_resourcesand gates on no rank being empty; sincebatch_sizeisunchanged by the trim, that vote remains valid afterwards and no re-vote or
extra collective is needed. A rank that shrinks more than its peers simply
computes fewer tokens that iteration.
Pipeline parallel.
_pp_schedule_and_propagategives every PP rank the samebatch, so all ranks reach the same trim decision. PP needs one extra
consideration, which this PR fixes:
_add_inflight_idsregisters the batch'slast-chunk context requests, and
_remove_inflight_idspreviously re-derivedthat set from the batch at removal time. The trim can move a request out of
context_requests_last_chunkin between, so the two views no longer agree._add_inflight_idsnow records exactly what it inserted onScheduledRequests.added_inflight_req_ids, and_remove_inflight_idserasesthat snapshot. This is load-bearing, not defensive: the scheduler skips inflight
ids, so an id left behind is unrecoverable — the request would never be
scheduled again while still holding its KV blocks and sequence slot.
What this PR deliberately does not do
main. The_prepare_tp_inputsassertstays a bare assert and no executor loop grows a new except branch. An earlier
revision converted it into a typed error routed to a server-terminating
shutdown behind an opt-out
TorchLlmArgsflag; both were removed after review.The flag did not restore pre-fallback behaviour when disabled (it selected a
third behaviour), and the fatal path called
_handle_errorsfrom inside theloop, which performs collective gathers under attention DP — the deadlock
hazard
_event_loop_wrapperexplicitly documents and avoids, for a conditionthat can be rank-local.
a batch with no context requests returns immediately — context work is the
only thing this can shed, so scanning generation requests was dead work on the
executor loop's hottest path; and a batch where the generation requests alone
already exceed the budget is warned about rather than raised on, even when
context requests are present. The earlier revision raised a
RuntimeErrorthere. That raise was rank-local under attention DP and unwound into
_event_loop_wrapper, killing one rank's loop thread while its peers waitedin a collective. Generation-only overshoot is a configuration property —
max_batch_size x beam_width x (1 + max_draft_len)is known at startup — andis better validated there than discovered in the scheduling path; it currently
falls back to the existing assert, which fails one batch instead of the
server.
partial context chunk, which the attention backend is only set up to consume
under chunked prefill; forcing one produces an invalid forward pass. The
overshoot is logged with a clear warning and the pre-existing assert fires as
it does on
main.its one-block floor), the remainder is logged and the existing assert fires.
Handling that case requires removing work from the batch, which is a larger
change and is left to a follow-up.
KVCacheManagerV2is untouched. The V2 scheduler already sizes each chunkas
min(remaining_budget, context_remaining)and setscontext_chunk_sizetothat same value inline, so it is structurally immune;
ResourceManager.maybe_fit_token_budgetgates onhasattr(kv_cache_manager, "maybe_fit_token_budget"), which V2 does notdefine. The draft-model manager is skipped as well — it builds inputs with a
different token shape and its budget is handled separately.
There is no API change. This PR touches 5 files.
Test Coverage
tests/unittest/_torch/executor/test_token_budget_fallback.py(new, GPU-free)drives
KVCacheManager.fit_token_budgetdirectly with lightweight fakes —23 tests. Highlights:
test_trim_runs_after_every_manager/test_prepare_resources_trims— thecentral design claim: the trim is driven from the end of
ResourceManager.prepare_resources, after every manager has prepared.TestReuseDiscountedChunk— the regression for reading the chunk too early: a19212-token prompt with a 19200-token cache hit costs 12 forward tokens
(
test_reuse_hit_costs_only_the_uncached_tail) and must not be trimmed(
test_reuse_hit_is_not_trimmed). This is the defect the earlier revisionshipped with.
test_within_budget_is_noop/test_untrimmed_batch_round_trips— a batchwithin budget is left untouched.
test_overshoot_shrinks_context_to_fit— the [Bug]: Scheduler deadlock on main + #12976 + #13029: AssertionError total_num_tokens > max_num_tokens in _prepare_tp_inputs under KV offload + chunked prefill permanently hangs the event loop #13318 scenario: near-fullgeneration batch plus an oversized last chunk, shrunk to a block-aligned fit.
test_shrink_keeps_chunk_end_block_aligned/test_shrink_never_produces_a_zero_token_chunk— the two invariants above.test_shrink_rebins_to_chunking/test_shrink_drops_last_chunk_draft_tokens— a shrunk request stops being a last chunk and stops contributing drafts.
test_nothing_is_ever_dropped— shrink-only: membership never changes. Thisis the property every parallelism argument above rests on.
test_sheds_the_last_chunk_first/test_shrinks_multiple_requests_when_one_is_not_enough— back-to-frontordering, and spilling onto earlier requests only when needed.
test_gen_only_batch_is_left_alone/test_generation_alone_over_budget_does_not_raise— the hot-path early-out,and that generation-only overshoot does not raise.
test_no_shrink_when_chunked_prefill_disabled/test_mm_bidirectional_is_not_shrunk/test_disagg_gen_init_requests_are_left_alone— the three cases that must notbe re-chunked.
TestInflightIdsSurviveTrim(
test_shrunk_context_requests_leave_no_inflight_ids) — a shrunk contextrequest leaves no id stranded in the PP inflight set.
test_maybe_fit_token_budget_skips_draft_manager— the draft manager's batchis not trimmed.
Validation
Measured on H100 at this base:
test_token_budget_fallback.pytests/unittest/_torch/executor/keep=15, rechunk=0, defer=0— no request is deferred or re-chunked when reuse is estimated correctlykeep=49thenkeep=97,defer=0max_num_tokens, all requests completetotal_num_tokens (3606) > max_num_tokens (2048)→ assert →Sampling failed→ executor loop dies (i.e. #13318 reproduced)The fault-injection A/B is the direct evidence that the trim converts the
reported failure into a survivable, correctly-sized batch.
Dev Engineer Review
KVCacheManager.ResourceManager.maybe_fit_token_budget.QA Engineer Review
tests/unittest/_torch/executor/test_token_budget_fallback.py.TestReuseDiscountedChunkfor cache reuse and chunk bounds.TestFitTokenBudgetfor token accounting, budget fitting, request preservation, block-aligned shrinking, re-binning, draft-token handling, trimming order, disabled chunked prefill, multimodal and disaggregated requests, generation overflow, manager ordering, and resource preparation.TestInflightIdsSurviveTrimfor inflight-ID handling.TestConnectorSeesTheTrimmedBatchfor connector visibility after trimming.tests/integration/test_lists/test-db/ortests/integration/test_lists/.