Skip to content

specdec_bench: forward kv_cache_dtype to the vLLM engine - #2496

Open
yeyu-nvidia wants to merge 1 commit into
NVIDIA:mainfrom
yeyu-nvidia:yeyu/specdec-bench-kv-cache-dtype
Open

yeyu-nvidia wants to merge 1 commit into
NVIDIA:mainfrom
yeyu-nvidia:yeyu/specdec-bench-kv-cache-dtype

Conversation

@yeyu-nvidia

@yeyu-nvidia yeyu-nvidia commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Type of change: Bug fix

Adds kv_cache_dtype to PASSTHROUGH_ENGINE_ARGS in the specdec_bench vLLM model wrapper.

AsyncEngineArgs never received kv_cache_dtype, so setting it through --runtime_params was accepted and silently dropped. Benchmarking a checkpoint with a quantized KV cache therefore ran BF16 KV against FP8/NVFP4 weights, with nothing in the logs to indicate it. That shifts measured acceptance length with no visible error — the run looks successful and the number is simply wrong.

The checkpoint declaration and the engine flag are two independent switches, and both have to be on. kv_cache_quant_algo: FP8 makes vLLM attach a KV-cache quant method to the attention layers and load k_scale/v_scale; those scales are consumed only when CacheConfig.cache_dtype is an fp8 variant. kv_cache_dtype: "auto" resolves to the model dtype — per vLLM's own docstring, "If 'auto', will use model data type" — not to the checkpoint's declared KV algo. So before this change the scales were loaded and then ignored.

Found while measuring AL for NVFP4/FP8 quantized Nemotron 3.5 Super with an MTP draft, where the target checkpoint carries an FP8 KV cache.

Usage

python3 run.py \
    --model_dir <quantized-checkpoint-with-fp8-kv> \
    --engine VLLM \
    --runtime_params '{"engine_args": {"kv_cache_dtype": "fp8"}}'

Before this change the flag was dropped and the engine recorded kv_cache_dtype: auto. After it, get_serving_config() records the effective value per run, which makes the setting checkable rather than assertable.

Testing

Verified on Nemotron 3.5 Super 120B-A12B with an MTP draft on 4x GB200: the engine-recorded kv_cache_dtype now reflects the requested value instead of auto, and the FP8-KV vs BF16-KV arms produce distinguishable acceptance lengths where previously both arms silently ran the same configuration.

No new unit test: the change adds one string to an existing passthrough tuple whose forwarding mechanism is already exercised by the surrounding mamba_* keys.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
  • Did you write any new necessary tests?: N/A
  • Did you update Changelog?: N/A
  • Did you get Claude approval on this PR?: ✅

Additional Information

kv_cache_dtype is engine-global: it lands on CacheConfig and so applies to the draft's attention layers as well as the target's, unlike the target-scoped mamba_* keys it now sits beside. vLLM exposes no per-model knob. This is called out in an inline comment on the tuple, since that is what reaches whoever extends it next.

The case where that bites: a target carrying FP8 KV scales plus a draft checkpoint that does not. Setting kv_cache_dtype: fp8 quantizes the draft's KV cache too, and layers with no loaded k_scale/v_scale fall back to a default scale of 1.0 — the draft then drafts from a degraded cache and AL drops silently. Rejecting the combination would be wrong (for in-target MTP drafts there is no separate checkpoint to inspect, and a uniformly-FP8 target plus in-target MTP layer is a perfectly normal config), so this is documented rather than enforced.

Cross-engine note, out of scope here: models/sglang.py and the TRT-LLM wrapper have no equivalent forwarding, so the same --runtime_params key stays silently dropped on those engines.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added support for configuring the key-value cache data type when running vLLM benchmarks.
    • The setting applies consistently to both draft and target attention layers.

@yeyu-nvidia
yeyu-nvidia requested a review from a team as a code owner September 22, 2026 01:30
@coderabbitai

coderabbitai Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: NVIDIA/Model-Optimizer/.coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 8d40f74f-eadf-46b9-ad7b-671c0ea16eec

📥 Commits

Reviewing files that changed from the base of the PR and between f628fb8 and 9a0bad0.

📒 Files selected for processing (1)
  • examples/specdec_bench/specdec_bench/models/vllm.py

Included review availability: Your plan provides up to 12 included reviews per hour; 10 remain after this review.


