Skip to content

[Bugfix][Kimi-K3] Fix two crashes that make --use-replayssm unusable - #54495

Open
mispa-ms wants to merge 5 commits into
vllm-project:mainfrom
mispa-ms:misunp/k3-recoverssm-unrun-paths
Open

mispa-ms wants to merge 5 commits into
vllm-project:mainfrom
mispa-ms:misunp/k3-recoverssm-unrun-paths

Conversation

@mispa-ms

@mispa-ms mispa-ms commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Purpose

--use-replayssm selects the RecoverSSM path on Kimi-K3. It cannot start: it
raises during cudagraph capture, and after that is fixed it raises again on the
first execute_model.

1. GDNAttentionMetadataBuilder never calls its base __init__.

AttributeError: 'KimiK3KDAMetadataBuilder' object has no attribute 'layer_names'
  kda_metadata.py, in _get_recoverssm_context
    layers = [forward_context[layer_name] for layer_name in self.layer_names]

AttentionMetadataBuilder.__init__ stores kv_cache_spec, layer_names,
vllm_config and device. This builder calls none of it and assigns four
fields by hand, leaving out layer_names and device. layer_names has one
reader, _get_recoverssm_context; device has none yet. Calling super()
restores both and matches flash_attn, mamba_attn and triton_attn.

2. The checkpoint block's guard does not match the one that binds its
variable.

UnboundLocalError: cannot access local variable 'active_non_spec_mask_cpu'
  kda_metadata.py, in build
    request_rows = active_non_spec_mask_cpu.nonzero().flatten().tolist()

active_non_spec_mask_cpu is bound only under num_spec_decodes > 0, which is
what the sibling has_initial_state use guards on. The checkpoint block guards
on spec_sequence_masks_cpu is not None.

The reachable case is an all-False mask, not a zero draft sum.
num_decode_draft_tokens is filled with -1 and written only on spec-decode
rows, so on a prefill-only step num_decode_draft_tokens_cpu >= 0 is entirely
False. Without RecoverSSM the classification clears that mask to None; the
clearing carries not self.use_recoverssm, so under RecoverSSM a non-None
all-False mask survives with num_spec_decodes == 0. This is therefore every
prefill step under align with checkpoint blocks, not a rare corner, which is
why it fires on the first execute_model.

The root cause is a dropped normalization clause. This file states it
"intentionally mirror[s] GDNAttentionMetadataBuilder", and the shared builder
keeps spec_sequence_masks_cpu is not None equivalent to num_spec_decodes > 0
at the source:

# vllm/v1/attention/backends/gdn_attn.py
spec_sequence_masks_cpu = num_decode_draft_tokens_cpu >= 0
num_spec_decodes = spec_sequence_masks_cpu.sum().item()
if (
    num_spec_decodes == 0
    or num_decode_draft_tokens_cpu[spec_sequence_masks_cpu].sum().item()
    == 0
):
    num_spec_decodes = 0
    spec_sequence_masks = None
    spec_sequence_masks_cpu = None

That invariant is why guarding on the mask is safe there. The Kimi copy dropped
the num_spec_decodes == 0 disjunct and moved the count into the else, and
adding not self.use_recoverssm then made the gap reachable. Restoring the
clause is the other possible fix; it is left as a follow-up because it would
make the mask None again and so stop the regression test below from
distinguishing the two guards.

num_spec_decodes > 0 is also the correct guard, not just the bound one:
checkpoint_offsets is consumed by _store_cache_checkpoints_kernel on a grid
of its own numel(), with seq_idx indexing non_spec_query_start_loc. At
num_spec_decodes == 0 that tensor is the full query_start_loc, so
request_rows must be every row — the fallback value. Masking there would have
shortened request_rows against a full-length start-loc array.

Not a duplicate

active_non_spec_mask_cpu returns no results in this repo's issues or PRs. The
RecoverSSM hits are #51855 (closed, added this path), #52506 and #54255
(FlashInfer backends), #54103 (ROCm). None touches either line.

Test

Two regression tests added. The existing RecoverSSM tests set
builder.recoverssm_context = Mock(), so _get_recoverssm_context returns
early and the layer_names read never executes -- which is why CI is green on
a path that cannot start.

Kimi-K3-MXFP4, 16×GB300, aggregated, TP16 × DCP8 × PP1, DSpark
num_speculative_tokens=4, mamba_cache_mode=align, mamba-backend=triton,
VLLM_USE_V2_MODEL_RUNNER=1, 3600 s per point. Three arms one flag apart.

base +triton +--use-replayssm
c64, tok/s/GPU 3,760.3 3,816.9 3,940.8
c64, ITL p90 ms 166.94 170.48 165.28
c128, tok/s/GPU 2,749.9 2,706.1 2,828.5
c128, ITL p90 ms 205.23 204.00 192.51

+3.2% at c64 and +4.5% at c128 over the triton control, in ITL.

Limitations

  • The throughput arms use synthetic acceptance with ignore_eos, so a stale
    recurrent state would not show in them. GSM8K with
    rejection_sample_method=standard, 1319 questions, 5 shots, same config one
    flag apart: 0.9530 off / 0.9545 on, invalid 0.08% / 0.15%. Two questions
    on 1319, and in the wrong direction for a staleness bug.
  • Kimi-K3 / NVIDIA KDA only. Fix 1 is in shared GDN code; the AMD KDA builder
    has no RecoverSSM or checkpoint path and does not read either field, so it is
    unaffected.
  • Single configuration, no repeats, so no error bars.

AI assistance

Written with Claude Code. Both crashes were reproduced on hardware and the
tracebacks are verbatim.

--use-replayssm selects the RecoverSSM path on Kimi-K3. That path cannot start
today: it raises during cudagraph capture, and once that is fixed it raises
again on the first execute_model. Both are unbound reads.

1. GDNAttentionMetadataBuilder.__init__ does not call
   AttentionMetadataBuilder.__init__, where layer_names is stored, and assigns
   its fields by hand -- vllm_config, compilation_config, speculative_config,
   kv_cache_spec. layer_names was left out. KimiK3KDAMetadataBuilder passes it
   up correctly; nothing receives it. Only _get_recoverssm_context reads the
   attribute, so no other path has noticed.

2. The checkpoint block guards on 'spec_sequence_masks_cpu is not None' while
   active_non_spec_mask_cpu is bound only in the num_spec_decodes > 0 branch;
   the sibling has_initial_state use guards on the latter. The two agree
   without RecoverSSM, because a step whose draft tokens sum to zero clears the
   mask -- but that clearing carries 'not self.use_recoverssm', so under
   RecoverSSM the mask survives with a zero count and the read is unbound.

   num_spec_decodes > 0 is also the correct alignment. checkpoint_offsets is
   consumed by _store_cache_checkpoints_kernel over a grid of its own numel,
   where seq_idx indexes non_spec_query_start_loc; when num_spec_decodes == 0
   that tensor is the full query_start_loc, so request_rows must be all rows,
   which is what the fallback already builds.

Signed-off-by: misunp <misunp@nvidia.com>

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

@mergify mergify Bot added kimi k3 bug Something isn't working labels Aug 31, 2026
@mispa-ms
mispa-ms marked this pull request as draft August 31, 2026 03:31
Review feedback.

self.device was missing for the same reason layer_names was: the builder never
calls AttentionMetadataBuilder.__init__ and assigns its fields by hand. Call
super() instead of adding a second field -- it restores all four the base
stores, matches flash_attn / mamba_attn / triton_attn, and is two lines shorter.

Two regression tests. The existing recoverssm tests set
builder.recoverssm_context = Mock(), so _get_recoverssm_context returns early
and the layer_names read is never executed; that is why CI is green on a path
that cannot start. The first test asserts the base fields directly. The second
mirrors test_internal_checkpoint_metadata_targets_last_aligned_boundary with
use_recoverssm=True on a prefill-only batch, which is the real trigger:
num_decode_draft_tokens is filled with -1 and written only on spec-decode rows,
so the >= 0 mask is all False -- non-None with num_spec_decodes == 0.

Signed-off-by: misunp <misunp@nvidia.com>
Review feedback: the test passed against the unfixed code, so it guarded
nothing.

build() takes num_decode_draft_tokens_cpu=None by default, and the first
classification branch is 'not self.use_spec_decode or
num_decode_draft_tokens_cpu is None', which sets spec_sequence_masks_cpu to
None. The old guard was then False and active_non_spec_mask_cpu was never read.
The docstring described the all-False mask correctly while the body never built
one.

Pass the -1 fill the runner uses on a prefill-only step, as
test_recoverssm_distinguishes_draftless_decode_from_one_token_prefill already
does. The mask is then a non-None all-False tensor with num_spec_decodes == 0:
the old guard reads an unbound local, the new one takes request_rows =
[0, 1]. Expectations are unchanged.

Signed-off-by: misunp <misunp@nvidia.com>
@mispa-ms
mispa-ms marked this pull request as ready for review August 31, 2026 04:24
Review feedback: the test did not run when its subject changed.

The Kimi K3 suite triggers on vllm/models/kimi_k3/ and tests/models/kimi_k3/;
the V1 Attention suite triggers on vllm/v1/attention/ and tests/v1/attention/.
Neither list contains the other, so a later change to gdn_attn.py that drops
layer_names or device again would not run the assert written to catch it --
restoring exactly the condition under which crash 1 survived.

tests/v1/attention/test_gdn_metadata_builder.py already has a CPU DEVICE, no
CUDA gate and a _create_gdn_builder() helper that passes layer_names and
device, so the test is four lines there and exercises the shared builder
directly rather than through the Kimi subclass. It also stops being pinned to
b200-k8s for what is a pure CPU check.

The checkpoint test stays in tests/models/kimi_k3/: its guard lives in
vllm/models/kimi_k3/nvidia/kda_metadata.py and the trigger already matches.

Signed-off-by: misunp <misunp@nvidia.com>
It described assigning fields instead of calling super() as current behaviour,
sitting next to the super() call this PR adds. The next reader would take it as
saying not calling super() is fine here.

Signed-off-by: misunp <misunp@nvidia.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.

1 participant