Skip to content

Commit 1e2362d

Browse files
authored
fix(model): preserve Gemma 4 image attention across pipeline stages (NVIDIA-NeMo#6187)
Signed-off-by: Chen Cui <chcui@nvidia.com>
1 parent 1f8873b commit 1e2362d

4 files changed

Lines changed: 122 additions & 10 deletions

File tree

‎src/megatron/bridge/models/gemma/modeling_gemma4.py‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1741,8 +1741,21 @@ def __init__(
17411741
**kwargs,
17421742
):
17431743
config = copy.deepcopy(config)
1744-
if _is_local_attn_layer(layer_number, config.interleaved_attn_pattern):
1745-
config.window_size = (config.window_size - 1, 0)
1744+
is_local = _is_local_attn_layer(layer_number, config.interleaved_attn_pattern)
1745+
text_config = getattr(config, "text_config", None)
1746+
self._image_bidirectional_attention = (
1747+
is_local and getattr(text_config, "use_bidirectional_attention", None) == "vision"
1748+
)
1749+
self._image_attention_window = config.window_size if self._image_bidirectional_attention else None
1750+
if is_local:
1751+
if self._image_bidirectional_attention:
1752+
# Compose the left window into the explicit mask. TE cannot express
1753+
# a finite left window with an unrestricted right window, and a zero
1754+
# right window would remove future tokens in the same image block.
1755+
config.window_size = None
1756+
attn_mask_type = AttnMaskType.arbitrary
1757+
else:
1758+
config.window_size = (config.window_size - 1, 0)
17461759
else:
17471760
config.window_size = None
17481761

@@ -1755,6 +1768,28 @@ def __init__(
17551768
**kwargs,
17561769
)
17571770

1771+
def forward(
1772+
self,
1773+
query: Tensor,
1774+
key: Tensor,
1775+
value: Tensor,
1776+
attention_mask: Tensor | None,
1777+
attn_mask_type: AttnMaskType,
1778+
**kwargs,
1779+
) -> Tensor:
1780+
"""Honor the image mask in sliding layers while keeping global layers causal."""
1781+
if self._image_bidirectional_attention:
1782+
# TE ignores explicit masks in causal mode. Global Gemma 4 attention
1783+
# remains causal, including image tokens, as in the HF implementation.
1784+
query_positions = torch.arange(query.size(0), device=query.device) + key.size(0) - query.size(0)
1785+
key_positions = torch.arange(key.size(0), device=key.device)
1786+
outside_window = key_positions[None, :] <= query_positions[:, None] - self._image_attention_window
1787+
if attention_mask is None:
1788+
attention_mask = key_positions[None, :] > query_positions[:, None]
1789+
attention_mask = attention_mask | outside_window[None, None, :, :]
1790+
attn_mask_type = AttnMaskType.arbitrary
1791+
return super().forward(query, key, value, attention_mask, attn_mask_type, **kwargs)
1792+
17581793

17591794
class Gemma4RotaryEmbedding(RotaryEmbedding):
17601795
"""Gemma 4 MoE position RoPE — dual local/global embeddings."""

‎src/megatron/bridge/models/gemma_vl/modeling_gemma4_vl.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,8 +365,7 @@ def freeze(
365365

366366
def _compute_attention_mask(self, input_ids: torch.Tensor) -> Optional[torch.Tensor]:
367367
"""Compute HF-style attention masks for full and sliding Gemma4 layers."""
368-
if not self.pre_process:
369-
return None
368+
# Every pipeline stage receives token IDs and must preserve image attention.
370369
batch_size, seq_len = input_ids.shape
371370
causal_mask = torch.tril(
372371
torch.ones((batch_size, 1, seq_len, seq_len), dtype=torch.bool, device=input_ids.device)

‎tests/unit_tests/models/gemma/test_gemma4_modeling.py‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,77 @@ def fake_forward(self, **kwargs):
838838

839839

840840
class TestGemma4TEDotProductAttention:
841+
def test_image_attention_keeps_future_image_tokens_in_window(self, monkeypatch):
842+
from megatron.core.transformer.enums import AttnMaskType
843+
844+
calls = {}
845+
846+
def fake_init(self, **kwargs):
847+
calls.update(kwargs)
848+
849+
monkeypatch.setattr("megatron.bridge.models.gemma.modeling_gemma4.TEDotProductAttention.__init__", fake_init)
850+
config = SimpleNamespace(
851+
interleaved_attn_pattern=(1, 1),
852+
window_size=512,
853+
text_config=SimpleNamespace(use_bidirectional_attention="vision"),
854+
)
855+
Gemma4TEDotProductAttention(
856+
config=config, layer_number=1, attn_mask_type=AttnMaskType.causal, attention_type="self"
857+
)
858+
859+
assert calls["config"].window_size is None
860+
assert calls["attn_mask_type"] == AttnMaskType.arbitrary
861+
assert config.window_size == 512
862+
863+
@pytest.mark.parametrize("image_bidirectional", [True, False])
864+
def test_forward_selects_explicit_image_mask_mode(self, monkeypatch, image_bidirectional):
865+
from megatron.core.transformer.enums import AttnMaskType
866+
867+
calls = {}
868+
869+
def fake_forward(self, query, key, value, attention_mask, attn_mask_type, **kwargs):
870+
calls.update(mask=attention_mask, mask_type=attn_mask_type, **kwargs)
871+
return value
872+
873+
monkeypatch.setattr("megatron.bridge.models.gemma.modeling_gemma4.TEDotProductAttention.forward", fake_forward)
874+
attention = object.__new__(Gemma4TEDotProductAttention)
875+
attention._image_bidirectional_attention = image_bidirectional
876+
attention._image_attention_window = 512 if image_bidirectional else None
877+
mask = torch.tensor([[[[False, False], [False, False]]]])
878+
value = torch.ones(2, 1, 1, 4)
879+
880+
output = attention.forward(value, value, value, mask, AttnMaskType.causal, attention_bias=None)
881+
882+
assert output is value
883+
torch.testing.assert_close(calls["mask"], mask)
884+
assert calls["mask_type"] == (AttnMaskType.arbitrary if image_bidirectional else AttnMaskType.causal)
885+
assert calls["attention_bias"] is None
886+
887+
@pytest.mark.parametrize("with_image_mask", [True, False])
888+
def test_image_attention_applies_left_window_without_losing_future_image_tokens(
889+
self, monkeypatch, with_image_mask
890+
):
891+
from megatron.core.transformer.enums import AttnMaskType
892+
893+
calls = {}
894+
895+
def fake_forward(self, query, key, value, attention_mask, attn_mask_type, **kwargs):
896+
calls["mask"] = attention_mask
897+
return value
898+
899+
monkeypatch.setattr("megatron.bridge.models.gemma.modeling_gemma4.TEDotProductAttention.forward", fake_forward)
900+
attention = object.__new__(Gemma4TEDotProductAttention)
901+
attention._image_bidirectional_attention = True
902+
attention._image_attention_window = 2
903+
value = torch.ones(4, 1, 1, 4)
904+
mask = torch.zeros(1, 1, 4, 4, dtype=torch.bool) if with_image_mask else None
905+
906+
attention.forward(value, value, value, mask, AttnMaskType.causal)
907+
908+
assert calls["mask"][0, 0, 3, 0].item() is True
909+
assert calls["mask"][0, 0, 3, 2].item() is False
910+
assert calls["mask"][0, 0, 0, 1].item() is (not with_image_mask)
911+
841912
def test_init_sets_local_window_size(self, monkeypatch):
842913
calls = []
843914

@@ -870,7 +941,11 @@ def fake_init(self, **kwargs):
870941
"megatron.bridge.models.gemma.modeling_gemma4.TEDotProductAttention.__init__",
871942
fake_init,
872943
)
873-
cfg = SimpleNamespace(interleaved_attn_pattern=(1, 1), window_size=512)
944+
cfg = SimpleNamespace(
945+
interleaved_attn_pattern=(1, 1),
946+
window_size=512,
947+
text_config=SimpleNamespace(use_bidirectional_attention="vision"),
948+
)
874949

875950
Gemma4TEDotProductAttention(
876951
config=cfg,

‎tests/unit_tests/models/gemma_vl/test_gemma4_vl_modeling.py‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,13 +151,16 @@ def test_two_separate_image_blocks(self):
151151
# The key point: block1 CANNOT attend forward to block2 (causal blocks it)
152152
assert mask[0, 0, 0, 3].item() is True, "block1 pos 0 should be blocked from future block2 pos 3"
153153

154-
def test_not_pre_process_returns_none(self):
155-
"""Returns None when pre_process=False (PP pipeline stage)."""
154+
@pytest.mark.parametrize("pre_process", [True, False])
155+
def test_image_attention_on_every_pipeline_stage(self, pre_process):
156+
"""Later stages preserve image bidirectionality and text causality."""
156157
model = _make_model()
157-
model.pre_process = False
158-
input_ids = self._make_ids([self.TEXT_TOKEN] * 4)
158+
model.pre_process = pre_process
159+
input_ids = self._make_ids([self.TEXT_TOKEN, self.IMAGE_TOKEN, self.IMAGE_TOKEN, self.TEXT_TOKEN])
159160
result = model._compute_attention_mask(input_ids)
160-
assert result is None
161+
assert result[0, 0, 1, 2].item() is False
162+
assert result[0, 0, 0, 1].item() is True
163+
assert result[0, 0, 2, 3].item() is True
161164

162165
def test_output_shape_batch_size_2(self):
163166
"""Mask shape is [B, 1, S, S] for batch_size=2."""

0 commit comments

Comments
 (0)