Skip to content

Commit 5bd4e39

Browse files
recky-ccursoragent
andauthored
[BugFix](pcp): align decode classification with vLLM reorder logic (vllm-project#10580)
## What this PR does / why we need it? With PCP enabled and speculative decoding (e.g. MTP), `PCPManager.init_batch_info()` previously marked a request as decode whenever `num_scheduled_tokens <= decode_threshold`. That is insufficient for chunked prefill: a request can still be prefilling while scheduling only a small token chunk (≤ `mtp + 1`), and it would be misclassified as decode. Misclassified requests were routed through the PCP **decode** attention path with inconsistent metadata (e.g. `actual_seq_lengths_q` vs local `Q_S`), which triggered `aclnnFusedInferAttentionScoreV3` failures (error `561002`). This PR aligns PCP decode/prefill classification with upstream vLLM `reorder_batch_to_split_decodes_and_prefills`: - Add `classify_decode_request_mask()` in `attention/utils.py`. A request is treated as decode only if it has context (`num_computed_tokens > 0`), is below the decode threshold, and has finished prefilling (`num_computed_tokens >= num_prompt_tokens`). - Update `PCPManager.init_batch_info()` to use this mask instead of a scheduled-token threshold alone, and pass `num_computed_tokens` / `num_prompt_tokens` from model runners. - Extend `split_decodes_and_prefills()`: - Default `treat_short_extends_as_decodes=False`, so short extends are prefills by default. - Honor `is_prefilling` when short extends are not treated as decodes. - Add a `num_reqs == 0` guard and PyTorch tensor input support in `classify_decode_request_mask()`. - Set `is_prefilling` in spec-decode graph-capture dummy-run paths (`llm_base_proposer`, `dflash_proposer`) to avoid startup assertion failures. - Correct stale E2E golden outputs in `test_accuracy.py` for DSV2 PCP and Qwen3 PCP/DCP cases after re-verifying greedy outputs on NPU. ## Does this PR introduce any user-facing change? No API or configuration changes. This is a bug fix for PCP + speculative decoding workloads, especially with chunked prefill. Users who previously hit decode/prefill misclassification and NPU attention kernel errors should see correct routing and stable inference. Behavior for true decode batches is unchanged. ## How was this patch tested? **Unit tests** - Added `test_split_decodes_short_extend_with_default_false` in `tests/ut/worker/test_pcp_manager.py` to verify short extends with `is_prefilling=True` are classified as prefills when `decode_threshold=4` (MTP scenario). - Updated existing PCP manager tests to pass `num_computed_tokens` and `num_prompt_tokens` into `init_batch_info`. **Manual reproduction** - Reproduced the original failure with PCP + MTP + chunked prefill (short prefill chunk misrouted to decode kernel). - Verified the fix routes those chunks through the prefill path and inference completes without `aclnnFusedInferAttentionScoreV3` error `561002`. **E2E** - Re-ran four-card CP accuracy tests on NPU and updated `DSV2_PCP_GOLDEN` / `QWEN3_GOLDEN` to match verified greedy outputs. DeepSeek-V3.1-Terminus-w8a8-mtp-QuaRot test result | dataset | version | metric | mode | vllm-api-stream-chat | |----- | ----- | ----- | ----- | -----| | gsm8k | d486ce | accuracy | gen | 94.20 | Qwen3-30B-A3B-W8A8 test result | dataset | version | metric | mode | vllm-api-stream-chat | |----- | ----- | ----- | ----- | -----| | gsm8k | d486ce | accuracy | gen | 92.80 | **CI** - Existing unit tests and lint checks. - vLLM version: v0.22.1 - vLLM main: vllm-project/vllm@967c5c3 --------- Signed-off-by: recky-c <ruiqicheng510@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent edcae83 commit 5bd4e39

14 files changed

Lines changed: 181 additions & 23 deletions

File tree

tests/e2e/pull_request/four_card/context_parallel/test_accuracy.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656

5757
DSV2_PCP_GOLDEN = [
5858
"The president of the United States is a man who is not only a liar, but",
59-
"The capital of France is Paris.\nThe capital of the United States is",
59+
"The capital of France is Paris.\nThe currency of France is the Euro",
6060
]
6161

6262
DSV2_DCP_GOLDEN = [
@@ -65,7 +65,7 @@
6565
]
6666

6767
QWEN3_GOLDEN = [
68-
"The capital of France is Paris. Which of the",
68+
"The capital of France is Paris. The capital of",
6969
"Hello, my name is Tom, I am 12 years old",
7070
"The president of United States is the head of state and",
7171
]

tests/ut/ops/test_gdn_attn_builder.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ def create_common_attn_metadata(
8282
max_seq_len = int(seq_lens_cpu.max())
8383
context_lens = [batch_spec.seq_lens[i] - batch_spec.query_lens[i] for i in range(batch_spec.batch_size)]
8484
num_computed_tokens_cpu = torch.tensor(context_lens, dtype=torch.int32)
85+
# Mirror model_runner: is_prefilling = num_computed < num_prompt_tokens.
86+
# Chunked prefills still have prompt tokens beyond num_computed; decodes do not.
87+
num_prompt_tokens_cpu = torch.tensor(
88+
[
89+
context_lens[i] + batch_spec.query_lens[i] if batch_spec.query_lens[i] > 1 else context_lens[i]
90+
for i in range(batch_spec.batch_size)
91+
],
92+
dtype=torch.int32,
93+
)
94+
is_prefilling = num_computed_tokens_cpu < num_prompt_tokens_cpu
8595
max_blocks = (max(batch_spec.seq_lens) + block_size - 1) // block_size
8696
block_table_tensor = torch.arange(
8797
batch_spec.batch_size * max_blocks,
@@ -103,6 +113,7 @@ def create_common_attn_metadata(
103113
block_table_tensor=block_table_tensor,
104114
slot_mapping=slot_mapping,
105115
causal=True,
116+
is_prefilling=is_prefilling,
106117
)
107118

108119

tests/ut/worker/test_pcp_manager.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import pytest
1919
import torch
2020

21+
from vllm_ascend.attention.utils import AscendCommonAttentionMetadata, split_decodes_and_prefills
2122
from vllm_ascend.worker.pcp_utils import PCPManager
2223

2324

@@ -150,7 +151,14 @@ def test_generate_pcp_metadata_mla_tail_projection_indices(pcp_size, pcp_rank, q
150151

151152
num_reqs = len(query_lens)
152153
num_scheduled_tokens = np.array(query_lens, dtype=np.int32)
153-
pcp_manager.init_batch_info(num_scheduled_tokens, num_reqs)
154+
num_computed_tokens = np.zeros(num_reqs, dtype=np.int32)
155+
num_prompt_tokens = np.array(query_lens, dtype=np.int32)
156+
pcp_manager.init_batch_info(
157+
num_scheduled_tokens,
158+
num_reqs,
159+
num_computed_tokens,
160+
num_prompt_tokens,
161+
)
154162

155163
input_batch = MagicMock()
156164
input_batch.num_reqs = num_reqs
@@ -251,7 +259,12 @@ def test_update_tokens_for_pcp_basic(
251259
input_batch.num_prompt_tokens = np.array(num_prompt_tokens, dtype=np.int32)
252260
arange_np = np.arange(10000)
253261
num_scheduled_tokens = np.array(tokens)
254-
pcp_manager.init_batch_info(num_scheduled_tokens, num_reqs)
262+
pcp_manager.init_batch_info(
263+
num_scheduled_tokens,
264+
num_reqs,
265+
input_batch.num_computed_tokens_cpu,
266+
input_batch.num_prompt_tokens,
267+
)
255268
pcp_tokens_result, positions_result = pcp_manager.update_tokens_for_pcp(num_scheduled_tokens, arange_np)
256269

257270
assert np.array_equal(pcp_tokens_result, expected_pcp_tokens), (
@@ -264,6 +277,39 @@ def test_update_tokens_for_pcp_basic(
264277
)
265278

266279

280+
def test_split_decodes_short_extend_with_default_false():
281+
"""Short extends should be treated as prefills by default."""
282+
long_seq_metadata = MagicMock()
283+
long_seq_metadata.query_lens_pcp_full_cpu = torch.tensor([3], dtype=torch.int32)
284+
long_seq_metadata.max_query_len_pcp_full = 3
285+
286+
query_start_loc_cpu = torch.tensor([0, 2], dtype=torch.int32)
287+
common_attn_metadata = AscendCommonAttentionMetadata(
288+
query_start_loc=query_start_loc_cpu,
289+
query_start_loc_cpu=query_start_loc_cpu,
290+
seq_lens=torch.tensor([173], dtype=torch.int32),
291+
num_reqs=1,
292+
num_actual_tokens=2,
293+
max_query_len=2,
294+
max_seq_len=173,
295+
block_table_tensor=torch.zeros((1, 1), dtype=torch.int32),
296+
slot_mapping=torch.arange(2, dtype=torch.int32),
297+
is_prefilling=torch.tensor([True]),
298+
prefill_context_parallel_metadata=long_seq_metadata,
299+
)
300+
301+
num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = split_decodes_and_prefills(
302+
common_attn_metadata,
303+
decode_threshold=4,
304+
treat_short_extends_as_decodes=False,
305+
)
306+
307+
assert num_decodes == 0
308+
assert num_prefills == 1
309+
assert num_decode_tokens == 0
310+
assert num_prefill_tokens == 2
311+
312+
267313
# yapf: disable
268314
@pytest.mark.parametrize(
269315
"seq_lens, pcp_world_size, dcp_world_size, cp_kv_cache_interleave_size, target",
@@ -417,7 +463,20 @@ def test_generate_pcp_mtp_input(
417463
for i, token_ids_tensor in enumerate(token_ids_tensor_list):
418464
token_ids_cpu_tensor[i][:token_ids_tensor.size(0)] = token_ids_tensor
419465

420-
pcp_manager.init_batch_info(np.array(list(num_scheduled_tokens.values())), num_reqs)
466+
num_prompt_tokens = np.zeros(max_num_reqs, dtype=np.int32)
467+
for i, req_id in enumerate(req_ids):
468+
if num_computed_tokens[i] > 0:
469+
num_prompt_tokens[i] = num_computed_tokens[i]
470+
else:
471+
num_prompt_tokens[i] = num_scheduled_tokens[req_id]
472+
input_batch.num_prompt_tokens = num_prompt_tokens
473+
474+
pcp_manager.init_batch_info(
475+
np.array(list(num_scheduled_tokens.values())),
476+
num_reqs,
477+
input_batch.num_computed_tokens_cpu,
478+
input_batch.num_prompt_tokens,
479+
)
421480
pcp_manager.generate_pcp_mtp_input(total_num_scheduled_tokens, num_scheduled_tokens, False,
422481
input_batch, arange_np)
423482
assert torch.equal(

vllm_ascend/_310p/model_runner_310p.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,8 @@ def _prepare_inputs( # type: ignore[override]
290290
self.pcp_manager.init_batch_info(
291291
num_scheduled_tokens,
292292
self.input_batch.num_reqs,
293+
self.input_batch.num_computed_tokens_cpu,
294+
self.input_batch.num_prompt_tokens,
293295
)
294296

295297
if self.speculative_config and self.use_cp:

vllm_ascend/attention/context_parallel/attention_cp.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ def build(
116116
query_start_loc_cpu = common_attn_metadata.query_start_loc_cpu[: num_reqs + 1]
117117

118118
num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = split_decodes_and_prefills(
119-
common_attn_metadata, decode_threshold=self.decode_threshold
119+
common_attn_metadata,
120+
decode_threshold=self.decode_threshold,
121+
treat_short_extends_as_decodes=False,
120122
)
121123
assert num_decodes + num_prefills == num_reqs
122124
assert num_decode_tokens + num_prefill_tokens == num_actual_tokens

vllm_ascend/attention/context_parallel/dsa_cp.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,11 @@ def build(
270270
num_input_tokens = common_attn_metadata.num_input_tokens
271271
if self.common_ratio_to_sas_metadata.get("input_positions", None) is None:
272272
self.num_decodes, self.num_prefills, self.num_decode_tokens, self.num_prefill_tokens = (
273-
split_decodes_and_prefills(common_attn_metadata, decode_threshold=self.decode_threshold)
273+
split_decodes_and_prefills(
274+
common_attn_metadata,
275+
decode_threshold=self.decode_threshold,
276+
treat_short_extends_as_decodes=False,
277+
)
274278
)
275279
self.common_ratio_to_sas_metadata["num_decodes"] = self.num_decodes
276280
self.common_ratio_to_sas_metadata["num_prefills"] = self.num_prefills
@@ -346,7 +350,9 @@ def build_for_drafting(
346350
num_reqs = common_attn_metadata.num_reqs
347351
num_input_tokens = common_attn_metadata.num_input_tokens
348352
num_decodes, num_prefills, num_decode_tokens, _ = split_decodes_and_prefills(
349-
common_attn_metadata, decode_threshold=self.decode_threshold
353+
common_attn_metadata,
354+
decode_threshold=self.decode_threshold,
355+
treat_short_extends_as_decodes=False,
350356
)
351357

352358
self.num_decodes = num_decodes

vllm_ascend/attention/context_parallel/sfa_cp.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ def build(
9393
) -> AscendSFAMetadata:
9494
metadata_cls = super().build(common_prefix_len, common_attn_metadata, fast_build)
9595
num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = split_decodes_and_prefills(
96-
common_attn_metadata, decode_threshold=self.decode_threshold
96+
common_attn_metadata,
97+
decode_threshold=self.decode_threshold,
98+
treat_short_extends_as_decodes=False,
9799
)
98100
num_reqs = common_attn_metadata.num_reqs
99101
assert num_decodes + num_prefills == num_reqs

vllm_ascend/attention/mla_v1.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,11 @@ def build(
437437
query_start_loc_cpu = common_attn_metadata.query_start_loc_cpu
438438

439439
self.num_decodes, self.num_prefills, self.num_decode_tokens, self.num_prefill_tokens = (
440-
split_decodes_and_prefills(common_attn_metadata, decode_threshold=self.decode_threshold)
440+
split_decodes_and_prefills(
441+
common_attn_metadata,
442+
decode_threshold=self.decode_threshold,
443+
treat_short_extends_as_decodes=common_attn_metadata.prefill_context_parallel_metadata is None,
444+
)
441445
)
442446
self.set_num_actual_tokens(common_attn_metadata)
443447
assert self.num_decodes + self.num_prefills == num_reqs

vllm_ascend/attention/utils.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,17 +273,29 @@ def filter_chunked_req_indices(
273273
def split_decodes_and_prefills(
274274
common_attn_metadata: AscendCommonAttentionMetadata,
275275
decode_threshold: int = 1,
276+
require_uniform: bool = False,
277+
treat_short_extends_as_decodes: bool = True,
276278
) -> tuple[int, int, int, int]:
277279
"""
278280
Assuming a reordered batch, finds the boundary between prefill and decode
279281
requests.
280282
While pcp > 1, query_lens is split across pcp ranks, so we pass in the
281283
original query_lens and max_query_len to distinguish prefills and decodes.
282284
285+
The batch is expected to be ordered as:
286+
decode -> short_extend -> long_extend -> prefill
287+
283288
Args:
284289
common_attn_metadata: AscendCommonAttentionMetadata object containing the
285290
batch metadata.
286291
decode_threshold: The maximum query length to be considered a decode.
292+
require_uniform: If True, requires that all decode requests have the
293+
same query length. When set, some queries may be considered
294+
prefills even if they are <= decode_threshold, in order to ensure
295+
uniformity.
296+
treat_short_extends_as_decodes: If True (default), short extends
297+
(query_len <= threshold but still prefilling) are counted as
298+
decodes. If False, they are counted as prefills.
287299
288300
Returns:
289301
num_decodes: The number of decode requests.
@@ -296,14 +308,43 @@ def split_decodes_and_prefills(
296308
max_query_len_pcp_full = long_seq_metadata.max_query_len_pcp_full if long_seq_metadata else 0
297309
max_query_len = common_attn_metadata.max_query_len if max_query_len_pcp_full == 0 else max_query_len_pcp_full
298310
num_reqs = common_attn_metadata.num_reqs
311+
if num_reqs == 0:
312+
return 0, 0, 0, 0
313+
299314
num_tokens = common_attn_metadata.num_actual_tokens
300315
query_start_loc = common_attn_metadata.query_start_loc_cpu
301316

302-
if max_query_len <= decode_threshold:
317+
if (
318+
max_query_len <= decode_threshold
319+
and (not require_uniform or decode_threshold <= 1)
320+
and treat_short_extends_as_decodes
321+
):
303322
return num_reqs, 0, num_tokens, 0
304323

305-
query_lens = (query_start_loc[1:] - query_start_loc[:-1]) if query_lens_pcp_full is None else query_lens_pcp_full
306-
is_prefill = query_lens > decode_threshold
324+
query_lens_sharded = query_start_loc[1:] - query_start_loc[:-1]
325+
query_lens = query_lens_sharded if query_lens_pcp_full is None else query_lens_pcp_full
326+
if query_lens[0].item() > decode_threshold:
327+
return 0, num_reqs, 0, num_tokens
328+
329+
if require_uniform:
330+
if torch.all((query_lens == query_lens[0]) | (query_lens == 0)):
331+
return num_reqs, 0, num_tokens, 0
332+
is_prefill = query_lens != query_lens[0]
333+
else:
334+
is_prefill = query_lens > decode_threshold
335+
336+
if not treat_short_extends_as_decodes:
337+
assert common_attn_metadata.is_prefilling is not None
338+
raw_is_prefilling = common_attn_metadata.is_prefilling
339+
is_prefilling = raw_is_prefilling[: query_lens.shape[0]]
340+
if is_prefilling.shape[0] < query_lens.shape[0]:
341+
is_prefilling = F.pad(
342+
is_prefilling,
343+
(0, query_lens.shape[0] - is_prefilling.shape[0]),
344+
value=False,
345+
)
346+
is_prefill |= is_prefilling
347+
307348
if not torch.any(is_prefill):
308349
return num_reqs, 0, num_tokens, 0
309350

vllm_ascend/ops/gdn_attn_builder.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,7 @@ def build( # type: ignore[override]
983983
num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = split_decodes_and_prefills(
984984
m,
985985
decode_threshold=1,
986+
treat_short_extends_as_decodes=False,
986987
)
987988
num_spec_decode_tokens = 0
988989
spec_token_indx = None

0 commit comments

Comments
 (0)