Skip to content

[Cute, bwd, sm90/100/110] Support learnable sink in backward - #2706

Open
henrylhtsang wants to merge 20 commits into
Dao-AILab:mainfrom
henrylhtsang:learnable-sink-backward
Open

[Cute, bwd, sm90/100/110] Support learnable sink in backward#2706
henrylhtsang wants to merge 20 commits into
Dao-AILab:mainfrom
henrylhtsang:learnable-sink-backward

Conversation

@henrylhtsang

@henrylhtsang henrylhtsang commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add FP16, BF16, and FP32 learnable sink gradients to supported CuTe backward kernels on SM90, SM100, and SM110; the sink dtype is independent of Q/K/V and dSink preserves it
  • reuse one dQ postprocess CTA per head to reduce dSink from FP32 dPsum and LSE, avoiding atomics and separate zero-initialization
  • support fixed-length and varlen autograd paths, including gradients from a returned LSE
  • return zero output and LSE equal to the sink when K is empty; learnable sink backward requires non-empty Q and K
  • fix SM90 fully masked sink-only rows producing NaN LSE

Test plan

CUDA_VISIBLE_DEVICES=0 FLASH_ATTENTION_CUTE_DSL_CACHE_ENABLED=1 pytest -x -q \
  'tests/cute/test_flash_attn.py::test_flash_attn_output[128-128-64-False-0-0.0-False-False-True-mha-dtype0]' \
  'tests/cute/test_flash_attn.py::test_flash_attn_output[128-128-128-True-0-0.0-False-False-True-gqa-dtype0]' \
  'tests/cute/test_flash_attn.py::test_flash_attn_output[128-128-192-False-1-15.0-True-False-True-mqa-dtype0]'
CUDA_VISIBLE_DEVICES=0 FLASH_ATTENTION_FAKE_TENSOR=1 \
  FLASH_ATTENTION_CUTE_DSL_CACHE_ENABLED=1 pytest -x -q \
  'tests/cute/test_flash_attn.py::test_flash_attn_learnable_sink_backward_dtype' \
  'tests/cute/test_flash_attn.py::test_flash_attn_varlen_learnable_sink_backward_with_lse'

The first three cases passed on SM100. The focused fake-tensor run passed all four cases: FP16, BF16, and FP32 sink dtypes plus the varlen/LSE regression.

@henrylhtsang henrylhtsang changed the title Support learnable sink backward in CuTe FA [Cute, bwd, sm90/100/110] Support learnable sink backward in CuTe FA Jul 13, 2026
@henrylhtsang
henrylhtsang marked this pull request as ready for review July 16, 2026 01:39
@henrylhtsang

Copy link
Copy Markdown
Contributor Author

cc @Johnsonms @drisspg @jayhshah would appreciate a review

@henrylhtsang henrylhtsang changed the title [Cute, bwd, sm90/100/110] Support learnable sink backward in CuTe FA [Cute, bwd, sm90/100/110] Support learnable sink in backward Jul 16, 2026
@henrylhtsang
henrylhtsang marked this pull request as draft July 16, 2026 02:04
@henrylhtsang
henrylhtsang marked this pull request as ready for review July 16, 2026 04:23
@henrylhtsang henrylhtsang reopened this Jul 29, 2026
@henrylhtsang
henrylhtsang marked this pull request as draft July 29, 2026 00:35
mCuSeqlensQ: Optional[cute.Tensor],
mSeqUsedQ: Optional[cute.Tensor],
mdPsum: Optional[cute.Tensor],
mLSE: Optional[cute.Tensor],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto down ehre

Comment thread flash_attn/cute/interface.py Outdated
batchp1 = cute.sym_int()
mCuSeqlensQ = fake_tensor(Int32, (batchp1,), divisibility=1) if has_cuseqlens_q else None
mSeqUsedQ = fake_tensor(Int32, (batch,), divisibility=1) if has_seqused_q else None
mLearnableSink = fake_tensor(cutlass.BFloat16, (mQ.shape[-2],), divisibility=1) if has_learnable_sink else None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

side note; do we not support fp16 for our sinks on purpose here? feels like a good follow up

Comment thread flash_attn/cute/interface.py Outdated
arch, dtype, hdim, block_size, num_threads,
atom_layout, swap_ab,
use_2cta_instrs=False, cluster_size=1,
dpsum=None, lse=None, learnable_sink=None, dsink=None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same deal could be a tuple of types since they need to come together

aux_scalars=ctx.aux_scalars,
mask_mod=ctx.mask_mod,
dlse=dlse,
learnable_sink=learnable_sink,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

codex: It doesn't look like learnable-sink backward is tested for varlen—the varlen test hardcodes has_learnable_sink=False and excludes it from backward. Could you add a numerical varlen regression, ideally with unequal and zero sequence lengths?

# do_o = ((g.float() * out.float()).sum(-1)).transpose(1, 2)
dq, dk, dv = torch.autograd.grad(out, (q, k, v), g)
grad_tensors = (q, k, v, learnable_sink) if has_learnable_sink else (q, k, v)
grads = torch.autograd.grad(out, grad_tensors, g)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CODEX:

Can we also add a return_lse=True case with learnable_sink, using a loss that depends on both out and lse, and compare dSink against the reference? This currently differentiates only out, so it tests the dO contribution to dSink but not the dLSE contribution. I want both features exercised together.

lse_val = Float32(lse_val)
sink_prob = (
Float32(1.0)
if lse_val == -Float32.inf

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex now: SM90 sink-only rows can reach this code with LSE = NaN, so this -inf special case does not protect them and sink_prob/dSink become NaN. flash_fwd_sm90.py leaves row_max=-inf before adding the finite sink, while SM100 replaces the empty-row max with the sink. Could SM90 use the same handling and add a causal seqlen_q > seqlen_k sink-gradient regression? The current backward test excludes that case.

scale: cutlass.Float32,
mCuSeqlensQ: Optional[cute.Tensor],
mSeqUsedQ: Optional[cute.Tensor],
mdPsum: Optional[cute.Tensor],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we bundle these as sink_tensors: tuple[cute.Tensor, cute.Tensor, cute.Tensor, cute.Tensor] | None, or preferably a small NamedTuple? These four tensors are all-or-none, so grouping them would encode that invariant and avoid threading four optional arguments through both entry points.

@henrylhtsang
henrylhtsang force-pushed the learnable-sink-backward branch from da6a8b0 to 0d49703 Compare July 29, 2026 01:10
lse.fill_(float("-inf"))
if learnable_sink is None:
lse.fill_(float("-inf"))
else:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: this is a behavior change for forward when sink is present.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes sense to me ; feels consistent

row_sum[r] += cute.math.exp2(
sink_val_cur * LOG2_E - row_max[r] * scale_log2, fastmath=True
)
if row_max[r] == -Float32.inf:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

afaik this is for sm90 only.

@henrylhtsang

Copy link
Copy Markdown
Contributor Author

addressed feedback

@henrylhtsang
henrylhtsang marked this pull request as ready for review July 29, 2026 23:49
@henrylhtsang
henrylhtsang marked this pull request as draft July 29, 2026 23:54
@henrylhtsang

henrylhtsang commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Tested this on an H100 / SM90. We have to relax the dSink test tolerance for the SM90 result to pass, but the relaxation is narrow and dtype-aware rather than a blanket threshold.

The kernel accumulates dSink in FP32 and casts it to the sink dtype. On SM90, the varlen + LSE case differed from the PyTorch reference by exactly one BF16 ULP (0.00390625), while the previous cancellation-based tolerance evaluated to zero. FP16 also needed an output-dtype rounding allowance; FP32 passed the original comparison.

@henrylhtsang
henrylhtsang marked this pull request as ready for review July 30, 2026 00:34
@henrylhtsang
henrylhtsang requested a review from drisspg July 30, 2026 22:22

@drisspg drisspg left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks pretty good; two comments

Comment on lines +1402 to 1412
if learnable_sink is not None:
assert arch // 10 in [9, 10, 11], "Learnable sink backward is supported on SM90 and SM100/SM110"
assert lse is not None, "learnable_sink backward requires LSE"
if q.numel() == 0 or k.numel() == 0:
dsink = (
dlse.sum(dim=(0, 2) if dlse.ndim == 3 else 1).to(learnable_sink.dtype)
if dlse is not None
else torch.zeros_like(learnable_sink)
)
return torch.zeros_like(q), torch.zeros_like(k), torch.zeros_like(v), dsink
sparse_q = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we preserve the preallocated dq/dk/dv outputs in this early-return path? Other paths in _flash_attn_bwd respect these buffers (and test_flash_attn_bwd_preallocated_outputs checks their identity). For example:

Suggested change
if learnable_sink is not None:
assert arch // 10 in [9, 10, 11], "Learnable sink backward is supported on SM90 and SM100/SM110"
assert lse is not None, "learnable_sink backward requires LSE"
if q.numel() == 0 or k.numel() == 0:
dsink = (
dlse.sum(dim=(0, 2) if dlse.ndim == 3 else 1).to(learnable_sink.dtype)
if dlse is not None
else torch.zeros_like(learnable_sink)
)
return torch.zeros_like(q), torch.zeros_like(k), torch.zeros_like(v), dsink
sparse_q = None
if q.numel() == 0 or k.numel() == 0:
dq = torch.zeros_like(q) if dq is None else dq.zero_()
dk = torch.zeros_like(k) if dk is None else dk.zero_()
dv = torch.zeros_like(v) if dv is None else dv.zero_()
dsink = (
dlse.sum(dim=(0, 2) if dlse.ndim == 3 else 1).to(learnable_sink.dtype)
if dlse is not None
else torch.zeros_like(learnable_sink)
)
return dq, dk, dv, dsink

Comment on lines +358 to +423
# Reuse one existing dQ postprocess CTA per head to reduce dSink from
# the per-row dPsum and LSE written by backward preprocess. This avoids
# both a global atomic accumulator and a separate zero-initialization.
if const_expr(sink_tensors is not None):
mdPsum, mLSE, mLearnableSink, mdSink = sink_tensors
block_x, block_y, block_z = cute.arch.block_idx()
sink_head_idx = head_idx if const_expr(mCuSeqlensQ is None) else block_x
# Varlen uses block_x to select one CTA per head. block_y and block_z
# are currently always zero, but check them defensively.
reduce_sink = (
m_block == 0 and batch_idx == 0
if const_expr(mCuSeqlensQ is None)
else block_x < mdSink.shape[0] and block_y == 0 and block_z == 0
)
if reduce_sink:
sink_sum = Float32(0.0)
num_batch = (
mdQ.shape[0] if const_expr(mCuSeqlensQ is None) else mCuSeqlensQ.shape[0] - 1
)
sink_val = Float32(mLearnableSink[sink_head_idx])
sink_batch = 0
while sink_batch < num_batch:
sink_seqlen = SeqlenInfoQK.create(
sink_batch,
mdQ.shape[1],
0,
mCuSeqlensQ=mCuSeqlensQ,
mSeqUsedQ=mSeqUsedQ,
tile_m=self.tile_m * self.cluster_size,
)
sink_row = tidx
while sink_row < sink_seqlen.seqlen_q:
if const_expr(mCuSeqlensQ is None):
dpsum_val = mdPsum[sink_batch, sink_head_idx, sink_row]
lse_val = mLSE[sink_batch, sink_head_idx, sink_row]
else:
dpsum_val = mdPsum[
sink_head_idx, sink_seqlen.padded_offset_q + sink_row
]
lse_val = mLSE[sink_head_idx, sink_seqlen.offset_q + sink_row]
lse_val = Float32(lse_val)
sink_prob = (
Float32(1.0)
if lse_val == -Float32.inf
else cute.math.exp2(
(sink_val - lse_val) * utils.LOG2_E,
fastmath=True,
)
)
sink_sum += -sink_prob * Float32(dpsum_val)
sink_row += self.num_threads
sink_batch += 1

sink_sum = utils.warp_reduce(sink_sum, operator.add)
lane_idx = cute.arch.lane_idx()
warp_idx = tidx // cute.arch.WARP_SIZE
num_warps = self.num_threads // cute.arch.WARP_SIZE
if lane_idx == 0:
sdQaccum_flat[warp_idx] = sink_sum
cute.arch.barrier(barrier_id=5, number_of_threads=self.num_threads)
if warp_idx == 0:
sink_sum = sdQaccum_flat[lane_idx] if lane_idx < num_warps else Float32(0.0)
sink_sum = utils.warp_reduce(sink_sum, operator.add)
if lane_idx == 0:
mdSink[sink_head_idx] = sink_sum.to(mdSink.element_type)
cute.arch.barrier(barrier_id=5, number_of_threads=self.num_threads)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is performance here? Do we get enough parallelism?

num_warps = self.num_threads // cute.arch.WARP_SIZE
if lane_idx == 0:
sdQaccum_flat[warp_idx] = sink_sum
cute.arch.barrier(barrier_id=5, number_of_threads=self.num_threads)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good follow up; we should switch to named barriers

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wait actually can this just be: cute.arch.sync_threads()?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants