[Bugfix][Core] Retire stale Mamba spec blocks in prefill checkpoint steps by simon-veitner-redhat · Pull Request #59759 · vllm-project/vllm · GitHub
Skip to content

[Bugfix][Core] Retire stale Mamba spec blocks in prefill checkpoint steps - #59759

Merged
ZJY0516 merged 2 commits into
vllm-project:mainfrom
simon-veitner-redhat:fix-mamba-ckpt-stale-spec-block
Oct 6, 2026
Merged

ZJY0516 merged 2 commits into
vllm-project:mainfrom
simon-veitner-redhat:fix-mamba-ckpt-stale-spec-block

Conversation

@simon-veitner-redhat

@simon-veitner-redhat simon-veitner-redhat commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Overview

With MTP and prefix caching on, hybrid models such as GLM-5.3-Flash can return garbage when a request reuses a cached prompt prefix. Some prefill steps save a checkpoint of the linear-attention state in the middle of a chunk. In those steps the scheduler leaves an unused MTP scratch block in the slot that belongs to the checkpoint. That block is never written, yet it is registered in the prefix cache ahead of the real checkpoint, so later requests resume from it. This change moves the scratch block out of the way in those steps too, as already happens in every other step.

The skip was added together with internal checkpoints in #53614. GLM-5.3-Flash reaches this path since #56960. The branch is based on current main (58b3298). No open PR changes speculative-block relocation; #57128 changes the Mamba lookup and does not touch this path.

Claims

  • Fixes persistent garbage output after prefix-cache hits for hybrid models with internal prefill checkpoints and MTP, for example GLM-5.3-Flash with --speculative-config '{"method":"mtp","num_speculative_tokens":1}'.
  • No behavior change without speculative decoding, for mamba_cache_mode other than align, or for steps without an internal checkpoint.

Validation

Setup: RedHatAI/GLM-5.3-Flash-NVFP4 on 2 H200 GPUs, served with default settings plus MTP:

vllm serve RedHatAI/GLM-5.3-Flash-NVFP4 --tensor-parallel-size 2 \
    --speculative-config '{"method": "mtp", "num_speculative_tokens": 1}'

Reproducer (script below): send a long prompt while another long prompt is still being processed, then send the exact same prompt again. The second request reuses the cached start of the prompt.

  • Before the fix, the second answer was garbage in 3 of 3 trials, for example File: /home/erdos/2024/4/4 # File: /home/erdos/2024/4/4 ..., while the first answer was a normal list of function names.
  • With the fix, both answers were normal in 3 of 3 trials.

Other checks:

  • A single long prompt sent twice, with nothing else running, also came back broken in 4 of 7 runs at about 25k tokens before the fix, and in 0 of 12 runs after.
  • In an 8-agent coding-agent run (10 turns, full history resent each turn), 2 agents switched to word salad right after reusing their cached history and stayed broken. With the fix, and with MTP off, no agent broke.
  • New test test_mamba_checkpoint_hash_maps_only_to_checkpoint_block fails on main and passes with the fix.
  • pytest tests/v1/core/test_single_type_kv_cache_manager.py tests/v1/core/test_prefix_caching.py tests/v1/core/test_mamba_align_chunk_split.py tests/v1/core/test_prefix_replay.py tests/v1/core/prefix_cache passes; the GPU part, test_hybrid_prefix_cache_hit_rate.py (base and mtp), was run on one H200.
repro.py
"""Reproducer: with MTP on, sending the same long prompt twice can return garbage the second time.

Server (any recent main):
  vllm serve RedHatAI/GLM-5.3-Flash-NVFP4 --tensor-parallel-size 2 \
      --speculative-config '{"method": "mtp", "num_speculative_tokens": 1}'

Run:
  python repro.py http://localhost:8000/v1

Each trial sends a long prompt while another long prompt is running, then sends the
same prompt again. The second answer reuses the cached start of the prompt. Before
the fix it is often garbage; after the fix both answers are normal.
"""

import random
import sys
import threading

from openai import OpenAI

client = OpenAI(base_url=sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000/v1", api_key="none")
MODEL = client.models.list().data[0].id
WORDS = "alpha beta gamma delta kernel buffer index cache shard block token commit".split()


def long_prompt(seed, size):
    """Made-up Python code of about size characters, different for every seed."""
    rng = random.Random(seed)
    lines = []
    while sum(map(len, lines)) < size:
        lines.append(f"def {rng.choice(WORDS)}_{rng.choice(WORDS)}(x): return {rng.randrange(10**6)}  "
                     f"# {' '.join(rng.choices(WORDS, k=8))}\n")
    return "Here is a file:\n" + "".join(lines) + "\nList the first five function names, then summarize the file."


def ask(prompt, max_tokens):
    out = client.chat.completions.create(
        model=MODEL, messages=[{"role": "user", "content": prompt}], max_tokens=max_tokens, temperature=0,
        extra_body={"chat_template_kwargs": {"reasoning_effort": "low"}})  # GLM-5.3: short thinking
    msg = out.choices[0].message
    thinking = getattr(msg, "reasoning", None) or getattr(msg, "reasoning_content", None) or ""
    return (thinking + (msg.content or "")).replace("\n", " ")


def run_together(prompts, max_tokens):
    threads = [threading.Thread(target=ask, args=(p, max_tokens)) for p in prompts]
    for t in threads:
        t.start()
    for t in threads:
        t.join()


# Whether a prompt is affected depends on its exact length; these lengths trigger it.
for trial, size in enumerate([60000, 60000, 60000]):
    # Keep the server busy for a moment, as it would be in production.
    run_together([long_prompt(1000 + 10 * trial + k, 90000) for k in range(6)], 64)
    # A long request is running while the target prompt is processed for the first time.
    background = threading.Thread(target=ask, args=(long_prompt(10 * trial + 1, size), 300))
    background.start()
    threading.Event().wait(0.3)
    target = long_prompt(10 * trial + 2, size)
    first = ask(target, 60)
    background.join()
    # The exact same request again.
    second = ask(target, 60)
    print(f"trial {trial}")
    print(f"  first:  {first[:100]}")
    print(f"  second: {second[:100]}")

Example output on main (before the fix):

trial 0
  first:  **First five function names:** 1. `alpha_beta` 2. `index_token` 3. `buffer_cache` 4. `gamma_shard` 5
  second: List the first five function names, then summarize the file. </span></div></div></div></div></
trial 1
  first:  **First five function names:** 1. `cache_kernel` 2. `delta_shard` 3. `token_buffer` 4. `cache_beta`
  second: File: /home/erdos/2024/4/4  # File: /home/erdos/2024/4/4 File: /home/erdos/2024/4/4 File: /home/erdo
trial 2
  first:  **First five function names:** 1. `gamma_delta` 2. `alpha_buffer` 3. `index_delta` 4. `kernel_gamma`
  second: The first five function names, then summarize the file.The first five function names, then summarize

With the fix:

trial 0
  first:  **First five function names:** 1. `alpha_beta` 2. `index_token` 3. `buffer_cache` 4. `gamma_shard` 5
  second: **First five function names:** 1. `alpha_beta` 2. `index_token` 3. `buffer_cache` 4. `gamma_shard` 5
trial 1
  first:  **First five function names:** 1. `cache_kernel` 2. `delta_shard` 3. `token_buffer` 4. `cache_beta`
  second: **First five function names:** 1. `cache_kernel` 2. `delta_shard` 3. `token_buffer` 4. `cache_beta`
trial 2
  first:  **First five function names:** 1. `gamma_delta` 2. `alpha_buffer` 3. `index_delta` 4. `kernel_gamma`
  second: **First five function names:** 1. `gamma_delta` 2. `alpha_buffer` 3. `index_delta` 4. `kernel_gamma`

Details

In align mode a request keeps its running state in the block after the last computed one, followed by num_speculative_blocks scratch blocks. A normal allocate_new_blocks call moves the old scratch blocks that fall below the new running state to the end of the table and nulls their slots. In a checkpoint step this was skipped (if blocks_allocated and not checkpoint_block).

With MTP, the checkpoint position P is shifted one block down. When one of the previous step's scratch blocks sits at index P // block_size - 1 (with k=1: the chunk starts one block below P), it occupies the natural slot of P. The checkpoint itself is written into a new block at cdiv(end, block_size) - 2, one index above it. In the same step, cache_full_blocks registers the unwritten scratch block under hash(P), and _cache_partial_tail_block then adds the real checkpoint block under the same hash. get_one_block returns the first block registered, so later hits at P resume from the scratch page. The entry stays until eviction, so every request that shares this prefix is affected.

It happens when a prompt's final prefill chunk starts one block below the shifted checkpoint, so it depends on prompt length and on how much of each step's token budget other requests use. Overlapping long prompts make it much more likely. For example, with a second request of 22350 tokens, a first request of up to 23424 tokens triggers it (3/3) and one of 23440 tokens does not (0/3).

The fix relocates scratch blocks below the checkpoint index (block_idx < num_skipped_blocks - checkpoint_block). A scratch block exactly at the checkpoint index stays and becomes the checkpoint block, since the worker exports the checkpoint into that column. Because relocated blocks are reused, checkpoint steps for already-allocated requests no longer need num_speculative_blocks extra new blocks. The allocation cap in allocate_new_blocks and the admission estimate in get_num_blocks_to_allocate both drop that term, so they stay equal. The existing test_mamba_checkpoint_admission_matches_allocation and the new test both check this.

On unfixed builds, disabling MTP or prefix caching avoids the bug.

Possible CI follow-up: no current job would catch this. The only nightly on this path, the B200 Kimi-K3 spec-decode GSM8K job, uses ~640-token prompts, and GSM8K accuracy does not move noticeably. A small extra phase in that job could catch this class of bug. After the GSM8K pass, it would send about 15 long prompts with lengths spread over one chunk-budget period, then send each again and check that the cached answer starts the same as a fresh computation. On GLM-5.3-Flash such a phase failed on unfixed main in 3 of 3 runs and passed with this fix in 3 of 3. I can send it as a separate PR if that is useful.

AI assistance (Claude) was used; the change was reviewed and validated by the submitter.


Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

🤖 Generated with Claude Code

…teps

In align mode with speculative decoding, a prefill chunk that exports an
internal checkpoint skipped relocating the previous step's speculative
scratch block. The block stayed at the natural index of the checkpoint
position, was never written, and was still cached under that position's
block hash next to the real checkpoint block. Lookups return the first
block registered under a hash, so later prefix-cache hits resumed the
linear-attention state from the stale block and produced garbage.

Relocate scratch blocks below the checkpoint index in checkpoint steps
too, and drop the extra speculative blocks from the allocation cap and
admission estimate, since relocation now covers them.

AI assistance was used (Claude). The change was reviewed and tested by
the submitter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Simon Veitner <sveitner@redhat.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.

@robertgshaw2-redhat robertgshaw2-redhat added the ready ONLY add when PR is ready to merge/full CI is needed label Oct 4, 2026
@robertgshaw2-redhat

Copy link
Copy Markdown
Collaborator

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown

✅ @simon-veitner-redhat, CI is now available for this PR.

  • /ci run starts upstream CI; /amd-ci run starts AMD CI only.
  • Your branch must contain every commit currently on its upstream target branch. Merge or rebase onto the latest target branch, then rerun the command. Append --allow-stale to a run command to test an outdated branch at your own risk.
  • /ci retry retries failed jobs in the CI build for the current PR head. If the current head has no CI build, it starts a new CI build for the current head containing only jobs that failed in the latest earlier CI build for this PR.
  • /amd-ci retry retries failed jobs in AMD CI for the current PR head. Use /amd-ci run when the current head has no AMD CI build.
  • /ci cancel cancels scheduled or running CI builds for this PR branch; /amd-ci cancel does the same for AMD CI only.

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown

✅ Triggered Buildkite CI #92804 for commit c70f55efee1c.

@vllm-agent

Copy link
Copy Markdown
Collaborator

CI selector (shadow): 29 test steps (45 jobs) instead of 60 (77 jobs)

Shadow mode: this changes nothing about what CI runs. It shows what the evidence-based selector would pick for this PR, next to today's rules. How it works.

Feedback welcome: reply here if it would skip a step this change needs, or runs something unrelated.

steps (jobs) Today's rules Selector Would skip Would add
NVIDIA, CPU and others 60 (77) 29 (45) 49 (61) 18 (29)
AMD mirrors 51 (62) 22 (31) 44 (54) 15 (23)
Selector would run (29)
  • amd-qwen3-next-mtp-async-eplb-accuracy
  • distributed-dp-tests-2-gpus
  • distributed-model-tests-2-gpus ×3
  • e2e-core-1-gpu
  • e2e-core-large-memory
  • entrypoints-integration-api-server-generate
  • extract-hidden-states-integration
  • hybrid-ssm-nixlconnector-pd-accuracy-tests-4-gpus
  • hybrid-ssm-nixlconnector-pd-prefix-cache-2-gpus
  • kimi-k3-prefix-cache-4xb200 ×2
  • kv-offload-medium
  • kv-offload-small
  • language-models-tests-granite-l4-compatibility
  • language-models-tests-hybrid ×2
  • lm-eval-watermarking
  • lora-tp-distributed ×4
  • model-executor
  • mrcr-eval-small-models
  • multi-modal-models-standard-4-other-whisper
  • pipeline-context-parallelism-4-gpus
  • quantization ×4
  • quantized-moe-test-b200
  • replayssm-e2e
  • spec-decode-draft-model ×4
  • spec-decode-mtp-qwen3-5
  • v1-core
  • v1-executor-worker
  • v1-kv-connectors ×4
  • v1-others-cpu
Would skip (today's rules run them) (49)
  • ascend-npu-test
  • async-engine-inputs-utils-worker
  • basic-correctness ×2
  • basic-correctness-cpu-offload
  • basic-correctness-cumem
  • basic-correctness-prefetch-offload
  • basic-correctness-sleep-mode
  • basic-models-test-other-cpu
  • basic-models-tests-initialization
  • basic-models-tests-other
  • benchmarks-cli-test
  • cpu-language-generation-and-pooling-model-tests ×3
  • cpu-multimodal-config
  • cpu-params-env-tokenizers-parser
  • cpu-reasoning-renderers
  • cpu-tool-parsers
  • e2e-scheduling-1-gpu
  • e2e-scheduling-accuracy-1-gpu
  • entrypoints-integration-api-server ×4
  • entrypoints-integration-api-server-openai-chat_completion
  • entrypoints-integration-api-server-openai-completion
  • entrypoints-integration-llm
  • entrypoints-integration-multimodal
  • entrypoints-integration-pooling
  • entrypoints-integration-responses-api
  • entrypoints-integration-speech_to_text
  • kernels-fla-ops-test-b200
  • kernels-mhc-test-b200
  • kernels-root-misc-test-b200
  • language-models-tests-standard
  • metrics-tracing-2-gpus
  • multi-modal-accuracy-eval-small-models
  • multi-modal-models-standard-1-qwen2
  • multi-modal-models-standard-2-qwen3-gemma
  • multi-modal-models-standard-3-llava-qwen2-vl
  • multi-modal-processor ×4
  • multi-modal-processor-cpu ×4
  • pytorch-compilation-dynamic-shapes
  • pytorch-compilation-passes-unit-tests
  • pytorch-compilation-unit-tests
  • pytorch-compilation-unit-tests-h100
  • pytorch-fullgraph-cudagraph-l4-compatibility
  • pytorch-fullgraph-test
  • regression
  • v1-kv-offload
  • v1-logits-oracle
  • v1-metrics-lmeval
  • v1-sample
  • v1-spec-decode
Would add (today's rules do not run them) (18)
  • amd-qwen3-next-mtp-async-eplb-accuracy (Python record)
  • distributed-dp-tests-2-gpus (Python record)
  • distributed-model-tests-2-gpus ×3 (Python record)
  • extract-hidden-states-integration (Python record)
  • hybrid-ssm-nixlconnector-pd-accuracy-tests-4-gpus (Python record)
  • hybrid-ssm-nixlconnector-pd-prefix-cache-2-gpus (Python record)
  • kv-offload-medium (Python record)
  • kv-offload-small (Python record)
  • lm-eval-watermarking (Python record)
  • lora-tp-distributed ×4 (Python record)
  • model-executor (Python record)
  • mrcr-eval-small-models (Python record)
  • pipeline-context-parallelism-4-gpus (Python record)
  • quantization ×4 (Python record)
  • quantized-moe-test-b200 (Python record)
  • replayssm-e2e (Python record)
  • spec-decode-draft-model ×4 (Python record)
  • spec-decode-mtp-qwen3-5 (Python record)
AMD mirrors: would skip (44)
  • async-engine-inputs-utils-worker
  • basic-correctness ×2
  • basic-correctness-cpu-offload
  • basic-correctness-cumem
  • basic-correctness-prefetch-offload
  • basic-correctness-sleep-mode
  • basic-models-tests-initialization
  • basic-models-tests-other
  • benchmarks-cli-test
  • e2e-scheduling-1-gpu
  • e2e-scheduling-accuracy-1-gpu
  • entrypoints-integration-api-server ×4
  • entrypoints-integration-api-server-openai-chat_completion
  • entrypoints-integration-api-server-openai-completion
  • entrypoints-integration-llm
  • entrypoints-integration-multimodal
  • entrypoints-integration-pooling
  • entrypoints-integration-responses-api
  • entrypoints-integration-speech_to_text
  • kernels-fla-ops-test-b200
  • kernels-mhc-test-b200
  • kernels-root-misc-test-b200
  • language-models-tests-standard
  • metrics-tracing-2-gpus
  • multi-modal-accuracy-eval-small-models
  • multi-modal-models-standard-1-qwen2
  • multi-modal-models-standard-2-qwen3-gemma
  • multi-modal-models-standard-3-llava-qwen2-vl
  • multi-modal-processor ×4
  • platform-tests
  • pytorch-compilation-dynamic-shapes
  • pytorch-compilation-passes-unit-tests
  • pytorch-compilation-unit-tests
  • pytorch-compilation-unit-tests-h100
  • pytorch-fullgraph-cudagraph-l4-compatibility
  • pytorch-fullgraph-test
  • regression
  • v1-executor-worker
  • v1-kv-connectors ×4
  • v1-kv-offload
  • v1-logits-oracle
  • v1-metrics-lmeval
  • v1-sample
  • v1-spec-decode
AMD mirrors: would add (15)
  • distributed-dp-tests-2-gpus (Python record)
  • distributed-model-tests-2-gpus ×3 (Python record)
  • extract-hidden-states-integration (Python record)
  • hybrid-ssm-nixlconnector-pd-accuracy-tests-4-gpus (Python record)
  • hybrid-ssm-nixlconnector-pd-prefix-cache-2-gpus (Python record)
  • kv-offload-medium (Python record)
  • kv-offload-small (Python record)
  • lm-eval-watermarking (Python record)
  • lora-tp-distributed ×4 (Python record)
  • model-executor (Python record)
  • mrcr-eval-small-models (Python record)
  • nixlconnector-pd-spec-decode-acceptance-2-gpus (Python record)
  • pipeline-context-parallelism-4-gpus (Python record)
  • spec-decode-draft-model ×4 (Python record)
  • spec-decode-mtp-qwen3-5 (Python record)

2 changed files · base bdd31c31de · head c70f55efee · Python record: build 92785 at 18f8f96025 · kernel record: table 18f8f96 (build 92785), map 18f8f96 · not counted: 10 build steps, 5 A100 steps the generator no longer emits, 49 optional steps the selector would also run

@benchislett benchislett left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

seems fine 🤷

@matkinson-godaddy

Copy link
Copy Markdown

@ZJY0516 ZJY0516 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for fixing this

@ZJY0516
ZJY0516 merged commit 6fa67af into vllm-project:main Oct 6, 2026
145 of 146 checks passed
@simon-veitner-redhat
simon-veitner-redhat deleted the fix-mamba-ckpt-stale-spec-block branch October 6, 2026 07:27
YibaiMeng added a commit to CentML/vllm that referenced this pull request Oct 7, 2026
…teps

Port of vllm-project#59759 (merged 2026-10-06). In a checkpoint step
allocate_new_blocks did not relocate the previous chunk's speculative
scratch blocks, so one could stay below the checkpoint column, never
written, where cache_blocks could register it as a full-block prefix entry.
Relocate scratch blocks below the checkpoint index (one at the checkpoint
index stays and becomes the checkpoint block); checkpoint steps of
allocated requests no longer need num_speculative_blocks extra new blocks,
in the allocation cap and in the admission estimate alike.

On this branch be3d9b8 (cache_blocks skips every column below the
checkpoint column) already keeps such a block out of the prefix cache, so
the upstream symptom is not reachable here; the upstream test passes with
and without this commit, and fails only with be3d9b8 reverted. Both stay:
this commit alone does not cover the sub-block prefix-hit copy case of
be3d9b8 (test_checkpointed_chunk_skips_sub_block_hit_copy fails without
it). Upstream test test_mamba_checkpoint_hash_maps_only_to_checkpoint_block
included. (pre-commit ruff SIM102 at line 1614 is pre-existing, untouched.)

Signed-off-by: Yibai Meng <yibaim@nvidia.com>
michael-gpu pushed a commit to practical-inference/vllm-ampere-extended that referenced this pull request Oct 20, 2026
…teps (vllm-project#59759)

Signed-off-by: Simon Veitner <sveitner@redhat.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Robert Shaw <114415538+robertgshaw2-redhat@users.noreply.github.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 kv-cache-manager ready ONLY add when PR is ready to merge/full CI is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants