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}