Repository navigation
Conversation
The fused attention kernel lines its causal mask up with the last keys (query i sees keys up to i + kL - qL), but masked only the last ceil(BQ / BK) key blocks a query block reaches, plus one when kL is not a whole number of key blocks. When kL - qL is not a multiple of BK, a query block's diagonal crosses one block more than that, and that block went unmasked, so some queries attended to keys after their own position. Mask from the key block holding the block's first query position, as MLX's steel_attention.h does since ml-explore/mlx#3271. Also clamp the causal block limit to NK, as MLX does since ml-explore/mlx#1999: with a partial last query block the limit could pass the last key block and the kernel read keys and values past kL. Two new tests in candle-nn/tests/sdpa.rs compare causal sdpa with a CPU reference for every kL - qL from 0 to 33, at head dims 64, 128 and 512 (key blocks 32, 16 and 8), in f32 and bf16. The second gives K and V as views of a buffer whose rows past kL are NaN. Claude-Session: https://claude.ai/code/session_01762aPcWc5SxPgH7Munvfj9
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
candle_nn::ops::sdpawithdo_causal = trueon Metal lets some queries attend to keys aftertheir own position when there are fewer queries than keys, which is the shape of a prefill on top
of a KV cache.
The full kernel (
steel_attentioninscaled_dot_product_attention.metal) lines the causal mask upwith the last keys: query
isees keys up toi + kL - qL. Each threadgroup handles a block of BQqueries and walks the keys in blocks of BK. It applied the mask only to the key blocks with
kb >= kb_lim - ceil(BQ / BK) - int(!align_K), that is the lastceil(BQ / BK)blocks the queryblock reaches, plus one when kL is not a whole number of key blocks. When
kL - qLis not amultiple of BK, the query block's diagonal starts inside a key block and crosses one block more
than that. If kL is also a whole number of key blocks, that first block is not masked, and its
queries see up to BK - 2 keys past their position.
A second, smaller problem sits beside it: the loop limit
kb_lim = ceil(q_max / BK)was notclamped to the number of key blocks. With a partial last query block,
q_maxruns past kL and thekernel read one block of keys and values past the end of K and V. Those keys are masked, but a
masked score times a NaN value is still NaN, so a K/V view into a longer buffer (a preallocated
KV cache) could put NaN in the output.
We found this in fastembed-rs, reusing a shared prefix across Qwen3 reranker documents
(anush008/fastembed-rs#342). On Metal in bf16, 5 of 100 documents moved by up to 1.09 in logit
against scoring each document whole. On a random model with head dim 64 in f32 the difference was
3.0e-3. The first test there used lengths that happened to miss the bad residues and passed.
The fix
The kernel came from MLX, which already fixed both:
kb_min_causal = max(0, tid.x * BQ + qL_off) / BK, the key blockholding the query block's first position. Every block before it holds only keys at or before
every query in the block.
kb_lim = min(NK, kb_lim).This change copies both lines from MLX's current
steel_attention.h. Nothing else in the kernelchanges.
Same work for shapes that were already right
When kL and
kL - qLare both whole numbers of key blocks (BQ is a multiple of BK at every headdim: 32/32, 32/16 and 8/8),
kb_lim = (tid.x + 1) * BQ / BK + (kL - qL) / BKexactly, so the oldfirst masked block
kb_lim - BQ / BKequals the newkb_min_causal: the kernel masks the sameblocks as before. When qL is a whole number of query blocks,
q_max <= kL, so the clamp neverchanges
kb_lim. The only added work is one integer division per threadgroup. I checked this byreading the arithmetic, not by timing: the machine was too loaded for a timing to mean much.
The tests
Two tests in
candle-nn/tests/sdpa.rs, in the Metal module. They comparesdpa(.., do_causal = true, ..)with a CPU reference in f32 (matmul, the same mask aligned to the last keys, softmax,matmul) built from the same rounded inputs:
kL - qLfrom 0 to 33;96 - (kL - qL) % 32(which makes kL a whole number of key blocks);
The tolerance is 1e-4 in f32 and 3e-2 in bf16. With this change the largest differences are
2.0e-6 in f32 and 1.7e-2 in bf16. On main the smallest failing difference is 0.057.
sdpa_full_causal_fewer_queries_than_keysuses contiguous K and V.sdpa_full_causal_reads_no_key_past_kv_lengives K and V as views of the first kL rows of abuffer whose remaining 64 rows are NaN, so any read past kL shows up in the output.
where kL is a whole number of key blocks and
(kL - qL) % BKis neither 0 nor BK - 1 (31 athead dim 64, 29 at 128, 25 at 512). The largest difference is 4.2 (head dim 512, bf16, qL = 95,
kL = 96). In the second test all 318 come out NaN: every failing shape has a partial last query
block, so the kernel read the NaN rows past kL.
sdpa.rs(7 passed).Checks run
On an Apple M4 Pro with macOS's Metal, in this branch's own target directory:
cargo fmt --all -- --check: clean.cargo clippy -p candle-metal-kernels -p candle-nn --features candle-nn/metal --lib -- -D warnings:clean.
cargo clippy -p candle-nn --features metal --lib --tests -- -D warnings: clean.candle-metal-kernels' own tests do not build on main at present:src/tests.rsusesrandandrand_distr, which are not among its dependencies. This change does not touch that file.🍄