[Kernel][Model] Optimize FA4 mm_prefix range lookup (#50294)
Signed-off-by: Juqi Li <2223621784@qq.com> Signed-off-by: Matthew Bonanni <mbonanni@redhat.com> Co-authored-by: Matthew Bonanni <mbonanni@redhat.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Li Juqi committed
Aug 5, 2026 at 22:33 UTC
66b3c0e61f1e477820212201adf1ed871df7ee98
5 files changed
+819
-45
tests/v1/attention/test_mm_prefix.py
new
+636
@@ -0,0 +1,636 @@
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 fill_mm_prefix_query_ranges
24
+
25
+
26
+def _fa4_available() -> bool:
27
+ if not current_platform.is_cuda():
28
+ return False
29
+ from vllm.v1.attention.backends.fa_utils import is_fa_version_supported
30
+
31
+ return is_fa_version_supported(4)
32
+
33
+
34
+requires_fa4 = pytest.mark.skipif(
35
+ not _fa4_available(), reason="FA4 mm_prefix mask_mod requires FA4"
36
+)
37
+
38
+# Imported conditionally because these pull in CuTe / FA4 build artifacts that
39
+# are absent wherever requires_fa4 skips.
40
+if _fa4_available():
41
+ from types import MethodType
42
+
43
+ from tests.v1.attention.test_attention_backends import (
44
+ MockAttentionLayer,
45
+ create_and_prepopulate_kv_cache,
46
+ )
47
+ from tests.v1.attention.utils import (
48
+ BatchSpec,
49
+ create_common_attn_metadata,
50
+ create_standard_kv_cache_spec,
51
+ create_vllm_config,
52
+ )
53
+ from vllm.config import set_current_vllm_config
54
+ from vllm.v1.attention.backends.fa_utils import get_flash_attn_version
55
+ from vllm.v1.attention.backends.flash_attn import (
56
+ FlashAttentionImpl,
57
+ FlashAttentionMetadataBuilder,
58
+ _make_mm_prefix_mask_mod,
59
+ )
60
+ from vllm.v1.kv_cache_interface import FullAttentionSpec, get_kv_quant_mode
61
+ from vllm.vllm_flash_attn.flash_attn_interface import flash_attn_varlen_func
62
+
63
+DEVICE = torch.device("cuda:0")
64
+DTYPE = torch.bfloat16
65
+HEAD_SIZE = 128
66
+NUM_HEADS = 8
67
+NUM_KV_HEADS = 8
68
+SLIDING_WINDOW_LEFT = 129
69
+
70
+
71
+# --------------------------------------------------------------------------- #
72
+# Metadata construction
73
+# --------------------------------------------------------------------------- #
74
+
75
+# Stands in for the builder's persistent (max_num_batched_tokens, 2) buffer.
76
+STAGING_CAPACITY = 64
77
+
78
+
79
+def _query_ranges(mm_ranges, query_lens, seq_lens):
80
+ """Fill a staging buffer and return the written rows, or None if empty."""
81
+ query_start_loc = torch.tensor(
82
+ [0, *torch.tensor(query_lens).cumsum(0).tolist()], dtype=torch.int32
83
+ )
84
+ # Poison the buffer so a stale row surviving the fill is visible.
85
+ out = np.full((STAGING_CAPACITY, 2), 12345, dtype=np.int32)
86
+ num_tokens = fill_mm_prefix_query_ranges(
87
+ out,
88
+ mm_ranges,
89
+ query_start_loc,
90
+ torch.tensor(seq_lens, dtype=torch.int32),
91
+ )
92
+ if num_tokens == 0:
93
+ return None
94
+ return torch.from_numpy(out[:num_tokens])
95
+
96
+
97
+def test_matches_range_scan_semantics_with_context_offset():
98
+ """Pin the equivalence the O(1) lookup rests on.
99
+
100
+ The kernel checks ``r_start <= kv_idx <= r_end`` for the range containing
101
+ the query token, which is only equal to the old scan's
102
+ ``any(q in r and kv in r)`` because ranges never overlap. Request 1 carries
103
+ a context offset so the local-to-absolute query mapping is exercised too.
104
+ """
105
+ mm_ranges = {0: [(1, 3), (5, 7)], 1: [(2, 4), (9, 12)]}
106
+ query_lens = [8, 6]
107
+ seq_lens = [8, 13]
108
+
109
+ query_ranges = _query_ranges(mm_ranges, query_lens, seq_lens)
110
+ assert query_ranges is not None
111
+
112
+ token_start = 0
113
+ for req_idx, query_len in enumerate(query_lens):
114
+ context_len = seq_lens[req_idx] - query_len
115
+ for q_local in range(query_len):
116
+ q_abs = context_len + q_local
117
+ r_start, r_end = query_ranges[token_start + q_local].tolist()
118
+ for kv_idx in range(seq_lens[req_idx]):
119
+ old_scan_keep = any(
120
+ start < end and start <= q_abs <= end and start <= kv_idx <= end
121
+ for start, end in mm_ranges[req_idx]
122
+ )
123
+ new_keep = r_start <= kv_idx <= r_end
124
+ assert new_keep == old_scan_keep, (req_idx, q_abs, kv_idx)
125
+ token_start += query_len
126
+
127
+
128
+def test_ranges_beyond_scheduled_chunk_are_clipped():
129
+ """Chunked prefill must not error or produce a wrong mask.
130
+
131
+ ``disable_chunked_mm_input`` keeps a single mm item intact but still splits
132
+ a prompt across steps, so a request's ranges routinely sit entirely past the
133
+ tokens scheduled so far. Sizing by query token makes the out-of-chunk part
134
+ a no-op rather than an out-of-bounds condition.
135
+ """
136
+ # Step 1 of "TTTT IIIIII": only the 4 text tokens are scheduled, so the
137
+ # range is entirely ahead of the chunk. The old bounds check raised here.
138
+ assert _query_ranges({0: [(4, 9)]}, query_lens=[4], seq_lens=[4]) is None
139
+
140
+ # A range starting mid-chunk and running past its end keeps its absolute
141
+ # bounds, so the bidirectional block still reaches the range's later keys
142
+ # once they are cached. Query tokens here are absolute positions 2..5.
143
+ query_ranges = _query_ranges({0: [(4, 9)]}, query_lens=[4], seq_lens=[6])
144
+ assert query_ranges is not None
145
+ expected = torch.tensor([[-1, -1], [-1, -1], [4, 9], [4, 9]], dtype=torch.int32)
146
+ torch.testing.assert_close(query_ranges, expected)
147
+
148
+
149
+def test_buffer_reuse_does_not_leak_previous_rows():
150
+ """The staging buffer persists across steps, so every reported row must be
151
+ rewritten. Guards against dropping the ``-1`` fill as an optimization: a
152
+ step whose ranges shrink would otherwise expose the prior step's bounds and
153
+ silently widen the bidirectional mask.
154
+ """
155
+ out = np.zeros((STAGING_CAPACITY, 2), dtype=np.int32)
156
+ query_start_loc = torch.tensor([0, 8], dtype=torch.int32)
157
+ seq_lens = torch.tensor([8], dtype=torch.int32)
158
+
159
+ num_tokens = fill_mm_prefix_query_ranges(
160
+ out, {0: [(0, 7)]}, query_start_loc, seq_lens
161
+ )
162
+ assert num_tokens == 8
163
+ np.testing.assert_array_equal(out[:8], np.tile([0, 7], (8, 1)))
164
+
165
+ # Same rows scheduled, but now only tokens 2..3 sit inside a range.
166
+ num_tokens = fill_mm_prefix_query_ranges(
167
+ out, {0: [(2, 3)]}, query_start_loc, seq_lens
168
+ )
169
+ assert num_tokens == 8
170
+ expected = np.full((8, 2), -1, dtype=np.int32)
171
+ expected[2:4] = (2, 3)
172
+ np.testing.assert_array_equal(out[:8], expected)
173
+
174
+
175
+def test_returns_none_when_no_range_covers_a_query_token():
176
+ """No aux tensor means forward() skips the mask_mod entirely.
177
+
178
+ Returning None rather than an all-``-1`` tensor is what keeps text-only and
179
+ decode-only batches off the fill path.
180
+ """
181
+ # Text-only batch on an mm_prefix model: every request present, no ranges.
182
+ assert _query_ranges({0: [], 1: []}, query_lens=[2, 2], seq_lens=[2, 2]) is None
183
+ # Degenerate single-token ranges are skipped, matching the Triton path's
184
+ # `start < end` validity check.
185
+ assert _query_ranges({0: [(1, 1)]}, query_lens=[4], seq_lens=[4]) is None
186
+ # Decode rows: the query token is generated, so it is in no range.
187
+ assert _query_ranges({0: [(1, 3)]}, query_lens=[1], seq_lens=[9]) is None
188
+
189
+
190
+# --------------------------------------------------------------------------- #
191
+# FA4 kernel
192
+# --------------------------------------------------------------------------- #
193
+
194
+
195
+def _cu_seqlens(lens: list[int]) -> torch.Tensor:
196
+ out = torch.zeros(len(lens) + 1, dtype=torch.int32)
197
+ out[1:] = torch.tensor(lens, dtype=torch.int32).cumsum(0)
198
+ return out
199
+
200
+
201
+def _dense_reference(
202
+ q,
203
+ k_per_req,
204
+ v_per_req,
205
+ query_lens,
206
+ seq_lens,
207
+ mm_ranges,
208
+ sliding_window_left,
209
+ scale,
210
+ mm_clamp_sw=0,
211
+):
212
+ """Dense float32 ``(causal AND window) OR mm_prefix`` per request.
213
+
214
+ ``mm_clamp_sw`` confines the bidirectional block to the window, reproducing
215
+ Gemma4's ``mm_prefix_clamp_sliding_window``. Only the past bound applies, so
216
+ future keys inside a range still pass.
217
+ """
218
+ out = torch.empty_like(q, dtype=torch.float32)
219
+ repeats = q.shape[1] // k_per_req[0].shape[1]
220
+ q_off = 0
221
+ for req_idx, (q_len, k_len) in enumerate(zip(query_lens, seq_lens)):
222
+ ctx = k_len - q_len
223
+ q_pos = torch.arange(q_len, device=q.device) + ctx
224
+ k_pos = torch.arange(k_len, device=q.device)
225
+ delta = q_pos[:, None] - k_pos[None, :]
226
+
227
+ keep = delta >= 0
228
+ if sliding_window_left is not None:
229
+ keep &= delta < sliding_window_left
230
+
231
+ for start, end in mm_ranges.get(req_idx, []):
232
+ if start >= end:
233
+ continue
234
+ q_in = (q_pos >= start) & (q_pos <= end)
235
+ k_in = (k_pos >= start) & (k_pos <= end)
236
+ mm = q_in[:, None] & k_in[None, :]
237
+ if mm_clamp_sw > 0:
238
+ mm &= delta < mm_clamp_sw
239
+ keep |= mm
240
+
241
+ q_i = q[q_off : q_off + q_len].float().transpose(0, 1)
242
+ k_i = k_per_req[req_idx].float().transpose(0, 1)
243
+ v_i = v_per_req[req_idx].float().transpose(0, 1)
244
+ if repeats > 1:
245
+ k_i = k_i.repeat_interleave(repeats, dim=0)
246
+ v_i = v_i.repeat_interleave(repeats, dim=0)
247
+
248
+ scores = (q_i @ k_i.transpose(-1, -2)) * scale
249
+ scores = scores.masked_fill(~keep[None], float("-inf"))
250
+ out[q_off : q_off + q_len] = (scores.softmax(-1) @ v_i).transpose(0, 1)
251
+ q_off += q_len
252
+ return out
253
+
254
+
255
+def _split(packed: torch.Tensor, lens: list[int]) -> list[torch.Tensor]:
256
+ return list(packed.split(lens))
257
+
258
+
259
+def _reference(q, k, v, query_lens, seq_lens, mm_ranges, sw_left, scale, **kwargs):
260
+ return _dense_reference(
261
+ q,
262
+ _split(k, seq_lens),
263
+ _split(v, seq_lens),
264
+ query_lens,
265
+ seq_lens,
266
+ mm_ranges,
267
+ sw_left,
268
+ scale,
269
+ **kwargs,
270
+ )
271
+
272
+
273
+def _run_kernel(
274
+ q, k, v, query_lens, seq_lens, mm_ranges, sliding_window_left, scale, mm_clamp_sw=0
275
+):
276
+ cu_q = _cu_seqlens(query_lens)
277
+ cu_k = _cu_seqlens(seq_lens)
278
+
279
+ staging = torch.full((int(cu_q[-1]), 2), 12345, dtype=torch.int32).numpy()
280
+ num_rows = fill_mm_prefix_query_ranges(
281
+ staging, mm_ranges, cu_q, torch.tensor(seq_lens, dtype=torch.int32)
282
+ )
283
+ assert num_rows > 0, "test case must have at least one in-range query token"
284
+ q_ranges = torch.from_numpy(staging[:num_rows]).to(DEVICE)
285
+ cu_q_gpu = cu_q.to(DEVICE)
286
+
287
+ out = torch.empty_like(q)
288
+ flash_attn_varlen_func(
289
+ q=q,
290
+ k=k,
291
+ v=v,
292
+ out=out,
293
+ cu_seqlens_q=cu_q_gpu,
294
+ cu_seqlens_k=cu_k.to(DEVICE),
295
+ max_seqlen_q=max(query_lens),
296
+ max_seqlen_k=max(seq_lens),
297
+ softmax_scale=scale,
298
+ causal=False,
299
+ fa_version=4,
300
+ mask_mod=_make_mm_prefix_mask_mod(
301
+ sliding_window=mm_clamp_sw, sliding_window_left=sliding_window_left
302
+ ),
303
+ aux_tensors=[q_ranges, cu_q_gpu],
304
+ )
305
+ return out
306
+
307
+
308
+def _randn_qkv(query_lens, seq_lens):
309
+ torch.manual_seed(0)
310
+ q = torch.randn(sum(query_lens), NUM_HEADS, HEAD_SIZE, dtype=DTYPE, device=DEVICE)
311
+ k = torch.randn(sum(seq_lens), NUM_KV_HEADS, HEAD_SIZE, dtype=DTYPE, device=DEVICE)
312
+ v = torch.randn(sum(seq_lens), NUM_KV_HEADS, HEAD_SIZE, dtype=DTYPE, device=DEVICE)
313
+ return q, k, v, HEAD_SIZE**-0.5
314
+
315
+
316
+# (name, query_lens, seq_lens, mm_ranges). One shape per index computation the
317
+# mask_mod performs; shapes that differ only in request count are omitted.
318
+CASES = [
319
+ # cu_seqlens_q[b] + q_local: unequal query lens across the batch, the
320
+ # packing the pooling workload never produced.
321
+ (
322
+ "varlen_batch",
323
+ [128, 64, 200],
324
+ [128, 64, 200],
325
+ {0: [(8, 71)], 1: [(0, 31), (40, 55)], 2: [(64, 191)]},
326
+ ),
327
+ # q_abs = q_idx + (seqlen_k - seqlen_q): ranges are absolute prompt
328
+ # positions, so part of each range sits in the context.
329
+ (
330
+ "context_offset",
331
+ [64],
332
+ [320],
333
+ {0: [(32, 95), (200, 287)]},
334
+ ),
335
+ # Decode rows: the query token is generated, so it is in no range and must
336
+ # get the (-1, -1) sentinel. One prefill row keeps the batch mixed.
337
+ (
338
+ "mixed_prefill_decode",
339
+ [160, 1, 1, 1],
340
+ [160, 512, 300, 97],
341
+ {0: [(32, 127)], 1: [(4, 259)], 2: [(8, 71)], 3: [(1, 64)]},
342
+ ),
343
+]
344
+
345
+# A 320-token range, wider than the window. Every CASES range is narrower than
346
+# the window and therefore clamp-insensitive, so the window modes are exercised
347
+# on this shape instead: images larger than the window are exactly why
348
+# mm_prefix_clamp_sliding_window exists.
349
+WIDE_RANGE_CASE = ([384], [384], {0: [(32, 351)]})
350
+
351
+# (sliding_window_left, mm_clamp_sw). sliding_window_left selects between the
352
+# two mask_mod variants; mm_clamp_sw gates the Gemma4 clamp inside the sliding
353
+# one. Gemma4 sets mm_clamp_sw == sw_val, and the unclamped variant ignores
354
+# mm_clamp_sw, so a clamp without a window is not a reachable configuration.
355
+WINDOW_MODES = [
356
+ (None, 0),
357
+ (SLIDING_WINDOW_LEFT, 0),
358
+ (SLIDING_WINDOW_LEFT, SLIDING_WINDOW_LEFT),
359
+]
360
+WINDOW_IDS = ["full_causal", "window", "window_clamped"]
361
+
362
+
363
+@requires_fa4
364
+@pytest.mark.parametrize("case", CASES, ids=[c[0] for c in CASES])
365
+def test_mm_prefix_mask_mod_matches_dense_reference(case):
366
+ _, query_lens, seq_lens, mm_ranges = case
367
+ q, k, v, scale = _randn_qkv(query_lens, seq_lens)
368
+ args = (q, k, v, query_lens, seq_lens, mm_ranges, SLIDING_WINDOW_LEFT, scale)
369
+ torch.testing.assert_close(
370
+ _run_kernel(*args).float(), _reference(*args), atol=2e-2, rtol=2e-2
371
+ )
372
+
373
+
374
+@requires_fa4
375
+@pytest.mark.parametrize("window_mode", WINDOW_MODES, ids=WINDOW_IDS)
376
+def test_window_modes_on_a_range_wider_than_the_window(window_mode):
377
+ """Cover the three reachable window configurations.
378
+
379
+ The clamped mode additionally asserts the clamp changes the mask at all,
380
+ without which a range narrower than the window would leave the clamp branch
381
+ unexercised while the test still passed.
382
+ """
383
+ sliding_window_left, mm_clamp_sw = window_mode
384
+ query_lens, seq_lens, mm_ranges = WIDE_RANGE_CASE
385
+ q, k, v, scale = _randn_qkv(query_lens, seq_lens)
386
+ args = (q, k, v, query_lens, seq_lens, mm_ranges, sliding_window_left, scale)
387
+
388
+ expected = _reference(*args, mm_clamp_sw=mm_clamp_sw)
389
+ if mm_clamp_sw:
390
+ assert not torch.allclose(
391
+ expected, _reference(*args, mm_clamp_sw=0), atol=2e-2, rtol=2e-2
392
+ ), "range is not wide enough to exercise the clamp branch"
393
+
394
+ actual = _run_kernel(*args, mm_clamp_sw=mm_clamp_sw)
395
+ torch.testing.assert_close(actual.float(), expected, atol=2e-2, rtol=2e-2)
396
+
397
+
398
+@requires_fa4
399
+def test_ranges_outside_chunk_degrade_to_causal():
400
+ """A range fully behind the scheduled chunk must not widen the mask.
401
+
402
+ Under chunked prefill a request's ranges routinely sit entirely in the
403
+ already-computed context. Indexing by query token makes those a no-op; the
404
+ result must be identical to running with no ranges at all.
405
+ """
406
+ query_lens, seq_lens = [64], [512]
407
+ q, k, v, scale = _randn_qkv(query_lens, seq_lens)
408
+
409
+ # Range (16, 79) is entirely below the chunk start (448), plus one live
410
+ # range so the fill still reports rows.
411
+ args = (q, k, v, query_lens, seq_lens)
412
+ actual = _run_kernel(*args, {0: [(16, 79), (448, 511)]}, None, scale)
413
+ expected = _reference(*args, {0: [(448, 511)]}, None, scale)
414
+ torch.testing.assert_close(actual.float(), expected, atol=2e-2, rtol=2e-2)
415
+
416
+
417
+# --------------------------------------------------------------------------- #
418
+# FA4 through the metadata builder and the paged KV cache
419
+# --------------------------------------------------------------------------- #
420
+
421
+# Stock google/gemma-4-E2B-it ships text_config.use_bidirectional_attention as
422
+# null, so is_mm_prefix_lm is False out of the box. Tests that exercise the
423
+# builder must flip it via `_enable_mm_prefix`. Only config.json / tokenizer
424
+# are read; weights are never loaded.
425
+MODEL = "google/gemma-4-E2B-it"
426
+
427
+BLOCK_SIZE = 16
428
+SLIDING_WINDOW = 256
429
+
430
+# Request 0 is a prefill chunk whose range starts mid-context; requests 1-3 are
431
+# decodes whose generated token sits outside every range.
432
+PAGED_SEQ_LENS = [352, 513, 200, 97]
433
+PAGED_QUERY_LENS = [96, 1, 1, 1]
434
+PAGED_MM_RANGES = {
435
+ 0: [(224, 287), (300, 351)],
436
+ 1: [(4, 259)],
437
+ 2: [(0, 63), (80, 143)],
438
+ 3: [(1, 64)],
439
+}
440
+
441
+
442
+def _enable_mm_prefix(vllm_config):
443
+ """Flip stock Gemma4's null use_bidirectional_attention to "vision".
444
+
445
+ ``ModelConfig.__init__`` derives ``model_arch_config`` once from
446
+ ``hf_text_config``, and ``is_mm_prefix_lm`` is a ``cached_property`` that
447
+ may already hold False from that first derivation, so re-derive and drop
448
+ the cache.
449
+ """
450
+ model_config = vllm_config.model_config
451
+ model_config.hf_text_config.use_bidirectional_attention = "vision"
452
+ model_config.model_arch_config = model_config.get_model_arch_config()
453
+ model_config.__dict__.pop("is_mm_prefix_lm", None)
454
+ return vllm_config
455
+
456
+
457
+@requires_fa4
458
+def test_decode_only_batch_reports_no_ranges():
459
+ """Decode-only steps must carry no mm_prefix metadata at all.
460
+
461
+ This is what makes FULL CUDA graph capture consistent for this feature.
462
+ Capture runs through ``_dummy_run`` with no multimodal requests, so no
463
+ mask_mod is attached to the captured graph; a decode-only replay must
464
+ therefore also want no mask_mod, or the captured graph would silently drop
465
+ it. Indexing by query token gives that for free, because a generated token
466
+ is always past every prompt range.
467
+
468
+ The range-id scheme keyed off the request having ranges at all, so it
469
+ attached a mask_mod to decode steps and did not have this property.
470
+ """
471
+ vllm_config = _enable_mm_prefix(
472
+ create_vllm_config(
473
+ model_name=MODEL,
474
+ max_model_len=1024,
475
+ block_size=BLOCK_SIZE,
476
+ num_gpu_blocks=256,
477
+ )
478
+ )
479
+ decode_batch = BatchSpec(
480
+ seq_lens=[513, 200, 97], query_lens=[1, 1, 1], name="decode_only"
481
+ )
482
+ common = create_common_attn_metadata(decode_batch, BLOCK_SIZE, DEVICE)
483
+ common.mm_req_doc_ranges = {0: [(4, 259)], 1: [(0, 63)], 2: [(1, 64)]}
484
+
485
+ builder = FlashAttentionMetadataBuilder(
486
+ create_standard_kv_cache_spec(vllm_config),
487
+ ["model.layers.0.self_attn.attn"],
488
+ vllm_config,
489
+ DEVICE,
490
+ )
491
+ md = builder.build(common_prefix_len=0, common_attn_metadata=common)
492
+ assert md.mm_prefix_query_range_tensor is None
493
+
494
+
495
+@requires_fa4
496
+@pytest.mark.parametrize(
497
+ "head_size",
498
+ [
499
+ # 128: FA4's default SM100 path (not the dedicated hd256 kernel), so
500
+ # paged KV + seqused_k works on B200 and H100. Non-paged mm_prefix
501
+ # cases in this file already use HEAD_SIZE=128.
502
+ pytest.param(128, id="hd128"),
503
+ # 512: Gemma4 global head dim; FA4 resolves on H100, skips on
504
+ # Blackwell (TMEM).
505
+ pytest.param(512, id="global_512"),
506
+ ],
507
+)
508
+def test_mm_prefix_kv_cache_path(head_size: int):
509
+ """FA4 + mask_mod + block_table matches a dense reference.
510
+
511
+ Use head_size=128 on B200/H100 (FA4 + paged/`seqused_k` supported) and
512
+ 512 on H100 only (Blackwell FA4 rejects 512 via TMEM). Avoid 256: SM100's
513
+ dedicated hd256 2CTA kernel asserts away ``seqused_k``, which paged KV
514
+ requires.
515
+
516
+ Tensors, kv_cache_spec, and FlashAttentionImpl must all use the same
517
+ ``head_size`` — ``run_attention_backend`` / ``create_standard_kv_cache_spec``
518
+ read ``get_head_size()`` (=512 for Gemma4), so this test builds them
519
+ explicitly. The sliding window is set on the impl; mm_prefix encodes it in
520
+ mask_mod and clears FA's built-in window at forward time.
521
+ """
522
+ torch.manual_seed(0)
523
+ vllm_config = _enable_mm_prefix(
524
+ create_vllm_config(
525
+ model_name=MODEL,
526
+ max_model_len=max(PAGED_SEQ_LENS),
527
+ block_size=BLOCK_SIZE,
528
+ num_gpu_blocks=2048,
529
+ )
530
+ )
531
+ assert vllm_config.model_config.is_mm_prefix_lm
532
+
533
+ mc = vllm_config.model_config
534
+ # Keep every get_head_size() reader (builder helpers, etc.) on the same dim.
535
+ mc.get_head_size = MethodType(lambda self: head_size, mc)
536
+
537
+ # Skip under the same config Gemma4 forces (flash_attn_version=4), so the
538
+ # check matches FlashAttentionImpl's get_flash_attn_version call.
539
+ with set_current_vllm_config(vllm_config):
540
+ if get_flash_attn_version(head_size=head_size) != 4:
541
+ pytest.skip(f"FA4 does not support head_size={head_size} on this device")
542
+
543
+ num_heads = mc.get_num_attention_heads(vllm_config.parallel_config)
544
+ num_kv_heads = mc.get_num_kv_heads(vllm_config.parallel_config)
545
+ scale = head_size**-0.5
546
+
547
+ batch = BatchSpec(
548
+ seq_lens=PAGED_SEQ_LENS, query_lens=PAGED_QUERY_LENS, name="mm_prefix_mixed"
549
+ )
550
+ kv_cache_spec = FullAttentionSpec(
551
+ block_size=BLOCK_SIZE,
552
+ num_kv_heads=num_kv_heads,
553
+ head_size=head_size,
554
+ dtype=mc.dtype,
555
+ sliding_window=SLIDING_WINDOW,
556
+ kv_quant_mode=get_kv_quant_mode(vllm_config.cache_config.cache_dtype),
557
+ )
558
+ common = create_common_attn_metadata(batch, BLOCK_SIZE, DEVICE)
559
+ common.mm_req_doc_ranges = PAGED_MM_RANGES
560
+
561
+ qs, new_ks, new_vs, k_fulls, v_fulls, k_ctxs, v_ctxs = [], [], [], [], [], [], []
562
+ for q_len, s_len in zip(PAGED_QUERY_LENS, PAGED_SEQ_LENS):
563
+ ctx = s_len - q_len
564
+ shape = (num_kv_heads, head_size)
565
+ qs.append(torch.randn(q_len, num_heads, head_size, dtype=DTYPE, device=DEVICE))
566
+ k_full = torch.randn(s_len, *shape, dtype=DTYPE, device=DEVICE)
567
+ v_full = torch.randn(s_len, *shape, dtype=DTYPE, device=DEVICE)
568
+ k_fulls.append(k_full)
569
+ v_fulls.append(v_full)
570
+ k_ctxs.append(k_full[:ctx])
571
+ v_ctxs.append(v_full[:ctx])
572
+ new_ks.append(k_full[ctx:])
573
+ new_vs.append(v_full[ctx:])
574
+
575
+ query = torch.cat(qs)
576
+ key = torch.cat(new_ks)
577
+ value = torch.cat(new_vs)
578
+ kv_cache = create_and_prepopulate_kv_cache(
579
+ k_contexts=k_ctxs,
580
+ v_contexts=v_ctxs,
581
+ block_size=BLOCK_SIZE,
582
+ num_kv_heads=num_kv_heads,
583
+ head_size=head_size,
584
+ dtype=DTYPE,
585
+ device=DEVICE,
586
+ num_blocks=2048,
587
+ common_attn_metadata=common,
588
+ randomize_blocks=True,
589
+ )
590
+
591
+ # Packed KV layout is [..., 2 * head_size]; impl.split(head_size) needs match.
592
+ assert kv_cache.shape[-1] == 2 * head_size
593
+
594
+ layer_names = ["model.layers.0.self_attn.attn"]
595
+ with set_current_vllm_config(vllm_config):
596
+ builder = FlashAttentionMetadataBuilder(
597
+ kv_cache_spec, layer_names, vllm_config, DEVICE
598
+ )
599
+ attn_metadata = builder.build(common_prefix_len=0, common_attn_metadata=common)
600
+ # head_size=128 keeps FA4 under local attention on Blackwell (the
601
+ # FA4→FA2 demotion is only for local+256). Window is still encoded in
602
+ # mask_mod at forward; FA's built-in window is cleared there.
603
+ impl = FlashAttentionImpl(
604
+ num_heads=num_heads,
605
+ head_size=head_size,
606
+ scale=scale,
607
+ num_kv_heads=num_kv_heads,
608
+ alibi_slopes=None,
609
+ sliding_window=SLIDING_WINDOW,
610
+ kv_cache_dtype="auto",
611
+ )
612
+ assert impl.vllm_flash_attn_version == 4, (
613
+ f"expected FA4, got FA{impl.vllm_flash_attn_version} for "
614
+ f"head_size={head_size}"
615
+ )
616
+ assert impl.head_size == head_size == kv_cache_spec.head_size
617
+
618
+ mock_layer = MockAttentionLayer(DEVICE)
619
+ output = torch.empty_like(query)
620
+ impl.do_kv_cache_update(
621
+ mock_layer, key, value, kv_cache, attn_metadata.slot_mapping
622
+ )
623
+ actual = impl.forward(
624
+ mock_layer, query, key, value, kv_cache, attn_metadata, output=output
625
+ )
626
+
627
+ ref_args = (query, k_fulls, v_fulls, PAGED_QUERY_LENS, PAGED_SEQ_LENS)
628
+ expected = _dense_reference(*ref_args, PAGED_MM_RANGES, SLIDING_WINDOW, scale)
629
+ torch.testing.assert_close(actual.float(), expected, atol=2e-2, rtol=2e-2)
630
+
631
+ # Without this the assertion above would also pass if the mask_mod were
632
+ # silently dropped, since the batch would then be plain causal + window.
633
+ causal_only = _dense_reference(*ref_args, {}, SLIDING_WINDOW, scale)
634
+ assert not torch.allclose(expected, causal_only, atol=2e-2, rtol=2e-2), (
635
+ "test batch does not actually exercise the mm_prefix branch"
636
+ )
vllm/model_executor/models/gemma4_mm.py
+2
@@ -2078,6 +2078,8 @@ class Gemma4ForConditionalGeneration(
2078
metadata.mm_prefix_range = None
2079
if hasattr(metadata, "mm_prefix_range_tensor"):
2080
metadata.mm_prefix_range_tensor = None
2081
+ if hasattr(metadata, "mm_prefix_query_range_tensor"):
2082
+ metadata.mm_prefix_query_range_tensor = None
2083
2084
if isinstance(attn_metadata, list):
2085
for ub_metadata in attn_metadata:
vllm/v1/attention/backend.py
+1
-1
@@ -471,7 +471,7 @@ class CommonAttentionMetadata:
471
"""PrefixLM bidirectional ranges for multimodal tokens. Maps
472
request index to list of (start, end) token position ranges
473
where bidirectional attention should apply. None for text-only
474
- batches or non-PrefixLM models."""
474
+ batches or non-PrefixLM models. A request's ranges must not overlap."""
475
476
rswa_prefix_lens: torch.Tensor | None = None
477
"""(batch_size,) per-request prefix length (prompt/image token count) for
vllm/v1/attention/backends/flash_attn.py
+109
-44
@@ -3,6 +3,7 @@
3
"""Attention layer with FlashAttention."""
4
5
import copy
6
+import functools
7
from dataclasses import dataclass
8
from typing import ClassVar
9
@@ -12,6 +13,7 @@ import torch
13
from vllm.model_executor.layers.attention import Attention
14
from vllm.platforms import current_platform
15
from vllm.utils.torch_utils import (
16
+ PIN_MEMORY,
17
canonicalize_singleton_dim_strides,
18
is_quantized_kv_cache,
19
)
@@ -28,7 +30,10 @@ from vllm.v1.attention.backends.fa_utils import (
30
is_fa_version_supported,
31
is_flash_attn_varlen_func_available,
32
)
31
-from vllm.v1.attention.backends.utils import get_dcp_local_seq_lens
33
+from vllm.v1.attention.backends.utils import (
34
+ fill_mm_prefix_query_ranges,
35
+ get_dcp_local_seq_lens,
36
+)
37
from vllm.v1.attention.ops.common import cp_lse_ag_out_rs
38
from vllm.v1.attention.ops.dcp_alltoall import dcp_a2a_lse_reduce
39
from vllm.v1.attention.ops.merge_attn_states import merge_attn_states
@@ -284,9 +289,10 @@ class FlashAttentionMetadata:
289
290
sliding_window: tuple[int, int] | None = None
291
287
- # PrefixLM bidirectional ranges for multimodal tokens.
288
- # Shape: (num_seqs, max_ranges, 2) int32, [start, end] per range.
289
- mm_prefix_range_tensor: torch.Tensor | None = None
292
+ # PrefixLM bidirectional range containing each scheduled query token.
293
+ # Shape: (num_actual_tokens, 2) int32, absolute [start, end] bounds;
294
+ # (-1, -1) for query tokens outside every multimodal range.
295
+ mm_prefix_query_range_tensor: torch.Tensor | None = None
296
297
# Reference Sliding Window Attention (R-SWA) fields.
298
# rswa_prefix_lens: per-request prompt lengths [num_reqs], int32, CUDA.
@@ -455,6 +461,21 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
461
[self.rswa_window], dtype=torch.int32, device=self.device
462
)
463
464
+ # mm_prefix: persistent staging + device buffers owned by this builder,
465
+ # sized by scheduled query tokens so build() never allocates.
466
+ self.mm_prefix_query_ranges_cpu: torch.Tensor | None = None
467
+ self.mm_prefix_query_ranges_np: np.ndarray | None = None
468
+ self.mm_prefix_query_ranges_gpu: torch.Tensor | None = None
469
+ if self.model_config.is_mm_prefix_lm:
470
+ max_num_tokens = vllm_config.scheduler_config.max_num_batched_tokens
471
+ self.mm_prefix_query_ranges_cpu = torch.empty(
472
+ (max_num_tokens, 2), dtype=torch.int32, pin_memory=PIN_MEMORY
473
+ )
474
+ self.mm_prefix_query_ranges_np = self.mm_prefix_query_ranges_cpu.numpy()
475
+ self.mm_prefix_query_ranges_gpu = torch.empty(
476
+ (max_num_tokens, 2), dtype=torch.int32, device=self.device
477
+ )
478
+
479
def build(
480
self,
481
common_prefix_len: int,
@@ -696,16 +717,31 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
717
)
718
719
# Compute mm_prefix range tensor if the batch contains
699
- # multimodal tokens with bidirectional ranges.
720
+ # multimodal tokens with bidirectional ranges. Built for every FA
721
+ # group; Gemma4 nulls the field for its non-sliding layers.
722
mm_ranges = common_attn_metadata.mm_req_doc_ranges
701
- if mm_ranges is not None:
702
- from vllm.v1.attention.backends.utils import (
703
- compute_mm_prefix_range_tensor,
723
+ if mm_ranges is not None and self.mm_prefix_query_ranges_np is not None:
724
+ # The upper bound is exact for prefill rows, which is where
725
+ # mm_prefix ranges live; decode rows only ever get an optimistic
726
+ # (larger) context, moving them further past every range.
727
+ assert common_attn_metadata.seq_lens_cpu_upper_bound is not None, (
728
+ "mm_prefix requires seq_lens_cpu_upper_bound"
729
)
705
-
706
- attn_metadata.mm_prefix_range_tensor = compute_mm_prefix_range_tensor(
707
- mm_ranges, num_reqs, seq_lens.device
730
+ num_mm_tokens = fill_mm_prefix_query_ranges(
731
+ self.mm_prefix_query_ranges_np,
732
+ mm_ranges,
733
+ common_attn_metadata.query_start_loc_cpu,
734
+ common_attn_metadata.seq_lens_cpu_upper_bound,
735
)
736
+ if num_mm_tokens > 0:
737
+ assert self.mm_prefix_query_ranges_cpu is not None
738
+ assert self.mm_prefix_query_ranges_gpu is not None
739
+ mm_query_ranges = self.mm_prefix_query_ranges_gpu[:num_mm_tokens]
740
+ mm_query_ranges.copy_(
741
+ self.mm_prefix_query_ranges_cpu[:num_mm_tokens],
742
+ non_blocking=True,
743
+ )
744
+ attn_metadata.mm_prefix_query_range_tensor = mm_query_ranges
745
746
# R-SWA: copy prefix lengths into persistent buffers (outside the
747
# compiled region) so forward() never allocates during CUDA graph
@@ -971,23 +1007,25 @@ class FlashAttentionImpl(AttentionImpl):
1007
causal = attn_metadata.causal
1008
is_dynamic_causal = isinstance(causal, torch.Tensor)
1009
974
- mm_prefix_ranges = attn_metadata.mm_prefix_range_tensor
1010
+ mm_prefix_query_ranges = attn_metadata.mm_prefix_query_range_tensor
1011
mm_mask_mod = None
1012
mm_aux = None
1013
if (
978
- mm_prefix_ranges is not None
1014
+ mm_prefix_query_ranges is not None
1015
and not is_dynamic_causal
1016
and causal is True
1017
and self.vllm_flash_attn_version == 4
1018
):
983
- max_ranges = mm_prefix_ranges.shape[1]
984
- # Sliding window value in Triton convention
985
- # (1 + window_size[0]). Global-attention layers
986
- # store (-1, -1) → sw stays None / 0.
1019
+ # Use the layer impl's window, not attn_metadata's. The
1020
+ # metadata field comes from kv_cache_spec (model-wide, e.g.
1021
+ # Gemma4's 512), while the impl holds the per-layer window
1022
+ # the mask_mod must encode (e.g. a test override).
1023
+ # Triton convention: 1 + window_size[0]. Global layers store
1024
+ # (-1, -1) → sw stays None.
1025
+ layer_window = self.sliding_window
1026
sw_val = (
988
- 1 + sliding_window_size[0]
989
- if sliding_window_size is not None
990
- and sliding_window_size[0] >= 0
1027
+ 1 + layer_window[0]
1028
+ if layer_window is not None and layer_window[0] >= 0
1029
else None
1030
)
1031
# Gemma4: also clamp the bidirectional block to the
@@ -1000,11 +1038,16 @@ class FlashAttentionImpl(AttentionImpl):
1038
):
1039
mm_clamp_sw = sw_val
1040
mm_mask_mod = _make_mm_prefix_mask_mod(
1003
- max_ranges,
1041
sliding_window=mm_clamp_sw,
1042
sliding_window_left=sw_val,
1043
)
1007
- mm_aux = [mm_prefix_ranges]
1044
+ mm_aux = [mm_prefix_query_ranges, attn_metadata.query_start_loc]
1045
+ # mm_prefix is (causal ∧ window) ∨ bidirectional-range —
1046
+ # not ⊆ causal. FA #155 stopped auto-clearing causal/local
1047
+ # when mask_mod is set, so the caller must disable them or
1048
+ # the built-in causal path shorts out / clips the mask_mod.
1049
+ causal = False
1050
+ sliding_window_size = None
1051
1052
# R-SWA: use CuTE-DSL mask_mod on FA4 for exact token-level
1053
# mask without block-size approximation. The mask_mod encodes
@@ -1378,14 +1421,19 @@ class FlashAttentionImpl(AttentionImpl):
1421
return output
1422
1423
1424
+@functools.cache
1425
def _make_mm_prefix_mask_mod(
1382
- max_ranges: int,
1426
sliding_window: int = 0,
1427
sliding_window_left: int | None = None,
1428
):
1429
"""Build a CuTE-DSL mask_mod implementing
1430
``(causal AND sliding_window) OR mm_prefix``.
1431
1432
+ Cached so identical ``(sliding_window, sliding_window_left)`` reuse the
1433
+ same function object. FA4's ``hash_callable`` mixes ``repr()`` of closure
1434
+ cells into the compile key; the nested ``_load_q_range`` would otherwise
1435
+ get a new address each call and force a full JIT recompile every forward.
1436
+
1437
The FA4 kernel passes *local* ``q_idx`` (0-based within the current
1438
prefill chunk) while ``kv_idx`` is absolute (0-based over the full
1439
KV cache). We recover the absolute Q position via
@@ -1394,6 +1442,15 @@ def _make_mm_prefix_mask_mod(
1442
use consistent absolute positions. This matches the Triton
1443
reference path (``compute_kv_seq_mask``).
1444
1445
+ ``aux_tensors[0]`` holds the absolute ``[start, end]`` bounds of the
1446
+ mm_prefix range containing each scheduled query token (``(-1, -1)`` when
1447
+ none), and ``aux_tensors[1]`` is ``cu_seqlens_q``, used to turn the local
1448
+ ``q_idx`` into a packed row index. Because mm_prefix ranges never overlap,
1449
+ ``r_start <= kv_idx <= r_end`` is exactly "query and key share a range", so
1450
+ no key-side lookup is needed and ``kv_idx`` is never used as an index.
1451
+ The ``(-1, -1)`` sentinel falls out for free: ``kv_idx <= -1`` is false for
1452
+ every valid key.
1453
+
1454
``sliding_window_left`` enforces the sliding window on the causal
1455
term (None = full causal, no window). ``sliding_window`` clamps the
1456
bidirectional block to the window (0 = unclamped; >0 = Gemma4 local
@@ -1405,8 +1462,29 @@ def _make_mm_prefix_mask_mod(
1462
1463
from vllm.vllm_flash_attn.cute.utils import ( # type: ignore[import-untyped]
1464
scalar_to_ssa,
1465
+ ssa_to_scalar,
1466
)
1467
1468
+ @cute.jit
1469
+ def _load_q_range(q_idx, seqlen_info, aux_tensors, batch_idx):
1470
+ """Load the mm_prefix range bounds for this query row.
1471
+
1472
+ Both loads depend only on the query index, so they hoist out of the
1473
+ unrolled per-element mask loop. ``ssa_to_scalar`` reads lane 0, so one
1474
+ call must not span query rows (hence the ``__vec_size__`` pin below).
1475
+ Clamping keeps ``token_idx`` in bounds for partial tiles and padded
1476
+ (``seqlen_q == 0``) rows; neither produces output.
1477
+ """
1478
+ q_ranges = aux_tensors[0]
1479
+ cu_seqlens_q = aux_tensors[1]
1480
+ b = batch_idx[0]
1481
+ q_local = cutlass.min(ssa_to_scalar(q_idx), seqlen_info.seqlen_q - Int32(1))
1482
+ token_idx = cutlass.max(cu_seqlens_q[b] + q_local, Int32(0))
1483
+ return (
1484
+ scalar_to_ssa(q_ranges[token_idx, 0], Int32),
1485
+ scalar_to_ssa(q_ranges[token_idx, 1], Int32),
1486
+ )
1487
+
1488
if sliding_window_left is not None:
1489
1490
@cute.jit
@@ -1422,18 +1500,11 @@ def _make_mm_prefix_mask_mod(
1500
q_abs = q_idx + ctx_off
1501
sw = scalar_to_ssa(Int32(sliding_window_left), Int32)
1502
keep = (kv_idx <= q_abs) & ((q_abs - kv_idx) < sw)
1425
- ranges = aux_tensors[0]
1426
- b = batch_idx[0]
1427
- for i in cutlass.range_constexpr(max_ranges): # type: ignore[attr-defined]
1428
- r_start = scalar_to_ssa(ranges[b, i, 0], Int32)
1429
- r_end = scalar_to_ssa(ranges[b, i, 1], Int32)
1430
- valid = r_start < r_end
1431
- q_in = (q_abs >= r_start) & (q_abs <= r_end) & valid
1432
- k_in = (kv_idx >= r_start) & (kv_idx <= r_end) & valid
1433
- mm = q_in & k_in
1434
- if sliding_window > 0:
1435
- mm = mm & ((q_abs - kv_idx) < sw)
1436
- keep = keep | mm
1503
+ r_start, r_end = _load_q_range(q_idx, seqlen_info, aux_tensors, batch_idx)
1504
+ mm = (kv_idx >= r_start) & (kv_idx <= r_end)
1505
+ if sliding_window > 0:
1506
+ mm = mm & ((q_abs - kv_idx) < sw)
1507
+ keep = keep | mm
1508
return keep
1509
1510
else:
@@ -1450,18 +1521,12 @@ def _make_mm_prefix_mask_mod(
1521
ctx_off = scalar_to_ssa(seqlen_info.seqlen_k - seqlen_info.seqlen_q, Int32)
1522
q_abs = q_idx + ctx_off
1523
keep = kv_idx <= q_abs
1453
- ranges = aux_tensors[0]
1454
- b = batch_idx[0]
1455
- for i in cutlass.range_constexpr(max_ranges): # type: ignore[attr-defined]
1456
- r_start = scalar_to_ssa(ranges[b, i, 0], Int32)
1457
- r_end = scalar_to_ssa(ranges[b, i, 1], Int32)
1458
- valid = r_start < r_end
1459
- q_in = (q_abs >= r_start) & (q_abs <= r_end) & valid
1460
- k_in = (kv_idx >= r_start) & (kv_idx <= r_end) & valid
1461
- keep = keep | (q_in & k_in)
1524
+ r_start, r_end = _load_q_range(q_idx, seqlen_info, aux_tensors, batch_idx)
1525
+ keep = keep | ((kv_idx >= r_start) & (kv_idx <= r_end))
1526
return keep
1527
1528
mm_prefix_mask_mod.use_fast_sampling = True
1529
+ mm_prefix_mask_mod.__vec_size__ = 1 # _load_q_range takes lane 0 of q_idx
1530
return mm_prefix_mask_mod
1531
1532
vllm/v1/attention/backends/utils.py
+71
@@ -75,6 +75,77 @@ def compute_mm_prefix_range_tensor(
75
return padded.view(num_seqs, max_ranges, 2)
76
77
78
+def fill_mm_prefix_query_ranges(
79
+ out: np.ndarray,
80
+ mm_prefix_range: dict[int, list[tuple[int, int]]] | None,
81
+ query_start_loc_cpu: torch.Tensor,
82
+ seq_lens_cpu: torch.Tensor,
83
+) -> int:
84
+ """Map each scheduled query token to the mm_prefix range containing it.
85
+
86
+ Writes into ``out``, a caller-owned ``(max_num_batched_tokens, 2)`` int32
87
+ staging buffer, and returns the number of rows written (0 if no range
88
+ covers any scheduled query token, in which case ``out`` is untouched and
89
+ the caller should skip the mask_mod entirely). Row ``i`` holds the absolute
90
+ ``[start, end]`` bounds of the bidirectional range that query token ``i``
91
+ belongs to, or ``(-1, -1)`` when it is outside every range.
92
+
93
+ mm_prefix ranges never overlap, so "query and key share a range" is
94
+ equivalent to "the key lies inside the query's own range". The kernel
95
+ therefore needs no key-side lookup, and this metadata is sized by scheduled
96
+ query tokens rather than by context length -- bounded by
97
+ ``max_num_batched_tokens`` instead of ``num_seqs * max_seq_len``.
98
+
99
+ Ranges are absolute prompt positions and may extend past the tokens
100
+ scheduled so far under chunked prefill; the portion outside the current
101
+ chunk is simply not recorded. Degenerate ranges (``start >= end``) are
102
+ skipped to match the Triton path's ``start < end`` validity check.
103
+
104
+ ``seq_lens_cpu`` only needs to be exact for prefill rows, since mm_prefix
105
+ ranges cover prompt tokens: an over-estimate on a decode row shifts that
106
+ row's query position further past every range, which still matches nothing.
107
+ """
108
+ if mm_prefix_range is None:
109
+ return 0
110
+
111
+ query_start_loc = query_start_loc_cpu.numpy()
112
+ num_actual_tokens = int(query_start_loc[-1])
113
+ if num_actual_tokens <= 0:
114
+ return 0
115
+ assert num_actual_tokens <= out.shape[0], (
116
+ f"mm_prefix staging buffer holds {out.shape[0]} tokens, got {num_actual_tokens}"
117
+ )
118
+
119
+ # Resolve every span before touching `out`, so batches whose ranges all
120
+ # fall outside the scheduled tokens skip the fill entirely.
121
+ spans: list[tuple[int, int, int, int]] = []
122
+ for req_idx, req_ranges in mm_prefix_range.items():
123
+ if not req_ranges:
124
+ continue
125
+ token_start = int(query_start_loc[req_idx])
126
+ query_len = int(query_start_loc[req_idx + 1]) - token_start
127
+ if query_len <= 0:
128
+ continue
129
+ # Absolute position of this request's first scheduled query token.
130
+ context_len = int(seq_lens_cpu[req_idx]) - query_len
131
+ for start, end in req_ranges:
132
+ if start >= end:
133
+ continue
134
+ first = max(start - context_len, 0)
135
+ last = min(end - context_len, query_len - 1)
136
+ if first > last:
137
+ continue
138
+ spans.append((token_start + first, token_start + last + 1, start, end))
139
+
140
+ if not spans:
141
+ return 0
142
+
143
+ out[:num_actual_tokens] = -1
144
+ for row_start, row_end, start, end in spans:
145
+ out[row_start:row_end] = (start, end)
146
+ return num_actual_tokens
147
+
148
+
149
def is_valid_kv_cache_layout(value: str) -> bool:
150
return value in get_args(KVCacheLayoutType)
151