[Attention][Spec Decode] Support varlen trtllm-gen decode for adaptive verification (#52157)
Signed-off-by: Guan-Ming (Wesley) Chiu <105915352+guan404ming@users.noreply.github.com>
Guan-Ming Chiu committed
Aug 25, 2026 at 02:43 UTC
6a9c69fa851389dcf1ee5d3a2363e27af665d26d
2 files changed
+138
-7
tests/kernels/attention/test_flashinfer_trtllm_attention.py
+93
@@ -358,6 +358,99 @@ def test_flashinfer_trtllm_decode_with_baseline(
358
)
359
360
361
+@pytest.mark.parametrize("quant_dtypes", [(None, None, None), (None, FP8_DTYPE, None)])
362
+@pytest.mark.parametrize("q_lens_cfg", [[1, 3, 2, 4], [2, 2, 1, 5, 3]])
363
+@torch.inference_mode
364
+def test_flashinfer_trtllm_decode_varlen(
365
+ quant_dtypes: tuple[torch.dtype | None, torch.dtype | None, torch.dtype | None],
366
+ q_lens_cfg: list[int],
367
+) -> None:
368
+ """Ragged decode via cum_seq_lens_q must match the varlen baseline."""
369
+ dtype = torch.bfloat16
370
+ num_qo_heads, num_kv_heads = 40, 8
371
+ head_size = 128
372
+ block_size = 16
373
+ max_kv_len = 4096
374
+ torch.set_default_device("cuda")
375
+ set_random_seed(42)
376
+
377
+ _, kv_quant_dtype, _ = quant_dtypes
378
+ kv_quant_dtype = kv_quant_dtype or dtype
379
+ sm_scale = float(1.0 / (head_size**0.5))
380
+ batch_size = len(q_lens_cfg)
381
+
382
+ q_lens = torch.tensor(q_lens_cfg, dtype=torch.int32)
383
+ q_indptr = torch.cat(
384
+ [
385
+ torch.tensor([0], dtype=torch.int32),
386
+ torch.cumsum(q_lens, dim=0, dtype=torch.int32),
387
+ ]
388
+ )
389
+ query = torch.randn(int(q_lens.sum()), num_qo_heads, head_size, dtype=dtype)
390
+
391
+ kv_lens = torch.randint(1, max_kv_len, (batch_size,), dtype=torch.int32)
392
+ kv_lens[-1] = max_kv_len
393
+ seq_lens = kv_lens + q_lens
394
+ max_seq_len = int(seq_lens.max())
395
+
396
+ kv_cache_shape = (NUM_BLOCKS, 2, num_kv_heads, block_size, head_size)
397
+ kv_cache = torch.randn(kv_cache_shape, dtype=dtype)
398
+ kv_cache, kv_cache_sf, kv_scale, ref_kv_cache, _ = make_quantized_kv_cache(
399
+ kv_cache, kv_quant_dtype, block_size, head_size
400
+ )
401
+
402
+ max_num_blocks_per_seq = (max_seq_len + block_size - 1) // block_size
403
+ block_tables = torch.randint(
404
+ 0, NUM_BLOCKS, (batch_size, max_num_blocks_per_seq), dtype=torch.int32
405
+ )
406
+ kv_indptr, kv_indices, kv_last_page_lens = build_paged_kv_metadata(
407
+ seq_lens, block_tables, block_size
408
+ )
409
+ workspace_buffer = torch.zeros(128 * 1024 * 1024, dtype=torch.int8)
410
+
411
+ wrapper = flashinfer.BatchPrefillWithPagedKVCacheWrapper(
412
+ float_workspace_buffer=workspace_buffer, kv_layout="HND", backend="fa2"
413
+ )
414
+ wrapper.plan(
415
+ qo_indptr=q_indptr,
416
+ paged_kv_indptr=kv_indptr,
417
+ paged_kv_indices=kv_indices,
418
+ paged_kv_last_page_len=kv_last_page_lens,
419
+ num_qo_heads=num_qo_heads,
420
+ num_kv_heads=num_kv_heads,
421
+ head_dim_qk=head_size,
422
+ page_size=block_size,
423
+ causal=True,
424
+ sm_scale=sm_scale,
425
+ window_left=-1,
426
+ q_data_type=dtype,
427
+ kv_data_type=dtype,
428
+ )
429
+ output = torch.empty(query.shape, dtype=dtype)
430
+ wrapper.run(query, ref_kv_cache, out=output)
431
+
432
+ output_trtllm = torch.empty(query.shape, dtype=dtype)
433
+ flashinfer.decode.trtllm_batch_decode_with_kv_cache(
434
+ query=query,
435
+ kv_cache=kv_cache,
436
+ workspace_buffer=workspace_buffer,
437
+ block_tables=block_tables,
438
+ seq_lens=seq_lens,
439
+ max_seq_len=max_seq_len,
440
+ bmm1_scale=kv_scale * sm_scale,
441
+ bmm2_scale=kv_scale,
442
+ window_left=-1,
443
+ out=output_trtllm,
444
+ q_len_per_req=None,
445
+ max_q_len=int(q_lens.max()),
446
+ cum_seq_lens_q=q_indptr,
447
+ kv_cache_sf=kv_cache_sf,
448
+ )
449
+
450
+ rtol, atol = (4e-2, 6e-2) if kv_quant_dtype == FP8_DTYPE else (1e-2, 1e-2)
451
+ torch.testing.assert_close(output, output_trtllm, atol=atol, rtol=rtol)
452
+
453
+
454
@pytest.mark.parametrize("dtype", DTYPE)
455
@pytest.mark.parametrize("quant_dtypes", QUANT_DTYPES)
456
@pytest.mark.parametrize("batch_size", BATCH_SIZE)
vllm/v1/attention/backends/flashinfer.py
+45
-7
@@ -450,9 +450,19 @@ class FlashInferBackend(AttentionBackend):
450
451
@classmethod
452
def supports_device_cpu_query_lens_mismatch(cls) -> bool:
453
- # The wrappers are planned from qo_indptr_cpu, so the CPU query offsets
454
- # have to be the ones the kernel runs on.
455
- return False
453
+ # Only trtllm-gen decode (SM100 family) reads query offsets from the
454
+ # device tensor; XQA and wrapper decode plan from qo_indptr_cpu, and
455
+ # under DCP spec queries are routed to the prefill path.
456
+ if not current_platform.is_device_capability_family(100):
457
+ return False
458
+ vllm_config = get_current_vllm_config_or_none()
459
+ if vllm_config is None or vllm_config.model_config is None:
460
+ return True
461
+ pc = vllm_config.parallel_config
462
+ mc = vllm_config.model_config
463
+ return pc.decode_context_parallel_size == 1 and can_use_trtllm_attention(
464
+ mc.get_num_attention_heads(pc), mc.get_num_kv_heads(pc), is_prefill=False
465
+ )
466
467
@classmethod
468
def supports_sliding_window(cls) -> bool:
@@ -834,6 +844,16 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
844
current_platform.is_device_capability_family(120)
845
and self.flashinfer_trtllm_api_decode_kernel == FlashInferDecodeKernel.XQA
846
)
847
+ # Adaptive verification trims drafts on device, so decode query lengths
848
+ # must come from the device qo_indptr; only trtllm-gen supports that
849
+ # (the selector already rejects the other configurations).
850
+ self.use_trtllm_gen_varlen_decode = (
851
+ speculative_config is not None
852
+ and speculative_config.enable_adaptive_verification
853
+ and self.flashinfer_trtllm_api_decode_kernel
854
+ == FlashInferDecodeKernel.TRTLLM_GEN
855
+ and not self.use_dcp
856
+ )
857
supports_spec_as_decode = (
858
self.flashinfer_trtllm_api_decode_kernel
859
== FlashInferDecodeKernel.TRTLLM_GEN
@@ -1619,7 +1639,7 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1639
if num_decodes > 0:
1640
if decode_with_flashinfer_trtllm_api:
1641
assert self.flashinfer_trtllm_api_decode_kernel is not None
1622
- if not self.use_dedicated_xqa:
1642
+ if not self.use_dedicated_xqa and not self.use_trtllm_gen_varlen_decode:
1643
assert num_decode_tokens % num_decodes == 0, (
1644
"XQA/trtllm-gen decode requires uniform query lengths "
1645
f"per request. Got {num_decode_tokens=} and {num_decodes=}."
@@ -1648,6 +1668,16 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1668
num_decodes,
1669
bool(causal),
1670
)
1671
+ elif self.use_trtllm_gen_varlen_decode:
1672
+ # CPU lens are only an upper bound, device qo_indptr is
1673
+ # the truth.
1674
+ decode_q_lens = (
1675
+ qo_indptr_cpu[1 : num_decodes + 1] - qo_indptr_cpu[:num_decodes]
1676
+ )
1677
+ max_q_upper = int(decode_q_lens.max())
1678
+ if max_q_upper > 1:
1679
+ q_len_per_req = max_q_upper
1680
+ q_cu_seq_lens = qo_indptr[: num_decodes + 1]
1681
attn_metadata.decode = FlashInferTrtllmAPIDecode(
1682
kernel=self.flashinfer_trtllm_api_decode_kernel,
1683
block_tables=block_table_tensor[:num_decodes],
@@ -2382,7 +2412,7 @@ class FlashInferImpl(AttentionImpl):
2412
bmm1_scale = self.get_xqa_bmm1_scale(
2413
layer, attn_metadata.q_data_type_decode
2414
)
2385
- q_len_per_req = attn_metadata.decode.q_len_per_req
2415
+ q_len_per_req: int | None = attn_metadata.decode.q_len_per_req
2416
2417
flashinfer_xqa_batch_decode_with_kv_cache(
2418
query=decode_query,
@@ -2421,14 +2451,20 @@ class FlashInferImpl(AttentionImpl):
2451
if needs_fp8_out:
2452
out = self._nvfp4_fp8_out[:num_decode_tokens]
2453
2424
- if num_decode_tokens % attn_metadata.num_decodes != 0:
2454
+ q_cu_seq_lens = attn_metadata.decode.q_cu_seq_lens
2455
+ max_q_len = None
2456
+ q_len_per_req = None
2457
+ if q_cu_seq_lens is not None:
2458
+ # q_len_per_req must stay None for the kernel's varlen path.
2459
+ max_q_len = attn_metadata.decode.q_len_per_req
2460
+ elif num_decode_tokens % attn_metadata.num_decodes != 0:
2461
# This gets triggered when the dummy_run forces
2462
# attention to be initialized with q_len = 0
2463
q_len_per_req = 1
2464
else:
2465
q_len_per_req = num_decode_tokens // attn_metadata.num_decodes
2466
2431
- if decode_with_xqa and q_len_per_req > 1:
2467
+ if decode_with_xqa and q_len_per_req is not None and q_len_per_req > 1:
2468
raise NotImplementedError(
2469
"FlashInfer XQA speculative decode is not wired in vLLM yet."
2470
)
@@ -2472,6 +2508,8 @@ class FlashInferImpl(AttentionImpl):
2508
kv_layout=get_flashinfer_layout_string(self.kv_cache_layout),
2509
backend=attn_metadata.decode.kernel.value,
2510
q_len_per_req=q_len_per_req,
2511
+ max_q_len=max_q_len,
2512
+ cum_seq_lens_q=q_cu_seq_lens,
2513
kv_cache_sf=(
2514
nvfp4_kv_block_scales if self.is_kvcache_nvfp4 else None
2515
),