Skip to content

Commit ce044a7

Browse files
committed
[perf]: register a real backward for the FA2 default custom op
Follow-up to hao-ai-lab#1373 + its CI carve-out (commit fae1cd1). The custom op fastvideo::_flash_attn_default_forward shipped with a forward + fake kernel but no register_autograd, so it was opaque to autograd; the carve-out routed grad-enabled calls back to the original FA2 flash_attn_func (an autograd.Function) at the cost of a graph break on the training path. This commit closes that gap for FA2 by mirroring the FP4 cute template's 4-piece pattern: - forward returns (out, softmax_lse), obtained via flash_attn_func's return_attn_probs=True path so we don't touch private FA2 fwd APIs; - register_fake returns the matching tuple, with softmax_lse fixed at [batch, nheads, seqlen_q] fp32; - setup_context saves q,k,v,out,lse + softmax_scale, causal; - the backward calls flash_attn.flash_attn_interface._flash_attn_backward (FA2's private bwd) with all version-fragile kwargs pinned to the flash-attn==2.8.1 defaults (the version FastVideo pins). With autograd registered, flash_attn_func_compilable can drop its carve- out — grad-enabled calls go through the op like inference calls, so the training path is also dynamo-traceable. Numerics unchanged (lse is saved- for-backward only, never differentiated; backward writes the same dq/dk/ dv FA2's autograd.Function would produce). FA3 keeps the carve-out from fae1cd1 untouched. FA3 exposes a different private _flash_attn_backward signature that wants validation on a real Hopper box; once Kuan-Hao's Modal FA3 setup PR lands we mirror the FA2 pattern there. Tests: extend test_flash_attn_default_custom_op.py with - test_default_op_backward_through_registered_autograd: calls torch.ops.fastvideo._flash_attn_default_forward directly with requires_grad inputs, autograd.grad through the op, asserts grads match the original flash_attn_func to dtype-appropriate tolerance. - test_default_op_opcheck_with_grad_inputs: torch.library.opcheck with requires_grad inputs (exercises test_autograd_registration — the gap that let hao-ai-lab#1373's first revision ship without a backward). Both gate on fa_version=="2"; FA3 retains the carve-out tests as-is. Directional confirmation from Will Lin (FastVideo maintainer) 2026-05-22: "it definitely is [wanted], but testing the correctness and performance is more involved".
1 parent 2f3ca8a commit ce044a7

2 files changed

Lines changed: 181 additions & 13 deletions

File tree

fastvideo/attention/backends/flash_attn.py

