[Bugfix][Gemma4] Keep image bidirectional attention within the sliding window (#47217)

Signed-off-by: Luciano Martins <lucianommartins@users.noreply.github.com> Co-authored-by: Luciano Martins <lucianommartins@users.noreply.github.com>

Luciano Martins committed Jul 2, 2026 at 23:57 UTC 979f5511d78b317760d45df9290233c27793a0af
8 files changed +89 -9
vllm/model_executor/layers/attention/attention.py
+4
@@ -216,6 +216,7 @@ class Attention(nn.Module, AttentionLayerBase):
216 prefix: str = "",
217 attn_type: str = AttentionType.DECODER,
218 kv_sharing_target_layer_name: str | None = None,
219 + mm_prefix_clamp_sliding_window: bool = False,
220 attn_backend: type[AttentionBackend] | None = None,
221 head_size_v: int | None = None,
222 **extra_impl_args,
@@ -420,6 +421,9 @@ class Attention(nn.Module, AttentionLayerBase):
421 compilation_config.static_forward_context,
422 )
423 self.kv_sharing_target_layer_name = kv_sharing_target_layer_name
424 + # Gemma4: clamp mm_prefix bidirectional ranges by the sliding window
425 + # (read by the Triton backend impl). Default False for all other models.
426 + self.mm_prefix_clamp_sliding_window = mm_prefix_clamp_sliding_window
427
428 # use a placeholder kv cache tensor during init, which will be replaced
429 # by bind_kv_cache
vllm/model_executor/models/gemma4.py
+7
@@ -499,6 +499,13 @@ class Gemma4Attention(nn.Module):
499 logits_soft_cap=attn_logits_soft_cap,
500 per_layer_sliding_window=sliding_window,
501 kv_sharing_target_layer_name=kv_sharing_target_layer_name,
502 + # Gemma4 vision bidi: on sliding layers the bidirectional image
503 + # block must stay within the sliding window, matching HF's
504 + # (causal OR blockwise) AND sliding_window. Without this the image
505 + # span (~1100 soft tokens at max_soft_tokens=1120) exceeds the 1024
506 + # window; the runner keeps the full range and the kernel bounds it
507 + # per-query here.
508 + mm_prefix_clamp_sliding_window=self.is_sliding,
509 prefix=f"{prefix}.attn",
510 )
511
vllm/model_executor/models/gemma4_mm.py
+7
@@ -978,6 +978,13 @@ class Gemma4ForConditionalGeneration(
978 SupportsLoRA,
979 SupportsEagle3,
980 ):
981 + # Gemma4 clamps mm_prefix bidirectional ranges to the sliding window
982 + # in-kernel (HF's (causal OR blockwise) AND sliding_window). The model
983 + # runner reads this to keep image bidirectional ranges that exceed the
984 + # window instead of dropping them (which would make image attention
985 + # causal-only for images larger than the sliding window).
986 + mm_prefix_clamp_sliding_window: bool = True
987 +
988 packed_modules_mapping = {
989 "qkv_proj": [
990 "q_proj",
vllm/v1/attention/backends/flash_attn.py
+32 -4
@@ -892,7 +892,22 @@ class FlashAttentionImpl(AttentionImpl):
892 and self.vllm_flash_attn_version == 4
893 ):
894 max_ranges = mm_prefix_ranges.shape[1]
895 - mm_mask_mod = _make_mm_prefix_mask_mod(max_ranges)
895 + # Gemma4: clamp the bidirectional block to the sliding
896 + # window (HF (causal OR blockwise) AND sliding_window on
897 + # local layers). Other mm-prefix models pass sliding_window=0
898 + # -> unclamped, behavior unchanged. sliding_window_size[0]+1
899 + # is the window (self.sliding_window = (sw-1, 0) on sliding
900 + # layers); causal mm_prefix never hits the symmetric path.
901 + mm_clamp_sw = 0
902 + if (
903 + getattr(layer, "mm_prefix_clamp_sliding_window", False)
904 + and sliding_window_size is not None
905 + and sliding_window_size[0] >= 0
906 + ):
907 + mm_clamp_sw = sliding_window_size[0] + 1
908 + mm_mask_mod = _make_mm_prefix_mask_mod(
909 + max_ranges, sliding_window=mm_clamp_sw
910 + )
911 mm_aux = [mm_prefix_ranges]
912
913 # R-SWA: use CuTE-DSL mask_mod on FA4 for exact token-level
@@ -1189,13 +1204,23 @@ class FlashAttentionImpl(AttentionImpl):
1204 return output
1205
1206
1192 -def _make_mm_prefix_mask_mod(max_ranges: int):
1207 +def _make_mm_prefix_mask_mod(max_ranges: int, sliding_window: int = 0):
1208 """Build a CuTE-DSL mask_mod implementing (causal OR mm_prefix).
1209
1210 Returns a @cute.jit callable that evaluates:
1211 keep = (kv_idx <= q_idx) OR
1197 - (q_idx in [r_start,r_end] AND kv_idx in [r_start,r_end])
1212 + (q_idx in [r_start,r_end] AND kv_idx in [r_start,r_end]
1213 + [AND (q_idx - kv_idx) < sliding_window])
1214 for each mm_prefix range stored in aux_tensors[0].
1215 +
1216 + When sliding_window > 0 (Gemma4 sliding layers), the bidirectional block is
1217 + clamped to the sliding window, matching HF's
1218 + (causal OR blockwise) AND sliding_window (== sliding_window_overlay
1219 + kv > q - sw; future kv passes since q - kv < 0). sliding_window <= 0
1220 + (default) leaves the block unclamped, so other mm-prefix models are
1221 + unchanged. q_idx/kv_idx are local offsets, but their difference is
1222 + frame-invariant and image bidirectional attention only matters during
1223 + prefill (where local == absolute).
1224 """
1225 import cutlass
1226 import cutlass.cute as cute
@@ -1223,7 +1248,10 @@ def _make_mm_prefix_mask_mod(max_ranges: int):
1248 valid = r_start < r_end
1249 q_in = (q_idx >= r_start) & (q_idx <= r_end) & valid
1250 k_in = (kv_idx >= r_start) & (kv_idx <= r_end) & valid
1226 - keep = keep | (q_in & k_in)
1251 + mm = q_in & k_in
1252 + if sliding_window > 0:
1253 + mm = mm & ((q_idx - kv_idx) < sliding_window)
1254 + keep = keep | mm
1255 return keep
1256
1257 mm_prefix_mask_mod.use_fast_sampling = True
vllm/v1/attention/backends/triton_attn.py
+3
@@ -676,6 +676,9 @@ class TritonAttentionImpl(AttentionImpl):
676 v_scale_cache=v_scale_cache,
677 chunk_lookback=self.chunk_lookback,
678 use_td=self.use_td,
679 + mm_prefix_clamp_sliding_window=getattr(
680 + layer, "mm_prefix_clamp_sliding_window", False
681 + ),
682 )
683
684 return output
vllm/v1/attention/ops/triton_attention_helpers.py
+13 -2
@@ -283,6 +283,7 @@ def compute_kv_seq_mask(
283 per_seq_causal_ptr=None,
284 CHUNK_LOOKBACK: tl.constexpr = -1,
285 CHUNK_SIZE: tl.constexpr = -1,
286 + MM_PREFIX_CLAMP_SW: tl.constexpr = False,
287 ):
288 """Build the KV mask for one tile.
289
@@ -333,7 +334,14 @@ def compute_kv_seq_mask(
334 seq_mask = seq_mask & sw_left
335
336 # PrefixLM: extend mask with bidirectional ranges for multimodal tokens.
336 - # Applied AFTER sliding window so mm_prefix ranges override SW restriction.
337 + # Default (MM_PREFIX_CLAMP_SW=False): applied AFTER sliding window so
338 + # mm_prefix ranges override the SW restriction -> (causal AND SW) OR mm.
339 + # Gemma4 (MM_PREFIX_CLAMP_SW=True): the bidirectional image block must stay
340 + # within the sliding window, matching HF's (causal OR blockwise) AND
341 + # sliding_window. We AND each range with the SW past-bound
342 + # (query_abs_pos - seq_offset) < SLIDING_WINDOW (== HF sliding_window_overlay
343 + # kv > q - sw; future kv passes trivially). Inert for full-attention layers
344 + # (SLIDING_WINDOW <= 0).
345 if USE_MM_PREFIX:
346 for i in range(MAX_MM_RANGES):
347 range_start = tl.load(
@@ -351,7 +359,10 @@ def compute_kv_seq_mask(
359 & (seq_offset[None, :] <= range_end)
360 & is_valid
361 )
354 - seq_mask |= q_in_range & k_in_range
362 + mm_mask = q_in_range & k_in_range
363 + if MM_PREFIX_CLAMP_SW and SLIDING_WINDOW > 0:
364 + mm_mask = mm_mask & ((query_abs_pos - seq_offset) < SLIDING_WINDOW)
365 + seq_mask |= mm_mask
366 return seq_mask
367
368
vllm/v1/attention/ops/triton_unified_attention.py
+9
@@ -275,6 +275,10 @@ def kernel_unified_attention(
275 USE_TD: tl.constexpr = False,
276 USE_TD_QO: tl.constexpr = False,
277 Q_IS_FP8: tl.constexpr = False,
278 + # Gemma4: clamp mm_prefix bidirectional ranges by the sliding window
279 + # instead of letting them override it. Default False preserves the
280 + # original (causal AND SW) OR mm_prefix behavior for all other models.
281 + MM_PREFIX_CLAMP_SW: tl.constexpr = False,
282 ):
283 USE_PER_TOKEN_HEAD_SCALES: tl.constexpr = KV_QUANT_MODE >= 2
284 USE_FP8_Q_DESCALE: tl.constexpr = KV_QUANT_MODE == 1 and Q_IS_FP8
@@ -509,6 +513,7 @@ def kernel_unified_attention(
513 per_seq_causal_ptr,
514 CHUNK_LOOKBACK,
515 CHUNK_SIZE,
516 + MM_PREFIX_CLAMP_SW,
517 )
518
519 # S : (BLOCK_M, TILE_SIZE)
@@ -819,6 +824,9 @@ def unified_attention(
824 # The non-TD branch is dead-code-eliminated at Triton compile time so
825 # disabling this flag costs nothing.
826 use_td: bool = False,
827 + # Gemma4: clamp mm_prefix bidirectional ranges by the sliding window.
828 + # Default False keeps the original behavior for every other model.
829 + mm_prefix_clamp_sliding_window: bool = False,
830 ):
831 # Resolve causal: bool or per-seq tensor.
832 use_per_seq_causal = isinstance(causal, torch.Tensor)
@@ -1130,6 +1138,7 @@ def unified_attention(
1138 CHUNK_SIZE=chunk_size,
1139 USE_TD=use_td,
1140 USE_TD_QO=use_td_qo,
1141 + MM_PREFIX_CLAMP_SW=mm_prefix_clamp_sliding_window,
1142 **launch_kwargs,
1143 )
1144
vllm/v1/worker/gpu_model_runner.py
+14 -3
@@ -2330,13 +2330,20 @@ class GPUModelRunner(
2330
2331 # Compute mm_prefix bidirectional ranges before building
2332 # attention metadata so builders handle them during build().
2333 - # Ranges exceeding sliding_window are skipped to prevent
2334 - # early tokens from attending across the entire image span.
2333 + # By default, ranges exceeding sliding_window are skipped to prevent
2334 + # early tokens from attending across the entire image span. Models that
2335 + # clamp mm_prefix to the sliding window *in-kernel* (e.g. Gemma4, which
2336 + # needs HF's (causal OR blockwise) AND sliding_window on sliding layers)
2337 + # opt out of the skip so the bidirectional range survives for images
2338 + # larger than the window; the kernel then bounds it per-query.
2339 req_doc_ranges: dict[int, list[tuple[int, int]]] | None = None
2340 if self.is_mm_prefix_lm:
2341 req_doc_ranges = {}
2342 hf_text_config = self.model_config.hf_text_config
2343 _bidi_sw = getattr(hf_text_config, "sliding_window", None)
2344 + _clamps_in_kernel = getattr(
2345 + self.model, "mm_prefix_clamp_sliding_window", False
2346 + )
2347 for req_id in self.input_batch.req_ids:
2348 image_doc_ranges = []
2349 req_state = self.requests[req_id]
@@ -2346,7 +2353,11 @@ class GPUModelRunner(
2353 pos_info = mm_feature.mm_position
2354 img_doc_range = pos_info.extract_embeds_range()
2355 for r in img_doc_range:
2349 - if _bidi_sw is not None and (r[1] - r[0] + 1) > _bidi_sw:
2356 + if (
2357 + not _clamps_in_kernel
2358 + and _bidi_sw is not None
2359 + and (r[1] - r[0] + 1) > _bidi_sw
2360 + ):
2361 continue
2362 image_doc_ranges.append(r)
2363 req_idx = self.input_batch.req_id_to_index[req_id]