main
py 493 lines 17.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from types import SimpleNamespace
5
6 import pytest
7 import torch
8
9 from tests.v1.attention.utils import create_vllm_config
10 from vllm.model_executor.layers.attention.sparse_mla_attention import (
11 SparseMLACommonMetadataBuilder,
12 )
13 from vllm.models.deepseek_v4.sparse_mla import DeepseekV4SparseMLABackend
14 from vllm.models.deepseek_v41.sparse_mla import (
15 DeepseekV4SparseMLABackend as DeepseekV41SparseMLABackend,
16 )
17 from vllm.v1.attention.backend import CommonAttentionMetadata
18 from vllm.v1.attention.backends.mla import indexer as indexer_module
19 from vllm.v1.attention.backends.mla.compressor_utils import (
20 CompressedSlotMappingKernel,
21 get_compressed_slot_mapping,
22 )
23 from vllm.v1.attention.backends.mla.indexer import (
24 BuildPrefillChunkMetadataKernel,
25 DeepseekV4IndexerBackend,
26 DeepseekV32IndexerMetadataBuilder,
27 DeepseekV41IndexerBackend,
28 get_max_prefill_buffer_size,
29 )
30 from vllm.v1.attention.backends.mla.sparse_utils import (
31 ConvertReqIndexToGlobalIndexKernel,
32 )
33 from vllm.v1.kv_cache_interface import (
34 MLAAttentionSpec,
35 compute_layer_kv_cache_shape_bytes,
36 )
37 from vllm.v1.worker.block_table import get_block_table_width
38 from vllm.v1.worker.utils import select_common_block_size
39
40
41 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
42 @pytest.mark.parametrize("query_lens", [[1], [6] * 16, [0, 2, 6, 0, 4], [0, 0, 0]])
43 @pytest.mark.parametrize("padding", [0, 17])
44 def test_fused_indexer_decode_metadata(query_lens, padding):
45 """Flatten device query boundaries and clear graph padding on every replay."""
46 from vllm.v1.attention.ops.metadata import _indexer_decode_metadata_kernel
47
48 device = "cuda"
49 lengths = torch.tensor(query_lens, device=device, dtype=torch.int32)
50 qsl = torch.cat(
51 [torch.zeros(1, device=device, dtype=torch.int32), lengths.cumsum(0).int()]
52 )
53 reqs = len(query_lens)
54 n = sum(query_lens)
55 tokens = n + padding
56 capacity = max(tokens, reqs) + 31
57 seq = lengths + 4096
58 # Exercise block-table row padding and masked columns.
59 bt = torch.arange(reqs * 74, device=device, dtype=torch.int32).view(reqs, 74)[
60 :, :65
61 ]
62 out_bt = torch.full((capacity, 69), -99, device=device, dtype=torch.int32)
63 outputs = [
64 torch.full((capacity,), -99, device=device, dtype=torch.int32) for _ in range(4)
65 ]
66 out_seq, out_lens, indices, per_req = outputs
67 grid = max(reqs, tokens + (capacity - tokens + 255) // 256)
68 _indexer_decode_metadata_kernel[(grid,)](
69 qsl,
70 seq,
71 bt,
72 out_seq,
73 out_bt,
74 out_lens,
75 indices,
76 per_req,
77 reqs,
78 tokens,
79 capacity,
80 bt.stride(0),
81 out_bt.stride(0),
82 BLOCK_COLS=bt.shape[1],
83 num_warps=4,
84 )
85 expected_req = torch.repeat_interleave(torch.arange(reqs, device=device), lengths)
86 expected_seq = torch.cat(
87 [torch.arange(4097, 4097 + q, device=device) for q in query_lens]
88 )
89 torch.testing.assert_close(out_seq[:n], expected_seq.int(), rtol=0, atol=0)
90 assert torch.count_nonzero(out_seq[n:]) == 0
91 torch.testing.assert_close(out_bt[:n, :65], bt[expected_req], rtol=0, atol=0)
92 assert torch.count_nonzero(out_bt[n:tokens, :65]) == 0
93 torch.testing.assert_close(indices[:n], expected_req.int(), rtol=0, atol=0)
94 torch.testing.assert_close(
95 indices[n:tokens],
96 torch.arange(reqs, reqs + padding, device=device, dtype=torch.int32),
97 )
98 assert torch.all(out_lens[:tokens] == 1)
99 torch.testing.assert_close(per_req[:reqs], lengths)
100
101
102 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
103 @pytest.mark.parametrize(
104 "query_lens", [[1], [0, 257, 1, 0, 3], [6] * 64, [2, 6, 4, 0], [0, 0, 0]]
105 )
106 @pytest.mark.parametrize("use_sparse_mla_builder", [False, True])
107 def test_device_token_request_mapping(query_lens, use_sparse_mla_builder):
108 """Graph replay follows device boundaries even when CPU lengths are stale."""
109 lengths = torch.tensor(query_lens, device="cuda", dtype=torch.int32)
110 qsl = torch.cat(
111 [torch.zeros(1, device="cuda", dtype=torch.int32), lengths.cumsum(0).int()]
112 )
113 n = sum(query_lens)
114 output = torch.full((n + 7,), -99, device="cuda", dtype=torch.int32)
115 common = CommonAttentionMetadata(
116 query_start_loc=qsl,
117 query_start_loc_cpu=qsl.cpu(),
118 seq_lens=lengths,
119 num_reqs=len(query_lens),
120 num_actual_tokens=output.numel(),
121 max_query_len=max(query_lens),
122 max_seq_len=max(query_lens),
123 block_table_tensor=torch.empty(
124 (len(query_lens), 1), device="cuda", dtype=torch.int32
125 ),
126 slot_mapping=torch.full((n + 7,), -1, device="cuda", dtype=torch.int64),
127 )
128
129 def build_mapping():
130 if use_sparse_mla_builder:
131 builder = SimpleNamespace(req_id_per_token_buffer=output)
132 return SparseMLACommonMetadataBuilder._build_req_id_per_token(
133 builder, common
134 )
135 return common.token_to_req_indices(output)
136
137 result = build_mapping()
138 assert result.data_ptr() == output.data_ptr()
139 expected = torch.repeat_interleave(
140 torch.arange(len(query_lens), device="cuda", dtype=torch.int32), lengths
141 )
142 torch.testing.assert_close(output[:n], expected)
143 assert torch.count_nonzero(output[n:]) == 0
144 graph = torch.cuda.CUDAGraph()
145 common._token_to_req_indices_cache = None
146 with torch.cuda.graph(graph):
147 build_mapping()
148 reversed_lens = lengths.flip(0)
149 qsl[1:].copy_(reversed_lens.cumsum(0))
150 graph.replay()
151 expected = torch.repeat_interleave(
152 torch.arange(len(query_lens), device="cuda", dtype=torch.int32), reversed_lens
153 )
154 torch.testing.assert_close(output[:n], expected)
155 assert torch.count_nonzero(output[n:]) == 0
156
157
158 def test_indexer_shares_uncompressed_block_size_with_deepseek_v4_mla():
159 """Packed MLA/indexer groups must retain 64 compressed rows per page."""
160 kernel_block_size = select_common_block_size(
161 256, [DeepseekV4SparseMLABackend, DeepseekV4IndexerBackend]
162 )
163 spec = MLAAttentionSpec(
164 block_size=256,
165 num_kv_heads=1,
166 head_size=132,
167 dtype=torch.uint8,
168 tokens_per_state=4,
169 )
170 assert compute_layer_kv_cache_shape_bytes(spec, 2, kernel_block_size) == (
171 2,
172 1,
173 64,
174 132,
175 )
176
177
178 def test_indexer_preserves_deepseek_v41_mla_block_size():
179 """V4.1 retains its smaller pages independently of V4's C4 indexer."""
180 block_size = DeepseekV41SparseMLABackend.get_supported_kernel_block_sizes()[0]
181 assert isinstance(block_size, int)
182 assert (
183 select_common_block_size(
184 block_size, [DeepseekV41SparseMLABackend, DeepseekV41IndexerBackend]
185 )
186 == block_size
187 )
188
189
190 def test_indexer_warmup_normalizes_zero_compress_ratios():
191 config = SimpleNamespace(
192 scheduler_config=SimpleNamespace(max_num_batched_tokens=8),
193 model_config=SimpleNamespace(
194 hf_text_config=SimpleNamespace(
195 compress_ratios=[0, 0, 4, 128, 0], index_kpool=32
196 )
197 ),
198 parallel_config=SimpleNamespace(
199 decode_context_parallel_size=1,
200 prefill_context_parallel_size=1,
201 cp_kv_cache_interleave_size=1,
202 ),
203 )
204
205 keys = BuildPrefillChunkMetadataKernel().get_warmup_keys(config)
206
207 assert {key.compress_ratio for key in keys} == {1, 4, 32, 128}
208 assert {(key.query_slice_start, key.query_slice_stop) for key in keys} == {
209 (query_slice_start, query_slice_stop)
210 for query_slice_start in (1, 2, 16)
211 for query_slice_stop in (1, 2, 16)
212 }
213
214
215 def test_indexer_warmup_includes_pcp_normalized_dcp_key(monkeypatch):
216 monkeypatch.setattr(
217 "vllm.v1.attention.backends.mla.indexer.get_dcp_group",
218 lambda: SimpleNamespace(rank_in_group=2),
219 )
220 config = SimpleNamespace(
221 scheduler_config=SimpleNamespace(max_num_batched_tokens=8),
222 model_config=SimpleNamespace(
223 hf_text_config=SimpleNamespace(compress_ratios=[32], index_kpool=32)
224 ),
225 parallel_config=SimpleNamespace(
226 decode_context_parallel_size=4,
227 prefill_context_parallel_size=4,
228 cp_kv_cache_interleave_size=1,
229 ),
230 )
231
232 keys = BuildPrefillChunkMetadataKernel().get_warmup_keys(config)
233
234 # Triton's compile key normalizes generic i32 values to 2 and divisible
235 # i32 values (including zero) to 16.
236 assert {(key.dcp_rank, key.dcp_world) for key in keys} == {(2, 2), (16, 1)}
237 assert {
238 (
239 key.input_variant.is_aligned("uncompressed_seq_lens"),
240 key.input_variant.is_aligned("cu_compressed_seq_lens"),
241 )
242 for key in keys
243 } == {(False, False), (False, True), (True, False), (True, True)}
244
245
246 def test_compressed_slot_mapping_warmup_includes_index_kpool():
247 config = SimpleNamespace(
248 cache_config=SimpleNamespace(block_size=256),
249 model_config=SimpleNamespace(hf_text_config=SimpleNamespace(index_kpool=32)),
250 )
251
252 keys = CompressedSlotMappingKernel().get_warmup_keys(config)
253 assert {(key.compress_ratio, key.block_size) for key in keys} == {(32, 2)}
254
255
256 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
257 def test_compressed_slot_mapping_inherits_padded_token_slots():
258 """A token whose own slot is padded (SWA bounded replay) closes no
259 compressed state either."""
260 device = torch.device("cuda")
261 query_start_loc = torch.tensor([0, 8], dtype=torch.int32, device=device)
262 seq_lens = torch.tensor([8], dtype=torch.int32, device=device)
263 block_table = torch.tensor([[3]], dtype=torch.int32, device=device)
264 slot_mapping = torch.arange(8, dtype=torch.int64, device=device)
265 slot_mapping[:4] = -1
266 compressed = get_compressed_slot_mapping(
267 8,
268 slot_mapping,
269 query_start_loc,
270 seq_lens,
271 block_table,
272 block_size=4,
273 compress_ratio=2,
274 )
275 assert compressed.tolist() == [-1, -1, -1, -1, -1, 3 * 4 + 2, -1, 3 * 4 + 3]
276
277
278 def test_index_conversion_warmup_uses_physical_block_stride():
279 config = SimpleNamespace(
280 cache_config=SimpleNamespace(block_size=64),
281 model_config=SimpleNamespace(
282 max_model_len=1024,
283 hf_text_config=SimpleNamespace(index_topk=2048),
284 ),
285 parallel_config=SimpleNamespace(
286 decode_context_parallel_size=1,
287 prefill_context_parallel_size=1,
288 cp_kv_cache_interleave_size=1,
289 ),
290 )
291
292 keys = ConvertReqIndexToGlobalIndexKernel().get_warmup_keys(
293 config,
294 block_stride_rows=4096,
295 )
296 assert {key.block_stride_rows for key in keys} == {4096}
297
298
299 def test_zero_token_pcp_rank_participates_in_compressed_mapping_gather(monkeypatch):
300 builder = object.__new__(DeepseekV32IndexerMetadataBuilder)
301 builder.compress_ratio = 4
302 builder.use_pcp = True
303 builder.pcp_world_size = 4
304 builder.pcp_rank = 0
305 builder.kernel_block_size = None
306 builder.kv_cache_spec = SimpleNamespace(block_size=64, num_states=64)
307 builder.compressed_slot_mapping_buffer = torch.zeros(8, dtype=torch.int64)
308
309 def fake_get_compressed_slot_mapping(
310 num_tokens,
311 slot_mapping,
312 query_start_loc,
313 seq_lens,
314 block_table,
315 block_size,
316 compress_ratio,
317 out,
318 ):
319 assert num_tokens == 0
320 assert block_size == 64
321 assert compress_ratio == 4
322 out.fill_(-1)
323 return out[:num_tokens]
324
325 gathered_slot_mapping = torch.tensor([123, -1, -1, -1], dtype=torch.int64)
326
327 class FakePCPGroup:
328 def __init__(self):
329 self.calls = 0
330
331 def all_gather(self, tensor, dim=0):
332 self.calls += 1
333 assert dim == 0
334 torch.testing.assert_close(tensor, torch.tensor([-1]))
335 return gathered_slot_mapping
336
337 fake_pcp_group = FakePCPGroup()
338 monkeypatch.setattr(
339 indexer_module,
340 "get_compressed_slot_mapping",
341 fake_get_compressed_slot_mapping,
342 )
343 monkeypatch.setattr(indexer_module, "get_pcp_group", lambda: fake_pcp_group)
344
345 query_start_loc = torch.tensor([0, 0], dtype=torch.int32)
346 seq_lens = torch.tensor([16], dtype=torch.int32)
347 common = CommonAttentionMetadata(
348 query_start_loc=query_start_loc,
349 query_start_loc_cpu=query_start_loc,
350 seq_lens=seq_lens,
351 seq_lens_cpu_upper_bound=seq_lens,
352 num_reqs=1,
353 num_actual_tokens=0,
354 max_query_len=0,
355 max_seq_len=16,
356 block_table_tensor=torch.tensor([[0]], dtype=torch.int32),
357 slot_mapping=torch.tensor([11, 22, 33, 44], dtype=torch.int64),
358 causal=True,
359 )
360
361 metadata = builder.build(common_prefix_len=0, common_attn_metadata=common)
362
363 assert fake_pcp_group.calls == 1
364 assert metadata.num_decodes == 0
365 assert metadata.num_decode_tokens == 0
366 assert metadata.num_prefills == 0
367 assert metadata.num_prefill_tokens == 0
368 torch.testing.assert_close(metadata.slot_mapping, gathered_slot_mapping)
369
370
371 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
372 def test_indexer_builder_deepseek_v4_compressed_slot_mapping_uses_num_states():
373 """Regression test: DeepseekV4 compression path must compute slot_mapping from
374 compressed positions, not reuse the uncompressed common metadata mapping.
375 """
376 device = torch.device("cuda")
377
378 # num_states = block_size // tokens_per_state = 256 // 4 = 64
379 kv_cache_spec = MLAAttentionSpec(
380 block_size=256,
381 num_kv_heads=1,
382 head_size=128,
383 dtype=torch.bfloat16,
384 tokens_per_state=4,
385 )
386 vllm_config = create_vllm_config(max_model_len=1024)
387 max_num_blocks = kv_cache_spec.max_num_blocks_per_req(vllm_config, 1024)
388 block_table_width = get_block_table_width(max_num_blocks, kv_cache_spec.block_size)
389 builder = DeepseekV32IndexerMetadataBuilder(
390 kv_cache_spec=kv_cache_spec,
391 layer_names=["dummy"],
392 vllm_config=vllm_config,
393 device=device,
394 block_table_width=block_table_width,
395 )
396
397 # Construct a single request where:
398 # - num_computed = 240 (=> compressed_pos_start = 60)
399 # - query_len = 40 (=> num_groups = 10)
400 # => compressed positions are 60..69 which cross the storage block boundary at 64.
401 query_start_loc = torch.tensor([0, 40], dtype=torch.int32, device=device)
402 query_start_loc_cpu = query_start_loc.cpu()
403 seq_lens = torch.tensor([280], dtype=torch.int32, device=device) # 240 + 40
404
405 # Two blocks: compressed positions 0..63 map to block 5, 64..127 map to block 7.
406 block_table_tensor = torch.tensor([[5, 7]], dtype=torch.int32, device=device)
407
408 # Dummy uncompressed slot mapping (length == uncompressed num_actual_tokens).
409 slot_mapping = torch.full((40,), -123, dtype=torch.int64, device=device)
410
411 common = CommonAttentionMetadata(
412 query_start_loc=query_start_loc,
413 query_start_loc_cpu=query_start_loc_cpu,
414 seq_lens=seq_lens,
415 seq_lens_cpu_upper_bound=seq_lens.cpu(),
416 num_reqs=1,
417 num_actual_tokens=40,
418 max_query_len=40,
419 max_seq_len=280,
420 block_table_tensor=block_table_tensor,
421 slot_mapping=slot_mapping,
422 causal=True,
423 )
424
425 md = builder.build(common_prefix_len=0, common_attn_metadata=common)
426
427 # The compressed slot_mapping retains the original uncompressed size (40).
428 # Only every compress_ratio-th position gets a valid slot; the rest are -1.
429 assert md.slot_mapping.numel() == 40
430 valid_slots = md.slot_mapping[md.slot_mapping >= 0]
431 assert valid_slots.numel() == 10 # 40 tokens / compress_ratio 4
432
433 storage_bs = kv_cache_spec.num_states # 64
434 # Compressed positions 60..63 land in block 5, positions 64..69 in block 7.
435 expected = torch.tensor(
436 [
437 5 * storage_bs + 60,
438 5 * storage_bs + 61,
439 5 * storage_bs + 62,
440 5 * storage_bs + 63,
441 ]
442 + [
443 7 * storage_bs + 0,
444 7 * storage_bs + 1,
445 7 * storage_bs + 2,
446 7 * storage_bs + 3,
447 7 * storage_bs + 4,
448 7 * storage_bs + 5,
449 ],
450 dtype=torch.int64,
451 device=device,
452 )
453 torch.testing.assert_close(valid_slots, expected)
454
455
456 @pytest.mark.parametrize("compress_ratio", [1, 4])
457 def test_indexer_prefill_budget_matches_compressed_workspace(compress_ratio):
458 """The chunker budget is in compressed rows, like the K-gather workspace."""
459 max_model_len = 1024
460 kv_cache_spec = MLAAttentionSpec(
461 block_size=256,
462 num_kv_heads=1,
463 head_size=128,
464 dtype=torch.bfloat16,
465 tokens_per_state=compress_ratio,
466 )
467 vllm_config = create_vllm_config(max_model_len=max_model_len)
468 max_num_blocks = kv_cache_spec.max_num_blocks_per_req(vllm_config, max_model_len)
469 block_table_width = get_block_table_width(max_num_blocks, kv_cache_spec.block_size)
470 builder = DeepseekV32IndexerMetadataBuilder(
471 kv_cache_spec=kv_cache_spec,
472 layer_names=["dummy"],
473 vllm_config=vllm_config,
474 device=torch.device("cpu"),
475 block_table_width=block_table_width,
476 )
477
478 workspace_rows = get_max_prefill_buffer_size(vllm_config) // compress_ratio
479 assert builder.max_prefill_buffer_size == workspace_rows
480
481 # Overflows the workspace unless the chunker splits.
482 num_reqs = 3 * (workspace_rows // (max_model_len // compress_ratio)) + 1
483 compressed_seq_lens = torch.full((num_reqs,), max_model_len // compress_ratio)
484 query_lens = torch.ones(num_reqs, dtype=torch.int64)
485 chunks = builder._split_indexer_prefill_chunks(
486 compressed_seq_lens,
487 query_lens,
488 builder.max_prefill_buffer_size,
489 max_logits_bytes=10**15,
490 )
491 assert len(chunks) > 1
492 for req_slice, _ in chunks:
493 assert int(compressed_seq_lens[req_slice].sum()) <= workspace_rows