main
py 4,026 lines 143 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Unit tests for the sparse MLA backends and utilities."""
4
5 import math
6 from collections import deque
7 from types import MethodType, SimpleNamespace
8 from unittest.mock import MagicMock
9
10 import pytest
11 import torch
12
13 from tests.v1.attention.test_mla_backends import (
14 BATCH_SPECS,
15 BatchSpec,
16 MockSparseMLAAttentionLayer,
17 create_and_prepopulate_kv_cache,
18 )
19 from tests.v1.attention.utils import (
20 create_common_attn_metadata,
21 create_standard_kv_cache_spec,
22 create_vllm_config,
23 )
24 from vllm import _custom_ops as ops
25 from vllm.compilation.breakable_cudagraph import BreakableCUDAGraphCapture
26 from vllm.config import (
27 CUDAGraphMode,
28 HiSparseConfig,
29 SpeculativeConfig,
30 set_current_vllm_config,
31 )
32 from vllm.distributed.kv_transfer.kv_connector.v1.hisparse.worker import (
33 HiSparseConnectorWorker,
34 )
35 from vllm.forward_context import set_forward_context
36 from vllm.model_executor.layers.attention import mla_attention
37 from vllm.model_executor.layers.attention.mla_attention import _use_masked_mha
38 from vllm.model_executor.layers.attention.sparse_mla_attention import (
39 GLOBAL_TOPK_MASK_MAX_BYTES,
40 SparseMLACommonImpl,
41 SparseMLAPrefillMetadata,
42 _is_masked_mha_available,
43 _masked_mha_workspace_fits,
44 _topk_mask_shape,
45 _use_dense_mha_prefill,
46 )
47 from vllm.model_executor.layers.linear import ColumnParallelLinear
48 from vllm.platforms import current_platform
49
50 # TODO: Integrate ROCMAiterMLASparseBackend for ROCm.
51 # The ROCm sparse MLA backend (rocm_aiter_mla_sparse.py) has a compatible
52 # forward_mqa interface but needs validation on ROCm hardware.
53 if not current_platform.is_cuda():
54 pytest.skip(
55 "Sparse MLA backend tests currently only support CUDA. "
56 "ROCm support requires integrating ROCMAiterMLASparseBackend.",
57 allow_module_level=True,
58 )
59
60 from vllm.model_executor.layers.attention.mla_attention import (
61 _canonicalize_sparse_mla_kv_cache_dtype,
62 )
63 from vllm.model_executor.layers.attention.sparse_mla_attention import (
64 SharedTopkIndicesBuffer,
65 )
66 from vllm.utils.math_utils import cdiv
67 from vllm.utils.torch_utils import current_stream
68 from vllm.v1.attention.backends.mla import index_group as index_group_module
69 from vllm.v1.attention.backends.mla.flashattn_mla_sparse import (
70 FlashAttnMLASparseImpl,
71 FlashAttnMLASparseMetadataBuilder,
72 )
73 from vllm.v1.attention.backends.mla.flashinfer_mla_sparse import (
74 FlashInferMLASparseImpl,
75 FlashInferMLASparseTRTLLMBackend,
76 )
77 from vllm.v1.attention.backends.mla.flashinfer_mla_sparse_sm120 import (
78 FlashInferMLASparseSM120Impl,
79 )
80 from vllm.v1.attention.backends.mla.flashmla_sparse import (
81 FlashMLASparseBackend,
82 FlashMLASparseImpl,
83 FlashMLASparseMetadata,
84 FlashMLASparseMetadataBuilder,
85 triton_convert_req_index_to_global_index,
86 )
87 from vllm.v1.attention.backends.mla.index_group import (
88 HiSparseMLAIndexGroup,
89 SparseMLAIndexGroupBuilder,
90 )
91 from vllm.v1.attention.backends.mla.indexer import (
92 DeepseekV32IndexerMetadataBuilder,
93 )
94 from vllm.v1.attention.backends.mla.prefill import get_mla_prefill_backend
95 from vllm.v1.attention.backends.utils import (
96 split_decodes_and_prefills,
97 split_prefill_chunks,
98 )
99 from vllm.v1.attention.ops import flashmla
100 from vllm.v1.hisparse import runtime as hisparse_runtime
101 from vllm.v1.hisparse.runtime import (
102 HiSparseCacheHandle,
103 HiSparseRuntime,
104 ResolvedHiSparseConfig,
105 _has_hisparse_ops,
106 build_hisparse_prefill_staging_plan,
107 hisparse_prefill_staging_remap,
108 )
109 from vllm.v1.hisparse.types import SparseKVRowMirror
110
111 SPARSE_BACKEND_BATCH_SPECS = {
112 name: BATCH_SPECS[name]
113 for name in [
114 "mixed_small",
115 "mixed_medium",
116 "small_prefill",
117 "medium_prefill",
118 "single_prefill",
119 ]
120 }
121
122 SPARSE_BACKEND_BATCH_SPECS["large_q_prefill"] = BatchSpec(
123 seq_lens=[1024] * 2, query_lens=[256] * 2
124 )
125 SPARSE_BACKEND_BATCH_SPECS["large_q_pure_prefill"] = BatchSpec(
126 seq_lens=[256] * 2, query_lens=[256] * 2
127 )
128
129 DEVICE_TYPE = current_platform.device_type
130
131
132 def test_nope_flashinfer_sparse_mla_uses_model_scale(monkeypatch):
133 """Weight absorption must not change the model's attention temperature."""
134 model_scale = 256**-0.5
135 kv_lora_rank = 512
136 topk = torch.zeros((1, 1), dtype=torch.int32)
137 metadata = SimpleNamespace(
138 req_id_per_token=torch.zeros(1, dtype=torch.int32),
139 block_table=torch.zeros((1, 1), dtype=torch.int32),
140 block_size=1,
141 )
142 recorded_scale = None
143
144 impl = object.__new__(FlashInferMLASparseImpl)
145 impl.scale = model_scale
146 impl.qk_nope_head_dim = 256
147 impl.kv_lora_rank = kv_lora_rank
148 impl.qk_rope_head_dim = 0
149 impl.kv_cache_dtype = "auto"
150 impl.topk_indices_buffer = topk
151 impl.dcp_world_size = 1
152 impl._workspace_buffer = torch.empty(1)
153 impl.bmm1_scale = None
154 impl.bmm2_scale = None
155 impl.is_nope_mla = True
156 impl.need_to_return_lse_for_decode = False
157 impl.index_group = MagicMock()
158 impl.index_group_index = 0
159 impl.index_group.convert_logical_to_physical_topk.return_value = (
160 topk,
161 torch.ones(1, dtype=torch.int32),
162 )
163
164 import flashinfer.decode
165
166 def fake_flashinfer(**kwargs):
167 nonlocal recorded_scale
168 recorded_scale = kwargs["bmm1_scale"]
169 return torch.zeros((1, 1, 1, kv_lora_rank))
170
171 monkeypatch.setattr(
172 flashinfer.decode,
173 "trtllm_batch_decode_with_kv_cache_mla",
174 fake_flashinfer,
175 )
176 impl.forward_mqa(
177 torch.zeros(1, 1, kv_lora_rank),
178 torch.zeros(1, 1, kv_lora_rank),
179 metadata,
180 SimpleNamespace(),
181 )
182
183 assert recorded_scale == model_scale
184 assert recorded_scale != kv_lora_rank**-0.5
185
186
187 def test_hisparse_routes_prefill_to_sparse_mqa():
188 layer = SimpleNamespace(hisparse_cache=object())
189
190 assert not mla_attention.MLAAttention._use_sparse_mha(layer, SimpleNamespace())
191
192
193 def test_hisparse_metadata_keeps_short_prefill_indexer_enabled():
194 config = SimpleNamespace(
195 attention_config=SimpleNamespace(
196 hisparse_config=object(), sparse_mla_force_mqa=False
197 )
198 )
199
200 assert not _use_dense_mha_prefill(config, prefill_max_seq_len=512, topk_tokens=2048)
201
202
203 def test_unified_mla_prepares_batch_without_slot_mapping(monkeypatch):
204 layer = SimpleNamespace(
205 prepare_kv_cache_update=MagicMock(),
206 update_kv_cache=MagicMock(),
207 )
208 metadata = object()
209 monkeypatch.setattr(
210 mla_attention,
211 "get_attention_context",
212 lambda _: (metadata, layer, torch.empty(0), None),
213 )
214 tensor = torch.empty(0, device=DEVICE_TYPE)
215
216 mla_attention.unified_mla_kv_cache_update(tensor, tensor, "layer", "auto", tensor)
217
218 layer.prepare_kv_cache_update.assert_called_once_with(metadata)
219 layer.update_kv_cache.assert_not_called()
220
221
222 def _float_to_e8m0_truncate(f: float) -> float:
223 """Simulate SM100's float -> e8m0 -> bf16 scale conversion.
224 e8m0 format only stores the exponent (power of 2).
225 cudaRoundZero truncates toward zero, meaning we round down to the
226 nearest power of 2.
227 """
228 if f <= 0:
229 return 0.0
230 # e8m0 = floor(log2(f)), then 2^(e8m0)
231 # This is equivalent to truncating to the nearest power of 2 below f
232 exp = math.floor(math.log2(f))
233 return 2.0**exp
234
235
236 def _dequantize_fp8_ds_mla_entry(
237 cache_slice: torch.Tensor,
238 kv_lora_rank: int,
239 rope_dim: int,
240 dtype: torch.dtype,
241 simulate_sm100_e8m0_scales: bool = False,
242 ) -> tuple[torch.Tensor, torch.Tensor]:
243 """Dequantize a single fp8_ds_mla cache entry back to latent + rope.
244
245 Args:
246 simulate_sm100_e8m0_scales: If True, simulate the SM100 kernel's
247 float -> e8m0 -> bf16 scale conversion path.
248
249 """
250 # The first kv_lora_rank bytes store FP8 latent values with one scale per
251 # 128 element tile written as float32 right after the latent payload.
252 scales = cache_slice.view(torch.float32)[kv_lora_rank // 4 : kv_lora_rank // 4 + 4]
253 latent = torch.empty(kv_lora_rank, dtype=torch.float16, device=cache_slice.device)
254 for tile_idx in range(4):
255 tile_start = tile_idx * 128
256 tile_end = tile_start + 128
257 scale_val = float(scales[tile_idx].item())
258 if simulate_sm100_e8m0_scales:
259 # Simulate the lossy float -> e8m0 -> bf16 conversion
260 scale_val = _float_to_e8m0_truncate(scale_val)
261 ops.convert_fp8(
262 latent[tile_start:tile_end],
263 cache_slice[tile_start:tile_end],
264 scale_val,
265 kv_dtype="fp8",
266 )
267 latent = latent.to(dtype)
268
269 rope_offset = kv_lora_rank // 2 + 8
270 rope_vals = cache_slice.view(dtype)[rope_offset : rope_offset + rope_dim]
271 return latent, rope_vals.clone()
272
273
274 def _quantize_dequantize_fp8_ds_mla(
275 kv_c: torch.Tensor,
276 k_pe: torch.Tensor,
277 block_size: int,
278 scale: torch.Tensor,
279 simulate_sm100_e8m0_scales: bool = False,
280 ) -> tuple[torch.Tensor, torch.Tensor]:
281 """Round-trip kv_c/k_pe though the fp8_ds_mla cache layout.
282
283 Args:
284 simulate_sm100_e8m0_scales: If True, simulate the SM100 kernel's
285 float -> e8m0 -> bf16 scale conversion in dequantization.
286
287 """
288 if kv_c.numel() == 0:
289 return kv_c.clone(), k_pe.clone()
290
291 kv_lora_rank = kv_c.shape[-1]
292 rope_dim = k_pe.shape[-1]
293 num_tokens = kv_c.shape[0]
294 num_blocks = max(1, math.ceil(num_tokens / block_size))
295 entry_size = kv_lora_rank + 4 * 4 + 2 * rope_dim
296
297 tmp_cache = torch.zeros(
298 num_blocks, block_size, entry_size, dtype=torch.uint8, device=kv_c.device
299 )
300 slot_mapping = torch.arange(num_tokens, dtype=torch.long, device=kv_c.device)
301
302 ops.concat_and_cache_mla(
303 kv_c, k_pe, tmp_cache, slot_mapping, kv_cache_dtype="fp8_ds_mla", scale=scale
304 )
305
306 dequant_kv_c = torch.empty_like(kv_c)
307 dequant_k_pe = torch.empty_like(k_pe)
308
309 for token_idx in range(num_tokens):
310 slot = slot_mapping[token_idx].item()
311 block_idx = slot // block_size
312 block_offset = slot % block_size
313 cache_slice = tmp_cache[block_idx, block_offset]
314 latent, rope_vals = _dequantize_fp8_ds_mla_entry(
315 cache_slice,
316 kv_lora_rank,
317 rope_dim,
318 kv_c.dtype,
319 simulate_sm100_e8m0_scales=simulate_sm100_e8m0_scales,
320 )
321 dequant_kv_c[token_idx] = latent
322 dequant_k_pe[token_idx] = rope_vals
323
324 return dequant_kv_c, dequant_k_pe
325
326
327 _E2M1_TABLE = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0]
328
329
330 def _quantize_dequantize_nvfp4_ds_mla(
331 kv_c: torch.Tensor,
332 k_pe: torch.Tensor,
333 block_size: int,
334 kv_cache_dtype: str,
335 scale: torch.Tensor,
336 ) -> tuple[torch.Tensor, torch.Tensor]:
337 """Round-trip kv_c/k_pe through the nvfp4_ds_mla cache layout.
338
339 Layout (per token, uint8): [e2m1-packed NoPE | unscaled e4m3 RoPE |
340 per-16 e4m3 NoPE SFs]. The SF bytes are stored permuted (an 8x4 -> 4x8
341 transpose: the scale for element block s lives at byte 8 * (s & 3) +
342 (s >> 2)) so that the 8 scales one FlashMLA dequant thread needs are
343 contiguous. Dequant: x = value * float(sf), which is exact in bf16, so
344 this python dequant matches the kernels bit-for-bit.
345 """
346 if kv_c.numel() == 0:
347 return kv_c.clone(), k_pe.clone()
348
349 kv_lora_rank = kv_c.shape[-1]
350 rope_dim = k_pe.shape[-1]
351 num_tokens = kv_c.shape[0]
352 num_blocks = max(1, math.ceil(num_tokens / block_size))
353 entry_size = (
354 math.ceil((kv_lora_rank // 2 + rope_dim + kv_lora_rank // 16) / 16) * 16
355 )
356 sf_nope_off = kv_lora_rank // 2 + rope_dim
357
358 tmp_cache = torch.zeros(
359 num_blocks, block_size, entry_size, dtype=torch.uint8, device=kv_c.device
360 )
361 slot_mapping = torch.arange(num_tokens, dtype=torch.long, device=kv_c.device)
362 ops.concat_and_cache_mla(
363 kv_c, k_pe, tmp_cache, slot_mapping, kv_cache_dtype=kv_cache_dtype, scale=scale
364 )
365
366 tokens = tmp_cache.view(-1, entry_size)[:num_tokens]
367 table = torch.tensor(
368 _E2M1_TABLE + [-v for v in _E2M1_TABLE],
369 dtype=torch.float32,
370 device=kv_c.device,
371 )
372
373 def unpack_e2m1(packed: torch.Tensor) -> torch.Tensor:
374 lo = (packed & 0xF).long()
375 hi = (packed >> 4).long()
376 return table[torch.stack([lo, hi], dim=-1).flatten(-2)]
377
378 nope_vals = unpack_e2m1(tokens[:, : kv_lora_rank // 2])
379 num_nope_sf = kv_lora_rank // 16
380 nope_sf = (
381 # undo the on-wire SF permutation -> element-block order
382 tokens[:, sf_nope_off : sf_nope_off + num_nope_sf]
383 .unflatten(-1, (4, num_nope_sf // 4))
384 .transpose(-1, -2)
385 .flatten(-2)
386 .view(torch.float8_e4m3fn)
387 .float()
388 )
389 dequant_kv_c = (
390 (nope_vals.unflatten(-1, (-1, 16)) * nope_sf.unsqueeze(-1)).flatten(-2)
391 ).to(kv_c.dtype)
392
393 # RoPE: plain e4m3, no scale factor
394 rope_raw = tokens[:, kv_lora_rank // 2 : sf_nope_off]
395 dequant_k_pe = rope_raw.view(torch.float8_e4m3fn).to(k_pe.dtype)
396
397 return dequant_kv_c, dequant_k_pe
398
399
400 @pytest.mark.parametrize(
401 "backend_cls",
402 [FlashMLASparseBackend, FlashInferMLASparseTRTLLMBackend],
403 ids=["FlashMLA", "FlashInferTRTLLM"],
404 )
405 @pytest.mark.parametrize("batch_name", list(SPARSE_BACKEND_BATCH_SPECS.keys()))
406 @pytest.mark.parametrize(
407 "kv_cache_dtype",
408 ["auto", "fp8", "fp8_ds_mla", "nvfp4_ds_mla"],
409 )
410 @pytest.mark.parametrize("tensor_parallel_size", [1, 2, 4])
411 @pytest.mark.parametrize("block_size", [32, 64])
412 @pytest.mark.parametrize(("q_scale", "k_scale"), [(1.0, 1.0), (2.0, 3.0)])
413 def test_sparse_backend_decode_correctness(
414 default_vllm_config,
415 dist_init,
416 backend_cls,
417 batch_name,
418 kv_cache_dtype,
419 tensor_parallel_size,
420 block_size,
421 workspace_init,
422 q_scale: float,
423 k_scale: float,
424 monkeypatch,
425 ):
426 if (
427 batch_name == "large_q_pure_prefill"
428 and backend_cls == FlashMLASparseBackend
429 and kv_cache_dtype == "fp8_ds_mla"
430 and tensor_parallel_size == 4
431 ):
432 monkeypatch.setattr(
433 "vllm.v1.attention.backends.mla.flashmla_sparse.split_prefill_chunks",
434 lambda rows, capacity: [(i, i + 1) for i in range(len(rows))],
435 )
436 if kv_cache_dtype not in backend_cls.supported_kv_cache_dtypes:
437 pytest.skip(f"{backend_cls.get_name()} does not support {kv_cache_dtype}")
438
439 if (
440 backend_cls == FlashMLASparseBackend
441 and kv_cache_dtype.startswith("fp8")
442 and kv_cache_dtype != "fp8_ds_mla"
443 ):
444 pytest.skip(
445 "FlashMLA Sparse Attention backend fp8 only supports "
446 "fp8_ds_mla kv-cache dtype"
447 )
448
449 if kv_cache_dtype == "nvfp4_ds_mla":
450 # Unlike "fp8" (an alias canonicalized to "fp8_ds_mla" above), the
451 # NVFP4 DS-MLA dtype must reach the backend unchanged.
452 assert (
453 _canonicalize_sparse_mla_kv_cache_dtype(backend_cls, kv_cache_dtype)
454 == kv_cache_dtype
455 )
456 device_capability = current_platform.get_device_capability()
457 if device_capability is None or device_capability.major != 10:
458 pytest.skip("The NVFP4 DS-MLA kv-cache dtype requires SM 10.x")
459
460 supported_block_sizes = backend_cls.get_supported_kernel_block_sizes()
461 if block_size not in supported_block_sizes:
462 pytest.skip(
463 f"{backend_cls.get_name()} does not support block_size={block_size}"
464 )
465
466 if backend_cls == FlashMLASparseBackend:
467 ok, reason = flashmla.is_flashmla_sparse_supported()
468 if not ok:
469 pytest.skip(reason)
470 elif backend_cls == FlashInferMLASparseTRTLLMBackend:
471 device_capability = current_platform.get_device_capability()
472 if device_capability is None or not backend_cls.supports_compute_capability(
473 device_capability
474 ):
475 pytest.skip("FlashInferMLASparseTRTLLMBackend requires SM 10.x capability")
476
477 batch_spec = SPARSE_BACKEND_BATCH_SPECS[batch_name]
478 use_fp8_ds_mla_quantization = kv_cache_dtype == "fp8_ds_mla"
479 use_nvfp4_ds_mla_quantization = kv_cache_dtype == "nvfp4_ds_mla"
480
481 device = torch.device(DEVICE_TYPE)
482 dtype = torch.bfloat16
483
484 # Model hyper-parameters (kept intentionally small for the unit test)
485 total_num_heads = 128
486 # Compute per-rank heads for simulated TP
487 num_heads = max(1, total_num_heads // tensor_parallel_size)
488
489 kv_lora_rank = 512
490 qk_nope_head_dim = 128
491 qk_rope_head_dim = 64
492 v_head_dim = 128
493 head_size = kv_lora_rank + qk_rope_head_dim
494 topk_tokens = 128
495
496 max_seqlen = max(batch_spec.seq_lens)
497 total_cache_tokens = sum(batch_spec.seq_lens)
498
499 # Note: We use TP=1 to avoid multi-GPU requirements in CI.
500 # The test simulates head partitioning via mocked methods below.
501 vllm_config = create_vllm_config(
502 model_name="deepseek-ai/DeepSeek-V2-Lite-Chat",
503 tensor_parallel_size=1,
504 max_model_len=max_seqlen,
505 num_gpu_blocks=max(2048, cdiv(total_cache_tokens, block_size) + 1),
506 block_size=block_size,
507 hf_config_override={
508 "index_topk": topk_tokens,
509 "attn_module_list_cfg": [{"topk_tokens": topk_tokens}],
510 },
511 )
512 model_config = vllm_config.model_config
513 model_config.hf_text_config = SimpleNamespace(
514 index_topk=topk_tokens,
515 q_lora_rank=None,
516 kv_lora_rank=kv_lora_rank,
517 qk_nope_head_dim=qk_nope_head_dim,
518 qk_rope_head_dim=qk_rope_head_dim,
519 v_head_dim=v_head_dim,
520 model_type="deepseek_v2",
521 )
522 del model_config.hf_config.index_topk # Composite configs only nest this field.
523 model_config.dtype = dtype
524 model_config.get_num_attention_heads = MethodType(
525 lambda self, parallel_config: num_heads,
526 model_config,
527 )
528 model_config.get_num_kv_heads = MethodType(
529 lambda self, parallel_config: 1, model_config
530 )
531 model_config.get_head_size = MethodType(lambda self: head_size, model_config)
532 model_config.get_sliding_window = MethodType(lambda self: None, model_config)
533
534 kv_cache_spec = create_standard_kv_cache_spec(vllm_config)
535
536 torch.manual_seed(0)
537
538 scale = 1.0 / math.sqrt(head_size)
539
540 # Shared MLA projection weights to keep reference and backend in sync
541 W_UK = torch.rand(
542 kv_lora_rank, num_heads, qk_nope_head_dim, dtype=dtype, device=device
543 )
544 W_UV = torch.rand(kv_lora_rank, num_heads, v_head_dim, dtype=dtype, device=device)
545
546 # Build synthetic decode-only workload
547 seq_lens = batch_spec.seq_lens
548 query_lens = batch_spec.query_lens
549
550 # Pre-compute positions and sparse indices for all tokens.
551 # We need these BEFORE computing the reference to use sparse attention masks.
552 total_query_tokens = sum(query_lens)
553 positions = []
554 for i in range(batch_spec.batch_size):
555 s_len = seq_lens[i]
556 q_len = query_lens[i]
557 ctx_len = s_len - q_len
558 for q_idx in range(q_len):
559 positions.append(ctx_len + q_idx)
560
561 # Create sparse indices with UNIQUE per-token offsets to catch bugs where
562 # the kernel uses wrong indices for some tokens (e.g., due to incorrect
563 # tensor shapes like [1, num_tokens, ...] instead of [num_tokens, 1, ...]).
564 # Also include -1 masked indices to verify the kernel handles them correctly.
565 sparse_indices = torch.empty(
566 total_query_tokens, topk_tokens, dtype=torch.int32, device=device
567 )
568 for tok_idx in range(total_query_tokens):
569 max_valid_idx = positions[tok_idx]
570 offset = tok_idx * 7 # Prime number for varied offsets
571 # Use only half the topk indices as valid, mask the rest with -1
572 # This tests that the kernel correctly ignores -1 indices
573 num_valid = min(topk_tokens // 2, max_valid_idx + 1)
574 if num_valid > 0:
575 valid_range = torch.arange(num_valid, device=device, dtype=torch.int32)
576 tok_indices = (valid_range + offset) % (max_valid_idx + 1)
577 # Pad with -1 for the remaining positions
578 tok_indices = torch.cat(
579 [
580 tok_indices,
581 torch.full(
582 (topk_tokens - num_valid,), -1, device=device, dtype=torch.int32
583 ),
584 ]
585 )
586 else:
587 tok_indices = torch.full(
588 (topk_tokens,), -1, device=device, dtype=torch.int32
589 )
590 tok_indices[0] = 0 # At least one valid index
591 sparse_indices[tok_idx] = tok_indices
592
593 all_q_vllm, all_kv_c_vllm, all_k_pe_vllm = [], [], []
594 kv_c_contexts, k_pe_contexts = [], []
595 reference_outputs = []
596
597 kv_cache_scale = torch.tensor(k_scale, dtype=torch.float32, device=device)
598 global_token_idx = 0
599
600 for i in range(batch_spec.batch_size):
601 s_len = seq_lens[i]
602 q_len = query_lens[i]
603 ctx_len = s_len - q_len
604
605 q_c = torch.rand(
606 q_len,
607 num_heads,
608 qk_nope_head_dim + qk_rope_head_dim,
609 dtype=dtype,
610 device=device,
611 )
612 kv_c_full = torch.rand(s_len, kv_lora_rank, dtype=dtype, device=device)
613 k_pe_full = torch.rand(s_len, 1, qk_rope_head_dim, dtype=dtype, device=device)
614
615 if use_fp8_ds_mla_quantization:
616 is_sm100 = torch.cuda.get_device_capability()[0] >= 10
617 kv_c_full, k_pe_squeezed = _quantize_dequantize_fp8_ds_mla(
618 kv_c_full,
619 k_pe_full.squeeze(1),
620 block_size=block_size,
621 scale=kv_cache_scale,
622 simulate_sm100_e8m0_scales=is_sm100,
623 )
624 k_pe_full = k_pe_squeezed.unsqueeze(1)
625 elif use_nvfp4_ds_mla_quantization:
626 kv_c_full, k_pe_squeezed = _quantize_dequantize_nvfp4_ds_mla(
627 kv_c_full,
628 k_pe_full.squeeze(1),
629 block_size=block_size,
630 kv_cache_dtype=kv_cache_dtype,
631 scale=kv_cache_scale,
632 )
633 k_pe_full = k_pe_squeezed.unsqueeze(1)
634
635 q_nope, q_pe = q_c.split([qk_nope_head_dim, qk_rope_head_dim], dim=-1)
636 ql_nope = torch.einsum("qnh,lnh->qnl", q_nope, W_UK)
637 q_mqa = torch.cat([ql_nope, q_pe], dim=-1)
638
639 k_mqa = torch.cat([kv_c_full, k_pe_full.squeeze(1)], dim=-1)
640 v_mqa = kv_c_full
641
642 # Compute sparse SDPA reference per query token using its sparse indices
643 for q_idx in range(q_len):
644 tok_sparse_idx = sparse_indices[global_token_idx]
645 valid_mask = tok_sparse_idx >= 0
646 valid_indices = tok_sparse_idx[valid_mask].long()
647
648 q_tok = q_mqa[q_idx : q_idx + 1] # [1, num_heads, head_dim]
649 k_sparse = k_mqa[valid_indices] # [num_valid, head_dim]
650 v_sparse = v_mqa[valid_indices] # [num_valid, kv_lora_rank]
651
652 k_sparse = k_sparse.unsqueeze(1).expand(-1, num_heads, -1)
653 v_sparse = v_sparse.unsqueeze(1).expand(-1, num_heads, -1)
654
655 # SDPA: [1, num_heads, 1, head_dim] x [1, num_heads, num_valid, head_dim]
656 q_sdpa_in = q_tok.unsqueeze(0).transpose(1, 2)
657 k_sdpa_in = k_sparse.unsqueeze(0).transpose(1, 2)
658 v_sdpa_in = v_sparse.unsqueeze(0).transpose(1, 2)
659
660 sdpa_out = torch.nn.functional.scaled_dot_product_attention(
661 q_sdpa_in, k_sdpa_in, v_sdpa_in, scale=scale
662 )
663 sdpa_out = sdpa_out.transpose(1, 2).squeeze(
664 0
665 ) # [1, num_heads, kv_lora_rank]
666
667 sdpa_out = torch.einsum("qnl,lnv->qnv", sdpa_out, W_UV)
668 reference_outputs.append(sdpa_out.flatten(start_dim=-2))
669
670 global_token_idx += 1
671
672 all_q_vllm.append(q_c)
673 all_kv_c_vllm.append(kv_c_full[ctx_len:])
674 all_k_pe_vllm.append(k_pe_full[ctx_len:])
675 kv_c_contexts.append(kv_c_full[: ctx_len + 1])
676 k_pe_contexts.append(k_pe_full[: ctx_len + 1])
677
678 query_vllm = torch.cat(all_q_vllm, dim=0)
679 kv_c_vllm = torch.cat(all_kv_c_vllm, dim=0)
680 k_pe_vllm = torch.cat(all_k_pe_vllm, dim=0)
681 sdpa_reference = torch.cat(reference_outputs, dim=0)
682
683 vllm_config.cache_config.cache_dtype = kv_cache_dtype
684 vllm_config.model_config.hf_text_config.index_topk = topk_tokens
685
686 common_attn_metadata = create_common_attn_metadata(
687 batch_spec,
688 vllm_config.cache_config.block_size,
689 device,
690 arange_block_indices=True,
691 )
692
693 kv_cache = create_and_prepopulate_kv_cache(
694 kv_c_contexts=kv_c_contexts,
695 k_pe_contexts=k_pe_contexts,
696 block_size=vllm_config.cache_config.block_size,
697 head_size=head_size,
698 dtype=dtype,
699 device=device,
700 num_blocks=vllm_config.cache_config.num_gpu_blocks,
701 common_attn_metadata=common_attn_metadata,
702 randomize_blocks=False,
703 kv_cache_dtype=kv_cache_dtype,
704 scale=kv_cache_scale,
705 )
706
707 # The sparse builder clones the layer's dense-MHA prefill backend from
708 # static_forward_context; register a mock layer carrying one.
709 prefill_backend = get_mla_prefill_backend(vllm_config)(
710 num_heads=num_heads,
711 scale=scale,
712 kv_lora_rank=kv_lora_rank,
713 qk_nope_head_dim=qk_nope_head_dim,
714 qk_rope_head_dim=qk_rope_head_dim,
715 v_head_dim=v_head_dim,
716 vllm_config=vllm_config,
717 )
718 vllm_config.compilation_config.static_forward_context["placeholder"] = (
719 SimpleNamespace(prefill_backend=prefill_backend)
720 )
721
722 builder_cls = backend_cls.get_builder_cls()
723 builder = builder_cls(kv_cache_spec, ["placeholder"], vllm_config, device)
724 metadata = builder.build(
725 common_prefix_len=0, common_attn_metadata=common_attn_metadata
726 )
727
728 # Use the pre-computed sparse_indices for the mock indexer
729 mock_indexer = SimpleNamespace(topk_indices_buffer=sparse_indices)
730
731 kv_b_proj_weight = torch.cat([W_UK, W_UV], dim=-1)
732 kv_b_proj_weight = kv_b_proj_weight.view(
733 kv_lora_rank, num_heads * (qk_nope_head_dim + v_head_dim)
734 )
735
736 mock_kv_b_proj = ColumnParallelLinear(
737 input_size=kv_lora_rank,
738 output_size=num_heads * (qk_nope_head_dim + v_head_dim),
739 bias=False,
740 ).to(device=device, dtype=dtype)
741 mock_kv_b_proj.weight = torch.nn.Parameter(kv_b_proj_weight.T.contiguous())
742
743 impl_cls = backend_cls.get_impl_cls()
744 with set_current_vllm_config(vllm_config):
745 impl = impl_cls(
746 num_heads=num_heads,
747 head_size=head_size,
748 scale=scale,
749 num_kv_heads=1,
750 alibi_slopes=None,
751 sliding_window=None,
752 kv_cache_dtype=vllm_config.cache_config.cache_dtype,
753 logits_soft_cap=None,
754 attn_type="decoder",
755 kv_sharing_target_layer_name=None,
756 q_lora_rank=None,
757 kv_lora_rank=kv_lora_rank,
758 qk_nope_head_dim=qk_nope_head_dim,
759 qk_rope_head_dim=qk_rope_head_dim,
760 qk_head_dim=qk_nope_head_dim + qk_rope_head_dim,
761 v_head_dim=v_head_dim,
762 kv_b_proj=mock_kv_b_proj,
763 indexer=mock_indexer,
764 )
765
766 impl.process_weights_after_loading(dtype)
767
768 # Create mock sparse MLA layer with weight matrices
769 mock_layer = MockSparseMLAAttentionLayer(
770 impl=impl,
771 num_heads=num_heads,
772 qk_nope_head_dim=qk_nope_head_dim,
773 qk_rope_head_dim=qk_rope_head_dim,
774 v_head_dim=v_head_dim,
775 kv_lora_rank=kv_lora_rank,
776 device=device,
777 W_UK=W_UK,
778 W_UV=W_UV,
779 q_scale=q_scale,
780 k_scale=k_scale,
781 )
782
783 out_buffer = torch.empty(
784 metadata.num_actual_tokens, num_heads * v_head_dim, dtype=dtype, device=device
785 )
786
787 with torch.inference_mode():
788 if backend_cls == FlashMLASparseBackend and kv_cache_dtype == "fp8_ds_mla":
789 from vllm.v1.worker.workspace import current_workspace_manager
790
791 current_workspace_manager().lock()
792 backend_output = mock_layer.forward_impl(
793 query_vllm,
794 kv_c_vllm,
795 k_pe_vllm,
796 kv_cache,
797 metadata,
798 out_buffer,
799 )
800
801 assert backend_output.shape == sdpa_reference.shape
802 assert backend_output.dtype == sdpa_reference.dtype
803 assert torch.isfinite(backend_output).all()
804
805 # FP8 quantization introduces some error, but should be within reasonable bounds
806 # BF16 (auto) should be very accurate, FP8 allows slightly more tolerance
807 if kv_cache_dtype.startswith("fp8"):
808 torch.testing.assert_close(
809 backend_output, sdpa_reference, rtol=0.065, atol=0.05
810 )
811 else:
812 torch.testing.assert_close(backend_output, sdpa_reference, rtol=0.01, atol=0.01)
813
814
815 def _triton_convert_reference_impl(
816 req_ids: torch.Tensor,
817 block_table: torch.Tensor,
818 token_indices: torch.Tensor,
819 block_size: int,
820 num_topk_tokens: int,
821 HAS_PREFILL_WORKSPACE: bool = False,
822 prefill_workspace_request_ids: torch.Tensor | None = None,
823 prefill_workspace_starts: torch.Tensor | None = None,
824 ) -> torch.Tensor:
825 """Reference implementation for triton_convert_req_index_to_global_index."""
826 num_tokens = req_ids.shape[0]
827 max_blocks_per_req = block_table.shape[1]
828 result = torch.empty(
829 num_tokens, num_topk_tokens, dtype=torch.int32, device=req_ids.device
830 )
831
832 for token_id in range(num_tokens):
833 req_id = req_ids[token_id].item()
834
835 # Determine if this token uses workspace or paged cache
836 use_prefill_workspace = False
837 workspace_start = 0
838 if HAS_PREFILL_WORKSPACE and prefill_workspace_request_ids is not None:
839 assert prefill_workspace_starts is not None
840 prefill_req_id = prefill_workspace_request_ids[token_id].item()
841 if prefill_req_id >= 0:
842 use_prefill_workspace = True
843 workspace_start = prefill_workspace_starts[prefill_req_id].item()
844
845 for idx_id in range(num_topk_tokens):
846 token_idx = token_indices[token_id, idx_id].item()
847
848 if token_idx == -1:
849 result[token_id, idx_id] = -1
850 elif use_prefill_workspace:
851 # Prefill + using prefill workspace: map to workspace offset
852 result[token_id, idx_id] = workspace_start + token_idx
853 else:
854 # Decode: map to paged cache
855 block_id = token_idx // block_size
856 if block_id >= max_blocks_per_req:
857 result[token_id, idx_id] = -1
858 else:
859 block_num = block_table[req_id, block_id].item()
860 offset = token_idx % block_size
861 result[token_id, idx_id] = block_num * block_size + offset
862
863 return result
864
865
866 @pytest.mark.parametrize("block_size", [16, 64, 128])
867 @pytest.mark.parametrize("num_topk_tokens", [128, 256, 512])
868 @pytest.mark.skipif(
869 torch.cuda.get_device_capability() < (9, 0),
870 reason="FlashMLASparseBackend requires CUDA 9.0 or higher",
871 )
872 def test_triton_convert_req_index_to_global_index_decode_only(
873 block_size, num_topk_tokens
874 ):
875 device = torch.device(DEVICE_TYPE)
876 num_tokens = 8
877 num_requests = 4
878 max_blocks_per_req = 10
879
880 req_id = torch.randint(
881 0, num_requests, (num_tokens,), dtype=torch.int32, device=device
882 )
883 block_table = torch.randint(
884 0, 100, (num_requests, max_blocks_per_req), dtype=torch.int32, device=device
885 )
886
887 token_indices = torch.randint(
888 0,
889 block_size * max_blocks_per_req,
890 (num_tokens, num_topk_tokens),
891 dtype=torch.int32,
892 device=device,
893 )
894
895 # Set some to -1 to test masking
896 token_indices[0, :10] = -1
897 token_indices[3, 50:60] = -1
898
899 # Set some to out of bounds
900 token_indices[2, 100:110] = max_blocks_per_req * block_size
901 token_indices[6, 150:160] = max_blocks_per_req * block_size
902
903 result = triton_convert_req_index_to_global_index(
904 req_id,
905 block_table,
906 token_indices,
907 BLOCK_SIZE=block_size,
908 NUM_TOPK_TOKENS=num_topk_tokens,
909 )
910
911 reference_result = _triton_convert_reference_impl(
912 req_id,
913 block_table,
914 token_indices,
915 block_size,
916 num_topk_tokens,
917 )
918
919 torch.testing.assert_close(result, reference_result, rtol=0, atol=0)
920
921
922 def test_index_group_convert_during_piecewise_capture():
923 """In piecewise cudagraph mode the indexer is captured while the convert
924 runs in the following eager break. The convert's side stream must not
925 wait on ``logical_topk_ready`` there: an event recorded inside a captured
926 segment is graph-local, and an eager wait on it raises
927 cudaErrorInvalidValue."""
928 device = torch.device(DEVICE_TYPE)
929 num_tokens, num_topk, num_requests, blocks_per_req, block_size = 8, 128, 4, 4, 16
930
931 logical_topk_indices = torch.randint(
932 0,
933 block_size * blocks_per_req,
934 (num_tokens, num_topk),
935 dtype=torch.int32,
936 device=device,
937 )
938 builder = SparseMLAIndexGroupBuilder(logical_topk_indices)
939 group, layer_index = builder.register_layer(is_index_producing_layer=True)
940 assert layer_index == 0 and group.has_indexer
941
942 req_id = torch.arange(num_tokens, dtype=torch.int32, device=device) % num_requests
943 block_table = torch.arange(
944 num_requests * blocks_per_req, dtype=torch.int32, device=device
945 ).view(num_requests, blocks_per_req)
946 attn_metadata = SimpleNamespace(
947 req_id_per_token=req_id, block_table=block_table, block_size=block_size
948 )
949 expected = triton_convert_req_index_to_global_index(
950 req_id,
951 block_table,
952 logical_topk_indices,
953 BLOCK_SIZE=block_size,
954 NUM_TOPK_TOKENS=num_topk,
955 )
956
957 vllm_config = create_vllm_config(model_name="Qwen/Qwen3.5-0.8B")
958 marker = torch.zeros(1, device=device)
959 capture_stream = torch.cuda.Stream(device=device)
960 # torch.cuda.stream routes through vLLM's patched torch.cuda.set_stream,
961 # so current_stream() tracks the capture stream as in production.
962 with (
963 torch.cuda.stream(capture_stream),
964 set_forward_context(
965 None, vllm_config, cudagraph_runtime_mode=CUDAGraphMode.PIECEWISE
966 ),
967 ):
968 capture = BreakableCUDAGraphCapture()
969 with capture:
970 marker.add_(1)
971 # Record inside a captured segment, convert in the eager
972 # break — mirrors mla.py -> unified_mla_attention_with_output.
973 group.set_logical_topk_ready(layer_index)
974 capture.add_eager(
975 lambda: group.convert_logical_to_physical_topk(
976 layer_index,
977 logical_topk_indices,
978 attn_metadata,
979 block_stride_rows=None,
980 return_valid_counts=False,
981 )
982 )
983 marker.add_(1)
984 capture.replay()
985 capture_stream.synchronize()
986
987 result = group.physical_topk_indices[:num_tokens]
988 torch.testing.assert_close(result, expected, rtol=0, atol=0)
989
990
991 @pytest.mark.parametrize("block_size", [16])
992 @pytest.mark.skipif(
993 torch.cuda.get_device_capability() < (9, 0),
994 reason="FlashMLASparseBackend requires CUDA 9.0 or higher",
995 )
996 def test_triton_convert_req_index_to_global_index_with_prefill_workspace(block_size):
997 device = torch.device(DEVICE_TYPE)
998 num_requests = 4
999 max_blocks_per_req = 8
1000 num_topk_tokens = 128
1001
1002 # First 6 tokens are decode (reqs 0, 1), last 6 are prefill (reqs 2, 3)
1003 req_id = torch.tensor(
1004 [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3], dtype=torch.int32, device=device
1005 )
1006 prefill_workspace_request_ids = torch.tensor(
1007 [-1, -1, -1, -1, -1, -1, 0, 0, 0, 1, 1, 1], dtype=torch.int32, device=device
1008 )
1009
1010 # Workspace starts for the 2 prefill reqs: req 2 starts at 0, req 3 starts at 100
1011 prefill_workspace_starts = torch.tensor([0, 100], dtype=torch.int32, device=device)
1012
1013 block_table = torch.randint(
1014 0, 50, (num_requests, max_blocks_per_req), dtype=torch.int32, device=device
1015 )
1016 token_indices = torch.randint(
1017 0,
1018 block_size * max_blocks_per_req,
1019 (req_id.shape[0], num_topk_tokens),
1020 dtype=torch.int32,
1021 device=device,
1022 )
1023
1024 # Set some to -1 to test masking
1025 token_indices[0, :10] = -1
1026 token_indices[3, 50:60] = -1
1027
1028 # Set some to out of bounds
1029 token_indices[2, 100:110] = max_blocks_per_req * block_size
1030 token_indices[6, 150:160] = max_blocks_per_req * block_size
1031
1032 result = triton_convert_req_index_to_global_index(
1033 req_id,
1034 block_table,
1035 token_indices,
1036 BLOCK_SIZE=block_size,
1037 NUM_TOPK_TOKENS=num_topk_tokens,
1038 HAS_PREFILL_WORKSPACE=True,
1039 prefill_workspace_request_ids=prefill_workspace_request_ids,
1040 prefill_workspace_starts=prefill_workspace_starts,
1041 )
1042
1043 reference_result = _triton_convert_reference_impl(
1044 req_id,
1045 block_table,
1046 token_indices,
1047 block_size,
1048 num_topk_tokens,
1049 HAS_PREFILL_WORKSPACE=True,
1050 prefill_workspace_request_ids=prefill_workspace_request_ids,
1051 prefill_workspace_starts=prefill_workspace_starts,
1052 )
1053
1054 torch.testing.assert_close(result, reference_result, rtol=0, atol=0)
1055
1056
1057 @pytest.mark.skipif(
1058 torch.cuda.get_device_capability() < (9, 0),
1059 reason="FlashMLASparseBackend requires CUDA 9.0 or higher",
1060 )
1061 def test_triton_convert_rejects_req_id_longer_than_token_indices():
1062 """Guard against the #47327 regression: the kernel grid is sized by
1063 req_id but the output is allocated like token_indices, so a full-batch
1064 req_id combined with an MQA-subset token_indices wrote past the end of
1065 the output buffer. The wrapper must reject the length mismatch instead
1066 of corrupting memory."""
1067 device = torch.device(DEVICE_TYPE)
1068 num_topk_tokens = 128
1069 block_size = 64
1070 block_table = torch.arange(40, dtype=torch.int32, device=device).view(4, 10)
1071
1072 # Full batch: 2 decode tokens + 10 prefill tokens
1073 req_id_full = torch.tensor(
1074 [0, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3], dtype=torch.int32, device=device
1075 )
1076 num_mqa_tokens = 2
1077 token_indices = torch.randint(
1078 0,
1079 block_size * 10,
1080 (num_mqa_tokens, num_topk_tokens),
1081 dtype=torch.int32,
1082 device=device,
1083 )
1084
1085 with pytest.raises(AssertionError, match="must cover the same tokens"):
1086 triton_convert_req_index_to_global_index(
1087 req_id_full,
1088 block_table,
1089 token_indices,
1090 BLOCK_SIZE=block_size,
1091 NUM_TOPK_TOKENS=num_topk_tokens,
1092 )
1093
1094 # The sliced call is the intended usage and must match the reference.
1095 result = triton_convert_req_index_to_global_index(
1096 req_id_full[:num_mqa_tokens],
1097 block_table,
1098 token_indices,
1099 BLOCK_SIZE=block_size,
1100 NUM_TOPK_TOKENS=num_topk_tokens,
1101 )
1102 reference = _triton_convert_reference_impl(
1103 req_id_full[:num_mqa_tokens],
1104 block_table,
1105 token_indices,
1106 block_size,
1107 num_topk_tokens,
1108 )
1109 torch.testing.assert_close(result, reference, rtol=0, atol=0)
1110
1111
1112 @pytest.mark.skipif(
1113 torch.cuda.get_device_capability() < (9, 0),
1114 reason="FlashMLASparseBackend requires CUDA 9.0 or higher",
1115 )
1116 def test_flashmla_forward_bf16_kv_slices_req_id_to_mqa_tokens():
1117 """Guard against the #47327 regression: when the dense-MHA prefill split
1118 is active, forward_mqa only receives the leading decode tokens, but
1119 _forward_bf16_kv passed the full-batch req_id_per_token to the index
1120 conversion, making it write past the end of its output buffer. The call
1121 site must slice req_id_per_token to the MQA tokens."""
1122 device = torch.device(DEVICE_TYPE)
1123 num_topk_tokens = 128
1124 block_size = 64
1125 num_batch_tokens = 12
1126 num_mqa_tokens = 2
1127
1128 attn_metadata = SimpleNamespace(
1129 req_id_per_token=torch.tensor(
1130 [0, 1] + [2] * 5 + [3] * 5, dtype=torch.int32, device=device
1131 ),
1132 block_table=torch.arange(40, dtype=torch.int32, device=device).view(4, 10),
1133 block_size=block_size,
1134 num_decode_tokens=num_mqa_tokens,
1135 num_actual_tokens=num_mqa_tokens,
1136 )
1137 assert attn_metadata.req_id_per_token.shape[0] == num_batch_tokens
1138
1139 q = torch.zeros(num_mqa_tokens, 4, 576, dtype=torch.bfloat16, device=device)
1140 kv_cache = torch.zeros(40, block_size, 576, dtype=torch.bfloat16, device=device)
1141 topk_indices = torch.randint(
1142 0,
1143 block_size * 10,
1144 (num_mqa_tokens, num_topk_tokens),
1145 dtype=torch.int32,
1146 device=device,
1147 )
1148
1149 captured = {}
1150
1151 def _stub_kernel(q, kv, indices, lengths, actual_num_heads):
1152 captured["indices"] = indices
1153 captured["actual_num_heads"] = actual_num_heads
1154 return (
1155 torch.zeros(q.shape[0], q.shape[1], 512, dtype=q.dtype, device=q.device),
1156 None,
1157 )
1158
1159 def _convert_topk(indices, metadata, **kwargs):
1160 return triton_convert_req_index_to_global_index(
1161 metadata.req_id_per_token[: indices.shape[0]],
1162 metadata.block_table,
1163 indices,
1164 BLOCK_SIZE=metadata.block_size,
1165 BLOCK_STRIDE_ROWS=kwargs["block_stride_rows"],
1166 NUM_TOPK_TOKENS=indices.shape[1],
1167 return_valid_counts=kwargs["return_valid_counts"],
1168 )
1169
1170 stub_impl = SimpleNamespace(
1171 _bf16_flash_mla_kernel=_stub_kernel,
1172 _convert_logical_to_physical_topk=_convert_topk,
1173 index_group=None,
1174 index_group_index=0,
1175 pcp_dcp_kv_gather=False,
1176 )
1177
1178 out, _ = FlashMLASparseImpl._forward_bf16_kv(
1179 stub_impl, q, kv_cache, topk_indices, attn_metadata, q.shape[1]
1180 )
1181
1182 assert out.shape[0] == num_mqa_tokens
1183 assert captured["indices"].shape[0] == num_mqa_tokens
1184 assert captured["actual_num_heads"] == q.shape[1]
1185 reference = _triton_convert_reference_impl(
1186 attn_metadata.req_id_per_token[:num_mqa_tokens],
1187 attn_metadata.block_table,
1188 topk_indices,
1189 block_size,
1190 num_topk_tokens,
1191 )
1192 torch.testing.assert_close(captured["indices"], reference, rtol=0, atol=0)
1193
1194
1195 @pytest.mark.parametrize(
1196 "seq_lens,max_buf,expected",
1197 [
1198 # Basic split: totals per chunk ≤ max_buf
1199 (torch.tensor([2, 3, 4, 2]), 5, [(0, 2), (2, 3), (3, 4)]),
1200 # Exact fits should split between items when adding the next would overflow
1201 (torch.tensor([5, 5, 5]), 5, [(0, 1), (1, 2), (2, 3)]),
1202 # All requests fit in a single chunk
1203 (torch.tensor([1, 1, 1]), 10, [(0, 3)]),
1204 # Large buffer
1205 (torch.tensor([4, 4, 4]), 100, [(0, 3)]),
1206 ],
1207 )
1208 def test_split_prefill_chunks(seq_lens, max_buf, expected):
1209 out = split_prefill_chunks(seq_lens, max_buf)
1210 assert out == expected
1211
1212
1213 @pytest.mark.parametrize(
1214 ("max_query_len", "expected"),
1215 [(32768, True), (33024, False)],
1216 )
1217 def test_masked_mha_workspace_fits_single_request_boundary(max_query_len, expected):
1218 """A 32K prefill needs the default workspace exactly; shrinking it would
1219 push a supported request onto MQA."""
1220 assert (
1221 _masked_mha_workspace_fits(
1222 batch_size=1,
1223 max_query_len=max_query_len,
1224 max_context_chunk_seq_len=0,
1225 workspace_numel=GLOBAL_TOPK_MASK_MAX_BYTES // torch.int32.itemsize,
1226 )
1227 is expected
1228 )
1229
1230
1231 @pytest.mark.parametrize(
1232 ("backend_name", "tensor_parallel_size", "query_len"),
1233 [
1234 ("FLASHMLA_SPARSE", 4, 48 * 1024),
1235 ("FLASHMLA_SPARSE", 8, 112 * 1024),
1236 ("FLASHINFER_MLA_SPARSE", 4, 36 * 1024),
1237 ("FLASHINFER_MLA_SPARSE", 8, 64 * 1024),
1238 ],
1239 )
1240 def test_masked_mha_workspace_guards_long_routing_policy(
1241 backend_name, tensor_parallel_size, query_len
1242 ):
1243 assert _use_masked_mha(
1244 backend_name=backend_name,
1245 tensor_parallel_size=tensor_parallel_size,
1246 qk_head_dim=256,
1247 v_head_dim=256,
1248 query_len=query_len,
1249 seq_len=query_len,
1250 has_context=False,
1251 )
1252 assert not _masked_mha_workspace_fits(
1253 batch_size=1,
1254 max_query_len=query_len,
1255 max_context_chunk_seq_len=0,
1256 workspace_numel=GLOBAL_TOPK_MASK_MAX_BYTES // torch.int32.itemsize,
1257 )
1258
1259
1260 @pytest.mark.parametrize(
1261 ("model_dims", "kv_cache_dtype", "fa_version", "expected"),
1262 [
1263 pytest.param((128, 512, 128, 64, 128), "auto", 4, True, id="deepseek_v32"),
1264 pytest.param((64, 512, 192, 64, 256), "auto", 4, True, id="glm5"),
1265 pytest.param((64, 512, 256, 0, 256), "auto", 4, True, id="glm53_flash_nope"),
1266 pytest.param((64, 512, 256, 0, 256), "fp8", 4, False, id="quantized_kv"),
1267 pytest.param((64, 512, 256, 0, 256), "auto", 3, False, id="no_fa4"),
1268 pytest.param((64, 512, 256, 64, 256), "auto", 4, False, id="rope_320"),
1269 pytest.param((128, 512, 256, 0, 256), "auto", 4, False, id="wrong_heads"),
1270 ],
1271 )
1272 def test_is_masked_mha_available_model_dims(
1273 monkeypatch, model_dims, kv_cache_dtype, fa_version, expected
1274 ):
1275 """The allow-list gates masked MHA per exact model geometry: the DeepSeek-V3.2,
1276 GLM-5 and NoPE GLM-5.3-Flash layouts on an SM100-family GPU with FA4 and an
1277 unquantized KV cache, nothing else."""
1278 import vllm.model_executor.layers.attention.sparse_mla_attention as mod
1279
1280 monkeypatch.setattr(
1281 mod.current_platform, "is_device_capability_family", lambda family: True
1282 )
1283 monkeypatch.setattr(mod, "get_flash_attn_version", lambda **kwargs: fa_version)
1284 assert _is_masked_mha_available(*model_dims, kv_cache_dtype) is expected
1285
1286
1287 def test_masked_mha_workspace_fits_accounts_for_batch_and_context():
1288 """Request count and context chunk length are independent multipliers."""
1289 base = dict(batch_size=2, max_query_len=2048, max_context_chunk_seq_len=2048)
1290 exact = math.prod(_topk_mask_shape(2, 2048, 2048))
1291
1292 assert _masked_mha_workspace_fits(**base, workspace_numel=exact)
1293 assert not _masked_mha_workspace_fits(
1294 **{**base, "batch_size": 3}, workspace_numel=exact
1295 )
1296 assert not _masked_mha_workspace_fits(
1297 **{**base, "max_context_chunk_seq_len": 4096}, workspace_numel=exact
1298 )
1299
1300
1301 PREFILL_BATCH_SPECS = {
1302 "short_dense_mha": BatchSpec(seq_lens=[64, 128], query_lens=[64, 128]),
1303 "short_context_dense_mha": BatchSpec(seq_lens=[128, 160], query_lens=[64, 32]),
1304 "masked_mha": BatchSpec(seq_lens=[256], query_lens=[256]),
1305 "masked_mha_chunked_context": BatchSpec(seq_lens=[448, 384], query_lens=[256, 256]),
1306 }
1307
1308
1309 @pytest.mark.skipif(
1310 torch.cuda.get_device_capability()[0] < 10,
1311 reason="Sparse MLA forward_mha requires FA4 (SM100+)",
1312 )
1313 @pytest.mark.parametrize("batch_name", list(PREFILL_BATCH_SPECS.keys()))
1314 @pytest.mark.parametrize("kv_cache_dtype", ["auto"])
1315 @pytest.mark.parametrize(
1316 ("num_heads", "qk_nope_head_dim", "qk_rope_head_dim", "v_head_dim"),
1317 [
1318 pytest.param(128, 128, 64, 128, id="deepseek_hd192_v128"),
1319 pytest.param(64, 192, 64, 256, id="glm5_hd256_v256"),
1320 pytest.param(64, 256, 0, 256, id="glm53_flash_nope_hd256_v256"),
1321 ],
1322 )
1323 def test_sparse_backend_prefill_correctness(
1324 default_vllm_config,
1325 dist_init,
1326 batch_name,
1327 kv_cache_dtype,
1328 num_heads,
1329 qk_nope_head_dim,
1330 qk_rope_head_dim,
1331 v_head_dim,
1332 workspace_init,
1333 ):
1334 """Test dense and masked MHA across supported sparse MLA dimensions."""
1335 backend_cls = FlashMLASparseBackend
1336 batch_spec = PREFILL_BATCH_SPECS[batch_name]
1337
1338 device = torch.device("cuda")
1339 dtype = torch.bfloat16
1340 block_size = 64
1341
1342 kv_lora_rank = 512
1343 head_size = kv_lora_rank + qk_rope_head_dim
1344 masked_mha = batch_name.startswith("masked_mha")
1345 topk_tokens = 200 if masked_mha else 512
1346
1347 max_seqlen = max(batch_spec.seq_lens)
1348 total_cache_tokens = sum(batch_spec.seq_lens)
1349
1350 vllm_config = create_vllm_config(
1351 model_name="deepseek-ai/DeepSeek-V2-Lite-Chat",
1352 tensor_parallel_size=1,
1353 max_model_len=max_seqlen,
1354 num_gpu_blocks=max(2048, cdiv(total_cache_tokens, block_size) + 1),
1355 block_size=block_size,
1356 hf_config_override={
1357 "index_topk": topk_tokens,
1358 "attn_module_list_cfg": [{"topk_tokens": topk_tokens}],
1359 },
1360 )
1361 model_config = vllm_config.model_config
1362 model_config.hf_text_config = SimpleNamespace(
1363 index_topk=topk_tokens,
1364 q_lora_rank=None,
1365 kv_lora_rank=kv_lora_rank,
1366 qk_nope_head_dim=qk_nope_head_dim,
1367 qk_rope_head_dim=qk_rope_head_dim,
1368 v_head_dim=v_head_dim,
1369 model_type="deepseek_v2",
1370 )
1371 del model_config.hf_config.index_topk # Composite configs only nest this field.
1372 model_config.dtype = dtype
1373 model_config.model_arch_config.total_num_attention_heads = num_heads
1374 model_config.get_num_attention_heads = MethodType(
1375 lambda self, parallel_config: num_heads, model_config
1376 )
1377 model_config.get_num_kv_heads = MethodType(
1378 lambda self, parallel_config: 1, model_config
1379 )
1380 model_config.get_head_size = MethodType(lambda self: head_size, model_config)
1381 model_config.get_sliding_window = MethodType(lambda self: None, model_config)
1382
1383 kv_cache_spec = create_standard_kv_cache_spec(vllm_config)
1384 scale = 1.0 / math.sqrt(qk_nope_head_dim + qk_rope_head_dim)
1385
1386 torch.manual_seed(42)
1387
1388 W_UK = torch.rand(
1389 kv_lora_rank, num_heads, qk_nope_head_dim, dtype=dtype, device=device
1390 )
1391 W_UV = torch.rand(kv_lora_rank, num_heads, v_head_dim, dtype=dtype, device=device)
1392
1393 seq_lens = batch_spec.seq_lens
1394 query_lens = batch_spec.query_lens
1395
1396 # Compute dense reference outputs.
1397 total_query_tokens = sum(query_lens)
1398 sparse_indices = torch.full(
1399 (total_query_tokens, topk_tokens), -1, dtype=torch.int32, device=device
1400 )
1401
1402 all_q, all_kv_c_new, all_k_pe_new = [], [], []
1403 kv_c_contexts, k_pe_contexts = [], []
1404 reference_outputs = []
1405 global_token_idx = 0
1406
1407 for i in range(batch_spec.batch_size):
1408 s_len = seq_lens[i]
1409 q_len = query_lens[i]
1410 ctx_len = s_len - q_len
1411
1412 q_mha = torch.rand(
1413 q_len,
1414 num_heads,
1415 qk_nope_head_dim + qk_rope_head_dim,
1416 dtype=dtype,
1417 device=device,
1418 )
1419 kv_c_full = torch.rand(s_len, kv_lora_rank, dtype=dtype, device=device)
1420 k_pe_full = torch.rand(s_len, 1, qk_rope_head_dim, dtype=dtype, device=device)
1421
1422 # Decompress all KV for reference
1423 kv_b_weight = torch.cat([W_UK, W_UV], dim=-1).view(
1424 kv_lora_rank, num_heads * (qk_nope_head_dim + v_head_dim)
1425 )
1426 kv_decompressed = (kv_c_full @ kv_b_weight).view(
1427 s_len, num_heads, qk_nope_head_dim + v_head_dim
1428 )
1429 k_nope_all, v_all = kv_decompressed.split(
1430 [qk_nope_head_dim, v_head_dim], dim=-1
1431 )
1432 k_pe_expanded = k_pe_full.expand(-1, num_heads, -1)
1433 k_all = torch.cat([k_nope_all, k_pe_expanded], dim=-1)
1434
1435 for j in range(q_len):
1436 attend_end = ctx_len + j + 1
1437 q_tok = q_mha[j : j + 1] # (1, H, D_qk)
1438 if masked_mha:
1439 actual_topk = min(topk_tokens, attend_end)
1440 attend_indices = torch.randperm(attend_end, device=device)[:actual_topk]
1441 sparse_indices[global_token_idx, :actual_topk] = attend_indices
1442 k_attend = k_all[attend_indices]
1443 v_attend = v_all[attend_indices]
1444 else:
1445 k_attend = k_all[:attend_end] # (N, H, D_qk)
1446 v_attend = v_all[:attend_end] # (N, H, D_v)
1447
1448 q_sdpa = q_tok.unsqueeze(0).transpose(1, 2).float()
1449 k_sdpa = k_attend.unsqueeze(0).transpose(1, 2).float()
1450 v_sdpa = v_attend.unsqueeze(0).transpose(1, 2).float()
1451
1452 out = torch.nn.functional.scaled_dot_product_attention(
1453 q_sdpa, k_sdpa, v_sdpa, scale=scale
1454 )
1455 out = out.transpose(1, 2).squeeze(0) # (1, H, D_v)
1456 reference_outputs.append(out.to(dtype).flatten(start_dim=-2))
1457 global_token_idx += 1
1458
1459 all_q.append(q_mha)
1460 all_kv_c_new.append(kv_c_full[ctx_len:])
1461 all_k_pe_new.append(k_pe_full[ctx_len:])
1462 kv_c_contexts.append(kv_c_full)
1463 k_pe_contexts.append(k_pe_full)
1464
1465 query_cat = torch.cat(all_q, dim=0)
1466 kv_c_cat = torch.cat(all_kv_c_new, dim=0)
1467 k_pe_cat = torch.cat(all_k_pe_new, dim=0)
1468 ref_output = torch.cat(reference_outputs, dim=0)
1469
1470 vllm_config.cache_config.cache_dtype = kv_cache_dtype
1471 vllm_config.model_config.hf_text_config.index_topk = topk_tokens
1472
1473 common_attn_metadata = create_common_attn_metadata(
1474 batch_spec,
1475 vllm_config.cache_config.block_size,
1476 device,
1477 arange_block_indices=True,
1478 )
1479
1480 kv_cache = create_and_prepopulate_kv_cache(
1481 kv_c_contexts=kv_c_contexts,
1482 k_pe_contexts=k_pe_contexts,
1483 block_size=block_size,
1484 head_size=head_size,
1485 dtype=dtype,
1486 device=device,
1487 num_blocks=vllm_config.cache_config.num_gpu_blocks,
1488 common_attn_metadata=common_attn_metadata,
1489 randomize_blocks=False,
1490 kv_cache_dtype=kv_cache_dtype,
1491 )
1492
1493 # The sparse builder clones the layer's dense-MHA prefill backend from
1494 # static_forward_context; register a mock layer carrying one.
1495 prefill_backend = get_mla_prefill_backend(vllm_config)(
1496 num_heads=num_heads,
1497 scale=scale,
1498 kv_lora_rank=kv_lora_rank,
1499 qk_nope_head_dim=qk_nope_head_dim,
1500 qk_rope_head_dim=qk_rope_head_dim,
1501 v_head_dim=v_head_dim,
1502 vllm_config=vllm_config,
1503 )
1504 vllm_config.compilation_config.static_forward_context["placeholder"] = (
1505 SimpleNamespace(prefill_backend=prefill_backend)
1506 )
1507
1508 builder_cls = backend_cls.get_builder_cls()
1509 builder = builder_cls(kv_cache_spec, ["placeholder"], vllm_config, device)
1510 if batch_name == "masked_mha_chunked_context":
1511 builder.chunked_prefill_workspace_size = block_size * batch_spec.batch_size
1512 builder.chunked_prefill_workspace = torch.empty(
1513 (builder.chunked_prefill_workspace_size, head_size),
1514 dtype=dtype,
1515 device=device,
1516 )
1517 # Drive the queries through the dense-MHA prefill path directly (the routing
1518 # threshold would otherwise classify these short queries as MQA decodes).
1519 builder.reorder_batch_threshold = 1
1520 metadata = builder.build(
1521 common_prefix_len=0, common_attn_metadata=common_attn_metadata
1522 )
1523
1524 mock_indexer = SimpleNamespace(topk_indices_buffer=sparse_indices)
1525
1526 kv_b_proj_weight = torch.cat([W_UK, W_UV], dim=-1).view(
1527 kv_lora_rank, num_heads * (qk_nope_head_dim + v_head_dim)
1528 )
1529
1530 mock_kv_b_proj = ColumnParallelLinear(
1531 input_size=kv_lora_rank,
1532 output_size=num_heads * (qk_nope_head_dim + v_head_dim),
1533 bias=False,
1534 ).to(device=device, dtype=dtype)
1535 mock_kv_b_proj.weight = torch.nn.Parameter(kv_b_proj_weight.T.contiguous())
1536
1537 impl_cls = backend_cls.get_impl_cls()
1538 with set_current_vllm_config(vllm_config):
1539 impl = impl_cls(
1540 num_heads=num_heads,
1541 head_size=head_size,
1542 scale=scale,
1543 num_kv_heads=1,
1544 alibi_slopes=None,
1545 sliding_window=None,
1546 kv_cache_dtype=kv_cache_dtype,
1547 logits_soft_cap=None,
1548 attn_type="decoder",
1549 kv_sharing_target_layer_name=None,
1550 q_lora_rank=None,
1551 kv_lora_rank=kv_lora_rank,
1552 qk_nope_head_dim=qk_nope_head_dim,
1553 qk_rope_head_dim=qk_rope_head_dim,
1554 qk_head_dim=qk_nope_head_dim + qk_rope_head_dim,
1555 v_head_dim=v_head_dim,
1556 kv_b_proj=mock_kv_b_proj,
1557 indexer=mock_indexer,
1558 )
1559 impl.process_weights_after_loading(dtype)
1560
1561 out_buffer = torch.empty(
1562 total_query_tokens, num_heads * v_head_dim, dtype=dtype, device=device
1563 )
1564
1565 with torch.inference_mode():
1566 impl.forward_mha(
1567 q=query_cat,
1568 kv_c_normed=kv_c_cat,
1569 k_pe=k_pe_cat,
1570 # Impls see the bind-time-squeezed [B, N, C] cache; mirror bind_kv_cache.
1571 kv_c_and_k_pe_cache=kv_cache.squeeze(1),
1572 attn_metadata=metadata,
1573 k_scale=torch.tensor(1.0, device=device),
1574 output=out_buffer,
1575 )
1576
1577 assert out_buffer.shape == ref_output.shape
1578 assert torch.isfinite(out_buffer).all(), "Non-finite values in output"
1579 torch.testing.assert_close(out_buffer, ref_output, rtol=0.01, atol=0.01)
1580
1581
1582 @pytest.mark.parametrize(
1583 "seq_lens,query_lens,workspace_size,max_logits_bytes,expected",
1584 [
1585 (
1586 torch.tensor([0]),
1587 torch.tensor([0]),
1588 100,
1589 1000,
1590 [],
1591 ),
1592 # Logits constraint triggers split (M*N exceeds budget)
1593 # req0: M=10, N=100 -> 1000 elems (4000 bytes) - fits in 5000
1594 # req1: adding M=10, N=100 -> new_M=20, new_N=200 -> 4000 elems > 1250
1595 (
1596 torch.tensor([100, 100, 100]),
1597 torch.tensor([10, 10, 10]),
1598 1000, # workspace allows all
1599 5000, # 1250 float32 elems -> forces split
1600 [
1601 (slice(0, 1), slice(0, 10)),
1602 (slice(1, 2), slice(0, 10)),
1603 (slice(2, 3), slice(0, 10)),
1604 ],
1605 ),
1606 # Both constraints satisfied - all fit in one chunk
1607 (
1608 torch.tensor([10, 10, 10]),
1609 torch.tensor([5, 5, 5]),
1610 100,
1611 10000, # 2500 elems, M*N = 15*30 = 450 < 2500
1612 [(slice(0, 3), slice(0, 15))],
1613 ),
1614 # Workspace constraint triggers first
1615 (
1616 torch.tensor([50, 50, 50]),
1617 torch.tensor([1, 1, 1]),
1618 50, # workspace only fits one at a time
1619 1000000, # logits budget is huge
1620 [
1621 (slice(0, 1), slice(0, 1)),
1622 (slice(1, 2), slice(0, 1)),
1623 (slice(2, 3), slice(0, 1)),
1624 ],
1625 ),
1626 # Greedy filling: first two fit, third doesn't
1627 # req0: M=5, N=10 -> 50 elems
1628 # req0+1: M=10, N=20 -> 200 elems <= 250
1629 # req0+1+2: M=15, N=30 -> 450 elems > 250
1630 (
1631 torch.tensor([10, 10, 10]),
1632 torch.tensor([5, 5, 5]),
1633 100,
1634 1000, # 250 elems
1635 [(slice(0, 2), slice(0, 10)), (slice(2, 3), slice(0, 5))],
1636 ),
1637 ],
1638 )
1639 def test_split_indexer_prefill_chunks(
1640 seq_lens, query_lens, workspace_size, max_logits_bytes, expected
1641 ):
1642 out = DeepseekV32IndexerMetadataBuilder._split_indexer_prefill_chunks(
1643 seq_lens,
1644 query_lens,
1645 workspace_size,
1646 max_logits_bytes,
1647 )
1648 assert out == expected
1649
1650
1651 def test_split_indexer_prefill_chunks_single_request_overflow():
1652 """Test that single request exceeding budget is sub-chunked on query dim."""
1653 seq_lens = torch.tensor([1000, 50])
1654 query_lens = torch.tensor([100, 5])
1655
1656 out = DeepseekV32IndexerMetadataBuilder._split_indexer_prefill_chunks(
1657 seq_lens, query_lens, 2000, 1000
1658 )
1659 # max_logits_elems = 250, N=1000 -> max_q = 1 -> 100 query sub-chunks
1660 expected = [(slice(0, 1), slice(i, i + 1)) for i in range(100)]
1661 # req1: M=5, N=50 -> 250 elems fits budget
1662 expected.append((slice(1, 2), slice(0, 5)))
1663 assert out == expected
1664
1665
1666 # Power-of-two, GLM's padded tile, and atomic fallback, with reused buffers.
1667 @pytest.mark.parametrize(
1668 "num_topk_tokens,reuse_buffers",
1669 [(128, False), (2176, False), (2176, True), (4224, False), (4224, True)],
1670 )
1671 def test_triton_convert_returns_valid_counts(num_topk_tokens: int, reuse_buffers: bool):
1672 """Test that return_valid_counts correctly counts non-negative indices."""
1673 device = torch.device(DEVICE_TYPE)
1674 num_tokens = 8
1675 num_requests = 2
1676 max_blocks_per_req = 10
1677 block_size = 64
1678
1679 req_id = torch.tensor([0, 0, 0, 0, 1, 1, 1, 1], dtype=torch.int32, device=device)
1680 block_table = torch.arange(
1681 num_requests * max_blocks_per_req, dtype=torch.int32, device=device
1682 ).view(num_requests, max_blocks_per_req)
1683
1684 # Include partly valid, fully valid, and empty selections for each request.
1685 token_indices = torch.full(
1686 (num_tokens, num_topk_tokens), -1, dtype=torch.int32, device=device
1687 )
1688 valid_counts_per_token = [
1689 num_topk_tokens // 2,
1690 num_topk_tokens // 4,
1691 num_topk_tokens,
1692 0,
1693 ] * 2
1694 expected_valid = []
1695 for i in range(num_tokens):
1696 num_valid = valid_counts_per_token[i]
1697 token_indices[i, :num_valid] = torch.arange(
1698 num_valid, dtype=torch.int32, device=device
1699 ) % (block_size * max_blocks_per_req)
1700 expected_valid.append(num_valid)
1701
1702 expected_valid_tensor = torch.tensor(
1703 expected_valid, dtype=torch.int32, device=device
1704 )
1705
1706 buffers = {}
1707 if reuse_buffers:
1708 buffers = dict(
1709 out=torch.full_like(token_indices, -99),
1710 valid_counts_out=torch.full(
1711 (num_tokens,), 99, dtype=torch.int32, device=device
1712 ),
1713 )
1714
1715 # Test with return_valid_counts=True
1716 result, valid_counts = triton_convert_req_index_to_global_index(
1717 req_id,
1718 block_table,
1719 token_indices,
1720 BLOCK_SIZE=block_size,
1721 NUM_TOPK_TOKENS=num_topk_tokens,
1722 return_valid_counts=True,
1723 **buffers,
1724 )
1725
1726 torch.testing.assert_close(valid_counts, expected_valid_tensor, rtol=0, atol=0)
1727
1728 # Test that return_valid_counts=False returns only the indices
1729 result_only = triton_convert_req_index_to_global_index(
1730 req_id,
1731 block_table,
1732 token_indices,
1733 BLOCK_SIZE=block_size,
1734 NUM_TOPK_TOKENS=num_topk_tokens,
1735 return_valid_counts=False,
1736 )
1737 assert isinstance(result_only, torch.Tensor)
1738 for row, num_valid in enumerate(expected_valid):
1739 compact_valid = result[row, :num_valid].sort().values
1740 original_valid = result_only[row][result_only[row] >= 0].sort().values
1741 torch.testing.assert_close(compact_valid, original_valid, rtol=0, atol=0)
1742 assert torch.all(result[row, num_valid:] == -1)
1743
1744
1745 def test_sparse_mla_index_group_converts_decode_indices_once(monkeypatch):
1746 device = torch.device(DEVICE_TYPE)
1747 logical = torch.full((2, 128), -1, dtype=torch.int32, device=device)
1748 logical[:, :4] = torch.tensor(
1749 [[0, 2, 5, -1], [1, 3, 7, -1]], dtype=torch.int32, device=device
1750 )
1751 builder = SparseMLAIndexGroupBuilder(logical)
1752 group, leader_index = builder.register_layer(True)
1753 follower_group, follower_index = builder.register_layer(False)
1754 assert follower_group is group
1755
1756 block_table = torch.tensor([[2, 3], [4, 5]], dtype=torch.int32, device=device)
1757 metadata = SimpleNamespace(
1758 block_table=block_table,
1759 block_size=4,
1760 req_id_per_token=torch.tensor([0, 1], dtype=torch.int32, device=device),
1761 num_decode_tokens=2,
1762 num_actual_tokens=2,
1763 )
1764 calls = []
1765 convert = index_group_module.triton_convert_req_index_to_global_index
1766
1767 def convert_spy(*args, **kwargs):
1768 calls.append((args, kwargs))
1769 return convert(*args, **kwargs)
1770
1771 monkeypatch.setattr(
1772 index_group_module,
1773 "triton_convert_req_index_to_global_index",
1774 convert_spy,
1775 )
1776 group.set_logical_topk_ready(leader_index)
1777 leader_result = group.convert_logical_to_physical_topk(
1778 leader_index,
1779 logical,
1780 metadata,
1781 block_stride_rows=None,
1782 return_valid_counts=True,
1783 )
1784 follower_result = group.convert_logical_to_physical_topk(
1785 follower_index,
1786 logical,
1787 metadata,
1788 block_stride_rows=None,
1789 return_valid_counts=True,
1790 )
1791 torch.accelerator.synchronize()
1792
1793 assert len(calls) == 1
1794 leader_indices, leader_counts = leader_result
1795 follower_indices, follower_counts = follower_result
1796 torch.testing.assert_close(follower_indices, leader_indices)
1797 torch.testing.assert_close(follower_counts, leader_counts)
1798
1799
1800 def test_sparse_mla_index_group_falls_back_for_prefill_sized_batch(monkeypatch):
1801 device = torch.device(DEVICE_TYPE)
1802 logical = torch.full((4, 128), -1, dtype=torch.int32, device=device)
1803 logical[:, 0] = torch.tensor([0, 1, 2, 3], dtype=torch.int32, device=device)
1804 builder = SparseMLAIndexGroupBuilder(logical, max_decode_rows=2)
1805 group, leader_index = builder.register_layer(True)
1806 follower_group, follower_index = builder.register_layer(False)
1807 assert follower_group is group
1808
1809 metadata = SimpleNamespace(
1810 block_table=torch.tensor(
1811 [[2], [3], [4], [5]], dtype=torch.int32, device=device
1812 ),
1813 block_size=4,
1814 req_id_per_token=torch.arange(4, dtype=torch.int32, device=device),
1815 )
1816 calls = 0
1817 convert = index_group_module.triton_convert_req_index_to_global_index
1818
1819 def convert_spy(*args, **kwargs):
1820 nonlocal calls
1821 calls += 1
1822 return convert(*args, **kwargs)
1823
1824 monkeypatch.setattr(
1825 index_group_module,
1826 "triton_convert_req_index_to_global_index",
1827 convert_spy,
1828 )
1829 leader_result = group.convert_logical_to_physical_topk(
1830 leader_index,
1831 logical,
1832 metadata,
1833 block_stride_rows=None,
1834 return_valid_counts=False,
1835 )
1836 follower_result = group.convert_logical_to_physical_topk(
1837 follower_index,
1838 logical,
1839 metadata,
1840 block_stride_rows=None,
1841 return_valid_counts=False,
1842 )
1843 torch.accelerator.synchronize()
1844
1845 assert calls == 2
1846 torch.testing.assert_close(follower_result, leader_result)
1847
1848
1849 def test_sparse_mla_index_groups_own_distinct_physical_buffers():
1850 logical = torch.empty((8, 128), dtype=torch.int32, device=DEVICE_TYPE)
1851 builder = SparseMLAIndexGroupBuilder(logical, max_decode_rows=2)
1852
1853 first, _ = builder.register_layer(True)
1854 follower, _ = builder.register_layer(False)
1855 second, _ = builder.register_layer(True)
1856
1857 assert follower is first
1858 assert second is not first
1859 assert first.physical_topk_indices[:2].shape == (2, 128)
1860 assert second.physical_topk_indices[:2].shape == (2, 128)
1861 assert first.physical_topk_indices.data_ptr() != (
1862 second.physical_topk_indices.data_ptr()
1863 )
1864
1865
1866 # HiSparse is host-resident-only and kernel-only: runtime construction
1867 # raises without the compiled CUDA ops.
1868 requires_hisparse_ops = pytest.mark.skipif(
1869 not _has_hisparse_ops(),
1870 reason="HiSparse CUDA ops not compiled",
1871 )
1872
1873
1874 def _make_hisparse_runtime(
1875 *,
1876 top_k: int = 4,
1877 device_buffer_size: int = 5,
1878 max_num_reqs: int = 2,
1879 row_width: int = 8,
1880 block_size: int = 64,
1881 max_swap_rows: int | None = None,
1882 index_group: hisparse_runtime.HiSparseIndexGroup | None = None,
1883 max_union_rows: int | None = None,
1884 ) -> HiSparseRuntime:
1885 runtime = HiSparseRuntime(
1886 config=ResolvedHiSparseConfig(
1887 top_k=top_k,
1888 device_buffer_size=device_buffer_size,
1889 max_union_rows=max_union_rows or device_buffer_size,
1890 ),
1891 max_num_reqs=max_num_reqs,
1892 row_width=row_width,
1893 kv_dtype=torch.float32,
1894 device=DEVICE_TYPE,
1895 max_swap_rows=max_swap_rows,
1896 index_group=index_group,
1897 )
1898 blocks_per_request = cdiv(runtime.region_stride, block_size)
1899 # Leave one extra physical block for resident-tier tests. HMA group layouts
1900 # alias this backing, but distinct group allocations use distinct block IDs.
1901 num_blocks = max_num_reqs * blocks_per_request + 1
1902 raw = torch.zeros(
1903 num_blocks * block_size * row_width,
1904 dtype=torch.float32,
1905 device=DEVICE_TYPE,
1906 ).view(torch.int8)
1907 block_table = torch.arange(
1908 max_num_reqs * blocks_per_request,
1909 dtype=torch.int32,
1910 device=DEVICE_TYPE,
1911 ).view(max_num_reqs, blocks_per_request)
1912 runtime.bind_hot_cache(
1913 raw,
1914 byte_offset=0,
1915 block_stride=block_size * row_width * torch.float32.itemsize,
1916 num_blocks=num_blocks,
1917 block_size=block_size,
1918 block_table=block_table,
1919 )
1920 runtime.request_state_indices = torch.arange(
1921 max_num_reqs, dtype=torch.int32, device=runtime.device
1922 )
1923 return runtime
1924
1925
1926 def _make_hisparse_cache_handle(
1927 *,
1928 top_k: int = 4,
1929 device_buffer_size: int = 5,
1930 max_num_reqs: int = 2,
1931 row_width: int = 8,
1932 block_size: int = 64,
1933 max_swap_rows: int | None = None,
1934 ) -> HiSparseCacheHandle:
1935 runtime = _make_hisparse_runtime(
1936 top_k=top_k,
1937 device_buffer_size=device_buffer_size,
1938 max_num_reqs=max_num_reqs,
1939 row_width=row_width,
1940 block_size=block_size,
1941 max_swap_rows=max_swap_rows,
1942 )
1943 return HiSparseCacheHandle(runtime)
1944
1945
1946 def _valid_rows(num_rows: int) -> torch.Tensor:
1947 return torch.tensor([num_rows], dtype=torch.int32, device=DEVICE_TYPE)
1948
1949
1950 @requires_hisparse_ops
1951 def test_hisparse_uses_graph_stable_request_state_mapping():
1952 device = torch.device(DEVICE_TYPE)
1953 block_size, row_width = 64, 8
1954 runtime = _make_hisparse_runtime(
1955 top_k=1,
1956 device_buffer_size=2,
1957 max_num_reqs=2,
1958 row_width=row_width,
1959 block_size=block_size,
1960 )
1961 runtime.bind_source_cache(
1962 torch.arange(2 * block_size * row_width, dtype=torch.float32)
1963 .view(2, block_size, row_width)
1964 .pin_memory()
1965 )
1966 runtime.request_state_indices = torch.tensor([1], dtype=torch.int32, device=device)
1967
1968 cache_handle = HiSparseCacheHandle(runtime)
1969 cache_handle.all_context_pages_resident = False
1970 runtime.begin_forward()
1971 cache_handle.swap_in(
1972 req_id_per_token=torch.tensor([0], dtype=torch.int32, device=device),
1973 block_table=torch.tensor([[1]], dtype=torch.int32, device=device),
1974 logical_topk_indices=torch.tensor([[0]], dtype=torch.int32, device=device),
1975 block_size=block_size,
1976 num_valid_rows=_valid_rows(1),
1977 )
1978 torch.accelerator.synchronize()
1979
1980 assert (runtime.index_group.device_global_indices[0] == -1).all()
1981 assert (runtime.index_group.device_global_indices[1] == block_size).any()
1982
1983
1984 @requires_hisparse_ops
1985 def test_hisparse_maps_speculative_rows_through_request_state():
1986 """Multiple verification rows for one request share its persistent state."""
1987 device = torch.device(DEVICE_TYPE)
1988 block_size, row_width = 64, 8
1989 runtime = _make_hisparse_runtime(
1990 top_k=1,
1991 device_buffer_size=2,
1992 max_num_reqs=1,
1993 row_width=row_width,
1994 block_size=block_size,
1995 max_swap_rows=4,
1996 )
1997 runtime.bind_source_cache(
1998 torch.zeros(1, block_size, row_width, dtype=torch.float32).pin_memory()
1999 )
2000
2001 cache_handle = HiSparseCacheHandle(runtime)
2002 resident_table = torch.tensor([[1]], dtype=torch.int32, device=device)
2003 cache_handle.bind_cache(
2004 runtime.hot.cache.view(torch.int8),
2005 byte_offset=0,
2006 block_stride=block_size * row_width * torch.float32.itemsize,
2007 num_blocks=2,
2008 block_size=block_size,
2009 block_table=resident_table,
2010 slot_mapping=torch.arange(4, dtype=torch.int64, device=device),
2011 )
2012 cache_handle.all_context_pages_resident = True
2013 runtime.begin_forward()
2014 physical = cache_handle.swap_in(
2015 req_id_per_token=torch.zeros(4, dtype=torch.int32, device=device),
2016 block_table=torch.zeros((1, 1), dtype=torch.int32, device=device),
2017 logical_topk_indices=torch.zeros((4, 1), dtype=torch.int32, device=device),
2018 block_size=block_size,
2019 num_valid_rows=_valid_rows(4),
2020 )
2021 torch.accelerator.synchronize()
2022
2023 assert physical.tolist() == [[block_size]] * 4
2024
2025
2026 @requires_hisparse_ops
2027 @pytest.mark.parametrize(
2028 "num_rows,row_stride,top_k",
2029 [(8, 128, 128), (4, 32, 128), (8, 2048, 2048)],
2030 ids=["disjoint", "overlap", "long_mtp"],
2031 )
2032 def test_hisparse_speculative_rows_resolve_host_misses_consistently(
2033 num_rows, row_stride, top_k
2034 ):
2035 """Verification rows of one request must agree on its hot-buffer slots.
2036
2037 Each row resolved residency in its own thread block against the request's
2038 shared slot ownership and LRU, so rows missing on different host rows could
2039 claim the same free slot. One row's hot index then pointed at a slot holding
2040 another row's KV. Overlapping rows must share one slot per host row, and
2041 long_mtp's rows span several resolution passes.
2042 """
2043 device = torch.device(DEVICE_TYPE)
2044 block_size, row_width = 64, 64
2045 num_blocks = 2 * num_rows * top_k // block_size
2046
2047 kv_pool = torch.randn(
2048 (num_blocks + 1, block_size, row_width), dtype=torch.float32
2049 ).pin_memory()
2050 flat_pool = kv_pool.reshape(-1, row_width)
2051 runtime = _make_hisparse_runtime(
2052 top_k=top_k,
2053 device_buffer_size=num_rows * top_k,
2054 max_num_reqs=1,
2055 row_width=row_width,
2056 block_size=block_size,
2057 max_swap_rows=num_rows,
2058 )
2059 runtime.bind_source_cache(kv_pool)
2060 cache = HiSparseCacheHandle(runtime)
2061 cache.all_context_pages_resident = False
2062
2063 block_table = torch.arange(
2064 1, num_blocks + 1, dtype=torch.int32, device=device
2065 ).view(1, num_blocks)
2066 req_ids = torch.zeros(num_rows, dtype=torch.int32, device=device)
2067 rows = torch.arange(num_rows, dtype=torch.int32, device=device)[:, None]
2068 base = torch.arange(top_k, dtype=torch.int32, device=device)[None, :]
2069 for step in range(4):
2070 runtime.begin_forward()
2071 # Rows start row_stride apart (disjoint at row_stride == top_k) and
2072 # shift by half a top-k per step: all misses first, then a mix.
2073 topk = (rows * row_stride + base + step * (top_k // 2)) % (
2074 num_blocks * block_size
2075 )
2076 hot_indices = cache.swap_in(
2077 req_id_per_token=req_ids,
2078 block_table=block_table,
2079 logical_topk_indices=topk.clone(),
2080 block_size=block_size,
2081 num_valid_rows=_valid_rows(num_rows),
2082 )
2083 torch.accelerator.synchronize()
2084
2085 global_ref = _triton_convert_reference_impl(
2086 req_ids, block_table, topk, block_size, top_k
2087 )
2088 gathered = runtime.hot.attention_cache.reshape(-1, row_width)[
2089 hot_indices.to(torch.long)
2090 ].cpu()
2091 torch.testing.assert_close(gathered, flat_pool[global_ref.cpu().to(torch.long)])
2092
2093
2094 @requires_hisparse_ops
2095 def test_hisparse_speculative_rows_count_a_shared_load_once():
2096 """A host row two verification rows select loads once; the reuse is a hit."""
2097 device = torch.device(DEVICE_TYPE)
2098 block_size, row_width, top_k = 64, 64, 128
2099 num_blocks = top_k // block_size
2100 kv_pool = torch.randn(
2101 (num_blocks + 1, block_size, row_width), dtype=torch.float32
2102 ).pin_memory()
2103 runtime = _make_hisparse_runtime(
2104 top_k=top_k,
2105 device_buffer_size=2 * top_k,
2106 max_num_reqs=1,
2107 row_width=row_width,
2108 block_size=block_size,
2109 max_swap_rows=2,
2110 )
2111 runtime.bind_source_cache(kv_pool)
2112 cache = HiSparseCacheHandle(runtime)
2113 cache.all_context_pages_resident = False
2114 block_table = torch.arange(
2115 1, num_blocks + 1, dtype=torch.int32, device=device
2116 ).view(1, num_blocks)
2117 topk = torch.arange(top_k, dtype=torch.int32, device=device).repeat(2, 1)
2118
2119 runtime.begin_forward()
2120 cache.swap_in(
2121 req_id_per_token=torch.zeros(2, dtype=torch.int32, device=device),
2122 block_table=block_table,
2123 logical_topk_indices=topk,
2124 block_size=block_size,
2125 num_valid_rows=_valid_rows(2),
2126 )
2127 torch.accelerator.synchronize()
2128
2129 assert runtime.index_group.swap_stats.cpu().tolist() == [top_k, top_k]
2130
2131
2132 @requires_hisparse_ops
2133 def test_hisparse_resolver_reads_current_request_ids():
2134 """The resolver must see this call's request ids, not the previous call's.
2135
2136 It runs on the copy stream after waiting only on ``logical_topk_ready``,
2137 which the indexer records before attention writes the ids. An MTP drafter
2138 alternates four rows per request with one, so stale ids resolve rows
2139 against other requests' KV. Stalling the compute stream exposes the race.
2140 """
2141 device = torch.device(DEVICE_TYPE)
2142 block_size, row_width, top_k, blocks_per_req, num_reqs = 64, 16, 64, 4, 2
2143 kv_pool = torch.randn(
2144 (num_reqs * blocks_per_req + 1, block_size, row_width), dtype=torch.float32
2145 ).pin_memory()
2146 runtime = _make_hisparse_runtime(
2147 top_k=top_k,
2148 device_buffer_size=4 * top_k,
2149 max_num_reqs=num_reqs,
2150 row_width=row_width,
2151 block_size=block_size,
2152 max_swap_rows=4 * num_reqs,
2153 )
2154 runtime.bind_source_cache(kv_pool)
2155 ready = torch.Event()
2156 runtime.index_group.logical_topk_ready = ready
2157 cache = HiSparseCacheHandle(runtime)
2158 cache.all_context_pages_resident = False
2159 cache.source_block_table = torch.arange(
2160 1, num_reqs * blocks_per_req + 1, dtype=torch.int32, device=device
2161 ).view(num_reqs, blocks_per_req)
2162
2163 index_group = object.__new__(HiSparseMLAIndexGroup)
2164 index_group.caches = [cache]
2165 index_group.physical_topk_indices = torch.empty(
2166 (4 * num_reqs + 1, top_k), dtype=torch.int32, device=device
2167 )
2168
2169 # Four rows per request, then one per request plus a CUDA-graph padding
2170 # row, which maps to request 0 and must be skipped.
2171 for rows_per_req, num_padding in ((4, 0), (1, 1)):
2172 num_rows = num_reqs * rows_per_req
2173 req_ids = torch.arange(num_reqs, dtype=torch.int32, device=device)
2174 req_ids = torch.cat(
2175 [
2176 req_ids.repeat_interleave(rows_per_req),
2177 torch.zeros(num_padding, dtype=torch.int32, device=device),
2178 ]
2179 )
2180 topk = torch.stack(
2181 [
2182 torch.randperm(blocks_per_req * block_size, device=device)[:top_k]
2183 for _ in range(req_ids.numel())
2184 ]
2185 ).to(torch.int32)
2186 query_start_loc = torch.arange(
2187 0, num_rows + 1, rows_per_req, dtype=torch.int32, device=device
2188 )
2189 attn_metadata = SimpleNamespace(
2190 req_id_per_token=req_ids,
2191 query_start_loc=torch.cat([query_start_loc, query_start_loc[-1:]]),
2192 block_size=block_size,
2193 )
2194 runtime.begin_forward()
2195 ready.record(current_stream())
2196 torch.cuda._sleep(200_000_000)
2197 hot_indices = index_group.convert_logical_to_physical_topk(
2198 0, topk, attn_metadata, block_stride_rows=None, return_valid_counts=False
2199 )
2200 torch.accelerator.synchronize()
2201
2202 assert (hot_indices[num_rows:] == -1).all()
2203 global_ref = _triton_convert_reference_impl(
2204 req_ids[:num_rows],
2205 cache.source_block_table,
2206 topk[:num_rows],
2207 block_size,
2208 top_k,
2209 )
2210 gathered = runtime.hot.attention_cache.reshape(-1, row_width)[
2211 hot_indices[:num_rows].to(torch.long)
2212 ].cpu()
2213 torch.testing.assert_close(
2214 gathered, kv_pool.reshape(-1, row_width)[global_ref.cpu().to(torch.long)]
2215 )
2216
2217
2218 @requires_hisparse_ops
2219 def test_hisparse_union_table_scales_with_union_bound():
2220 """Shared memory follows the per-request union bound, not the hot buffer.
2221
2222 Sizing the table from device_buffer_size needs ~340 KB here and fails.
2223 """
2224 device = torch.device(DEVICE_TYPE)
2225 block_size, row_width, top_k = 64, 64, 128
2226 num_blocks = top_k // block_size
2227 kv_pool = torch.randn(
2228 (num_blocks + 1, block_size, row_width), dtype=torch.float32
2229 ).pin_memory()
2230 runtime = _make_hisparse_runtime(
2231 top_k=top_k,
2232 device_buffer_size=32768,
2233 max_union_rows=top_k,
2234 max_num_reqs=1,
2235 row_width=row_width,
2236 block_size=block_size,
2237 max_swap_rows=1,
2238 )
2239 runtime.bind_source_cache(kv_pool)
2240 cache = HiSparseCacheHandle(runtime)
2241 cache.all_context_pages_resident = False
2242 req_ids = torch.zeros(1, dtype=torch.int32, device=device)
2243 block_table = torch.arange(
2244 1, num_blocks + 1, dtype=torch.int32, device=device
2245 ).view(1, num_blocks)
2246 topk = torch.arange(top_k, dtype=torch.int32, device=device).view(1, top_k)
2247
2248 runtime.begin_forward()
2249 hot_indices = cache.swap_in(
2250 req_id_per_token=req_ids,
2251 block_table=block_table,
2252 logical_topk_indices=topk,
2253 block_size=block_size,
2254 num_valid_rows=_valid_rows(req_ids.numel()),
2255 )
2256 torch.accelerator.synchronize()
2257
2258 global_ref = _triton_convert_reference_impl(
2259 req_ids, block_table, topk, block_size, top_k
2260 )
2261 gathered = runtime.hot.attention_cache.reshape(-1, row_width)[
2262 hot_indices.to(torch.long)
2263 ].cpu()
2264 torch.testing.assert_close(
2265 gathered, kv_pool.reshape(-1, row_width)[global_ref.cpu().to(torch.long)]
2266 )
2267
2268
2269 @requires_hisparse_ops
2270 def test_hisparse_rejects_union_bound_beyond_shared_memory():
2271 """An oversized union bound fails at construction, not on the first forward."""
2272 with pytest.raises(ValueError, match="shared memory"):
2273 _make_hisparse_runtime(top_k=2048, device_buffer_size=32768)
2274
2275
2276 @requires_hisparse_ops
2277 def test_hisparse_resident_rows_bypass_hot_lru():
2278 device = torch.device(DEVICE_TYPE)
2279 block_size, row_width = 64, 8
2280 cache_handle = _make_hisparse_cache_handle(
2281 top_k=4,
2282 device_buffer_size=5,
2283 max_num_reqs=1,
2284 row_width=row_width,
2285 block_size=block_size,
2286 )
2287 raw = cache_handle.runtime.hot.cache.view(torch.int8)
2288 resident_table = torch.tensor([[1]], dtype=torch.int32, device=device)
2289 resident_slots = torch.tensor([block_size], dtype=torch.int64, device=device)
2290 cache_handle.bind_cache(
2291 raw,
2292 byte_offset=0,
2293 block_stride=block_size * row_width * torch.float32.itemsize,
2294 num_blocks=2,
2295 block_size=block_size,
2296 block_table=resident_table,
2297 slot_mapping=resident_slots,
2298 )
2299
2300 host = torch.randn(1, block_size, row_width).pin_memory()
2301 cache_handle.runtime.bind_source_cache(host)
2302 resident_rows = host[0].to(device).add(7)
2303 cache_handle.runtime.hot.cache[1].copy_(resident_rows)
2304 source_table = torch.tensor([[5]], dtype=torch.int32, device=device)
2305 request_ids = torch.zeros(1, dtype=torch.int32, device=device)
2306 topk = torch.tensor([[1, 2, 3, 4]], dtype=torch.int32, device=device)
2307 lru_before = cache_handle.runtime.index_group.lru_slots.clone()
2308
2309 cache_handle.runtime.begin_forward()
2310 indices, valid_counts = cache_handle.swap_in(
2311 req_id_per_token=request_ids,
2312 block_table=source_table,
2313 logical_topk_indices=topk,
2314 block_size=block_size,
2315 num_valid_rows=_valid_rows(request_ids.numel()),
2316 return_valid_counts=True,
2317 )
2318 torch.accelerator.synchronize()
2319
2320 expected_indices = topk + block_size
2321 torch.testing.assert_close(indices, expected_indices)
2322 torch.testing.assert_close(
2323 valid_counts, torch.tensor([topk.shape[1]], dtype=torch.int32, device=device)
2324 )
2325 torch.testing.assert_close(cache_handle.runtime.index_group.lru_slots, lru_before)
2326 torch.testing.assert_close(
2327 cache_handle.runtime.hot.attention_cache.view(-1, row_width)[
2328 indices.to(torch.long)
2329 ],
2330 resident_rows[topk.to(torch.long)],
2331 )
2332
2333
2334 @requires_hisparse_ops
2335 def test_hisparse_swap_in_preserves_rows_across_eviction():
2336 device = torch.device(DEVICE_TYPE)
2337 block_size = 64
2338 row_width = 64
2339 num_blocks = 64
2340 top_k = 128
2341 buf = 256
2342 num_reqs = 4
2343
2344 kv_pool = torch.randn(
2345 (num_blocks + 1, block_size, row_width), dtype=torch.float32
2346 ).pin_memory()
2347 flat_pool = kv_pool.reshape(-1, row_width)
2348
2349 runtime = _make_hisparse_runtime(
2350 top_k=top_k,
2351 device_buffer_size=buf,
2352 max_num_reqs=num_reqs,
2353 row_width=row_width,
2354 )
2355 runtime.bind_source_cache(kv_pool)
2356 cache = HiSparseCacheHandle(runtime)
2357 cache.all_context_pages_resident = False
2358 cache.runtime.begin_forward()
2359
2360 blocks_per_req = num_blocks // num_reqs
2361 block_table = torch.arange(
2362 1, num_blocks + 1, dtype=torch.int32, device=device
2363 ).view(num_reqs, blocks_per_req)
2364 req_ids = torch.arange(num_reqs, dtype=torch.int32, device=device)
2365 seq_len = blocks_per_req * block_size
2366 base = torch.arange(top_k, dtype=torch.int32, device=device)
2367 for step in range(3):
2368 cache.runtime.begin_forward()
2369 topk = torch.stack(
2370 [(base + step * top_k + row * 17) % seq_len for row in range(num_reqs)]
2371 )
2372 topk[:, -1] = -1
2373
2374 hot_indices, valid_counts = cache.swap_in(
2375 req_id_per_token=req_ids,
2376 block_table=block_table,
2377 logical_topk_indices=topk.clone(),
2378 block_size=block_size,
2379 num_valid_rows=_valid_rows(req_ids.numel()),
2380 return_valid_counts=True,
2381 )
2382 torch.accelerator.synchronize()
2383
2384 global_ref = _triton_convert_reference_impl(
2385 req_ids, block_table, topk, block_size, top_k
2386 )
2387 valid = hot_indices >= 0
2388 torch.testing.assert_close(
2389 valid_counts,
2390 (global_ref >= 0).sum(dim=1, dtype=torch.int32),
2391 rtol=0,
2392 atol=0,
2393 )
2394 gathered = cache.runtime.hot.attention_cache.reshape(-1, row_width)[
2395 hot_indices[valid].to(torch.long)
2396 ].cpu()
2397 expected = flat_pool[global_ref[valid].cpu().to(torch.long)]
2398 torch.testing.assert_close(gathered, expected)
2399
2400
2401 @requires_hisparse_ops
2402 def test_hisparse_multi_step_swaps_match_independent():
2403 """The shared LRU must retain every speculative step until followers run."""
2404 device = torch.device(DEVICE_TYPE)
2405 torch.manual_seed(0)
2406 block_size = 64
2407 row_width = 64
2408 num_blocks = 64
2409 top_k = 128
2410 buf = 3 * top_k
2411 num_reqs = 4
2412
2413 kv_pool = torch.randn(
2414 (num_blocks, block_size, row_width), dtype=torch.float32
2415 ).pin_memory()
2416
2417 def make(
2418 index_group: hisparse_runtime.HiSparseIndexGroup | None = None,
2419 ) -> HiSparseCacheHandle:
2420 runtime = _make_hisparse_runtime(
2421 top_k=top_k,
2422 max_num_reqs=num_reqs,
2423 device_buffer_size=buf,
2424 row_width=row_width,
2425 block_size=block_size,
2426 max_swap_rows=2 * num_reqs,
2427 index_group=index_group,
2428 )
2429 runtime.bind_source_cache(kv_pool)
2430 cache = HiSparseCacheHandle(runtime)
2431 cache.all_context_pages_resident = False
2432 return cache
2433
2434 blocks_per_req = num_blocks // num_reqs
2435 block_table = torch.arange(num_blocks, dtype=torch.int32, device=device).view(
2436 num_reqs, blocks_per_req
2437 )
2438 req_ids = torch.arange(num_reqs, dtype=torch.int32, device=device)
2439 seq_len = blocks_per_req * block_size
2440 producer = make()
2441 shared = make(producer.runtime.index_group)
2442 indep = make()
2443 base = torch.arange(top_k, dtype=torch.int32, device=device)
2444 for iteration in range(2):
2445 producer.runtime.begin_forward()
2446 shared.runtime.begin_forward()
2447 indep.runtime.begin_forward()
2448 topks = [
2449 torch.stack(
2450 [
2451 (base + (iteration * 2 + step) * top_k + row * 17) % seq_len
2452 for row in range(num_reqs)
2453 ]
2454 )
2455 for step in range(2)
2456 ]
2457 kw = dict(
2458 req_id_per_token=req_ids,
2459 block_table=block_table,
2460 block_size=block_size,
2461 num_valid_rows=_valid_rows(req_ids.numel()),
2462 )
2463 producer_indices = [
2464 producer.swap_in(logical_topk_indices=topk.clone(), **kw) for topk in topks
2465 ]
2466 independent_indices = [
2467 indep.swap_in(logical_topk_indices=topk.clone(), **kw) for topk in topks
2468 ]
2469 follower_indices = [
2470 shared.swap_in(logical_topk_indices=topk.clone(), **kw) for topk in topks
2471 ]
2472 torch.accelerator.synchronize()
2473
2474 for idx_full, idx_indep, idx_shared in zip(
2475 producer_indices, independent_indices, follower_indices
2476 ):
2477 torch.testing.assert_close(idx_shared, idx_full, rtol=0, atol=0)
2478 torch.testing.assert_close(idx_indep, idx_full, rtol=0, atol=0)
2479 torch.testing.assert_close(shared.runtime.hot.cache, indep.runtime.hot.cache)
2480
2481 shared_rows = shared.runtime.hot.attention_cache.reshape(-1, row_width)
2482 host_rows = kv_pool.reshape(-1, row_width)
2483 for physical_rows, logical_rows in zip(follower_indices, topks):
2484 global_rows = _triton_convert_reference_impl(
2485 req_ids, block_table, logical_rows, block_size, top_k
2486 )
2487 valid = physical_rows >= 0
2488 torch.testing.assert_close(
2489 shared_rows[physical_rows[valid].to(torch.long)].cpu(),
2490 host_rows[global_rows[valid].cpu().to(torch.long)],
2491 )
2492
2493
2494 @requires_hisparse_ops
2495 def test_hisparse_multi_step_writes_request_major_output():
2496 device = torch.device(DEVICE_TYPE)
2497 block_size = 64
2498 row_width = 8
2499 num_reqs = 2
2500 query_len = 3
2501 top_k = 4
2502 cache = _make_hisparse_cache_handle(
2503 top_k=top_k,
2504 device_buffer_size=query_len * top_k,
2505 max_num_reqs=num_reqs,
2506 row_width=row_width,
2507 block_size=block_size,
2508 max_swap_rows=num_reqs * query_len,
2509 )
2510 host = torch.arange(
2511 (num_reqs + 1) * block_size * row_width, dtype=torch.float32
2512 ).view(num_reqs + 1, block_size, row_width)
2513 cache.runtime.bind_source_cache(host.pin_memory())
2514 block_table = torch.arange(1, num_reqs + 1, dtype=torch.int32, device=device).view(
2515 -1, 1
2516 )
2517 request_ids = torch.arange(num_reqs, dtype=torch.int32, device=device)
2518 logical = torch.tensor(
2519 [
2520 [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]],
2521 [[12, 13, 14, 15], [16, 17, 18, 19], [20, 21, 22, 23]],
2522 ],
2523 dtype=torch.int32,
2524 device=device,
2525 )
2526 physical = torch.full_like(logical, -1)
2527 valid_counts = torch.zeros((num_reqs, query_len), dtype=torch.int32, device=device)
2528
2529 cache.runtime.begin_forward()
2530 for step in range(query_len):
2531 cache.swap_in(
2532 request_ids,
2533 block_table,
2534 logical[:, step],
2535 block_size=block_size,
2536 num_valid_rows=_valid_rows(request_ids.numel()),
2537 return_valid_counts=True,
2538 attention_indices_out=physical[:, step],
2539 valid_counts_out=valid_counts[:, step],
2540 )
2541 torch.accelerator.synchronize()
2542
2543 torch.testing.assert_close(
2544 valid_counts,
2545 torch.full_like(valid_counts, top_k),
2546 )
2547 global_rows = _triton_convert_reference_impl(
2548 torch.arange(num_reqs, device=device, dtype=torch.int32).repeat_interleave(
2549 query_len
2550 ),
2551 block_table,
2552 logical.view(num_reqs * query_len, top_k),
2553 block_size,
2554 top_k,
2555 )
2556 hot_rows = cache.runtime.hot.attention_cache.reshape(-1, row_width)
2557 host_rows = host.reshape(-1, row_width)
2558 torch.testing.assert_close(
2559 hot_rows[physical.view(num_reqs * query_len, top_k).to(torch.long)].cpu(),
2560 host_rows[global_rows.cpu().to(torch.long)],
2561 )
2562
2563
2564 @requires_hisparse_ops
2565 def test_hisparse_kv_update_writes_resident_and_staging_caches():
2566 device = torch.device(DEVICE_TYPE)
2567 block_size = 4
2568 row_width = 8
2569 cache_handle = _make_hisparse_cache_handle(
2570 max_num_reqs=1,
2571 row_width=row_width,
2572 block_size=block_size,
2573 )
2574 resident_slots = torch.tensor([4, 5, -1], dtype=torch.int64, device=device)
2575 cache_handle.bind_cache(
2576 cache_handle.runtime.hot.cache.view(torch.int8),
2577 byte_offset=0,
2578 block_stride=block_size * row_width * torch.float32.itemsize,
2579 num_blocks=cache_handle.runtime.hot.cache.shape[0],
2580 block_size=block_size,
2581 block_table=torch.tensor([[1]], dtype=torch.int32, device=device),
2582 slot_mapping=resident_slots,
2583 )
2584 cache_handle.mirror_staging_cache = torch.empty(
2585 (1, block_size, row_width), dtype=torch.float32, device=device
2586 )
2587 cache_handle.mirror_staging_slots = torch.arange(
2588 block_size, dtype=torch.int64, device=device
2589 )
2590 slots = torch.tensor([3, 7, -1], dtype=torch.int64, device=device)
2591 kv_c = torch.randn(8, row_width - 2, device=device)
2592 k_pe = torch.randn(8, 1, 2, device=device)
2593 cache_handle.num_actual_tokens = slots.numel()
2594 cache_handle.decode_batch = False
2595 cache_handle.host_mirror_required = True
2596 source_cache = torch.zeros_like(cache_handle.view.cache)
2597 impl = object.__new__(FlashMLASparseImpl)
2598 layer = SimpleNamespace(
2599 hisparse_cache=cache_handle,
2600 use_pcp=False,
2601 pcp_shard_decode_requests=False,
2602 impl=impl,
2603 )
2604 mla_attention.MLAAttention.update_kv_cache(
2605 layer,
2606 kv_c,
2607 k_pe,
2608 source_cache,
2609 slots,
2610 SimpleNamespace(num_decode_tokens=0),
2611 "auto",
2612 torch.tensor(1.0, device=device),
2613 )
2614 torch.accelerator.synchronize()
2615
2616 expected = torch.cat([kv_c[:2], k_pe[:2, 0]], dim=-1).cpu()
2617 torch.testing.assert_close(
2618 cache_handle.view.cache.view(-1, row_width)[resident_slots[:2]],
2619 expected.to(device),
2620 )
2621 torch.testing.assert_close(source_cache, torch.zeros_like(source_cache))
2622 staged = cache_handle.mirror_staging_cache.view(-1, row_width)
2623 staged_expected = torch.cat([kv_c[:3], k_pe[:3, 0]], dim=-1)
2624 torch.testing.assert_close(staged[:3], staged_expected)
2625
2626
2627 @requires_hisparse_ops
2628 def test_hisparse_row_dma_copies_discontiguous_spans_across_layers():
2629 device = torch.device(DEVICE_TYPE)
2630 block_size = 4
2631 row_width = 8
2632 num_rows = 16
2633 resident_caches = tuple(
2634 (
2635 torch.arange(num_rows * row_width, dtype=torch.uint8, device=device)
2636 .add_(layer_index * 17)
2637 .view(num_rows // block_size, block_size, row_width)
2638 )
2639 for layer_index in range(2)
2640 )
2641 host_caches = tuple(
2642 torch.zeros((num_rows, row_width), dtype=torch.uint8, pin_memory=True)
2643 for _ in resident_caches
2644 )
2645 worker = object.__new__(HiSparseConnectorWorker)
2646 worker.is_host_writer = True
2647 worker.kernel_block_size = block_size
2648 worker.resident_caches = resident_caches
2649 worker.host_caches = host_caches
2650 worker.cache_handles = [
2651 SimpleNamespace(
2652 decode_batch=True,
2653 runtime=SimpleNamespace(resident_source_index=0),
2654 )
2655 for _ in resident_caches
2656 ]
2657 worker.dma_stream = torch.cuda.Stream(device=device)
2658 worker.host_write_event = torch.Event()
2659 worker._dma_free_descriptors = []
2660 worker._pending_dma_descriptors = deque()
2661 worker._pending_transfer_events = deque()
2662 worker._enqueued_transfer_ids = []
2663 worker._dma_submitted = False
2664 worker._set_row_mirrors(
2665 (
2666 SparseKVRowMirror((1,), 5, 2),
2667 SparseKVRowMirror((8,), 10, 4),
2668 )
2669 )
2670
2671 worker._enqueue_row_dma(range(2))
2672 current_stream().wait_event(worker.host_write_event)
2673 torch.accelerator.synchronize()
2674
2675 for resident, host in zip(resident_caches, host_caches):
2676 flat_resident = resident.view(num_rows, row_width).cpu()
2677 torch.testing.assert_close(host[5:7], flat_resident[1:3])
2678 torch.testing.assert_close(host[10:14], flat_resident[8:12])
2679
2680
2681 @requires_hisparse_ops
2682 def test_hisparse_remaps_strided_hma_rows_for_attention():
2683 device = torch.device(DEVICE_TYPE)
2684 block_size, row_width = 4, 8
2685 runtime = _make_hisparse_runtime(
2686 max_num_reqs=1,
2687 block_size=block_size,
2688 row_width=row_width,
2689 )
2690 page_elements = block_size * row_width
2691 stride_elements = 3 * page_elements
2692 byte_offset = page_elements * torch.float32.itemsize
2693 raw = torch.zeros(2 * stride_elements, dtype=torch.float32, device=device).view(
2694 torch.int8
2695 )
2696 runtime.bind_hot_cache(
2697 raw,
2698 byte_offset=byte_offset,
2699 block_stride=stride_elements * torch.float32.itemsize,
2700 num_blocks=2,
2701 block_size=block_size,
2702 block_table=torch.tensor([[0, 1]], dtype=torch.int32, device=device),
2703 )
2704
2705 source = (
2706 torch.arange(36 * row_width, dtype=torch.float32)
2707 .view(9, block_size, row_width)
2708 .pin_memory()
2709 )
2710 runtime.bind_source_cache(source)
2711 cache = HiSparseCacheHandle(runtime)
2712 cache.all_context_pages_resident = False
2713 runtime.begin_forward()
2714 attention_indices = cache.swap_in(
2715 req_id_per_token=torch.tensor([0], dtype=torch.int32, device=device),
2716 block_table=torch.arange(1, 9, dtype=torch.int32, device=device).view(1, 8),
2717 logical_topk_indices=torch.tensor(
2718 [[0, 1, 2, 3]], dtype=torch.int32, device=device
2719 ),
2720 block_size=block_size,
2721 num_valid_rows=_valid_rows(1),
2722 )
2723
2724 assert runtime.hot.attention_cache is not None
2725 assert runtime.hot.attention_cache.is_contiguous()
2726 physical_indices = runtime.index_group.shared_topk.device_topk_rows[0]
2727 expected = (
2728 physical_indices // block_size * (stride_elements // row_width)
2729 + physical_indices % block_size
2730 )
2731 torch.testing.assert_close(attention_indices[0], expected)
2732
2733
2734 @requires_hisparse_ops
2735 def test_hisparse_gather_zeroes_unaligned_destination():
2736 """Invalid host rows are safely zeroed even at a byte-unaligned address."""
2737 device = torch.device(DEVICE_TYPE)
2738 row_bytes = 16
2739 host_cache = torch.ones(1, row_bytes, dtype=torch.uint8).pin_memory()
2740 storage = torch.full((2 * row_bytes + 1,), 7, dtype=torch.uint8, device=device)
2741 hot_cache = torch.as_strided(
2742 storage,
2743 size=(2, 1, row_bytes),
2744 stride=(row_bytes + 1, row_bytes, 1),
2745 )
2746 global_indices = torch.tensor([[1]], dtype=torch.int32, device=device)
2747 hot_indices = torch.tensor([[1]], dtype=torch.int32, device=device)
2748 miss_mask = torch.ones_like(global_indices)
2749
2750 torch.ops._C_cache_ops.hisparse_gather_plan(
2751 host_cache,
2752 hot_cache,
2753 global_indices,
2754 hot_indices,
2755 miss_mask,
2756 None,
2757 )
2758 torch.accelerator.synchronize()
2759
2760 torch.testing.assert_close(hot_cache[1, 0], torch.zeros_like(hot_cache[1, 0]))
2761 assert storage[row_bytes].item() == 7
2762
2763
2764 @requires_hisparse_ops
2765 def test_hisparse_gather_rejects_short_attention_block_stride():
2766 """Reject a stride that would let the gather kernel cross block bounds."""
2767 device = torch.device(DEVICE_TYPE)
2768 host_cache = torch.ones(1, 16, dtype=torch.uint8).pin_memory()
2769 hot_cache = torch.empty((1, 2, 16), dtype=torch.uint8, device=device)
2770 global_indices = torch.zeros((1, 1), dtype=torch.int32, device=device)
2771 hot_indices = torch.zeros_like(global_indices)
2772 miss_mask = torch.ones_like(global_indices)
2773 attention_indices = torch.empty_like(global_indices)
2774
2775 with pytest.raises(RuntimeError, match="stride must cover one hot block"):
2776 torch.ops._C_cache_ops.hisparse_gather_plan(
2777 host_cache,
2778 hot_cache,
2779 global_indices,
2780 hot_indices,
2781 miss_mask,
2782 None,
2783 attention_indices,
2784 1,
2785 )
2786
2787
2788 @requires_hisparse_ops
2789 def test_hisparse_newest_write_and_recycled_slot_invalidation():
2790 """Newest writes clamp padding and invalidated rows are loaded again."""
2791 device = torch.device(DEVICE_TYPE)
2792 block_size = 4
2793 row_width = 8
2794 num_blocks = 8
2795
2796 kv_pool = (
2797 torch.arange(num_blocks * block_size * row_width, dtype=torch.float32)
2798 .view(num_blocks, block_size, row_width)
2799 .pin_memory()
2800 )
2801 flat_pool = kv_pool.reshape(-1, row_width)
2802
2803 cache_handle = _make_hisparse_cache_handle(block_size=block_size)
2804 cache_handle.runtime.eager_host_mirror = True
2805
2806 block_table = torch.tensor([[2, 0, 4]], dtype=torch.int32, device=device)
2807 resident_slots = torch.tensor([4, -1], dtype=torch.int64, device=device)
2808 cache_handle.bind_cache(
2809 cache_handle.runtime.hot.cache.view(torch.int8),
2810 byte_offset=0,
2811 block_stride=block_size * row_width * torch.float32.itemsize,
2812 num_blocks=cache_handle.runtime.hot.cache.shape[0],
2813 block_size=block_size,
2814 block_table=torch.tensor([[0, 0, 1]], dtype=torch.int32, device=device),
2815 slot_mapping=resident_slots,
2816 )
2817 req_ids = torch.tensor([0], dtype=torch.int32, device=device)
2818 newest_global = 4 * block_size
2819 padded_global = 1
2820 padded_row = flat_pool[padded_global].clone()
2821 slot_mapping = torch.tensor(
2822 [newest_global, padded_global], dtype=torch.int64, device=device
2823 )
2824 request_state_indices = torch.tensor([0, -1], dtype=torch.int32, device=device)
2825 cache_handle.runtime.request_state_indices = request_state_indices
2826
2827 kv_c = torch.randn(3, row_width - 2, device=device)
2828 k_pe = torch.randn(3, 1, 2, device=device)
2829 cache_handle.runtime.bind_source_cache(kv_pool)
2830 cache_handle.num_actual_tokens = slot_mapping.numel()
2831 ops.concat_and_cache_mla(
2832 kv_c[:1],
2833 k_pe[:1, 0],
2834 cache_handle.view.cache,
2835 resident_slots[:1],
2836 kv_cache_dtype="auto",
2837 scale=torch.tensor(1.0, device=device),
2838 )
2839 torch.accelerator.synchronize()
2840 expected_row = torch.cat([kv_c[0], k_pe[0, 0]]).cpu()
2841 flat_pool[newest_global].copy_(expected_row)
2842 torch.testing.assert_close(flat_pool[newest_global], expected_row)
2843 torch.testing.assert_close(flat_pool[padded_global], padded_row)
2844 flat_hot = cache_handle.runtime.hot.cache.view(-1, row_width)
2845 torch.testing.assert_close(flat_hot[resident_slots[0]].cpu(), expected_row)
2846
2847 topk = torch.tensor([[0, -1, -1, -1]], dtype=torch.int32, device=device)
2848 cache_handle.all_context_pages_resident = False
2849 cache_handle.runtime.begin_forward()
2850 hot_indices = cache_handle.swap_in(
2851 req_id_per_token=req_ids,
2852 block_table=block_table,
2853 logical_topk_indices=topk,
2854 block_size=block_size,
2855 num_valid_rows=_valid_rows(req_ids.numel()),
2856 )
2857 torch.accelerator.synchronize()
2858 stale_hot_slot = hot_indices[0, 0].item()
2859 stale_row = flat_hot[stale_hot_slot].clone()
2860
2861 flat_pool[8] += 1000
2862 cache_handle.runtime.invalidate_written_slots(
2863 torch.tensor([8], dtype=torch.int64, device=device), req_ids
2864 )
2865 cache_handle.runtime.begin_forward()
2866 hot_indices = cache_handle.swap_in(
2867 req_id_per_token=req_ids,
2868 block_table=block_table,
2869 logical_topk_indices=topk,
2870 block_size=block_size,
2871 num_valid_rows=_valid_rows(req_ids.numel()),
2872 )
2873 torch.accelerator.synchronize()
2874 idx = hot_indices.cpu().tolist()[0][0]
2875 assert not torch.equal(flat_hot[idx], stale_row)
2876 torch.testing.assert_close(flat_hot[idx].cpu(), flat_pool[8])
2877
2878
2879 @requires_hisparse_ops
2880 def test_hisparse_mixed_batch_bf16_row_split(
2881 default_vllm_config, dist_init, workspace_init
2882 ):
2883 """Host-resident mixed batch on the bf16 path is row-split.
2884
2885 Two long-context speculative-decode requests + one short local-prefill
2886 chunk: every decode step must be served from the bounded hot buffer before
2887 it is reused, while only the prefill rows' blocks are staged host->GPU.
2888 """
2889 ok, reason = flashmla.is_flashmla_sparse_supported()
2890 if not ok:
2891 pytest.skip(reason)
2892
2893 device = torch.device(DEVICE_TYPE)
2894 dtype = torch.bfloat16
2895 torch.manual_seed(0)
2896
2897 num_heads = 64
2898 kv_lora_rank = 512
2899 qk_nope_head_dim = 128
2900 qk_rope_head_dim = 64
2901 v_head_dim = 128
2902 head_size = kv_lora_rank + qk_rope_head_dim
2903 topk_tokens = 128
2904 block_size = 64
2905
2906 # Long decode contexts + a short prefill chunk (router shortcut shape).
2907 batch_spec = BatchSpec(seq_lens=[2048, 2048, 192], query_lens=[2, 2, 64])
2908 max_seqlen = max(batch_spec.seq_lens)
2909 total_cache_tokens = sum(batch_spec.seq_lens)
2910 total_tokens = batch_spec.compute_num_tokens()
2911
2912 vllm_config = create_vllm_config(
2913 model_name="deepseek-ai/DeepSeek-V2-Lite-Chat",
2914 tensor_parallel_size=1,
2915 max_model_len=max_seqlen,
2916 num_gpu_blocks=max(2048, cdiv(total_cache_tokens, block_size) + 1),
2917 block_size=block_size,
2918 hf_config_override={
2919 "index_topk": topk_tokens,
2920 "attn_module_list_cfg": [{"topk_tokens": topk_tokens}],
2921 },
2922 )
2923 vllm_config.attention_config.hisparse_config = HiSparseConfig(
2924 device_buffer_size=2 * topk_tokens,
2925 )
2926 vllm_config.speculative_config = SpeculativeConfig(
2927 method="ngram", num_speculative_tokens=1
2928 )
2929 model_config = vllm_config.model_config
2930 model_config.hf_text_config = SimpleNamespace(
2931 index_topk=topk_tokens,
2932 q_lora_rank=None,
2933 kv_lora_rank=kv_lora_rank,
2934 qk_nope_head_dim=qk_nope_head_dim,
2935 qk_rope_head_dim=qk_rope_head_dim,
2936 v_head_dim=v_head_dim,
2937 model_type="deepseek_v2",
2938 )
2939 model_config.dtype = dtype
2940 model_config.get_num_attention_heads = MethodType(
2941 lambda self, parallel_config: num_heads, model_config
2942 )
2943 model_config.get_num_kv_heads = MethodType(
2944 lambda self, parallel_config: 1, model_config
2945 )
2946 model_config.get_head_size = MethodType(lambda self: head_size, model_config)
2947 model_config.get_sliding_window = MethodType(lambda self: None, model_config)
2948 # create_hisparse_cache_handle sizes hot buffers per layer.
2949 model_config.get_num_layers = MethodType(
2950 lambda self, parallel_config: 1, model_config
2951 )
2952
2953 kv_cache_spec = create_standard_kv_cache_spec(vllm_config)
2954 common_attn_metadata = create_common_attn_metadata(
2955 batch_spec,
2956 vllm_config.cache_config.block_size,
2957 device,
2958 arange_block_indices=True,
2959 )
2960 # Prepopulate every position of every sequence so the forward needs no
2961 # KV-cache update (the row split itself is what is under test).
2962 kv_c_contexts = [
2963 torch.rand(s_len, kv_lora_rank, dtype=dtype, device=device)
2964 for s_len in batch_spec.seq_lens
2965 ]
2966 k_pe_contexts = [
2967 torch.rand(s_len, 1, qk_rope_head_dim, dtype=dtype, device=device)
2968 for s_len in batch_spec.seq_lens
2969 ]
2970 kv_cache = create_and_prepopulate_kv_cache(
2971 kv_c_contexts=kv_c_contexts,
2972 k_pe_contexts=k_pe_contexts,
2973 block_size=vllm_config.cache_config.block_size,
2974 head_size=head_size,
2975 dtype=dtype,
2976 device=device,
2977 num_blocks=vllm_config.cache_config.num_gpu_blocks,
2978 common_attn_metadata=common_attn_metadata,
2979 randomize_blocks=False,
2980 kv_cache_dtype="auto",
2981 )
2982
2983 prefill_backend = get_mla_prefill_backend(vllm_config)(
2984 num_heads=num_heads,
2985 scale=1.0 / math.sqrt(head_size),
2986 kv_lora_rank=kv_lora_rank,
2987 qk_nope_head_dim=qk_nope_head_dim,
2988 qk_rope_head_dim=qk_rope_head_dim,
2989 v_head_dim=v_head_dim,
2990 vllm_config=vllm_config,
2991 )
2992 vllm_config.compilation_config.static_forward_context["placeholder"] = (
2993 SimpleNamespace(prefill_backend=prefill_backend)
2994 )
2995
2996 builder_cls = FlashMLASparseBackend.get_builder_cls()
2997 builder = builder_cls(kv_cache_spec, ["placeholder"], vllm_config, device)
2998 metadata = builder.build(
2999 common_prefix_len=0, common_attn_metadata=common_attn_metadata
3000 )
3001 assert isinstance(metadata.prefill, SparseMLAPrefillMetadata)
3002
3003 # Per-token sparse indices bounded by each token's position, with unique
3004 # offsets and -1 padding (same construction as the decode parity test).
3005 positions: list[int] = []
3006 for i in range(batch_spec.batch_size):
3007 ctx_len = batch_spec.seq_lens[i] - batch_spec.query_lens[i]
3008 positions.extend(ctx_len + q_idx for q_idx in range(batch_spec.query_lens[i]))
3009 sparse_indices = torch.empty(
3010 total_tokens, topk_tokens, dtype=torch.int32, device=device
3011 )
3012 for tok_idx in range(total_tokens):
3013 max_valid_idx = positions[tok_idx]
3014 offset = tok_idx * 7 # Prime number for varied offsets
3015 num_valid = min(topk_tokens // 2, max_valid_idx + 1)
3016 valid_range = torch.arange(num_valid, device=device, dtype=torch.int32)
3017 sparse_indices[tok_idx] = torch.cat(
3018 [
3019 (valid_range + offset) % (max_valid_idx + 1),
3020 torch.full(
3021 (topk_tokens - num_valid,), -1, device=device, dtype=torch.int32
3022 ),
3023 ]
3024 )
3025
3026 q = torch.rand(total_tokens, num_heads, head_size, dtype=dtype, device=device)
3027 mock_indexer = SimpleNamespace(
3028 topk_indices_buffer=sparse_indices, topk_tokens=topk_tokens
3029 )
3030
3031 impl_cls = FlashMLASparseBackend.get_impl_cls()
3032 with set_current_vllm_config(vllm_config):
3033 impl = impl_cls(
3034 num_heads=num_heads,
3035 head_size=head_size,
3036 scale=1.0 / math.sqrt(head_size),
3037 num_kv_heads=1,
3038 alibi_slopes=None,
3039 sliding_window=None,
3040 kv_cache_dtype="auto",
3041 logits_soft_cap=None,
3042 attn_type="decoder",
3043 kv_sharing_target_layer_name=None,
3044 q_lora_rank=None,
3045 kv_lora_rank=kv_lora_rank,
3046 qk_nope_head_dim=qk_nope_head_dim,
3047 qk_rope_head_dim=qk_rope_head_dim,
3048 qk_head_dim=qk_nope_head_dim + qk_rope_head_dim,
3049 v_head_dim=v_head_dim,
3050 kv_b_proj=None,
3051 indexer=mock_indexer,
3052 )
3053 assert isinstance(impl.index_group, HiSparseMLAIndexGroup)
3054 cache_handle = impl.index_group.cache(impl.index_group_index)
3055 blocks_per_request = cdiv(cache_handle.runtime.region_stride, block_size)
3056 num_hot_blocks = cache_handle.runtime.max_num_reqs * blocks_per_request
3057 hot_cache = torch.zeros(
3058 num_hot_blocks * block_size * head_size,
3059 dtype=dtype,
3060 device=device,
3061 ).view(torch.int8)
3062 cache_handle.runtime.bind_hot_cache(
3063 hot_cache,
3064 byte_offset=0,
3065 block_stride=block_size * head_size * dtype.itemsize,
3066 num_blocks=num_hot_blocks,
3067 block_size=block_size,
3068 block_table=torch.arange(num_hot_blocks, dtype=torch.int32, device=device).view(
3069 cache_handle.runtime.max_num_reqs, blocks_per_request
3070 ),
3071 )
3072 cache_handle.runtime.request_state_indices = torch.arange(
3073 cache_handle.runtime.max_num_reqs, dtype=torch.int32, device=device
3074 )
3075 impl.prepare_for_batch(metadata)
3076
3077 # Device-resident reference: the whole batch converted against the full
3078 # block table and run as one kernel call over the GPU cache.
3079 ref_topk, ref_topk_length = triton_convert_req_index_to_global_index(
3080 metadata.req_id_per_token,
3081 metadata.block_table,
3082 sparse_indices,
3083 BLOCK_SIZE=metadata.block_size,
3084 NUM_TOPK_TOKENS=topk_tokens,
3085 return_valid_counts=True,
3086 )
3087 reference, _ = impl._bf16_flash_mla_kernel(q, kv_cache, ref_topk, ref_topk_length)
3088
3089 # Host-resident pool with identical contents.
3090 kv_pool = kv_cache.squeeze(1).cpu().pin_memory()
3091 cache_handle.runtime.bind_source_cache(kv_pool)
3092 cache_handle.source_block_table = metadata.block_table
3093
3094 staging_calls = []
3095 original_gather = cache_handle.runtime.gather_prefill_cache
3096
3097 def spy_gather(self, kv, plan, resident_cache=None):
3098 staged = original_gather(kv, plan, resident_cache)
3099 staging_calls.append((plan, staged.shape))
3100 return staged
3101
3102 cache_handle.runtime.gather_prefill_cache = MethodType(
3103 spy_gather, cache_handle.runtime
3104 )
3105
3106 backend_output, _ = impl._forward_bf16_kv(
3107 q, kv_pool, sparse_indices, metadata, q.shape[1]
3108 )
3109 torch.accelerator.synchronize()
3110
3111 # Only the prefill rows' blocks were staged: the decode rows' 2048-token
3112 # contexts (32 blocks each) must stay off the staging gather.
3113 assert len(staging_calls) == 1
3114 plan, staged_shape = staging_calls[0]
3115 assert plan is metadata.prefill.host_staging_plan
3116 prefill_blocks = cdiv(batch_spec.seq_lens[-1], block_size)
3117 assert staged_shape[0] <= prefill_blocks + 1 # +1: block-0 tail padding
3118
3119 torch.testing.assert_close(backend_output, reference, rtol=0.01, atol=0.01)
3120
3121
3122 def test_hisparse_prefill_staging_remap():
3123 """Compacted staging references the same host rows as direct indexing."""
3124 block_size = 4
3125 block_table = torch.tensor(
3126 [[5, 2, -1, -1], [2, 7, 3, -1], [9, -1, -1, -1]], dtype=torch.int32
3127 )
3128
3129 new_bt, row_ids = hisparse_prefill_staging_remap(block_table, block_size)
3130
3131 assert new_bt.shape == block_table.shape
3132 assert row_ids.dtype == torch.int32 and new_bt.dtype == torch.int32
3133 n_unique = len({0, 2, 3, 5, 7, 9}) # -1 clamps to block 0
3134 flat_rows = row_ids.flatten()
3135 assert (flat_rows >= 0).sum() == n_unique * block_size
3136 for i in range(block_table.shape[0]):
3137 for j in range(block_table.shape[1]):
3138 orig = max(int(block_table[i, j]), 0)
3139 staged = int(new_bt[i, j])
3140 for k in range(block_size):
3141 assert int(flat_rows[staged * block_size + k]) == orig * block_size + k
3142
3143
3144 def test_hisparse_prefill_staging_plan_masks_unused_blocks():
3145 block_table = torch.tensor([[5, 2, 7], [9, 3, 4]], dtype=torch.int32)
3146 plan = build_hisparse_prefill_staging_plan(
3147 block_table,
3148 seq_lens=torch.tensor([5, 8], dtype=torch.int32),
3149 block_size=4,
3150 staging_block_capacity=4,
3151 )
3152
3153 assert (plan.block_table[:, 2] == 0).all()
3154 valid_rows = plan.row_ids[plan.row_ids >= 0]
3155 assert set((valid_rows[::4] // 4).tolist()) == {0, 2, 3, 5, 9}
3156 for row, used in enumerate((2, 2)):
3157 for column in range(used):
3158 staged_block = int(plan.block_table[row, column])
3159 host_block = int(block_table[row, column])
3160 assert int(valid_rows[staged_block * 4]) == host_block * 4
3161
3162
3163 def test_hisparse_prefill_staging_plan_resolves_resident_sources():
3164 """Per-page resident hits become device sources; misses stay host DMAs."""
3165 block_size = 4
3166 resident_block_size = 2
3167 block_table = torch.tensor([[5, 2, 0], [9, 3, 0]], dtype=torch.int32)
3168 seq_lens = torch.tensor([5, 8], dtype=torch.int32)
3169 plan = build_hisparse_prefill_staging_plan(
3170 block_table, seq_lens, block_size, staging_block_capacity=4
3171 )
3172 # Two resident pages per host block; 0 entries are null (not resident).
3173 resident_table = torch.tensor(
3174 [[11, 12, 0, 13, 0, 0], [21, 0, 22, 23, 0, 0]], dtype=torch.int32
3175 )
3176
3177 plan.ensure_gpu_sources(resident_table, resident_block_size)
3178
3179 assert plan.gpu_row_ids is not None
3180 unique_hosts = (plan.row_ids[0].view(-1, block_size)[:, 0] // block_size).tolist()
3181 gpu_rows = plan.gpu_row_ids[0].view(-1, block_size)
3182 reps = {5: (0, 0), 2: (0, 1), 9: (1, 0), 3: (1, 1)}
3183 for u, host_id in enumerate(unique_hosts):
3184 for t in range(block_size):
3185 if host_id < 0:
3186 assert int(gpu_rows[u, t]) == -1
3187 continue
3188 if host_id == 0:
3189 assert int(gpu_rows[u, t]) == -1
3190 continue
3191 row, col = reps[host_id]
3192 page = t // resident_block_size
3193 res_block = int(resident_table[row, col * 2 + page])
3194 expected = (
3195 res_block * resident_block_size + t % resident_block_size
3196 if res_block > 0
3197 else -1
3198 )
3199 assert int(gpu_rows[u, t]) == expected
3200 valid_rows = plan.row_ids >= 0
3201 torch.testing.assert_close(
3202 plan.miss_mask,
3203 ((plan.gpu_row_ids < 0) & valid_rows).int(),
3204 check_dtype=False,
3205 )
3206
3207 plan.ensure_gpu_sources(torch.zeros_like(resident_table), resident_block_size)
3208
3209 assert plan.gpu_row_ids is not None
3210 assert (plan.gpu_row_ids == -1).all()
3211
3212
3213 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
3214 def test_hisparse_gather_prefill_cache_prefers_resident_rows():
3215 """Staged rows come from the resident cache when a shadow page exists."""
3216 if not _has_hisparse_ops():
3217 pytest.skip("hisparse CUDA ops unavailable")
3218 device = torch.device("cuda")
3219 block_size, resident_block_size, row_width = 4, 2, 16
3220 block_table = torch.tensor([[5, 2, 0], [9, 3, 0]], dtype=torch.int32, device=device)
3221 seq_lens = torch.tensor([5, 8], dtype=torch.int32, device=device)
3222 plan = build_hisparse_prefill_staging_plan(
3223 block_table, seq_lens, block_size, staging_block_capacity=4
3224 )
3225 resident_table = torch.tensor(
3226 [[11, 12, 0, 13, 0, 0], [21, 0, 22, 23, 0, 0]],
3227 dtype=torch.int32,
3228 device=device,
3229 )
3230 plan.ensure_gpu_sources(resident_table, resident_block_size)
3231
3232 num_host_blocks, num_res_blocks = 10, 24
3233 host_cache = (
3234 torch.arange(num_host_blocks * block_size * row_width, dtype=torch.float32)
3235 .view(num_host_blocks, block_size, row_width)
3236 .pin_memory()
3237 )
3238 resident_cache = (
3239 (
3240 -torch.arange(
3241 num_res_blocks * resident_block_size * row_width, dtype=torch.float32
3242 )
3243 - 1.0
3244 )
3245 .view(num_res_blocks, resident_block_size, row_width)
3246 .to(device)
3247 )
3248
3249 staged = HiSparseRuntime.gather_prefill_cache(
3250 None, host_cache, plan, resident_cache=resident_cache
3251 )
3252
3253 staged_flat = staged.view(-1, row_width).cpu()
3254 host_flat = host_cache.view(-1, row_width)
3255 resident_flat = resident_cache.reshape(-1, row_width).cpu()
3256 gpu_rows = plan.gpu_row_ids[0].cpu()
3257 host_rows = plan.row_ids[0].cpu()
3258 for i in range(staged_flat.shape[0]):
3259 if int(host_rows[i]) < 0:
3260 assert int(plan.miss_mask[0, i]) == 0
3261 continue
3262 expected = (
3263 resident_flat[int(gpu_rows[i])]
3264 if int(gpu_rows[i]) >= 0
3265 else host_flat[int(host_rows[i])]
3266 )
3267 torch.testing.assert_close(staged_flat[i], expected)
3268
3269
3270 def test_hisparse_fp8_decode_resolves_rows_once_then_runs_batched_attention():
3271 """Verification rows resolve in one swap_in; FP8 attention sees them batched."""
3272 device = torch.device("cpu")
3273 num_decodes = 2
3274 query_len = 3
3275 num_tokens = num_decodes * query_len
3276 q = torch.randn(num_tokens, 2, 4, device=device)
3277 topk = (
3278 torch.arange(num_tokens, dtype=torch.int32, device=device)
3279 .view(-1, 1)
3280 .expand(-1, 4)
3281 )
3282 kernel_shapes: list[torch.Size] = []
3283
3284 def swap_in(req_ids, *, logical_topk_indices, **kwargs): # noqa: ARG001
3285 return logical_topk_indices + 10
3286
3287 def run_kernel(self, *, q, **kwargs): # noqa: ARG001
3288 kernel_shapes.append(q.shape)
3289 return q[..., :1], None
3290
3291 cache = SimpleNamespace(
3292 runtime=SimpleNamespace(
3293 hot=SimpleNamespace(attention_cache=torch.empty(1, device=device))
3294 ),
3295 source_block_table=torch.empty(
3296 num_decodes, 1, dtype=torch.int32, device=device
3297 ),
3298 swap_in=MagicMock(side_effect=swap_in),
3299 )
3300 index_group = object.__new__(HiSparseMLAIndexGroup)
3301 index_group.caches = [cache]
3302 index_group.physical_topk_indices = torch.empty(
3303 (num_tokens + 1, 4), dtype=torch.int32, device=device
3304 )
3305 impl = SimpleNamespace(
3306 kv_lora_rank=1,
3307 index_group=index_group,
3308 index_group_index=0,
3309 )
3310 impl._fp8_flash_mla_kernel = MethodType(run_kernel, impl)
3311 metadata = SimpleNamespace(
3312 query_start_loc=torch.arange(
3313 0, num_tokens + 1, query_len, dtype=torch.int32, device=device
3314 ),
3315 req_id_per_token=torch.arange(
3316 num_decodes, dtype=torch.int32, device=device
3317 ).repeat_interleave(query_len),
3318 block_size=64,
3319 )
3320
3321 output = FlashMLASparseImpl._host_backed_fp8_decode(
3322 impl, q, topk, metadata, SimpleNamespace(), num_decodes
3323 )
3324
3325 cache.swap_in.assert_called_once()
3326 torch.testing.assert_close(
3327 cache.swap_in.call_args.args[0], metadata.req_id_per_token
3328 )
3329 torch.testing.assert_close(
3330 cache.swap_in.call_args.kwargs["logical_topk_indices"], topk
3331 )
3332 assert kernel_shapes == [(num_decodes, query_len, 2, 4)]
3333 assert output.shape == (num_tokens, 2, 1)
3334
3335
3336 def test_hisparse_keeps_speculative_rows_as_decodes():
3337 """HiSparse limits decodes to verification requests, not short extends.
3338
3339 FlashAttn sparse MLA used to classify MTP verification as prefill under
3340 HiSparse, whose residency-dependent route a FULL CUDA graph freezes at
3341 capture.
3342 """
3343 builder = object.__new__(FlashAttnMLASparseMetadataBuilder)
3344 builder.vllm_config = SimpleNamespace(
3345 attention_config=SimpleNamespace(hisparse_config=object()),
3346 speculative_config=SimpleNamespace(
3347 num_speculative_tokens=3, parallel_drafting=False
3348 ),
3349 parallel_config=SimpleNamespace(decode_context_parallel_size=1),
3350 use_v2_model_runner=True,
3351 )
3352
3353 builder._init_reorder_batch_threshold(256, supports_spec_as_decode=True)
3354
3355 assert builder.reorder_batch_threshold == 4
3356
3357
3358 def test_flashinfer_hisparse_decode_runs_batched_attention():
3359 device = torch.device("cpu")
3360 num_tokens = 6
3361 q = torch.randn(num_tokens, 2, 4, device=device)
3362 topk = torch.zeros(num_tokens, 4, dtype=torch.int32, device=device)
3363 physical_topk = topk + 10
3364 valid_counts = torch.full((num_tokens,), 4, dtype=torch.int32, device=device)
3365 kernel_shapes: list[torch.Size] = []
3366
3367 def convert_decode(self, *args, **kwargs): # noqa: ARG001
3368 return physical_topk, valid_counts
3369
3370 def prepare_kernel(self, *args, **kwargs): # noqa: ARG001
3371 pass
3372
3373 def run_kernel(self, q, cache, indices, counts): # noqa: ARG001
3374 kernel_shapes.append(q.shape)
3375 return q[..., :1], None
3376
3377 cache_handle = SimpleNamespace(
3378 runtime=SimpleNamespace(
3379 hot=SimpleNamespace(attention_cache=torch.empty(1, device=device))
3380 ),
3381 )
3382 index_group = object.__new__(HiSparseMLAIndexGroup)
3383 index_group.caches = [cache_handle]
3384 index_group.convert_logical_to_physical_topk = MethodType(
3385 convert_decode, index_group
3386 )
3387 impl = object.__new__(FlashInferMLASparseImpl)
3388 impl.topk_indices_buffer = topk
3389 impl.index_group = index_group
3390 impl.index_group_index = 0
3391 impl._prepare_mqa_kernel = MethodType(prepare_kernel, impl)
3392 impl._run_mqa_kernel = MethodType(run_kernel, impl)
3393 metadata = SimpleNamespace(num_decode_tokens=num_tokens)
3394
3395 output, lse = FlashInferMLASparseImpl.forward_mqa(
3396 impl,
3397 q,
3398 torch.empty(1, device=device),
3399 metadata,
3400 SimpleNamespace(),
3401 )
3402
3403 assert kernel_shapes == [q.shape]
3404 assert output.shape == (num_tokens, 2, 1)
3405 assert lse is None
3406
3407
3408 def test_flashattn_hisparse_decode_uses_index_group():
3409 num_tokens = 4
3410 q_nope = torch.empty(num_tokens, 2, 3, device=DEVICE_TYPE)
3411 q_rope = torch.empty(num_tokens, 2, 1, device=DEVICE_TYPE)
3412 topk = torch.zeros(num_tokens, 4, dtype=torch.int32, device=DEVICE_TYPE)
3413 physical = topk.clone()
3414 counts = torch.full((num_tokens,), 4, dtype=torch.int32, device=DEVICE_TYPE)
3415 index_group = object.__new__(HiSparseMLAIndexGroup)
3416 index_group.convert_logical_to_physical_topk = MagicMock(
3417 return_value=(physical, counts)
3418 )
3419 index_group.physical_kv_cache = MagicMock(
3420 return_value=torch.empty(1, device=DEVICE_TYPE)
3421 )
3422 impl = object.__new__(FlashAttnMLASparseImpl)
3423 impl.topk_indices_buffer = topk
3424 impl.index_group = index_group
3425 impl.index_group_index = 0
3426 impl._run_mqa_kernel = MagicMock(return_value=q_nope[..., :1])
3427 metadata = SimpleNamespace(num_decode_tokens=num_tokens, block_size=64)
3428
3429 output, lse = FlashAttnMLASparseImpl.forward_mqa(
3430 impl,
3431 (q_nope, q_rope),
3432 torch.empty(1, device=DEVICE_TYPE),
3433 metadata,
3434 SimpleNamespace(),
3435 )
3436
3437 assert output.shape == (num_tokens, 2, 1)
3438 assert lse is None
3439 index_group.convert_logical_to_physical_topk.assert_called_once()
3440 impl._run_mqa_kernel.assert_called_once()
3441
3442
3443 def test_flashinfer_sm120_hisparse_decode_uses_index_group():
3444 num_tokens = 4
3445 q = torch.empty(num_tokens, 2, 4, device=DEVICE_TYPE)
3446 topk = torch.zeros(num_tokens, 4, dtype=torch.int32, device=DEVICE_TYPE)
3447 physical = topk.clone()
3448 index_group = object.__new__(HiSparseMLAIndexGroup)
3449 index_group.convert_logical_to_physical_topk = MagicMock(return_value=physical)
3450 index_group.physical_kv_cache = MagicMock(
3451 return_value=torch.empty(1, device=DEVICE_TYPE)
3452 )
3453 impl = object.__new__(FlashInferMLASparseSM120Impl)
3454 impl.topk_indices_buffer = topk
3455 impl.index_group = index_group
3456 impl.index_group_index = 0
3457 impl._run_mqa_kernel = MagicMock(return_value=q[..., :1])
3458 metadata = SimpleNamespace(
3459 num_decode_tokens=num_tokens,
3460 topk_tokens=topk.shape[1],
3461 )
3462
3463 output, lse = FlashInferMLASparseSM120Impl.forward_mqa(
3464 impl,
3465 q,
3466 torch.empty(1, device=DEVICE_TYPE),
3467 metadata,
3468 SimpleNamespace(),
3469 )
3470
3471 assert output.shape == (num_tokens, 2, 1)
3472 assert lse is None
3473 index_group.convert_logical_to_physical_topk.assert_called_once()
3474 impl._run_mqa_kernel.assert_called_once()
3475
3476
3477 def test_hisparse_resident_prefill_uses_attention_block_stride():
3478 expected = torch.tensor([[19]], dtype=torch.int32)
3479 cache_handle = SimpleNamespace(
3480 all_context_pages_resident=True,
3481 view=SimpleNamespace(block_size=64, attention_block_stride=832),
3482 block_table=torch.tensor([[3]], dtype=torch.int32),
3483 )
3484 index_group = object.__new__(HiSparseMLAIndexGroup)
3485 index_group.caches = [cache_handle]
3486 index_group.physical_topk_indices = torch.empty((1, 1), dtype=torch.int32)
3487 index_group._convert_once = MagicMock(return_value=expected)
3488 topk = torch.zeros((2, 1), dtype=torch.int32)
3489 metadata = SimpleNamespace(
3490 req_id_per_token=torch.zeros(2, dtype=torch.int32),
3491 )
3492
3493 result = index_group.convert_logical_to_physical_topk(
3494 0,
3495 topk,
3496 metadata,
3497 block_stride_rows=None,
3498 return_valid_counts=False,
3499 )
3500
3501 assert result is expected
3502 assert index_group._convert_once.call_args.kwargs["block_stride_rows"] == 832
3503
3504
3505 def test_hisparse_flashmla_reorders_full_speculative_window_as_decode():
3506 builder = object.__new__(FlashMLASparseMetadataBuilder)
3507 builder.vllm_config = SimpleNamespace(
3508 attention_config=SimpleNamespace(hisparse_config=object()),
3509 speculative_config=SimpleNamespace(
3510 num_speculative_tokens=3,
3511 parallel_drafting=False,
3512 ),
3513 parallel_config=SimpleNamespace(decode_context_parallel_size=1),
3514 )
3515
3516 builder._init_reorder_batch_threshold(
3517 256,
3518 supports_spec_as_decode=True,
3519 supports_dcp_with_varlen=True,
3520 )
3521
3522 assert builder.reorder_batch_threshold == 4
3523
3524
3525 def test_hisparse_mixed_single_token_batch_is_not_decode_only():
3526 cache = SimpleNamespace(
3527 runtime=SimpleNamespace(
3528 begin_forward=lambda: None,
3529 eager_host_mirror=True,
3530 ),
3531 )
3532 metadata = SimpleNamespace(
3533 num_decode_tokens=1,
3534 num_actual_tokens=2,
3535 max_query_len=1,
3536 num_reqs=2,
3537 req_id_per_token=torch.arange(2),
3538 )
3539
3540 HiSparseCacheHandle._prepare_for_batch(cache, metadata)
3541
3542 assert not cache.decode_batch
3543
3544
3545 def test_flashmla_cache_dtype_aliases_use_ds_layout():
3546 # kv-cache dtype aliases are canonicalized to fp8_ds_mla before the layer
3547 # stores kv_cache_dtype, so they cannot bypass the gate.
3548 for alias in ("fp8", "fp8_e4m3"):
3549 assert (
3550 _canonicalize_sparse_mla_kv_cache_dtype(FlashMLASparseBackend, alias)
3551 == "fp8_ds_mla"
3552 )
3553
3554
3555 def test_flashmla_common_metadata_requires_uniform_decodes():
3556 common_metadata = SimpleNamespace(
3557 max_query_len=3,
3558 num_reqs=3,
3559 num_actual_tokens=6,
3560 query_start_loc_cpu=torch.tensor([0, 1, 3, 6]),
3561 is_prefilling=None,
3562 )
3563
3564 split = split_decodes_and_prefills(
3565 common_metadata,
3566 decode_threshold=128,
3567 require_uniform=FlashMLASparseMetadataBuilder.require_uniform_decodes,
3568 )
3569
3570 assert split == (1, 2, 1, 5)
3571
3572
3573 def test_flashmla_fp8_metadata_excludes_zero_token_decode_padding(monkeypatch):
3574 monkeypatch.setattr(
3575 "vllm.v1.attention.backends.mla.flashmla_sparse.get_mla_metadata",
3576 lambda: (object(), None),
3577 )
3578 builder = SimpleNamespace(
3579 device=torch.device(DEVICE_TYPE),
3580 dummy_block_table=torch.zeros(7, 1, device=DEVICE_TYPE),
3581 max_model_len_tensor=torch.zeros(7, device=DEVICE_TYPE),
3582 pcp_dcp_kv_gather=False,
3583 )
3584 query_start_loc_cpu = torch.tensor([0, 110, 220, 330, 440, 550, 660, 660])
3585 common_metadata = SimpleNamespace(
3586 num_actual_tokens=660,
3587 query_start_loc_cpu=query_start_loc_cpu,
3588 seq_lens=torch.arange(7, device=DEVICE_TYPE),
3589 )
3590 metadata = FlashMLASparseMetadata(
3591 num_reqs=7,
3592 max_query_len=110,
3593 max_seq_len=110,
3594 num_actual_tokens=660,
3595 query_start_loc=query_start_loc_cpu.to(DEVICE_TYPE),
3596 slot_mapping=torch.arange(660, device=DEVICE_TYPE),
3597 block_table=torch.zeros(7, 1, dtype=torch.int32, device=DEVICE_TYPE),
3598 req_id_per_token=torch.zeros(660, dtype=torch.int32, device=DEVICE_TYPE),
3599 num_decodes=7,
3600 num_prefills=0,
3601 num_decode_tokens=660,
3602 )
3603
3604 fp8_metadata = FlashMLASparseMetadataBuilder._build_fp8_separate_prefill_decode(
3605 builder, common_metadata, metadata
3606 )
3607
3608 assert fp8_metadata.num_decodes == 6
3609 assert fp8_metadata.num_decode_tokens == 660
3610 assert fp8_metadata.decode is not None
3611 assert fp8_metadata.decode.decode_query_len == 110
3612 torch.testing.assert_close(
3613 fp8_metadata.decode.seq_lens, torch.arange(6, device=DEVICE_TYPE)
3614 )
3615
3616
3617 @pytest.mark.parametrize("use_mixed_batch", [False, True])
3618 def test_flashmla_fp8_paths_accept_decode_subset(monkeypatch, use_mixed_batch: bool):
3619 num_decode_tokens = 2
3620 num_batch_tokens = 5
3621 q = torch.empty(num_decode_tokens, 2, 3, device=DEVICE_TYPE)
3622 topk_indices = torch.empty(num_decode_tokens, 4, device=DEVICE_TYPE)
3623 kernel_q_shapes = []
3624
3625 def convert_indices(*args, **kwargs): # noqa: ARG001
3626 assert not kwargs.get("HAS_PREFILL_WORKSPACE", False)
3627 if not kwargs.get("return_valid_counts", False):
3628 return topk_indices
3629 valid_counts = torch.full(
3630 (num_decode_tokens,), 4, dtype=torch.int32, device=DEVICE_TYPE
3631 )
3632 return topk_indices, valid_counts
3633
3634 monkeypatch.setattr(
3635 "vllm.v1.attention.backends.mla.flashmla_sparse."
3636 "triton_convert_req_index_to_global_index",
3637 convert_indices,
3638 )
3639
3640 def run_kernel(**kwargs):
3641 kernel_q_shapes.append(kwargs["q"].shape)
3642 return kwargs["q"][..., :1], None
3643
3644 if use_mixed_batch:
3645 fp8_metadata = FlashMLASparseMetadata.FP8KernelMetadata(
3646 scheduler_metadata=object(),
3647 dummy_block_table=torch.empty(1, 1, dtype=torch.int32, device=DEVICE_TYPE),
3648 cache_lens=torch.empty(1, dtype=torch.int32, device=DEVICE_TYPE),
3649 )
3650 else:
3651 FP8Meta = FlashMLASparseMetadata.FP8SeparatePrefillDecode
3652 fp8_metadata = FP8Meta(
3653 num_decodes=1,
3654 num_prefills=1,
3655 num_decode_tokens=num_decode_tokens,
3656 num_prefill_tokens=num_batch_tokens - num_decode_tokens,
3657 decode=FP8Meta.Decode(
3658 seq_lens=torch.empty(1, dtype=torch.int32, device=DEVICE_TYPE),
3659 kernel_metadata=object(),
3660 decode_query_len=num_decode_tokens,
3661 ),
3662 prefill=FP8Meta.Prefill(
3663 request_ids=torch.empty(
3664 num_batch_tokens, dtype=torch.int32, device=DEVICE_TYPE
3665 ),
3666 workspace_starts=torch.empty(1, dtype=torch.int32, device=DEVICE_TYPE),
3667 chunks=[],
3668 ),
3669 )
3670 metadata = SimpleNamespace(
3671 fp8_extra_metadata=fp8_metadata,
3672 fp8_use_mixed_batch=use_mixed_batch,
3673 num_actual_tokens=num_batch_tokens,
3674 num_decode_tokens=num_decode_tokens,
3675 req_id_per_token=torch.empty(
3676 num_batch_tokens, dtype=torch.int32, device=DEVICE_TYPE
3677 ),
3678 block_table=torch.empty(1, 1, dtype=torch.int32, device=DEVICE_TYPE),
3679 block_size=64,
3680 )
3681 impl = SimpleNamespace(
3682 kv_cache_dtype="fp8_ds_mla",
3683 topk_indices_buffer=topk_indices,
3684 num_heads=2,
3685 kv_lora_rank=1,
3686 index_group=None,
3687 index_group_index=0,
3688 dcp_world_size=1,
3689 pcp_dcp_kv_gather=False,
3690 need_to_return_lse_for_decode=False,
3691 _fp8_flash_mla_kernel=run_kernel,
3692 _convert_logical_to_physical_topk=(
3693 lambda indices, metadata, **kwargs: convert_indices(
3694 metadata.req_id_per_token[: indices.shape[0]],
3695 metadata.block_table,
3696 indices,
3697 return_valid_counts=kwargs["return_valid_counts"],
3698 )
3699 ),
3700 )
3701 impl._forward_fp8_kv_mixed_batch = MethodType(
3702 FlashMLASparseImpl._forward_fp8_kv_mixed_batch, impl
3703 )
3704 impl._forward_fp8_kv_separate_prefill_decode = MethodType(
3705 FlashMLASparseImpl._forward_fp8_kv_separate_prefill_decode, impl
3706 )
3707
3708 output, lse = FlashMLASparseImpl.forward_mqa(
3709 impl,
3710 q,
3711 torch.empty(0, device=DEVICE_TYPE),
3712 metadata,
3713 None,
3714 )
3715
3716 assert kernel_q_shapes == [(1, num_decode_tokens, 2, 3)]
3717 assert output.shape == (num_decode_tokens, 2, 1)
3718 assert lse is None
3719
3720
3721 def _build_sparse_dcp_vllm_config(
3722 local_heads: int,
3723 dcp_world_size: int,
3724 comm_backend: str = "ag_rs",
3725 ):
3726 """Minimal sparse-MLA VllmConfig for the FlashMLASparse DCP head-envelope
3727 guard. TP is simulated by mocking ``get_num_attention_heads`` to return the
3728 per-rank head count, as the decode-correctness test above does.
3729 """
3730 kv_lora_rank = 512
3731 qk_nope_head_dim = 128
3732 qk_rope_head_dim = 64
3733 v_head_dim = 128
3734 head_size = kv_lora_rank + qk_rope_head_dim
3735 topk_tokens = 128
3736
3737 vllm_config = create_vllm_config(
3738 model_name="deepseek-ai/DeepSeek-V2-Lite-Chat",
3739 tensor_parallel_size=1,
3740 max_model_len=4096,
3741 block_size=64,
3742 hf_config_override={
3743 "index_topk": topk_tokens,
3744 "attn_module_list_cfg": [{"topk_tokens": topk_tokens}],
3745 },
3746 )
3747 model_config = vllm_config.model_config
3748 model_config.dtype = torch.bfloat16
3749 model_config.hf_text_config = SimpleNamespace(
3750 index_topk=topk_tokens,
3751 q_lora_rank=None,
3752 kv_lora_rank=kv_lora_rank,
3753 qk_nope_head_dim=qk_nope_head_dim,
3754 qk_rope_head_dim=qk_rope_head_dim,
3755 v_head_dim=v_head_dim,
3756 model_type="deepseek_v2",
3757 )
3758 del model_config.hf_config.index_topk # Composite configs only nest this field.
3759 model_config.get_num_attention_heads = MethodType(
3760 lambda self, parallel_config: local_heads, model_config
3761 )
3762 model_config.get_num_kv_heads = MethodType(
3763 lambda self, parallel_config: 1, model_config
3764 )
3765 model_config.get_head_size = MethodType(lambda self: head_size, model_config)
3766 model_config.get_sliding_window = MethodType(lambda self: None, model_config)
3767
3768 vllm_config.cache_config.cache_dtype = "fp8_ds_mla"
3769 vllm_config.parallel_config.decode_context_parallel_size = dcp_world_size
3770 vllm_config.parallel_config.dcp_comm_backend = comm_backend
3771 # The base builder clones the layer's dense-MHA prefill backend from
3772 # static_forward_context; the guard tests never run prefill.
3773 vllm_config.compilation_config.static_forward_context["placeholder"] = (
3774 SimpleNamespace(prefill_backend=None)
3775 )
3776 return vllm_config
3777
3778
3779 @pytest.mark.skipif(
3780 torch.cuda.get_device_capability() < (9, 0),
3781 reason="FlashMLASparseBackend requires CUDA 9.0 or higher",
3782 )
3783 @pytest.mark.parametrize(
3784 "local_heads,dcp_world_size,should_raise",
3785 [
3786 (16, 8, True),
3787 (24, 4, True),
3788 (16, 4, False),
3789 (16, 1, False),
3790 ],
3791 )
3792 def test_fp8_dcp_head_envelope_guard(local_heads, dcp_world_size, should_raise):
3793 """The fp8 decode envelope (head padding + tile-scheduler metadata) is
3794 sized from the local head count while the kernel runs on the DCP-gathered
3795 heads, so the builder must reject configs where the two pad differently.
3796 """
3797 device = torch.device(DEVICE_TYPE)
3798 vllm_config = _build_sparse_dcp_vllm_config(local_heads, dcp_world_size)
3799 kv_cache_spec = create_standard_kv_cache_spec(vllm_config)
3800 builder_cls = FlashMLASparseBackend.get_builder_cls()
3801
3802 if should_raise:
3803 with pytest.raises(NotImplementedError, match="envelope"):
3804 builder_cls(kv_cache_spec, ["placeholder"], vllm_config, device)
3805 else:
3806 builder = builder_cls(kv_cache_spec, ["placeholder"], vllm_config, device)
3807 gathered_heads = local_heads * dcp_world_size
3808 local_pad = 64 if local_heads <= 64 else 128
3809 gathered_pad = 64 if gathered_heads <= 64 else 128
3810 assert builder.fp8_decode_padded_heads == local_pad
3811 assert local_pad == gathered_pad
3812
3813
3814 def test_fp8_mixed_batch_dcp_neutralizes_empty_rows(monkeypatch):
3815 """A decode row whose top-k shard holds no local candidates (all -1) has
3816 undefined kernel out/lse; it must come back as (0, -inf), the identity of
3817 the cross-rank LSE merge, or a NaN would survive the merge even at zero
3818 weight (0 * NaN = NaN)."""
3819 num_tokens, num_heads, head_dim = 3, 2, 3
3820 q = torch.empty(num_tokens, num_heads, head_dim, device=DEVICE_TYPE)
3821 local_indices = torch.tensor(
3822 [[0, 1, -1, -1], [-1, -1, -1, -1], [2, -1, 3, -1]],
3823 dtype=torch.int32,
3824 device=DEVICE_TYPE,
3825 )
3826
3827 monkeypatch.setattr(
3828 "vllm.v1.attention.backends.mla.flashmla_sparse."
3829 "triton_filter_and_convert_dcp_index",
3830 lambda *args, **kwargs: local_indices,
3831 )
3832
3833 def run_kernel(**kwargs):
3834 out = torch.full(
3835 (1, num_tokens, num_heads, 1), float("nan"), device=DEVICE_TYPE
3836 )
3837 lse = torch.full((1, num_heads, num_tokens), float("nan"), device=DEVICE_TYPE)
3838 for token_id in (0, 2): # rows with local candidates get real values
3839 out[0, token_id] = float(token_id + 1)
3840 lse[0, :, token_id] = float(token_id + 1)
3841 return out, lse
3842
3843 metadata = SimpleNamespace(
3844 fp8_extra_metadata=FlashMLASparseMetadata.FP8KernelMetadata(
3845 scheduler_metadata=object(),
3846 dummy_block_table=torch.empty(1, 1, dtype=torch.int32, device=DEVICE_TYPE),
3847 cache_lens=torch.empty(1, dtype=torch.int32, device=DEVICE_TYPE),
3848 ),
3849 req_id_per_token=torch.empty(num_tokens, dtype=torch.int32, device=DEVICE_TYPE),
3850 block_table=torch.empty(1, 1, dtype=torch.int32, device=DEVICE_TYPE),
3851 block_size=64,
3852 cp_kv_cache_interleave_size=1,
3853 )
3854 impl = SimpleNamespace(
3855 index_group=None,
3856 index_group_index=0,
3857 dcp_world_size=2,
3858 dcp_rank=0,
3859 need_to_return_lse_for_decode=True,
3860 _fp8_flash_mla_kernel=run_kernel,
3861 )
3862
3863 out, lse = FlashMLASparseImpl._forward_fp8_kv_mixed_batch(
3864 impl, q, torch.empty(0, device=DEVICE_TYPE), local_indices, metadata
3865 )
3866
3867 assert torch.equal(out[1], torch.zeros_like(out[1]))
3868 assert torch.isneginf(lse[1]).all()
3869 for token_id in (0, 2):
3870 assert torch.equal(out[token_id], torch.full_like(out[token_id], token_id + 1))
3871 assert torch.equal(lse[token_id], torch.full_like(lse[token_id], token_id + 1))
3872 assert out.is_contiguous()
3873 assert not out.isnan().any()
3874 assert not lse.isnan().any()
3875
3876
3877 def test_hisparse_prefill_reuses_builder_staging_plan():
3878 """Every layer must reuse the batch plan instead of synchronizing to dedupe."""
3879 plan = SimpleNamespace(
3880 block_table=torch.tensor([[0]], dtype=torch.int32),
3881 ensure_gpu_sources=MagicMock(),
3882 )
3883 staged = torch.empty((1, 1, 8))
3884 calls = []
3885
3886 def gather(kv_cache, staging_plan, resident_cache=None):
3887 calls.append((kv_cache, staging_plan, resident_cache))
3888 return staged
3889
3890 resident_cache = torch.empty((1, 1, 8))
3891 resident_block_table = torch.tensor([[1]], dtype=torch.int32)
3892 cache = SimpleNamespace(
3893 runtime=SimpleNamespace(
3894 gather_prefill_cache=gather,
3895 ),
3896 view=SimpleNamespace(cache=resident_cache, block_size=1),
3897 block_table=resident_block_table,
3898 )
3899 index_group = object.__new__(HiSparseMLAIndexGroup)
3900 index_group.caches = [cache]
3901 source = torch.empty((1, 1, 8))
3902 metadata = SimpleNamespace(
3903 num_decodes=0,
3904 num_decode_tokens=0,
3905 seq_lens=torch.tensor([1], dtype=torch.int32),
3906 prefill=SimpleNamespace(host_staging_plan=plan),
3907 req_id_per_token=torch.tensor([0], dtype=torch.int32),
3908 )
3909
3910 result, block_table, request_ids = index_group.stage_prefill_rows(
3911 0, source, metadata
3912 )
3913
3914 assert result is staged
3915 assert block_table is plan.block_table
3916 torch.testing.assert_close(request_ids, metadata.req_id_per_token)
3917 plan.ensure_gpu_sources.assert_called_once()
3918 args = plan.ensure_gpu_sources.call_args.args
3919 torch.testing.assert_close(args[0], resident_block_table)
3920 assert args[1] == 1
3921 assert calls == [(source, plan, resident_cache)]
3922
3923
3924 def test_hisparse_fp8_prefill_gather_uses_dedicated_stream(monkeypatch):
3925 compute_stream = object()
3926 prefill_stream = MagicMock()
3927 gather = MagicMock()
3928 monkeypatch.setattr(index_group_module, "current_stream", lambda: compute_stream)
3929 monkeypatch.setattr(
3930 index_group_module.ops, "cp_gather_and_upconvert_fp8_kv_cache", gather
3931 )
3932
3933 resident_cache = torch.empty((2, 4, 656), dtype=torch.uint8)
3934 ready = MagicMock()
3935 cache = SimpleNamespace(
3936 view=SimpleNamespace(cache=resident_cache, block_size=4),
3937 block_table=torch.tensor([[2, 3]], dtype=torch.int32),
3938 )
3939 index_group = object.__new__(HiSparseMLAIndexGroup)
3940 index_group.caches = [cache]
3941 index_group.prefill_stream = prefill_stream
3942 index_group.prefill_ready_events = [ready]
3943 source = torch.empty((3, 4, 656), dtype=torch.uint8)
3944 dst = torch.empty((8, 576), dtype=torch.bfloat16)
3945 block_table = torch.tensor([[0, 1]], dtype=torch.int32)
3946 workspace_starts = torch.tensor([0], dtype=torch.int32)
3947 plan = SimpleNamespace(
3948 block_table=torch.tensor([[0, 1]], dtype=torch.int32),
3949 row_ids=torch.arange(8, dtype=torch.int32).view(1, -1),
3950 gpu_row_ids=torch.full((1, 8), -1, dtype=torch.int32),
3951 ensure_gpu_sources=MagicMock(),
3952 )
3953 metadata = SimpleNamespace(
3954 prefill=SimpleNamespace(host_staging_plan=plan),
3955 num_decodes=0,
3956 )
3957
3958 result = index_group.gather_fp8_prefill(
3959 0,
3960 source,
3961 dst,
3962 block_table,
3963 workspace_starts,
3964 1,
3965 metadata,
3966 0,
3967 )
3968
3969 prefill_stream.wait_stream.assert_called_once_with(compute_stream)
3970 ready.record.assert_called_once_with(prefill_stream)
3971 assert result is ready
3972 gather.assert_called_once()
3973 args = gather.call_args
3974 assert args.args[0] is resident_cache
3975 assert args.args[1] is dst
3976 torch.testing.assert_close(args.args[2], plan.block_table)
3977 assert args.args[3] is workspace_starts
3978 assert args.args[4] == 1
3979 assert args.kwargs["host_cache"].data_ptr() == source.data_ptr()
3980 assert args.kwargs["host_row_ids"] is plan.row_ids
3981 assert args.kwargs["device_row_ids"] is plan.gpu_row_ids
3982 plan.ensure_gpu_sources.assert_called_once()
3983 ensure_args = plan.ensure_gpu_sources.call_args.args
3984 torch.testing.assert_close(ensure_args[0], cache.block_table)
3985 assert ensure_args[1] == 4
3986
3987
3988 def test_sparse_impl_observes_repointed_indexer_buffer():
3989 """The MTP proposer repoints the draft's indexer at the target model's buffer
3990 after the backend impl is built, so the impl must resolve the buffer per read.
3991 Snapshotting it in __init__ leaves the layer reading indices nothing writes."""
3992 impl = object.__new__(FlashInferMLASparseImpl)
3993 own = torch.zeros(4, 8, dtype=torch.int32)
3994 target = torch.ones(4, 8, dtype=torch.int32)
3995 indexer = SimpleNamespace(topk_indices_buffer=own)
3996 impl.init_topk_indices_buffer(indexer, None)
3997
3998 assert impl.topk_indices_buffer is own
3999
4000 indexer.topk_indices_buffer = target
4001
4002 assert impl.topk_indices_buffer is target
4003
4004
4005 def test_explicit_topk_buffer_supersedes_indexer():
4006 """Backbone skip-topk layers have no indexer, and the proposer also assigns the
4007 shared buffer directly onto draft submodules."""
4008 impl = object.__new__(FlashInferMLASparseImpl)
4009 indexer = SimpleNamespace(
4010 topk_indices_buffer=torch.zeros(2, 2, dtype=torch.int32),
4011 )
4012 impl.init_topk_indices_buffer(indexer, None)
4013 explicit = torch.ones(2, 2, dtype=torch.int32)
4014
4015 impl.topk_indices_buffer = explicit
4016
4017 assert impl.topk_indices_buffer is explicit
4018
4019
4020 def test_sparse_mla_common_impl_resolves_buffer_lazily():
4021 """Guards the whole SparseMLACommonImpl family at once: a backend that
4022 snapshots the buffer instead silently loses MTP buffer sharing."""
4023 assert issubclass(SparseMLACommonImpl, SharedTopkIndicesBuffer)
4024 assert isinstance(SparseMLACommonImpl.topk_indices_buffer, property), (
4025 "topk_indices_buffer must stay a lazily-resolved property"
4026 )