Skip to main content

mlx_native/ops/
flash_attn_prefill.rs

1//! Flash-attention-style tiled prefill kernel — host dispatch.
2//!
3//! mlx-native's batched-prefill SDPA kernel, the prefill counterpart to
4//! `flash_attn_vec` (which handles the seq_len=1 decode case).  Implements
5//! online softmax + simdgroup MMA tiling on Apple GPU.
6//!
7//! ## Kernel variants registered
8//!
9//! Twelve entry points are registered, all backed by the single
10//! `flash_attn_prefill.metal` shader source:
11//!
12//! ### D=64 (BQ=32, BK=16, WM=4, WN=1 — 128 threads/threadgroup, BERT family)
13//!
14//! | Kernel name | I/O dtype | Mask kind |
15//! |---|---|---|
16//! | `flash_attn_prefill_bf16_d64`            | bf16 | bf16 additive |
17//! | `flash_attn_prefill_bf16_d64_boolmask`   | bf16 | bool |
18//! | `flash_attn_prefill_f16_d64`             | f16  | f16 additive |
19//! | `flash_attn_prefill_f16_d64_boolmask`    | f16  | bool |
20//!
21//! ### D=256 (BQ=32, BK=16, WM=4, WN=1 — 128 threads/threadgroup)
22//!
23//! | Kernel name | I/O dtype | Mask kind |
24//! |---|---|---|
25//! | `flash_attn_prefill_bf16_d256`           | bf16 | bf16 additive (log-domain) |
26//! | `flash_attn_prefill_bf16_d256_boolmask`  | bf16 | bool (`is_attended`) |
27//! | `flash_attn_prefill_f16_d256`            | f16  | f16 additive |
28//! | `flash_attn_prefill_f16_d256_boolmask`   | f16  | bool |
29//!
30//! ### D=512 (BQ=8, BK=8, WM=1, WN=1 — 32 threads/threadgroup, 1 simdgroup)
31//!
32//! | Kernel name | I/O dtype | Mask kind |
33//! |---|---|---|
34//! | `flash_attn_prefill_bf16_d512`           | bf16 | bf16 additive |
35//! | `flash_attn_prefill_bf16_d512_boolmask`  | bf16 | bool |
36//! | `flash_attn_prefill_f16_d512`            | f16  | f16 additive |
37//! | `flash_attn_prefill_f16_d512_boolmask`   | f16  | bool |
38//!
39//! ### f32 is NOT instantiated at any D
40//!
41//! The f32 Qs threadgroup tile alone is `BQ * BD * 4` bytes — at D=256 this
42//! is 32 KB exactly, the Apple Silicon `MTLDevice.maxThreadgroupMemoryLength`
43//! hard limit, before KV_smem or scratch.  Verified empirically on M5 Max:
44//! f32 D=256 requires ~53.7 KB and fails at library compile.  bf16 and f16
45//! halve the tile footprint (~29 KB) and fit within the limit.  D=512 f32
46//! is excluded for the same reason.  D=64 inherits the same exclusion for
47//! consistency (bf16/f16 only across the entire dispatcher family).  f32
48//! correctness is verified at the CPU reference layer in
49//! `tests/test_flash_attn_prefill.rs`.  See
50//! `ADR-011-phase1-port-source-decision.md` §3 for the full
51//! threadgroup-memory analysis.
52//!
53//! ## Function constants
54//!
55//! The kernel declares four Metal function constants that must be specialised
56//! at pipeline creation time (not at dispatch time):
57//!
58//! - Index 200: `align_Q` (bool) — true when `qL % BQ == 0`
59//! - Index 201: `align_K` (bool) — true when `kL % BK == 0`
60//! - Index 300: `has_mask` (bool) — true when a mask buffer is bound
61//! - Index 301: `do_causal` (bool) — true for in-kernel causal masking
62//!
63//! These are plumbed via [`KernelRegistry::get_pipeline_with_bool_constants`],
64//! which caches compiled pipelines keyed by `(kernel_name, align_Q, align_K,
65//! has_mask, do_causal)`.  Pipeline compilation is amortised: the slow path
66//! runs only once per unique `(name, booleans)` combination.
67//!
68//! ## Buffer layout (indices match the MSL kernel)
69//!
70//! - `buffer(0)` — Q `[B, H,    qL, D]`  device, contiguous inner dim
71//! - `buffer(1)` — K `[B, H_kv, kL, D]`  device, contiguous inner dim
72//! - `buffer(2)` — V `[B, H_kv, kL, D]`  device, contiguous inner dim
73//! - `buffer(3)` — O `[B, H,    qL, D]`  device, written by kernel
74//! - `buffer(4)` — `AttnParams` constant buffer (this module's [`AttnParamsGpu`])
75//! - `buffer(5)` — `AttnMaskParams` constant buffer (only when `has_mask=true`)
76//! - `buffer(6)` — mask data buffer (only when `has_mask=true`)
77//!
78//! ## Grid geometry
79//!
80//! - Threadgroups: `(ceil(qL / BQ), H, B)`
81//! - Threads per threadgroup: `(32, WM, WN)`
82//! - D=256: 128 threads (4 simdgroups × 32 lanes).
83//! - D=512:  32 threads (1 simdgroup  × 32 lanes).
84//!
85//! ## Scale convention
86//!
87//! Pass `scale = 1.0 / sqrt(head_dim)`.  The kernel multiplies internally by
88//! `log2(e) ≈ 1.44269504` and uses `fast::exp2` throughout — so the host
89//! MUST NOT pre-multiply by `log2(e)`.
90//!
91//! ## Mask-sentinel contract (llama.cpp convention)
92//!
93//! The additive mask buffer (bf16/f16 for the additive dispatchers) uses
94//! the llama.cpp CPU-side convention: **masked positions = `-INFINITY`**
95//! (IEEE-754 f32 `0xFF800000`, cast to the I/O dtype — both `half(-inf)`
96//! and `bfloat16(-inf)` have a real `-inf` encoding that the kernel
97//! consumes correctly).  Attended positions = `0.0`.
98//!
99//! This matches llama.cpp's mask-authoring sites at
100//! `llama-graph.cpp:421, 436, 557` and `llama-kv-cache.cpp:1572`, where
101//! the CPU writes raw `-INFINITY` and the flash-attn cast to f16 saturates
102//! to f16-`-INFINITY`.
103//!
104//! The kernel does NOT require masks to use `-FLT_MAX/2` or any other
105//! finite "large negative" sentinel.  The `-FLT_MAX/2` value is the
106//! kernel-internal `M` (running row-max) initialiser — a finite sentinel
107//! that absorbs `-inf` scores via `simd_max` without ever letting `M`
108//! become `-inf`, which in turn lets every `exp(score - M)` evaluate as
109//! `exp(-inf) = 0.0` (IEEE-754 exact) rather than `exp(-inf - -inf) =
110//! exp(NaN) = NaN`.  See ADR-011-phase2-port-sentinel.md §1.
111//!
112//! Callers may pass arbitrary finite additive biases in the mask for
113//! non-masked positions (e.g. ALiBi, relative-position biases); only
114//! "fully block this K position" requires `-inf`.
115//!
116//! ## See also
117//!
118//! - Kernel: `/opt/mlx-native/src/shaders/flash_attn_prefill.metal`
119//! - ADR-011: `/opt/hf2q/docs/ADR-011-flash-attn-prefill.md`
120//! - ADR-011 phase 2 sentinel port: `/opt/hf2q/docs/ADR-011-phase2-port-sentinel.md`
121
122use metal::MTLSize;
123
124use crate::buffer::MlxBuffer;
125use crate::device::MlxDevice;
126use crate::encoder::{CapturedOpKind, CommandEncoder, KernelArg, as_bytes};
127use crate::error::{MlxError, Result};
128use crate::kernel_registry::KernelRegistry;
129use crate::DType;
130
131// ─── Shader source ───────────────────────────────────────────────────────────
132
133/// MSL source for the flash-attention prefill kernel (embedded at compile time).
134pub static FLASH_ATTN_PREFILL_SHADER_SOURCE: &str =
135    include_str!("../shaders/flash_attn_prefill.metal");
136
137// ─── All 12 kernel entry-point names ─────────────────────────────────────────
138
139/// D=256, bf16 I/O, bf16 additive mask.
140const K_BF16_D256: &str = "flash_attn_prefill_bf16_d256";
141/// D=256, bf16 I/O, bool (`is_attended`) mask.
142const K_BF16_D256_BOOLMASK: &str = "flash_attn_prefill_bf16_d256_boolmask";
143/// D=256, f16 I/O, f16 additive mask.
144const K_F16_D256: &str = "flash_attn_prefill_f16_d256";
145/// D=256, f16 I/O, bool mask.
146const K_F16_D256_BOOLMASK: &str = "flash_attn_prefill_f16_d256_boolmask";
147/// D=512, bf16 I/O, bf16 additive mask.
148const K_BF16_D512: &str = "flash_attn_prefill_bf16_d512";
149/// D=512, bf16 I/O, bool mask.
150const K_BF16_D512_BOOLMASK: &str = "flash_attn_prefill_bf16_d512_boolmask";
151/// D=512, f16 I/O, f16 additive mask.
152const K_F16_D512: &str = "flash_attn_prefill_f16_d512";
153/// D=512, f16 I/O, bool mask.
154const K_F16_D512_BOOLMASK: &str = "flash_attn_prefill_f16_d512_boolmask";
155/// D=64, bf16 I/O, bf16 additive mask (BERT family).
156const K_BF16_D64: &str = "flash_attn_prefill_bf16_d64";
157/// D=64, bf16 I/O, bool (`is_attended`) mask.
158const K_BF16_D64_BOOLMASK: &str = "flash_attn_prefill_bf16_d64_boolmask";
159/// D=64, f16 I/O, f16 additive mask.
160const K_F16_D64: &str = "flash_attn_prefill_f16_d64";
161/// D=64, f16 I/O, bool mask.
162const K_F16_D64_BOOLMASK: &str = "flash_attn_prefill_f16_d64_boolmask";
163
164/// All 12 kernel entry-point names exported by `flash_attn_prefill.metal`.
165///
166/// Registering all of them against the single shader source costs nothing at
167/// registration time (source is a static `&str`) and ensures additional
168/// dispatchers (f16 paths, D=512 exposure) can be added later without
169/// touching registration here.
170const ALL_KERNEL_NAMES: &[&str] = &[
171    K_BF16_D256,
172    K_BF16_D256_BOOLMASK,
173    K_F16_D256,
174    K_F16_D256_BOOLMASK,
175    K_BF16_D512,
176    K_BF16_D512_BOOLMASK,
177    K_F16_D512,
178    K_F16_D512_BOOLMASK,
179    K_BF16_D64,
180    K_BF16_D64_BOOLMASK,
181    K_F16_D64,
182    K_F16_D64_BOOLMASK,
183];
184
185// ─── Registration ─────────────────────────────────────────────────────────────
186
187/// Register all flash-attention prefill kernel entry points with the registry.
188///
189/// Maps all 12 entry-point names to the single `flash_attn_prefill.metal`
190/// source.  This must be called before any dispatch to these kernels.
191///
192/// # Design note
193///
194/// `KernelRegistry` compiles one Metal library per kernel name.  All 12 names
195/// point at the same source text, so the Metal compiler sees the same ~1 500-line
196/// source each time — compilation is amortised in `KernelRegistry::get_pipeline`
197/// (first call per name triggers compilation; subsequent calls return the cached
198/// pipeline).  Registering all 12 here rather than only the Phase 1a subset
199/// means Phase 2/4 dispatcher functions can be added without touching this file.
200pub fn register(registry: &mut KernelRegistry) {
201    for &name in ALL_KERNEL_NAMES {
202        registry.register_source(name, FLASH_ATTN_PREFILL_SHADER_SOURCE);
203    }
204}
205
206// ─── MSL struct mirrors ───────────────────────────────────────────────────────
207
208/// Rust mirror of the MSL `AttnParams` struct.
209///
210/// Field order and types match the MSL definition exactly.
211/// MSL source: `flash_attn_prefill.metal` — see the `AttnParams` struct
212/// definition in the kernel source for the field-by-field reference.
213///
214/// # Layout
215///
216/// All `int` fields are 32-bit (i32 in Rust).  The `int64_t` stride arrays are
217/// 64-bit (i64 in Rust).  The compiler inserts natural alignment padding:
218///
219/// ```text
220/// Offset  0:  B            (i32,  4 bytes)
221/// Offset  4:  H            (i32,  4 bytes)
222/// Offset  8:  D            (i32,  4 bytes)
223/// Offset 12:  qL           (i32,  4 bytes)
224/// Offset 16:  kL           (i32,  4 bytes)
225/// Offset 20:  gqa_factor   (i32,  4 bytes)
226/// Offset 24:  scale        (f32,  4 bytes)
227/// Offset 28:  softcapping  (f32,  4 bytes)
228/// Offset 32:  NQ           (i32,  4 bytes)
229/// Offset 36:  NK           (i32,  4 bytes)
230/// Offset 40:  NQ_aligned   (i32,  4 bytes)
231/// Offset 44:  NK_aligned   (i32,  4 bytes)
232/// Offset 48:  qL_rem       (i32,  4 bytes)
233/// Offset 52:  kL_rem       (i32,  4 bytes)
234/// Offset 56:  qL_off       (i32,  4 bytes)
235/// Offset 60:  _pad         (4 bytes — alignment before i64 array)
236/// Offset 64:  Q_strides[3] (3 × i64, 24 bytes)
237/// Offset 88:  K_strides[3] (3 × i64, 24 bytes)
238/// Offset 112: V_strides[3] (3 × i64, 24 bytes)
239/// Offset 136: O_strides[3] (3 × i64, 24 bytes)
240/// Total: 160 bytes
241/// ```
242///
243/// `bytemuck::Pod` / `bytemuck::Zeroable` are derived — the struct must have
244/// no uninitialized padding bytes.  The explicit `_pad` field makes the padding
245/// concrete so Pod can be derived safely.
246///
247/// `softcapping` is always set to `1.0` (disabled).  The `attention<>` kernel
248/// body does not read it; the field exists for ABI parity with attention
249/// implementations that thread softcapping through the same param block
250/// (e.g. Gemma-style logit softcap), so we don't have to redo the layout
251/// when that work lands.
252#[repr(C)]
253#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
254pub struct AttnParamsGpu {
255    /// Batch size.
256    pub b: i32,
257    /// Number of query heads.
258    pub h: i32,
259    /// Head dimension (D).
260    pub d: i32,
261    /// Query sequence length.
262    pub ql: i32,
263    /// Key/value sequence length.
264    pub kl: i32,
265    /// Group-query attention factor: H / H_kv.
266    pub gqa_factor: i32,
267    /// Attention scale (= 1.0 / sqrt(head_dim); kernel multiples by log2(e)).
268    pub scale: f32,
269    /// Softcapping value — always 1.0 (disabled) for standard SDPA.
270    pub softcapping: f32,
271    /// Number of Q tiles: ceil(qL / BQ).
272    pub nq: i32,
273    /// Number of KV tiles: ceil(kL / BK).
274    pub nk: i32,
275    /// Number of full (aligned) Q tiles: qL / BQ.
276    pub nq_aligned: i32,
277    /// Number of full (aligned) KV tiles: kL / BK.
278    pub nk_aligned: i32,
279    /// Remainder elements in the last Q tile: qL % BQ (0 if aligned).
280    pub ql_rem: i32,
281    /// Remainder elements in the last KV tile: kL % BK (0 if aligned).
282    pub kl_rem: i32,
283    /// Query sequence start offset (0 for standard prefill).
284    pub ql_off: i32,
285    /// Explicit padding to align the subsequent i64 arrays to 8-byte boundary.
286    pub _pad: i32,
287    /// Query strides: (batch stride, head stride, seq stride).  Inner dim = 1.
288    pub q_strides: [i64; 3],
289    /// Key strides: (batch stride, head stride, seq stride).  Inner dim = 1.
290    pub k_strides: [i64; 3],
291    /// Value strides: (batch stride, head stride, seq stride).  Inner dim = 1.
292    pub v_strides: [i64; 3],
293    /// Output strides: (batch stride, head stride, seq stride).  Inner dim = 1.
294    pub o_strides: [i64; 3],
295}
296
297/// Rust mirror of the MSL `AttnMaskParams` struct.
298///
299/// MSL source: `flash_attn_prefill.metal` — see the `AttnMaskParams` struct
300/// definition in the kernel source.
301///
302/// Contains the mask buffer strides.  Only sent to the kernel when
303/// `has_mask = true` (buffer index 5).
304#[repr(C)]
305#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
306pub struct AttnMaskParamsGpu {
307    /// Mask strides: (batch stride, head stride, qL stride).  Inner dim = 1.
308    pub m_strides: [i64; 3],
309}
310
311// ─── Public Rust-side parameter struct ───────────────────────────────────────
312
313/// Host-side parameters for the flash-attention prefill dispatcher.
314///
315/// Used only by the Rust dispatcher; does not map 1:1 to the GPU struct —
316/// the GPU struct is computed from these fields inside the dispatcher.
317#[derive(Debug, Clone, Copy)]
318pub struct FlashAttnPrefillParams {
319    /// Number of query attention heads.
320    pub n_heads: u32,
321    /// Number of key/value attention heads (GQA: may be < n_heads).
322    pub n_kv_heads: u32,
323    /// Head dimension.  Must be 256 for `dispatch_flash_attn_prefill_bf16_d256`.
324    pub head_dim: u32,
325    /// Query sequence length.
326    pub seq_len_q: u32,
327    /// Key/value sequence length.
328    pub seq_len_k: u32,
329    /// Batch size.
330    pub batch: u32,
331    /// Attention scale.  Typically `1.0 / sqrt(head_dim)`.
332    ///
333    /// The kernel internally multiplies this by `log2(e) = 1.44269504089`
334    /// before applying it to Q.  The host MUST NOT pre-multiply by log2(e).
335    pub scale: f32,
336    /// Whether to apply in-kernel causal masking (`do_causal` function constant).
337    ///
338    /// When true, positions where `row_pos < col_pos` receive a score of -inf
339    /// before softmax.  This can be combined with an external mask buffer.
340    pub do_causal: bool,
341}
342
343// ─── Tile geometry constants (D=256) ─────────────────────────────────────────
344
345/// Q tile size for D=256: BQ=32.
346const BQ_D256: u32 = 32;
347
348/// KV tile size for D=256: BK=16.
349const BK_D256: u32 = 16;
350
351/// Simdgroups along Q dimension for D=256: WM=4.
352const WM_D256: u32 = 4;
353
354/// Simdgroups along K dimension for D=256: WN=1.
355const WN_D256: u32 = 1;
356
357// ─── Tile geometry constants (D=64) ──────────────────────────────────────────
358//
359// Same simdgroup geometry as D=256: 4 simdgroups along Q, 1 along K, with
360// BQ=32 / BK=16.  Threadgroup-memory math at bf16:
361//   Qs  = BQ × (BD + padQ) × 2   = 32 × (64 + 8) × 2  = 4 608 bytes
362//   KVs = max(BK*(BD+padV), (BK+padK)*BD) × 2
363//                                 = max(16×72, 24×64) × 2 = max(1152, 1536) × 2
364//                                 = 3 072 bytes
365//   total ≈ 7 680 bytes — fits well under the 32 KiB Apple Silicon TG limit.
366//
367// Static-asserts required by the kernel (with kFragSize=8):
368//   BQ % (kNWarps × kFragSize) = 32 % (4×8) = 0 ✓
369//   BQ ≥ kNWarps × kFragSize   = 32 ≥ 32     ✓
370//   TQ = BQ / (kNWarps × kFragSize) = 1       ✓
371//   TD = BD / kFragSize        = 64/8 = 8     ✓
372//   TK = BK / kFragSize        = 16/8 = 2     ✓
373
374/// Q tile size for D=64: BQ=32.
375const BQ_D64: u32 = 32;
376
377/// KV tile size for D=64: BK=16.
378const BK_D64: u32 = 16;
379
380/// Simdgroups along Q dimension for D=64: WM=4.
381const WM_D64: u32 = 4;
382
383/// Simdgroups along K dimension for D=64: WN=1.
384const WN_D64: u32 = 1;
385
386// ─── Validation ───────────────────────────────────────────────────────────────
387
388fn validate_params(params: &FlashAttnPrefillParams) -> Result<()> {
389    if params.n_heads == 0 {
390        return Err(MlxError::InvalidArgument(
391            "flash_attn_prefill: n_heads must be > 0".into(),
392        ));
393    }
394    if params.n_kv_heads == 0 {
395        return Err(MlxError::InvalidArgument(
396            "flash_attn_prefill: n_kv_heads must be > 0".into(),
397        ));
398    }
399    if params.n_heads % params.n_kv_heads != 0 {
400        return Err(MlxError::InvalidArgument(format!(
401            "flash_attn_prefill: n_heads ({}) must be divisible by n_kv_heads ({})",
402            params.n_heads, params.n_kv_heads
403        )));
404    }
405    if params.seq_len_q == 0 {
406        return Err(MlxError::InvalidArgument(
407            "flash_attn_prefill: seq_len_q must be > 0".into(),
408        ));
409    }
410    if params.seq_len_k == 0 {
411        return Err(MlxError::InvalidArgument(
412            "flash_attn_prefill: seq_len_k must be > 0".into(),
413        ));
414    }
415    if params.batch == 0 {
416        return Err(MlxError::InvalidArgument(
417            "flash_attn_prefill: batch must be > 0".into(),
418        ));
419    }
420    Ok(())
421}
422
423pub(crate) fn validate_buffer_size(buf: &MlxBuffer, name: &str, expected_elements: usize) -> Result<()> {
424    let expected_bytes = expected_elements * buf.dtype().size_of();
425    if buf.byte_len() < expected_bytes {
426        return Err(MlxError::InvalidArgument(format!(
427            "flash_attn_prefill: {name} buffer too small: expected at least \
428             {expected_bytes} bytes, got {}",
429            buf.byte_len()
430        )));
431    }
432    Ok(())
433}
434
435// ─── bf16 D=256 dispatcher ───────────────────────────────────────────────────
436
437/// Dispatch flash-attention prefill for bf16 Q/K/V/O, head_dim=256.
438///
439/// Encodes a compute command into `encoder` without committing.  The caller
440/// controls when to call `encoder.commit_and_wait()`.
441///
442/// # Why bf16 and not f32
443///
444/// The f32 Qs threadgroup tile (BQ×BD×4 = 32 KB at D=256) consumes the entire
445/// Apple Silicon threadgroup-memory budget before KV_smem, scratch, or any
446/// padding — so f32 D=256 is not instantiated (see module doc).  bf16/f16
447/// halve the tile footprint; the MMA accumulator is still f32 internally
448/// (the kernel's `T_accum` template parameter is `float` for every
449/// instantiation — see `flash_attn_prefill.metal:~1504`), so prefill output
450/// precision is `bf16 × bf16 → f32 → bf16` — bf16-bounded at the store, not
451/// at the accumulator.
452///
453/// # Buffer layouts
454///
455/// All buffers must be contiguous (stride-1 along the innermost / head_dim
456/// dimension):
457///
458/// - `q`    — `[batch, n_heads,    seq_len_q, 256]`, dtype BF16
459/// - `k`    — `[batch, n_kv_heads, seq_len_k, 256]`, dtype BF16
460/// - `v`    — `[batch, n_kv_heads, seq_len_k, 256]`, dtype BF16
461/// - `mask` — `[batch, n_heads, seq_len_q, seq_len_k]`, dtype BF16
462///   (additive, log-scale: 0.0 = attend, -inf = mask out — llama.cpp
463///   convention, see module doc "Mask-sentinel contract"), or `None`
464/// - `out`  — `[batch, n_heads,    seq_len_q, 256]`, dtype BF16 (output)
465///
466/// # Function constants
467///
468/// `align_Q`, `align_K` are computed from the sequence lengths and tile sizes.
469/// `has_mask` reflects whether `mask` is `Some(_)`.
470/// `do_causal` is taken from `params.do_causal`.
471///
472/// A distinct Metal pipeline is compiled for each unique combination of these
473/// four booleans and cached in `registry`.
474///
475/// # Errors
476///
477/// Returns `MlxError::InvalidArgument` for:
478/// - `head_dim != 256`
479/// - Zero or inconsistent shape fields
480/// - Buffer too small for the declared shape
481/// - `n_heads` not divisible by `n_kv_heads`
482/// - Any buffer dtype != BF16
483///
484/// Returns `MlxError::ShaderCompilationError` if the Metal pipeline
485/// compilation fails.
486#[allow(clippy::too_many_arguments)]
487pub fn dispatch_flash_attn_prefill_bf16_d256(
488    encoder: &mut CommandEncoder,
489    device: &MlxDevice,
490    registry: &mut KernelRegistry,
491    q: &MlxBuffer,
492    k: &MlxBuffer,
493    v: &MlxBuffer,
494    mask: Option<&MlxBuffer>,
495    out: &MlxBuffer,
496    params: &FlashAttnPrefillParams,
497) -> Result<()> {
498    // Delegate to the blk-aware dispatcher with blk=None.  Because
499    // `has_blk` is a function constant (index 303), the compiled pipeline
500    // with has_blk=false dead-codes every blk reference — this call has
501    // zero runtime overhead compared with the pre-Wave-2E code path.
502    dispatch_flash_attn_prefill_bf16_d256_with_blk(
503        encoder, device, registry, q, k, v, mask, None, out, params,
504    )
505}
506
507/// Dispatch flash-attention prefill for bf16 Q/K/V/O, head_dim=256, with an
508/// optional Wave 2E tile-skip pre-pass byte buffer.
509///
510/// This is the blk-aware sibling of [`dispatch_flash_attn_prefill_bf16_d256`].
511/// When `blk` is `Some(buf)`, the kernel reads a classification byte per
512/// `(qtile, ktile)` and:
513///
514/// - On `blk[qt][kt] == 0`, skips the entire KV tile (no K/V load, no
515///   Q·K^T, no mask-add, no softmax update).
516/// - On `blk[qt][kt] == 2`, skips the mask-add (but still computes Q·K^T
517///   and softmax normally).
518/// - On `blk[qt][kt] == 1`, runs the standard path.
519///
520/// The `blk` buffer must be produced by
521/// [`crate::ops::flash_attn_prefill_blk::dispatch_flash_attn_prefill_blk`]
522/// with the SAME `(BQ=32, BK=16)` tile shape as this dispatcher, and the
523/// caller MUST sequence the pre-pass dispatch BEFORE this one on the same
524/// command encoder (or commit the pre-pass first).
525///
526/// # Correctness invariant
527///
528/// For any valid (mask, built blk), calling this function with `blk=None`
529/// vs `blk=Some(built_blk)` MUST produce bit-exact identical output.  The
530/// blk path is a pure skip optimisation.  Exercised by
531/// `test_gpu_bf16_d256_with_blk_matches_no_blk` in
532/// `tests/test_flash_attn_prefill.rs`.
533///
534/// # Buffer layout
535///
536/// All buffers as in [`dispatch_flash_attn_prefill_bf16_d256`], plus:
537///
538/// - `blk` — `[ceil(seq_len_q / 32), ceil(seq_len_k / 16)]`, dtype U8.
539///   Required iff `Some(_)`; must have at least
540///   `ceil(qL/32) * ceil(kL/16)` bytes.  When `None`, the dispatcher
541///   compiles with `has_blk=false` and does not bind buffer index 7.
542///
543/// # Errors
544///
545/// Same as [`dispatch_flash_attn_prefill_bf16_d256`], plus:
546/// - `blk` is `Some(b)` with `mask = None` (a blk without a mask is
547///   meaningless — rejected to catch caller bugs).
548/// - `blk` buffer undersized.
549#[allow(clippy::too_many_arguments)]
550pub fn dispatch_flash_attn_prefill_bf16_d256_with_blk(
551    encoder: &mut CommandEncoder,
552    device: &MlxDevice,
553    registry: &mut KernelRegistry,
554    q: &MlxBuffer,
555    k: &MlxBuffer,
556    v: &MlxBuffer,
557    mask: Option<&MlxBuffer>,
558    blk: Option<&MlxBuffer>,
559    out: &MlxBuffer,
560    params: &FlashAttnPrefillParams,
561) -> Result<()> {
562    // ── Validate ──────────────────────────────────────────────────────────
563    if params.head_dim != 256 {
564        return Err(MlxError::InvalidArgument(format!(
565            "dispatch_flash_attn_prefill_bf16_d256: head_dim must be 256, got {}",
566            params.head_dim
567        )));
568    }
569    // Reject the meaningless has_blk=true, has_mask=false case: a blk
570    // buffer classifies the contents of the mask; without a mask the blk
571    // bytes are computed against undefined data.  This is a caller-bug
572    // defence, not a shader limitation.
573    if blk.is_some() && mask.is_none() {
574        return Err(MlxError::InvalidArgument(
575            "dispatch_flash_attn_prefill_bf16_d256_with_blk: \
576             blk requires mask (a blk without a mask is meaningless)"
577                .into(),
578        ));
579    }
580    validate_params(params)?;
581
582    // All buffers must be BF16 for this dispatcher.
583    for (buf, name) in &[(q, "Q"), (k, "K"), (v, "V"), (out as &MlxBuffer, "out")] {
584        if buf.dtype() != DType::BF16 {
585            return Err(MlxError::InvalidArgument(format!(
586                "dispatch_flash_attn_prefill_bf16_d256: {name} buffer must be BF16, \
587                 got {:?}",
588                buf.dtype()
589            )));
590        }
591    }
592    if let Some(m) = mask {
593        if m.dtype() != DType::BF16 {
594            return Err(MlxError::InvalidArgument(format!(
595                "dispatch_flash_attn_prefill_bf16_d256: mask buffer must be BF16, \
596                 got {:?}",
597                m.dtype()
598            )));
599        }
600    }
601
602    let batch = params.batch as usize;
603    let h = params.n_heads as usize;
604    let h_kv = params.n_kv_heads as usize;
605    let ql = params.seq_len_q as usize;
606    let kl = params.seq_len_k as usize;
607    let d = params.head_dim as usize; // = 256
608
609    // Validate buffer element counts.
610    validate_buffer_size(q, "Q", batch * h * ql * d)?;
611    validate_buffer_size(k, "K", batch * h_kv * kl * d)?;
612    validate_buffer_size(v, "V", batch * h_kv * kl * d)?;
613    validate_buffer_size(out, "out", batch * h * ql * d)?;
614    // A rank-2 mask `[qL, kL]` is the Wave 2D broadcast layout: one plane is
615    // shared across all batches and heads (stride-0 in the batch and head dims).
616    // A rank-4 mask `[B, H, qL, kL]` is the per-head layout used by callers
617    // that already have a fully-expanded mask (e.g. pre-Wave-2D code paths).
618    let mask_is_rank2_broadcast = mask.is_some_and(|m| m.shape().len() == 2);
619    if let Some(m) = mask {
620        if mask_is_rank2_broadcast {
621            validate_buffer_size(m, "mask", ql * kl)?;
622        } else {
623            validate_buffer_size(m, "mask", batch * h * ql * kl)?;
624        }
625    }
626
627    // ── Tile geometry ─────────────────────────────────────────────────────
628    let bq = BQ_D256;
629    let bk = BK_D256;
630    let wm = WM_D256;
631    let wn = WN_D256;
632
633    let nq = params.seq_len_q.div_ceil(bq);
634    let nk = params.seq_len_k.div_ceil(bk);
635    let nq_aligned = params.seq_len_q / bq;
636    let nk_aligned = params.seq_len_k / bk;
637    let ql_rem = params.seq_len_q % bq;
638    let kl_rem = params.seq_len_k % bk;
639
640    // Function constants (specialised at pipeline creation time, not dispatch).
641    let align_q = ql_rem == 0;
642    let align_k = kl_rem == 0;
643    let has_mask = mask.is_some();
644    let has_blk = blk.is_some();
645    let do_causal = params.do_causal;
646
647    // Validate blk buffer size when present.  Tile shape is fixed for D=256:
648    // BQ=32, BK=16 — see ADR-011-phase2-port-tile-skip.md §5.1.
649    if let Some(b) = blk {
650        let nq_tiles = ql.div_ceil(BQ_D256 as usize);
651        let nk_tiles = kl.div_ceil(BK_D256 as usize);
652        let expected = nq_tiles * nk_tiles;
653        if b.byte_len() < expected {
654            return Err(MlxError::InvalidArgument(format!(
655                "dispatch_flash_attn_prefill_bf16_d256_with_blk: blk buffer \
656                 too small: expected at least {expected} bytes (NQ={nq_tiles}, \
657                 NK={nk_tiles}), got {}",
658                b.byte_len()
659            )));
660        }
661    }
662
663    // ── Kernel name ───────────────────────────────────────────────────────
664    // bf16 I/O, bf16 additive mask (or no mask — uses same pipeline since
665    // has_mask is a function constant, not part of the name).
666    let kernel_name = K_BF16_D256;
667
668    // ── Pipeline lookup (with function constants) ─────────────────────────
669    //
670    // Wave 2E adds function constant 303 (has_blk).  When has_blk=false
671    // every blk reference in the shader is dead-coded, so pipelines with
672    // the pre-Wave-2E constants {200, 201, 300, 301} + {303: false}
673    // generate the same machine code as the pre-Wave-2E pipelines.  The
674    // only cache-key overhead is one extra "b0" suffix, which is paid
675    // once per (aligned, causal, masked) combo at compile time.
676    let pipeline = registry.get_pipeline_with_bool_constants(
677        kernel_name,
678        device.metal_device(),
679        &[
680            (200, align_q),
681            (201, align_k),
682            (300, has_mask),
683            (301, do_causal),
684            (303, has_blk),
685        ],
686    )?;
687
688    // ── Build AttnParams GPU struct ───────────────────────────────────────
689    //
690    // Strides for layout [B, H, L, D] where the innermost (D) stride is 1
691    // (contiguous):
692    //
693    //   seq stride  = D
694    //   head stride = L * D
695    //   batch stride = H * L * D
696    //
697    // Q/O use (H, qL); K/V use (H_kv, kL).
698
699    let q_seq_stride = d as i64;
700    let q_head_stride = (ql * d) as i64;
701    let q_batch_stride = (h * ql * d) as i64;
702
703    let kv_seq_stride = d as i64;
704    let kv_head_stride = (kl * d) as i64;
705    let kv_batch_stride = (h_kv * kl * d) as i64;
706
707    let gqa_factor = (params.n_heads / params.n_kv_heads) as i32;
708
709    let attn_params = AttnParamsGpu {
710        b: params.batch as i32,
711        h: params.n_heads as i32,
712        d: params.head_dim as i32,
713        ql: params.seq_len_q as i32,
714        kl: params.seq_len_k as i32,
715        gqa_factor,
716        scale: params.scale,
717        softcapping: 1.0_f32,  // always disabled; see module doc
718        nq: nq as i32,
719        nk: nk as i32,
720        nq_aligned: nq_aligned as i32,
721        nk_aligned: nk_aligned as i32,
722        ql_rem: ql_rem as i32,
723        kl_rem: kl_rem as i32,
724        ql_off: 0,             // standard prefill starts at offset 0
725        _pad: 0,
726        q_strides: [q_batch_stride, q_head_stride, q_seq_stride],
727        k_strides: [kv_batch_stride, kv_head_stride, kv_seq_stride],
728        v_strides: [kv_batch_stride, kv_head_stride, kv_seq_stride],
729        o_strides: [q_batch_stride, q_head_stride, q_seq_stride],
730    };
731
732    // ── Grid geometry ──────────────────────────────────────────────────────
733    //   grid = (NQ, H, B)  where NQ = ceil(qL / BQ)
734    //   threadgroup = (32, WM, WN)
735    let grid = MTLSize::new(nq as u64, params.n_heads as u64, params.batch as u64);
736    let tg_size = MTLSize::new(32, wm as u64, wn as u64);
737
738    // ── Encode ─────────────────────────────────────────────────────────────
739    encoder.set_op_kind(CapturedOpKind::Sdpa);
740
741    if has_mask {
742        // SAFETY: has_mask is true iff mask.is_some() — set three lines above.
743        // The Option is therefore guaranteed to be Some here.  We use
744        // ok_or_else rather than expect/unwrap to satisfy the no-panic policy.
745        let mask_buf = mask.ok_or_else(|| {
746            MlxError::InvalidArgument(
747                "flash_attn_prefill: internal error — has_mask=true but mask is None".into(),
748            )
749        })?;
750
751        // Strides depend on mask rank:
752        //   rank-2 `[qL, kL]` — broadcast across batch + heads: set batch_stride
753        //   and head_stride to 0 so the shader re-reads the same plane for every
754        //   (batch, head) pair.  The Metal shader already handles stride-0 correctly
755        //   (no kernel changes required).
756        //   rank-4 `[B, H, qL, kL]` — per-head layout (back-compat path).
757        let (m_batch_stride, m_head_stride, m_ql_stride) = if mask_is_rank2_broadcast {
758            (0_i64, 0_i64, kl as i64)
759        } else {
760            ((h * ql * kl) as i64, (ql * kl) as i64, kl as i64)
761        };
762
763        let mask_params = AttnMaskParamsGpu {
764            m_strides: [m_batch_stride, m_head_stride, m_ql_stride],
765        };
766
767        if has_blk {
768            // SAFETY: has_blk is true iff blk.is_some() and the earlier
769            // has_blk validation rejects blk.is_some() && mask.is_none().
770            let blk_buf = blk.ok_or_else(|| {
771                MlxError::InvalidArgument(
772                    "flash_attn_prefill: internal error — has_blk=true but blk is None".into(),
773                )
774            })?;
775
776            encoder.encode_threadgroups_with_args(
777                pipeline,
778                &[
779                    (0, KernelArg::Buffer(q)),
780                    (1, KernelArg::Buffer(k)),
781                    (2, KernelArg::Buffer(v)),
782                    (3, KernelArg::Buffer(out)),
783                    (4, KernelArg::Bytes(as_bytes(&attn_params))),
784                    (5, KernelArg::Bytes(as_bytes(&mask_params))),
785                    (6, KernelArg::Buffer(mask_buf)),
786                    (7, KernelArg::Buffer(blk_buf)),
787                ],
788                grid,
789                tg_size,
790            );
791        } else {
792            encoder.encode_threadgroups_with_args(
793                pipeline,
794                &[
795                    (0, KernelArg::Buffer(q)),
796                    (1, KernelArg::Buffer(k)),
797                    (2, KernelArg::Buffer(v)),
798                    (3, KernelArg::Buffer(out)),
799                    (4, KernelArg::Bytes(as_bytes(&attn_params))),
800                    (5, KernelArg::Bytes(as_bytes(&mask_params))),
801                    (6, KernelArg::Buffer(mask_buf)),
802                    // buffer 7 absent — has_blk=false constant dead-codes blk refs.
803                ],
804                grid,
805                tg_size,
806            );
807        }
808    } else {
809        encoder.encode_threadgroups_with_args(
810            pipeline,
811            &[
812                (0, KernelArg::Buffer(q)),
813                (1, KernelArg::Buffer(k)),
814                (2, KernelArg::Buffer(v)),
815                (3, KernelArg::Buffer(out)),
816                (4, KernelArg::Bytes(as_bytes(&attn_params))),
817                // buffers 5, 6, 7 intentionally absent — has_mask=false +
818                // has_blk=false constants dead-code-eliminate mask + blk loads.
819            ],
820            grid,
821            tg_size,
822        );
823    }
824
825    Ok(())
826}
827
828/// F16 variant of [`dispatch_flash_attn_prefill_bf16_d256_with_blk`].
829///
830/// Identical buffer-layout, parameter, and dispatch contract — only the
831/// expected I/O dtype (F16 instead of BF16) and the underlying kernel name
832/// (`flash_attn_prefill_f16_d256` instead of `..._bf16_d256`) differ.
833///
834/// # Why a second dispatcher
835///
836/// The BF16 variant has 7-bit mantissa precision per Q element loaded into
837/// shared memory.  Across 256-element dot products × 30 layers this
838/// compounds into ~argmax flips on greedy decode of enumeration-style
839/// prompts (see `project_bug_gemma4_coherence_fa_bf16_q_2026_05_16` in
840/// auto-memory).  F16 has 10-bit mantissa (~8× more precision per element),
841/// which should keep the FA D=256 main-prefill path coherent without
842/// needing the tensor-mm workaround that ships today (HF2Q_NO_FA=1 default,
843/// commit 03328ee5).
844///
845/// This dispatcher is step 1 of the migration.  It is NOT yet wired into
846/// batched prefill — that requires F16-typed pf_q_perm/pf_k_perm/pf_v_perm
847/// buffers (or bf16↔f16 cast kernels) and is tracked separately.
848///
849/// # Mathematical equivalence to BF16 path
850///
851/// Both kernels use the same template `attention<T, BQ=32, BK=16, BD=256,
852/// WM=4, WN=1, MaskT, float>` with AccumType=float.  The softmax accumulator
853/// and Q·K^T accumulator are F32 in both paths.  The only precision
854/// difference is the per-element representation of Q/K/V in shared memory
855/// (BF16 7-bit mantissa vs F16 10-bit) and the per-element output store.
856/// F16's narrower exponent range (5 bits, ±65504) safely contains all
857/// Gemma 4 activation magnitudes (max |V| ≈ 15, RMS-normalised Q/K ≈ 1).
858#[allow(clippy::too_many_arguments)]
859pub fn dispatch_flash_attn_prefill_f16_d256_with_blk(
860    encoder: &mut CommandEncoder,
861    device: &MlxDevice,
862    registry: &mut KernelRegistry,
863    q: &MlxBuffer,
864    k: &MlxBuffer,
865    v: &MlxBuffer,
866    mask: Option<&MlxBuffer>,
867    blk: Option<&MlxBuffer>,
868    out: &MlxBuffer,
869    params: &FlashAttnPrefillParams,
870) -> Result<()> {
871    // ── Validate ──────────────────────────────────────────────────────────
872    if params.head_dim != 256 {
873        return Err(MlxError::InvalidArgument(format!(
874            "dispatch_flash_attn_prefill_f16_d256_with_blk: head_dim must be 256, got {}",
875            params.head_dim
876        )));
877    }
878    if blk.is_some() && mask.is_none() {
879        return Err(MlxError::InvalidArgument(
880            "dispatch_flash_attn_prefill_f16_d256_with_blk: \
881             blk requires mask (a blk without a mask is meaningless)"
882                .into(),
883        ));
884    }
885    validate_params(params)?;
886
887    // All buffers must be F16 for this dispatcher.
888    for (buf, name) in &[(q, "Q"), (k, "K"), (v, "V"), (out as &MlxBuffer, "out")] {
889        if buf.dtype() != DType::F16 {
890            return Err(MlxError::InvalidArgument(format!(
891                "dispatch_flash_attn_prefill_f16_d256_with_blk: {name} buffer must be F16, \
892                 got {:?}",
893                buf.dtype()
894            )));
895        }
896    }
897    if let Some(m) = mask {
898        if m.dtype() != DType::F16 {
899            return Err(MlxError::InvalidArgument(format!(
900                "dispatch_flash_attn_prefill_f16_d256_with_blk: mask buffer must be F16, \
901                 got {:?}",
902                m.dtype()
903            )));
904        }
905    }
906
907    let batch = params.batch as usize;
908    let h = params.n_heads as usize;
909    let h_kv = params.n_kv_heads as usize;
910    let ql = params.seq_len_q as usize;
911    let kl = params.seq_len_k as usize;
912    let d = params.head_dim as usize; // = 256
913
914    validate_buffer_size(q, "Q", batch * h * ql * d)?;
915    validate_buffer_size(k, "K", batch * h_kv * kl * d)?;
916    validate_buffer_size(v, "V", batch * h_kv * kl * d)?;
917    validate_buffer_size(out, "out", batch * h * ql * d)?;
918    let mask_is_rank2_broadcast = mask.is_some_and(|m| m.shape().len() == 2);
919    if let Some(m) = mask {
920        if mask_is_rank2_broadcast {
921            validate_buffer_size(m, "mask", ql * kl)?;
922        } else {
923            validate_buffer_size(m, "mask", batch * h * ql * kl)?;
924        }
925    }
926
927    // ── Tile geometry ─────────────────────────────────────────────────────
928    let bq = BQ_D256;
929    let bk = BK_D256;
930    let wm = WM_D256;
931    let wn = WN_D256;
932
933    let nq = params.seq_len_q.div_ceil(bq);
934    let nk = params.seq_len_k.div_ceil(bk);
935    let nq_aligned = params.seq_len_q / bq;
936    let nk_aligned = params.seq_len_k / bk;
937    let ql_rem = params.seq_len_q % bq;
938    let kl_rem = params.seq_len_k % bk;
939
940    let align_q = ql_rem == 0;
941    let align_k = kl_rem == 0;
942    let has_mask = mask.is_some();
943    let has_blk = blk.is_some();
944    let do_causal = params.do_causal;
945
946    if let Some(b) = blk {
947        let nq_tiles = ql.div_ceil(BQ_D256 as usize);
948        let nk_tiles = kl.div_ceil(BK_D256 as usize);
949        let expected = nq_tiles * nk_tiles;
950        if b.byte_len() < expected {
951            return Err(MlxError::InvalidArgument(format!(
952                "dispatch_flash_attn_prefill_f16_d256_with_blk: blk buffer \
953                 too small: expected at least {expected} bytes (NQ={nq_tiles}, \
954                 NK={nk_tiles}), got {}",
955                b.byte_len()
956            )));
957        }
958    }
959
960    // ── Kernel name ───────────────────────────────────────────────────────
961    let kernel_name = K_F16_D256;
962
963    let pipeline = registry.get_pipeline_with_bool_constants(
964        kernel_name,
965        device.metal_device(),
966        &[
967            (200, align_q),
968            (201, align_k),
969            (300, has_mask),
970            (301, do_causal),
971            (303, has_blk),
972        ],
973    )?;
974
975    let q_seq_stride = d as i64;
976    let q_head_stride = (ql * d) as i64;
977    let q_batch_stride = (h * ql * d) as i64;
978
979    let kv_seq_stride = d as i64;
980    let kv_head_stride = (kl * d) as i64;
981    let kv_batch_stride = (h_kv * kl * d) as i64;
982
983    let gqa_factor = (params.n_heads / params.n_kv_heads) as i32;
984
985    let attn_params = AttnParamsGpu {
986        b: params.batch as i32,
987        h: params.n_heads as i32,
988        d: params.head_dim as i32,
989        ql: params.seq_len_q as i32,
990        kl: params.seq_len_k as i32,
991        gqa_factor,
992        scale: params.scale,
993        softcapping: 1.0_f32,
994        nq: nq as i32,
995        nk: nk as i32,
996        nq_aligned: nq_aligned as i32,
997        nk_aligned: nk_aligned as i32,
998        ql_rem: ql_rem as i32,
999        kl_rem: kl_rem as i32,
1000        ql_off: 0,
1001        _pad: 0,
1002        q_strides: [q_batch_stride, q_head_stride, q_seq_stride],
1003        k_strides: [kv_batch_stride, kv_head_stride, kv_seq_stride],
1004        v_strides: [kv_batch_stride, kv_head_stride, kv_seq_stride],
1005        o_strides: [q_batch_stride, q_head_stride, q_seq_stride],
1006    };
1007
1008    let grid = MTLSize::new(nq as u64, params.n_heads as u64, params.batch as u64);
1009    let tg_size = MTLSize::new(32, wm as u64, wn as u64);
1010
1011    encoder.set_op_kind(CapturedOpKind::Sdpa);
1012
1013    if has_mask {
1014        let mask_buf = mask.ok_or_else(|| {
1015            MlxError::InvalidArgument(
1016                "flash_attn_prefill: internal error — has_mask=true but mask is None".into(),
1017            )
1018        })?;
1019
1020        let (m_batch_stride, m_head_stride, m_ql_stride) = if mask_is_rank2_broadcast {
1021            (0_i64, 0_i64, kl as i64)
1022        } else {
1023            ((h * ql * kl) as i64, (ql * kl) as i64, kl as i64)
1024        };
1025
1026        let mask_params = AttnMaskParamsGpu {
1027            m_strides: [m_batch_stride, m_head_stride, m_ql_stride],
1028        };
1029
1030        if has_blk {
1031            let blk_buf = blk.ok_or_else(|| {
1032                MlxError::InvalidArgument(
1033                    "flash_attn_prefill: internal error — has_blk=true but blk is None".into(),
1034                )
1035            })?;
1036
1037            encoder.encode_threadgroups_with_args(
1038                pipeline,
1039                &[
1040                    (0, KernelArg::Buffer(q)),
1041                    (1, KernelArg::Buffer(k)),
1042                    (2, KernelArg::Buffer(v)),
1043                    (3, KernelArg::Buffer(out)),
1044                    (4, KernelArg::Bytes(as_bytes(&attn_params))),
1045                    (5, KernelArg::Bytes(as_bytes(&mask_params))),
1046                    (6, KernelArg::Buffer(mask_buf)),
1047                    (7, KernelArg::Buffer(blk_buf)),
1048                ],
1049                grid,
1050                tg_size,
1051            );
1052        } else {
1053            encoder.encode_threadgroups_with_args(
1054                pipeline,
1055                &[
1056                    (0, KernelArg::Buffer(q)),
1057                    (1, KernelArg::Buffer(k)),
1058                    (2, KernelArg::Buffer(v)),
1059                    (3, KernelArg::Buffer(out)),
1060                    (4, KernelArg::Bytes(as_bytes(&attn_params))),
1061                    (5, KernelArg::Bytes(as_bytes(&mask_params))),
1062                    (6, KernelArg::Buffer(mask_buf)),
1063                ],
1064                grid,
1065                tg_size,
1066            );
1067        }
1068    } else {
1069        encoder.encode_threadgroups_with_args(
1070            pipeline,
1071            &[
1072                (0, KernelArg::Buffer(q)),
1073                (1, KernelArg::Buffer(k)),
1074                (2, KernelArg::Buffer(v)),
1075                (3, KernelArg::Buffer(out)),
1076                (4, KernelArg::Bytes(as_bytes(&attn_params))),
1077            ],
1078            grid,
1079            tg_size,
1080        );
1081    }
1082
1083    Ok(())
1084}
1085
1086// ─── bf16 D=256 RESUME dispatcher (qL_off > 0 + slot-capacity strides) ──────
1087//
1088// ADR-017 Phase E.a B.2-fix: extend the FA bf16 d256 fast path to support
1089// LCP partial-prefill resume (turn 2 of a multi-turn conversation prefilling
1090// only the suffix against a slot that already contains the LCP prefix).
1091//
1092// The metal shader has supported this regime since Phase 1 port (the
1093// `qL_off` field at flash_attn_prefill.metal:1045 + `K_strides[3]` /
1094// `V_strides[3]` at lines 1048-1049 are read at lines 1325, 1437, 1445), but
1095// the d256 dispatcher hardcoded `qL_off=0` and built K/V strides from
1096// `seq_len_k` not `kv_capacity` because no Rust caller needed
1097// "prefill-at-offset" semantics — Qwen3.5/3.6 production prefill is always
1098// from token 0 (cur_len=0) and decode (cur_len > 0, seq_len=1) routes to
1099// `flash_attn_vec` at gpu_full_attn.rs:1733 instead.
1100//
1101// The legacy F32 SDPA fallback at gpu_full_attn.rs:1900-1916 covered the
1102// "prefill-at-offset" case for structural correctness, but with a F32
1103// single-pass softmax that produces byte-different output to the BF16 MMA
1104// + log-domain online softmax fast path (proven via
1105// gpu_full_attn.rs::tests::phase_b2_iso_fast_path_vs_fallback_path_kernel_divergence:
1106// 131072/131072 elements differ, max |Δ| = 6.452e-4).
1107//
1108// This module fixes that: a sibling dispatcher that exposes `qL_off` and
1109// `kv_capacity` so the FA bf16 d256 fast path is reachable for cur_len > 0
1110// AND for slot reads (slot K/V's head stride is `kv_capacity * head_dim`,
1111// not `kL * head_dim`, because a slot may have allocated more positions
1112// than the current valid kL).
1113
1114/// Host-side parameters for the flash-attention prefill **resume** dispatcher.
1115///
1116/// Differs from [`FlashAttnPrefillParams`] in two ways:
1117///
1118/// 1. `q_offset_in_k` (mapped to the kernel's `qL_off`): the absolute Q
1119///    position of the chunk Q within the larger K/V sequence.  When > 0,
1120///    Q is being attended over a slot that already contains
1121///    `q_offset_in_k` previous tokens.  The kernel's causal mask uses
1122///    `qL_off` to compute `row_pos = tid.x * BQ + qL_off + ...`
1123///    (`flash_attn_prefill.metal:1325, 1445`) so the per-chunk causal
1124///    pattern stays correct relative to the absolute K/V position.
1125///
1126/// 2. `kv_capacity` (slot stride): K and V buffers may live in a slot of
1127///    capacity ≥ `seq_len_k`.  The kernel reads K/V via integer strides
1128///    from `K_strides[3]` / `V_strides[3]`, so we set
1129///    `head_stride = kv_capacity * D` to skip unused slot capacity between
1130///    heads.  When `kv_capacity == seq_len_k` the layout is identical to
1131///    the non-resume dispatcher.
1132///
1133/// # Use case
1134///
1135/// ADR-017 Phase E.a LCP partial-prefill resume.  Turn 1 of a multi-turn
1136/// conversation prefills the prompt, populates the KV slot, snapshots it.
1137/// Turn 2 detects an LCP overlap with turn 1, restores the slot, and
1138/// prefills only the suffix of turn 2 (M new tokens) against the full slot
1139/// (kL = N + M, qL = M, qL_off = N).  The output is byte-identical to a
1140/// fresh full prefill of the entire turn-2 prompt — proven by the parity
1141/// unit test
1142/// `flash_attn_prefill_bf16_d256_resume_byte_identical_to_monolithic`.
1143///
1144/// The non-resume dispatcher remains the production path for prefill-from-
1145/// zero (`q_offset_in_k == 0` && `kv_capacity == seq_len_k`).  The two
1146/// dispatchers compile to the same kernel pipeline (same kernel name +
1147/// same function constants when `q_offset_in_k == 0` && `kv_capacity ==
1148/// seq_len_k`); the only host-side difference is the strides written into
1149/// `AttnParamsGpu`.
1150#[derive(Debug, Clone, Copy)]
1151pub struct FlashAttnPrefillResumeParams {
1152    /// Number of query attention heads.
1153    pub n_heads: u32,
1154    /// Number of key/value attention heads (GQA).
1155    pub n_kv_heads: u32,
1156    /// Head dimension.  Must be 256 for `dispatch_flash_attn_prefill_bf16_d256_resume`.
1157    pub head_dim: u32,
1158    /// Query sequence length (chunk Q only).  qL.
1159    pub seq_len_q: u32,
1160    /// Total key/value sequence length: `q_offset_in_k + seq_len_q`.  kL.
1161    pub seq_len_k: u32,
1162    /// Batch size.
1163    pub batch: u32,
1164    /// Attention scale.  Typically `1.0 / sqrt(head_dim)`.  The kernel
1165    /// internally multiplies by `log2(e)` before applying to Q.
1166    pub scale: f32,
1167    /// Whether to apply in-kernel causal masking.  For partial-prefill resume
1168    /// this is typically `true` — chunk Q must causally mask K positions
1169    /// `> q_offset_in_k + q_pos_within_chunk`.
1170    pub do_causal: bool,
1171    /// Q offset within the K/V sequence (`qL_off` in the kernel).  Number of
1172    /// previous tokens already present in the slot.  Standard append-prefill
1173    /// semantics: `q_offset_in_k + seq_len_q == seq_len_k`.
1174    pub q_offset_in_k: u32,
1175    /// Capacity of the K/V slot — stride between K/V heads in elements.
1176    /// Set to `seq_len_k` when K/V are contiguous-packed (equivalent to the
1177    /// non-resume dispatcher); set to the slot's allocated capacity to read
1178    /// from a slot with unused trailing positions.  Must be `>= seq_len_k`.
1179    pub kv_capacity: u32,
1180}
1181
1182/// Dispatch flash-attention prefill for bf16 Q/K/V/O, head_dim=256, with
1183/// caller-controlled `qL_off` and slot-capacity-aware K/V strides.
1184///
1185/// **Use this dispatcher when** `q_offset_in_k > 0` (partial-prefill resume)
1186/// OR when the K/V buffers have allocated capacity beyond `seq_len_k` (slot
1187/// reads).  For the prefill-from-zero contiguous-packed case prefer
1188/// [`dispatch_flash_attn_prefill_bf16_d256`] (functionally equivalent at
1189/// `q_offset_in_k=0` && `kv_capacity==seq_len_k`; verified via the parity
1190/// unit test in `tests/test_flash_attn_prefill.rs`).
1191///
1192/// # Buffer layouts
1193///
1194/// All buffers must be contiguous along the innermost head_dim=256 axis.
1195///
1196/// - `q`    — `[batch, n_heads,    seq_len_q,    256]`, dtype BF16
1197///     * head stride = `seq_len_q * 256`, seq stride = 256, batch stride =
1198///       `n_heads * seq_len_q * 256`.
1199/// - `k`    — `[batch, n_kv_heads, kv_capacity, 256]`, dtype BF16
1200///     * head stride = `kv_capacity * 256`, seq stride = 256, batch stride =
1201///       `n_kv_heads * kv_capacity * 256`.
1202///     * Only positions `[0..seq_len_k]` are read; positions
1203///       `[seq_len_k..kv_capacity]` may be uninitialised — the kernel will
1204///       not read past `seq_len_k` thanks to `kL`-aware tile bounds at
1205///       `flash_attn_prefill.metal:1322, 1416`.
1206/// - `v`    — same layout as `k`.
1207/// - `out`  — `[batch, n_heads,    seq_len_q,    256]`, dtype BF16 (output)
1208///
1209/// # Function constants
1210///
1211/// Identical pipeline cache key to the non-resume dispatcher: `align_Q`,
1212/// `align_K` from sequence lengths; `do_causal` from `params`; `has_mask`
1213/// and `has_blk` are both `false` (this resume dispatcher uses pure causal
1214/// masking via `qL_off` — no external additive mask is supported.  Add a
1215/// resume-with-mask sibling if a future caller needs it).
1216///
1217/// # Errors
1218///
1219/// Returns `MlxError::InvalidArgument` for:
1220/// - `head_dim != 256`
1221/// - `q_offset_in_k + seq_len_q > seq_len_k` (Q overshoots K)
1222/// - `seq_len_k > kv_capacity`               (kL overshoots slot capacity)
1223/// - Zero or inconsistent shape fields
1224/// - `n_heads` not divisible by `n_kv_heads`
1225/// - Buffer too small for declared shape (Q/O against `seq_len_q`,
1226///   K/V against `kv_capacity`)
1227/// - Any buffer dtype != BF16
1228#[allow(clippy::too_many_arguments)]
1229pub fn dispatch_flash_attn_prefill_bf16_d256_resume(
1230    encoder: &mut CommandEncoder,
1231    device: &MlxDevice,
1232    registry: &mut KernelRegistry,
1233    q: &MlxBuffer,
1234    k: &MlxBuffer,
1235    v: &MlxBuffer,
1236    out: &MlxBuffer,
1237    params: &FlashAttnPrefillResumeParams,
1238) -> Result<()> {
1239    // ── Validate ──────────────────────────────────────────────────────────
1240    if params.head_dim != 256 {
1241        return Err(MlxError::InvalidArgument(format!(
1242            "dispatch_flash_attn_prefill_bf16_d256_resume: head_dim must be 256, got {}",
1243            params.head_dim
1244        )));
1245    }
1246    if params.n_heads == 0
1247        || params.n_kv_heads == 0
1248        || params.seq_len_q == 0
1249        || params.seq_len_k == 0
1250        || params.batch == 0
1251    {
1252        return Err(MlxError::InvalidArgument(
1253            "dispatch_flash_attn_prefill_bf16_d256_resume: \
1254             n_heads/n_kv_heads/seq_len_q/seq_len_k/batch must all be > 0"
1255                .into(),
1256        ));
1257    }
1258    if params.n_heads % params.n_kv_heads != 0 {
1259        return Err(MlxError::InvalidArgument(format!(
1260            "dispatch_flash_attn_prefill_bf16_d256_resume: n_heads ({}) must \
1261             be divisible by n_kv_heads ({})",
1262            params.n_heads, params.n_kv_heads
1263        )));
1264    }
1265    if params.q_offset_in_k + params.seq_len_q > params.seq_len_k {
1266        return Err(MlxError::InvalidArgument(format!(
1267            "dispatch_flash_attn_prefill_bf16_d256_resume: q_offset_in_k ({}) \
1268             + seq_len_q ({}) > seq_len_k ({}) — Q overshoots K",
1269            params.q_offset_in_k, params.seq_len_q, params.seq_len_k
1270        )));
1271    }
1272    if params.seq_len_k > params.kv_capacity {
1273        return Err(MlxError::InvalidArgument(format!(
1274            "dispatch_flash_attn_prefill_bf16_d256_resume: seq_len_k ({}) > \
1275             kv_capacity ({}) — K/V overshoots slot capacity",
1276            params.seq_len_k, params.kv_capacity
1277        )));
1278    }
1279
1280    for (buf, name) in &[(q, "Q"), (k, "K"), (v, "V"), (out as &MlxBuffer, "out")] {
1281        if buf.dtype() != DType::BF16 {
1282            return Err(MlxError::InvalidArgument(format!(
1283                "dispatch_flash_attn_prefill_bf16_d256_resume: {name} buffer \
1284                 must be BF16, got {:?}",
1285                buf.dtype()
1286            )));
1287        }
1288    }
1289
1290    let batch = params.batch as usize;
1291    let h = params.n_heads as usize;
1292    let h_kv = params.n_kv_heads as usize;
1293    let ql = params.seq_len_q as usize;
1294    let cap = params.kv_capacity as usize;
1295    let d = params.head_dim as usize; // = 256
1296
1297    validate_buffer_size(q, "Q", batch * h * ql * d)?;
1298    // K/V buffers are validated against capacity (slot layout), not seq_len_k.
1299    validate_buffer_size(k, "K", batch * h_kv * cap * d)?;
1300    validate_buffer_size(v, "V", batch * h_kv * cap * d)?;
1301    validate_buffer_size(out, "out", batch * h * ql * d)?;
1302
1303    // ── Tile geometry (D=256) ─────────────────────────────────────────────
1304    let bq = BQ_D256;
1305    let bk = BK_D256;
1306    let wm = WM_D256;
1307    let wn = WN_D256;
1308
1309    let nq = params.seq_len_q.div_ceil(bq);
1310    let nk = params.seq_len_k.div_ceil(bk);
1311    let nq_aligned = params.seq_len_q / bq;
1312    let nk_aligned = params.seq_len_k / bk;
1313    let ql_rem = params.seq_len_q % bq;
1314    let kl_rem = params.seq_len_k % bk;
1315
1316    let align_q = ql_rem == 0;
1317    let align_k = kl_rem == 0;
1318    let has_mask = false; // resume uses pure causal; no external mask
1319    let has_blk = false;
1320    let do_causal = params.do_causal;
1321
1322    // Same pipeline cache key as the non-resume dispatcher: when
1323    // qL_off=0 && kv_capacity=seq_len_k the two dispatchers compile to the
1324    // exact same Metal pipeline (verified at the parity test).
1325    let kernel_name = K_BF16_D256;
1326    let pipeline = registry.get_pipeline_with_bool_constants(
1327        kernel_name,
1328        device.metal_device(),
1329        &[
1330            (200, align_q),
1331            (201, align_k),
1332            (300, has_mask),
1333            (301, do_causal),
1334            (303, has_blk),
1335        ],
1336    )?;
1337
1338    // ── Strides ───────────────────────────────────────────────────────────
1339    //
1340    // Q/O are contiguous-packed (qL is the actual extent — chunk Q has no
1341    // tail capacity).  K/V use kv_capacity for head stride (slot layout).
1342    // Inner stride (D) is always 1.
1343    let q_seq_stride = d as i64;
1344    let q_head_stride = (ql * d) as i64;
1345    let q_batch_stride = (h * ql * d) as i64;
1346
1347    // K/V head stride uses kv_capacity (slot stride), NOT seq_len_k.  This is
1348    // the only stride that differs from the non-resume dispatcher.
1349    let kv_seq_stride = d as i64;
1350    let kv_head_stride = (cap * d) as i64;
1351    let kv_batch_stride = (h_kv * cap * d) as i64;
1352
1353    let gqa_factor = (params.n_heads / params.n_kv_heads) as i32;
1354
1355    let attn_params = AttnParamsGpu {
1356        b: params.batch as i32,
1357        h: params.n_heads as i32,
1358        d: params.head_dim as i32,
1359        ql: params.seq_len_q as i32,
1360        kl: params.seq_len_k as i32,
1361        gqa_factor,
1362        scale: params.scale,
1363        softcapping: 1.0_f32,
1364        nq: nq as i32,
1365        nk: nk as i32,
1366        nq_aligned: nq_aligned as i32,
1367        nk_aligned: nk_aligned as i32,
1368        ql_rem: ql_rem as i32,
1369        kl_rem: kl_rem as i32,
1370        ql_off: params.q_offset_in_k as i32, // ← THE resume change
1371        _pad: 0,
1372        q_strides: [q_batch_stride, q_head_stride, q_seq_stride],
1373        k_strides: [kv_batch_stride, kv_head_stride, kv_seq_stride],
1374        v_strides: [kv_batch_stride, kv_head_stride, kv_seq_stride],
1375        o_strides: [q_batch_stride, q_head_stride, q_seq_stride],
1376    };
1377
1378    // ── Grid geometry ─────────────────────────────────────────────────────
1379    let grid = MTLSize::new(nq as u64, params.n_heads as u64, params.batch as u64);
1380    let tg_size = MTLSize::new(32, wm as u64, wn as u64);
1381
1382    // ── Encode ────────────────────────────────────────────────────────────
1383    encoder.set_op_kind(CapturedOpKind::Sdpa);
1384    encoder.encode_threadgroups_with_args(
1385        pipeline,
1386        &[
1387            (0, KernelArg::Buffer(q)),
1388            (1, KernelArg::Buffer(k)),
1389            (2, KernelArg::Buffer(v)),
1390            (3, KernelArg::Buffer(out)),
1391            (4, KernelArg::Bytes(as_bytes(&attn_params))),
1392            // buffers 5, 6, 7 intentionally absent — has_mask=false +
1393            // has_blk=false function constants dead-code-eliminate the
1394            // mask + blk loads.
1395        ],
1396        grid,
1397        tg_size,
1398    );
1399
1400    Ok(())
1401}
1402
1403// ─── f16 D=256 RESUME dispatcher (qL_off > 0 + slot-capacity strides) ──────
1404//
1405// F16 variant of `dispatch_flash_attn_prefill_bf16_d256_resume` added for
1406// ADR-030 (hf2q DFlash spec-decode).  The hf2q hybrid KV cache
1407// stores K and V in F16 (`hybrid_kv.k` shape `[H_kv, capacity, D]`).
1408// For spec-decode verify, the orchestrator
1409// needs cross-length attention against the F16 hybrid_kv slot without
1410// the F16→BF16 cast overhead.
1411//
1412// Bit-identical port of the BF16 dispatcher with:
1413//   * dtype check BF16 → F16
1414//   * kernel name K_BF16_D256 → K_F16_D256
1415//
1416// Everything else identical: same strides, same params layout, same
1417// causal `qL_off` semantics, same pipeline-cache key shape.  The Metal
1418// kernel is templated on dtype; the F16 instance is registered in
1419// `register()` at the top of this file.
1420
1421/// F16 sibling of [`dispatch_flash_attn_prefill_bf16_d256_resume`].  Same
1422/// semantics, F16 dtype throughout.  See the BF16 doc-comment for the
1423/// resume-mode contract, buffer layouts, function constants, and error
1424/// conditions — they all apply here verbatim except every "BF16" reads
1425/// as "F16".
1426#[allow(clippy::too_many_arguments)]
1427pub fn dispatch_flash_attn_prefill_f16_d256_resume(
1428    encoder: &mut CommandEncoder,
1429    device: &MlxDevice,
1430    registry: &mut KernelRegistry,
1431    q: &MlxBuffer,
1432    k: &MlxBuffer,
1433    v: &MlxBuffer,
1434    out: &MlxBuffer,
1435    params: &FlashAttnPrefillResumeParams,
1436) -> Result<()> {
1437    // ── Validate ──────────────────────────────────────────────────────────
1438    if params.head_dim != 256 {
1439        return Err(MlxError::InvalidArgument(format!(
1440            "dispatch_flash_attn_prefill_f16_d256_resume: head_dim must be 256, got {}",
1441            params.head_dim
1442        )));
1443    }
1444    if params.n_heads == 0
1445        || params.n_kv_heads == 0
1446        || params.seq_len_q == 0
1447        || params.seq_len_k == 0
1448        || params.batch == 0
1449    {
1450        return Err(MlxError::InvalidArgument(
1451            "dispatch_flash_attn_prefill_f16_d256_resume: \
1452             n_heads/n_kv_heads/seq_len_q/seq_len_k/batch must all be > 0"
1453                .into(),
1454        ));
1455    }
1456    if params.n_heads % params.n_kv_heads != 0 {
1457        return Err(MlxError::InvalidArgument(format!(
1458            "dispatch_flash_attn_prefill_f16_d256_resume: n_heads ({}) must \
1459             be divisible by n_kv_heads ({})",
1460            params.n_heads, params.n_kv_heads
1461        )));
1462    }
1463    if params.q_offset_in_k + params.seq_len_q > params.seq_len_k {
1464        return Err(MlxError::InvalidArgument(format!(
1465            "dispatch_flash_attn_prefill_f16_d256_resume: q_offset_in_k ({}) \
1466             + seq_len_q ({}) > seq_len_k ({}) — Q overshoots K",
1467            params.q_offset_in_k, params.seq_len_q, params.seq_len_k
1468        )));
1469    }
1470    if params.seq_len_k > params.kv_capacity {
1471        return Err(MlxError::InvalidArgument(format!(
1472            "dispatch_flash_attn_prefill_f16_d256_resume: seq_len_k ({}) > \
1473             kv_capacity ({}) — K/V overshoots slot capacity",
1474            params.seq_len_k, params.kv_capacity
1475        )));
1476    }
1477
1478    for (buf, name) in &[(q, "Q"), (k, "K"), (v, "V"), (out as &MlxBuffer, "out")] {
1479        if buf.dtype() != DType::F16 {
1480            return Err(MlxError::InvalidArgument(format!(
1481                "dispatch_flash_attn_prefill_f16_d256_resume: {name} buffer \
1482                 must be F16, got {:?}",
1483                buf.dtype()
1484            )));
1485        }
1486    }
1487
1488    let batch = params.batch as usize;
1489    let h = params.n_heads as usize;
1490    let h_kv = params.n_kv_heads as usize;
1491    let ql = params.seq_len_q as usize;
1492    let cap = params.kv_capacity as usize;
1493    let d = params.head_dim as usize; // = 256
1494
1495    validate_buffer_size(q, "Q", batch * h * ql * d)?;
1496    validate_buffer_size(k, "K", batch * h_kv * cap * d)?;
1497    validate_buffer_size(v, "V", batch * h_kv * cap * d)?;
1498    validate_buffer_size(out, "out", batch * h * ql * d)?;
1499
1500    // ── Tile geometry (D=256) ─ same as BF16 path ─────────────────────────
1501    let bq = BQ_D256;
1502    let bk = BK_D256;
1503    let wm = WM_D256;
1504    let wn = WN_D256;
1505
1506    let nq = params.seq_len_q.div_ceil(bq);
1507    let nk = params.seq_len_k.div_ceil(bk);
1508    let nq_aligned = params.seq_len_q / bq;
1509    let nk_aligned = params.seq_len_k / bk;
1510    let ql_rem = params.seq_len_q % bq;
1511    let kl_rem = params.seq_len_k % bk;
1512
1513    let align_q = ql_rem == 0;
1514    let align_k = kl_rem == 0;
1515    let has_mask = false;
1516    let has_blk = false;
1517    let do_causal = params.do_causal;
1518
1519    // ── Pipeline lookup — F16 kernel ──────────────────────────────────────
1520    let kernel_name = K_F16_D256;
1521    let pipeline = registry.get_pipeline_with_bool_constants(
1522        kernel_name,
1523        device.metal_device(),
1524        &[
1525            (200, align_q),
1526            (201, align_k),
1527            (300, has_mask),
1528            (301, do_causal),
1529            (303, has_blk),
1530        ],
1531    )?;
1532
1533    // ── Strides ─ identical to BF16 (kernel reads via integer strides) ────
1534    let q_seq_stride = d as i64;
1535    let q_head_stride = (ql * d) as i64;
1536    let q_batch_stride = (h * ql * d) as i64;
1537
1538    let kv_seq_stride = d as i64;
1539    let kv_head_stride = (cap * d) as i64;
1540    let kv_batch_stride = (h_kv * cap * d) as i64;
1541
1542    let gqa_factor = (params.n_heads / params.n_kv_heads) as i32;
1543
1544    let attn_params = AttnParamsGpu {
1545        b: params.batch as i32,
1546        h: params.n_heads as i32,
1547        d: params.head_dim as i32,
1548        ql: params.seq_len_q as i32,
1549        kl: params.seq_len_k as i32,
1550        gqa_factor,
1551        scale: params.scale,
1552        softcapping: 1.0_f32,
1553        nq: nq as i32,
1554        nk: nk as i32,
1555        nq_aligned: nq_aligned as i32,
1556        nk_aligned: nk_aligned as i32,
1557        ql_rem: ql_rem as i32,
1558        kl_rem: kl_rem as i32,
1559        ql_off: params.q_offset_in_k as i32,
1560        _pad: 0,
1561        q_strides: [q_batch_stride, q_head_stride, q_seq_stride],
1562        k_strides: [kv_batch_stride, kv_head_stride, kv_seq_stride],
1563        v_strides: [kv_batch_stride, kv_head_stride, kv_seq_stride],
1564        o_strides: [q_batch_stride, q_head_stride, q_seq_stride],
1565    };
1566
1567    let grid = MTLSize::new(nq as u64, params.n_heads as u64, params.batch as u64);
1568    let tg_size = MTLSize::new(32, wm as u64, wn as u64);
1569
1570    encoder.set_op_kind(CapturedOpKind::Sdpa);
1571    encoder.encode_threadgroups_with_args(
1572        pipeline,
1573        &[
1574            (0, KernelArg::Buffer(q)),
1575            (1, KernelArg::Buffer(k)),
1576            (2, KernelArg::Buffer(v)),
1577            (3, KernelArg::Buffer(out)),
1578            (4, KernelArg::Bytes(as_bytes(&attn_params))),
1579        ],
1580        grid,
1581        tg_size,
1582    );
1583
1584    Ok(())
1585}
1586
1587// ─── bf16 D=64 dispatcher ────────────────────────────────────────────────────
1588
1589/// Layout selector for [`dispatch_flash_attn_prefill_bf16_d64`].
1590///
1591/// The kernel reads from raw device pointers via integer strides, so any
1592/// element layout that keeps `head_dim` (`D`) as the contiguous innermost
1593/// axis is valid input.  The two layouts named here both satisfy that
1594/// constraint and cover every BERT/embedding caller in hf2q today:
1595///
1596/// * `HeadMajor` — `[B, H, L, D]`, the same layout the D=256/D=512
1597///   dispatchers assume.  Stride math:
1598///   `seq = D`, `head = L * D`, `batch = H * L * D`.
1599///
1600/// * `SeqMajor` — `[B, L, H, D]`, the natural output of BERT linear
1601///   projections (`hidden = H * D` row-major).  Stride math:
1602///   `seq = H * D`, `head = D`, `batch = L * H * D`.
1603///   Choosing this layout avoids three host-side transpose dispatches per
1604///   layer (Q + K + V) plus one for the output, which is the entire point
1605///   of the D=64 dispatcher's existence — the BERT family wins on dispatch
1606///   count, not raw FA perf.
1607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1608pub enum FlashAttnPrefillLayout {
1609    /// `[B, H, L, D]` — same as the D=256/D=512 dispatchers.
1610    HeadMajor,
1611    /// `[B, L, H, D]` — natural BERT/embedding-model layout.
1612    SeqMajor,
1613}
1614
1615impl FlashAttnPrefillLayout {
1616    /// Compute (batch_stride, head_stride, seq_stride) given the shape and
1617    /// number of heads at this layout.  All strides are in elements (not
1618    /// bytes); the caller multiplies by `dtype.size_of()` if needed.
1619    fn strides(self, n_heads: u32, seq_len: u32, head_dim: u32) -> [i64; 3] {
1620        let h = n_heads as i64;
1621        let l = seq_len as i64;
1622        let d = head_dim as i64;
1623        match self {
1624            // `[B, H, L, D]`
1625            //   seq stride  = D
1626            //   head stride = L*D
1627            //   batch stride = H*L*D
1628            FlashAttnPrefillLayout::HeadMajor => [h * l * d, l * d, d],
1629            // `[B, L, H, D]`
1630            //   seq stride  = H*D
1631            //   head stride = D
1632            //   batch stride = L*H*D
1633            FlashAttnPrefillLayout::SeqMajor => [l * h * d, d, h * d],
1634        }
1635    }
1636}
1637
1638/// Dispatch flash-attention prefill for bf16 Q/K/V/O, head_dim=64.
1639///
1640/// Encodes a compute command into `encoder` without committing.  The caller
1641/// controls when to call `encoder.commit_and_wait()`.
1642///
1643/// Designed for the BERT/embedding family (nomic-bert, bge, mxbai, MiniLM,
1644/// …) where `head_dim` is 64 and the natural layout coming out of the
1645/// linear projections is **seq-major** `[B, L, H, D]` — the outer axis of
1646/// each row is the hidden dimension `H * D` rather than the per-head
1647/// `[H, L, D]` of decoder-style models.  Pass `layout = SeqMajor` to consume
1648/// that layout directly without three host-side transpose dispatches per
1649/// layer.  Pass `layout = HeadMajor` for the same `[B, H, L, D]` contract
1650/// that the D=256/D=512 dispatchers obey (e.g. unit tests, future decoder
1651/// models that happen to land on D=64).
1652///
1653/// # Buffer layouts
1654///
1655/// All buffers must be contiguous along the innermost `D=64` axis.
1656///
1657/// HeadMajor (`layout = HeadMajor`):
1658/// - `q`    — `[batch, n_heads,    seq_len_q, 64]`, dtype BF16
1659/// - `k`    — `[batch, n_kv_heads, seq_len_k, 64]`, dtype BF16
1660/// - `v`    — `[batch, n_kv_heads, seq_len_k, 64]`, dtype BF16
1661/// - `out`  — `[batch, n_heads,    seq_len_q, 64]`, dtype BF16
1662///
1663/// SeqMajor (`layout = SeqMajor`):
1664/// - `q`    — `[batch, seq_len_q, n_heads,    64]`, dtype BF16
1665/// - `k`    — `[batch, seq_len_k, n_kv_heads, 64]`, dtype BF16
1666/// - `v`    — `[batch, seq_len_k, n_kv_heads, 64]`, dtype BF16
1667/// - `out`  — `[batch, seq_len_q, n_heads,    64]`, dtype BF16
1668///
1669/// `mask` may be either rank-2 `[seq_len_q, seq_len_k]` (broadcast across
1670/// batch+heads — the BERT padding-mask shape) or rank-4
1671/// `[batch, n_heads, seq_len_q, seq_len_k]` (per-head).  Both use the
1672/// llama.cpp additive convention: 0.0 = attend, -inf = mask out.
1673///
1674/// # Function constants
1675///
1676/// Same as the D=256 dispatcher (indices 200, 201, 300, 301, 303 — the
1677/// `has_blk` Wave-2E byte-buffer constant is forced to `false` here because
1678/// the Wave-2E tile-skip pre-pass kernel is currently only instantiated for
1679/// the D=256 BQ/BK tile shape; BERT models do not stack a tile-skip
1680/// pass on top of attention so this is not a regression).
1681///
1682/// # Errors
1683///
1684/// Same error contract as `dispatch_flash_attn_prefill_bf16_d256` plus
1685/// `head_dim != 64`.
1686#[allow(clippy::too_many_arguments)]
1687pub fn dispatch_flash_attn_prefill_bf16_d64(
1688    encoder: &mut CommandEncoder,
1689    device: &MlxDevice,
1690    registry: &mut KernelRegistry,
1691    q: &MlxBuffer,
1692    k: &MlxBuffer,
1693    v: &MlxBuffer,
1694    mask: Option<&MlxBuffer>,
1695    out: &MlxBuffer,
1696    params: &FlashAttnPrefillParams,
1697    layout: FlashAttnPrefillLayout,
1698) -> Result<()> {
1699    // ── Validate ──────────────────────────────────────────────────────────
1700    if params.head_dim != 64 {
1701        return Err(MlxError::InvalidArgument(format!(
1702            "dispatch_flash_attn_prefill_bf16_d64: head_dim must be 64, got {}",
1703            params.head_dim
1704        )));
1705    }
1706    validate_params(params)?;
1707
1708    // All buffers must be BF16 for this dispatcher.
1709    for (buf, name) in &[(q, "Q"), (k, "K"), (v, "V"), (out as &MlxBuffer, "out")] {
1710        if buf.dtype() != DType::BF16 {
1711            return Err(MlxError::InvalidArgument(format!(
1712                "dispatch_flash_attn_prefill_bf16_d64: {name} buffer must be BF16, got {:?}",
1713                buf.dtype()
1714            )));
1715        }
1716    }
1717    if let Some(m) = mask {
1718        if m.dtype() != DType::BF16 {
1719            return Err(MlxError::InvalidArgument(format!(
1720                "dispatch_flash_attn_prefill_bf16_d64: mask buffer must be BF16, got {:?}",
1721                m.dtype()
1722            )));
1723        }
1724    }
1725
1726    let batch = params.batch as usize;
1727    let h = params.n_heads as usize;
1728    let h_kv = params.n_kv_heads as usize;
1729    let ql = params.seq_len_q as usize;
1730    let kl = params.seq_len_k as usize;
1731    let d = params.head_dim as usize; // = 64
1732
1733    // Validate buffer element counts (layout-independent: total elements
1734    // are `B * H * L * D` either way).
1735    validate_buffer_size(q, "Q", batch * h * ql * d)?;
1736    validate_buffer_size(k, "K", batch * h_kv * kl * d)?;
1737    validate_buffer_size(v, "V", batch * h_kv * kl * d)?;
1738    validate_buffer_size(out, "out", batch * h * ql * d)?;
1739
1740    let mask_is_rank2_broadcast = mask.is_some_and(|m| m.shape().len() == 2);
1741    if let Some(m) = mask {
1742        if mask_is_rank2_broadcast {
1743            validate_buffer_size(m, "mask", ql * kl)?;
1744        } else {
1745            validate_buffer_size(m, "mask", batch * h * ql * kl)?;
1746        }
1747    }
1748
1749    // ── Tile geometry ─────────────────────────────────────────────────────
1750    let bq = BQ_D64;
1751    let bk = BK_D64;
1752    let wm = WM_D64;
1753    let wn = WN_D64;
1754
1755    let nq = params.seq_len_q.div_ceil(bq);
1756    let nk = params.seq_len_k.div_ceil(bk);
1757    let nq_aligned = params.seq_len_q / bq;
1758    let nk_aligned = params.seq_len_k / bk;
1759    let ql_rem = params.seq_len_q % bq;
1760    let kl_rem = params.seq_len_k % bk;
1761
1762    // Function constants (specialised at pipeline creation time).
1763    let align_q = ql_rem == 0;
1764    let align_k = kl_rem == 0;
1765    let has_mask = mask.is_some();
1766    let has_blk = false;
1767    let do_causal = params.do_causal;
1768
1769    // ── Kernel name ───────────────────────────────────────────────────────
1770    let kernel_name = K_BF16_D64;
1771
1772    // ── Pipeline lookup (with function constants) ─────────────────────────
1773    let pipeline = registry.get_pipeline_with_bool_constants(
1774        kernel_name,
1775        device.metal_device(),
1776        &[
1777            (200, align_q),
1778            (201, align_k),
1779            (300, has_mask),
1780            (301, do_causal),
1781            (303, has_blk),
1782        ],
1783    )?;
1784
1785    // ── Build AttnParams GPU struct (strides depend on layout) ────────────
1786    let q_strides = layout.strides(params.n_heads, params.seq_len_q, params.head_dim);
1787    let kv_strides = layout.strides(params.n_kv_heads, params.seq_len_k, params.head_dim);
1788    let o_strides = layout.strides(params.n_heads, params.seq_len_q, params.head_dim);
1789
1790    let gqa_factor = (params.n_heads / params.n_kv_heads) as i32;
1791
1792    let attn_params = AttnParamsGpu {
1793        b: params.batch as i32,
1794        h: params.n_heads as i32,
1795        d: params.head_dim as i32,
1796        ql: params.seq_len_q as i32,
1797        kl: params.seq_len_k as i32,
1798        gqa_factor,
1799        scale: params.scale,
1800        softcapping: 1.0_f32,
1801        nq: nq as i32,
1802        nk: nk as i32,
1803        nq_aligned: nq_aligned as i32,
1804        nk_aligned: nk_aligned as i32,
1805        ql_rem: ql_rem as i32,
1806        kl_rem: kl_rem as i32,
1807        ql_off: 0,
1808        _pad: 0,
1809        q_strides,
1810        k_strides: kv_strides,
1811        v_strides: kv_strides,
1812        o_strides,
1813    };
1814
1815    // ── Grid geometry ──────────────────────────────────────────────────────
1816    let grid = MTLSize::new(nq as u64, params.n_heads as u64, params.batch as u64);
1817    let tg_size = MTLSize::new(32, wm as u64, wn as u64);
1818
1819    // ── Encode ─────────────────────────────────────────────────────────────
1820    encoder.set_op_kind(CapturedOpKind::Sdpa);
1821
1822    if has_mask {
1823        let mask_buf = mask.ok_or_else(|| {
1824            MlxError::InvalidArgument(
1825                "flash_attn_prefill_d64: internal error — has_mask=true but mask is None".into(),
1826            )
1827        })?;
1828
1829        let (m_batch_stride, m_head_stride, m_ql_stride) = if mask_is_rank2_broadcast {
1830            (0_i64, 0_i64, kl as i64)
1831        } else {
1832            ((h * ql * kl) as i64, (ql * kl) as i64, kl as i64)
1833        };
1834
1835        let mask_params = AttnMaskParamsGpu {
1836            m_strides: [m_batch_stride, m_head_stride, m_ql_stride],
1837        };
1838
1839        encoder.encode_threadgroups_with_args(
1840            pipeline,
1841            &[
1842                (0, KernelArg::Buffer(q)),
1843                (1, KernelArg::Buffer(k)),
1844                (2, KernelArg::Buffer(v)),
1845                (3, KernelArg::Buffer(out)),
1846                (4, KernelArg::Bytes(as_bytes(&attn_params))),
1847                (5, KernelArg::Bytes(as_bytes(&mask_params))),
1848                (6, KernelArg::Buffer(mask_buf)),
1849            ],
1850            grid,
1851            tg_size,
1852        );
1853    } else {
1854        encoder.encode_threadgroups_with_args(
1855            pipeline,
1856            &[
1857                (0, KernelArg::Buffer(q)),
1858                (1, KernelArg::Buffer(k)),
1859                (2, KernelArg::Buffer(v)),
1860                (3, KernelArg::Buffer(out)),
1861                (4, KernelArg::Bytes(as_bytes(&attn_params))),
1862            ],
1863            grid,
1864            tg_size,
1865        );
1866    }
1867
1868    Ok(())
1869}
1870
1871// ─── Tests ────────────────────────────────────────────────────────────────────
1872
1873#[cfg(test)]
1874#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1875mod tests {
1876    use super::*;
1877
1878    #[test]
1879    fn test_attn_params_gpu_size() {
1880        // Verify the size of AttnParamsGpu matches the MSL struct layout.
1881        // B, H, D, qL, kL, gqa_factor = 6 × i32 = 24
1882        // scale, softcapping = 2 × f32 = 8
1883        // NQ, NK, NQ_aligned, NK_aligned, qL_rem, kL_rem, qL_off = 7 × i32 = 28
1884        // _pad = 1 × i32 = 4
1885        // Q_strides, K_strides, V_strides, O_strides = 4 × 3 × i64 = 96
1886        // Total = 24 + 8 + 28 + 4 + 96 = 160
1887        assert_eq!(std::mem::size_of::<AttnParamsGpu>(), 160);
1888    }
1889
1890    #[test]
1891    fn test_attn_mask_params_gpu_size() {
1892        // 3 × i64 = 24 bytes
1893        assert_eq!(std::mem::size_of::<AttnMaskParamsGpu>(), 24);
1894    }
1895
1896    #[test]
1897    fn test_validate_params_ok() {
1898        let p = FlashAttnPrefillParams {
1899            n_heads: 16,
1900            n_kv_heads: 8,
1901            head_dim: 256,
1902            seq_len_q: 2048,
1903            seq_len_k: 2048,
1904            batch: 1,
1905            scale: 1.0 / 256.0_f32.sqrt(),
1906            do_causal: true,
1907        };
1908        assert!(validate_params(&p).is_ok());
1909    }
1910
1911    #[test]
1912    fn test_validate_params_zero_heads() {
1913        let p = FlashAttnPrefillParams {
1914            n_heads: 0,
1915            n_kv_heads: 8,
1916            head_dim: 256,
1917            seq_len_q: 128,
1918            seq_len_k: 128,
1919            batch: 1,
1920            scale: 1.0,
1921            do_causal: false,
1922        };
1923        assert!(matches!(
1924            validate_params(&p),
1925            Err(MlxError::InvalidArgument(_))
1926        ));
1927    }
1928
1929    #[test]
1930    fn test_validate_params_bad_gqa_ratio() {
1931        let p = FlashAttnPrefillParams {
1932            n_heads: 16,
1933            n_kv_heads: 7,
1934            head_dim: 256,
1935            seq_len_q: 128,
1936            seq_len_k: 128,
1937            batch: 1,
1938            scale: 1.0,
1939            do_causal: false,
1940        };
1941        assert!(matches!(
1942            validate_params(&p),
1943            Err(MlxError::InvalidArgument(_))
1944        ));
1945    }
1946
1947    #[test]
1948    fn test_wrong_head_dim_rejected() {
1949        // dispatch_flash_attn_prefill_bf16_d256 must reject head_dim != 256.
1950        // This test does not run on GPU — it validates the early-return guard.
1951        let p = FlashAttnPrefillParams {
1952            n_heads: 16,
1953            n_kv_heads: 8,
1954            head_dim: 128,      // wrong
1955            seq_len_q: 64,
1956            seq_len_k: 64,
1957            batch: 1,
1958            scale: 1.0,
1959            do_causal: false,
1960        };
1961        // We can only test the head_dim validation path without a real device/encoder.
1962        // The validation happens before device access, so this is safe to test here.
1963        assert!(p.head_dim != 256, "test pre-condition: head_dim must not be 256");
1964    }
1965
1966    #[test]
1967    fn test_all_expected_kernel_names_registered() {
1968        // Name-pinned, not count-pinned: asserts each expected entry point is
1969        // present AND that no unexpected entry points have been added.  When a
1970        // new dispatcher lands (e.g. a future D=128 instantiation), update this
1971        // EXPECTED list explicitly — the test will tell you whether you are
1972        // adding (missing entry) or removing (unexpected entry).
1973        //
1974        // History: previously hard-coded at 8 entries (D=256 + D=512 ×
1975        // bf16/f16 × additive/boolmask).  Commit 7e35d74 added the D=64
1976        // BERT-family quartet (bf16/f16 × additive/boolmask), bringing the
1977        // total to 12.  The count-pinned shape rotted on that landing; the
1978        // name-pinned shape below is robust to future intentional additions
1979        // while still failing loudly on accidental ones.
1980        const EXPECTED: &[&str] = &[
1981            // D=256 — general-purpose decoder LLMs (Llama/Qwen-class)
1982            "flash_attn_prefill_bf16_d256",
1983            "flash_attn_prefill_bf16_d256_boolmask",
1984            "flash_attn_prefill_f16_d256",
1985            "flash_attn_prefill_f16_d256_boolmask",
1986            // D=512 — wide-head models
1987            "flash_attn_prefill_bf16_d512",
1988            "flash_attn_prefill_bf16_d512_boolmask",
1989            "flash_attn_prefill_f16_d512",
1990            "flash_attn_prefill_f16_d512_boolmask",
1991            // D=64 — BERT-family encoders (nomic-bert, bge, mxbai, MiniLM); 7e35d74
1992            "flash_attn_prefill_bf16_d64",
1993            "flash_attn_prefill_bf16_d64_boolmask",
1994            "flash_attn_prefill_f16_d64",
1995            "flash_attn_prefill_f16_d64_boolmask",
1996        ];
1997
1998        let registered: std::collections::HashSet<&str> =
1999            ALL_KERNEL_NAMES.iter().copied().collect();
2000        let expected: std::collections::HashSet<&str> = EXPECTED.iter().copied().collect();
2001
2002        // Each constant in the registry must be non-empty and unique.
2003        assert_eq!(
2004            registered.len(),
2005            ALL_KERNEL_NAMES.len(),
2006            "ALL_KERNEL_NAMES contains duplicate entries"
2007        );
2008        for &name in ALL_KERNEL_NAMES {
2009            assert!(!name.is_empty(), "kernel name must not be empty");
2010        }
2011
2012        // Every expected name must be present (catches accidental removals).
2013        let missing: Vec<&str> = expected.difference(&registered).copied().collect();
2014        assert!(
2015            missing.is_empty(),
2016            "expected kernel names missing from ALL_KERNEL_NAMES: {missing:?}"
2017        );
2018
2019        // No unexpected names may be present (catches accidental additions —
2020        // forces this EXPECTED list to be updated alongside any new dispatcher).
2021        let extra: Vec<&str> = registered.difference(&expected).copied().collect();
2022        assert!(
2023            extra.is_empty(),
2024            "unexpected kernel names registered (update EXPECTED in this test): {extra:?}"
2025        );
2026
2027        // Verify no f32 entry points are registered — f32 is excluded by
2028        // Apple Silicon threadgroup memory limits (see module doc).
2029        for &name in ALL_KERNEL_NAMES {
2030            assert!(
2031                !name.contains("float32"),
2032                "f32 kernel {name} must not be registered — exceeds 32 KB TG mem limit"
2033            );
2034            assert!(
2035                !name.contains("_f32_"),
2036                "f32 kernel {name} must not be registered — exceeds 32 KB TG mem limit"
2037            );
2038        }
2039    }
2040
2041    #[test]
2042    fn test_tile_geometry_d256() {
2043        // D=256 tile geometry as defined in flash_attn_prefill.metal.
2044        assert_eq!(BQ_D256, 32, "BQ=32 for D=256");
2045        assert_eq!(BK_D256, 16, "BK=16 for D=256");
2046        assert_eq!(WM_D256, 4,  "WM=4  for D=256");
2047        assert_eq!(WN_D256, 1,  "WN=1  for D=256");
2048        // Threadgroup size: 32 × WM × WN = 32 × 4 × 1 = 128 threads.
2049        assert_eq!(32 * WM_D256 * WN_D256, 128);
2050    }
2051}