Lines changed: 116 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,129 @@
3232
# opaque-but-traceable node. The kernel still runs eager inside the op
3333
# (correct — flash-attn must run eager); only dynamo's treatment of the
3434
# boundary changes, so numerics are unchanged (SSIM-gate to confirm).
35-
if fa_version in ("2", "3"):
36-
_fa_default = flash_attn_func
37-
35+
#
36+
# Autograd: FA2 has full register_autograd parity — the custom op's
37+
# backward calls flash_attn's `_flash_attn_backward` directly, so
38+
# training backprops *through* the op (no graph break on the training
39+
# path either). FA3 currently keeps the no-backward + carve-out pattern
40+
# from PR #1373 because FA3's private backward signature wants
41+
# validation on a real Hopper box (gated on Kuan-Hao's Modal FA3 setup
42+
# PR). Once that lands the FA3 path can mirror FA2.
43+
if fa_version == "2":
3844
# Scope: this op covers exactly the q/k/v + softmax_scale + causal
3945
# call shape used by FlashAttentionImpl.forward's default branch
4046
# (see `flash_attn_func_compilable(...)` call site below). The
41-
# masked/no-pad and varlen / cross-attn paths use different
42-
# entry points (`flash_attn_no_pad`, `flash_attn_varlen_*`) which
43-
# are intentionally out of scope for this PR — wrapping them is a
47+
# masked/no-pad and varlen / cross-attn paths use different entry
48+
# points (`flash_attn_no_pad`, `flash_attn_varlen_*`) which are
49+
# intentionally out of scope for this PR — wrapping them is a
4450
# natural follow-up. The wrapper's signature is the contract: any
4551
# extra kwarg (dropout_p, window_size, alibi_slopes, deterministic,
4652
# return_attn_probs, ...) raises TypeError at the call site, so
4753
# silent loss of kwargs is not a failure mode.
54+
from flash_attn.flash_attn_interface import _flash_attn_backward as _fa2_backward
55+
_fa_default = flash_attn_func
56+
57+
@torch.library.custom_op(
58+
"fastvideo::_flash_attn_default_forward",
59+
mutates_args=(),
60+
device_types="cuda",
61+
)
62+
def _flash_attn_default_forward(
63+
q: torch.Tensor,
64+
k: torch.Tensor,
65+
v: torch.Tensor,
66+
softmax_scale: float | None,
67+
causal: bool,
68+
) -> tuple[torch.Tensor, torch.Tensor]:
69+
# `return_attn_probs=True` asks FA2 to also return softmax_lse +
70+
# S_dmask. We need softmax_lse to feed the backward; S_dmask is the
71+
# dropout mask (always None here since dropout_p is fixed at 0).
72+
out, softmax_lse, _ = _fa_default(q, k, v, softmax_scale=softmax_scale,
73+
causal=causal, return_attn_probs=True)
74+
return out, softmax_lse
75+
76+
@torch.library.register_fake("fastvideo::_flash_attn_default_forward")
77+
def _flash_attn_default_forward_fake(
78+
q: torch.Tensor,
79+
k: torch.Tensor,
80+
v: torch.Tensor,
81+
softmax_scale: float | None,
82+
causal: bool,
83+
) -> tuple[torch.Tensor, torch.Tensor]:
84+
del softmax_scale, causal
85+
# FA2 default path: out = [batch, seqlen_q, nheads, head_dim_v],
86+
# softmax_lse = [batch, nheads, seqlen_q], fp32 regardless of q dtype.
87+
b, sq, hq = q.shape[0], q.shape[1], q.shape[2]
88+
out = q.new_empty(b, sq, hq, v.shape[-1])
89+
lse = q.new_empty(b, hq, sq, dtype=torch.float32)
90+
return out, lse
91+
92+
def _flash_attn_default_setup_context(ctx, inputs, output):
93+
q, k, v, softmax_scale, causal = inputs
94+
out, lse = output
95+
ctx.save_for_backward(q, k, v, out, lse)
96+
# FA2's *forward* substitutes `1 / sqrt(head_dim)` for `softmax_scale=None`
97+
# internally; FA2's *backward* (`_flash_attn_backward`) demands a concrete
98+
# float in its C++ schema and rejects None at the binding boundary. Resolve
99+
# the default here so the value saved on ctx (and passed to backward) is
100+
# always a real float — matches what FA2's own autograd.Function does.
101+
if softmax_scale is None:
102+
softmax_scale = q.shape[-1] ** -0.5
103+
ctx.softmax_scale = softmax_scale
104+
ctx.causal = causal
105+
106+
def _flash_attn_default_backward(ctx, grad_out, grad_lse):
107+
# We only differentiate `out`; softmax_lse is saved-for-backward, not
108+
# a real differentiable output. (Mirrors the FP4 cute template.)
109+
del grad_lse
110+
q, k, v, out, lse = ctx.saved_tensors
111+
dq = torch.empty_like(q)
112+
dk = torch.empty_like(k)
113+
dv = torch.empty_like(v)
114+
# FA2's `_flash_attn_backward` writes into dq/dk/dv in place. The
115+
# extra kwargs (window_size_*, softcap, alibi_slopes, deterministic,
116+
# rng_state) are pinned to the same defaults the forward wrapper
117+
# uses — flash-attn==2.8.1 (the version FastVideo pins) requires
118+
# all of them explicitly. `rng_state=None` is correct for our
119+
# `dropout_p=0` configuration.
120+
_fa2_backward(
121+
grad_out, q, k, v, out, lse,
122+
dq, dk, dv,
123+
dropout_p=0.0,
124+
softmax_scale=ctx.softmax_scale,
125+
causal=ctx.causal,
126+
window_size_left=-1,
127+
window_size_right=-1,
128+
softcap=0.0,
129+
alibi_slopes=None,
130+
deterministic=False,
131+
rng_state=None,
132+
)
133+
return dq, dk, dv, None, None
134+
135+
torch.library.register_autograd(
136+
"fastvideo::_flash_attn_default_forward",
137+
_flash_attn_default_backward,
138+
setup_context=_flash_attn_default_setup_context,
139+
)
140+
141+
def flash_attn_func_compilable(q, k, v, softmax_scale=None, causal=False):
142+
# Backward is registered: autograd flows through the op (training
143+
# path is also traceable; no carve-out needed). Public API matches
144+
# `flash_attn_func` — returns just `out`; we drop the saved-for-
145+
# backward `lse` here so callers see the original single-tensor
146+
# contract.
147+
out, _ = torch.ops.fastvideo._flash_attn_default_forward(q, k, v, softmax_scale, causal)
148+
return out
149+
elif fa_version == "3":
150+
# FA3 path: same forward+fake custom op as the original PR #1373, with
151+
# the autograd carve-out kept. The full backward (mirroring the FA2
152+
# leg above) wants a Hopper box for grad-check validation, which we
153+
# don't have until Kuan-Hao's Modal FA3 setup PR lands. Until then
154+
# this keeps inference traceable + training correct (via the original
155+
# autograd.Function path + a pre-PR-style graph break on training).
156+
_fa_default = flash_attn_func
157+
48158
@torch.library.custom_op(
49159
"fastvideo::_flash_attn_default_forward",
50160
mutates_args=(),
@@ -68,8 +178,6 @@ def _flash_attn_default_forward_fake(
68178
causal: bool,
69179
) -> torch.Tensor:
70180
del softmax_scale, causal
71-
# FA2/FA3 default path: [batch, seqlen_q, nheads, head_dim_v],
72-
# same dtype/device as q (head dim taken from v).
73181
return q.new_empty(q.shape[0], q.shape[1], q.shape[2], v.shape[-1])
74182

75183
def flash_attn_func_compilable(q, k, v, softmax_scale=None, causal=False):

fastvideo/tests/attention/test_flash_attn_default_custom_op.py

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,14 @@ def fa_default_impls():
4242
f"got {fa_backend.fa_version!r}"
4343
)
4444

45-
# compilable dispatcher, the original FA wrapper it falls back to, and
46-
# the raw custom op for opcheck.
45+
# compilable dispatcher, the original FA wrapper it falls back to, the
46+
# raw custom op for opcheck, and the fa_version (FA2 has full register_
47+
# autograd; FA3 keeps the carve-out so some tests gate on this).
4748
return (
4849
fa_backend.flash_attn_func_compilable,
4950
fa_backend._fa_default,
5051
torch.ops.fastvideo._flash_attn_default_forward,
52+
fa_backend.fa_version,
5153
)
5254

5355

@@ -71,7 +73,7 @@ def test_default_compilable_inference_matches_original(fa_default_impls, dtype,
7173
"""No-grad path routes through the custom op and is numerically identical."""
7274
if dtype == torch.bfloat16 and not torch.cuda.is_bf16_supported():
7375
pytest.skip("bfloat16 is not supported on this GPU")
74-
compilable, original, _ = fa_default_impls
76+
compilable, original, _, _ = fa_default_impls
7577

7678
torch.manual_seed(0)
7779
q, k, v = _qkv(dtype, requires_grad=False)
@@ -91,7 +93,7 @@ def test_default_compilable_training_backward_flows(fa_default_impls, dtype, cau
9193
"""
9294
if dtype == torch.bfloat16 and not torch.cuda.is_bf16_supported():
9395
pytest.skip("bfloat16 is not supported on this GPU")
94-
compilable, original, _ = fa_default_impls
96+
compilable, original, _, _ = fa_default_impls
9597

9698
torch.manual_seed(0)
9799
q_ref, k_ref, v_ref = _qkv(dtype, requires_grad=True)
@@ -115,7 +117,65 @@ def test_default_compilable_training_backward_flows(fa_default_impls, dtype, cau
115117
@pytest.mark.parametrize("causal", [False, True])
116118
def test_default_forward_opcheck(fa_default_impls, causal):
117119
"""Schema / fake-kernel consistency for the custom op (forward only)."""
118-
_, _, op = fa_default_impls
120+
_, _, op, _ = fa_default_impls
119121
torch.manual_seed(0)
120122
q, k, v = _qkv(torch.float16, requires_grad=False)
121123
torch.library.opcheck(op, (q, k, v, None, causal))
124+
125+
126+
# --------------------------------------------------------------------------- #
127+
# FA2-only: backward is registered on the custom op itself. Exercises the #
128+
# `register_autograd` wiring directly (not via the dispatcher's carve-out). #
129+
# Skipped on FA3 until Kuan-Hao's Modal FA3 setup PR lands and we mirror the #
130+
# pattern there. #
131+
# --------------------------------------------------------------------------- #
132+
133+
134+
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
135+
@pytest.mark.parametrize("causal", [False, True])
136+
def test_default_op_backward_through_registered_autograd(fa_default_impls, dtype, causal):
137+
"""FA2: gradients flow through ``torch.ops.fastvideo._flash_attn_default_forward``
138+
itself (no dispatcher carve-out involved) and match the original
139+
``flash_attn_func``'s gradients."""
140+
if dtype == torch.bfloat16 and not torch.cuda.is_bf16_supported():
141+
pytest.skip("bfloat16 is not supported on this GPU")
142+
_, original, op, fa_version = fa_default_impls
143+
if fa_version != "2":
144+
pytest.skip(f"register_autograd is only wired for FA2 right now; got {fa_version!r}")
145+
146+
torch.manual_seed(0)
147+
q_ref, k_ref, v_ref = _qkv(dtype, requires_grad=True)
148+
q_test, k_test, v_test = _clone(q_ref, k_ref, v_ref)
149+
150+
# Reference grads via the original autograd.Function.
151+
out_ref = original(q_ref, k_ref, v_ref, softmax_scale=None, causal=causal)
152+
# Custom-op grads via the registered backward — unpack (out, lse), discard lse.
153+
out_test, _ = op(q_test, k_test, v_test, None, causal)
154+
155+
torch.testing.assert_close(out_test, out_ref,
156+
atol=0 if dtype == torch.float16 else 1e-3,
157+
rtol=0 if dtype == torch.float16 else 1e-3)
158+
159+
dout = torch.randn_like(out_ref)
160+
dq_ref, dk_ref, dv_ref = torch.autograd.grad(
161+
(out_ref * dout).sum(), (q_ref, k_ref, v_ref))
162+
dq_test, dk_test, dv_test = torch.autograd.grad(
163+
(out_test * dout).sum(), (q_test, k_test, v_test))
164+
165+
atol = rtol = 6e-3 if dtype == torch.float16 else 2e-2
166+
torch.testing.assert_close(dq_test, dq_ref, atol=atol, rtol=rtol)
167+
torch.testing.assert_close(dk_test, dk_ref, atol=atol, rtol=rtol)
168+
torch.testing.assert_close(dv_test, dv_ref, atol=atol, rtol=rtol)
169+
170+
171+
@pytest.mark.parametrize("causal", [False, True])
172+
def test_default_op_opcheck_with_grad_inputs(fa_default_impls, causal):
173+
"""FA2: full ``opcheck`` including ``test_autograd_registration`` —
174+
catches a missing/inconsistent backward at unit-test time, which was
175+
exactly the gap that #1373's first revision shipped."""
176+
_, _, op, fa_version = fa_default_impls
177+
if fa_version != "2":
178+
pytest.skip(f"autograd registration only wired for FA2; got {fa_version!r}")
179+
torch.manual_seed(0)
180+
q, k, v = _qkv(torch.float16, requires_grad=True)
181+
torch.library.opcheck(op, (q, k, v, None, causal))

0 commit comments

Comments
 (0)