Skip to content

[TRTLLM-14779][fix] Clear capture-only sampling override from cached CUDA graph metadata - #17033

Merged
xwang233 merged 3 commits into
NVIDIA:mainfrom
xwang233:fix/spec-capture-sampling-override-leak
Jul 31, 2026
Merged

[TRTLLM-14779][fix] Clear capture-only sampling override from cached CUDA graph metadata#17033
xwang233 merged 3 commits into
NVIDIA:mainfrom
xwang233:fix/spec-capture-sampling-override-leak

Conversation

@xwang233

@xwang233 xwang233 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Description

The bug

The advanced-sampling CUDA graph capture pass sets _force_non_greedy_for_capture=True on the live SpecMetadata so that parameter-less warmup requests scan as non-greedy, and the advanced-sampling branch (not the argmax fast path) is the one recorded into the graph.

create_cuda_graph_metadata() shallow-copies the live SpecMetadata, so every graph captured during that pass caches a copy that inherited the flag. Those copies are reseated as the live spec_metadata on each later replay, but the capture pass clears the flag on the base object only. The flag therefore stays set on the cached copies forever, and _scan_one_model_sampling() takes the capture branch at serving time, replacing every request's sampling parameters with the synthetic capture values — temperature 0.7, top_k 50, top_p 0.9.

A client asking for temperature=1.0 / top_p=1.0 is silently served at 0.7 / 50 / 0.9, on every row, for the whole lifetime of the server.

Preconditions and blast radius

Only one-engine speculative decoding (spec_dec_mode.use_one_engine() — MTP, EAGLE3-one-model, etc.) runs the capture pass, and only when CUDA graphs are enabled. Since cuda_graph_config defaults to enabled, that is the default serving posture for those modes; no opt-in is required to hit it. Non-speculative decoding never runs the pass and is unaffected.

Symptoms vary by workload, which is why this went unnoticed:

workload symptom
long-form reasoning at high temperature runaway repetition and token inflation; temperature 0.7 sharpens the distribution and top_p 0.9 can collapse the nucleus to a single token, so a repetition loop becomes inescapable and the generation only stops at the length cap
short-output chat / evals near-invisible quality and diversity shift — still plausible text, nothing fails
greedy requests in a mixed batch a request asking for temperature=0 is sampled at 0.7 / 50 / 0.9 whenever its batch contains at least one non-greedy request, i.e. non-deterministic "greedy" output depending on batch composition
logprob consumers / best-of-n silently biased sampling data

The fix

Clear the flag on the cached copies in the capture pass's finally, after every graph for the pass has been captured, so the flag is still in effect for the captures that need it.

Clearing it at copy time instead would be wrong: the pass-2 populate would then scan the parameter-less warmup requests as greedy and bake the argmax fast path — with the top-k/top-p kernels absent — into the graph keyed as the advanced-sampling variant. A unit test pins this constraint so the cleanup is not "simplified" later.

Also asserts in _prepare_inputs that the override is never live outside warmup. Kept fatal on purpose: the failure mode is silent output corruption, and a warning would be scrolled past.

Measured impact

GLM-5.2 NVFP4 on GB200, GPQA Diamond (198 questions), one-engine MTP k=1 with rejection sampling, CUDA graphs on, reasoning enabled with a 400k-token output budget, client requesting temperature=1.0 / top_p=1.0. "Runaways" are generations that hit the length cap while looping.

build accuracy runaways mean out tok p90 out tok max out tok
before fix 84.85% 15 / 198 62,470 217,814 399,783
speculative decoding off (reference) 90.91% 0 / 198 21,396 49,889 148,468
after fix, run 1 92.42% 0 / 198 21,100 50,710 143,750
after fix, run 2 91.41% 0 / 198 20,408 48,463 119,037
after fix, run 3 91.41% 0 / 198 19,870 55,768 87,619

Pooled over three full runs on the fixed build: 0 / 594 runaways, versus 15 / 198 before (Fisher exact p ≈ 2e-13). Every endpoint — accuracy, mean, p90, max — lands on top of the speculative-decoding-off reference, so the apparent accuracy and token-inflation penalty of enabling MTP on this configuration was entirely this bug.

Test Coverage

tests/unittest/_torch/speculative/test_capture_override_leak.py (new, CPU-only, no GPU or model forward required; registered in l0_h100.yml):

  • test_graph_copy_inherits_flag_and_base_teardown_does_not_reach_it — pins the shallow-copy semantics the bug rests on.
  • test_clear_capture_only_spec_state_clears_every_cached_copy — clears exactly the flagged copies, leaves greedy-pass copies and non-spec entries alone, idempotent.
  • test_serving_scan_honors_client_params_after_capture_teardownfails without the fix, where the scan returns 0.7 / 50 / 0.9 instead of the request's own parameters.
  • test_override_stays_live_while_the_flag_is_set — anti-regression for the rejected "clear at copy time" variant.

