Skip to content

[Bugfix][Kimi-K3] Do not classify a stateless first chunk as a decode - #51483

Open
sashko-zakharchuk wants to merge 3 commits into
vllm-project:mainfrom
sashko-zakharchuk:fix-kda-first-token
Open

sashko-zakharchuk wants to merge 3 commits into
vllm-project:mainfrom
sashko-zakharchuk:fix-kda-first-token

Conversation

@sashko-zakharchuk

@sashko-zakharchuk sashko-zakharchuk commented Aug 8, 2026

Copy link
Copy Markdown

Purpose

Related to #51039.

A request being forwarded for the first time owns no KDA state, but the non-spec branch of the
Kimi-K3 KDA metadata builder classified any one-token row as a decode
(split_decodes_and_prefills(m, decode_threshold=1) with the default
treat_short_extends_as_decodes=True, nvidia/kda_metadata.py:405). When every row in a batch is one
token, num_prefills is 0, has_initial_state is never computed, and kda.py takes the
pure-decode route, which reads conv_state[slot] and recurrent_state[slot] unconditionally.
gather_initial_states is the only thing that masks a stateless slot, and it lives on the
prefill route. Mamba blocks are not zeroed on reallocation
(single_type_kv_cache_manager.py:86 restricts zero-on-allocate to the attention specs), so
such a request silently continues whatever KDA state the previous owner of its recycled block
left behind.

This builder already applies the correct rule on its spec path
(nvidia/kda_metadata.py:439-447, "Query length alone cannot distinguish a true decode from a
one-token prefill chunk"), and the Mamba2 builder does the same at mamba_attn.py:459-481
("First-token prefills have no prior Mamba state and must stay prefills").

Reachable on stock flags: a one-token prompt scheduled alongside ordinary decodes. Also
reachable when a request's first chunk is clipped to one token by the remaining token budget
(scheduler.py:990). Under the default mamba_cache_mode="align",
Scheduler._mamba_block_aligned_split often clips that chunk to zero and drops the request from
the step instead. Two cases get through: a block-aligned chunk when the mamba spec carries
prefill checkpoint blocks and no Eagle drafter is configured, which is how Kimi-K3 runs under
the FlashKDA prefill backend (nvidia/kda.py:598), and any chunk when the mamba block size
exceeds the prefill budget (scheduler.py:419-436). A batch containing any row longer than one
token is unaffected, since nvidia/kda.py:824 then routes everything through the packed prefill
with the correct has_initial_state.

Three changes:

  1. Classify by state rather than by query length. no_prior_state is computed from
    seq_lens_cpu_upper_bound <= query_lens_cpu, which identifies rows with no computed tokens
    (the bound is documented as precise for prefill rows and optimistic only for async spec
    decode rows, backend.py:422-426; an optimistic bound can only miss a stateless row, never
    promote a real decode), and it is additionally masked with the runner's is_prefilling flag.
    The dummy batch used for cudagraph capture also has seq_len == query_len for every row,
    and without that mask the captured decode graph would record the prefill kernels.

    One-token chunks that resume a partially prefilled request own valid state and keep the
    decode path, so treat_short_extends_as_decodes=False alone would be wrong: it would move a
    whole decode batch onto chunk_kda_with_fused_gate whenever a chunked prefill ends on a
    one-token chunk.

  2. Re-key the cudagraph staging guard. Decode-graph dispatch is shape based
    (_is_uniform_decode decides from token counts before build() runs), so a one-token batch
    can replay the captured decode graph even when a stateless first chunk makes it a prefill batch. Keyed on
    num_prefills == 0 the guard would go false, staging would be skipped, and the replay would
    read state indices staged in an earlier step. The new condition is the one the dispatcher
    uses. On base this is a strict no-op: num_prefills == 0 and max_query_len <= 1 are
    equivalent there, and the removed fill_(NULL_BLOCK_ID) is dead because entering the guard
    implied num_decodes == batch_size (NULL padding still comes from the runner's block table).

  3. Exclude cudagraph padding from num_prefills. split_decodes_and_prefills returns
    num_reqs - num_decodes, the whole suffix after the first prefill, so trailing zero-length
    padding rows are counted as prefill requests once a stateless first chunk promotes a row ahead
    of them. The value is only ever compared against zero downstream and never crosses zero, so no
    routing decision changes. num_prefill_tokens is inflated separately, since it inherits
    num_actual_tokens, which a full cudagraph pads past the last real token; it is recomputed
    from the same real query boundary, and nothing on the KDA execution path reads it. The shared GDN
    builder has all three defects, and [Bugfix][GDN] Fix stateless first-chunk classification #51565 makes the same changes there.

Note on blast radius: kda.py does not split the non-spec group, so once num_prefills > 0 the
whole non-spec batch takes the prefill route for that step. A single freshly admitted request
therefore moves an otherwise all-decode batch off ops.fused_kda_decode in eager, PIECEWISE,
and uncaptured sizes. Both prefill backends handle all-length-1 cu_seqlens correctly.

What this does not do. It corrects the metadata, not the dispatch. With the default
FULL_AND_PIECEWISE an all-one-token batch is still dispatched to the captured decode graph on
shape alone, so a replayed graph is unchanged; Mamba2 has the same residual gap. Closing it needs
either worker-side zeroing of mamba pages on reallocation or making the dispatcher decline the
full graph when a mamba group has a stateless row, both core-runner scope, and I am happy to
follow up on either.

It is also a no-op on batches whose metadata carries no is_prefilling flag, which is the
microbatching and context-parallel path (ubatch_utils._make_metadata_with_slice,
cp_utils.py): without the flag the builder cannot tell a first chunk from a resumed one-token
chunk, so it conservatively keeps the base classification there rather than promote a
capture-shaped batch by mistake.

On #51039 specifically: this is a correctness bug that needs no NaN, and it is also one way a
poisoned state block can be inherited by a brand-new request. It does not explain the origin of
the first NaN at 240K, so I am not claiming it resolves that incident.

Coordination: #50855 (draft) touches the same two files and adds an all_initial_states_fresh
flag computed from the same fresh-versus-resumed distinction this changes. Whoever lands second should re-check that flag against the new classification.

Line references above are against d9fbe526c, main at the time of writing.

Test Plan

Ten tests in tests/models/kimi_k3/test_kda_metadata.py, driving the real builder:

  • test_one_token_first_chunk_is_not_a_decode: seq_lens=[100, 50, 1], all query lengths 1,
    expects 2 decodes, 1 prefill, has_initial_state == [True, True, False].
  • test_one_token_extend_chunk_stays_a_decode: seq_lens=[100, 50, 65], all query lengths 1,
    expects 3 decodes and no prefill, so the change cannot regress mid-prefill one-token chunks.
  • test_zero_length_padding_row_is_not_a_prefill: a padded row is never promoted.
  • test_one_token_first_chunk_excludes_trailing_padding: two real rows (an ordinary decode,
    then a stateless first chunk) followed by two zero-length padding rows, expects 1 decode and
    1 prefill, 1 token each.
  • test_one_token_first_chunk_excludes_padded_tokens: the same batch with num_actual_tokens
    forced to 4, expects 2 decodes, 1 prefill, 1 prefill token.
  • test_cudagraph_capture_batch_stays_decode_only: drives build_for_cudagraph_capture on a
    uniform dummy batch and asserts it stays decode-only.
  • test_one_token_batch_stages_cudagraph_state_indices, parametrized over a first-token chunk
    and a pure decode, and test_all_stateless_one_token_batch_stages_cudagraph_state_indices
    for the num_decodes == 0 case: assert the returned tensor is the builder's persistent
    staging buffer and equals block_table_tensor[:, 0].
  • test_multi_token_batch_does_not_stage_cudagraph_state_indices: a batch with a longer row is
    not a decode-graph batch, so the staging guard must not fire.
  • test_first_chunk_without_prefill_flag_is_left_unclassified: with no is_prefilling flag the
    builder keeps the base classification rather than promote a possibly capture-shaped batch.

Test Result

  • test_one_token_first_chunk_is_not_a_decode fails on unpatched main and passes with the
    change.
  • Each guard is pinned by mutation. Removing the is_prefilling mask fails the capture test;
    keying the staging guard on num_decodes > 0 fails the all-stateless test; dropping the
    and m.max_query_len <= 1 clause fails the multi-token staging test; dropping the
    query_lens > 0 clause fails the zero-length padding test; removing the padding subtraction
    fails the trailing-padding test; dropping the num_prefill_tokens recompute fails the
    padded-token test. Making the flag-less fallback a no-op fails the unclassified
    test, and reverting the classification entirely fails three tests.
  • tests/models/kimi_k3/test_kda_metadata.py: 29 passed.
  • tests/models/kimi_k3/ (excluding test_latent_moe_tail.py, which needs ray, absent in my
    environment): 139 passed, 9 failed, 38 skipped, against 128 passed, 9 failed, 38 skipped on
    the same base (a3561ef8e). The difference is the eleven new cases. The nine failures are in
    test_mla_prefill_context.py and are present on the unmodified base.
  • ruff check and ruff format --check clean on both changed files.
  • Environment: RTX 5050 (sm_120), torch 2.13.0+cu130.

Tool assistance

Claude Code was used for the investigation, for reviewing this change, and for drafting this
description. All code was reviewed by me, and every claim above was verified against the tree
and by running the tests reported here.

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

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use /ci run or /ci retry. New commits do not start CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@maxpla3

maxpla3 commented Aug 9, 2026

Copy link
Copy Markdown

#51508 might be related.

@sashko-zakharchuk

Copy link
Copy Markdown
Author

Same state-bookkeeping layer, different defect (it took me a while to untangle these). This PR
is the non-spec branch: a stateless first chunk (one-token row, num_computed_tokens == 0)
gets classified as a decode, so the decode route reads a recycled state block that nothing
masked. #51508 is the spec path after a discarded async-scheduling step:
num_accepted_tokens == 0, and the state index is num_accepted_tokens - 1. The GDN-side
counterpart of this PR's bug is filed separately as #51562; the wider cluster
(#49918 / PR #47123) is mapped in
#51039 (comment).

Practical overlap: #51508 also touches kda_metadata.py and test_kda_metadata.py, so
whichever lands second needs a small rebase. I'll rebase this one if it goes second.

@sashko-zakharchuk

Copy link
Copy Markdown
Author

@ZJY0516 you added kda_metadata.py in #50089. This has been open since 8 August with no
review of the code itself: vllm/models/kimi_k3/nvidia/ has no CODEOWNERS entry, so the
reviewers auto-requested here own /tests/models and came in through the test file rather than
the change.

The bug: in the non-spec branch of KimiK3KDAMetadataBuilder.build,
split_decodes_and_prefills(m, decode_threshold=1) classifies a request's first one-token chunk
as a decode. The decode route reads conv_state[slot] and recurrent_state[slot]
unconditionally, gather_initial_states masks a stateless slot only on the prefill route, and
mamba blocks are not zeroed on reallocation, so a fresh request continues whatever KDA state the
previous owner of its recycled block left behind. It came out of the #51039 investigation, and
it is a correctness bug on its own as well as a route by which a poisoned block can reach an
unrelated request.

The red pre-run-check is the gate that skips pre-commit for authors without four merged PRs,
not a test failure, so no CI has run at all yet; a ready label would let it run.
kda_metadata.py itself is untouched on main, so the diff still applies, but the branch is 268
commits behind, and I am happy to rebase and re-run the tests if you would rather review it
against current main.

A request being forwarded for the first time owns no KDA state, but the
non-spec branch of the KDA metadata builder classified any one-token row
as a decode. The decode path reads its conv and recurrent state slot
unconditionally; only the prefill path masks a stateless slot through
gather_initial_states, and mamba blocks are not zeroed on reallocation.
Such a request therefore continued whatever state the previous owner of
its recycled block left behind.

Classify by state instead of by query length, matching what this builder
already does on the spec path and what the Mamba2 builder does. One-token
chunks that resume a partially prefilled request keep the decode path,
since their state is valid, and the runner's prefill flag is required so
that the cudagraph capture batch, which also has seq_len == query_len,
stays a decode batch.

Decode-graph dispatch is shape based, so re-key the cudagraph staging
guard to the same condition the dispatcher uses; otherwise a one-token
batch containing a first chunk would replay the captured graph against
state indices staged in an earlier step.

Assisted-by: Claude Code
Signed-off-by: Oleksandr Zakharchuk <oleksandr.zakharchuk@gmail.com>
… count

split_decodes_and_prefills counts every request after the first prefill, so
trailing zero-length cudagraph padding was counted as prefill requests once a
stateless first chunk promoted a row ahead of it. Padding rows have zero query
length, so the misclassification moved only the request count; the token split
is unchanged, and num_prefills is only ever compared against zero on this path,
so no kernel route changes.

vllm-project#51565 carries the same subtraction for the shared GDN builder.

Assisted-by: Claude Code
Signed-off-by: Oleksandr Zakharchuk <oleksandr.zakharchuk@gmail.com>
num_prefill_tokens is derived from num_actual_tokens, which a full cudagraph
pads past the last real token, so the non-spec path reported the padded slots
as prefill tokens. Recompute it from the real query boundary. vllm-project#51565 makes the
same recompute for the shared GDN builder.

On query_lens=[1, 1, 1, 0] with num_actual_tokens=4 this read 2 prefill tokens
where 1 is correct. Neither nvidia/kda.py nor amd/kda.py reads
num_prefill_tokens, so no kernel behaviour changes.

Assisted-by: Claude Code
Signed-off-by: Oleksandr Zakharchuk <oleksandr.zakharchuk@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working k3 kimi

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants