Skip to content

Commit c167928

Browse files
committed
[PyTorch] Zero padded THD rows for FlashAttention and CP
Signed-off-by: xiaoyao0115 <1804647152@qq.com>
1 parent 1634a5a commit c167928

5 files changed

Lines changed: 215 additions & 8 deletions

File tree

‎tests/pytorch/attention/run_attention_with_cp.py‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
get_cu_seqlens_on_cp_rank,
1414
get_thd_partitioned_indices,
1515
)
16-
from transformer_engine.pytorch.attention.dot_product_attention.utils import combine_and_quantize
16+
from transformer_engine.pytorch.attention.dot_product_attention.utils import (
17+
combine_and_quantize,
18+
get_thd_padding_mask,
19+
)
1720
from transformer_engine.pytorch import DType
1821
from test_attention_with_cp import (
1922
model_configs_flash_attn,
@@ -696,11 +699,18 @@ def run_dpa_with_cp(
696699
num_pads_kv = (cu_seqlens_kv_padded - cu_seqlens_kv)[1:] - (
697700
cu_seqlens_kv_padded - cu_seqlens_kv
698701
)[:-1]
699-
# FA3 leaves garbage at padding positions despite seqused_q/k (tile spillover).
700-
# Forward out_ can't be pre-zeroed because FA3's custom op returns out_ as an
701-
# output rather than mutating it in-place, triggering PyTorch's aliasing constraint.
702-
# Backward dq/dk/dv CAN be pre-zeroed because FA3 marks them as mutated inputs.
702+
# FA3/FA4 kernels may leave physically padded rows unwritten. The CP
703+
# implementation must clean them after partial-output/gradient merges.
703704
if fa_pad_between_seqs == "True":
705+
out_padding_mask = get_thd_padding_mask(
706+
out_.shape[0], cu_seqlens_q, cu_seqlens_q_padded
707+
)
708+
nnz = torch.count_nonzero(out_[out_padding_mask]).item()
709+
assert nnz == 0, (
710+
f"out_ has {nnz} nonzero values in THD padding — "
711+
"context_parallel.py should zero padding positions"
712+
)
713+
704714
# out_ is a view inside the CP custom autograd Function, so in-place
705715
# zeroing is blocked by PyTorch. Clone to break the view relationship.
706716
out_ = out_.clone()

‎tests/pytorch/attention/test_attention.py‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
FlashAttentionUtils,
3333
_get_supported_versions,
3434
check_set_window_size,
35+
get_thd_padding_mask,
3536
)
3637
from transformer_engine.pytorch.attention import RotaryPositionEmbedding
3738
import transformer_engine.pytorch.cpp_extensions as ext
@@ -129,6 +130,124 @@ def test_flash_attention_supported_version_message():
129130
)
130131

131132

133+
@pytest.mark.parametrize(
134+
"cu_seqlens,cu_seqlens_padded,expected",
135+
[
136+
([0, 3, 8], [0, 4, 12], [False] * 3 + [True] + [False] * 5 + [True] * 3),
137+
([0, 3, 8], [0, 4, 12], [False] * 3 + [True] + [False] * 5 + [True] * 7),
138+
([0, 3, 8], [0, 3, 8], [False] * 8 + [True] * 4),
139+
([0, 0, 3, 3, 5], [0, 0, 4, 4, 8], [False] * 3 + [True] + [False] * 2 + [True] * 4),
140+
],
141+
)
142+
def test_thd_padding_mask_capacity(cu_seqlens, cu_seqlens_padded, expected):
143+
"""Cover sequence gaps, reserved buffer tails, and zero-length sequences."""
144+
actual = get_thd_padding_mask(
145+
len(expected),
146+
torch.tensor(cu_seqlens, dtype=torch.int32, device="cuda"),
147+
torch.tensor(cu_seqlens_padded, dtype=torch.int32, device="cuda"),
148+
)
149+
torch.testing.assert_close(actual, torch.tensor(expected, dtype=torch.bool, device="cuda"))
150+
151+
152+
@pytest.mark.parametrize("fa_version", [3, 4])
153+
def test_dpa_flash_thd_padding_capacity_cuda_graph(fa_version, monkeypatch):
154+
"""Check padded outputs and gradients, including buffer tails, in eager and raw graphs."""
155+
if fa_version == 3 and (
156+
device_compute_capability != (9, 0) or not FlashAttentionUtils.v3_is_installed
157+
):
158+
pytest.skip("FlashAttention 3 on Hopper is required.")
159+
if fa_version == 4 and (
160+
not (10, 0) <= device_compute_capability < (12, 0)
161+
or not FlashAttentionUtils.v4_is_installed
162+
):
163+
pytest.skip("FlashAttention 4 on SM100/SM110 is required.")
164+
for key, value in {
165+
"NVTE_FLASH_ATTN": "1",
166+
"NVTE_FUSED_ATTN": "0",
167+
"NVTE_UNFUSED_ATTN": "0",
168+
"NVTE_FLASH_ATTN_V2": "0",
169+
"NVTE_FLASH_ATTN_V3": str(int(fa_version == 3)),
170+
"NVTE_FLASH_ATTN_V4": str(int(fa_version == 4)),
171+
}.items():
172+
monkeypatch.setenv(key, value)
173+
# Restore the complete backend cache along with the environment at test teardown.
174+
for key, value in _attention_backends.items():
175+
monkeypatch.setitem(_attention_backends, key, value)
176+
_attention_backends["backend_selection_requires_update"] = True
177+
reset_rng_states()
178+
cu_q, cu_kv, padded_q, padded_kv = [
179+
torch.tensor(offsets, dtype=torch.int32, device="cuda")
180+
for offsets in ([0, 61, 96], [0, 45, 118], [0, 64, 112], [0, 64, 160])
181+
]
182+
# Expected valid rows are independent of get_thd_padding_mask.
183+
q_rows = torch.tensor(list(range(61)) + list(range(64, 99)), device="cuda")
184+
kv_rows = torch.tensor(list(range(45)) + list(range(64, 137)), device="cuda")
185+
qkv = [
186+
torch.randn(tokens, 4, 64, dtype=torch.bfloat16, device="cuda", requires_grad=True)
187+
for tokens in (128, 192, 192)
188+
]
189+
grad_output = torch.randn(128, 256, dtype=torch.bfloat16, device="cuda")
190+
block = DotProductAttention(
191+
4,
192+
64,
193+
qkv_format="thd",
194+
attn_mask_type="padding",
195+
attention_type="cross",
196+
attention_dropout=0.0,
197+
).cuda()
198+
199+
def run(inputs, grad, padded):
200+
"""Run the selected FlashAttention backend and differentiate Q/K/V."""
201+
output = block(
202+
*inputs,
203+
cu_seqlens_q=cu_q,
204+
cu_seqlens_kv=cu_kv,
205+
cu_seqlens_q_padded=padded_q if padded else None,
206+
cu_seqlens_kv_padded=padded_kv if padded else None,
207+
max_seqlen_q=128,
208+
max_seqlen_kv=128,
209+
pad_between_seqs=padded,
210+
)
211+
assert _attention_backends["use_flash_attention"]
212+
assert _attention_backends["flash_attention_backend"].major == fa_version
213+
assert not _attention_backends["use_fused_attention"]
214+
assert not _attention_backends["use_unfused_attention"]
215+
return (output, *torch.autograd.grad(output, inputs, grad))
216+
217+
def check(actual):
218+
"""Compare valid rows with compact attention and require exact-zero padding."""
219+
compact_qkv = [
220+
tensor.detach()[rows].requires_grad_()
221+
for tensor, rows in zip(qkv, (q_rows, kv_rows, kv_rows))
222+
]
223+
expected = run(compact_qkv, grad_output[q_rows], False)
224+
for tensor, reference, rows in zip(actual, expected, (q_rows, q_rows, kv_rows, kv_rows)):
225+
assert tensor.shape[0] == (128 if rows is q_rows else 192)
226+
torch.testing.assert_close(tensor[rows], reference, atol=1.5e-2, rtol=1.5e-2)
227+
padding = torch.ones(tensor.shape[0], dtype=torch.bool, device="cuda")
228+
padding[rows] = False
229+
assert torch.count_nonzero(tensor[padding]).item() == 0
230+
231+
check(run(qkv, grad_output, True))
232+
stream = torch.cuda.Stream()
233+
stream.wait_stream(torch.cuda.current_stream())
234+
with torch.cuda.stream(stream):
235+
for _ in range(3):
236+
run(qkv, grad_output, True)
237+
torch.cuda.current_stream().wait_stream(stream)
238+
torch.cuda.synchronize()
239+
graph = torch.cuda.CUDAGraph()
240+
with torch.cuda.graph(graph, stream=stream):
241+
captured = run(qkv, grad_output, True)
242+
for _ in range(2):
243+
# Replays must consume fresh inputs and upstream gradients at the same addresses.
244+
with torch.no_grad():
245+
for tensor in (*qkv, grad_output):
246+
tensor.normal_()
247+
graph.replay()
248+
check(captured)
249+
250+
132251
# Define F16 data types to test
133252
param_types = [torch.float16]
134253
if is_bf16_available():
@@ -1560,6 +1679,22 @@ def get_dummy_cuda_rng_tracker() -> CudaRNGStatesTracker:
15601679

15611680
if backend in ["UnfusedDotProductAttention"]:
15621681
return out, max_logit, (q_grad, k_grad, v_grad, d_softmax_offset)
1682+
if backend == "FlashAttention" and qkv_format == "thd" and pad_between_seqs:
1683+
tensors_and_boundaries = [("out", out, cu_seqlens_q, cu_seqlens_q_after_pad)]
1684+
if is_training:
1685+
tensors_and_boundaries.extend(
1686+
[
1687+
("dq", q_grad, cu_seqlens_q, cu_seqlens_q_after_pad),
1688+
("dk", k_grad, cu_seqlens_kv, cu_seqlens_kv_after_pad),
1689+
("dv", v_grad, cu_seqlens_kv, cu_seqlens_kv_after_pad),
1690+
]
1691+
)
1692+
for tensor_name, tensor, cu_seqlens, cu_seqlens_padded in tensors_and_boundaries:
1693+
padding_mask = get_thd_padding_mask(tensor.shape[0], cu_seqlens, cu_seqlens_padded)
1694+
assert (
1695+
torch.count_nonzero(tensor[padding_mask]).item() == 0
1696+
), f"{backend} left nonzero values in {tensor_name} padding"
1697+
15631698
if backend in ["FusedAttention", "FlashAttention"]:
15641699
if qkv_format == "thd" and pad_between_seqs:
15651700
out_orig = torch.Tensor([]).to(device="cuda", dtype=dtype)

‎transformer_engine/pytorch/attention/dot_product_attention/backends.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,33 @@ def _unalias_cu_seqlens(cu_q: torch.Tensor, cu_kv: torch.Tensor):
844844
return cu_q, cu_kv
845845

846846

847+
def _mask_thd_padding(tensor: torch.Tensor, padding_mask: torch.Tensor) -> torch.Tensor:
848+
"""Return ``tensor`` with invalid THD token rows zeroed."""
849+
mask = padding_mask.view(padding_mask.shape[0], *([1] * (tensor.ndim - 1)))
850+
return tensor.masked_fill(mask, 0)
851+
852+
853+
class _MaskTHDPaddingGrad(torch.autograd.Function):
854+
"""Identity in forward and zero THD padding rows in backward.
855+
856+
FA3 and FA4 may leave padding rows unwritten in their gradient buffers.
857+
Keeping the forward as an identity avoids copying Q/K/V, while the custom
858+
backward guarantees zero padding rows before gradients reach the caller.
859+
"""
860+
861+
@staticmethod
862+
def forward(ctx, tensor: torch.Tensor, padding_mask: torch.Tensor) -> torch.Tensor:
863+
"""Save the padding mask and return the input without copying."""
864+
ctx.save_for_backward(padding_mask)
865+
return tensor
866+
867+
@staticmethod
868+
def backward(ctx, grad_output: torch.Tensor):
869+
"""Zero padding rows before propagating the input gradient."""
870+
(padding_mask,) = ctx.saved_tensors
871+
return _mask_thd_padding(grad_output, padding_mask), None
872+
873+
847874
class FlashAttention(torch.nn.Module):
848875
"""Dot product attention, using HazyResearch flash-attn package:
849876
https://github.com/Dao-AILab/flash-attention
@@ -1092,6 +1119,21 @@ def forward(
10921119
use_flash_attn_3 = (
10931120
flash_attention_backend is not None and flash_attention_backend.major == 3
10941121
)
1122+
q_padding_mask = None
1123+
if pad_between_seqs and qkv_format == "thd" and not context_parallel:
1124+
assert cu_seqlens_q_padded is not None and cu_seqlens_kv_padded is not None, (
1125+
"cu_seqlens_q_padded and cu_seqlens_kv_padded are required when "
1126+
"pad_between_seqs=True"
1127+
)
1128+
q_padding_mask = dpa_utils.get_thd_padding_mask(
1129+
query_layer.shape[0], cu_seqlens_q, cu_seqlens_q_padded
1130+
)
1131+
kv_padding_mask = dpa_utils.get_thd_padding_mask(
1132+
key_layer.shape[0], cu_seqlens_kv, cu_seqlens_kv_padded
1133+
)
1134+
query_layer = _MaskTHDPaddingGrad.apply(query_layer, q_padding_mask)
1135+
key_layer = _MaskTHDPaddingGrad.apply(key_layer, kv_padding_mask)
1136+
value_layer = _MaskTHDPaddingGrad.apply(value_layer, kv_padding_mask)
10951137
if (
10961138
use_flash_attn_4
10971139
and (10, 0) <= get_device_compute_capability() < (12, 0)
@@ -1229,6 +1271,8 @@ def forward(
12291271
)
12301272
if isinstance(output, (List, Tuple)):
12311273
output = output[0]
1274+
if q_padding_mask is not None:
1275+
output = _mask_thd_padding(output, q_padding_mask)
12321276
elif not use_flash_attn_3:
12331277
fa_optional_forward_kwargs = {}
12341278
if fa_utils.v2_3_plus:
@@ -1340,6 +1384,8 @@ def convert_to_torch_float8(tensor, dtype):
13401384

13411385
if fp8:
13421386
output = output.to(dtype=torch_orig_dtype)
1387+
if q_padding_mask is not None:
1388+
output = _mask_thd_padding(output, q_padding_mask)
13431389
if fp8 and fp8_output:
13441390
O_quantizer = quantizers["scaling_fwd"][META_O]
13451391
output = O_quantizer(output)

‎transformer_engine/pytorch/attention/dot_product_attention/context_parallel.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2270,6 +2270,9 @@ def forward(
22702270
softmax_lse_in_packed_format,
22712271
)
22722272
out = out.view(post_a2a_o_shape)
2273+
if qkv_format == "thd" and pad_between_seqs and not use_fused_attention:
2274+
# Partial-output correction can write FA3/FA4 padding rows.
2275+
_zero_thd_padding((out,), cu_seqlens_q_per_step[0], cu_seqlens_q_padded)
22732276
out_part = out.to(fwd_nominal_dtype)
22742277

22752278
if cp_size_a2a > 1:
@@ -4918,6 +4921,10 @@ def forward(
49184921
aux_ctx_tensors = [softmax_lse, rng_state]
49194922
out_part = out_
49204923

4924+
# Clean FA3/FA4 padding before inverse A2A changes sequence order.
4925+
if qkv_format == "thd" and pad_between_seqs:
4926+
_zero_thd_padding((out_,), cu_seqlens_q, cu_seqlens_q_padded)
4927+
49214928
# a2a: split s and gather h
49224929
# [b, s, h//cp, d] -> [b*s//cp, h, d]
49234930
# [s, b, h//cp, d] -> [s//cp*b, h, d]
@@ -5315,6 +5322,11 @@ def backward(ctx, dout, *_args):
53155322
**fa_backward_kwargs,
53165323
)
53175324

5325+
# Clean FA3/FA4 padding before inverse A2A changes sequence order.
5326+
if ctx.dqkv_format == "thd" and ctx.pad_between_seqs:
5327+
_zero_thd_padding((dq,), cu_seqlens_q, cu_seqlens_q_padded)
5328+
_zero_thd_padding((dk, dv), cu_seqlens_kv, cu_seqlens_kv_padded)
5329+
53185330
# dq, dk, dv:
53195331
# FP8DS: torch.uint8
53205332
# FP8CS/MXFP8/F16: torch.float16 or torch.bfloat16

‎transformer_engine/pytorch/attention/dot_product_attention/utils.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1706,11 +1706,15 @@ def _is_fa3_supported(num_heads, num_gqa_groups, head_dim_qk, head_dim_v, qkv_dt
17061706

17071707
@torch.no_grad()
17081708
def get_thd_padding_mask(num_tokens, cu_seqlens, cu_seqlens_padded):
1709-
"""Identify inter-sequence padding in a flattened packed THD buffer."""
1709+
"""Identify inter-sequence and tail padding in a flattened packed THD buffer."""
17101710
rows = torch.arange(num_tokens, device=cu_seqlens_padded.device)
17111711
sequence = torch.searchsorted(cu_seqlens_padded[1:], rows, right=True)
1712-
valid_end = cu_seqlens_padded[sequence] + cu_seqlens[sequence + 1] - cu_seqlens[sequence]
1713-
return rows >= valid_end
1712+
valid_ends = cu_seqlens_padded[:-1] + cu_seqlens[1:] - cu_seqlens[:-1]
1713+
# Tail rows map to sequence == batch_size. Append a zero-length sequence
1714+
# at the final physical boundary so that index is valid, even for an empty
1715+
# batch. All operations stay on device for CUDA Graph capture and replay.
1716+
valid_ends = torch.cat((valid_ends, cu_seqlens_padded[-1:]))
1717+
return rows >= valid_ends[sequence]
17141718

17151719

17161720
@torch.no_grad()

0 commit comments

Comments
 (0)