related: #14745

Dev Engineer Review

  • Added cleanup for capture-only speculative sampling state across cached CUDA graph metadata copies.
  • Added a warmup-scope assertion to prevent synthetic capture sampling parameters from leaking into serving.
  • Changes are consistent with the stated fix and include idempotent cleanup behavior.
  • Added H100 pre-merge coverage for the new regression test.

QA Engineer Review

  • Added:
    • test_graph_copy_inherits_flag_and_base_teardown_does_not_reach_it()
    • test_clear_capture_only_spec_state_clears_every_cached_copy()
    • test_serving_scan_honors_client_params_after_capture_teardown()
    • test_override_stays_live_while_the_flag_is_set()
  • The test module is covered in tests/integration/test_lists/test-db/l0_h100.yml.
  • Verdict: sufficient.

xwang233 added 2 commits July 29, 2026 16:43
…ph metadata

The advanced-sampling CUDA graph capture pass sets
_force_non_greedy_for_capture=True on spec_metadata so that parameter-less
warmup requests scan as non-greedy and the advanced-sampling branch is the one
recorded into the graph.

create_cuda_graph_metadata() shallow-copies the live SpecMetadata, so every
graph captured during that pass caches a copy that inherited the flag. Those
copies are reseated as the live spec_metadata on each later replay, but the
capture pass clears the flag on the base object only. The flag therefore stays
set on the cached copies forever, and _scan_one_model_sampling() takes the
capture branch at serving time, replacing EVERY request's sampling parameters
with the synthetic capture values (temperature 0.7, top_k 50, top_p 0.9).

Symptom: with one-engine speculative decoding and CUDA graphs enabled (the
default), a client asking for temperature=1.0 / top_p=1.0 is served at
0.7 / 50 / 0.9 on every row for the whole run. The narrowed nucleus can make a
repetition loop effectively inescapable, producing very long degenerate
generations that terminate only at the length limit. Only one-engine
speculative decoding runs the capture pass, so non-spec decoding is unaffected.

Clear the flag on the cached copies in the capture pass's finally, after every
graph for the pass has been captured, so the flag is still in effect for the
captures that need it. Clearing it at copy time instead would be wrong: the
pass-2 populate would then scan the parameter-less warmup requests as greedy
and bake the argmax fast path (and absent top-k/top-p kernels) into the graph
keyed as the advanced-sampling variant.

Also assert in _prepare_inputs that the override is never live outside warmup.

Signed-off-by: Xiao Wang <24860335+xwang233@users.noreply.github.com>
…e lifetime

Covers the leak fixed in the previous commit and the constraint that rules out
the obvious alternative fix:

1. A graph metadata shallow copy inherits _force_non_greedy_for_capture, and
   clearing the flag on the base object does not reach the copies.
2. clear_capture_only_spec_state() clears exactly the flagged copies, leaves
   greedy-pass copies and non-spec entries alone, and is idempotent.
3. After the capture pass tears down, a scan on the cached copy honors the
   request's own sampling params. This fails without the fix: the copy still
   carries the flag, so the scan returns the synthetic 0.7 / 50 / 0.9.
4. While the flag is set, the scan still substitutes the synthetic params and
   reports a non-greedy batch. This pins the flag as load-bearing during
   capture, so the clear cannot be moved to copy time without breaking the
   advanced-sampling graph it exists to record.

The tests construct a real base SpecMetadata (its __post_init__ is a no-op and
no field exercised here is a tensor) and call clear_capture_only_spec_state
unbound on a stand-in holding only graph_metadata, so no GPU, no runner
construction and no model forward is needed. Style follows the neighboring
test_group_all_greedy_sync.py.

Signed-off-by: Xiao Wang <24860335+xwang233@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: bce241d6-8e88-4f48-9ebd-ae3a4cf4c4fc

📥 Commits

Reviewing files that changed from the base of the PR and between 09d3581 and e0f5aab.

📒 Files selected for processing (1)
  • tests/integration/test_lists/test-db/l0_h100.yml

Walkthrough

Adds cleanup for capture-only CUDA graph sampling metadata, asserts the override is not active during serving, and introduces speculative decoding tests covering metadata copies, teardown, serving scans, and warmup behavior.

Changes

Capture override lifecycle

Layer / File(s) Summary
Capture metadata cleanup contract
tensorrt_llm/_torch/pyexecutor/cuda_graph_runner.py, tests/unittest/_torch/speculative/test_capture_override_leak.py
Adds cached metadata cleanup and tests shallow-copy inheritance, selective clearing, and idempotence.
Warmup cleanup and serving guard
tensorrt_llm/_torch/pyexecutor/model_engine.py, tests/unittest/_torch/speculative/test_capture_override_leak.py, tests/integration/test_lists/test-db/l0_h100.yml
Clears capture state after advanced-sampling warmup, rejects leaked state during serving input preparation, and registers the regression test for H100 pre-merge testing.
Capture-time sampling validation
tests/unittest/_torch/speculative/test_capture_override_leak.py
Verifies warmup scans continue using synthetic capture sampling parameters while the override remains set.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: qijune

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main change and follows the required ticket/type format.
Description check ✅ Passed The description is detailed and covers the bug, fix, and test coverage, with only the PR checklist section omitted.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/unittest/_torch/speculative/test_capture_override_leak.py (1)

38-143: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add annotations to the new helpers and tests.

The new functions are unannotated, contrary to the Python review requirement. As per coding guidelines, “Annotate every function.”

🤖 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/speculative/test_capture_override_leak.py` around lines
38 - 143, Add explicit return-type annotations to the new helper functions
_base_meta, _graph_copy, _clear, _request, and _scan, and annotate each test
function with its return type as well. Use the existing project types where they
are available and an appropriate type for helper collections or test-only
returns, without changing behavior.

Source: Coding guidelines

🤖 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/model_engine.py`:
- Around line 2000-2004: Gate the cleanup around clear_capture_only_spec_state
in model_engine.py: only clear cached override flags when
cuda_graph_runner.is_warmup_only is false, preserving them through the
warmup-only pass and clearing them after final capture. In
tests/unittest/_torch/speculative/test_capture_override_leak.py, add a lifecycle
test covering warmup-only capture followed by real capture, asserting cached
metadata remains forced during warmup and is cleared after the final capture.

---

Nitpick comments:
In `@tests/unittest/_torch/speculative/test_capture_override_leak.py`:
- Around line 38-143: Add explicit return-type annotations to the new helper
functions _base_meta, _graph_copy, _clear, _request, and _scan, and annotate
each test function with its return type as well. Use the existing project types
where they are available and an appropriate type for helper collections or
test-only returns, without changing behavior.
🪄 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: 9cfc2b7c-54c8-4cfe-98a1-7ad99a8de3c1

📥 Commits

Reviewing files that changed from the base of the PR and between c45ad83 and 09d3581.

📒 Files selected for processing (4)
  • tensorrt_llm/_torch/pyexecutor/cuda_graph_runner.py
  • tensorrt_llm/_torch/pyexecutor/model_engine.py
  • tests/integration/test_lists/test-db/l0_h100.yml
  • tests/unittest/_torch/speculative/test_capture_override_leak.py

Comment thread tensorrt_llm/_torch/pyexecutor/model_engine.py
@xwang233

Copy link
Copy Markdown
Collaborator Author

/bot run --disable-fail-fast

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #62633 [ run ] triggered by Bot. Commit: 09d3581 Link to invocation

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #62633 [ run ] completed with state FAILURE. Commit: 09d3581
/LLM/main/L0_MergeRequest_PR pipeline #50773 completed with status: 'FAILURE'

CI Report

⚠️ Action Required:

  • Please check the failed tests and fix your PR
  • If you cannot view the failures, ask the CI triggerer to share details
  • Once fixed, request an NVIDIA team member to trigger CI again

CI Agent Failure Analysis

Link to invocation

Signed-off-by: Xiao Wang <xiaow@nvidia.com>
Signed-off-by: Xiao Wang <24860335+xwang233@users.noreply.github.com>
@xwang233

Copy link
Copy Markdown
Collaborator Author

/bot run --disable-fail-fast

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #62808 [ run ] triggered by Bot. Commit: e0f5aab Link to invocation

@BowenFu

BowenFu commented Jul 30, 2026

Copy link
Copy Markdown

Adjudicating the open CodeRabbit thread on _run_capture_pass's finally, since it's the only thing still open here and I think it's a misread — and the suggested fix would reintroduce the bug this PR is fixing.

The premise doesn't hold. CodeRabbit's argument is that "capture() stores graph_metadata before returning for is_warmup_only; the subsequent real capture reuses that cached SpecMetadata." The second half isn't true. cuda_graph_runner.py:469-472 does

self.graph_metadata[key] = {
    "attn_metadata": attn_metadata,
    "spec_metadata": initial_inputs.get("spec_metadata", None),
}

unconditionally at the top of every capture() call, and that is before the if self.is_warmup_only: return output early exit at :498-499. The real capture doesn't read the cached entry back — it overwrites it with a fresh shallow copy taken from its own initial_inputs, i.e. one made while _force_non_greedy_for_capture is set on the live base object by that pass.

The two-pass structure is real (model_engine.py:1199-1205 calls _run_cuda_graph_warmup once with is_warmup_only = True, then again with it False), so the concern was worth raising — but each pass re-enters _run_capture_pass(force_non_greedy=True), re-sets the flag at :1943-1944, and re-populates graph_metadata. The advanced-key graph is captured with the flag live in the pass that actually captures it.

The suggested fix inverts the safety direction. Gating on if not self.cuda_graph_runner.is_warmup_only means the warmup-only pass leaves flag-set copies sitting in graph_metadata. For any key that is re-captured that's harmless (overwritten). For a key captured during estimation but skipped in the final pass — :1961-1970 shows the batch-None skip is a real branch — it leaves the flag set on a cached copy, which is precisely the leak into _scan_one_model_sampling. The current unconditional clear leaves it False in that case: the safe direction.

For what it's worth, the rest of the change reads correctly to me. Clearing in the finally after all captures is right — the flag is load-bearing during capture (it's what forces the pass-2 populate scan non-greedy so the advanced-sampling branch is what gets recorded), so clearing at copy time would be wrong, and the test pins that rejected variant. The assert self.is_warmup or not ... in _prepare_inputs is a good belt-and-braces guard given the flag already leaked once.

Not approving yet only because I'd rather not be the first approver on a spec-dec + CUDA-graph change; the diagnosis and the quantified result (GPQA 84.85% → 92.42%, runaways 15/198 → 0/594) are convincing.

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #62808 [ run ] completed with state FAILURE. Commit: e0f5aab
/LLM/main/L0_MergeRequest_PR pipeline #50936 completed with status: 'FAILURE'

CI Report

⚠️ Action Required:

  • Please check the failed tests and fix your PR
  • If you cannot view the failures, ask the CI triggerer to share details
  • Once fixed, request an NVIDIA team member to trigger CI again

CI Agent Failure Analysis

Link to invocation

@xwang233

Copy link
Copy Markdown
Collaborator Author

/bot run --disable-fail-fast --reuse-test

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #62858 [ run ] triggered by Bot. Commit: e0f5aab Link to invocation

@BowenFu BowenFu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approving. The default path is provably untouched: spec_metadata is None whenever spec decode is off, the new assert lives under if spec_metadata is not None, and the cleanup hook is only reachable from the force_non_greedy=True capture pass gated by use_one_engine().

Re-confirming my earlier read of the remaining CodeRabbit thread at e0f5aaba7, since it's the only thing still open. capture() assigns self.graph_metadata[key] unconditionally at the top, before if self.is_warmup_only: return output — so the second pass overwrites it with a fresh copy taken while the flag is live, rather than reusing the warmup-pass object. And the "advanced key captured in pass 1" case can't bite either: self.graphs[key] = graph is only reached after that early return, so a warmup-only key has no runnable graph and replay() can never select it. The suggested if not is_warmup_only: gate would be strictly worse — it leaves the flag set for a key captured during estimation but skipped in the final pass, which is the original leak.

Not blocking: the new tests are object-level on SpecMetadata (inheritance, clearing, post-clear sampling) rather than end-to-end through warmup()/capture()/replay(), so they'd miss a future regression in the call site rather than the flag itself.

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #62858 [ run ] completed with state SUCCESS. Commit: e0f5aab
/LLM/main/L0_MergeRequest_PR pipeline #50981 completed with status: 'FAILURE'

CI Report

⚠️ Action Required:

  • Please check the failed tests and fix your PR
  • If you cannot view the failures, ask the CI triggerer to share details
  • Once fixed, request an NVIDIA team member to trigger CI again

CI Agent Failure Analysis

Link to invocation

@xwang233

Copy link
Copy Markdown
Collaborator Author

/bot run --disable-fail-fast --reuse-test

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #62958 [ run ] triggered by Bot. Commit: e0f5aab Link to invocation

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #62958 [ run ] completed with state SUCCESS. Commit: e0f5aab
/LLM/main/L0_MergeRequest_PR pipeline #51074 completed with status: 'SUCCESS'

CI Report

Link to invocation

@xwang233
xwang233 merged commit b118fc3 into NVIDIA:main Jul 31, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants