Skip to content

[Spec decode] Support variable-length decode for Kimi-K3 adaptive ver - #52988

Merged
vllm-bot merged 16 commits into
vllm-project:mainfrom
qiching:k3-adaptive-varlen
Sep 23, 2026
Merged

vllm-bot merged 16 commits into
vllm-project:mainfrom
qiching:k3-adaptive-varlen

Conversation

@qiching

@qiching qiching commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Make Kimi-K3's MLA and KDA decode paths capturable as FULL varlen CUDA graphs so they work under adaptive DSpark verification (#47808), which schedules ragged per-request draft budgets and requires device-sourced query lengths. Before this change K3's FLASHINFER_MLA reported UNIFORM_BATCH/UNIFORM, and the decode builders derived per-request query length from a measured/averaged count, so a graph captured on a uniform dummy did not match ragged replay.

Changes

FlashInfer MLA (flashinfer_mla.py)

MLA common (mla_attention.py + subclass builders)

  • Thread a promised max_query_len through MLACommonMetadataBuilder._build_decode (clamped to reorder_batch_threshold) and the flashmla, flashattn_mla, rocm_aiter_mla, and dots3_note builders.

KDA (kda_metadata.py)

  • Promote KimiK3KDAMetadataBuilder to AttentionCGSupport.ALWAYS.
  • Route every active decode through the spec path by masking on is_prefilling rather than draft count.
  • Return True from supports_device_cpu_query_lens_mismatch.

KDA RecoverSSM (kda_metadata.py, opt-in via --use-replayssm, off by default)

  • Make RecoverSSM co-exist with the adaptive path so it can drop the KDA spec-decode SSM state from num_spec+1 slots to 1 per request (kernels already in [K3] support recoverssm for K3 #51855):
    • Restore self.layer_names on the builder so RecoverSSM's commit context can resolve the per-layer forward context (the GDN base does not retain it; inert when RecoverSSM is off).
    • Demote spec rows whose query_len exceeds num_spec+1 out of the RecoverSSM path instead of raising. Adaptive's cost-table profiler probes token counts past the cudagraph limit by even-splitting them over the capped request slots, producing eager dummy rows longer than RecoverSSM's num_spec+1 verify buffers; real captured/serving decode rows are always <= num_spec+1, so they are unaffected.

Mamba-hybrid model state (mamba_hybrid.py)

  • Prefer the batch's promised max_query_len, and classify spec-decode rows off is_prefilling rather than num_scheduled_tokens == draft_count + 1.

K3 DSpark draft (dspark_mla.py)

  • Build the confidence head when the config enables it, load its weights (skipped only when the draft did not build one), and expose compute_confidence.

Related Issue: #51867

Test plan

  • Updated the MLA backend metadata tests for the new _build_decode
    signature (test_mla_backends.py, test_rocm_aiter_mla_mtp_split.py).
  • Adaptive DSpark spec decode on Kimi-K3 (TP8, fp8 KV).
  • RecoverSSM + adaptive on Kimi-K3: boots and serves through full-graph capture; validated against the base-adaptive checks (accuracy/coherence artifacts were overwritten by later runs and are being regenerated).

Validation

Kimi-K3 (target) + Inferact/Kimi-K3-DSpark (draft), TP8 on 8×B300, FP8 KV cache, attention_backend=FLASHINFER_MLA, num_speculative_tokens=7, enable_adaptive_verification=true. (Numbers below are RecoverSSM off; it is opt-in.)

Accuracy — lossless vs. no-spec (GSM8K, in-tree runner, 1319 questions, 5-shot, temperature 0)

max-concurrency no-spec adaptive
16 0.949 0.949
64 0.951 0.949
128 0.948 0.948
Adaptive matches no-spec within run-to-run noise (invalid-response rate ~0.1% in every
run), i.e. adaptive verification is lossless at temperature 0.

Coherence (MTBench, 80 prompts, temperature 1)

Adaptive: 80/80 successful requests, 0 failed, ~19.5K generated tokens; no repetition or degeneration in the saved completions.

Speculative-decoding health (speed_bench, temperature 1, c16)

Acceptance rate 28.95%, acceptance length 3.03, per-position acceptance decaying 74.5% (pos 0) → 6.9% (pos 6) — non-zero at every position, confirming the draft + confidence head + varlen verification path is exercised end to end.

RecoverSSM (opt-in) health

Adaptive + RecoverSSM serves through full-graph capture with healthy acceptance length (2.4–2.9 across c1–c256), confirming the RecoverSSM verify/commit path is exercised end to end; matches base-adaptive accuracy/coherence.

Benchmarking

Pareto sweep of the varlen decode path: 16 configurations × 9 concurrency levels = 144 serving runs. Configurations are fixed draft length K=1..7 plus adaptive, each with and without RecoverSSM.

Kimi-K3 (target) + Inferact/Kimi-K3-DSpark (draft), TP8 on 8×B300, FP8 KV cache, attention_backend=FLASHINFER_MLA, draft_sample_method=probabilistic, num_speculative_tokens=7 for adaptive. speed_bench qualitative, --speed-bench-output-len 2048, --max-model-len 16384, prompt count scaled with concurrency. Axes: per-user token rate (1000 / median TPOT) vs aggregate output throughput.
pareto_throughput_vs_tps_per_user

Overlaying both families lets a non-RecoverSSM point appear to overtake a RecoverSSM one it never competes with, so each family is also plotted with its own frontier.

pareto_no_recoverssm pareto_recoverssm

RecoverSSM, adaptive off vs on

c off on gain
64 2248.6 2349.4 +4.5%
128 2241.5 4454.7 +98.7%
256 2222.9 4887.9 +119.9%

Comment on lines +178 to +180
max_query_len = (
int(query_lens_cpu.max().item()) if query_lens_cpu.numel() else 1
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we should pull max_query_len from CommonAttentionMetadata; with adaptive spec-decode we uniformly distribute the budget on the CPU side but this may not be the case on the GPU side. So CommonAttentionMetadata.max_query_len represents the real max ( query_lens_cpu.max().item() may be less)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

thanks @LucasWilkinson now _build_decode now takes max_query_len from CommonAttentionMetadata instead of measuring query_lens_cpu.max(). I also made the FlashInfer MLA flatten map rows via searchsorted on the device offsets and routed every active KDA decode through the spec path, would appreciate you sanity checking those two.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@LucasWilkinson could you take a look? thanks!

@mergify

mergify Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @qiching.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added needs-rebase rocm Related to AMD ROCm mrv2 Model Runner V2 specific labels Aug 20, 2026
@github-project-automation github-project-automation Bot moved this to Todo in AMD Aug 21, 2026
@qiching
qiching marked this pull request as ready for review August 27, 2026 03:39

@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 Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@TheEpicDolphin

TheEpicDolphin commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Hi @qiching, would you mind rebasing?

@TheEpicDolphin TheEpicDolphin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thx for the PR! Left some feedback

Comment on lines +128 to +129
# Ragged decode (flashinfer #3238); unset for single-token decode.
cum_seq_lens_q: torch.Tensor | None = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This property seems redundant because it's just query_start_loc_device when max_query_len > 1. Can we remove it?

Also, can we rename query_start_loc_device => query_start_loc, which conforms better to the general metadata naming?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment on lines +410 to +414
assert decode_backend is None, (
"FlashInferMLA ragged decode requires trtllm-gen, but num_heads="
f"{runtime_num_heads} forces the cute-dsl backend, which does not "
"support cum_seq_lens_q."
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is this assert needed? codex is flagging that cutedsl in the currently pinned flashinfer version supports cum_seq_lens_q.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

removed

class FlashInferMLAMetadataBuilder(MLACommonMetadataBuilder[FlashInferMLAMetadata]):
# trtllm-gen tiles ragged queries from cum_seq_lens_q (flashinfer #3238), so one
# k+1 graph replays any 1..k+1 mix (full varlen decode, not piecewise).
_cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.ALWAYS

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ALWAYS is too lenient here as well, for the same reason as above for kda.py. This would allow replaying the captured graph for prefill/mixed batches during cudagraph_mode=FULL, but the capture run does not record the prefill kernels because it doesn't enter this branch: https://github.com/vllm-project/vllm/blob/main/vllm/models/kimi_k3/nvidia/mla.py#L685-L696

Should be downgraded to UNIFORM_BATCH

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Kept, adaptive_verification.py rejects anything below ALWAYS, so UNIFORM_BATCH makes the engine raise at initialize_kv_cache. Same combination already exists upstream: DeepseekV4FlashMLAMetadataBuilder declares ALWAYS while branching on num_prefill_tokens > 0.

Comment on lines +299 to +301
# ALWAYS (overrides GDN's UNIFORM_BATCH): KDA reads per-request offsets off
# device within a fixed k+1 window, so one k+1 graph replays any 1..k+1 mix.
_cudagraph_support = AttentionCGSupport.ALWAYS

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think ALWAYS is too lenient. When cudagraph_mode=FULL, prefills and mixed batches will be admitted into full cudagraphs, but that's unsafe for KDA because the captured batch is always decode-shaped, but KimiK3DeltaAttention._forward branches on composition:

if m.num_prefills > 0:
, so those prefill kernels are never recorded, and wouldn't run during replay for prefill/mixed batches.

You can downgrade this to UNIFORM_BATCH and adaptive verification should still work. query_len_support = VARLEN is the property that matters for ragged decode.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same reason above. VARLEN is set as you noted, but alone it's not enough, UNIFORM_BATCH fails at startup with "must report AttentionCGSupport.ALWAYS, but KimiK3KDAAttentionBackend reports UNIFORM_BATCH". Verified on B300 8×GPU TP=8, 144 points across K=1..7 and adaptive, ±RecoverSSM.

@github-project-automation github-project-automation Bot moved this to In review in NVIDIA Sep 11, 2026
@benchislett

Copy link
Copy Markdown
Member

/ci run

@benchislett
benchislett enabled auto-merge (squash) September 21, 2026 15:47
@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #90244 for commit 9862c9be4c3a.

@TheEpicDolphin

Copy link
Copy Markdown
Collaborator

@qiching the test failures seem related. Plz take a look when you get the chance

The merge picked up tests added on main that predate this PR's signature
change: three _build_decode call sites in the aiter MTP split tests, and
the FlashInfer MLA DCP tests whose MagicMock metadata never stubbed
decode.max_query_len. The mamba hybrid SimpleNamespace gets the field
InputBatch already carries.

Signed-off-by: Albert Cheng <albecheng@nvidia.com>
auto-merge was automatically disabled September 22, 2026 17:49

Head branch was pushed to by a user without write access

@qiching

qiching commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #90469 for commit 372a9182071e.

The test drives forward_mqa with a MagicMock self, so on the non-causal
multi-token path self._flattened_decode_metadata resolved to a mock whose
return value unpacked to nothing.

Signed-off-by: Albert Cheng <albecheng@nvidia.com>
@qiching

qiching commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

/ci run

@github-actions

Copy link
Copy Markdown

❌ This PR is 8 commits behind upstream main. Your branch must contain every commit currently on upstream main. No new CI build was started. Merge or rebase onto the latest main, then rerun /ci run. To test this branch at your own risk, use /ci run --allow-stale.

Signed-off-by: Albert Cheng <albecheng@nvidia.com>
@qiching

qiching commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #90507 for commit 729132de6459.

@qiching

qiching commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

@qiching the test failures seem related. Plz take a look when you get the chance

The related test failures are fixed. The remaining ones are unrelated: the kernels MoE IPC test is broken on main by #57312 (fix in #58107), and the H200 MIG failures came from a network outage that broke HF model downloads (Network is unreachable / DNS errors).

@benchislett

Copy link
Copy Markdown
Member

/ci retry

@github-actions

Copy link
Copy Markdown

✅ Queued 7 failed job(s) for retry in Buildkite CI #90507.

@vllm-bot
vllm-bot merged commit 88aa0d2 into vllm-project:main Sep 23, 2026
200 of 202 checks passed
@github-project-automation github-project-automation Bot moved this from Ready to Done in Sprint - DFlash Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Ready to Done in NVIDIA Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in AMD Sep 23, 2026
njhill added a commit to njhill/vllm that referenced this pull request Sep 23, 2026
…id models

The scheduler pads a one-token prompt tail over prior state (e.g. a P/D
decode-node arrival) with K placeholder drafts to keep the K+1
spec-decode shape (vllm-project#45237). For recurrent-state layers these rows must
run the spec-decode kernels, which keep the running state and roll the
rejected placeholders back; the prefill kernels store only the state
after all K+1 tokens (vllm-project#55178).

vllm-project#52988 changed the hybrid model state to classify spec-decode rows by
request state (not prefilling) instead of num_scheduled_tokens, which
adaptive verification rewrites. That sent every padded tail's draft
count to -1, so Mamba's padded-tail handling no longer fires and GDN/KDA
build the row as a prefill, folding the placeholder tokens into the
request's recurrent state.

Also count prefilling rows with exactly one remaining prompt token and
prior state as decodes, still using request state only.

Signed-off-by: Nick Hill <nickhill123@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@njhill

njhill commented Sep 23, 2026

Copy link
Copy Markdown
Member

@qiching @TheEpicDolphin @benchislett I found that this introduced a regression, please see #58434.

njhill added a commit to njhill/vllm that referenced this pull request Sep 23, 2026
…id models

The scheduler pads a one-token prompt tail over prior state (e.g. a P/D
decode-node arrival) with K placeholder drafts to keep the K+1
spec-decode shape (vllm-project#45237). For recurrent-state layers these rows must
run the spec-decode kernels, which keep the running state and roll the
rejected placeholders back; the prefill kernels store only the state
after all K+1 tokens (vllm-project#55178).

vllm-project#52988 changed the hybrid model state to classify spec-decode rows by
request state (not prefilling) instead of num_scheduled_tokens, which
adaptive verification rewrites. That sent every padded tail's draft
count to -1, so Mamba's padded-tail handling no longer fires and GDN/KDA
build the row as a prefill, folding the placeholder tokens into the
request's recurrent state.

Also count prefilling rows with exactly one remaining prompt token and
prior state as decodes, still using request state only.

Signed-off-by: Nick Hill <nickhill123@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
zixi-qi added a commit to zixi-qi/vllm that referenced this pull request Sep 24, 2026
Rename get_varlen_decode_cudagraph_max_query_len() to
get_varlen_cudagraph_max_query_len() and return None both for builders
reporting ALWAYS, which replay any batch, and for builders that cannot
replay variable-length batches. Only a bounded builder returns a length:
FlashInfer returns 1 + num_speculative_tokens while its TRTLLM-GEN
varlen decode path is active. Adaptive verification accepts a target
builder that reports ALWAYS or a bound of at least the verification
width, which drops the max_num_batched_tokens stand-in for "no limit".

Keep batches with a prefill out of varlen decode graphs. Dispatch
matched them on max(num_scheduled_tokens) alone, so decodes plus a
prefill chunk of at most 1 + num_speculative_tokens tokens replayed a
decode graph. FlashInfer and FlashInfer MLA run such chunks on their
decode kernels, but the Kimi-K3 KDA builder (ALWAYS since vllm-project#52988)
classifies rows by request state and does not restage its graph buffers
when a prefill row is present. The runner now passes max_query_len=None
for batches with a prefill; dispatch already never matches None against
a bounded graph.

Validation on GB300: pre-commit and mypy pass, and 3,201 tests pass
across the adaptive verification, CUDA graph, attention, MLA, sparse
MLA, config, Kimi-K3 and Gemma4 suites. The DCP distributed tests and
two gated Hugging Face tests also fail on the previous revision, and
one fp8 MLA case passed 3/3 on rerun. In a run mixing verification
with 2-7 token prompts, all 32 steps that pair them now run piecewise;
the previous revision replayed the varlen decode graph for all 31.
Gemma4 + DSpark K=7 captures the same FULL graphs as before (fixed
2 + 32, adaptive 2 + 35); GSM8K is 25/50 fixed and 24/50 adaptive,
against 24/50 and 24/50.

Signed-off-by: zixi-qi <zixi@inferact.ai>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@qiching

qiching commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

@qiching @TheEpicDolphin @benchislett I found that this introduced a regression, please see #58434.

Thanks @njhill, my oversight on the padded prompt tail case. Approved #58434.

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

Labels

dflash k3 kimi mrv2 Model Runner V2 specific nvidia ready ONLY add when PR is ready to merge/full CI is needed rocm Related to AMD ROCm

Projects

Status: Done
Status: Done
Status: Done

Development

Successfully merging this pull request may close these issues.

6 participants