Skip to main content

nuvec_core/
fast.rs

1//! Fused operations with dedicated GPU kernels, as MLX's `mx.fast`: [`rms_norm`],
2//! [`layer_norm`], [`rope`], [`cross_entropy`], [`scaled_dot_product_attention`] and
3//! [`gated_delta`] (mlx-lm's Gated DeltaNet kernels); nuvec's [`add_rms_norm`] (a residual
4//! connection and its norm), [`norm_rope`] (norm, RoPE and a cache write) and
5//! [`paged_attention`] with [`paged_write`] (a block-table KV cache); and, through
6//! [`crate::Array::softmax`] and [`crate::Array::logsumexp`] over the last axis, MLX's
7//! softmax and log-sum-exp kernels.
8//!
9//! Each operation is one GPU kernel (MLX's, or nuvec's own) with an equivalent graph of ordinary
10//! operations, its *fallback*. As in MLX, the fallback runs on the CPU stream, and it
11//! defines the forward-mode derivative and batching; reverse mode uses dedicated kernels
12//! where MLX has them.
13//!
14//! Like every operation, these return arrays: invalid arguments give a failed array that
15//! carries the error and the caller's location (see [`crate::Array::check`]).
16//!
17//! # Memory
18//!
19//! The norms, RoPE, `norm_rope`, cross entropy, softmax, log-sum-exp, the gated delta rule
20//! and the paged kernels hold no scratch beyond their inputs and outputs on the GPU (the
21//! fallback graphs on the CPU hold their intermediates). Attention's scratch depends on its
22//! plan: see [`scaled_dot_product_attention`]. Rotation frequencies are computed per call,
23//! never cached.
24//!
25//! ```
26//! use nuvec_core::{array, fast};
27//!
28//! let x = array![[1.0, 2.0], [3.0, 4.0]];
29//! let y = fast::rms_norm(&x, None, 1e-5); // x / sqrt(mean(x²) + eps), per row
30//! let v = y.to_vec::<f32>().unwrap();
31//! assert!((v[0] - 1.0 / 2.5f32.sqrt()).abs() < 1e-5);
32//! ```
33
34pub(crate) mod imp;
35pub(crate) mod plan;
36
37use std::panic::Location;
38
39use smallvec::SmallVec;
40
41use crate::{Array, DType};
42
43pub use crate::kernel::{KernelCall, KernelConfig, MetalKernel, TemplateArg};
44pub use imp::{AttentionMask, FastOp, Rotation};
45
46/// The arrays among required and optional operands, for failure reporting.
47fn operands<'a>(
48    required: &[&'a Array],
49    optional: &[Option<&'a Array>],
50) -> SmallVec<[&'a Array; 8]> {
51    let mut out: SmallVec<[&Array; 8]> = SmallVec::from_slice(required);
52    out.extend(optional.iter().flatten().copied());
53    out
54}
55
56/// Root-mean-square normalisation over the last axis, `x / sqrt(mean(x²) + eps) · weight`,
57/// as MLX's `fast.rms_norm`: statistics in float32, the result in the promoted type of `x`
58/// and `weight` (1-D, the last axis's size).
59#[track_caller]
60pub fn rms_norm<'a>(x: &Array, weight: impl Into<Option<&'a Array>>, eps: f32) -> Array {
61    let weight = weight.into();
62    let ins = operands(&[x], &[weight]);
63    Array::sticky("rms_norm", Location::caller(), &ins, || {
64        imp::rms_norm(x, weight, eps)
65    })
66}
67
68/// A residual connection followed by RMS normalisation: `h = x + residual` and
69/// [`rms_norm`]`(h, weight, eps)`, returned as `(h, normed)`, from one GPU kernel. The
70/// results equal `x + residual` then [`rms_norm`] exactly; a weight that would widen the
71/// sum's dtype runs the two operations.
72#[track_caller]
73pub fn add_rms_norm<'a>(
74    x: &Array,
75    residual: &Array,
76    weight: impl Into<Option<&'a Array>>,
77    eps: f32,
78) -> (Array, Array) {
79    let weight = weight.into();
80    let ins = operands(&[x, residual], &[weight]);
81    let [h, normed] = Array::sticky_n("add_rms_norm", Location::caller(), &ins, || {
82        let (h, n) = imp::add_rms_norm(x, residual, weight, eps)?;
83        Ok([h, n])
84    });
85    (h, normed)
86}
87
88/// A normalised residual branch: `s = (h + rms_norm(x, weight, eps)) · scale` (no multiply
89/// without a scale) and, with `next = (weight, eps)`, [`rms_norm`] of `s`, returned as
90/// `(s, normed)`. Gemma 4's sandwich norm, residual add and layer scalar, and the next
91/// layer's input norm. On the GPU it is one kernel equal to that composition bit for bit
92/// when every input has `h`'s floating-point dtype, the weights are 1-D and the scale has
93/// one element (otherwise the composition, with MLX's type promotion).
94///
95/// ```
96/// use nuvec_core::{array, fast};
97///
98/// let (h, x, w) = (array![[1.0f32, 1.0]], array![[3.0f32, 3.0]], array![1.0f32, 2.0]);
99/// let (s, normed) = fast::residual_rms_norm(&h, &x, (&w, 0.0), None, Some((&w, 0.0)));
100/// assert_eq!(s.to_vec::<f32>().unwrap(), [2.0, 3.0]);
101/// assert!(normed.is_some());
102/// ```
103#[track_caller]
104pub fn residual_rms_norm<'a>(
105    h: &Array,
106    x: &Array,
107    norm: (&Array, f32),
108    scale: impl Into<Option<&'a Array>>,
109    next: impl Into<Option<(&'a Array, f32)>>,
110) -> (Array, Option<Array>) {
111    let (scale, next) = (scale.into(), next.into());
112    let ins = operands(&[h, x, norm.0], &[scale, next.map(|(w, _)| w)]);
113    let at = Location::caller();
114    match next {
115        Some(_) => {
116            let [s, n] = Array::sticky_n("residual_rms_norm", at, &ins, || {
117                let (s, n) = imp::residual_rms_norm(h, x, norm, scale, next)?;
118                Ok([s, n.expect("a next norm")])
119            });
120            (s, Some(n))
121        }
122        None => {
123            let s = Array::sticky("residual_rms_norm", at, &ins, || {
124                Ok(imp::residual_rms_norm(h, x, norm, scale, None)?.0)
125            });
126            (s, None)
127        }
128    }
129}
130
131/// Layer normalisation over the last axis, `(x - mean) / sqrt(var + eps) · weight + bias`,
132/// as MLX's `fast.layer_norm`: statistics in float32, the result in the promoted type.
133#[track_caller]
134pub fn layer_norm<'a>(
135    x: &Array,
136    weight: impl Into<Option<&'a Array>>,
137    bias: impl Into<Option<&'a Array>>,
138    eps: f32,
139) -> Array {
140    let weight = weight.into();
141    let bias = bias.into();
142    let ins = operands(&[x], &[weight, bias]);
143    Array::sticky("layer_norm", Location::caller(), &ins, || {
144        imp::layer_norm(x, weight, bias, eps)
145    })
146}
147
148/// Rotary position embeddings, as MLX's `fast.rope`, of `x` (at least 3-D: `[B, ...,
149/// T, D]`, positions along `T`): the first `dims` features (even) are rotated in pairs,
150/// the two halves (or interleaved pairs, `traditional`) by angle `(offset + t) · scale ·
151/// θᵢ`, with `θᵢ = base^(-2i/dims)` or `1 / freqs[i]`. Exactly one of `base` and `freqs`
152/// is given. `offset` is an integer scalar or one per batch entry.
153#[track_caller]
154pub fn rope<'a>(
155    x: &Array,
156    dims: i32,
157    traditional: bool,
158    base: Option<f32>,
159    scale: f32,
160    offset: &Array,
161    freqs: impl Into<Option<&'a Array>>,
162) -> Array {
163    let freqs = freqs.into();
164    let ins = operands(&[x, offset], &[freqs]);
165    Array::sticky("rope", Location::caller(), &ins, || {
166        let dims = usize::try_from(dims).map_err(|_| {
167            crate::Error::InvalidArgument(format!("rope: dims must not be negative, got {dims}"))
168        })?;
169        imp::rope(x, dims, traditional, base, scale, offset, freqs)
170    })
171}
172
173/// Cross entropy over the last axis of `logits` against integer class indices `targets`
174/// (the logits' shape without the last axis; negative indices count from the end), as
175/// MLX's `fast.cross_entropy`: `logsumexp(logits) - logits[target]` per row, in float32.
176#[track_caller]
177pub fn cross_entropy(logits: &Array, targets: &Array) -> Array {
178    Array::sticky(
179        "cross_entropy",
180        Location::caller(),
181        &[logits, targets],
182        || imp::cross_entropy(logits, targets),
183    )
184}
185
186/// An attention layer's query or key preparation in one kernel: each head's features of
187/// `x` (`[B, H, L, D]`, any strides, as the transposed projection) optionally normalised
188/// as [`rms_norm`] with `norm = (weight, eps)` (Qwen 3's `q_norm` and `k_norm`), then
189/// rotated as [`rope`] at positions `offset..offset + L`.
190///
191/// Without `cache`, the result is a new `[B, H, L, D]` array. With `cache` (`[B, H, C, D]`,
192/// `C ≥ offset + L`), the result is `cache` with positions `offset..offset + L` replaced
193/// (as [`crate::Array::at`] with `set`, and written in place when the evaluation owns the
194/// cache), so keys go into a key cache without a separate copy.
195///
196/// The results equal [`rms_norm`], [`rope`] and the update composed (on the GPU, bit for
197/// bit); differing dtypes, other ranks and head sizes over 1024 run that composition.
198#[track_caller]
199pub fn norm_rope<'a>(
200    x: &Array,
201    norm: Option<(&Array, f32)>,
202    rotation: Rotation<'_>,
203    offset: i32,
204    cache: impl Into<Option<&'a Array>>,
205) -> Array {
206    let cache = cache.into();
207    let ins = operands(&[x], &[norm.map(|n| n.0), rotation.freqs, cache]);
208    Array::sticky("norm_rope", Location::caller(), &ins, || {
209        let offset = usize::try_from(offset).map_err(|_| {
210            crate::Error::InvalidArgument(format!(
211                "norm_rope: offset must not be negative, got {offset}"
212            ))
213        })?;
214        imp::norm_rope(x, norm, rotation, offset, cache)
215    })
216}
217
218/// [`norm_rope`] for sequences at their own positions (a batched decoding step): each
219/// `[b, h, l]` row of `x` (`[B, H, L, D]`, any strides) optionally normalised as
220/// [`rms_norm`] with `norm = (weight, eps)`, then rotated as [`rope`] at position
221/// `positions[b] + l` (`positions`: `[B]` integers).
222///
223/// Without `pool`, the result is a new `[B, H, L, D]` array. With `pool = (pool, slots)`
224/// (`[blocks, H, block_size, D]` and `[B, L]` integers), the result is the pool with each
225/// row written at its slot as [`paged_write`] writes it (in place when the evaluation owns
226/// the pool; out-of-range slots skipped), so keys go into a paged cache without a separate
227/// write.
228///
229/// The results equal [`rms_norm`], [`rope`] and [`paged_write`] composed (on the GPU, bit
230/// for bit); differing dtypes, other ranks and head sizes over 1024 run that composition.
231///
232/// ```
233/// use nuvec_core::{Array, DType, fast::{self, Rotation}, ops};
234///
235/// let x = ops::ones([2, 1, 1, 8], DType::F32);
236/// let rotation = Rotation { dims: 8, traditional: false, base: Some(1e4), scale: 1.0, freqs: None };
237/// let pool = ops::zeros([4, 1, 4, 8], DType::F32);
238/// let slots = Array::from_slice(&[5i32, 9], [2, 1]);
239/// let at = Array::from([5i32, 9]);
240/// let pool = fast::norm_rope_at(&x, None, rotation, &at, (&pool, &slots));
241/// assert_eq!(pool.shape(), &[4, 1, 4, 8]);
242/// ```
243#[track_caller]
244pub fn norm_rope_at<'a>(
245    x: &Array,
246    norm: Option<(&Array, f32)>,
247    rotation: Rotation<'_>,
248    positions: &Array,
249    pool: impl Into<Option<(&'a Array, &'a Array)>>,
250) -> Array {
251    let pool = pool.into();
252    let ins = operands(
253        &[x, positions],
254        &[
255            norm.map(|n| n.0),
256            rotation.freqs,
257            pool.map(|p| p.0),
258            pool.map(|p| p.1),
259        ],
260    );
261    Array::sticky("norm_rope_at", Location::caller(), &ins, || {
262        imp::norm_rope_at(x, norm, rotation, positions, pool)
263    })
264}
265
266/// Gemma 4's keys and values from one projection (`attention_k_eq_v`): K is `x`
267/// (`[B, H, L, D]`, any strides) normalised with `k_norm = (weight, eps)` and rotated at
268/// positions `offset..` (as [`norm_rope`]); V is `x` RMS-normalised without a weight
269/// (`v_eps`; mlx-lm's `RMSNormNoScale`). With `caches = (k_cache, v_cache)` both are written
270/// at `offset` (in place when the evaluation owns them), as [`norm_rope`] writes one.
271///
272/// Each is one fused kernel reading `x` (V rotates by angle 0: infinite wavelengths, an
273/// exact identity), and equals the composition ([`rms_norm`], [`rope`], the update) bit for
274/// bit on the GPU.
275///
276/// ```
277/// use nuvec_core::{DType, fast::{self, Rotation}, ops};
278///
279/// let x = ops::ones([1, 2, 3, 8], DType::F32);
280/// let w = ops::ones([8], DType::F32);
281/// let rotation = Rotation { dims: 8, traditional: false, base: Some(1e4), scale: 1.0, freqs: None };
282/// let (k, v) = fast::norm_rope_kv(&x, Some((&w, 1e-6)), 1e-6, rotation, 0, None);
283/// assert_eq!((k.shape(), v.shape()), (&[1, 2, 3, 8][..], &[1, 2, 3, 8][..]));
284/// ```
285#[track_caller]
286pub fn norm_rope_kv<'a>(
287    x: &Array,
288    k_norm: Option<(&Array, f32)>,
289    v_eps: f32,
290    rotation: Rotation<'_>,
291    offset: i32,
292    caches: impl Into<Option<(&'a Array, &'a Array)>>,
293) -> (Array, Array) {
294    let caches = caches.into();
295    let k = norm_rope(x, k_norm, rotation, offset, caches.map(|c| c.0));
296    let d = x.shape().last().copied().unwrap_or(0);
297    let ones = crate::ops::ones([d], x.dtype());
298    let still = crate::ops::full([d / 2], f32::INFINITY);
299    let identity = Rotation {
300        dims: d - d % 2,
301        traditional: false,
302        base: None,
303        scale: 1.0,
304        freqs: Some(&still),
305    };
306    let v = match d {
307        // No pair to rotate: the norm, written into the cache as `norm_rope` would.
308        0 | 1 => {
309            let v = crate::fast::rms_norm(x, None, v_eps);
310            match caches {
311                Some((_, cache)) => {
312                    let l = x.shape().get(2).copied().unwrap_or(0);
313                    cache.clone().at((.., .., offset..offset + l)).set(&v)
314                }
315                None => v,
316            }
317        }
318        _ => norm_rope(
319            x,
320            Some((&ones, v_eps)),
321            identity,
322            offset,
323            caches.map(|c| c.1),
324        ),
325    };
326    (k, v)
327}
328
329/// Attention over a paged KV cache, for a batch of sequences of different lengths.
330///
331/// Keys and values live in blocks of positions, `k_cache` and `v_cache` being
332/// `[blocks, kv_heads, block_size, D]`; sequence `b` keeps its positions in the blocks
333/// `block_tables[b]` lists (`[B, max_blocks]`, integers; entries past the sequence's blocks
334/// are ignored). `q` is `[B, Hq, L, D]` (`Hq` a multiple of `kv_heads`): the last `L`
335/// positions of each sequence, whose keys are already in the cache. Query `l` of sequence
336/// `b` attends positions `0..=lengths[b] − L + l` (causal at the end; `lengths[b] ≥ L`),
337/// with `options`' window only the last `window` of those, and its sinks (`[Hq]`) in the
338/// softmax; an array mask is an error (`max_score_bytes` bounds only the composition).
339/// Returns `[B, Hq, L, D]`. Blocks wholly before a sequence's window are never read, so
340/// they may already hold other sequences' keys.
341///
342/// Out-of-range inputs never read outside the caches: block ids are clamped to
343/// `0..blocks` and lengths to `0..=max_blocks · block_size` (the table's capacity), on
344/// every device.
345///
346/// On the GPU this is MLX's vector attention (one pass, or two for long caches) reading
347/// keys through the block table, for head sizes 64, 128, 256 and 512 and power-of-two block
348/// sizes (others run the composition). Each sequence's result equals
349/// [`scaled_dot_product_attention`]'s vector kernel over the same keys stored contiguously
350/// when both take the same number of passes.
351#[track_caller]
352pub fn paged_attention(
353    q: &Array,
354    k_cache: &Array,
355    v_cache: &Array,
356    block_tables: &Array,
357    lengths: &Array,
358    scale: f32,
359    options: &AttentionOptions<'_>,
360) -> Array {
361    let ins = operands(
362        &[q, k_cache, v_cache, block_tables, lengths],
363        &[options.sinks],
364    );
365    Array::sticky("paged_attention", Location::caller(), &ins, || {
366        imp::paged_attention(q, k_cache, v_cache, block_tables, lengths, scale, options)
367    })
368}
369
370/// `cache` (`[blocks, kv_heads, block_size, D]`) with the positions `new` holds
371/// (`[T, kv_heads, D]`, or `[B, T, kv_heads, D]` for several sequences) written into their
372/// slots: `slots` (integers, `new`'s leading shape) holds block `slot / block_size`,
373/// position `slot % block_size` of each. Slots must be distinct; out-of-range slots
374/// (negative, or at least `blocks · block_size`) are skipped. In place when the evaluation
375/// owns the cache; on the GPU, `new` is read through its strides (a transposed view of a
376/// contiguous cache needs no copy).
377#[track_caller]
378pub fn paged_write(cache: &Array, new: &Array, slots: &Array) -> Array {
379    Array::sticky(
380        "paged_write",
381        Location::caller(),
382        &[cache, new, slots],
383        || imp::paged_write(cache, new, slots),
384    )
385}
386
387/// The options of [`scaled_dot_product_attention`] and [`plan_attention`]: the mask,
388/// attention sinks, a sliding window and a cap on score memory. Build them with
389/// [`AttentionOptions::causal`] or `Default` and the chained setters:
390///
391/// ```
392/// use nuvec_core::fast::AttentionOptions;
393///
394/// let opts = AttentionOptions::causal().window(4096).max_score_bytes(512 << 20);
395/// assert_eq!(opts.window, Some(4096));
396/// ```
397///
398/// This is the one options type of attention: new features (window and head-size-512
399/// kernels, backward routing) extend it, never a second entry point.
400#[non_exhaustive]
401#[derive(Clone, Copy, Debug, Default)]
402pub struct AttentionOptions<'a> {
403    /// The mask: none (the default), causal, or an array.
404    pub mask: AttentionMask<'a>,
405    /// Attention sinks (`[Hq]`): one logit per query head that takes part in the softmax
406    /// but attends to nothing.
407    pub sinks: Option<&'a Array>,
408    /// A sliding window: query `i` attends only to the `window` keys up to and including
409    /// its position (as mlx-lm's `create_causal_mask(..., window_size)`). Needs the causal
410    /// mask.
411    pub window: Option<i32>,
412    /// A cap, in bytes, on every score-sized intermediate (scores, masked scores, the
413    /// float32 sink-softmax intermediates, materialised masks); see
414    /// [`scaled_dot_product_attention`].
415    pub max_score_bytes: Option<usize>,
416}
417
418impl<'a> AttentionOptions<'a> {
419    /// Causal masking: query `i` attends to keys `0..=i + Lk - Lq` (the queries are the last
420    /// `Lq` positions).
421    pub fn causal() -> Self {
422        AttentionOptions {
423            mask: AttentionMask::Causal,
424            ..Self::default()
425        }
426    }
427
428    /// A boolean (true attends) or additive mask, broadcastable to `[B, Hq, Lq, Lk]`.
429    #[must_use]
430    pub fn mask(mut self, mask: &'a Array) -> Self {
431        self.mask = AttentionMask::Array(mask);
432        self
433    }
434
435    /// Attention sinks, `[Hq]` (`None` for none).
436    #[must_use]
437    pub fn sinks(mut self, sinks: impl Into<Option<&'a Array>>) -> Self {
438        self.sinks = sinks.into();
439        self
440    }
441
442    /// A sliding window of `keys` keys (needs [`AttentionOptions::causal`]): query `i`
443    /// attends to keys `i + Lk − Lq − keys < j ≤ i + Lk − Lq`. The vector and tiled kernels
444    /// skip the keys before the window, so a full cache needs no slicing; a cache that keeps
445    /// only the last `W − 1 + L` positions (nuvec-lm's sliding-window `KvCache`) passes
446    /// just those, with the same result:
447    ///
448    /// ```
449    /// use nuvec_core::{DType, fast::{self, AttentionOptions}, random};
450    ///
451    /// let (w, l) = (4, 2);
452    /// let q = random::normal([1, 2, l, 8], DType::F32, 0.0, 1.0, None);
453    /// let k = random::normal([1, 2, 10, 8], DType::F32, 0.0, 1.0, None);
454    /// let opts = AttentionOptions::causal().window(w);
455    /// let full = fast::scaled_dot_product_attention(&q, &k, &k, 0.35, &opts);
456    /// let tail = k.i((.., .., 10 - (w - 1 + l)..));
457    /// let sliced = fast::scaled_dot_product_attention(&q, &tail, &tail, 0.35, &opts);
458    /// let diff = full.subtract(&sliced).abs().max(..).item::<f32>().unwrap();
459    /// assert!(diff < 1e-6);
460    /// ```
461    #[must_use]
462    pub fn window(mut self, keys: i32) -> Self {
463        self.window = Some(keys);
464        self
465    }
466
467    /// Caps every score-sized intermediate at `bytes` (positive).
468    #[must_use]
469    pub fn max_score_bytes(mut self, bytes: usize) -> Self {
470        self.max_score_bytes = Some(bytes);
471        self
472    }
473}
474
475impl<'a> From<AttentionMask<'a>> for AttentionOptions<'a> {
476    /// Options with this mask and nothing else.
477    fn from(mask: AttentionMask<'a>) -> Self {
478        AttentionOptions {
479            mask,
480            ..Self::default()
481        }
482    }
483}
484
485/// The kernel family an attention call runs (see [`plan_attention`]).
486#[non_exhaustive]
487#[derive(Clone, Copy, Debug, PartialEq, Eq)]
488pub enum AttentionKernel {
489    /// At most 8 queries: MLX's vector kernels. `passes` is 1 or 2; `blocks` the key
490    /// partitions of the first of two passes (1 for one pass; the largest over
491    /// dispatches); `dispatches` is more than 1 when query rows need different one-query
492    /// configurations (the serial rule).
493    Vector {
494        /// Passes over the keys (1 or 2).
495        passes: u8,
496        /// Key partitions of the first pass.
497        blocks: i32,
498        /// Dispatches (groups of query rows with one configuration).
499        dispatches: i32,
500    },
501    /// MLX's tiled (steel) attention kernel: no scores are materialised.
502    Tiled,
503    /// The composition: `softmax(scale · q kᵀ + mask) v` from matrix products.
504    Composed,
505    /// The composition per block of `rows` queries, one block after another.
506    ComposedBlocks {
507        /// Queries per block (the last block may have fewer).
508        rows: i32,
509    },
510    /// MLX's blocked prefill at head size 512 (half precision, causal, `Lq ≥ 1024`, no
511    /// array mask or sinks): blocks of `rows` queries (256, halved under a cap), each over
512    /// only the keys its rows see.
513    BlockedD512 {
514        /// Queries per block (the last block may have fewer).
515        rows: i32,
516    },
517}
518
519/// How an attention call runs and the memory it holds (see [`plan_attention`]).
520#[non_exhaustive]
521#[derive(Clone, Copy, Debug, PartialEq, Eq)]
522pub struct AttentionPlan {
523    /// The kernel family.
524    pub kernel: AttentionKernel,
525    /// Bytes held beyond the inputs and the output while the call runs (for one block when
526    /// blocked; see the `# Memory` section of [`scaled_dot_product_attention`]).
527    pub scratch_bytes: usize,
528    /// The largest single score-sized intermediate: what `max_score_bytes` bounds.
529    pub max_intermediate_bytes: usize,
530    /// Whether the cap changed the choice.
531    pub capped: bool,
532}
533
534/// How [`scaled_dot_product_attention`] will run for these shapes (`[B, H, L, D]` each),
535/// dtype and options, on the calling thread's default device, without evaluating or
536/// allocating anything. Masks and sinks in `options` are read for their shape and dtype
537/// only. Errors are those the attention call would report.
538///
539/// The plan is a pure function of the shapes, the dtype, the options and the GPU's
540/// generation and class: the same inputs give the same plan in every call, thread and
541/// process, whatever memory is free; the attention op runs exactly this plan (one routing
542/// function).
543///
544/// ```
545/// use nuvec_core::{DType, fast::{AttentionKernel, AttentionOptions, plan_attention}};
546///
547/// # fn main() -> nuvec_core::Result<()> {
548/// let (q, k) = ([1, 16, 1024, 256], [1, 2, 33_792, 256]);
549/// let opts = AttentionOptions::causal().max_score_bytes(512 << 20);
550/// let plan = plan_attention(&q, &k, &k, DType::BF16, &opts)?;
551/// assert!(plan.max_intermediate_bytes <= 512 << 20);
552/// assert!(matches!(
553///     plan.kernel,
554///     AttentionKernel::Tiled | AttentionKernel::ComposedBlocks { .. }
555/// ));
556/// # Ok(())
557/// # }
558/// ```
559pub fn plan_attention(
560    q: &[i32],
561    k: &[i32],
562    v: &[i32],
563    dtype: crate::DType,
564    options: &AttentionOptions<'_>,
565) -> crate::Result<AttentionPlan> {
566    imp::plan_attention(q, k, v, dtype, options)
567}
568
569/// Scaled dot-product attention, as MLX's `fast.scaled_dot_product_attention`:
570/// `softmax(scale · q kᵀ + mask) v` over `[B, H, L, D]` arrays, with the mask, sinks,
571/// window and score cap in [`AttentionOptions`].
572///
573/// `k` and `v` may have fewer heads than `q` (grouped-query attention; `Hq` a multiple of
574/// `Hkv`), and `v` its own head size. Sinks (`[Hq]`) add one logit per head that takes part
575/// in the softmax but attends to nothing.
576///
577/// ```
578/// use nuvec_core::{DType, fast::{self, AttentionOptions}, ops};
579///
580/// let q = ops::ones([1, 4, 3, 64], DType::F32);
581/// let kv = ops::ones([1, 2, 5, 64], DType::F32);
582/// let out = fast::scaled_dot_product_attention(&q, &kv, &kv, 0.125, &AttentionOptions::causal());
583/// assert_eq!(out.shape(), [1, 4, 3, 64]);
584/// ```
585///
586/// # Routing
587///
588/// [`plan_attention`] decides, and reports, how the call runs. On the GPU, up to 8 queries
589/// use MLX's vector kernels, more use its tiled kernel or the composition (as MLX: the
590/// composition is faster for some head sizes and shapes); the CPU runs the composition.
591/// At head size 512, causal prefill of 1,024 queries or more in half precision (no array
592/// mask or sinks) runs as MLX's blocked path: the composition per block of 256 queries,
593/// each over only the keys its rows see, a key head's grouped queries in one product (bit
594/// for bit MLX's result, one block's scores live). A window narrows the keys of every
595/// composition block and is skipped inside the vector and tiled kernels.
596///
597/// **The serial rule.** With 2 to 8 queries (speculative verification), each query row
598/// takes the configuration (passes, key partitions, kernel variant) of the same row run
599/// alone as one query over its causal key prefix, and rows with equal configurations share
600/// a dispatch. So each row's result equals, bit for bit, the one-query call over its
601/// prefix (MLX picks one configuration for all rows instead). Rows that need the
602/// one-key-per-step kernel variant (float32 at head size 256) and do not fit one
603/// threadgroup together keep MLX's composition instead.
604///
605/// **The cap.** With `max_score_bytes = c`, no score-sized intermediate larger than `c`
606/// bytes is allocated (element size 4 with sinks or float32): either the tiled kernel
607/// runs (it materialises no scores) or the composition runs in blocks of query rows, one
608/// block after another (each block's queries depend on the previous block's output, so one
609/// block's scratch is live at a time), whichever measured faster. The result equals the
610/// uncapped composition within rounding. A cap below one query row's intermediate selects
611/// the tiled kernel where it supports the shape and is an `InvalidArgument` error naming
612/// the smallest admissible cap otherwise; a cap of 0 is invalid. Vector kernels (decode)
613/// never materialise scores, so the cap does not affect them.
614///
615/// # Memory
616///
617/// Scratch beyond inputs and output ([`AttentionPlan::scratch_bytes`]), with `s` the
618/// dtype's size, `r` the rows of a block (`Lq` unblocked), `L` its keys and `n = B·Hq·r·L`:
619/// vector one pass 0; vector two passes `B·Hq·rows·blocks·(Dv·s + 8)` for the largest
620/// dispatch (plus the gathered queries and outputs with several dispatches); tiled 0;
621/// composition `n·s` (the scores, masked and normalised in place) plus the larger of the
622/// scaled queries (`B·Hq·r·D·s`) and, with sinks below float32, the float32 softmax copy
623/// (`n·4`; three of them in blocks under 16 MiB), plus `r·L` bytes of causal mask; blocked
624/// (capped or head-512), one block's composition plus the output's bytes (the blocks
625/// before concatenation).
626#[track_caller]
627pub fn scaled_dot_product_attention(
628    q: &Array,
629    k: &Array,
630    v: &Array,
631    scale: f32,
632    options: &AttentionOptions<'_>,
633) -> Array {
634    let mask_array = match options.mask {
635        AttentionMask::Array(m) => Some(m),
636        _ => None,
637    };
638    let ins = operands(&[q, k, v], &[mask_array, options.sinks]);
639    Array::sticky(
640        "scaled_dot_product_attention",
641        Location::caller(),
642        &ins,
643        || imp::scaled_dot_product_attention(q, k, v, scale, options),
644    )
645}
646
647/// A mixture of experts' routing from its router's probabilities `probs` (`[..., E]`): the
648/// indices (uint32) of each row's `k` largest probabilities and those probabilities
649/// (`[..., k]` each), divided by their sum with `normalize` (mlx-lm's `norm_topk_prob`).
650///
651/// The indices are `argpartition(probs, -k)[..., -k:]`, in its order (ascending, equal
652/// probabilities by index), and the scores `take_along_axis` of them; on the GPU it is one
653/// kernel equal to that composition bit for bit (up to 1,024 experts, and 64 renormalised
654/// scores; otherwise the composition).
655///
656/// ```
657/// use nuvec_core::{array, fast};
658///
659/// let p = array![[0.1f32, 0.4, 0.2, 0.3]];
660/// let (top, scores) = fast::moe_route(&p, 2, true);
661/// assert_eq!(top.to_vec::<u32>().unwrap(), [3, 1]);
662/// let s = scores.to_vec::<f32>().unwrap();
663/// assert!((s[0] - 0.3 / 0.7).abs() < 1e-6 && (s[1] - 0.4 / 0.7).abs() < 1e-6);
664/// ```
665#[track_caller]
666pub fn moe_route(probs: &Array, k: i32, normalize: bool) -> (Array, Array) {
667    let [top, scores] = Array::sticky_n("moe_route", Location::caller(), &[probs], || {
668        let (top, scores) = imp::moe_route(probs, k, normalize)?;
669        Ok([top, scores])
670    });
671    (top, scores)
672}
673
674/// A causal depthwise convolution for a batch of sequences whose last inputs live in a
675/// pool (Gated DeltaNet's convolution state): sequence `b`'s window is `pool[slots[b]]`
676/// (`pool` is `[slots, K − 1, C]`, `slots` `[B]` distinct integers in range) followed by its
677/// new inputs `x[b]` (`x` is `[B, L, C]`), convolved with `w` (`[C, K, 1]`, one filter per
678/// channel) and, with `silu`, passed through `silu`. Returns the outputs (`[B, L, C]`) and
679/// the pool with each slot holding its window's last `K − 1` inputs, written in place when
680/// the evaluation owns it.
681///
682/// The results equal `take`, `concatenate`, `conv1d` (`groups = C`), `silu` and
683/// `put_along_axis` composed; on the GPU it is one kernel, bit for bit (up to 64 positions
684/// per window; longer windows and mixed dtypes run the composition).
685///
686/// ```
687/// use nuvec_core::{Array, DType, fast, ops};
688///
689/// let x = ops::ones([1, 1, 2], DType::F32);
690/// let w = ops::ones([2, 3, 1], DType::F32);
691/// let pool = ops::full([2, 2, 2], 2.0f32);
692/// let (y, pool) = fast::causal_conv1d_slots(&x, &w, &pool, &Array::from([1i32]), false);
693/// assert_eq!(y.to_vec::<f32>().unwrap(), [5.0, 5.0]);
694/// assert_eq!(pool.to_vec::<f32>().unwrap(), [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 1.0, 1.0]);
695/// ```
696#[track_caller]
697pub fn causal_conv1d_slots(
698    x: &Array,
699    w: &Array,
700    pool: &Array,
701    slots: &Array,
702    silu: bool,
703) -> (Array, Array) {
704    let [y, pool] = Array::sticky_n(
705        "causal_conv1d_slots",
706        Location::caller(),
707        &[x, w, pool, slots],
708        || {
709            let (y, pool) = imp::causal_conv1d_slots(x, w, pool, slots, silu)?;
710            Ok([y, pool])
711        },
712    );
713    (y, pool)
714}
715
716/// The experts' outputs `x` (`[..., k, D]`) weighted by `w` (`[..., k]`) and summed over the
717/// `k` experts: `(x * w[..., None]).sum(-2)`, products and sum in `x`'s dtype as mlx-lm's
718/// MoE blocks compute them. On the GPU it is one kernel equal to that composition bit for
719/// bit when both have one floating-point dtype and `k < 32` (otherwise the composition).
720///
721/// ```
722/// use nuvec_core::{array, fast};
723///
724/// let x = array![[[1.0f32, 2.0], [3.0, 4.0]]];
725/// let w = array![[0.5f32, 2.0]];
726/// assert_eq!(fast::weighted_sum(&x, &w).to_vec::<f32>().unwrap(), [6.5, 9.0]);
727/// ```
728#[track_caller]
729pub fn weighted_sum(x: &Array, w: &Array) -> Array {
730    Array::sticky("weighted_sum", Location::caller(), &[x, w], || {
731        imp::weighted_sum(x, w)
732    })
733}
734
735/// The gated delta rule, Gated DeltaNet's recurrence (as mlx-lm's `gated_delta_update`
736/// once its gates are computed). For each step `t` and value head, with `S` its state
737/// (`[Dv, Dk]`):
738///
739/// `S ← g_t · S`, then `S ← S + β_t (v_t − S k_t) k_tᵀ`, and `y_t = S q_t`.
740///
741/// Shapes: `q` and `k` are `[B, T, Hk, Dk]`, `v` is `[B, T, Hv, Dv]` (value head `h` reads
742/// key head `h / (Hv / Hk)`), `g` and `beta` are `[B, T, Hv]` and `state` is
743/// `[B, Hv, Dv, Dk]`. Returns `y` (`[B, T, Hv, Dv]`, in `q`'s dtype) and the final state
744/// (in `state`'s dtype); the recurrence runs in float32. On the GPU it is one kernel
745/// (mlx-lm's, for `Dk` a multiple of 32), else the steps as ordinary operations, which also
746/// define the derivatives.
747///
748/// ```
749/// use nuvec_core::{DType, fast, ops::{ones, zeros}};
750///
751/// let q = ones([1, 2, 1, 32], DType::F32);
752/// let v = ones([1, 2, 1, 4], DType::F32);
753/// let (g, beta) = (ones([1, 2, 1], DType::F32), ones([1, 2, 1], DType::F32));
754/// let state = zeros([1, 1, 4, 32], DType::F32);
755/// let (y, s) = fast::gated_delta(&(&q * 0.25), &(&q * 0.125), &v, &g, &(&beta * 0.5), &state);
756/// // k · k = 0.5 and k · q = 1. Step 1: S = 0.5 · v kᵀ, so y = 0.5. Step 2: S k = 0.25,
757/// // so S gains (1 − 0.25) · 0.5 · v kᵀ, and y = 0.875.
758/// assert_eq!(y.to_vec::<f32>().unwrap(), [0.5, 0.5, 0.5, 0.5, 0.875, 0.875, 0.875, 0.875]);
759/// assert_eq!(s.shape(), [1, 1, 4, 32]);
760/// ```
761#[track_caller]
762pub fn gated_delta(
763    q: &Array,
764    k: &Array,
765    v: &Array,
766    g: &Array,
767    beta: &Array,
768    state: &Array,
769) -> (Array, Array) {
770    let [y, s] = Array::sticky_n(
771        "gated_delta",
772        Location::caller(),
773        &[q, k, v, g, beta, state],
774        || {
775            let (y, s) = imp::gated_delta(q, k, v, g, beta, state)?;
776            Ok([y, s])
777        },
778    );
779    (y, s)
780}
781
782/// [`gated_delta`] returning the state after every step, `[B, T, Hv, Dv, Dk]` (step `t`'s
783/// state at index `t`; the last is [`gated_delta`]'s final state), so that a caller can
784/// roll the recurrence back to any step, as speculative decoding does after a rejected
785/// draft. The outputs equal [`gated_delta`]'s bit for bit; writing every step's state
786/// costs `T` times the state's bytes.
787#[track_caller]
788pub fn gated_delta_steps(
789    q: &Array,
790    k: &Array,
791    v: &Array,
792    g: &Array,
793    beta: &Array,
794    state: &Array,
795) -> (Array, Array) {
796    let [y, s] = Array::sticky_n(
797        "gated_delta_steps",
798        Location::caller(),
799        &[q, k, v, g, beta, state],
800        || {
801            let (y, s) = imp::gated_delta_steps(q, k, v, g, beta, state)?;
802            Ok([y, s])
803        },
804    );
805    (y, s)
806}
807
808/// Options of [`gated_delta_steps_with`].
809#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
810#[non_exhaustive]
811pub struct StepOptions {
812    /// The dtype states are stored in between steps (default: `state`'s).
813    pub storage: Option<DType>,
814}
815
816impl StepOptions {
817    /// Stores states in `dtype` between steps.
818    pub fn storage(mut self, dtype: DType) -> Self {
819        self.storage = Some(dtype);
820        self
821    }
822}
823
824/// [`gated_delta_steps`] for a runtime that stores its states in another dtype than
825/// float32 (bfloat16, say): each step's output is computed from the state the previous
826/// step stored, and its state is then stored in `options.storage`, so `states[t]` and the
827/// outputs equal `t + 1` serial [`gated_delta`] calls on a state kept in that dtype, bit
828/// for bit (each step runs the kernel serial decode runs). With float32 storage it is
829/// [`gated_delta_steps`].
830#[track_caller]
831pub fn gated_delta_steps_with(
832    q: &Array,
833    k: &Array,
834    v: &Array,
835    g: &Array,
836    beta: &Array,
837    state: &Array,
838    options: &StepOptions,
839) -> (Array, Array) {
840    let storage = options.storage.unwrap_or(state.dtype());
841    let [y, s] = Array::sticky_n(
842        "gated_delta_steps_with",
843        Location::caller(),
844        &[q, k, v, g, beta, state],
845        || {
846            if !matches!(storage, DType::F32 | DType::F16 | DType::BF16) {
847                return Err(crate::Error::op(
848                    "gated_delta_steps_with",
849                    crate::OpErrorKind::UnsupportedDType(storage),
850                ));
851            }
852            let state = state.try_astype(storage)?;
853            if storage == DType::F32 {
854                let (y, s) = imp::gated_delta_steps(q, k, v, g, beta, &state)?;
855                return Ok([y, s]);
856            }
857            let steps = q.dims().get(1).copied().unwrap_or(0) as i32;
858            let (mut ys, mut states) = (Vec::new(), Vec::new());
859            let mut s = state;
860            for t in 0..steps {
861                let at = |a: &Array| a.i((.., t..t + 1));
862                let (y, next) = gated_delta(&at(q), &at(k), &at(v), &at(g), &at(beta), &s);
863                ys.push(y);
864                states.push(next.clone());
865                s = next;
866            }
867            if steps == 0 {
868                let (y, s) = imp::gated_delta_steps(q, k, v, g, beta, &s)?;
869                return Ok([y, s]);
870            }
871            Ok([
872                crate::ops::concatenate(&ys, 1),
873                crate::ops::stack(&states, 1),
874            ])
875        },
876    );
877    (y, s)
878}
879
880/// [`gated_delta`] for a batch of sequences whose states live in a pool: sequence `b`
881/// starts from `pool[slots[b]]` (`pool` is `[slots, Hv, Dv, Dk]`, `slots` `[B]` integers,
882/// distinct) and its final state replaces it. Returns the outputs and the updated pool,
883/// which is written in place when the evaluation owns it, so decoding many sequences
884/// moves no states. Each sequence's results equal [`gated_delta`]'s on its state alone.
885#[track_caller]
886#[allow(clippy::too_many_arguments)]
887pub fn gated_delta_slots(
888    q: &Array,
889    k: &Array,
890    v: &Array,
891    g: &Array,
892    beta: &Array,
893    pool: &Array,
894    slots: &Array,
895) -> (Array, Array) {
896    let [y, p] = Array::sticky_n(
897        "gated_delta_slots",
898        Location::caller(),
899        &[q, k, v, g, beta, pool, slots],
900        || {
901            let (y, p) = imp::gated_delta_slots(q, k, v, g, beta, pool, slots)?;
902            Ok([y, p])
903        },
904    );
905    (y, p)
906}