[Kernel][Gemma4] Prune Triton sliding-window tiles for multimodal prefixes (#53147)
Signed-off-by: mobicham <hichamb@dropbox.com>
mobicham committed
Sep 1, 2026 at 00:21 UTC
07ea9350baf84e33fd696d36fec9b9f24735a733
3 files changed
+308
-10
tests/kernels/attention/test_triton_unified_attention.py
+253
@@ -6,8 +6,12 @@ import pytest
6
import torch
7
8
from vllm.platforms import current_platform
9
+from vllm.triton_utils import tl, triton
10
from vllm.utils.math_utils import next_power_of_2
11
from vllm.utils.torch_utils import set_random_seed
12
+from vllm.v1.attention.ops.triton_attention_helpers import (
13
+ compute_tile_loop_bounds,
14
+)
15
from vllm.v1.attention.ops.triton_unified_attention import unified_attention
16
from vllm.v1.kv_cache_interface import KVQuantMode
17
@@ -32,6 +36,91 @@ NUM_BLOCKS = [32768, 2048]
36
SEQ_THRESHOLD_3D_VALUES = [0, 8]
37
38
39
+@triton.jit
40
+def _compute_clamped_mm_tile_bounds(
41
+ output_ptr,
42
+ mm_prefix_range_ptr,
43
+ USE_CAUSAL: tl.constexpr,
44
+ USE_PER_SEQ_CAUSAL: tl.constexpr,
45
+ CHUNK_LOOKBACK: tl.constexpr,
46
+ CHUNK_SIZE: tl.constexpr,
47
+):
48
+ loop_lo, loop_hi, max_seq_prefix_len = compute_tile_loop_bounds(
49
+ 0, # context_len
50
+ 4096, # seq_len
51
+ 4096, # cur_batch_query_len
52
+ 140, # q_block_local_idx: query positions [1120, 1127]
53
+ 0, # segm_idx_or_0
54
+ 0, # tiles_per_segment_or_0
55
+ 32, # TILE_SIZE
56
+ 16, # BLOCK_M
57
+ 8, # BLOCK_Q
58
+ 2, # num_queries_per_kv
59
+ 1024, # SLIDING_WINDOW
60
+ True, # USE_MM_PREFIX
61
+ False, # IS_3D
62
+ USE_CAUSAL,
63
+ USE_PER_SEQ_CAUSAL,
64
+ CHUNK_LOOKBACK,
65
+ CHUNK_SIZE,
66
+ False, # USE_R_SWA
67
+ True, # MM_PREFIX_CLAMP_SW
68
+ 2, # MAX_MM_RANGES
69
+ mm_prefix_range_ptr,
70
+ 0, # seq_idx
71
+ )
72
+ tl.store(output_ptr, loop_lo)
73
+ tl.store(output_ptr + 1, loop_hi)
74
+ tl.store(output_ptr + 2, max_seq_prefix_len)
75
+
76
+
77
+def test_clamped_mm_prefix_prunes_sliding_window_tiles() -> None:
78
+ """Retain an intersecting image range without scanning the full sequence."""
79
+ mm_prefix_ranges = torch.tensor(
80
+ [[[1024, 2303], [0, 0]]], dtype=torch.int32, device=DEVICE_TYPE
81
+ )
82
+ bounds = torch.empty(3, dtype=torch.int32, device=DEVICE_TYPE)
83
+
84
+ _compute_clamped_mm_tile_bounds[(1,)](
85
+ bounds,
86
+ mm_prefix_ranges,
87
+ USE_CAUSAL=True,
88
+ USE_PER_SEQ_CAUSAL=False,
89
+ CHUNK_LOOKBACK=-1,
90
+ CHUNK_SIZE=-1,
91
+ )
92
+
93
+ # The range is wider than the sliding window, so the upper bound must use
94
+ # its inclusive endpoint rather than query_pos + sliding_window.
95
+ assert bounds.tolist() == [3, 72, 4096]
96
+
97
+
98
+@pytest.mark.parametrize(
99
+ ("use_causal", "use_per_seq_causal"), [(False, False), (True, True)]
100
+)
101
+def test_clamped_mm_prefix_preserves_noncausal_right_window(
102
+ use_causal: bool, use_per_seq_causal: bool
103
+) -> None:
104
+ """Union the non-causal right window with intersecting image ranges."""
105
+ mm_prefix_ranges = torch.tensor(
106
+ [[[1024, 1500], [0, 0]]], dtype=torch.int32, device=DEVICE_TYPE
107
+ )
108
+ bounds = torch.empty(3, dtype=torch.int32, device=DEVICE_TYPE)
109
+
110
+ _compute_clamped_mm_tile_bounds[(1,)](
111
+ bounds,
112
+ mm_prefix_ranges,
113
+ USE_CAUSAL=use_causal,
114
+ USE_PER_SEQ_CAUSAL=use_per_seq_causal,
115
+ CHUNK_LOOKBACK=-1,
116
+ CHUNK_SIZE=-1,
117
+ )
118
+
119
+ # q_hi=1127 and window=1024 admit keys through 2150, beyond the image
120
+ # range endpoint at 1500. Tile 67 must therefore remain in the loop.
121
+ assert bounds.tolist() == [3, 68, 4096]
122
+
123
+
124
def ref_paged_attn(
125
query: torch.Tensor,
126
key_cache: torch.Tensor,
@@ -90,6 +179,170 @@ def ref_paged_attn(
179
return torch.cat(outputs, dim=0)
180
181
182
+def ref_paged_clamped_mm_attn(
183
+ query: torch.Tensor,
184
+ key_cache: torch.Tensor,
185
+ value_cache: torch.Tensor,
186
+ query_lens: list[int],
187
+ kv_lens: list[int],
188
+ block_tables: torch.Tensor,
189
+ mm_ranges: list[list[tuple[int, int]]],
190
+ scale: float,
191
+ sliding_window: int,
192
+ chunk_lookback: int,
193
+) -> torch.Tensor:
194
+ block_tables_cpu = block_tables.cpu().numpy()
195
+ _, block_size, num_kv_heads, head_size = key_cache.shape
196
+ chunk_size = sliding_window // (chunk_lookback + 1)
197
+
198
+ outputs: list[torch.Tensor] = []
199
+ query_start = 0
200
+ for req_idx, (query_len, kv_len) in enumerate(zip(query_lens, kv_lens)):
201
+ query_i = query[query_start : query_start + query_len].float()
202
+ context_len = kv_len - query_len
203
+ query_pos = torch.arange(query_len, device=query.device) + context_len
204
+ key_pos = torch.arange(kv_len, device=query.device)
205
+
206
+ num_kv_blocks = (kv_len + block_size - 1) // block_size
207
+ block_indices = block_tables_cpu[req_idx, :num_kv_blocks]
208
+ key_i = key_cache[block_indices].view(-1, num_kv_heads, head_size)[:kv_len]
209
+ value_i = value_cache[block_indices].view(-1, num_kv_heads, head_size)[:kv_len]
210
+ if query_i.shape[1] != key_i.shape[1]:
211
+ repeats = query_i.shape[1] // key_i.shape[1]
212
+ key_i = torch.repeat_interleave(key_i, repeats, dim=1)
213
+ value_i = torch.repeat_interleave(value_i, repeats, dim=1)
214
+
215
+ delta = query_pos[:, None] - key_pos[None, :]
216
+ keep = (delta >= 0) & (
217
+ query_pos[:, None] // chunk_size - key_pos[None, :] // chunk_size
218
+ <= chunk_lookback
219
+ )
220
+ for range_start, range_end in mm_ranges[req_idx]:
221
+ q_in_range = (query_pos >= range_start) & (query_pos <= range_end)
222
+ k_in_range = (key_pos >= range_start) & (key_pos <= range_end)
223
+ mm_mask = q_in_range[:, None] & k_in_range[None, :]
224
+ keep |= mm_mask & (delta < sliding_window)
225
+
226
+ scores = torch.einsum("qhd,khd->hqk", query_i, key_i.float()) * scale
227
+ scores.masked_fill_(~keep[None], float("-inf"))
228
+ probs = scores.softmax(-1).to(value_i.dtype)
229
+ outputs.append(torch.einsum("hqk,khd->qhd", probs, value_i))
230
+ query_start += query_len
231
+
232
+ return torch.cat(outputs)
233
+
234
+
235
+@torch.inference_mode()
236
+def test_triton_unified_attn_clamped_mm_matches_dense_reference() -> None:
237
+ set_random_seed(0)
238
+ query_lens = [384, 96]
239
+ kv_lens_list = [384, 320]
240
+ mm_ranges = [[(32, 351)], [(160, 287)]]
241
+ sliding_window = 128
242
+ chunk_lookback = 0
243
+ block_size = 16
244
+ num_query_heads = 4
245
+ num_kv_heads = 2
246
+ head_size = 128
247
+ scale = head_size**-0.5
248
+
249
+ query = torch.randn(
250
+ sum(query_lens),
251
+ num_query_heads,
252
+ head_size,
253
+ dtype=torch.bfloat16,
254
+ device=DEVICE_TYPE,
255
+ )
256
+ num_blocks_per_req = [
257
+ (kv_len + block_size - 1) // block_size for kv_len in kv_lens_list
258
+ ]
259
+ total_blocks = sum(num_blocks_per_req)
260
+ key_cache = torch.randn(
261
+ total_blocks,
262
+ block_size,
263
+ num_kv_heads,
264
+ head_size,
265
+ dtype=torch.bfloat16,
266
+ device=DEVICE_TYPE,
267
+ )
268
+ value_cache = torch.randn_like(key_cache)
269
+
270
+ block_tables = torch.zeros(
271
+ len(query_lens),
272
+ max(num_blocks_per_req),
273
+ dtype=torch.int32,
274
+ device=DEVICE_TYPE,
275
+ )
276
+ block_start = 0
277
+ for req_idx, num_blocks in enumerate(num_blocks_per_req):
278
+ block_tables[req_idx, :num_blocks] = torch.arange(
279
+ block_start,
280
+ block_start + num_blocks,
281
+ dtype=torch.int32,
282
+ device=DEVICE_TYPE,
283
+ )
284
+ block_start += num_blocks
285
+
286
+ cu_seqlens_q = torch.tensor(
287
+ [0] + query_lens, dtype=torch.int32, device=DEVICE_TYPE
288
+ ).cumsum(0, dtype=torch.int32)
289
+ kv_lens = torch.tensor(kv_lens_list, dtype=torch.int32, device=DEVICE_TYPE)
290
+ mm_prefix_range = torch.tensor(
291
+ [[[32, 351]], [[160, 287]]], dtype=torch.int32, device=DEVICE_TYPE
292
+ )
293
+ actual = torch.empty_like(query)
294
+
295
+ unified_attention(
296
+ q=query,
297
+ k=key_cache,
298
+ v=value_cache,
299
+ out=actual,
300
+ cu_seqlens_q=cu_seqlens_q,
301
+ max_seqlen_q=max(query_lens),
302
+ seqused_k=kv_lens,
303
+ max_seqlen_k=max(kv_lens_list),
304
+ softmax_scale=scale,
305
+ causal=True,
306
+ window_size=(sliding_window - 1, 0),
307
+ block_table=block_tables,
308
+ softcap=0,
309
+ q_descale=None,
310
+ k_descale=None,
311
+ v_descale=None,
312
+ mm_prefix_range=mm_prefix_range,
313
+ chunk_lookback=chunk_lookback,
314
+ mm_prefix_clamp_sliding_window=True,
315
+ )
316
+
317
+ expected = ref_paged_clamped_mm_attn(
318
+ query,
319
+ key_cache,
320
+ value_cache,
321
+ query_lens,
322
+ kv_lens_list,
323
+ block_tables,
324
+ mm_ranges,
325
+ scale,
326
+ sliding_window,
327
+ chunk_lookback,
328
+ )
329
+ chunk_only = ref_paged_clamped_mm_attn(
330
+ query,
331
+ key_cache,
332
+ value_cache,
333
+ query_lens,
334
+ kv_lens_list,
335
+ block_tables,
336
+ [[], []],
337
+ scale,
338
+ sliding_window,
339
+ chunk_lookback,
340
+ )
341
+
342
+ torch.testing.assert_close(actual.float(), expected.float(), atol=2e-2, rtol=2e-2)
343
+ assert not torch.allclose(expected, chunk_only, atol=2e-2, rtol=2e-2)
344
+
345
+
346
@pytest.mark.parametrize(
347
"seq_lens", [[(1, 1328), (5, 18), (129, 463)], [(1, 523), (1, 37), (1, 2011)]]
348
)
vllm/v1/attention/ops/triton_attention_helpers.py
+49
-9
@@ -157,6 +157,11 @@ def compute_tile_loop_bounds(
157
USE_PER_SEQ_CAUSAL: tl.constexpr = False,
158
CHUNK_LOOKBACK: tl.constexpr = -1,
159
CHUNK_SIZE: tl.constexpr = -1,
160
+ USE_R_SWA: tl.constexpr = False,
161
+ MM_PREFIX_CLAMP_SW: tl.constexpr = False,
162
+ MAX_MM_RANGES: tl.constexpr = 0,
163
+ mm_prefix_range_ptr=None,
164
+ seq_idx=0,
165
):
166
"""Compute the tile-loop bounds ``(loop_lo, loop_hi)`` and the
167
derived ``max_seq_prefix_len`` used for per-tile masking.
@@ -168,8 +173,10 @@ def compute_tile_loop_bounds(
173
mm_prefix is active or non-causal sequences need the full
174
sequence.
175
2. Sliding-window pruning: narrows ``[tile_start, tile_end)`` to
171
- only tiles that can contain an allowed key under SWA.
172
- For non-causal sequences, the window extends in both directions.
176
+ only tiles that can contain an allowed key under SWA. For non-causal
177
+ sequences, the window extends in both directions. For Gemma4's
178
+ window-clamped multimodal prefix mask, the bounds include the union of
179
+ the base mask and each image range intersecting the query block.
180
3. 3D scoping: when ``IS_3D`` is True, further narrows to the
181
segment's slice via ``(segm_idx * tiles_per_segment,
182
(segm_idx + 1) * tiles_per_segment)``.
@@ -182,7 +189,7 @@ def compute_tile_loop_bounds(
189
+ (BLOCK_M - 1) // num_queries_per_kv
190
+ 1
191
)
185
- if USE_MM_PREFIX or USE_PER_SEQ_CAUSAL or (not USE_CAUSAL):
192
+ if USE_MM_PREFIX or USE_R_SWA or USE_PER_SEQ_CAUSAL or (not USE_CAUSAL):
193
# Read the full sequence but never past seq_len: the causal-style
194
# formula above can overshoot for non-causal sequences, and slots
195
# >= seq_len are unwritten KV (last-block tail) that may hold NaN
@@ -198,8 +205,13 @@ def compute_tile_loop_bounds(
205
# Default: keep previous global behavior
206
tile_start = 0
207
tile_end = num_tiles
201
- # TODO(Isotr0py): sliding window pruning with image bidirectional mask
202
- if SLIDING_WINDOW > 0 and not USE_MM_PREFIX:
208
+ # Prefix ranges normally override the sliding window, so they require the
209
+ # complete sequence. Gemma4 instead clamps prefix attention to the left
210
+ # edge of the sliding window. In that case, the union of possible keys is
211
+ # bounded by the normal left window and the end of any multimodal range
212
+ # intersecting this query block.
213
+ can_prune_sliding = (not USE_R_SWA) and ((not USE_MM_PREFIX) or MM_PREFIX_CLAMP_SW)
214
+ if SLIDING_WINDOW > 0 and can_prune_sliding:
215
# Query rows covered by this Q-block
216
qpos_lo = q_block_local_idx * BLOCK_Q
217
qpos_hi = tl.minimum(
@@ -218,12 +230,40 @@ def compute_tile_loop_bounds(
230
first_allowed_key = q_abs - SLIDING_WINDOW + 1
231
if USE_PER_SEQ_CAUSAL or (not USE_CAUSAL):
232
# Non-causal: keys can be AHEAD of query within the window
221
- last_allowed_key = tl.minimum(
222
- context_len + qpos_hi + SLIDING_WINDOW - 1,
223
- seq_len - 1,
224
- )
233
+ last_allowed_key = context_len + qpos_hi + SLIDING_WINDOW - 1
234
else:
235
last_allowed_key = context_len + qpos_hi
236
+ if USE_MM_PREFIX and MM_PREFIX_CLAMP_SW:
237
+ query_abs_lo = context_len + qpos_lo
238
+ query_abs_hi = context_len + qpos_hi
239
+ for i in range(MAX_MM_RANGES):
240
+ range_start = tl.load(
241
+ mm_prefix_range_ptr + seq_idx * MAX_MM_RANGES * 2 + i * 2
242
+ )
243
+ range_end = tl.load(
244
+ mm_prefix_range_ptr + seq_idx * MAX_MM_RANGES * 2 + i * 2 + 1
245
+ )
246
+ intersects_query_block = (
247
+ (range_start < range_end)
248
+ & (range_start <= query_abs_hi)
249
+ & (range_end >= query_abs_lo)
250
+ )
251
+ mm_first_allowed_key = tl.maximum(
252
+ range_start, query_abs_lo - SLIDING_WINDOW + 1
253
+ )
254
+ first_allowed_key = tl.minimum(
255
+ first_allowed_key,
256
+ tl.where(
257
+ intersects_query_block,
258
+ mm_first_allowed_key,
259
+ first_allowed_key,
260
+ ),
261
+ )
262
+ last_allowed_key = tl.maximum(
263
+ last_allowed_key,
264
+ tl.where(intersects_query_block, range_end, last_allowed_key),
265
+ )
266
+ last_allowed_key = tl.minimum(last_allowed_key, seq_len - 1)
267
# Convert to tile indices and clamp
268
tile_start = tl.maximum(0, first_allowed_key // TILE_SIZE)
269
tile_end = tl.minimum((last_allowed_key // TILE_SIZE) + 1, num_tiles)
vllm/v1/attention/ops/triton_unified_attention.py
+6
-1
@@ -408,12 +408,17 @@ def kernel_unified_attention(
408
BLOCK_Q,
409
num_queries_per_kv,
410
SLIDING_WINDOW,
411
- USE_MM_PREFIX or USE_R_SWA,
411
+ USE_MM_PREFIX,
412
IS_3D,
413
USE_CAUSAL,
414
USE_PER_SEQ_CAUSAL,
415
CHUNK_LOOKBACK,
416
CHUNK_SIZE,
417
+ USE_R_SWA,
418
+ MM_PREFIX_CLAMP_SW,
419
+ MAX_MM_RANGES,
420
+ mm_prefix_range_ptr,
421
+ seq_idx,
422
)
423
424
# iterate through tiles (now limited to the sliding window range)