A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 6, 14:30 UTC
[Bugfix] Fix NemotronH MTP + Chunked Prefill (#35447)
Benjamin Chislett committed
Mar 17, 2026 at 02:07 UTC
8a680463fab3bc9e6760417cd5c0a6aa58283065
5 files changed
+181
-8
tests/v1/e2e/test_hybrid_chunked_prefill.py
new
+104
@@ -0,0 +1,104 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import pytest
5
+
6
+from vllm import SamplingParams
7
+from vllm.platforms import current_platform
8
+
9
+from ...utils import large_gpu_mark, multi_gpu_marks
10
+
11
+# A trivial request with a short prompt to ensure we run a mixed batch
12
+SMALL_MESSAGE = [
13
+ {
14
+ "role": "user",
15
+ "content": "The secret beta value is 64. What is the secret beta?",
16
+ }
17
+]
18
+
19
+# Sample prompt with a bunch of filler in between the critical fact and the request.
20
+# Both parts need to be processed properly for the model to generate the correct answer
21
+MESSAGES = [
22
+ {
23
+ "role": "user",
24
+ "content": (
25
+ "Important: The secret number is 42. "
26
+ "The sky is green in this hypothetical world. "
27
+ "Apples grow on trees in the forest. "
28
+ "Rivers flow through the valleys and mountains. "
29
+ "Birds sing songs in the early morning light. "
30
+ "The weather today is sunny with clear skies ahead. "
31
+ "Flowers bloom in the garden during spring season. "
32
+ "Now answer with ONLY the number and nothing else: "
33
+ "What is the secret number plus one?"
34
+ ),
35
+ }
36
+]
37
+
38
+
39
+@pytest.mark.skipif(not current_platform.is_cuda(), reason="CUDA not available")
40
+@pytest.mark.parametrize(
41
+ "model_name",
42
+ [
43
+ pytest.param("Qwen/Qwen3.5-4B", marks=[large_gpu_mark(min_gb=40)]),
44
+ pytest.param(
45
+ "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-FP8",
46
+ marks=[large_gpu_mark(min_gb=80)] + multi_gpu_marks(num_gpus=2),
47
+ ),
48
+ ],
49
+)
50
+@pytest.mark.parametrize("enable_prefix_caching", [False, True])
51
+def test_mtp_speculative_mixed_batch_short_prefill(
52
+ vllm_runner, model_name, enable_prefix_caching
53
+):
54
+ """Test to ensure MTP speculative decoding correctly handles
55
+ short prefill chunks that fall below the reorder_batch_threshold."""
56
+
57
+ # Set so large that both prefills will be classified as decodes in a mixed batch
58
+ # note, with prefix caching we require chunk_size >= mamba_block_size
59
+ chunk_size = 256 if not enable_prefix_caching else 16384
60
+ num_draft_tokens = 100
61
+
62
+ with vllm_runner(
63
+ model_name,
64
+ speculative_config={
65
+ "method": "mtp",
66
+ "num_speculative_tokens": num_draft_tokens,
67
+ },
68
+ max_num_batched_tokens=chunk_size,
69
+ max_model_len=512,
70
+ enforce_eager=True,
71
+ tensor_parallel_size=2,
72
+ trust_remote_code=True,
73
+ enable_chunked_prefill=True,
74
+ enable_prefix_caching=enable_prefix_caching,
75
+ mamba_cache_mode="align" if enable_prefix_caching else "none",
76
+ ) as llm:
77
+ sampling_params = SamplingParams(
78
+ temperature=0.0,
79
+ max_tokens=128,
80
+ )
81
+
82
+ # First small message gets prefilled first, under normal conditions since the
83
+ # batch is not yet mixed. Then the second prefill arrives as a mixed batch, but
84
+ # is shorter than num_speculative_tokens, so it gets misclassified as a decode
85
+ # and processed with the wrong state management logic, causing the critical
86
+ # fact from the first chunk to be lost and the model to generate nonsense.
87
+ outputs = llm.get_llm().chat(
88
+ [SMALL_MESSAGE, MESSAGES],
89
+ sampling_params,
90
+ chat_template_kwargs={"enable_thinking": False},
91
+ )
92
+
93
+ responses = []
94
+ for output in outputs:
95
+ generated_text = output.outputs[0].text
96
+ print(f"Generated text: {generated_text!r}")
97
+ responses.append(generated_text)
98
+
99
+ assert "64" in responses[0], (
100
+ "The first response should contain the correct value of 64."
101
+ )
102
+ assert "43" in responses[1], (
103
+ "The second response should contain the correct value of 42+1=43."
104
+ )
vllm/model_executor/layers/mamba/ops/mamba_ssm.py
+3
-3
@@ -334,13 +334,13 @@ def selective_state_update(
334
dt_bias = dt_bias.unsqueeze(0)
335
if out.dim() == 2:
336
out = out.unsqueeze(1)
337
- if num_accepted_tokens is not None:
338
- assert state_batch_indices is not None and state_batch_indices.dim() == 2
339
- assert dst_state_batch_indices is None or dst_state_batch_indices.dim() == 2
337
if state_batch_indices is not None and state_batch_indices.dim() == 1:
338
state_batch_indices = state_batch_indices.unsqueeze(1)
339
if dst_state_batch_indices is not None and dst_state_batch_indices.dim() == 1:
340
dst_state_batch_indices = dst_state_batch_indices.unsqueeze(1)
341
+ if num_accepted_tokens is not None:
342
+ assert state_batch_indices is not None and state_batch_indices.dim() == 2
343
+ assert dst_state_batch_indices is None or dst_state_batch_indices.dim() == 2
344
345
_, nheads, dim, dstate = state.shape
346
batch = x.shape[0]
vllm/v1/attention/backends/mamba_attn.py
+6
-4
@@ -414,8 +414,11 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
414
]
415
state_indices_tensor_p = state_indices_tensor_p[:, 0]
416
417
- if num_decodes > 0 and self.use_spec_decode:
418
- assert num_accepted_tokens is not None
417
+ # Sometimes even with specdec enabled we get single-token prefill chunks that
418
+ # should be treated as decodes but don't have num_accepted_tokens set.
419
+ # These should be fine to process as non-spec decodes since there's only
420
+ # one token, so no risk of placing accepted tokens in the wrong slot.
421
+ if num_decodes > 0 and self.use_spec_decode and num_accepted_tokens is not None:
422
query_start_loc_d = common_attn_metadata.query_start_loc[: num_decodes + 1]
423
num_accepted_tokens = num_accepted_tokens[:num_decodes]
424
@@ -501,9 +504,8 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
504
state_indices_tensor_d = self.state_indices_tensor_d[:padded_bs]
505
state_indices_tensor_d[metadata.num_decodes :] = PAD_SLOT_ID
506
504
- if self.use_spec_decode:
507
+ if self.use_spec_decode and num_accepted_tokens is not None:
508
assert query_start_loc_d is not None
506
- assert num_accepted_tokens is not None
509
query_start_loc_d = query_start_loc_d[: padded_bs + 1]
510
self.decode_num_accepted_tokens[: metadata.num_decodes].copy_(
511
num_accepted_tokens, non_blocking=True
vllm/v1/worker/gpu_model_runner.py
+26
-1
@@ -739,6 +739,19 @@ class GPUModelRunner(
739
740
self.uniform_decode_query_len = 1 + self.num_spec_tokens
741
742
+ # When spec decode is active, the mamba backend classifies requests
743
+ # with query_len <= reorder_batch_threshold as "decodes". Prefill
744
+ # chunks that fall under this threshold get processed via the decode
745
+ # path, which stores intermediate states at sequential slots. We must
746
+ # set num_accepted_tokens to the chunk's query_len for those requests
747
+ # so the next iteration reads from the correct final-state slot.
748
+ # Prefills that went through the actual prefill path should keep the
749
+ # default value of 1 (the prefill path stores state at slot 0 only).
750
+ self.needs_prefill_as_decode_slots: bool = False
751
+ self.prefill_as_decode_num_tokens = self._make_buffer(
752
+ self.max_num_reqs, dtype=torch.int32
753
+ )
754
+
755
# Cudagraph dispatcher for runtime cudagraph dispatching.
756
self.cudagraph_dispatcher = CudagraphDispatcher(self.vllm_config)
757
@@ -1355,12 +1368,22 @@ class GPUModelRunner(
1368
.int()
1369
.argmax(-1)
1370
)
1371
+ spec_decode_active = bool(scheduler_output.scheduled_spec_decode_tokens)
1372
+ if self.needs_prefill_as_decode_slots and spec_decode_active:
1373
+ mamba_utils.update_accepted_tokens_for_prefill_as_decode(
1374
+ self.input_batch,
1375
+ self.prefill_as_decode_num_tokens,
1376
+ self.num_accepted_tokens.gpu,
1377
+ scheduler_output,
1378
+ self.reorder_batch_threshold,
1379
+ num_reqs,
1380
+ )
1381
+
1382
if self.cache_config.mamba_cache_mode == "align":
1383
for i, num_tokens in enumerate(
1384
self.num_accepted_tokens.gpu[:num_reqs].cpu().numpy()
1385
):
1386
self.input_batch.num_accepted_tokens_cpu[i] = num_tokens
1363
-
1387
mamba_utils.postprocess_mamba(
1388
scheduler_output,
1389
self.kv_cache_config,
@@ -2024,6 +2047,8 @@ class GPUModelRunner(
2047
else 0
2048
)
2049
2050
+ if isinstance(builder, Mamba2AttentionMetadataBuilder):
2051
+ self.needs_prefill_as_decode_slots = True
2052
extra_attn_metadata_args = {}
2053
if use_spec_decode and isinstance(
2054
builder, (Mamba2AttentionMetadataBuilder, GDNAttentionMetadataBuilder)
vllm/v1/worker/mamba_utils.py
+42
@@ -266,3 +266,45 @@ def postprocess_mamba(
266
if src_block_idx == dest_block_idx:
267
num_accepted_tokens_cpu[i] = 1
268
do_mamba_copy_block(copy_bufs)
269
+
270
+
271
+def update_accepted_tokens_for_prefill_as_decode(
272
+ input_batch: GPUInputBatch,
273
+ prefill_as_decode_num_tokens: CpuGpuBuffer,
274
+ num_accepted_tokens_gpu: torch.Tensor,
275
+ scheduler_output: SchedulerOutput,
276
+ decode_qlen_threshold: int | None,
277
+ num_reqs: int,
278
+):
279
+ """
280
+ Adjusts num_accepted_tokens for prefill chunks processed via the decode path.
281
+ This ensures subsequent iterations read from the correct sequential state slot
282
+ instead of the default prefill slot 0. Not used by GDN attention, which manually
283
+ separates short prefills and short decodes when building the attention metadata.
284
+ """
285
+ any_is_prefill = False
286
+ for i in range(num_reqs):
287
+ num_computed = input_batch.num_computed_tokens_cpu[i]
288
+ num_prompt = input_batch.num_prompt_tokens[i]
289
+ is_prefill = num_computed < num_prompt
290
+ req_id = input_batch.req_ids[i]
291
+ query_len = scheduler_output.num_scheduled_tokens[req_id]
292
+
293
+ if is_prefill:
294
+ classified_as_decode = (
295
+ decode_qlen_threshold is not None and query_len <= decode_qlen_threshold
296
+ )
297
+ num_tokens = query_len if classified_as_decode else 1
298
+ any_is_prefill = True
299
+ else:
300
+ num_tokens = -1
301
+ prefill_as_decode_num_tokens.np[i] = num_tokens
302
+
303
+ # We can skip the GPU transfer if there aren't any values to update
304
+ if any_is_prefill:
305
+ prefill_as_decode_num_tokens.copy_to_gpu(num_reqs)
306
+ num_accepted_tokens_gpu[:num_reqs] = torch.where(
307
+ prefill_as_decode_num_tokens.gpu[:num_reqs] != -1,
308
+ prefill_as_decode_num_tokens.gpu[:num_reqs],
309
+ num_accepted_tokens_gpu[:num_reqs],
310
+ )