[Cute, bwd, sm90/100/110] Support learnable sink in backward - #2706
[Cute, bwd, sm90/100/110] Support learnable sink in backward#2706henrylhtsang wants to merge 20 commits into
Conversation
|
cc @Johnsonms @drisspg @jayhshah would appreciate a review |
| mCuSeqlensQ: Optional[cute.Tensor], | ||
| mSeqUsedQ: Optional[cute.Tensor], | ||
| mdPsum: Optional[cute.Tensor], | ||
| mLSE: Optional[cute.Tensor], |
| 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 |
There was a problem hiding this comment.
side note; do we not support fp16 for our sinks on purpose here? feels like a good follow up
| 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, |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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], |
There was a problem hiding this comment.
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.
da6a8b0 to
0d49703
Compare
| lse.fill_(float("-inf")) | ||
| if learnable_sink is None: | ||
| lse.fill_(float("-inf")) | ||
| else: |
There was a problem hiding this comment.
NOTE: this is a behavior change for forward when sink is present.
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
afaik this is for sm90 only.
|
addressed feedback |
|
Tested this on an H100 / SM90. We have to relax the The kernel accumulates |
drisspg
left a comment
There was a problem hiding this comment.
Looks pretty good; two comments
| 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 |
There was a problem hiding this comment.
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:
| 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 |
| # 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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
Good follow up; we should switch to named barriers
There was a problem hiding this comment.
wait actually can this just be: cute.arch.sync_threads()?
Summary
Test plan
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.