📝 Walkthrough

Walkthrough

The vLLM model wrapper documents kv_cache_dtype as engine-global and forwards it to AsyncEngineArgs.

Changes

vLLM argument forwarding

Layer / File(s) Summary
Forward KV cache data type
examples/specdec_bench/specdec_bench/models/vllm.py
PASSTHROUGH_ENGINE_ARGS now includes the optional kv_cache_dtype runtime parameter. The documentation identifies it as engine-global.

Priority: ⬇️ Low

Estimated code review effort: 1 (Trivial) | ~2 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 9a0ba

The wrapper now forwards explicitly configured KV-cache dtype values while preserving default behavior when unset, with no merge-blocking risk identified.

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 1…
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.
Security Anti-Patterns ✅ Passed PASS. The PR changes only examples/specdec_bench/specdec_bench/models/vllm.py. It adds kv_cache_dtype to an existing argument tuple and forwards it conditionally to AsyncEngineArgs. The added li…
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main change: forwarding kv_cache_dtype to the vLLM engine.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

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

@yeyu-nvidia

Copy link
Copy Markdown
Contributor Author

@ChenhanYu tagging you as the main author of models/vllm.py — this is a one-line addition to PASSTHROUGH_ENGINE_ARGS, so it should be quick.

The part worth a second opinion is the blast radius rather than the diff: an explicit kv_cache_dtype applies to every layer, target and draft alike, since that is the only granularity vLLM exposes. For an MTP or EAGLE3 draft whose KV precision differs from the target, there is no way to express that here. Leaving the key unset keeps today's behaviour ("auto", honouring the checkpoint's kv_cache_quant_algo), so this only bites someone who sets it deliberately — but if you would rather that caveat lived in the README instead of a commit message, say so and I will move it.

Context for why it matters: without this, --runtime_params '{"engine_args": {"kv_cache_dtype": "fp8"}}' is accepted and dropped, so a quantized-KV checkpoint benchmarks as BF16 KV and reports a different acceptance length with nothing in the logs to say why. I hit this measuring AL for NVFP4/FP8 Nemotron 3.5 Super with an MTP draft.

@yeyu-nvidia

Copy link
Copy Markdown
Contributor Author

/claude review

Scope note for the review: the diff is one line, so the interesting questions are behavioural rather than syntactic.

  1. kv_cache_dtype is forwarded to all layers, target and draft alike — vLLM exposes no per-model granularity. Is that acceptable for a spec-dec benchmark where draft and target may carry different KV precision, or should the wrapper reject the combination rather than silently apply one dtype to both?
  2. Does adding it to PASSTHROUGH_ENGINE_ARGS interact badly with any checkpoint that already declares kv_cache_quant_algo? The intent is that unset preserves today's "auto" behaviour and an explicit value overrides it.
  3. Anything in the EAGLE3/MTP draft path that reads KV dtype independently and would now disagree with the engine setting.


# Forwarded from ``--runtime_params`` ``engine_args.<key>``; extend as needed.
PASSTHROUGH_ENGINE_ARGS = (
"kv_cache_dtype",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] The forwarding itself is correct, but this key differs from its tuple-mates in blast radius and that is invisible here.

Issue: every other entry in PASSTHROUGH_ENGINE_ARGS is a mamba_* knob scoped to the target model's own layers. kv_cache_dtype lands on CacheConfig and therefore applies to every attention layer the engine builds — including the EAGLE3/MTP/draft-model layers. The tuple gives a reader no hint that one of these keys crosses the target/draft boundary.

Why it matters: the failure mode is specific to spec-dec, and it moves exactly the metric this harness measures. If the target carries FP8 KV scales but the draft checkpoint does not, setting kv_cache_dtype: fp8 quantizes the draft's KV cache too, and vLLM falls back to a default scale of 1.0 for layers with no loaded k_scale/v_scale. The draft then drafts from a degraded cache, acceptance length drops, and — as with the bug this PR fixes — nothing in the logs says why. A benchmark whose output is AL should make that condition visible rather than leave it to the operator's memory.

Suggested fix: keep the passthrough as-is (rejecting the combination would be wrong — see the summary comment), and add a one-line note that this key is engine-global, so the next reader does not assume mamba_*-style target-only scoping:

# Forwarded from ``--runtime_params`` ``engine_args.<key>``; extend as needed.
# Note: ``kv_cache_dtype`` is engine-global — it applies to the draft's
# attention layers as well as the target's; vLLM exposes no per-model knob.
PASSTHROUGH_ENGINE_ARGS = (
    "kv_cache_dtype",

The README caveat you offered in the PR description is worth adding too, but the inline note is the one that reaches whoever extends this tuple next.

@codecov

codecov Bot commented Sep 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 71.18%. Comparing base (d0142c9) to head (9a0bad0).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #2496   +/-   ##
=======================================
  Coverage   71.17%   71.18%           
=======================================
  Files         603      603           
  Lines       66785    66785           
=======================================
+ Hits        47537    47542    +5     
+ Misses      19248    19243    -5     
Flag Coverage Δ
examples-llm_distill 13.53% <ø> (-0.01%) ⬇️
examples-llm_qat 17.73% <ø> (-0.01%) ⬇️
examples-llm_sparsity 15.97% <ø> (ø)
examples-specdec_bench 13.22% <ø> (ø)
examples-speculative_decoding 17.79% <ø> (-0.07%) ⬇️
examples-vllm_serve 13.87% <ø> (ø)
unit 58.25% <ø> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@claude

claude Bot commented Sep 22, 2026

Copy link
Copy Markdown

Claude review summary

Scope applied: per the scope note, I reviewed the one-line diff for behaviour rather than syntax, and answered the three questions directly. Files changed: 1 (examples/specdec_bench/specdec_bench/models/vllm.py); reviewed in full, along with the AsyncEngineArgs construction site, get_serving_config(), base.py runtime-params contract, and run.py engine_args plumbing.

Findings — CRITICAL: 0, IMPORTANT: 0, SUGGESTION: 1

The mechanism is correct. kv_cache_dtype is a real AsyncEngineArgs field, it does not collide with any explicitly-passed kwarg (so no duplicate-keyword TypeError), the if kwargs.get(key) is not None guard means unset leaves vLLM own default untouched, and because it is a dataclass field the dataclasses.asdict in get_serving_config() does record the effective value into configuration.json. The "checkable rather than assertable" claim in the description holds.

1. Global dtype for target and draft — acceptable; do not reject the combination

Rejecting would be the wrong call, for two reasons. First, the wrapper cannot know the draft KV precision without reaching into the draft checkpoint quant config — and for the MTP families where the draft layer ships inside the target (Qwen 3.5 etc., the --draft_model_dir-omitted path in your MTP branch), there is no separate checkpoint to inspect at all. Second, the combination is often exactly what the user wants: a uniformly FP8-KV target plus in-target MTP layer is a legitimate, non-pathological config. A hard rejection would block the common case to guard the uncommon one.

What is worth surfacing is the narrower case where it actually bites — a draft with no k_scale/v_scale inheriting an explicit fp8. Left as the inline SUGGESTION on line 33.

2. kv_cache_quant_algo interaction — no bad interaction, but the stated rationale is inverted

Adding the key does not conflict with a checkpoint that declares kv_cache_quant_algo, and the compat conclusion is right: unset preserves today behaviour exactly. The reason given in the description is backwards, though, and it is worth correcting because it is the same misconception that produced the bug.

kv_cache_dtype: "auto" does not honour the checkpoint kv_cache_quant_algoauto resolves to the model dtype, i.e. BF16. What kv_cache_quant_algo: FP8 does on its own is cause vLLM to attach a KV-cache quant method to the attention layers and load k_scale/v_scale; those scales are then only consumed if CacheConfig.cache_dtype is an fp8 variant. So before this PR the scales were loaded and ignored, which is precisely the silent BF16-KV-against-FP8-weights run you diagnosed. The checkpoint declaration and the engine flag are two independent switches, and both have to be on.

That means the two are not in an override relationship — an explicit fp8 does not "override" auto, it is the only thing that ever turns fp8 KV on. Worth fixing that sentence in the PR body so the next person reading it does not conclude the flag is redundant for ModelOpt checkpoints. (Deriving the default from the checkpoint hf_quant_config.json would arguably be the deeper root-cause fix, but that is a behaviour change well beyond this PR surgical scope — forwarding the flag is the right change here.)

3. EAGLE3/MTP paths reading KV dtype independently — nothing in-repo

Grepped examples/specdec_bench/ for kv_cache_dtype / kv_cache_quant_algo: the only other hit is the docstring in models/base.py:56, which already names kv_cache_dtype as an example of an engine-specific default that get_serving_config() should surface — so this change makes that docstring more accurate, not less. No draft-construction code in any of the EAGLE3/EAGLE/MTP/DFLASH/DSPARK branches reads or sets a KV dtype, so there is no in-repo value that can now disagree with the engine setting. The disagreement risk lives entirely inside vLLM, at the per-layer scale-loading level covered in point 1.

One cross-engine note, non-blocking: models/sglang.py and the TRT-LLM wrapper have no equivalent forwarding, so the same --runtime_params key stays silently dropped on those engines. Not this PR job to fix, but it means the key is engine-conditional in a way the flag name does not advertise.

Risk assessment

Low. Opt-in, additive, no default change, and the new value is recorded in configuration.json per run. The change strictly reduces silent-misconfiguration risk relative to main. The residual risk is the unscaled-draft case, which is a documentation matter rather than a defect.

🤖 Generated with Claude Code

@claude claude Bot 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.

Claude review passed — no blocking issues found. LGTM

``AsyncEngineArgs`` never received ``kv_cache_dtype``, so
``--runtime_params '{"engine_args": {"kv_cache_dtype": "fp8"}}'`` was accepted
and silently dropped. Benchmarking a checkpoint with a quantized KV cache then
ran BF16 KV against FP8 weights with nothing in the logs to say so, which shifts
acceptance length without any visible error.

The checkpoint declaration and the engine flag are two independent switches and
both have to be on: ``kv_cache_quant_algo: FP8`` makes vLLM attach a KV-cache
quant method and load ``k_scale``/``v_scale``, but those scales are consumed
only when ``CacheConfig.cache_dtype`` is an fp8 variant. ``kv_cache_dtype:
"auto"`` resolves to the model dtype, not to the checkpoint's declared KV algo,
so before this change the scales were loaded and then ignored.

Add the key to PASSTHROUGH_ENGINE_ARGS. It is forwarded only when the user sets
it, so the default is unchanged. Note inline that this key is engine-global --
it lands on CacheConfig and applies to the draft's attention layers as well as
the target's, unlike the target-scoped ``mamba_*`` keys it sits beside.

Signed-off-by: Ye Yu <yeyu@nvidia.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yeyu-nvidia
yeyu-nvidia force-pushed the yeyu/specdec-bench-kv-cache-dtype branch from f628fb8 to 9a0bad0 Compare September 22, 2026 01:44
@yeyu-nvidia

Copy link
Copy Markdown
Contributor Author

Thanks — both points taken, and pushed as 9a0bad0.

The "auto" correction is right and I have fixed it everywhere. I had written that "auto" honours the checkpoint's kv_cache_quant_algo; it does not. vLLM's own docstring on CacheConfig.cache_dtype is explicit — "If 'auto', will use model data type." So the two are not in an override relationship: kv_cache_quant_algo: FP8 loads k_scale/v_scale, and those scales are consumed only when cache_dtype is an fp8 variant, which means the engine flag is the only thing that ever turns fp8 KV on. Both switches have to be on. Corrected in the commit message and the PR description, since as you say that misconception is exactly what produced the bug — and left uncorrected it would invite the next reader to conclude the flag is redundant for ModelOpt checkpoints.

Inline SUGGESTION applied more or less verbatim, plus the mamba_* contrast, since the surprise is specifically that this key does not share its tuple-mates' scoping:

# Note: ``kv_cache_dtype`` is engine-global -- it lands on ``CacheConfig`` and so
# applies to the draft's attention layers as well as the target's; vLLM exposes
# no per-model knob. Unlike the ``mamba_*`` keys, which are scoped to the target.

The unscaled-draft failure case and the reasoning for documenting rather than rejecting it are now in the PR description too.

On deriving the default from hf_quant_config.json — I agree that is the deeper fix and agree it does not belong here. Happy to open a separate issue for it if a maintainer wants it tracked. Same for the sglang/TRT-LLM wrappers, where the key is still silently dropped.

@ChenhanYu the diff is now 4 lines (1 functional + 3 comment) — re-request when you have a moment.

@yeyu-nvidia
yeyu-nvidia enabled auto-merge (squash) September 23, 2026 18:12

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants