diff --git a/fastvideo/attention/backends/sdpa.py b/fastvideo/attention/backends/sdpa.py index 6bf1bc101..c8e662d47 100644 --- a/fastvideo/attention/backends/sdpa.py +++ b/fastvideo/attention/backends/sdpa.py @@ -50,13 +50,12 @@ class SDPAMetadataBuilder(AttentionMetadataBuilder): current_timestep: int, attn_mask: torch.Tensor, ) -> SDPAMetadata: - # A 2D mask handed to the builder is a tokenizer-style - # [batch, key_len] padding mask (the HunyuanVideo/HYWorld call - # sites); lift it to a broadcastable [batch, 1, 1, key_len] HERE, - # where that semantic is known. Masks that reach the impl as 2D keep - # torch's documented [query_len, key_len] broadcast meaning. - if attn_mask is not None and attn_mask.dim() == 2: - attn_mask = attn_mask[:, None, None, :] + # Store the mask exactly as passed. The metadata is cross-backend: + # call sites (HYWorld, HunyuanVideo15) build SDPAMetadata while the + # layer's selector may pick FLASH_ATTN, and the shared convention for + # padding masks is the tokenizer-style 2D [batch, key_len]. Any + # reshaping for torch.sdpa happens inside the SDPA impl + # (_normalize_attn_mask_for_sdpa). return SDPAMetadata(current_timestep=current_timestep, attn_mask=attn_mask) @@ -84,11 +83,14 @@ def _normalize_attn_mask_for_sdpa( valid_value = True if attn_mask.dtype == torch.bool else 0.0 attn_mask = F.pad(attn_mask, (key_len - attn_mask.shape[-1], 0), value=valid_value) + if attn_mask.dim() == 2: + # In-tree producers pass 2D [batch, key_len] padding masks; lift to a + # broadcastable [batch, 1, 1, key_len] here so torch.sdpa does not + # reinterpret 2D as its documented [query_len, key_len] broadcast. + return attn_mask[:, None, None, :] if attn_mask.dim() == 3: return attn_mask[:, None, :, :] - if attn_mask.dim() in (2, 4): - # 2D keeps torch's documented [query_len, key_len] broadcast; batch - # padding masks are lifted to 4D by SDPAMetadataBuilder.build. + if attn_mask.dim() == 4: return attn_mask raise ValueError(f"Unsupported attention mask shape for SDPA: {attn_mask.shape}") diff --git a/fastvideo/tests/attention/test_sdpa_metadata_mask_contract.py b/fastvideo/tests/attention/test_sdpa_metadata_mask_contract.py new file mode 100644 index 000000000..f5eb48b36 --- /dev/null +++ b/fastvideo/tests/attention/test_sdpa_metadata_mask_contract.py @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: Apache-2.0 +"""CPU-only regression test for the SDPA metadata mask contract. + +SDPAMetadata is cross-backend: HYWorld and HunyuanVideo15 build it while the +layer's attention selector may pick FLASH_ATTN, and the shared convention +for padding masks is the tokenizer-style 2D [batch, key_len]. The builder +must store 2D masks unchanged; only the torch-sdpa impl lifts them to 4D +internally. + +Regression guard for the CI break where the builder lifted 2D -> 4D and the +FLASH_ATTN equal-length branch padded ``attn_mask.shape[1]`` (== 1 for 4D) +into a garbage-length mask (CUDA device-side assert, SIGABRT). +""" + +from __future__ import annotations + +import torch + +from fastvideo.attention.backends.sdpa import (SDPAMetadataBuilder, _normalize_attn_mask_for_sdpa) + + +def test_builder_keeps_2d_padding_mask_2d() -> None: + """A tokenizer-style 2D int mask must be stored as-is (cross-backend contract).""" + mask = torch.tensor([[1, 1, 0], [1, 0, 0]], dtype=torch.int64) + md = SDPAMetadataBuilder().build(current_timestep=0, attn_mask=mask) + assert md.attn_mask is not None + assert md.attn_mask.dim() == 2 + assert md.attn_mask.shape == (2, 3) + assert torch.equal(md.attn_mask, mask) + + +def test_normalize_lifts_2d_int_mask_to_4d_bool_for_torch_sdpa() -> None: + """The torch-sdpa consumer coerces int -> bool and lifts 2D -> [B,1,1,K].""" + batch, q_len, k_len, heads, head_dim = 2, 4, 3, 1, 8 + query = torch.zeros(batch, heads, q_len, head_dim) + key = torch.zeros(batch, heads, k_len, head_dim) + mask = torch.tensor([[1, 1, 0], [1, 0, 0]], dtype=torch.int64) + + out = _normalize_attn_mask_for_sdpa(mask, query, key) + assert out is not None + assert out.dtype == torch.bool + assert out.shape == (batch, 1, 1, k_len) + assert torch.equal(out[:, 0, 0, :], mask != 0) diff --git a/fastvideo/tests/modal/pr_test.py b/fastvideo/tests/modal/pr_test.py index a80e9a8f6..4214e9861 100644 --- a/fastvideo/tests/modal/pr_test.py +++ b/fastvideo/tests/modal/pr_test.py @@ -280,7 +280,7 @@ def run_self_forcing_tests(): @app.function(gpu="L40S:1", image=image, timeout=900) def run_unit_test(): run_test( - "pytest ./fastvideo/tests/api/ ./fastvideo/tests/contract/ ./fastvideo/tests/dataset/ ./fastvideo/tests/workflow/ ./fastvideo/tests/entrypoints/ ./fastvideo/tests/train/ ./fastvideo/tests/stages/ ./fastvideo/tests/ops/ ./fastvideo/tests/training/test_trackers.py --ignore=./fastvideo/tests/entrypoints/test_openai_api_integration.py --ignore=./fastvideo/tests/train/models --ignore=./fastvideo/tests/train/methods -vs" + "pytest ./fastvideo/tests/api/ ./fastvideo/tests/contract/ ./fastvideo/tests/dataset/ ./fastvideo/tests/workflow/ ./fastvideo/tests/entrypoints/ ./fastvideo/tests/train/ ./fastvideo/tests/stages/ ./fastvideo/tests/ops/ ./fastvideo/tests/training/test_trackers.py ./fastvideo/tests/attention/test_sdpa_metadata_mask_contract.py --ignore=./fastvideo/tests/entrypoints/test_openai_api_integration.py --ignore=./fastvideo/tests/train/models --ignore=./fastvideo/tests/train/methods -vs" )