Repository navigation
fix(ArcAttention): Metal sinks arm dropped the caller's mask; memoize the V4 union mask across layers - #221
Merged
Conversation
CORRECTNESS, the other backend. #199 gated the CUDA fused arm on mask.is_none(), but the Metal arm kept engaging on head_dim alone and calling flash_attn_sinks_metal — which takes no mask parameter at all, so unlike the CUDA kernel it cannot even refuse one at the interface. GPT-OSS reaches this arm on every macOS build: head_dim 64 is in Metal's fused set and the model passes a mask it asserts is Some (gpt_oss.rs). MEASURED on a real Metal device (M-series, head_dim 128, F32): max|fused - reference(causal only)| = 0.000001 max|fused - reference(masked)| = 0.860029 max|ref(causal) - ref(masked)| = 0.860029 <- the mask's whole effect The maskless kernel tracks the causal-only reference to 1e-6 and misses the masked one by the mask's entire effect: dropped, not approximated. The fix is the same one line as CUDA's: an explicit mask pins the call to the unfused mask-honoring path, with a once-per-process warn naming the deliberate perf loss. New tests: * metal_sinks_mask_tests (feature = "metal", real device): the baseline above, plus the gate — masked dispatch vs masked reference, diff 0.0. Mutation-checked on hardware: with the guard reverted the gate test fails at max abs diff 0.8600293; restored, 0.00000000. * padding_mask_is_honored_and_dropping_it_changes_the_output (CPU, always runs): a padding-column mask — the class no fused kernel can re-derive — survives the dispatch end to end. Mutation-checked: dropping the mask at the fall-through arm fails it at max abs diff 0.9353 vs 0.0 required. Rider (perf, contained): sinks_attn_cpu_varlen rebuilt its per-sequence mask with Tensor::from_vec — a host-to-device upload per sequence per layer per step on the GPU fall-through (CLAUDE.md pitfall #5). It is a pure function of (q_len, kv_len, window, dtype, device), now memoized in a single slot; uniform batches build it once per shape. varlen_mask_memo_hits_and_never_ serves_stale pins both memo failure modes (mutation-checked: with the key comparison removed it fails on the (5, 8, 3) entry). Also corrects the file docs that called the unfused fall-through "CPU": it is device-agnostic candle math and is exactly what V4's head_dim-512 layers run on CUDA. The to_vec1 D2H syncs on cu_seqlens remain — hoisting the host copy into FlashParams is shared plumbing, reported not fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf
The union mask is a pure function of nine integers — (q0, t_q, raw_base, t_k, t_c, ratio, window, dtype, device) — yet dsv4_attention rebuilt it once per LAYER per step. On the prefill path that is three Tensor::arange host-to-device uploads plus their casts, compares, the U8 cat and the 0/-inf conversion, every layer (CLAUDE.md pitfall #5: each arange with a GPU device is a CPU-to-GPU sync). a7b0890/64fe1d678 already killed the DECODE-path aranges; this removes the per-layer rebuild wholesale on both paths. Mechanics: the validity build is now lazy (build_valid closure, character-for-character the old code on a miss) and the additive [1, 1, t_q, n_keys] mask is memoized thread-local on the integer key — 4 FIFO slots, since one step touches at most three keys (the Standard / CSA / HCA layer classes). Per step, each distinct mask is built once instead of ~once per layer. Excluded on purpose: * graph_positions (fixed-capacity graph decode): the key would be constant across steps, and the ops must stay inside the capture region rather than be hoisted into a warmup-owned tensor whose later eviction would leave a recorded graph reading freed memory. That build is already arange-free. * ragged cohorts (row_q0): the per-row leads are content but not key. Tests (CPU, validating code not perf — D14): * memo_hits_without_building_and_misses_on_a_changed_key: a hit returns the cached handle WITHOUT running the builder (handle identity is the only observable — a memo that always misses returns correct values while doing nothing); an advanced q0 must miss. * memoized_masks_are_bit_identical_to_fresh_builds: decode at ctx 35 then 36 (ratio 4, window 8) — two masks with the SAME shape and different content (threshold crosses 8 -> 9). Sequential same-thread runs must be bit-identical to fresh-thread (empty-memo) references. Mutation-checked: with the memo's key comparison replaced by "serve the newest entry", both tests fail — the identity test at TensorId(1) != TensorId(6), the end-to-end test with ctx=36's output bits diverging from the fresh reference (first lane 1051680768 vs 1051066368). No behavioral change: a miss runs the exact pre-change build, and the existing 38 dsv4_attention tests (bit-identity, ragged, graph-geometry, absorbed-decode) all pass unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf
Code Metrics Report━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Language Files Lines Code Comments Blanks ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ C Header 5 305 210 52 43 CSS 2 1181 1036 34 111 CUDA 81 29475 20161 6275 3039 Dockerfile 1 39 22 8 9 JavaScript 16 3546 2676 482 388 Jinja2 7 694 656 5 33 JSON 75 4896 4893 0 3 Makefile 1 6 5 0 1 Metal Shading Lan| 33 12224 9431 1142 1651 PowerShell 1 300 227 30 43 Python 147 15371 12677 824 1870 Shell 42 10304 6858 2769 677 Plain Text 4 3801 0 2479 1322 TOML 33 1498 1294 54 150 YAML 3 25 23 2 0 ───────────────────────────────────────────────────────────────────────────────── HTML 4 2687 2604 43 40 |- CSS 2 543 479 37 27 |- JavaScript 1 1233 1215 12 6 (Total) 4463 4298 92 73 ───────────────────────────────────────────────────────────────────────────────── Jupyter Notebooks 4 122 83 23 16 |- Markdown 1 60 30 22 8 |- Python 1 122 113 1 8 (Total) 304 226 46 32 ───────────────────────────────────────────────────────────────────────────────── Markdown 213 48932 0 38081 10851 |- BASH 72 1655 1203 331 121 |- C 4 19 19 0 0 |- CUDA 2 84 56 16 12 |- JSON 19 779 779 0 0 |- PowerShell 1 1 1 0 0 |- Python 23 1008 787 113 108 |- Rust 68 2063 1727 78 258 |- TOML 6 207 164 0 43 |- YAML 5 41 36 5 0 (Total) 54789 4772 38624 11393 ───────────────────────────────────────────────────────────────────────────────── Rust 681 340988 293252 18096 29640 |- Markdown 504 32562 471 28063 4028 (Total) 373550 293723 46159 33668 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Total 1353 516771 363188 99077 54506 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
…ched; #219 owns the allowlist) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf
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.
Parent system: ArcInfer / ArcAttention. Three-job lane: one correctness (ship-blocking), two performance. Base:
release/openrouter-ready@ 9a5921b.1. Correctness — the Metal sinks arm still dropped the caller's mask
#199 (
7df05f62a, the rebase of8dbdd1a87) gated the CUDA fused arm onmask.is_none()and gaveflash_attn_sinksa mask parameter so it can refuse one. The Metal arm got neither: it engaged on head_dim alone and calledflash_attn_sinks_metal— which has no mask parameter at all, so the interface cannot even refuse. Reachable today: GPT-OSS on any macOS build (head_dim 64 is in Metal's fused set{64,80,96,112,128,192,256}, andgpt_oss.rspasses a mask itassert!s isSome).Measured on a real Metal device (M-series, head_dim 128, F32,
metal_sinks_mask_tests):The maskless kernel tracks the causal-only reference to 1e-6 and misses the masked one by the mask's entire effect — dropped, not approximated. Fix: the same one-line
&& mask.is_none()gate as CUDA's, plus a once-per-process warn naming the deliberate perf loss.Mutation checks (values both ways):
masked_sinks_attention_on_metal_matches_the_masked_reference(real Metal device)padding_mask_is_honored_and_dropping_it_changes_the_output(CPU, always runs; padding-column mask — the class no fused kernel can re-derive)Not done here (out of fence,
mistralrs-quant): givingflash_attn_sinks_metala mask parameter so the Metal kernel can refuse at the interface the way the CUDA one now does. Until then the dispatch gate is the only line of defence on Metal.2. V≡K duplicate — already landed on this base; premise re-verified
The
v_aliases_kdedup shipped in #204 (00f31e3e1): handle-identity test (Tensor::id) at both duplication sites (the rawnarrow(..).contiguous()and the unionTensor::cat), with engagement counters (fused_v_stats). Re-verified against the current base:deepseek4.rsstill passes the same binding twice at three of four call sites (&k, &k@1888;&k_full, &k_full@1949;&k_cached, &k_cached@2043) and genuinely distinct tensors at the fourth (&k_full, &v_full@1874) — the dedup fires on the first three only.aliased_v_is_bit_identical_and_engages(bit-equality of outputs viato_bits(), counter deltas ≥ +2),alias_predicate_is_handle_identity(acopy()with equal values must NOT alias — the control),distinct_v_is_not_aliased(a different V must change the output). All pass on this branch.No code change needed; nothing added.
Live call-site trace (does the dedup actually fire?) — per review request, report-only (
deepseek4.rsis outside this lane's fence):paged_attnSome + metadata)&k_full, &v_full@ 1874-1875let v = k.copy()@ 1863 (deliberate:reshape_and_cachewrite-locks and read-locks the same storage, so aliased k/v self-deadlock the RwLock during the cache WRITE), thencache_write_and_gathergathers K and V from two separate pools&k, &k@ 1889-1890TensorIdhas_graph_mode_positions&& seq_len 1)&k_full, &k_full@ 1949-1950 (oneappend_graph_kv_mqaresult)&k_cached, &k_cached@ 2043-2044 (onecached.span(..)result)On a box serving with PagedAttention ON,
fused_v_statswill read(0, N)— the dedup is a no-op there. The one-line change that fixes it (NOT made, outside fence): pass&k_full, &k_fullat deepseek4.rs:1874-1875. Thev = k.copy()at :1863 must STAY — it protects the cache write, not the dispatch — but the gatheredv_fullis byte-identical tok_full(the value pool was written from a byte-copy of k and gathered with the same block tables), anddsv4_attentionis purely functional over k/v (narrow/contiguous/cat/matmul, no in-place ops), so handing itk_fulltwice is safe and bitwise identical. Deeper follow-up (also outside fence): with that in place the V-pool write+gather is dead weight on V4 — skipping it is the real capacity/bandwidth win, but it touchespaged_attentioninternals.3. Union-mask rebuild — memoized across layers
The union mask is a pure function of nine integers, yet
dsv4_attentionrebuilt it once per layer per step. The prompt's threearangesites (:759/:762/:786) had partially moved since:a7b0890d9/64fe1d678already killed the decode-path aranges (all-ones raw mask + the cachedcompress_positionstable). What remained: the prefill path still ran 3 aranges + casts + compares per layer, and both paths still ran the U8cat+ 0/−inf conversion per layer.This PR makes the validity build lazy (
build_valid, character-for-character the old code on a miss) and memoizes the additive[1,1,t_q,n_keys]mask thread-local on the integer key (UnionMaskKey, 4 FIFO slots — one step touches at most 3 keys: the Standard/CSA/HCA classes). Each distinct mask is now built once per step instead of ~once per layer. Deliberately excluded:graph_positions(constant key across steps; ops must stay inside the capture region, not be hoisted into a warmup-owned tensor whose eviction would leave a recorded graph reading freed memory) and ragged cohorts (per-row leads are content but not key).Rider in
sinks.rs:sinks_attn_cpu_varlenrebuilt its per-sequence mask withTensor::from_vec(an H2D upload per sequence per layer per step on the GPU fall-through) — now a single-slot memo,varlen_causal_mask_cached. Theto_vec1D2H syncs oncu_seqlensin the same function remain: hoisting the host copy intoFlashParamsis shared plumbing, reported not fixed.Mutation checks (values both ways):
memo_hits_without_building_and_misses_on_a_changed_keyTensorId(1) != TensorId(6)— "an advanced position was served the previous position's mask"memoized_masks_are_bit_identical_to_fresh_builds(ctx 35→36, same mask shape, different admitted block set)1051680768vs1051066368)varlen_mask_memo_hits_and_never_serves_stale(5, 8, 3)entry with the key comparison removedPremises verified / refuted
8dbdd1a87is not an ancestor, but its rebase7df05f62ais.v_catis an unconditional byte-identical duplicate" — already fixed by Eight cited defects that were reaching nobody: 4 wrong answers (default-on), 3 capacity, 1 dead loader #204 on this base, with the exact tests requested.sinks_attn_cpunaming/doc issue — docs corrected (device-agnostic fall-through, not CPU-only); the function is not renamed in this PR as instructed.gpt_oss.rsassertsattention_mask.is_some()and forwards it into the sinks dispatch).Not done / out of fence
deepseek4.rs,layers.rs, scheduler, sampler, ormistralrs-quant.capability_reachability.rs).dsv4_attention.rs(theq0checked_subblock) and the pre-existing clippy cast lints are untouched — present on base, out of scope.🤖 Generated with Claude Code
https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf