Skip to main content

mlx_native/ops/
tree_attention.rs

1//! Tree-attention vector kernel dispatch — ADR-037 Phase E1.1 (2026-05-22).
2//!
3//! Variant of `flash_attn_vec` that consumes an *explicit* mask buffer
4//! shaped `[q_l, mask_stride]` (row-major float) instead of computing
5//! an implicit causal mask from absolute position. Tree topologies for
6//! EAGLE-3 + dynamic tree speculative decoding (chains, fixed trees,
7//! dynamic asymmetric trees) all reduce to this representation.
8//!
9//! Byte-identity contract: when the explicit mask matches the implicit
10//! causal mask that `flash_attn_vec` would compute for the same params,
11//! this kernel's output is byte-identical to `flash_attn_vec`'s output.
12//! Validated by `tests/test_tree_attention_e1_1_parity.rs`.
13//!
14//! Reuses `flash_attn_vec_reduce` for the second pass — output layout
15//! is identical.
16
17use metal::MTLSize;
18
19use crate::buffer::MlxBuffer;
20use crate::device::MlxDevice;
21use crate::encoder::{as_bytes, CapturedOpKind, CommandEncoder, KernelArg};
22use crate::error::{MlxError, Result};
23use crate::kernel_registry::KernelRegistry;
24use crate::DType;
25
26/// MSL source for the tree-attention kernel (embedded at compile time).
27pub static TREE_ATTENTION_SHADER_SOURCE: &str =
28    include_str!("../shaders/tree_attention.metal");
29
30/// Register tree-attention shader source with the given kernel registry.
31///
32/// **Dependency**: this helper registers only the tree-attention main
33/// kernels. The reduce pass (`flash_attn_vec_reduce_*`) is reused
34/// verbatim from `flash_attn_vec`, so callers that use a minimal
35/// registry must ALSO call `flash_attn_vec::register(registry)`. The
36/// default `KernelRegistry::new()` pre-registers both — the explicit
37/// `register()` helpers exist for tooling that builds incremental
38/// registries.
39pub fn register(registry: &mut KernelRegistry) {
40    registry.register_source("tree_attention_dk128", TREE_ATTENTION_SHADER_SOURCE);
41    registry.register_source("tree_attention_dk256", TREE_ATTENTION_SHADER_SOURCE);
42    registry.register_source("tree_attention_dk512", TREE_ATTENTION_SHADER_SOURCE);
43    registry.register_source("tree_attention_f16kv_dk128", TREE_ATTENTION_SHADER_SOURCE);
44    registry.register_source("tree_attention_f16kv_dk256", TREE_ATTENTION_SHADER_SOURCE);
45    registry.register_source("tree_attention_f16kv_dk512", TREE_ATTENTION_SHADER_SOURCE);
46}
47
48/// Sentinel value for masked positions in the tree-mask buffer. Matches
49/// the flash_attn_vec implicit-causal sentinel (-MAXHALF) so that
50/// chunks fully composed of masked cells take the same early-exit
51/// branch in the shader.
52pub const TREE_MASK_MASKED: f32 = -65504.0;
53
54/// Sentinel value for attended positions.
55pub const TREE_MASK_ATTENDED: f32 = 0.0;
56
57/// Parameters for the tree-attention kernel.
58#[derive(Debug, Clone, Copy)]
59pub struct TreeAttentionParams {
60    pub num_heads: u32,
61    pub num_kv_heads: u32,
62    pub head_dim: u32,
63    pub kv_seq_len: u32,
64    pub kv_capacity: u32,
65    pub scale: f32,
66    /// Number of tree nodes (queries) to verify in this dispatch.
67    pub q_seq_len: u32,
68    /// Stride between rows of the mask buffer. Must be >= `kv_seq_len`.
69    /// Callers usually pass `mask_stride == kv_seq_len` for tight
70    /// packing; higher values are legal but waste memory.
71    pub mask_stride: u32,
72}
73
74/// GPU-side parameter struct. Must match the MSL `TreeAttentionParams`.
75#[repr(C)]
76#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
77struct TreeAttentionParamsGpu {
78    n_heads: u32,
79    n_kv_heads: u32,
80    head_dim: u32,
81    kv_seq_len: u32,
82    kv_capacity: u32,
83    scale: f32,
84    nwg: u32,
85    q_l: u32,
86    mask_stride: u32,
87}
88
89#[repr(C)]
90#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
91struct FlashAttnVecReduceParamsGpu {
92    nrows: u32,
93}
94
95/// Number of workgroups (matches `flash_attn_vec::NWG`). The reduce
96/// kernel assumes one (host-)thread per NWG and NWG <= 32.
97const NWG: u32 = 32;
98
99/// Codex /cfa Phase E1 gate (2026-05-22): defensive buffer dtype +
100/// byte-length validation. Caught:
101///   - K/V dtype mismatch (would read V with wrong stride)
102///   - Q/mask/output/tmp dtype slips (must all be F32)
103///   - Undersized buffers (GPU OOB read/write)
104/// Without these checks the kernel reads/writes purely from params,
105/// so undersized buffers escape to GPU.
106#[allow(clippy::too_many_arguments)]
107fn validate_buffers(
108    q: &MlxBuffer,
109    k: &MlxBuffer,
110    v: &MlxBuffer,
111    tree_mask: &MlxBuffer,
112    output: &MlxBuffer,
113    tmp: &MlxBuffer,
114    params: &TreeAttentionParams,
115) -> Result<()> {
116    // --- dtype contract ---
117    if q.dtype() != DType::F32 {
118        return Err(MlxError::InvalidArgument(format!(
119            "tree_attention: q dtype must be F32, got {:?}",
120            q.dtype()
121        )));
122    }
123    if k.dtype() != v.dtype() {
124        return Err(MlxError::InvalidArgument(format!(
125            "tree_attention: k.dtype ({:?}) must match v.dtype ({:?})",
126            k.dtype(),
127            v.dtype()
128        )));
129    }
130    if !matches!(k.dtype(), DType::F32 | DType::F16) {
131        return Err(MlxError::InvalidArgument(format!(
132            "tree_attention: k/v dtype must be F32 or F16, got {:?}",
133            k.dtype()
134        )));
135    }
136    if tree_mask.dtype() != DType::F32 {
137        return Err(MlxError::InvalidArgument(format!(
138            "tree_attention: tree_mask dtype must be F32, got {:?}",
139            tree_mask.dtype()
140        )));
141    }
142    if output.dtype() != DType::F32 {
143        return Err(MlxError::InvalidArgument(format!(
144            "tree_attention: output dtype must be F32, got {:?}",
145            output.dtype()
146        )));
147    }
148    if tmp.dtype() != DType::F32 {
149        return Err(MlxError::InvalidArgument(format!(
150            "tree_attention: tmp dtype must be F32, got {:?}",
151            tmp.dtype()
152        )));
153    }
154
155    // --- byte-length contract ---
156    let head_dim = params.head_dim as usize;
157    let num_heads = params.num_heads as usize;
158    let num_kv_heads = params.num_kv_heads as usize;
159    let q_seq_len = params.q_seq_len as usize;
160    let kv_capacity = params.kv_capacity as usize;
161    let mask_stride = params.mask_stride as usize;
162    let kv_dtype_bytes: usize = match k.dtype() {
163        DType::F32 => 4,
164        DType::F16 => 2,
165        _ => unreachable!("validated above"),
166    };
167
168    // ADR-037 Phase E4b.6 codex /cfa (2026-05-22) — Critical: use
169    // checked_mul on all required-byte computations. Plain usize
170    // multiply of u32-sourced values can wrap on adversarial/corrupted
171    // params, allowing undersized buffers to pass validation while
172    // the kernel indexes using the original large params.
173    let mul = |a: usize, b: usize, ctx: &str| -> Result<usize> {
174        a.checked_mul(b).ok_or_else(|| {
175            MlxError::InvalidArgument(format!(
176                "tree_attention: {} multiplication overflows usize",
177                ctx
178            ))
179        })
180    };
181    let req_q = {
182        let a = mul(num_heads, q_seq_len, "num_heads*q_seq_len")?;
183        let b = mul(a, head_dim, "num_heads*q_seq_len*head_dim")?;
184        mul(b, 4, "req_q")?
185    };
186    let req_kv = {
187        let a = mul(num_kv_heads, kv_capacity, "num_kv_heads*kv_capacity")?;
188        let b = mul(a, head_dim, "num_kv_heads*kv_capacity*head_dim")?;
189        mul(b, kv_dtype_bytes, "req_kv")?
190    };
191    let req_mask = {
192        let a = mul(q_seq_len, mask_stride, "q_seq_len*mask_stride")?;
193        mul(a, 4, "req_mask")?
194    };
195    let req_output = req_q; // same shape as Q output
196    let req_tmp =
197        tmp_buffer_bytes(params.num_heads, params.head_dim, params.q_seq_len);
198
199    if q.byte_len() < req_q {
200        return Err(MlxError::InvalidArgument(format!(
201            "tree_attention: q buffer too small: have {} bytes, need >= {}",
202            q.byte_len(),
203            req_q
204        )));
205    }
206    if k.byte_len() < req_kv {
207        return Err(MlxError::InvalidArgument(format!(
208            "tree_attention: k buffer too small: have {} bytes, need >= {}",
209            k.byte_len(),
210            req_kv
211        )));
212    }
213    if v.byte_len() < req_kv {
214        return Err(MlxError::InvalidArgument(format!(
215            "tree_attention: v buffer too small: have {} bytes, need >= {}",
216            v.byte_len(),
217            req_kv
218        )));
219    }
220    if tree_mask.byte_len() < req_mask {
221        return Err(MlxError::InvalidArgument(format!(
222            "tree_attention: tree_mask buffer too small: have {} bytes, need >= {}",
223            tree_mask.byte_len(),
224            req_mask
225        )));
226    }
227    if output.byte_len() < req_output {
228        return Err(MlxError::InvalidArgument(format!(
229            "tree_attention: output buffer too small: have {} bytes, need >= {}",
230            output.byte_len(),
231            req_output
232        )));
233    }
234    if tmp.byte_len() < req_tmp {
235        return Err(MlxError::InvalidArgument(format!(
236            "tree_attention: tmp buffer too small: have {} bytes, need >= {}",
237            tmp.byte_len(),
238            req_tmp
239        )));
240    }
241
242    Ok(())
243}
244
245fn validate_params(params: &TreeAttentionParams) -> Result<()> {
246    // ADR-037 Phase E4b.6 (2026-05-22): added 128 for Qwen 3.6 27B.
247    // Template `static_assert(DK % 32 == 0)` satisfied for all three.
248    if params.head_dim != 128 && params.head_dim != 256 && params.head_dim != 512 {
249        return Err(MlxError::InvalidArgument(format!(
250            "tree_attention: head_dim must be 128, 256, or 512, got {}",
251            params.head_dim
252        )));
253    }
254    if params.num_heads == 0 || params.num_kv_heads == 0 {
255        return Err(MlxError::InvalidArgument(
256            "tree_attention: num_heads and num_kv_heads must be > 0".into(),
257        ));
258    }
259    if params.num_heads % params.num_kv_heads != 0 {
260        return Err(MlxError::InvalidArgument(format!(
261            "tree_attention: num_heads ({}) must be divisible by num_kv_heads ({})",
262            params.num_heads, params.num_kv_heads
263        )));
264    }
265    if params.kv_seq_len == 0 {
266        return Err(MlxError::InvalidArgument(
267            "tree_attention: kv_seq_len must be > 0".into(),
268        ));
269    }
270    if params.kv_capacity < params.kv_seq_len {
271        return Err(MlxError::InvalidArgument(format!(
272            "tree_attention: kv_capacity ({}) must be >= kv_seq_len ({})",
273            params.kv_capacity, params.kv_seq_len
274        )));
275    }
276    if params.q_seq_len == 0 {
277        return Err(MlxError::InvalidArgument(
278            "tree_attention: q_seq_len must be > 0".into(),
279        ));
280    }
281    if params.mask_stride < params.kv_seq_len {
282        return Err(MlxError::InvalidArgument(format!(
283            "tree_attention: mask_stride ({}) must be >= kv_seq_len ({})",
284            params.mask_stride, params.kv_seq_len
285        )));
286    }
287    Ok(())
288}
289
290/// Dispatch tree-attention kernel on the GPU.
291///
292/// Two Metal compute passes:
293/// 1. Main kernel with NWG workgroups per (query, head) computing partial results
294/// 2. `flash_attn_vec_reduce` (reused — same output layout) combining results
295///
296/// # Buffers
297/// * `q` — `[num_heads, q_seq_len, head_dim]`, F32.
298/// * `k` — `[num_kv_heads, kv_capacity, head_dim]`, F32 or F16.
299///   K and V must share the same dtype (validated below).
300/// * `v` — `[num_kv_heads, kv_capacity, head_dim]`, same dtype as K.
301/// * `tree_mask` — `[q_seq_len, mask_stride]`, F32. Cell (i, j) ∈
302///   `{TREE_MASK_ATTENDED (0.0), TREE_MASK_MASKED (-65504.0)}` indicating
303///   whether query i can attend to KV position j. Cells with j >=
304///   kv_seq_len within a row are ignored (bounds-checked by shader).
305/// * `output` — **`[q_seq_len, num_heads, head_dim]`**, F32, pre-allocated.
306///   NOTE: output is **query-outer, head-inner** — different from Q
307///   layout — because the kernel writes via `rid = iq2 + iq1 *
308///   n_heads` (see `flash_attn_vec.metal:322`, inherited identically
309///   by tree_attention.metal). Callers reading row `iq1` for head `h`
310///   should index `output[iq1 * num_heads * head_dim + h * head_dim + d]`.
311/// * `tmp` — partial-result scratch sized by `tmp_buffer_bytes(...)`.
312pub fn tree_attention(
313    encoder: &mut CommandEncoder,
314    registry: &mut KernelRegistry,
315    device: &MlxDevice,
316    q: &MlxBuffer,
317    k: &MlxBuffer,
318    v: &MlxBuffer,
319    tree_mask: &MlxBuffer,
320    output: &MlxBuffer,
321    tmp: &MlxBuffer,
322    params: &TreeAttentionParams,
323) -> Result<()> {
324    validate_params(params)?;
325    validate_buffers(q, k, v, tree_mask, output, tmp, params)?;
326
327    let head_dim = params.head_dim;
328    let nwg = NWG;
329
330    let gpu_params = TreeAttentionParamsGpu {
331        n_heads: params.num_heads,
332        n_kv_heads: params.num_kv_heads,
333        head_dim: params.head_dim,
334        kv_seq_len: params.kv_seq_len,
335        kv_capacity: params.kv_capacity,
336        scale: params.scale,
337        nwg,
338        q_l: params.q_seq_len,
339        mask_stride: params.mask_stride,
340    };
341
342    // Codex /cfa Phase E1 gate (2026-05-22): K and V dtype must match;
343    // shader templates K and V on the same `KV_T` type. K/V mismatch
344    // would cause one side to be read with the wrong stride/precision.
345    // Q/tree_mask/output/tmp are always F32 (validated below).
346    let kv_is_f16 = k.dtype() == DType::F16;
347    let kernel_name = match (head_dim, kv_is_f16) {
348        (128, false) => "tree_attention_dk128",
349        (256, false) => "tree_attention_dk256",
350        (512, false) => "tree_attention_dk512",
351        (128, true)  => "tree_attention_f16kv_dk128",
352        (256, true)  => "tree_attention_f16kv_dk256",
353        (512, true)  => "tree_attention_f16kv_dk512",
354        _ => unreachable!(),
355    };
356    let pipeline = registry.get_pipeline(kernel_name, device.metal_device())?;
357
358    // Shared memory layout mirrors flash_attn_vec exactly.
359    let pk = pad2(head_dim as usize, 128);
360    let pv = pad2(head_dim as usize, 128);
361    let sh = 4 * 32;
362    let shmem_halfs = pk + sh + 2 * pv;
363    let shmem_bytes = shmem_halfs * 2;
364
365    encoder.set_op_kind(CapturedOpKind::Sdpa);
366
367    let threadgroups = MTLSize::new(
368        params.q_seq_len as u64,
369        params.num_heads as u64,
370        nwg as u64,
371    );
372    let threadgroup_size = MTLSize::new(32, 1, 1);
373
374    encoder.encode_threadgroups_with_args_and_shared(
375        pipeline,
376        &[
377            (0, KernelArg::Bytes(as_bytes(&gpu_params))),
378            (1, KernelArg::Buffer(q)),
379            (2, KernelArg::Buffer(k)),
380            (3, KernelArg::Buffer(v)),
381            (4, KernelArg::Buffer(tmp)),
382            (5, KernelArg::Buffer(tree_mask)),
383        ],
384        &[(0, shmem_bytes as u64)],
385        threadgroups,
386        threadgroup_size,
387    );
388
389    // --- Reduce pass (reuses flash_attn_vec_reduce — same output layout) ---
390    if nwg > 1 {
391        encoder.memory_barrier();
392        let total_rows = params.num_heads * params.q_seq_len;
393        let reduce_params = FlashAttnVecReduceParamsGpu { nrows: total_rows };
394
395        let reduce_kernel = match head_dim {
396            128 => "flash_attn_vec_reduce_dk128",
397            256 => "flash_attn_vec_reduce_dk256",
398            512 => "flash_attn_vec_reduce_dk512",
399            _ => unreachable!(),
400        };
401        let reduce_pipeline =
402            registry.get_pipeline(reduce_kernel, device.metal_device())?;
403
404        let reduce_tg = MTLSize::new(total_rows as u64, 1, 1);
405        let reduce_tg_size = MTLSize::new(32 * nwg as u64, 1, 1);
406
407        {
408            let read_ranges = vec![{
409                let s = tmp.contents_ptr() as usize;
410                (s, s + tmp.byte_len())
411            }];
412            let write_ranges = vec![{
413                let s = output.contents_ptr() as usize;
414                (s, s + output.byte_len())
415            }];
416            encoder.set_pending_buffer_ranges(read_ranges, write_ranges);
417        }
418
419        encoder.encode_threadgroups_with_args(
420            reduce_pipeline,
421            &[
422                (0, KernelArg::Bytes(as_bytes(&reduce_params))),
423                (1, KernelArg::Buffer(tmp)),
424                (2, KernelArg::Buffer(output)),
425                (3, KernelArg::Bytes(as_bytes(&nwg))),
426            ],
427            reduce_tg,
428            reduce_tg_size,
429        );
430    }
431
432    Ok(())
433}
434
435/// Compute the temp buffer size needed for tree-attention. Identical
436/// to flash_attn_vec's tmp buffer (same output layout) — kept as a
437/// separate function for clarity at call sites.
438///
439/// ADR-037 Phase E4b.6 codex /cfa (2026-05-22): uses saturating_mul
440/// so adversarial inputs above usize::MAX clamp to usize::MAX
441/// instead of wrapping. Callers see a clearly-too-large requirement
442/// which fails any subsequent alloc/comparison rather than silently
443/// passing with a small wrapped value.
444#[allow(non_snake_case)]
445pub fn tmp_buffer_bytes(num_heads: u32, head_dim: u32, q_seq_len: u32) -> usize {
446    let nrows = (num_heads as usize).saturating_mul(q_seq_len as usize);
447    let nwg = NWG as usize;
448    let dv = head_dim as usize;
449    let dv_plus_2 = dv.saturating_add(2);
450    nrows
451        .saturating_mul(nwg)
452        .saturating_mul(dv_plus_2)
453        .saturating_mul(std::mem::size_of::<f32>())
454}
455
456fn pad2(x: usize, n: usize) -> usize {
457    (x + n - 1) & !(n - 1)
458}
459
460#[cfg(test)]
461#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn test_validate_params_ok() {
467        let p = TreeAttentionParams {
468            num_heads: 16,
469            num_kv_heads: 8,
470            head_dim: 256,
471            kv_seq_len: 100,
472            kv_capacity: 1024,
473            scale: 1.0,
474            q_seq_len: 1,
475            mask_stride: 100,
476        };
477        assert!(validate_params(&p).is_ok());
478    }
479
480    #[test]
481    fn test_validate_params_mask_stride_too_small() {
482        let p = TreeAttentionParams {
483            num_heads: 16,
484            num_kv_heads: 8,
485            head_dim: 256,
486            kv_seq_len: 100,
487            kv_capacity: 1024,
488            scale: 1.0,
489            q_seq_len: 1,
490            mask_stride: 50,
491        };
492        let err = validate_params(&p).unwrap_err().to_string();
493        assert!(
494            err.contains("mask_stride"),
495            "expected mask_stride error, got: {err}"
496        );
497    }
498
499    #[test]
500    fn test_validate_params_bad_head_dim() {
501        // 64 isn't supported (only 128, 256, 512 are templated).
502        let p = TreeAttentionParams {
503            num_heads: 16,
504            num_kv_heads: 8,
505            head_dim: 64,
506            kv_seq_len: 100,
507            kv_capacity: 1024,
508            scale: 1.0,
509            q_seq_len: 1,
510            mask_stride: 100,
511        };
512        assert!(validate_params(&p).is_err());
513    }
514
515    #[test]
516    fn adr_037_e4b6_validate_params_accepts_dk128_2026_05_22() {
517        // Qwen 3.6 27B uses head_dim=128. Added by Phase E4b.6.
518        let p = TreeAttentionParams {
519            num_heads: 40,
520            num_kv_heads: 8,
521            head_dim: 128,
522            kv_seq_len: 64,
523            kv_capacity: 256,
524            scale: 1.0,
525            q_seq_len: 1,
526            mask_stride: 64,
527        };
528        validate_params(&p).expect("dk128 should validate");
529    }
530
531    #[test]
532    fn test_validate_params_q_seq_len_zero() {
533        let p = TreeAttentionParams {
534            num_heads: 16,
535            num_kv_heads: 8,
536            head_dim: 256,
537            kv_seq_len: 100,
538            kv_capacity: 1024,
539            scale: 1.0,
540            q_seq_len: 0,
541            mask_stride: 100,
542        };
543        let err = validate_params(&p).unwrap_err().to_string();
544        assert!(err.contains("q_seq_len"), "got: {err}");
545    }
546
547    #[test]
548    fn test_gpu_params_layout() {
549        // 9 fields x 4 bytes = 36 bytes
550        assert_eq!(std::mem::size_of::<TreeAttentionParamsGpu>(), 36);
551    }
552
553    #[test]
554    fn test_tmp_buffer_bytes() {
555        // 16 heads, dk256, q_seq_len=1, nwg=32
556        // 16 * 1 * 32 * (256 + 2) * 4 = 528_384
557        let bytes = tmp_buffer_bytes(16, 256, 1);
558        assert_eq!(bytes, 16 * 32 * 258 * 4);
559    }
560}