main
py 397 lines 12.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import math
5 from types import SimpleNamespace
6
7 import pytest
8 import torch
9
10 from tests.v1.attention.utils import dense_kv_cache_views
11 from vllm.v1.kv_cache_interface import (
12 ChunkedLocalAttentionSpec,
13 FullAttentionSpec,
14 KVCacheLayout,
15 SlidingWindowSpec,
16 )
17 from vllm.v1.worker import utils as worker_utils
18 from vllm.v1.worker.utils import (
19 AttentionGroup,
20 KVBlockZeroer,
21 _zero_kv_blocks_kernel,
22 )
23
24
25 @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
26 @pytest.mark.parametrize(
27 "spec",
28 [
29 SlidingWindowSpec(
30 block_size=2,
31 num_kv_heads=1,
32 head_size=1,
33 dtype=torch.uint8,
34 sliding_window=4,
35 ),
36 ChunkedLocalAttentionSpec(
37 block_size=2,
38 num_kv_heads=1,
39 head_size=1,
40 dtype=torch.uint8,
41 attention_chunk_size=4,
42 ),
43 ],
44 ids=["sliding-window", "chunked-local"],
45 )
46 def test_attention_blocks_are_zeroed(spec):
47 device = torch.device("cuda")
48 storage = torch.ones((4, 1, 2, 2), dtype=torch.uint8, device=device)
49 layer_name = "draft.self_attn"
50 zeroer = KVBlockZeroer(
51 device,
52 attn_groups_iter=[AttentionGroup(None, [layer_name], spec, 0)],
53 kernel_block_sizes=[2],
54 static_forward_context={
55 layer_name: SimpleNamespace(kv_cache=storage),
56 },
57 num_blocks=4,
58 )
59
60 zeroer.zero_block_ids([1])
61 torch.accelerator.synchronize()
62
63 expected = torch.ones_like(storage)
64 expected[1] = 0
65 assert torch.equal(storage, expected)
66
67
68 @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
69 def test_layers_in_one_group_may_use_different_kernel_pages_per_block():
70 """Derive kernel pages per block from each layer's allocation."""
71 device = torch.device("cuda")
72 num_blocks = 4
73 spec = SlidingWindowSpec(
74 block_size=2,
75 num_kv_heads=1,
76 head_size=1,
77 dtype=torch.int32,
78 sliding_window=2,
79 )
80 # Two kernel pages per logical block.
81 wide = torch.ones((num_blocks * 2, 3), dtype=torch.int32, device=device)
82 # One kernel page per logical block, carved out of a larger allocation so
83 # an over-strided write lands in the guard region instead of faulting or
84 # silently hitting another tensor.
85 narrow_backing = torch.ones((num_blocks * 3, 5), dtype=torch.int32, device=device)
86 narrow = narrow_backing[:num_blocks]
87
88 zeroer = KVBlockZeroer(
89 device,
90 attn_groups_iter=[
91 AttentionGroup(
92 None,
93 ["wide", "narrow"],
94 spec,
95 0,
96 )
97 ],
98 kernel_block_sizes=[1],
99 static_forward_context={
100 "wide": SimpleNamespace(kv_cache=wide),
101 "narrow": SimpleNamespace(kv_cache=narrow),
102 },
103 num_blocks=num_blocks,
104 )
105
106 zeroer.zero_block_ids([num_blocks - 1])
107 torch.accelerator.synchronize()
108
109 expected_wide = torch.ones_like(wide)
110 expected_wide[2 * (num_blocks - 1) :] = 0
111 assert torch.equal(wide, expected_wide)
112
113 expected_narrow = torch.ones_like(narrow_backing)
114 expected_narrow[num_blocks - 1] = 0
115 assert torch.equal(narrow_backing, expected_narrow)
116
117
118 @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
119 def test_block_ids_are_not_overwritten_while_copy_is_in_flight():
120 device = torch.device("cuda")
121 num_blocks = 4
122 page_size_el = 4
123 storage = torch.ones((num_blocks, page_size_el), dtype=torch.int32, device=device)
124
125 # Build the minimal zeroer state directly so the test can focus on the
126 # in-flight copy behavior without constructing model attention groups.
127 zeroer = KVBlockZeroer.__new__(KVBlockZeroer)
128 zeroer.device = device
129 zeroer._meta = (
130 torch.tensor([storage.data_ptr()], dtype=torch.uint64, device=device),
131 torch.tensor([page_size_el], dtype=torch.int64, device=device),
132 torch.tensor([page_size_el], dtype=torch.int64, device=device),
133 page_size_el // page_size_el, # max_chunks = 1
134 page_size_el, # blk_size
135 1, # n_segs
136 )
137
138 stream = torch.cuda.Stream()
139 with torch.cuda.stream(stream):
140 # Keep the first nonblocking H2D copy pending while the host submits the
141 # second call. Each call must stage from its own pinned source so the
142 # first copy is not corrupted before it runs.
143 torch.cuda._sleep(10_000_000)
144 zeroer.zero_block_ids([1])
145 zeroer.zero_block_ids([2])
146 stream.synchronize()
147
148 assert torch.all(storage[0] == 1)
149 assert torch.all(storage[1] == 0)
150 assert torch.all(storage[2] == 0)
151 assert torch.all(storage[3] == 1)
152
153
154 @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
155 def test_non_uniform_page_sizes():
156 """Two segments with different page sizes (e.g. MLA + DSA indexer)."""
157 device = torch.device("cuda")
158 num_blocks = 4
159 page_size_a = 10496 # int32 elements
160 page_size_b = 2112
161
162 storage_a = torch.ones((num_blocks, page_size_a), dtype=torch.int32, device=device)
163 storage_b = torch.ones((num_blocks, page_size_b), dtype=torch.int32, device=device)
164
165 zeroer = KVBlockZeroer.__new__(KVBlockZeroer)
166 zeroer.device = device
167
168 seg_page_sizes = [page_size_a, page_size_b]
169 max_ps = max(seg_page_sizes)
170
171 blk_size = min(1 << (max_ps - 1).bit_length(), 1024)
172
173 zeroer._meta = (
174 torch.tensor(
175 [storage_a.data_ptr(), storage_b.data_ptr()],
176 dtype=torch.uint64,
177 device=device,
178 ),
179 torch.tensor(seg_page_sizes, dtype=torch.int64, device=device),
180 torch.tensor(seg_page_sizes, dtype=torch.int64, device=device),
181 (max_ps + blk_size - 1) // blk_size,
182 blk_size,
183 2,
184 )
185
186 stream = torch.cuda.Stream()
187 with torch.cuda.stream(stream):
188 zeroer.zero_block_ids([1, 2])
189 stream.synchronize()
190
191 for storage in (storage_a, storage_b):
192 assert torch.all(storage[0] == 1)
193 assert torch.all(storage[1] == 0)
194 assert torch.all(storage[2] == 0)
195 assert torch.all(storage[3] == 1)
196
197
198 @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
199 def test_packed_segment_zeros_only_its_last_block_page():
200 """A packed KV segment steps by block stride but clears only its page."""
201 device = torch.device("cuda")
202 num_blocks = 4
203 block_stride_el = 12
204 page_size_el = 4
205 page_offset_el = 3
206 backing = torch.ones(
207 (num_blocks, block_stride_el), dtype=torch.int32, device=device
208 )
209
210 zeroer = KVBlockZeroer.__new__(KVBlockZeroer)
211 zeroer.device = device
212 zeroer._meta = (
213 torch.tensor(
214 [backing.data_ptr() + page_offset_el * backing.element_size()],
215 dtype=torch.uint64,
216 device=device,
217 ),
218 torch.tensor([block_stride_el], dtype=torch.int64, device=device),
219 torch.tensor([page_size_el], dtype=torch.int64, device=device),
220 1,
221 page_size_el,
222 1,
223 )
224
225 zeroer.zero_block_ids([num_blocks - 1])
226 torch.accelerator.synchronize()
227
228 expected = torch.ones_like(backing)
229 expected[-1, page_offset_el : page_offset_el + page_size_el] = 0
230 assert torch.equal(backing, expected)
231
232
233 def test_large_dsv4_launch_geometry(monkeypatch):
234 """Keep the failing DSV4 shape efficient and within launch limits."""
235 device = torch.device("cpu")
236 n_blocks, n_segs = 6870, 181
237 layer_names = [f"layer.{i}" for i in range(n_segs)]
238 page_sizes = [9344 if i % 2 == 0 else 292 for i in range(n_segs)]
239 spec = SlidingWindowSpec(
240 block_size=1,
241 num_kv_heads=1,
242 head_size=1,
243 dtype=torch.int32,
244 sliding_window=1,
245 )
246 storages = {
247 name: torch.ones((1, page_size), dtype=torch.int32)
248 for name, page_size in zip(layer_names, page_sizes)
249 }
250 zeroer = KVBlockZeroer(
251 device,
252 attn_groups_iter=[
253 AttentionGroup(None, [name], spec, group_id)
254 for group_id, name in enumerate(layer_names)
255 ],
256 kernel_block_sizes=[1] * n_segs,
257 static_forward_context={
258 name: SimpleNamespace(kv_cache=storage)
259 for name, storage in storages.items()
260 },
261 num_blocks=1,
262 )
263
264 assert zeroer._meta is not None
265 _, _, seg_page_sizes, max_chunks, blk_size, n_segs = zeroer._meta
266 assert seg_page_sizes.tolist() == page_sizes
267 assert (max_chunks, blk_size, n_segs) == (10, 1024, 181)
268
269 captured_grids = []
270
271 class FakeKernel:
272 def __getitem__(self, grid):
273 captured_grids.append(grid)
274 return lambda *args, **kwargs: None
275
276 monkeypatch.setattr(worker_utils, "_zero_kv_blocks_kernel", FakeKernel())
277 monkeypatch.setattr(
278 worker_utils,
279 "async_tensor_h2d",
280 lambda values, **kwargs: torch.tensor(values, dtype=torch.int64),
281 )
282
283 zeroer.zero_block_ids(list(range(n_blocks)))
284
285 old_max_chunks = max(page_sizes) // 4
286 assert math.prod((n_blocks, n_segs, old_max_chunks)) > 2**31 - 1
287 assert captured_grids == [(n_blocks, n_segs, max_chunks)]
288
289
290 @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
291 def test_warmup_compiles_for_all_block_counts():
292 """After warmup, no launch should trigger a first-request JIT compile.
293
294 The block count is carried by the launch grid, so changing it must reuse
295 the warmup's compiled kernel.
296 """
297 device = torch.device("cuda")
298 num_blocks = 64
299 page_size_el = 4
300 storage = torch.ones((num_blocks, page_size_el), dtype=torch.int32, device=device)
301
302 zeroer = KVBlockZeroer.__new__(KVBlockZeroer)
303 zeroer.device = device
304 zeroer._meta = (
305 torch.tensor([storage.data_ptr()], dtype=torch.uint64, device=device),
306 torch.tensor([page_size_el], dtype=torch.int64, device=device),
307 torch.tensor([page_size_el], dtype=torch.int64, device=device),
308 1, # max_chunks
309 page_size_el, # blk_size
310 1, # n_segs
311 )
312
313 def compiled_variants() -> set:
314 return {
315 key
316 for caches in _zero_kv_blocks_kernel.device_caches.values()
317 for key in caches[0]
318 }
319
320 zeroer.warmup(num_blocks)
321 torch.accelerator.synchronize()
322 warmed = compiled_variants()
323 assert warmed
324
325 for n_blocks in (1, 2, 3, 16, 32):
326 zeroer.zero_block_ids(list(range(n_blocks)))
327 torch.accelerator.synchronize()
328
329 assert compiled_variants() == warmed
330
331
332 @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
333 def test_warmup_respects_available_block_count():
334 """An empty KV cache must not be warmed with out-of-range block IDs."""
335 device = torch.device("cuda")
336 page_size_el = 4
337 storage = torch.ones((1, page_size_el), dtype=torch.int32, device=device)
338
339 zeroer = KVBlockZeroer.__new__(KVBlockZeroer)
340 zeroer.device = device
341 zeroer._meta = (
342 torch.tensor([storage.data_ptr()], dtype=torch.uint64, device=device),
343 torch.tensor([page_size_el], dtype=torch.int64, device=device),
344 torch.tensor([page_size_el], dtype=torch.int64, device=device),
345 1,
346 page_size_el,
347 1,
348 )
349
350 zeroer.warmup(0)
351 torch.accelerator.synchronize()
352
353 assert torch.all(storage == 1)
354
355
356 @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
357 @pytest.mark.parametrize("layout", list(KVCacheLayout))
358 def test_zeroes_exactly_one_block_per_layer(layout: KVCacheLayout):
359 """The zeroer must zero every byte of the target block in every layer and nothing
360 outside it — per head-group region under LHBNC, and never past the target block's
361 tile under block-major layouts (no out-of-bounds writes, no clobbering)."""
362 device = torch.device("cuda")
363 num_blocks, num_layers = 4, 2
364 spec = FullAttentionSpec(
365 block_size=4, num_kv_heads=2, head_size=8, dtype=torch.float32
366 )
367 raw = torch.empty(
368 num_blocks * num_layers * spec.page_size_bytes,
369 dtype=torch.int8,
370 device=device,
371 ).fill_(1)
372 views = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
373 groups = [
374 AttentionGroup(
375 backend=None,
376 layer_names=[f"layer.{i}" for i in range(num_layers)],
377 kv_cache_spec=spec,
378 kv_cache_group_id=0,
379 )
380 ]
381 ctx = {f"layer.{i}": SimpleNamespace(kv_cache=views[i]) for i in range(num_layers)}
382 zeroer = KVBlockZeroer(
383 device,
384 attn_groups_iter=iter(groups),
385 kernel_block_sizes=[spec.block_size],
386 static_forward_context=ctx,
387 num_blocks=num_blocks,
388 )
389 zeroer.zero_block_ids([2])
390 torch.accelerator.synchronize()
391
392 for view in views:
393 assert (view[2] == 0).all(), layout
394 for b in (0, 1, 3):
395 assert (view[b].view(torch.int8) == 1).all(), layout
396 zero_bytes = int((raw == 0).sum().item())
397 assert zero_bytes == num_layers * spec.page_size_bytes, layout