main
py 886 lines 34.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """mm_prefix query-range metadata and the FA4 mask_mod that consumes it.
4
5 The metadata tests run anywhere. The FA4 tests need SM100 and run the real
6 kernel against a dense float32 reference, which is the only way to check what
7 cannot be reasoned about from the Python side:
8
9 * ``q_ranges[token_idx, 0]`` indexing an aux tensor with a runtime ``Int32``.
10 * ``cu_seqlens_q[b] + q_local`` matching FA4's own packing of a varlen batch.
11
12 The mask_mod is the *entire* mask (``(causal ∧ window) ∨ mm_prefix``), so
13 callers must pass ``causal=False`` and no FA-layer window. FA #155 stopped
14 auto-clearing those when ``mask_mod`` is set; leaving ``causal=True`` would
15 short out the mask_mod on SM90 and clip bidirectional ranges on SM100.
16 """
17
18 import numpy as np
19 import pytest
20 import torch
21
22 from vllm.platforms import current_platform
23 from vllm.v1.attention.backends.utils import (
24 fill_mm_prefix_query_ranges,
25 )
26 from vllm.v1.kv_cache_interface import KVCacheLayout
27
28
29 def _fa4_available() -> bool:
30 if not current_platform.is_cuda():
31 return False
32 from vllm.v1.attention.backends.fa_utils import is_fa_version_supported
33
34 return is_fa_version_supported(4)
35
36
37 requires_fa4 = pytest.mark.skipif(
38 not _fa4_available(), reason="FA4 mm_prefix mask_mod requires FA4"
39 )
40
41 # Imported conditionally because these pull in CuTe / FA4 build artifacts that
42 # are absent wherever requires_fa4 skips.
43 if _fa4_available():
44 from types import MethodType
45
46 from tests.v1.attention.test_attention_backends import (
47 MockAttentionLayer,
48 create_and_prepopulate_kv_cache,
49 )
50 from tests.v1.attention.utils import (
51 BatchSpec,
52 create_common_attn_metadata,
53 create_standard_kv_cache_spec,
54 create_vllm_config,
55 )
56 from vllm.config import set_current_vllm_config
57 from vllm.v1.attention.backends.fa_utils import get_flash_attn_version
58 from vllm.v1.attention.backends.flash_attn import (
59 FlashAttentionImpl,
60 FlashAttentionMetadataBuilder,
61 _make_mm_prefix_mask_mod,
62 )
63 from vllm.v1.kv_cache_interface import FullAttentionSpec, get_kv_quant_mode
64 from vllm.vllm_flash_attn.flash_attn_interface import flash_attn_varlen_func
65
66 DEVICE = torch.device("cuda:0")
67 DTYPE = torch.bfloat16
68 HEAD_SIZE = 128
69 NUM_HEADS = 8
70 NUM_KV_HEADS = 8
71 SLIDING_WINDOW_LEFT = 129
72
73
74 # --------------------------------------------------------------------------- #
75 # Metadata construction
76 # --------------------------------------------------------------------------- #
77
78 # Stands in for the builder's persistent (max_num_batched_tokens, 2) buffer.
79 STAGING_CAPACITY = 64
80
81
82 def _query_ranges(mm_ranges, query_lens, seq_lens):
83 """Fill a staging buffer and return the written rows, or None if empty."""
84 query_start_loc = torch.tensor(
85 [0, *torch.tensor(query_lens).cumsum(0).tolist()], dtype=torch.int32
86 )
87 # Poison the buffer so a stale row surviving the fill is visible.
88 out = np.full((STAGING_CAPACITY, 2), 12345, dtype=np.int32)
89 num_tokens = fill_mm_prefix_query_ranges(
90 out,
91 mm_ranges,
92 query_start_loc,
93 torch.tensor(seq_lens, dtype=torch.int32),
94 )
95 if num_tokens == 0:
96 return None
97 return torch.from_numpy(out[:num_tokens])
98
99
100 def test_matches_range_scan_semantics_with_context_offset():
101 """Pin the equivalence the O(1) lookup rests on.
102
103 The kernel checks ``r_start <= kv_idx <= r_end`` for the range containing
104 the query token, which is only equal to the old scan's
105 ``any(q in r and kv in r)`` because ranges never overlap. Request 1 carries
106 a context offset so the local-to-absolute query mapping is exercised too.
107 """
108 mm_ranges = {0: [(1, 3), (5, 7)], 1: [(2, 4), (9, 12)]}
109 query_lens = [8, 6]
110 seq_lens = [8, 13]
111
112 query_ranges = _query_ranges(mm_ranges, query_lens, seq_lens)
113 assert query_ranges is not None
114
115 token_start = 0
116 for req_idx, query_len in enumerate(query_lens):
117 context_len = seq_lens[req_idx] - query_len
118 for q_local in range(query_len):
119 q_abs = context_len + q_local
120 r_start, r_end = query_ranges[token_start + q_local].tolist()
121 for kv_idx in range(seq_lens[req_idx]):
122 old_scan_keep = any(
123 start < end and start <= q_abs <= end and start <= kv_idx <= end
124 for start, end in mm_ranges[req_idx]
125 )
126 new_keep = r_start <= kv_idx <= r_end
127 assert new_keep == old_scan_keep, (req_idx, q_abs, kv_idx)
128 token_start += query_len
129
130
131 def test_ranges_beyond_scheduled_chunk_are_clipped():
132 """Chunked prefill must not error or produce a wrong mask.
133
134 ``disable_chunked_mm_input`` keeps a single mm item intact but still splits
135 a prompt across steps, so a request's ranges routinely sit entirely past the
136 tokens scheduled so far. Sizing by query token makes the out-of-chunk part
137 a no-op rather than an out-of-bounds condition.
138 """
139 # Step 1 of "TTTT IIIIII": only the 4 text tokens are scheduled, so the
140 # range is entirely ahead of the chunk. The old bounds check raised here.
141 assert _query_ranges({0: [(4, 9)]}, query_lens=[4], seq_lens=[4]) is None
142
143 # A range starting mid-chunk and running past its end keeps its absolute
144 # bounds, so the bidirectional block still reaches the range's later keys
145 # once they are cached. Query tokens here are absolute positions 2..5.
146 query_ranges = _query_ranges({0: [(4, 9)]}, query_lens=[4], seq_lens=[6])
147 assert query_ranges is not None
148 expected = torch.tensor([[-1, -1], [-1, -1], [4, 9], [4, 9]], dtype=torch.int32)
149 torch.testing.assert_close(query_ranges, expected)
150
151
152 def test_buffer_reuse_does_not_leak_previous_rows():
153 """The staging buffer persists across steps, so every reported row must be
154 rewritten. Guards against dropping the ``-1`` fill as an optimization: a
155 step whose ranges shrink would otherwise expose the prior step's bounds and
156 silently widen the bidirectional mask.
157 """
158 out = np.zeros((STAGING_CAPACITY, 2), dtype=np.int32)
159 query_start_loc = torch.tensor([0, 8], dtype=torch.int32)
160 seq_lens = torch.tensor([8], dtype=torch.int32)
161
162 num_tokens = fill_mm_prefix_query_ranges(
163 out, {0: [(0, 7)]}, query_start_loc, seq_lens
164 )
165 assert num_tokens == 8
166 np.testing.assert_array_equal(out[:8], np.tile([0, 7], (8, 1)))
167
168 # Same rows scheduled, but now only tokens 2..3 sit inside a range.
169 num_tokens = fill_mm_prefix_query_ranges(
170 out, {0: [(2, 3)]}, query_start_loc, seq_lens
171 )
172 assert num_tokens == 8
173 expected = np.full((8, 2), -1, dtype=np.int32)
174 expected[2:4] = (2, 3)
175 np.testing.assert_array_equal(out[:8], expected)
176
177
178 def test_returns_none_when_no_range_covers_a_query_token():
179 """No aux tensor means forward() skips the mask_mod entirely.
180
181 Returning None rather than an all-``-1`` tensor is what keeps text-only and
182 decode-only batches off the fill path.
183 """
184 # Text-only batch on an mm_prefix model: every request present, no ranges.
185 assert _query_ranges({0: [], 1: []}, query_lens=[2, 2], seq_lens=[2, 2]) is None
186 # Degenerate single-token ranges are skipped, matching the Triton path's
187 # `start < end` validity check.
188 assert _query_ranges({0: [(1, 1)]}, query_lens=[4], seq_lens=[4]) is None
189 # Decode rows: the query token is generated, so it is in no range.
190 assert _query_ranges({0: [(1, 3)]}, query_lens=[1], seq_lens=[9]) is None
191
192
193 # --------------------------------------------------------------------------- #
194 # FA4 kernel
195 # --------------------------------------------------------------------------- #
196
197
198 def _cu_seqlens(lens: list[int]) -> torch.Tensor:
199 out = torch.zeros(len(lens) + 1, dtype=torch.int32)
200 out[1:] = torch.tensor(lens, dtype=torch.int32).cumsum(0)
201 return out
202
203
204 def _dense_reference(
205 q,
206 k_per_req,
207 v_per_req,
208 query_lens,
209 seq_lens,
210 mm_ranges,
211 sliding_window_left,
212 scale,
213 mm_clamp_sw=0,
214 ):
215 """Dense float32 ``(causal AND window) OR mm_prefix`` per request.
216
217 ``mm_clamp_sw`` confines the bidirectional block to the window, reproducing
218 Gemma4's ``mm_prefix_clamp_sliding_window``. Only the past bound applies, so
219 future keys inside a range still pass.
220 """
221 out = torch.empty_like(q, dtype=torch.float32)
222 repeats = q.shape[1] // k_per_req[0].shape[1]
223 q_off = 0
224 for req_idx, (q_len, k_len) in enumerate(zip(query_lens, seq_lens)):
225 ctx = k_len - q_len
226 q_pos = torch.arange(q_len, device=q.device) + ctx
227 k_pos = torch.arange(k_len, device=q.device)
228 delta = q_pos[:, None] - k_pos[None, :]
229
230 keep = delta >= 0
231 if sliding_window_left is not None:
232 keep &= delta < sliding_window_left
233
234 for start, end in mm_ranges.get(req_idx, []):
235 if start >= end:
236 continue
237 q_in = (q_pos >= start) & (q_pos <= end)
238 k_in = (k_pos >= start) & (k_pos <= end)
239 mm = q_in[:, None] & k_in[None, :]
240 if mm_clamp_sw > 0:
241 mm &= delta < mm_clamp_sw
242 keep |= mm
243
244 q_i = q[q_off : q_off + q_len].float().transpose(0, 1)
245 k_i = k_per_req[req_idx].float().transpose(0, 1)
246 v_i = v_per_req[req_idx].float().transpose(0, 1)
247 if repeats > 1:
248 k_i = k_i.repeat_interleave(repeats, dim=0)
249 v_i = v_i.repeat_interleave(repeats, dim=0)
250
251 scores = (q_i @ k_i.transpose(-1, -2)) * scale
252 scores = scores.masked_fill(~keep[None], float("-inf"))
253 out[q_off : q_off + q_len] = (scores.softmax(-1) @ v_i).transpose(0, 1)
254 q_off += q_len
255 return out
256
257
258 def _split(packed: torch.Tensor, lens: list[int]) -> list[torch.Tensor]:
259 return list(packed.split(lens))
260
261
262 def _reference(q, k, v, query_lens, seq_lens, mm_ranges, sw_left, scale, **kwargs):
263 return _dense_reference(
264 q,
265 _split(k, seq_lens),
266 _split(v, seq_lens),
267 query_lens,
268 seq_lens,
269 mm_ranges,
270 sw_left,
271 scale,
272 **kwargs,
273 )
274
275
276 def _run_kernel(
277 q, k, v, query_lens, seq_lens, mm_ranges, sliding_window_left, scale, mm_clamp_sw=0
278 ):
279 cu_q = _cu_seqlens(query_lens)
280 cu_k = _cu_seqlens(seq_lens)
281
282 staging = torch.full((int(cu_q[-1]), 2), 12345, dtype=torch.int32).numpy()
283 num_rows = fill_mm_prefix_query_ranges(
284 staging, mm_ranges, cu_q, torch.tensor(seq_lens, dtype=torch.int32)
285 )
286 assert num_rows > 0, "test case must have at least one in-range query token"
287 q_ranges = torch.from_numpy(staging[:num_rows]).to(DEVICE)
288 cu_q_gpu = cu_q.to(DEVICE)
289
290 out = torch.empty_like(q)
291 flash_attn_varlen_func(
292 q=q,
293 k=k,
294 v=v,
295 out=out,
296 cu_seqlens_q=cu_q_gpu,
297 cu_seqlens_k=cu_k.to(DEVICE),
298 max_seqlen_q=max(query_lens),
299 max_seqlen_k=max(seq_lens),
300 softmax_scale=scale,
301 causal=False,
302 fa_version=4,
303 mask_mod=_make_mm_prefix_mask_mod(
304 sliding_window=mm_clamp_sw, sliding_window_left=sliding_window_left
305 ),
306 aux_tensors=[q_ranges, cu_q_gpu],
307 )
308 return out
309
310
311 def _randn_qkv(query_lens, seq_lens):
312 torch.manual_seed(0)
313 q = torch.randn(sum(query_lens), NUM_HEADS, HEAD_SIZE, dtype=DTYPE, device=DEVICE)
314 k = torch.randn(sum(seq_lens), NUM_KV_HEADS, HEAD_SIZE, dtype=DTYPE, device=DEVICE)
315 v = torch.randn(sum(seq_lens), NUM_KV_HEADS, HEAD_SIZE, dtype=DTYPE, device=DEVICE)
316 return q, k, v, HEAD_SIZE**-0.5
317
318
319 # (name, query_lens, seq_lens, mm_ranges). One shape per index computation the
320 # mask_mod performs; shapes that differ only in request count are omitted.
321 CASES = [
322 # cu_seqlens_q[b] + q_local: unequal query lens across the batch, the
323 # packing the pooling workload never produced.
324 (
325 "varlen_batch",
326 [128, 64, 200],
327 [128, 64, 200],
328 {0: [(8, 71)], 1: [(0, 31), (40, 55)], 2: [(64, 191)]},
329 ),
330 # q_abs = q_idx + (seqlen_k - seqlen_q): ranges are absolute prompt
331 # positions, so part of each range sits in the context.
332 (
333 "context_offset",
334 [64],
335 [320],
336 {0: [(32, 95), (200, 287)]},
337 ),
338 # Decode rows: the query token is generated, so it is in no range and must
339 # get the (-1, -1) sentinel. One prefill row keeps the batch mixed.
340 (
341 "mixed_prefill_decode",
342 [160, 1, 1, 1],
343 [160, 512, 300, 97],
344 {0: [(32, 127)], 1: [(4, 259)], 2: [(8, 71)], 3: [(1, 64)]},
345 ),
346 ]
347
348 # A 320-token range, wider than the window. Every CASES range is narrower than
349 # the window and therefore clamp-insensitive, so the window modes are exercised
350 # on this shape instead: images larger than the window are exactly why
351 # mm_prefix_clamp_sliding_window exists.
352 WIDE_RANGE_CASE = ([384], [384], {0: [(32, 351)]})
353
354 # (sliding_window_left, mm_clamp_sw). sliding_window_left selects between the
355 # two mask_mod variants; mm_clamp_sw gates the Gemma4 clamp inside the sliding
356 # one. Gemma4 sets mm_clamp_sw == sw_val, and the unclamped variant ignores
357 # mm_clamp_sw, so a clamp without a window is not a reachable configuration.
358 WINDOW_MODES = [
359 (None, 0),
360 (SLIDING_WINDOW_LEFT, 0),
361 (SLIDING_WINDOW_LEFT, SLIDING_WINDOW_LEFT),
362 ]
363 WINDOW_IDS = ["full_causal", "window", "window_clamped"]
364
365
366 @requires_fa4
367 @pytest.mark.parametrize("case", CASES, ids=[c[0] for c in CASES])
368 def test_mm_prefix_mask_mod_matches_dense_reference(case):
369 _, query_lens, seq_lens, mm_ranges = case
370 q, k, v, scale = _randn_qkv(query_lens, seq_lens)
371 args = (q, k, v, query_lens, seq_lens, mm_ranges, SLIDING_WINDOW_LEFT, scale)
372 torch.testing.assert_close(
373 _run_kernel(*args).float(), _reference(*args), atol=2e-2, rtol=2e-2
374 )
375
376
377 @requires_fa4
378 @pytest.mark.parametrize("window_mode", WINDOW_MODES, ids=WINDOW_IDS)
379 def test_window_modes_on_a_range_wider_than_the_window(window_mode):
380 """Cover the three reachable window configurations.
381
382 The clamped mode additionally asserts the clamp changes the mask at all,
383 without which a range narrower than the window would leave the clamp branch
384 unexercised while the test still passed.
385 """
386 sliding_window_left, mm_clamp_sw = window_mode
387 query_lens, seq_lens, mm_ranges = WIDE_RANGE_CASE
388 q, k, v, scale = _randn_qkv(query_lens, seq_lens)
389 args = (q, k, v, query_lens, seq_lens, mm_ranges, sliding_window_left, scale)
390
391 expected = _reference(*args, mm_clamp_sw=mm_clamp_sw)
392 if mm_clamp_sw:
393 assert not torch.allclose(
394 expected, _reference(*args, mm_clamp_sw=0), atol=2e-2, rtol=2e-2
395 ), "range is not wide enough to exercise the clamp branch"
396
397 actual = _run_kernel(*args, mm_clamp_sw=mm_clamp_sw)
398 torch.testing.assert_close(actual.float(), expected, atol=2e-2, rtol=2e-2)
399
400
401 @requires_fa4
402 def test_ranges_outside_chunk_degrade_to_causal():
403 """A range fully behind the scheduled chunk must not widen the mask.
404
405 Under chunked prefill a request's ranges routinely sit entirely in the
406 already-computed context. Indexing by query token makes those a no-op; the
407 result must be identical to running with no ranges at all.
408 """
409 query_lens, seq_lens = [64], [512]
410 q, k, v, scale = _randn_qkv(query_lens, seq_lens)
411
412 # Range (16, 79) is entirely below the chunk start (448), plus one live
413 # range so the fill still reports rows.
414 args = (q, k, v, query_lens, seq_lens)
415 actual = _run_kernel(*args, {0: [(16, 79), (448, 511)]}, None, scale)
416 expected = _reference(*args, {0: [(448, 511)]}, None, scale)
417 torch.testing.assert_close(actual.float(), expected, atol=2e-2, rtol=2e-2)
418
419
420 # --------------------------------------------------------------------------- #
421 # FA4 through the metadata builder and the paged KV cache
422 # --------------------------------------------------------------------------- #
423
424 # Stock google/gemma-4-E2B-it ships text_config.use_bidirectional_attention as
425 # null, so is_mm_prefix_lm is False out of the box. Tests that exercise the
426 # builder must flip it via `_enable_mm_prefix`. Only config.json / tokenizer
427 # are read; weights are never loaded.
428 MODEL = "google/gemma-4-E2B-it"
429
430 BLOCK_SIZE = 16
431 SLIDING_WINDOW = 256
432
433 # Request 0 is a prefill chunk whose range starts mid-context; requests 1-3 are
434 # decodes whose generated token sits outside every range.
435 PAGED_SEQ_LENS = [352, 513, 200, 97]
436 PAGED_QUERY_LENS = [96, 1, 1, 1]
437 PAGED_MM_RANGES = {
438 0: [(224, 287), (300, 351)],
439 1: [(4, 259)],
440 2: [(0, 63), (80, 143)],
441 3: [(1, 64)],
442 }
443
444
445 def _enable_mm_prefix(vllm_config):
446 """Flip stock Gemma4's null use_bidirectional_attention to "vision".
447
448 ``ModelConfig.__init__`` derives ``model_arch_config`` once from
449 ``hf_text_config``, and ``is_mm_prefix_lm`` is a ``cached_property`` that
450 may already hold False from that first derivation, so re-derive and drop
451 the cache.
452 """
453 model_config = vllm_config.model_config
454 model_config.hf_text_config.use_bidirectional_attention = "vision"
455 model_config.model_arch_config = model_config.get_model_arch_config()
456 model_config.__dict__.pop("is_mm_prefix_lm", None)
457 return vllm_config
458
459
460 @requires_fa4
461 def test_decode_only_batch_reports_no_ranges():
462 """Decode-only steps must carry no mm_prefix metadata at all.
463
464 This is what makes FULL CUDA graph capture consistent for this feature.
465 Capture runs through ``_dummy_run`` with no multimodal requests, so no
466 mask_mod is attached to the captured graph; a decode-only replay must
467 therefore also want no mask_mod, or the captured graph would silently drop
468 it. Indexing by query token gives that for free, because a generated token
469 is always past every prompt range.
470
471 The range-id scheme keyed off the request having ranges at all, so it
472 attached a mask_mod to decode steps and did not have this property.
473 """
474 vllm_config = _enable_mm_prefix(
475 create_vllm_config(
476 model_name=MODEL,
477 max_model_len=1024,
478 block_size=BLOCK_SIZE,
479 num_gpu_blocks=256,
480 )
481 )
482 decode_batch = BatchSpec(
483 seq_lens=[513, 200, 97], query_lens=[1, 1, 1], name="decode_only"
484 )
485 common = create_common_attn_metadata(decode_batch, BLOCK_SIZE, DEVICE)
486 common.mm_req_doc_ranges = {0: [(4, 259)], 1: [(0, 63)], 2: [(1, 64)]}
487
488 builder = FlashAttentionMetadataBuilder(
489 create_standard_kv_cache_spec(vllm_config),
490 ["model.layers.0.self_attn.attn"],
491 vllm_config,
492 DEVICE,
493 )
494 md = builder.build(common_prefix_len=0, common_attn_metadata=common)
495 assert md.mm_prefix_query_range_tensor is None
496
497
498 @requires_fa4
499 @pytest.mark.parametrize(
500 "head_size",
501 [
502 # 128: FA4's default SM100 path (not the dedicated hd256 kernel), so
503 # paged KV + seqused_k works on B200 and H100. Non-paged mm_prefix
504 # cases in this file already use HEAD_SIZE=128.
505 pytest.param(128, id="hd128"),
506 # 512: Gemma4 global head dim; FA4 resolves on H100, skips on
507 # Blackwell (TMEM).
508 pytest.param(512, id="global_512"),
509 ],
510 )
511 def test_mm_prefix_kv_cache_path(head_size: int):
512 """FA4 + mask_mod + block_table matches a dense reference.
513
514 Use head_size=128 on B200/H100 (FA4 + paged/`seqused_k` supported) and
515 512 on H100 only (Blackwell FA4 rejects 512 via TMEM). Avoid 256: SM100's
516 dedicated hd256 2CTA kernel asserts away ``seqused_k``, which paged KV
517 requires.
518
519 Tensors, kv_cache_spec, and FlashAttentionImpl must all use the same
520 ``head_size`` — ``run_attention_backend`` / ``create_standard_kv_cache_spec``
521 read ``get_head_size()`` (=512 for Gemma4), so this test builds them
522 explicitly. The sliding window is set on the impl; mm_prefix encodes it in
523 mask_mod and clears FA's built-in window at forward time.
524 """
525 torch.manual_seed(0)
526 vllm_config = _enable_mm_prefix(
527 create_vllm_config(
528 model_name=MODEL,
529 max_model_len=max(PAGED_SEQ_LENS),
530 block_size=BLOCK_SIZE,
531 num_gpu_blocks=2048,
532 )
533 )
534 assert vllm_config.model_config.is_mm_prefix_lm
535
536 mc = vllm_config.model_config
537 # Keep every get_head_size() reader (builder helpers, etc.) on the same dim.
538 mc.get_head_size = MethodType(lambda self: head_size, mc)
539
540 # Skip under the same config Gemma4 forces (flash_attn_version=4), so the
541 # check matches FlashAttentionImpl's get_flash_attn_version call.
542 with set_current_vllm_config(vllm_config):
543 if get_flash_attn_version(head_size=head_size) != 4:
544 pytest.skip(f"FA4 does not support head_size={head_size} on this device")
545
546 num_heads = mc.get_num_attention_heads(vllm_config.parallel_config)
547 num_kv_heads = mc.get_num_kv_heads(vllm_config.parallel_config)
548 scale = head_size**-0.5
549
550 batch = BatchSpec(
551 seq_lens=PAGED_SEQ_LENS, query_lens=PAGED_QUERY_LENS, name="mm_prefix_mixed"
552 )
553 kv_cache_spec = FullAttentionSpec(
554 block_size=BLOCK_SIZE,
555 num_kv_heads=num_kv_heads,
556 head_size=head_size,
557 dtype=mc.dtype,
558 sliding_window=SLIDING_WINDOW,
559 kv_quant_mode=get_kv_quant_mode(vllm_config.cache_config.cache_dtype),
560 )
561 common = create_common_attn_metadata(batch, BLOCK_SIZE, DEVICE)
562 common.mm_req_doc_ranges = PAGED_MM_RANGES
563
564 qs, new_ks, new_vs, k_fulls, v_fulls, k_ctxs, v_ctxs = [], [], [], [], [], [], []
565 for q_len, s_len in zip(PAGED_QUERY_LENS, PAGED_SEQ_LENS):
566 ctx = s_len - q_len
567 shape = (num_kv_heads, head_size)
568 qs.append(torch.randn(q_len, num_heads, head_size, dtype=DTYPE, device=DEVICE))
569 k_full = torch.randn(s_len, *shape, dtype=DTYPE, device=DEVICE)
570 v_full = torch.randn(s_len, *shape, dtype=DTYPE, device=DEVICE)
571 k_fulls.append(k_full)
572 v_fulls.append(v_full)
573 k_ctxs.append(k_full[:ctx])
574 v_ctxs.append(v_full[:ctx])
575 new_ks.append(k_full[ctx:])
576 new_vs.append(v_full[ctx:])
577
578 query = torch.cat(qs)
579 key = torch.cat(new_ks)
580 value = torch.cat(new_vs)
581 kv_cache = create_and_prepopulate_kv_cache(
582 k_contexts=k_ctxs,
583 v_contexts=v_ctxs,
584 block_size=BLOCK_SIZE,
585 num_kv_heads=num_kv_heads,
586 head_size=head_size,
587 dtype=DTYPE,
588 device=DEVICE,
589 num_blocks=2048,
590 common_attn_metadata=common,
591 layout=KVCacheLayout.LBNHC,
592 randomize_blocks=True,
593 )
594
595 # Packed KV layout is [..., 2 * head_size]; impl.split(head_size) needs match.
596 assert kv_cache.shape[-1] == 2 * head_size
597
598 layer_names = ["model.layers.0.self_attn.attn"]
599 with set_current_vllm_config(vllm_config):
600 builder = FlashAttentionMetadataBuilder(
601 kv_cache_spec, layer_names, vllm_config, DEVICE
602 )
603 attn_metadata = builder.build(common_prefix_len=0, common_attn_metadata=common)
604 # head_size=128 keeps FA4 under local attention on Blackwell (the
605 # FA4→FA2 demotion is only for local+256). Window is still encoded in
606 # mask_mod at forward; FA's built-in window is cleared there.
607 impl = FlashAttentionImpl(
608 num_heads=num_heads,
609 head_size=head_size,
610 scale=scale,
611 num_kv_heads=num_kv_heads,
612 alibi_slopes=None,
613 sliding_window=SLIDING_WINDOW,
614 kv_cache_dtype="auto",
615 )
616 assert impl.vllm_flash_attn_version == 4, (
617 f"expected FA4, got FA{impl.vllm_flash_attn_version} for "
618 f"head_size={head_size}"
619 )
620 assert impl.head_size == head_size == kv_cache_spec.head_size
621
622 mock_layer = MockAttentionLayer(DEVICE)
623 output = torch.empty_like(query)
624 impl.do_kv_cache_update(
625 mock_layer, key, value, kv_cache, attn_metadata.slot_mapping
626 )
627 actual = impl.forward(
628 mock_layer, query, key, value, kv_cache, attn_metadata, output=output
629 )
630
631 ref_args = (query, k_fulls, v_fulls, PAGED_QUERY_LENS, PAGED_SEQ_LENS)
632 expected = _dense_reference(*ref_args, PAGED_MM_RANGES, SLIDING_WINDOW, scale)
633 torch.testing.assert_close(actual.float(), expected, atol=2e-2, rtol=2e-2)
634
635 # Without this the assertion above would also pass if the mask_mod were
636 # silently dropped, since the batch would then be plain causal + window.
637 causal_only = _dense_reference(*ref_args, {}, SLIDING_WINDOW, scale)
638 assert not torch.allclose(expected, causal_only, atol=2e-2, rtol=2e-2), (
639 "test batch does not actually exercise the mm_prefix branch"
640 )
641
642
643 @pytest.mark.skipif(not current_platform.is_cuda(), reason="CUDA backend imports")
644 @pytest.mark.parametrize(
645 "query_len,seq_len,spans,expected,unclamped_expected",
646 [
647 (16, 32, [(20, 28)], True, True),
648 (16, 32, [(0, 16)], False, True),
649 (16, 32, [(0, 17)], True, True),
650 (16, 32, [(32, 40)], False, False),
651 (16, 32, [(31, 40)], False, True),
652 (1, 24, [(20, 28)], False, True),
653 (0, 24, [(20, 28)], False, False),
654 (16, 32, [(20, 20)], False, False),
655 (16, 64, [(20, 28)], False, False),
656 ],
657 )
658 def test_composite_routes_queries_that_need_image_masking(
659 query_len, seq_len, spans, expected, unclamped_expected
660 ):
661 """Historical images and single-query steps must use the causal graph path."""
662 from types import SimpleNamespace
663
664 from vllm.v1.attention.backends.composite import requires_mm_prefix
665
666 common = SimpleNamespace(
667 max_query_len=query_len,
668 mm_req_doc_ranges={0: spans},
669 query_start_loc_cpu=torch.tensor([0, query_len]),
670 seq_lens_cpu_upper_bound=torch.tensor([seq_len]),
671 )
672 assert requires_mm_prefix(common) is expected
673 assert requires_mm_prefix(common, unclamped_window=True) is unclamped_expected
674
675
676 @pytest.mark.skipif(not current_platform.is_cuda(), reason="CUDA attention kernels")
677 @pytest.mark.parametrize("head_size", [256, 512])
678 @pytest.mark.parametrize(
679 "reverse_children,cache_dtype",
680 [(False, "auto"), (True, "auto"), (False, "fp8_e4m3")],
681 )
682 @pytest.mark.parametrize("causal_backend_name", ["FLASHINFER", "FLASH_ATTN"])
683 def test_composite_shared_cache_across_image_and_causal_steps(
684 head_size, reverse_children, cache_dtype, causal_backend_name
685 ):
686 """Changing routes must preserve KV writes, image masking and graph replay."""
687 if causal_backend_name == "FLASHINFER":
688 if not current_platform.is_device_capability_family(100):
689 pytest.skip("The Triton/FlashInfer composite supports Blackwell")
690 else:
691 if not current_platform.is_device_capability(90):
692 pytest.skip("The Triton/FlashAttention composite supports Hopper")
693 if reverse_children or cache_dtype != "auto":
694 pytest.skip("Reverse-child and FP8 coverage is backend-independent")
695
696 from vllm.config import set_current_vllm_config
697 from vllm.engine.arg_utils import EngineArgs
698 from vllm.model_executor.layers.attention import Attention
699 from vllm.utils.torch_utils import set_default_torch_dtype
700 from vllm.v1.attention.backend import AttentionCGSupport, CommonAttentionMetadata
701 from vllm.v1.attention.backends.composite import (
702 MMPrefixAttentionRouting,
703 create_composite_attention_backend,
704 )
705 from vllm.v1.attention.backends.flash_attn import (
706 FlashAttentionMetadata,
707 )
708 from vllm.v1.attention.backends.flashinfer import (
709 FlashInferBackend,
710 FlashInferMetadata,
711 FlashInferTrtllmAPIDecode,
712 TRTLLMPrefill,
713 )
714 from vllm.v1.attention.backends.triton_attn import (
715 TritonAttentionBackend,
716 TritonAttentionMetadata,
717 )
718 from vllm.v1.attention.backends.triton_flash_attn import (
719 TritonFlashAttentionBackend,
720 )
721 from vllm.v1.attention.backends.triton_flashinfer import (
722 TritonFlashInferBackend,
723 )
724 from vllm.v1.kv_cache_interface import FullAttentionSpec, SlidingWindowSpec
725
726 if causal_backend_name == "FLASHINFER":
727 backend = TritonFlashInferBackend
728 causal_metadata_type = FlashInferMetadata
729 block_size = backend.get_supported_kernel_block_sizes()[0]
730 else:
731 backend = TritonFlashAttentionBackend
732 causal_metadata_type = FlashAttentionMetadata
733 block_size = backend.get_preferred_block_size(16)
734 num_blocks = (209 + block_size - 1) // block_size
735 torch.manual_seed(42)
736 attention_config = {"backend": backend.get_name()}
737 if causal_backend_name == "FLASH_ATTN":
738 attention_config["flash_attn_version"] = 4
739 cfg = EngineArgs(
740 model="google/gemma-4-31B-it",
741 dtype="bfloat16",
742 max_model_len=512,
743 max_num_seqs=4,
744 max_num_batched_tokens=512,
745 block_size=block_size,
746 kv_cache_dtype=cache_dtype,
747 enforce_eager=True,
748 attention_config=attention_config,
749 ).create_engine_config()
750 cfg.cache_config.kv_cache_layout = "LBHNC"
751 window = 128 if head_size == 256 else None
752 storage_dtype = torch.uint8 if cache_dtype == "fp8_e4m3" else DTYPE
753 spec_kwargs = dict(
754 block_size=block_size,
755 num_kv_heads=16,
756 head_size=head_size,
757 dtype=storage_dtype,
758 kv_quant_mode=get_kv_quant_mode(cache_dtype),
759 )
760 spec = (
761 SlidingWindowSpec(**spec_kwargs, sliding_window=window)
762 if window
763 else FullAttentionSpec(**spec_kwargs)
764 )
765 if reverse_children:
766 # Swapping the children and policy must preserve outputs and KV writes;
767 # dispatch cannot depend on a particular child's class or position.
768 class ReversedRouting(MMPrefixAttentionRouting):
769 capture_variant = 0
770
771 def select(self, metadata):
772 return 1 - super().select(metadata)
773
774 @staticmethod
775 def variant_uses_mm_prefix(variant):
776 return variant == 1
777
778 backend = create_composite_attention_backend(
779 FlashInferBackend,
780 TritonAttentionBackend,
781 name="ReversedCompositeBackend",
782 backend_name="CUSTOM",
783 module=__name__,
784 routing_policy=ReversedRouting,
785 )
786 with set_current_vllm_config(cfg), set_default_torch_dtype(DTYPE):
787 layer = Attention(
788 32,
789 head_size,
790 head_size**-0.5,
791 num_kv_heads=16,
792 cache_config=cfg.cache_config,
793 prefix="composite_test",
794 per_layer_sliding_window=window,
795 attn_backend=backend,
796 ).to(DEVICE)
797 layer.mm_prefix_clamp_sliding_window = window is not None
798 assert backend.get_builder_cls().get_cudagraph_support(cfg, spec) == (
799 AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE
800 )
801 if window:
802 layer.mm_prefix_clamp_sliding_window = False
803 assert backend.get_builder_cls().get_cudagraph_support(cfg, spec) == (
804 AttentionCGSupport.NEVER
805 )
806 layer.mm_prefix_clamp_sliding_window = True
807 builder = backend.get_builder_cls()(spec, ["composite_test"], cfg, DEVICE)
808 builder.set_kernel_block_size(block_size)
809 # Match production, where unwritten page tails are zero-initialized.
810 cache = torch.zeros(
811 num_blocks,
812 16,
813 block_size,
814 2 * head_size,
815 dtype=storage_dtype,
816 device=DEVICE,
817 )
818 keys = torch.randn(209, 16, head_size, dtype=DTYPE, device=DEVICE)
819 values = torch.randn_like(keys)
820 if cache_dtype == "fp8_e4m3":
821 keys = keys.to(torch.float8_e4m3fn).to(DTYPE)
822 values = values.to(torch.float8_e4m3fn).to(DTYPE)
823 table = torch.arange(num_blocks, dtype=torch.int32, device=DEVICE).unsqueeze(0)
824 previous = 0
825 for end, spans, metadata_type in [
826 (128, [(32, 111)], TritonAttentionMetadata),
827 (129, [(32, 111)], causal_metadata_type),
828 (145, [(32, 111)], causal_metadata_type),
829 (209, [(32, 111), (160, 207)], TritonAttentionMetadata),
830 ]:
831 qlen = end - previous
832 starts = torch.tensor([0, qlen], dtype=torch.int32)
833 lengths = torch.tensor([end], dtype=torch.int32)
834 common = CommonAttentionMetadata(
835 query_start_loc=starts.to(DEVICE),
836 query_start_loc_cpu=starts,
837 seq_lens=lengths.to(DEVICE),
838 seq_lens_cpu_upper_bound=lengths,
839 num_reqs=1,
840 num_actual_tokens=qlen,
841 max_query_len=qlen,
842 max_seq_len=end,
843 block_table_tensor=table,
844 slot_mapping=torch.arange(previous, end, device=DEVICE),
845 mm_req_doc_ranges={0: spans},
846 )
847 metadata = builder.build(0, common)
848 assert isinstance(metadata, metadata_type)
849 if isinstance(metadata, FlashInferMetadata):
850 if qlen == 1:
851 assert isinstance(metadata.decode, FlashInferTrtllmAPIDecode)
852 else:
853 assert isinstance(metadata.prefill, TRTLLMPrefill)
854 query = torch.randn(qlen, 32, head_size, dtype=DTYPE, device=DEVICE)
855 if cache_dtype == "fp8_e4m3":
856 query = query.to(torch.float8_e4m3fn).to(DTYPE)
857 key, value = keys[previous:end], values[previous:end]
858 output = torch.empty_like(query)
859 layer.impl.do_kv_cache_update(layer, key, value, cache, common.slot_mapping)
860 layer.impl.forward(layer, query, key, value, cache, metadata, output)
861 expected = _dense_reference(
862 query,
863 [keys[:end]],
864 [values[:end]],
865 [qlen],
866 [end],
867 {0: spans},
868 window,
869 head_size**-0.5,
870 mm_clamp_sw=window or 0,
871 )
872 torch.testing.assert_close(output.float(), expected, atol=2e-2, rtol=2e-2)
873 if qlen == 1:
874 metadata = builder.build_for_cudagraph_capture(common)
875 assert isinstance(metadata, metadata_type)
876 graph = torch.cuda.CUDAGraph()
877 with torch.cuda.graph(graph):
878 layer.impl.forward(
879 layer, query, key, value, cache, metadata, output
880 )
881 output.zero_()
882 graph.replay()
883 torch.testing.assert_close(
884 output.float(), expected, atol=2e-2, rtol=2e-2
885 )
886 previous = end