Skip to main content

mlx_native/ops/
flash_attn_vec.rs

1//! Flash attention vector kernel dispatch — SIMD-vectorized decode-path SDPA.
2//!
3//! Ported from llama.cpp's `flash_attn_ext_vec` kernel. This replaces the naive
4//! SDPA kernel with a workgroup-parallel implementation that splits the KV cache
5//! across `nwg` workgroups, each computing partial softmax results, then a
6//! reduce kernel combines them.
7//!
8//! This kernel is optimized for the decode path (seq_len=1) with F32 Q/K/V.
9
10use metal::MTLSize;
11
12use crate::buffer::MlxBuffer;
13use crate::device::MlxDevice;
14use crate::encoder::{as_bytes, CapturedOpKind, CommandEncoder, KernelArg};
15use crate::error::{MlxError, Result};
16use crate::kernel_registry::KernelRegistry;
17use crate::DType;
18
19/// MSL source for the flash attention vector kernel (embedded at compile time).
20pub static FLASH_ATTN_VEC_SHADER_SOURCE: &str =
21    include_str!("../shaders/flash_attn_vec.metal");
22
23/// Register flash attention vector shader source with the given kernel registry.
24pub fn register(registry: &mut KernelRegistry) {
25    registry.register_source("flash_attn_vec_dk256", FLASH_ATTN_VEC_SHADER_SOURCE);
26    registry.register_source("flash_attn_vec_dk512", FLASH_ATTN_VEC_SHADER_SOURCE);
27    registry.register_source("flash_attn_vec_reduce_dk128", FLASH_ATTN_VEC_SHADER_SOURCE);
28    registry.register_source("flash_attn_vec_reduce_dk256", FLASH_ATTN_VEC_SHADER_SOURCE);
29    registry.register_source("flash_attn_vec_reduce_dk512", FLASH_ATTN_VEC_SHADER_SOURCE);
30    // F16 KV variants (Phase 4a)
31    registry.register_source("flash_attn_vec_f16kv_dk256", FLASH_ATTN_VEC_SHADER_SOURCE);
32    registry.register_source("flash_attn_vec_f16kv_dk512", FLASH_ATTN_VEC_SHADER_SOURCE);
33}
34
35/// Parameters for the flash attention vector kernel.
36#[derive(Debug, Clone, Copy)]
37pub struct FlashAttnVecParams {
38    /// Number of query attention heads.
39    pub num_heads: u32,
40    /// Number of key/value attention heads (GQA: may be < num_heads).
41    pub num_kv_heads: u32,
42    /// Dimension of each attention head (256 or 512).
43    pub head_dim: u32,
44    /// Current KV sequence length (number of valid positions).
45    pub kv_seq_len: u32,
46    /// KV cache capacity (stride between KV heads in positions).
47    pub kv_capacity: u32,
48    /// Attention score scaling factor (e.g. 1/sqrt(head_dim) or 1.0).
49    pub scale: f32,
50    /// Mask type: 0=none, 1=causal, 2=sliding_window.
51    pub mask_type: u32,
52    /// Sliding window size (only used when mask_type == 2).
53    pub sliding_window: u32,
54    /// Logit softcapping (0 = disabled).
55    pub softcap: f32,
56    /// ADR-034 task #89 (2026-05-21) — number of queries to process
57    /// per dispatch. Default 1 (decode). For spec-decode verify with
58    /// `cur_len > 0` + `seq_len in [2, 8]`, call with `q_seq_len =
59    /// seq_len`. The shader dispatches `q_seq_len` threadgroups in
60    /// `grid.x`; each handles one (query, head) pair with causal mask
61    /// `abs_pos = kv_seq_len - q_seq_len + iq1`. Peer-code reference:
62    /// llama.cpp's `kernel_flash_attn_ext_vec` (NQPSG=1, ne01
63    /// threadgroups in qL dim).
64    pub q_seq_len: u32,
65}
66
67impl FlashAttnVecParams {
68    /// Default `q_seq_len` for decode (single query). Use for all
69    /// existing call sites; spec-decode verify sets `q_seq_len > 1`.
70    pub const DEFAULT_Q_SEQ_LEN: u32 = 1;
71}
72
73/// GPU-side parameter struct. Must match the MSL `FlashAttnVecParams` exactly.
74#[repr(C)]
75#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
76struct FlashAttnVecParamsGpu {
77    n_heads: u32,
78    n_kv_heads: u32,
79    head_dim: u32,
80    kv_seq_len: u32,
81    kv_capacity: u32,
82    scale: f32,
83    mask_type: u32,
84    sliding_window: u32,
85    softcap: f32,
86    nwg: u32,
87    /// ADR-034 task #89 (2026-05-21) — see FlashAttnVecParams::q_seq_len.
88    q_l: u32,
89}
90
91/// GPU-side reduce params. Must match MSL `FlashAttnVecReduceParams`.
92#[repr(C)]
93#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
94struct FlashAttnVecReduceParamsGpu {
95    nrows: u32,
96}
97
98/// Number of workgroups to split the KV cache across.
99/// llama.cpp uses 32 as default. Must be <= 32 (one per SIMD lane in reduce).
100const NWG: u32 = 32;
101
102/// Validate flash attention parameters.
103fn validate_params(params: &FlashAttnVecParams) -> Result<()> {
104    if params.head_dim != 256 && params.head_dim != 512 {
105        return Err(MlxError::InvalidArgument(format!(
106            "flash_attn_vec: head_dim must be 256 or 512, got {}",
107            params.head_dim
108        )));
109    }
110    if params.num_heads == 0 || params.num_kv_heads == 0 {
111        return Err(MlxError::InvalidArgument(
112            "flash_attn_vec: num_heads and num_kv_heads must be > 0".into(),
113        ));
114    }
115    if params.num_heads % params.num_kv_heads != 0 {
116        return Err(MlxError::InvalidArgument(format!(
117            "flash_attn_vec: num_heads ({}) must be divisible by num_kv_heads ({})",
118            params.num_heads, params.num_kv_heads
119        )));
120    }
121    if params.kv_seq_len == 0 {
122        return Err(MlxError::InvalidArgument(
123            "flash_attn_vec: kv_seq_len must be > 0".into(),
124        ));
125    }
126    if params.kv_capacity < params.kv_seq_len {
127        return Err(MlxError::InvalidArgument(format!(
128            "flash_attn_vec: kv_capacity ({}) must be >= kv_seq_len ({})",
129            params.kv_capacity, params.kv_seq_len
130        )));
131    }
132    // ADR-034 task #89: q_seq_len >= 1 invariant. The shader uses
133    // `abs_pos = kv_seq_len - q_seq_len + iq1`, so q_seq_len > kv_seq_len
134    // would underflow the unsigned subtraction.
135    if params.q_seq_len == 0 {
136        return Err(MlxError::InvalidArgument(
137            "flash_attn_vec: q_seq_len must be > 0".into(),
138        ));
139    }
140    if params.q_seq_len > params.kv_seq_len {
141        return Err(MlxError::InvalidArgument(format!(
142            "flash_attn_vec: q_seq_len ({}) must be <= kv_seq_len ({})",
143            params.q_seq_len, params.kv_seq_len
144        )));
145    }
146    Ok(())
147}
148
149/// Dispatch flash attention vector kernel on the GPU.
150///
151/// This dispatches two Metal compute passes:
152/// 1. The main kernel with NWG workgroups per head computing partial results
153/// 2. The reduce kernel combining results from all workgroups
154///
155/// # Arguments
156///
157/// * `encoder`  — Command encoder to record dispatches into.
158/// * `registry` — Kernel registry for pipeline lookup/compilation.
159/// * `device`   — Metal device for buffer allocation.
160/// * `q`        — Query buffer `[num_heads, 1, head_dim]`, F32.
161/// * `k`        — Key buffer `[num_kv_heads, kv_capacity, head_dim]`, F32.
162/// * `v`        — Value buffer `[num_kv_heads, kv_capacity, head_dim]`, F32.
163/// * `output`   — Output buffer `[num_heads, 1, head_dim]`, F32, pre-allocated.
164/// * `tmp`      — Temporary buffer for workgroup partial results, pre-allocated.
165///                Size: `num_heads * nwg * (head_dim + 2) * sizeof(f32)` bytes.
166/// * `params`   — Flash attention parameters.
167pub fn flash_attn_vec(
168    encoder: &mut CommandEncoder,
169    registry: &mut KernelRegistry,
170    device: &MlxDevice,
171    q: &MlxBuffer,
172    k: &MlxBuffer,
173    v: &MlxBuffer,
174    output: &MlxBuffer,
175    tmp: &MlxBuffer,
176    params: &FlashAttnVecParams,
177) -> Result<()> {
178    validate_params(params)?;
179
180    let head_dim = params.head_dim;
181    let nwg = NWG;
182
183    // Build GPU params.
184    let gpu_params = FlashAttnVecParamsGpu {
185        n_heads: params.num_heads,
186        n_kv_heads: params.num_kv_heads,
187        head_dim: params.head_dim,
188        kv_seq_len: params.kv_seq_len,
189        kv_capacity: params.kv_capacity,
190        scale: params.scale,
191        mask_type: params.mask_type,
192        sliding_window: params.sliding_window,
193        softcap: params.softcap,
194        nwg,
195        q_l: params.q_seq_len,
196    };
197    // Select kernel by head dimension and KV dtype.
198    // F16 KV: K/V buffers are half-precision, halving bandwidth (Phase 4a).
199    let kv_is_f16 = k.dtype() == DType::F16;
200    let kernel_name = match (head_dim, kv_is_f16) {
201        (256, false) => "flash_attn_vec_dk256",
202        (512, false) => "flash_attn_vec_dk512",
203        (256, true)  => "flash_attn_vec_f16kv_dk256",
204        (512, true)  => "flash_attn_vec_f16kv_dk512",
205        _ => unreachable!(), // validated above
206    };
207    let pipeline = registry.get_pipeline(kernel_name, device.metal_device())?;
208
209    // Shared memory size.
210    // Layout: PK halfs (Q) + SH halfs (scratch) + 2*PV halfs (output as float4)
211    // PK = PAD2(head_dim, 128), PV = PAD2(head_dim, 128)
212    // SH = 4 * C = 128 halfs
213    let pk = pad2(head_dim as usize, 128);
214    let pv = pad2(head_dim as usize, 128);
215    let sh = 4 * 32; // 4 * C = 128 halfs
216    let shmem_halfs = pk + sh + 2 * pv;
217    let shmem_bytes = shmem_halfs * 2; // 2 bytes per half
218
219    // Tag for the reorder pass (Phase 4e.3): SDPA is NOT reorderable.
220    encoder.set_op_kind(CapturedOpKind::Sdpa);
221
222    // Dispatch main kernel.
223    // Grid: (q_seq_len, num_heads, nwg)
224    // ADR-034 task #89 (2026-05-21) — q_seq_len = 1 (decode) keeps the
225    // pre-change behavior exactly (single threadgroup per head, single
226    // query at position kv_seq_len-1). q_seq_len > 1 (spec-decode
227    // verify) dispatches one threadgroup per (query, head) pair; each
228    // computes attention for its own query at its own causal position.
229    let threadgroups = MTLSize::new(
230        params.q_seq_len as u64,
231        params.num_heads as u64,
232        nwg as u64,
233    );
234    let threadgroup_size = MTLSize::new(32, 1, 1); // 1 simdgroup of 32 threads
235
236    // Pass params as inline bytes — no Metal buffer allocation.
237    // This eliminates 1 [MTLDevice newBufferWithLength:] per SDPA call (30/token).
238    encoder.encode_threadgroups_with_args_and_shared(
239        pipeline,
240        &[
241            (0, KernelArg::Bytes(as_bytes(&gpu_params))),
242            (1, KernelArg::Buffer(q)),
243            (2, KernelArg::Buffer(k)),
244            (3, KernelArg::Buffer(v)),
245            (4, KernelArg::Buffer(tmp)),
246        ],
247        &[(0, shmem_bytes as u64)],
248        threadgroups,
249        threadgroup_size,
250    );
251
252    // --- Reduce kernel ---
253    // Only needed when NWG > 1.
254    // Barrier: reduce reads `tmp` written by the main dispatch above.
255    // With MTLDispatchTypeConcurrent, both dispatches would run simultaneously
256    // without this barrier, causing the reduce to read stale/partial `tmp` data.
257    if nwg > 1 {
258        encoder.memory_barrier();
259        // ADR-034 task #89 (2026-05-21) — nrows = num_heads * q_seq_len.
260        // At q_seq_len=1 this is byte-identical to pre-task-#89 (was just
261        // num_heads). At q_seq_len>1, the reduce dispatches one
262        // threadgroup per (query, head) pair, matching the main kernel's
263        // partial-output layout `dst[rid * DV4 * NWG + ...]` where
264        // rid in [0, num_heads * q_seq_len).
265        let total_rows = params.num_heads * params.q_seq_len;
266        let reduce_params = FlashAttnVecReduceParamsGpu {
267            nrows: total_rows,
268        };
269
270        let reduce_kernel = match head_dim {
271            256 => "flash_attn_vec_reduce_dk256",
272            512 => "flash_attn_vec_reduce_dk512",
273            _ => unreachable!(),
274        };
275        let reduce_pipeline =
276            registry.get_pipeline(reduce_kernel, device.metal_device())?;
277
278        // Grid: (num_heads * q_seq_len, 1, 1), Threadgroup: (32*NWG, 1, 1)
279        let reduce_tg = MTLSize::new(total_rows as u64, 1, 1);
280        let reduce_tg_size = MTLSize::new(32 * nwg as u64, 1, 1);
281
282        // Annotate reads/writes for the capture graph so the reorder pass
283        // (Phase 4e.3) can track data dependencies on the reduce kernel.
284        {
285            let read_ranges = vec![
286                {
287                    let s = tmp.contents_ptr() as usize;
288                    (s, s + tmp.byte_len())
289                },
290            ];
291            let write_ranges = vec![
292                {
293                    let s = output.contents_ptr() as usize;
294                    (s, s + output.byte_len())
295                },
296            ];
297            encoder.set_pending_buffer_ranges(read_ranges, write_ranges);
298        }
299
300        // Pass params as inline bytes — no Metal buffer allocation.
301        // This eliminates 2 [MTLDevice newBufferWithLength:] per SDPA call (60/token).
302        encoder.encode_threadgroups_with_args(
303            reduce_pipeline,
304            &[
305                (0, KernelArg::Bytes(as_bytes(&reduce_params))),
306                (1, KernelArg::Buffer(tmp)),
307                (2, KernelArg::Buffer(output)),
308                (3, KernelArg::Bytes(as_bytes(&nwg))),
309            ],
310            reduce_tg,
311            reduce_tg_size,
312        );
313    }
314
315    Ok(())
316}
317
318/// Compute the size in bytes of the temporary buffer needed for flash_attn_vec.
319///
320/// The temp buffer stores partial results from NWG workgroups:
321/// - `nrows * head_dim * NWG` floats for the partial output vectors
322/// - `nrows * 2 * NWG` floats for the S and M values
323///
324/// ADR-034 task #89 (2026-05-21) — `nrows = num_heads * q_seq_len`. At
325/// the default `q_seq_len = 1` this returns the same value as
326/// pre-task-#89 (`num_heads * NWG * (head_dim + 2) * 4`). For
327/// `q_seq_len > 1` the buffer scales linearly with qL.
328pub fn tmp_buffer_bytes(num_heads: u32, head_dim: u32) -> usize {
329    tmp_buffer_bytes_with_qL(num_heads, head_dim, 1)
330}
331
332/// ADR-034 task #89 (2026-05-21) — qL-aware temp buffer size. Callers
333/// that dispatch with `q_seq_len > 1` (spec-decode verify) must use
334/// this to size the tmp buffer correctly. `tmp_buffer_bytes(...)` is
335/// kept as the qL=1 wrapper for backward compatibility.
336#[allow(non_snake_case)]
337pub fn tmp_buffer_bytes_with_qL(num_heads: u32, head_dim: u32, q_seq_len: u32) -> usize {
338    let nrows = (num_heads as usize) * (q_seq_len as usize);
339    let nwg = NWG as usize;
340    let dv = head_dim as usize;
341    // DV * NWG floats per row for output, plus 2 * NWG floats per row for S/M.
342    (nrows * nwg * (dv + 2)) * std::mem::size_of::<f32>()
343}
344
345/// Pad x up to next multiple of n (n must be power of 2).
346fn pad2(x: usize, n: usize) -> usize {
347    (x + n - 1) & !(n - 1)
348}
349
350#[cfg(test)]
351#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn test_validate_params_ok() {
357        let p = FlashAttnVecParams {
358            num_heads: 16,
359            num_kv_heads: 8,
360            head_dim: 256,
361            kv_seq_len: 100,
362            kv_capacity: 1024,
363            scale: 1.0,
364            mask_type: 1,
365            sliding_window: 0,
366            softcap: 0.0,
367            q_seq_len: FlashAttnVecParams::DEFAULT_Q_SEQ_LEN,
368        };
369        assert!(validate_params(&p).is_ok());
370    }
371
372    #[test]
373    fn test_validate_params_bad_head_dim() {
374        let p = FlashAttnVecParams {
375            num_heads: 16,
376            num_kv_heads: 8,
377            head_dim: 128,
378            kv_seq_len: 100,
379            kv_capacity: 1024,
380            scale: 1.0,
381            mask_type: 0,
382            sliding_window: 0,
383            softcap: 0.0,
384            q_seq_len: FlashAttnVecParams::DEFAULT_Q_SEQ_LEN,
385        };
386        assert!(validate_params(&p).is_err());
387    }
388
389    #[test]
390    fn adr_034_task_89_q_seq_len_zero_rejected_2026_05_21() {
391        let p = FlashAttnVecParams {
392            num_heads: 16,
393            num_kv_heads: 8,
394            head_dim: 256,
395            kv_seq_len: 100,
396            kv_capacity: 1024,
397            scale: 1.0,
398            mask_type: 1,
399            sliding_window: 0,
400            softcap: 0.0,
401            q_seq_len: 0,
402        };
403        let err = validate_params(&p).unwrap_err().to_string();
404        assert!(err.contains("q_seq_len must be > 0"), "got: {err}");
405    }
406
407    #[test]
408    fn adr_034_task_89_q_seq_len_exceeds_kv_rejected_2026_05_21() {
409        let p = FlashAttnVecParams {
410            num_heads: 16,
411            num_kv_heads: 8,
412            head_dim: 256,
413            kv_seq_len: 4,
414            kv_capacity: 1024,
415            scale: 1.0,
416            mask_type: 1,
417            sliding_window: 0,
418            softcap: 0.0,
419            q_seq_len: 8, // > kv_seq_len, would underflow abs_pos
420        };
421        let err = validate_params(&p).unwrap_err().to_string();
422        assert!(
423            err.contains("must be <= kv_seq_len"),
424            "got: {err}"
425        );
426    }
427
428    #[test]
429    fn test_gpu_params_layout() {
430        assert_eq!(
431            std::mem::size_of::<FlashAttnVecParamsGpu>(),
432            // ADR-034 task #89 (2026-05-21): added q_l field; was 40
433            // bytes (10 fields), now 44 bytes (11 fields).
434            44, // 11 x u32/f32 = 44 bytes
435        );
436    }
437
438    #[test]
439    fn test_tmp_buffer_size() {
440        // 16 heads, dk256, nwg=32
441        let bytes = tmp_buffer_bytes(16, 256);
442        // 16 * 32 * (256 + 2) * 4 = 16 * 32 * 258 * 4 = 528384
443        assert_eq!(bytes, 16 * 32 * 258 * 4);
444    }
445}