SDPA: skip the non-attendable key columns - #22112
Conversation
Summary:
Clamp the key-block width in the flash-attention custom SDPA
kernel (`op_sdpa_impl.h`) to the causally attendable extent.
Under causal attention the query block starting at `m` can only attend to
`num_keys = min(m + start_pos + qBlockSize, kvSize)` keys. The columns
beyond that were computed by the q @ k.T gemm, filled with -inf, run
through the softmax, and then multiplied by v -- all to contribute exactly
zero. The change is one line:
- int64_t kvBlockSize = std::min(kvSplitSize, kvSize - n);
+ int64_t kvBlockSize = std::min(kvSplitSize, num_keys - n);
`num_keys <= kvSize` always, so the block can only get smaller, never go
out of bounds; and when `is_causal` is false `num_keys == kvSize` and
nothing changes at all. For a prefill split into `qSlice` query blocks the
key-column count goes from `qSlice * kvSize` down to the sum over blocks of
`num_keys(block)`, a factor of `2 * qSlice / (qSlice + 1)`.
Worked example -- backbone prefill of 128 query tokens at `start_pos = 11`.
`seq_len = 128` dispatches `cpu_flash_attention<CTYPE, 32, 512>`, so
`qSplitSize = 32` and `kvSplitSize = 512`, and the runner hands in a key
extent trimmed to `kvSize = 139`:
query block m num_keys kvBlockSize before after
0 43 139 43
32 75 139 75
64 107 139 107
96 139 139 139
Key columns processed: 556 -> 364.
Three supporting hunks:
- The causal-mask guard is retightened from `kvSplitSize` to `kvBlockSize`.
Masking is needed for a row iff `m_start_pos + row < n + kvBlockSize - 1`,
which is now exactly the loop condition. The old form also ran the rows
with `last_col >= kvBlockSize`, which have nothing to mask: the fill size
`kvBlockSize - last_col` comes out zero or negative there and `fill_stub`
no-ops either way, and the write extent was always derived from
`kvBlockSize` rather than `kvSplitSize`, so nothing was written out of
range. This hunk is a consistency fix rather than a bug fix -- but the
clamp takes `kvBlockSize < kvSplitSize` from rare to routine, so it is
worth having the loop bound agree with the width the rows are actually
strided by.
- `fill_stub(qk_data, ...)` is sized `qBlockSize * kvBlockSize` rather than
the scratch extent `qSplitSize * kvSplitSize`. Safe because `qk_data` is
packed, not strided by `kvSplitSize`: every consumer indexes it as
`qk_data + row * kvBlockSize`, and `_qk_at_v_gemm` is passed `kvBlockSize`
as the leading dimension. So the tighter fill covers exactly the live
region.
- `fill_stub(dst_data, ...)` likewise, at stride `headSize`.
This is not bit-exact. The skipped columns contribute exactly zero to the
output, but dropping them changes how the softmax row sum and the second
gemm's K extent partition across vector lanes, so the order of the floating
point accumulation moves and the last bit with it. Fourteen autoregressive
layers on that is enough to flip a near-tie argmax: on the ar suites this
diff changes generated text in 2 of 59 cases relative to its parent, and
neither of them changes a verdict.
Stacking note: this diff sits on top of the `exp_u20` change rather than
under it. Applied to master on its own it regresses one benchmark case
(`calling_101010000000018016_2000`); stacked on `exp_u20` it does not. See
the end-to-end table below.
Differential Revision: D117259224
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22112
Note: Links to docs will display an error until the docs builds have been completed. ❌ 2 New FailuresAs of commit 7761a72 with merge base 4d3b46d ( NEW FAILURES - The following jobs have failed:
This comment was automatically generated by Dr. CI and updates every 15 minutes. |
|
@pssrawat has exported this pull request. If you are a Meta employee, you can view the originating Diff in D117259224. |
This PR needs a
|
Summary:
Clamp the key-block width in the flash-attention custom SDPA
kernel (
op_sdpa_impl.h) to the causally attendable extent.Under causal attention the query block starting at
mcan only attend tonum_keys = min(m + start_pos + qBlockSize, kvSize)keys. The columnsbeyond that were computed by the q @ k.T gemm, filled with -inf, run
through the softmax, and then multiplied by v -- all to contribute exactly
zero. The change is one line:
num_keys <= kvSizealways, so the block can only get smaller, never goout of bounds; and when
is_causalis falsenum_keys == kvSizeandnothing changes at all. For a prefill split into
qSlicequery blocks thekey-column count goes from
qSlice * kvSizedown to the sum over blocks ofnum_keys(block), a factor of2 * qSlice / (qSlice + 1).Worked example -- backbone prefill of 128 query tokens at
start_pos = 11.seq_len = 128dispatchescpu_flash_attention<CTYPE, 32, 512>, soqSplitSize = 32andkvSplitSize = 512, and the runner hands in a keyextent trimmed to
kvSize = 139:query block m num_keys kvBlockSize before after
0 43 139 43
32 75 139 75
64 107 139 107
96 139 139 139
Key columns processed: 556 -> 364.
Three supporting hunks:
kvSplitSizetokvBlockSize.Masking is needed for a row iff
m_start_pos + row < n + kvBlockSize - 1,which is now exactly the loop condition. The old form also ran the rows
with
last_col >= kvBlockSize, which have nothing to mask: the fill sizekvBlockSize - last_colcomes out zero or negative there andfill_stubno-ops either way, and the write extent was always derived from
kvBlockSizerather thankvSplitSize, so nothing was written out ofrange. This hunk is a consistency fix rather than a bug fix -- but the
clamp takes
kvBlockSize < kvSplitSizefrom rare to routine, so it isworth having the loop bound agree with the width the rows are actually
strided by.
fill_stub(qk_data, ...)is sizedqBlockSize * kvBlockSizerather thanthe scratch extent
qSplitSize * kvSplitSize. Safe becauseqk_dataispacked, not strided by
kvSplitSize: every consumer indexes it asqk_data + row * kvBlockSize, and_qk_at_v_gemmis passedkvBlockSizeas the leading dimension. So the tighter fill covers exactly the live
region.
fill_stub(dst_data, ...)likewise, at strideheadSize.This is not bit-exact. The skipped columns contribute exactly zero to the
output, but dropping them changes how the softmax row sum and the second
gemm's K extent partition across vector lanes, so the order of the floating
point accumulation moves and the last bit with it. Fourteen autoregressive
layers on that is enough to flip a near-tie argmax: on the ar suites this
diff changes generated text in 2 of 59 cases relative to its parent, and
neither of them changes a verdict.
Stacking note: this diff sits on top of the
exp_u20change rather thanunder it. Applied to master on its own it regresses one benchmark case
(
calling_101010000000018016_2000); stacked onexp_u20it does not. Seethe end-to-end table below.
Differential Revision: D117259224