Skip to content

Commit f38a1fc

Browse files
authored
[PyTorch] Support FA3/FA4 with pad_between_seqs=True (#3205)
[PyTorch] Zero padded THD rows for FlashAttention and CP Signed-off-by: xiaoyao0115 <1804647152@qq.com>
1 parent 0838ecb commit f38a1fc

5 files changed

Lines changed: 251 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,
@@ -699,11 +702,18 @@ def run_dpa_with_cp(
699702
num_pads_kv = (cu_seqlens_kv_padded - cu_seqlens_kv)[1:] - (
700703
cu_seqlens_kv_padded - cu_seqlens_kv
701704
)[:-1]
702-
# FA3 leaves garbage at padding positions despite seqused_q/k (tile spillover).
703-
# Forward out_ can't be pre-zeroed because FA3's custom op returns out_ as an
704-
# output rather than mutating it in-place, triggering PyTorch's aliasing constraint.
705-
# Backward dq/dk/dv CAN be pre-zeroed because FA3 marks them as mutated inputs.
705+
# FA3/FA4 kernels may leave physically padded rows unwritten. The CP
706+
# implementation must clean them after partial-output/gradient merges.
706707
if fa_pad_between_seqs == "True":
708+
out_padding_mask = get_thd_padding_mask(
709+
out_.shape[0], cu_seqlens_q, cu_seqlens_q_padded
710+
)
711+
nnz = torch.count_nonzero(out_[out_padding_mask]).item()
712+
assert nnz == 0, (
713+
f"out_ has {nnz} nonzero values in THD padding — "
714+
"context_parallel.py should zero padding positions"
715+
)
716+
707717
# out_ is a view inside the CP custom autograd Function, so in-place
708718
# zeroing is blocked by PyTorch. Clone to break the view relationship.
709719
out_ = out_.clone()

‎tests/pytorch/attention/test_attention.py‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,14 @@
3333
_attention_backends,
3434
)
3535
from transformer_engine.pytorch.attention.dot_product_attention.utils import (
36+
AttentionParams,
3637
FlashAttentionUtils,
3738
FusedAttentionParams,
3839
_get_supported_versions,
3940
check_set_window_size,
41+
get_attention_backend,
4042
get_fused_attn_spec,
43+
get_thd_padding_mask,
4144
)
4245
from transformer_engine.pytorch.attention import RotaryPositionEmbedding
4346
import transformer_engine.pytorch.cpp_extensions as ext
@@ -212,6 +215,147 @@ def test_fused_attn_backend_message():
212215
assert message != ""
213216

214217

218+
@pytest.mark.parametrize(
219+
"cu_seqlens,cu_seqlens_padded,expected",
220+
[
221+
([0, 3, 8], [0, 4, 12], [False] * 3 + [True] + [False] * 5 + [True] * 3),
222+
([0, 3, 8], [0, 4, 12], [False] * 3 + [True] + [False] * 5 + [True] * 7),
223+
([0, 3, 8], [0, 3, 8], [False] * 8 + [True] * 4),
224+
([0, 0, 3, 3, 5], [0, 0, 4, 4, 8], [False] * 3 + [True] + [False] * 2 + [True] * 4),
225+
],
226+
)
227+
def test_thd_padding_mask_capacity(cu_seqlens, cu_seqlens_padded, expected):
228+
"""Cover sequence gaps, reserved buffer tails, and zero-length sequences."""
229+
actual = get_thd_padding_mask(
230+
len(expected),
231+
torch.tensor(cu_seqlens, dtype=torch.int32, device="cuda"),
232+
torch.tensor(cu_seqlens_padded, dtype=torch.int32, device="cuda"),
233+
)
234+
torch.testing.assert_close(actual, torch.tensor(expected, dtype=torch.bool, device="cuda"))
235+
236+
237+
@pytest.mark.parametrize("fa_version", [3, 4])
238+
def test_dpa_flash_thd_padding_capacity_cuda_graph(fa_version, monkeypatch):
239+
"""Check padded outputs and gradients, including buffer tails, without CP.
240+
241+
This regression exercises eager execution and raw CUDA Graph capture. CP graph
242+
capture uses TE's make_graphed_callables and is outside this test's scope.
243+
"""
244+
for key, value in {
245+
"NVTE_FLASH_ATTN": "1",
246+
"NVTE_FUSED_ATTN": "0",
247+
"NVTE_UNFUSED_ATTN": "0",
248+
"NVTE_FLASH_ATTN_V2": "0",
249+
"NVTE_FLASH_ATTN_V3": str(int(fa_version == 3)),
250+
"NVTE_FLASH_ATTN_V4": str(int(fa_version == 4)),
251+
}.items():
252+
monkeypatch.setenv(key, value)
253+
use_flash_attention, flash_attention_backend, *_ = get_attention_backend(
254+
AttentionParams(
255+
qkv_dtype=torch.bfloat16,
256+
qkv_layout="thd_thd_thd",
257+
batch_size=2,
258+
num_heads=4,
259+
num_gqa_groups=4,
260+
max_seqlen_q=128,
261+
max_seqlen_kv=128,
262+
num_tokens_q=128,
263+
num_tokens_kv=192,
264+
head_dim_qk=64,
265+
head_dim_v=64,
266+
attn_mask_type="padding",
267+
window_size=(-1, -1),
268+
core_attention_bias_requires_grad=False,
269+
pad_between_seqs=True,
270+
attention_dropout=0.0,
271+
context_parallel=False,
272+
deterministic=_deterministic,
273+
is_training=True,
274+
cuda_graph=True,
275+
softmax_scale=0.125,
276+
)
277+
)
278+
if not use_flash_attention:
279+
pytest.skip(f"FlashAttention {fa_version} does not support this CUDA Graph configuration.")
280+
assert flash_attention_backend.major == fa_version
281+
# Restore the complete backend cache along with the environment at test teardown.
282+
for key, value in _attention_backends.items():
283+
monkeypatch.setitem(_attention_backends, key, value)
284+
_attention_backends["backend_selection_requires_update"] = True
285+
reset_rng_states()
286+
cu_q, cu_kv, padded_q, padded_kv = [
287+
torch.tensor(offsets, dtype=torch.int32, device="cuda")
288+
for offsets in ([0, 61, 96], [0, 45, 118], [0, 64, 112], [0, 64, 160])
289+
]
290+
# Expected valid token positions are independent of get_thd_padding_mask.
291+
q_rows = torch.tensor(list(range(61)) + list(range(64, 99)), device="cuda")
292+
kv_rows = torch.tensor(list(range(45)) + list(range(64, 137)), device="cuda")
293+
qkv = [
294+
torch.randn(tokens, 4, 64, dtype=torch.bfloat16, device="cuda", requires_grad=True)
295+
for tokens in (128, 192, 192)
296+
]
297+
grad_output = torch.randn(128, 256, dtype=torch.bfloat16, device="cuda")
298+
block = DotProductAttention(
299+
4,
300+
64,
301+
qkv_format="thd",
302+
attn_mask_type="padding",
303+
attention_type="cross",
304+
attention_dropout=0.0,
305+
).cuda()
306+
307+
def run(inputs, grad, padded):
308+
"""Run the selected FlashAttention backend and differentiate Q/K/V."""
309+
output = block(
310+
*inputs,
311+
cu_seqlens_q=cu_q,
312+
cu_seqlens_kv=cu_kv,
313+
cu_seqlens_q_padded=padded_q if padded else None,
314+
cu_seqlens_kv_padded=padded_kv if padded else None,
315+
max_seqlen_q=128,
316+
max_seqlen_kv=128,
317+
pad_between_seqs=padded,
318+
)
319+
assert _attention_backends["use_flash_attention"]
320+
assert _attention_backends["flash_attention_backend"].major == fa_version
321+
assert not _attention_backends["use_fused_attention"]
322+
assert not _attention_backends["use_unfused_attention"]
323+
return (output, *torch.autograd.grad(output, inputs, grad))
324+
325+
def check(actual):
326+
"""Compare valid token positions with compact attention and require exact-zero padding."""
327+
compact_qkv = [
328+
tensor.detach()[rows].requires_grad_()
329+
for tensor, rows in zip(qkv, (q_rows, kv_rows, kv_rows))
330+
]
331+
expected = run(compact_qkv, grad_output[q_rows], False)
332+
for tensor, reference, rows in zip(actual, expected, (q_rows, q_rows, kv_rows, kv_rows)):
333+
assert tensor.shape[0] == (128 if rows is q_rows else 192)
334+
torch.testing.assert_close(tensor[rows], reference, atol=1.5e-2, rtol=1.5e-2)
335+
padding = torch.ones(tensor.shape[0], dtype=torch.bool, device="cuda")
336+
padding[rows] = False
337+
assert torch.count_nonzero(tensor[padding]).item() == 0
338+
339+
check(run(qkv, grad_output, True))
340+
stream = torch.cuda.Stream()
341+
stream.wait_stream(torch.cuda.current_stream())
342+
with torch.cuda.stream(stream):
343+
for _ in range(3):
344+
run(qkv, grad_output, True)
345+
torch.cuda.current_stream().wait_stream(stream)
346+
torch.cuda.synchronize()
347+
graph = torch.cuda.CUDAGraph()
348+
with torch.cuda.graph(graph, stream=stream):
349+
captured = run(qkv, grad_output, True)
350+
for _ in range(2):
351+
# Replays must consume fresh inputs and upstream gradients at the same addresses.
352+
with torch.no_grad():
353+
for tensor in (*qkv, grad_output):
354+
tensor.normal_()
355+
graph.replay()
356+
check(captured)
357+
358+
215359
# Define F16 data types to test
216360
param_types = [torch.float16]
217361
if is_bf16_available():
@@ -2166,6 +2310,22 @@ def get_dummy_cuda_rng_tracker() -> CudaRNGStatesTracker:
21662310

21672311
if backend in ["UnfusedDotProductAttention"]:
21682312
return out, max_logit, (q_grad, k_grad, v_grad, d_softmax_offset)
2313+
if backend == "FlashAttention" and qkv_format == "thd" and pad_between_seqs:
2314+
tensors_and_boundaries = [("out", out, cu_seqlens_q, cu_seqlens_q_after_pad)]
2315+
if is_training:
2316+
tensors_and_boundaries.extend(
2317+
[
2318+
("dq", q_grad, cu_seqlens_q, cu_seqlens_q_after_pad),
2319+
("dk", k_grad, cu_seqlens_kv, cu_seqlens_kv_after_pad),
2320+
("dv", v_grad, cu_seqlens_kv, cu_seqlens_kv_after_pad),
2321+
]
2322+
)
2323+
for tensor_name, tensor, cu_seqlens, cu_seqlens_padded in tensors_and_boundaries:
2324+
padding_mask = get_thd_padding_mask(tensor.shape[0], cu_seqlens, cu_seqlens_padded)
2325+
assert (
2326+
torch.count_nonzero(tensor[padding_mask]).item() == 0
2327+
), f"{backend} left nonzero values in {tensor_name} padding"
2328+
21692329
if backend in ["FusedAttention", "FlashAttention"]:
21702330
if qkv_format == "thd" and pad_between_seqs:
21712331
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
@@ -909,6 +909,33 @@ def _unalias_cu_seqlens(cu_q: torch.Tensor, cu_kv: torch.Tensor):
909909
return cu_q, cu_kv
910910

911911

912+
def _mask_thd_padding(tensor: torch.Tensor, padding_mask: torch.Tensor) -> torch.Tensor:
913+
"""Return ``tensor`` with invalid THD token positions zeroed."""
914+
mask = padding_mask.view(padding_mask.shape[0], *([1] * (tensor.ndim - 1)))
915+
return tensor.masked_fill(mask, 0)
916+
917+
918+
class _MaskTHDPaddingGrad(torch.autograd.Function):
919+
"""Identity in forward and zero THD padding positions in backward.
920+
921+
FA3 and FA4 may leave padding positions unwritten in their gradient buffers.
922+
Keeping the forward as an identity avoids copying Q/K/V, while the custom
923+
backward guarantees zero padding positions before gradients reach the caller.
924+
"""
925+
926+
@staticmethod
927+
def forward(ctx, tensor: torch.Tensor, padding_mask: torch.Tensor) -> torch.Tensor:
928+
"""Save the padding mask and return the input without copying."""
929+
ctx.save_for_backward(padding_mask)
930+
return tensor
931+
932+
@staticmethod
933+
def backward(ctx, grad_output: torch.Tensor):
934+
"""Zero padding positions before propagating the input gradient."""
935+
(padding_mask,) = ctx.saved_tensors
936+
return _mask_thd_padding(grad_output, padding_mask), None
937+
938+
912939
class FlashAttention(torch.nn.Module):
913940
"""Dot product attention, using HazyResearch flash-attn package:
914941
https://github.com/Dao-AILab/flash-attention
@@ -1158,6 +1185,21 @@ def forward(
11581185
use_flash_attn_3 = (
11591186
flash_attention_backend is not None and flash_attention_backend.major == 3
11601187
)
1188+
q_padding_mask = None
1189+
if pad_between_seqs and qkv_format == "thd" and not context_parallel:
1190+
assert cu_seqlens_q_padded is not None and cu_seqlens_kv_padded is not None, (
1191+
"cu_seqlens_q_padded and cu_seqlens_kv_padded are required when "
1192+
"pad_between_seqs=True"
1193+
)
1194+
q_padding_mask = dpa_utils.get_thd_padding_mask(
1195+
query_layer.shape[0], cu_seqlens_q, cu_seqlens_q_padded
1196+
)
1197+
kv_padding_mask = dpa_utils.get_thd_padding_mask(
1198+
key_layer.shape[0], cu_seqlens_kv, cu_seqlens_kv_padded
1199+
)
1200+
query_layer = _MaskTHDPaddingGrad.apply(query_layer, q_padding_mask)
1201+
key_layer = _MaskTHDPaddingGrad.apply(key_layer, kv_padding_mask)
1202+
value_layer = _MaskTHDPaddingGrad.apply(value_layer, kv_padding_mask)
11611203
if (
11621204
use_flash_attn_4
11631205
and (10, 0) <= get_device_compute_capability() < (12, 0)
@@ -1301,6 +1343,8 @@ def forward(
13011343
)
13021344
if isinstance(output, (List, Tuple)):
13031345
output = output[0]
1346+
if q_padding_mask is not None:
1347+
output = _mask_thd_padding(output, q_padding_mask)
13041348
elif not use_flash_attn_3:
13051349
fa_optional_forward_kwargs = {}
13061350
if fa_utils.v2_3_plus:
@@ -1422,6 +1466,8 @@ def convert_to_torch_float8(tensor, dtype):
14221466

14231467
if fp8:
14241468
output = output.to(dtype=torch_orig_dtype)
1469+
if q_padding_mask is not None:
1470+
output = _mask_thd_padding(output, q_padding_mask)
14251471
if fp8 and fp8_output:
14261472
O_quantizer = quantizers["scaling_fwd"][META_O]
14271473
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
@@ -2252,6 +2252,9 @@ def forward(
22522252

22532253
# Save the rank-local output for backward before restoring the A2A layout.
22542254
out = out.view(post_a2a_o_shape)
2255+
if qkv_format == "thd" and pad_between_seqs and not use_fused_attention:
2256+
# Partial-output correction can write FA3/FA4 padding rows.
2257+
_zero_thd_padding((out,), cu_seqlens_q_per_step[0], cu_seqlens_q_padded)
22552258
out_part = out.to(fwd_nominal_dtype)
22562259

22572260
if cp_size_a2a > 1:
@@ -4906,6 +4909,10 @@ def forward(
49064909
aux_ctx_tensors = [softmax_lse, rng_state]
49074910
out_part = out_
49084911

4912+
# Clean FA3/FA4 padding before inverse A2A changes sequence order.
4913+
if qkv_format == "thd" and pad_between_seqs:
4914+
_zero_thd_padding((out_,), cu_seqlens_q, cu_seqlens_q_padded)
4915+
49094916
# a2a: split s and gather h
49104917
# [b, s, h//cp, d] -> [b*s//cp, h, d]
49114918
# [s, b, h//cp, d] -> [s//cp*b, h, d]
@@ -5304,6 +5311,11 @@ def backward(ctx, dout, *_args):
53045311
**fa_backward_kwargs,
53055312
)
53065313

5314+
# Clean FA3/FA4 padding before inverse A2A changes sequence order.
5315+
if ctx.dqkv_format == "thd" and ctx.pad_between_seqs:
5316+
_zero_thd_padding((dq,), cu_seqlens_q, cu_seqlens_q_padded)
5317+
_zero_thd_padding((dk, dv), cu_seqlens_kv, cu_seqlens_kv_padded)
5318+
53075319
# dq, dk, dv:
53085320
# FP8DS: torch.uint8
53095321
# FP8CS/MXFP8/F16: torch.float16 or torch.bfloat16

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1939,11 +1939,26 @@ def _is_fa3_supported(num_heads, num_gqa_groups, head_dim_qk, head_dim_v, qkv_dt
19391939

19401940
@torch.no_grad()
19411941
def get_thd_padding_mask(num_tokens, cu_seqlens, cu_seqlens_padded):
1942-
"""Identify inter-sequence padding in a flattened packed THD buffer."""
1942+
"""Return a boolean mask identifying padding token positions in a THD buffer.
1943+
1944+
``num_tokens`` is the full physical buffer capacity, which may exceed
1945+
``cu_seqlens_padded[-1]``. ``cu_seqlens`` describes cumulative valid token
1946+
counts, while ``cu_seqlens_padded`` describes physical sequence boundaries.
1947+
Inter-sequence gaps and all positions at or beyond the final padded boundary
1948+
are marked True. For example, a 16-token buffer with ``cu_seqlens=[0, 3, 8]``
1949+
and ``cu_seqlens_padded=[0, 4, 12]`` includes tail padding at positions [12, 16).
1950+
1951+
This helper is shared by non-CP and CP attention. Its operations stay on
1952+
device so that padding cleanup remains CUDA-graph-capturable in both paths.
1953+
"""
19431954
rows = torch.arange(num_tokens, device=cu_seqlens_padded.device)
19441955
sequence = torch.searchsorted(cu_seqlens_padded[1:], rows, right=True)
1945-
valid_end = cu_seqlens_padded[sequence] + cu_seqlens[sequence + 1] - cu_seqlens[sequence]
1946-
return rows >= valid_end
1956+
valid_ends = cu_seqlens_padded[:-1] + cu_seqlens[1:] - cu_seqlens[:-1]
1957+
# Tail token positions map to sequence == batch_size. Append a zero-length sequence
1958+
# at the final physical boundary so that index is valid, even for an empty
1959+
# batch. All operations stay on device for CUDA Graph capture and replay.
1960+
valid_ends = torch.cat((valid_ends, cu_seqlens_padded[-1:]))
1961+
return rows >= valid_ends[sequence]
19471962

19481963

19491964
@torch.no_grad()

0 commit comments

Comments
 (0)