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/// Layer normalisation over the last axis, `(x - mean) / sqrt(var + eps) · weight + bias`,
89/// as MLX's `fast.layer_norm`: statistics in float32, the result in the promoted type.
90#[track_caller]
91pub fn layer_norm<'a>(
92 x: &Array,
93 weight: impl Into<Option<&'a Array>>,
94 bias: impl Into<Option<&'a Array>>,
95 eps: f32,
96) -> Array {
97 let weight = weight.into();
98 let bias = bias.into();
99 let ins = operands(&[x], &[weight, bias]);
100 Array::sticky("layer_norm", Location::caller(), &ins, || {
101 imp::layer_norm(x, weight, bias, eps)
102 })
103}
104
105/// Rotary position embeddings, as MLX's `fast.rope`, of `x` (at least 3-D: `[B, ...,
106/// T, D]`, positions along `T`): the first `dims` features (even) are rotated in pairs,
107/// the two halves (or interleaved pairs, `traditional`) by angle `(offset + t) · scale ·
108/// θᵢ`, with `θᵢ = base^(-2i/dims)` or `1 / freqs[i]`. Exactly one of `base` and `freqs`
109/// is given. `offset` is an integer scalar or one per batch entry.
110#[track_caller]
111pub fn rope<'a>(
112 x: &Array,
113 dims: i32,
114 traditional: bool,
115 base: Option<f32>,
116 scale: f32,
117 offset: &Array,
118 freqs: impl Into<Option<&'a Array>>,
119) -> Array {
120 let freqs = freqs.into();
121 let ins = operands(&[x, offset], &[freqs]);
122 Array::sticky("rope", Location::caller(), &ins, || {
123 let dims = usize::try_from(dims).map_err(|_| {
124 crate::Error::InvalidArgument(format!("rope: dims must not be negative, got {dims}"))
125 })?;
126 imp::rope(x, dims, traditional, base, scale, offset, freqs)
127 })
128}
129
130/// Cross entropy over the last axis of `logits` against integer class indices `targets`
131/// (the logits' shape without the last axis; negative indices count from the end), as
132/// MLX's `fast.cross_entropy`: `logsumexp(logits) - logits[target]` per row, in float32.
133#[track_caller]
134pub fn cross_entropy(logits: &Array, targets: &Array) -> Array {
135 Array::sticky(
136 "cross_entropy",
137 Location::caller(),
138 &[logits, targets],
139 || imp::cross_entropy(logits, targets),
140 )
141}
142
143/// An attention layer's query or key preparation in one kernel: each head's features of
144/// `x` (`[B, H, L, D]`, any strides, as the transposed projection) optionally normalised
145/// as [`rms_norm`] with `norm = (weight, eps)` (Qwen 3's `q_norm` and `k_norm`), then
146/// rotated as [`rope`] at positions `offset..offset + L`.
147///
148/// Without `cache`, the result is a new `[B, H, L, D]` array. With `cache` (`[B, H, C, D]`,
149/// `C ≥ offset + L`), the result is `cache` with positions `offset..offset + L` replaced
150/// (as [`crate::Array::at`] with `set`, and written in place when the evaluation owns the
151/// cache), so keys go into a key cache without a separate copy.
152///
153/// The results equal [`rms_norm`], [`rope`] and the update composed (on the GPU, bit for
154/// bit); differing dtypes, other ranks and head sizes over 1024 run that composition.
155#[track_caller]
156pub fn norm_rope<'a>(
157 x: &Array,
158 norm: Option<(&Array, f32)>,
159 rotation: Rotation<'_>,
160 offset: i32,
161 cache: impl Into<Option<&'a Array>>,
162) -> Array {
163 let cache = cache.into();
164 let ins = operands(&[x], &[norm.map(|n| n.0), rotation.freqs, cache]);
165 Array::sticky("norm_rope", Location::caller(), &ins, || {
166 let offset = usize::try_from(offset).map_err(|_| {
167 crate::Error::InvalidArgument(format!(
168 "norm_rope: offset must not be negative, got {offset}"
169 ))
170 })?;
171 imp::norm_rope(x, norm, rotation, offset, cache)
172 })
173}
174
175/// Gemma 4's keys and values from one projection (`attention_k_eq_v`): K is `x`
176/// (`[B, H, L, D]`, any strides) normalised with `k_norm = (weight, eps)` and rotated at
177/// positions `offset..` (as [`norm_rope`]); V is `x` RMS-normalised without a weight
178/// (`v_eps`; mlx-lm's `RMSNormNoScale`). With `caches = (k_cache, v_cache)` both are written
179/// at `offset` (in place when the evaluation owns them), as [`norm_rope`] writes one.
180///
181/// Each is one fused kernel reading `x` (V rotates by angle 0: infinite wavelengths, an
182/// exact identity), and equals the composition ([`rms_norm`], [`rope`], the update) bit for
183/// bit on the GPU.
184///
185/// ```
186/// use nuvec_core::{DType, fast::{self, Rotation}, ops};
187///
188/// let x = ops::ones([1, 2, 3, 8], DType::F32);
189/// let w = ops::ones([8], DType::F32);
190/// let rotation = Rotation { dims: 8, traditional: false, base: Some(1e4), scale: 1.0, freqs: None };
191/// let (k, v) = fast::norm_rope_kv(&x, Some((&w, 1e-6)), 1e-6, rotation, 0, None);
192/// assert_eq!((k.shape(), v.shape()), (&[1, 2, 3, 8][..], &[1, 2, 3, 8][..]));
193/// ```
194#[track_caller]
195pub fn norm_rope_kv<'a>(
196 x: &Array,
197 k_norm: Option<(&Array, f32)>,
198 v_eps: f32,
199 rotation: Rotation<'_>,
200 offset: i32,
201 caches: impl Into<Option<(&'a Array, &'a Array)>>,
202) -> (Array, Array) {
203 let caches = caches.into();
204 let k = norm_rope(x, k_norm, rotation, offset, caches.map(|c| c.0));
205 let d = x.shape().last().copied().unwrap_or(0);
206 let ones = crate::ops::ones([d], x.dtype());
207 let still = crate::ops::full([d / 2], f32::INFINITY);
208 let identity = Rotation {
209 dims: d - d % 2,
210 traditional: false,
211 base: None,
212 scale: 1.0,
213 freqs: Some(&still),
214 };
215 let v = match d {
216 // No pair to rotate: the norm, written into the cache as `norm_rope` would.
217 0 | 1 => {
218 let v = crate::fast::rms_norm(x, None, v_eps);
219 match caches {
220 Some((_, cache)) => {
221 let l = x.shape().get(2).copied().unwrap_or(0);
222 cache.clone().at((.., .., offset..offset + l)).set(&v)
223 }
224 None => v,
225 }
226 }
227 _ => norm_rope(
228 x,
229 Some((&ones, v_eps)),
230 identity,
231 offset,
232 caches.map(|c| c.1),
233 ),
234 };
235 (k, v)
236}
237
238/// Attention over a paged KV cache, for a batch of sequences of different lengths.
239///
240/// Keys and values live in blocks of positions, `k_cache` and `v_cache` being
241/// `[blocks, kv_heads, block_size, D]`; sequence `b` keeps its positions in the blocks
242/// `block_tables[b]` lists (`[B, max_blocks]`, integers; entries past the sequence's blocks
243/// are ignored). `q` is `[B, Hq, L, D]` (`Hq` a multiple of `kv_heads`): the last `L`
244/// positions of each sequence, whose keys are already in the cache. Query `l` of sequence
245/// `b` attends positions `0..=lengths[b] − L + l` (causal at the end; `lengths[b] ≥ L`),
246/// with `options`' window only the last `window` of those, and its sinks (`[Hq]`) in the
247/// softmax; an array mask is an error (`max_score_bytes` bounds only the composition).
248/// Returns `[B, Hq, L, D]`. Blocks wholly before a sequence's window are never read, so
249/// they may already hold other sequences' keys.
250///
251/// Out-of-range inputs never read outside the caches: block ids are clamped to
252/// `0..blocks` and lengths to `0..=max_blocks · block_size` (the table's capacity), on
253/// every device.
254///
255/// On the GPU this is MLX's vector attention (one pass, or two for long caches) reading
256/// keys through the block table, for head sizes 64, 128, 256 and 512 and power-of-two block
257/// sizes (others run the composition). Each sequence's result equals
258/// [`scaled_dot_product_attention`]'s vector kernel over the same keys stored contiguously
259/// when both take the same number of passes.
260#[track_caller]
261pub fn paged_attention(
262 q: &Array,
263 k_cache: &Array,
264 v_cache: &Array,
265 block_tables: &Array,
266 lengths: &Array,
267 scale: f32,
268 options: &AttentionOptions<'_>,
269) -> Array {
270 let ins = operands(
271 &[q, k_cache, v_cache, block_tables, lengths],
272 &[options.sinks],
273 );
274 Array::sticky("paged_attention", Location::caller(), &ins, || {
275 imp::paged_attention(q, k_cache, v_cache, block_tables, lengths, scale, options)
276 })
277}
278
279/// `cache` (`[blocks, kv_heads, block_size, D]`) with the positions `new` holds
280/// (`[T, kv_heads, D]`) written into their slots: `slots[t]` (integers, `[T]`) is block
281/// `slots[t] / block_size`, position `slots[t] % block_size`. Slots must be distinct;
282/// out-of-range slots (negative, or at least `blocks · block_size`) are skipped. In place
283/// when the evaluation owns the cache.
284#[track_caller]
285pub fn paged_write(cache: &Array, new: &Array, slots: &Array) -> Array {
286 Array::sticky(
287 "paged_write",
288 Location::caller(),
289 &[cache, new, slots],
290 || imp::paged_write(cache, new, slots),
291 )
292}
293
294/// The options of [`scaled_dot_product_attention`] and [`plan_attention`]: the mask,
295/// attention sinks, a sliding window and a cap on score memory. Build them with
296/// [`AttentionOptions::causal`] or `Default` and the chained setters:
297///
298/// ```
299/// use nuvec_core::fast::AttentionOptions;
300///
301/// let opts = AttentionOptions::causal().window(4096).max_score_bytes(512 << 20);
302/// assert_eq!(opts.window, Some(4096));
303/// ```
304///
305/// This is the one options type of attention: new features (window and head-size-512
306/// kernels, backward routing) extend it, never a second entry point.
307#[non_exhaustive]
308#[derive(Clone, Copy, Debug, Default)]
309pub struct AttentionOptions<'a> {
310 /// The mask: none (the default), causal, or an array.
311 pub mask: AttentionMask<'a>,
312 /// Attention sinks (`[Hq]`): one logit per query head that takes part in the softmax
313 /// but attends to nothing.
314 pub sinks: Option<&'a Array>,
315 /// A sliding window: query `i` attends only to the `window` keys up to and including
316 /// its position (as mlx-lm's `create_causal_mask(..., window_size)`). Needs the causal
317 /// mask.
318 pub window: Option<i32>,
319 /// A cap, in bytes, on every score-sized intermediate (scores, masked scores, the
320 /// float32 sink-softmax intermediates, materialised masks); see
321 /// [`scaled_dot_product_attention`].
322 pub max_score_bytes: Option<usize>,
323}
324
325impl<'a> AttentionOptions<'a> {
326 /// Causal masking: query `i` attends to keys `0..=i + Lk - Lq` (the queries are the last
327 /// `Lq` positions).
328 pub fn causal() -> Self {
329 AttentionOptions {
330 mask: AttentionMask::Causal,
331 ..Self::default()
332 }
333 }
334
335 /// A boolean (true attends) or additive mask, broadcastable to `[B, Hq, Lq, Lk]`.
336 #[must_use]
337 pub fn mask(mut self, mask: &'a Array) -> Self {
338 self.mask = AttentionMask::Array(mask);
339 self
340 }
341
342 /// Attention sinks, `[Hq]` (`None` for none).
343 #[must_use]
344 pub fn sinks(mut self, sinks: impl Into<Option<&'a Array>>) -> Self {
345 self.sinks = sinks.into();
346 self
347 }
348
349 /// A sliding window of `keys` keys (needs [`AttentionOptions::causal`]): query `i`
350 /// attends to keys `i + Lk − Lq − keys < j ≤ i + Lk − Lq`. The vector and tiled kernels
351 /// skip the keys before the window, so a full cache needs no slicing; a cache that keeps
352 /// only the last `W − 1 + L` positions (nuvec-lm's sliding-window `KvCache`) passes
353 /// just those, with the same result:
354 ///
355 /// ```
356 /// use nuvec_core::{DType, fast::{self, AttentionOptions}, random};
357 ///
358 /// let (w, l) = (4, 2);
359 /// let q = random::normal([1, 2, l, 8], DType::F32, 0.0, 1.0, None);
360 /// let k = random::normal([1, 2, 10, 8], DType::F32, 0.0, 1.0, None);
361 /// let opts = AttentionOptions::causal().window(w);
362 /// let full = fast::scaled_dot_product_attention(&q, &k, &k, 0.35, &opts);
363 /// let tail = k.i((.., .., 10 - (w - 1 + l)..));
364 /// let sliced = fast::scaled_dot_product_attention(&q, &tail, &tail, 0.35, &opts);
365 /// let diff = full.subtract(&sliced).abs().max(..).item::<f32>().unwrap();
366 /// assert!(diff < 1e-6);
367 /// ```
368 #[must_use]
369 pub fn window(mut self, keys: i32) -> Self {
370 self.window = Some(keys);
371 self
372 }
373
374 /// Caps every score-sized intermediate at `bytes` (positive).
375 #[must_use]
376 pub fn max_score_bytes(mut self, bytes: usize) -> Self {
377 self.max_score_bytes = Some(bytes);
378 self
379 }
380}
381
382impl<'a> From<AttentionMask<'a>> for AttentionOptions<'a> {
383 /// Options with this mask and nothing else.
384 fn from(mask: AttentionMask<'a>) -> Self {
385 AttentionOptions {
386 mask,
387 ..Self::default()
388 }
389 }
390}
391
392/// The kernel family an attention call runs (see [`plan_attention`]).
393#[non_exhaustive]
394#[derive(Clone, Copy, Debug, PartialEq, Eq)]
395pub enum AttentionKernel {
396 /// At most 8 queries: MLX's vector kernels. `passes` is 1 or 2; `blocks` the key
397 /// partitions of the first of two passes (1 for one pass; the largest over
398 /// dispatches); `dispatches` is more than 1 when query rows need different one-query
399 /// configurations (the serial rule).
400 Vector {
401 /// Passes over the keys (1 or 2).
402 passes: u8,
403 /// Key partitions of the first pass.
404 blocks: i32,
405 /// Dispatches (groups of query rows with one configuration).
406 dispatches: i32,
407 },
408 /// MLX's tiled (steel) attention kernel: no scores are materialised.
409 Tiled,
410 /// The composition: `softmax(scale · q kᵀ + mask) v` from matrix products.
411 Composed,
412 /// The composition per block of `rows` queries, one block after another.
413 ComposedBlocks {
414 /// Queries per block (the last block may have fewer).
415 rows: i32,
416 },
417 /// MLX's blocked prefill at head size 512 (half precision, causal, `Lq ≥ 1024`, no
418 /// array mask or sinks): blocks of `rows` queries (256, halved under a cap), each over
419 /// only the keys its rows see.
420 BlockedD512 {
421 /// Queries per block (the last block may have fewer).
422 rows: i32,
423 },
424}
425
426/// How an attention call runs and the memory it holds (see [`plan_attention`]).
427#[non_exhaustive]
428#[derive(Clone, Copy, Debug, PartialEq, Eq)]
429pub struct AttentionPlan {
430 /// The kernel family.
431 pub kernel: AttentionKernel,
432 /// Bytes held beyond the inputs and the output while the call runs (for one block when
433 /// blocked; see the `# Memory` section of [`scaled_dot_product_attention`]).
434 pub scratch_bytes: usize,
435 /// The largest single score-sized intermediate: what `max_score_bytes` bounds.
436 pub max_intermediate_bytes: usize,
437 /// Whether the cap changed the choice.
438 pub capped: bool,
439}
440
441/// How [`scaled_dot_product_attention`] will run for these shapes (`[B, H, L, D]` each),
442/// dtype and options, on the calling thread's default device, without evaluating or
443/// allocating anything. Masks and sinks in `options` are read for their shape and dtype
444/// only. Errors are those the attention call would report.
445///
446/// The plan is a pure function of the shapes, the dtype, the options and the GPU's
447/// generation and class: the same inputs give the same plan in every call, thread and
448/// process, whatever memory is free; the attention op runs exactly this plan (one routing
449/// function).
450///
451/// ```
452/// use nuvec_core::{DType, fast::{AttentionKernel, AttentionOptions, plan_attention}};
453///
454/// # fn main() -> nuvec_core::Result<()> {
455/// let (q, k) = ([1, 16, 1024, 256], [1, 2, 33_792, 256]);
456/// let opts = AttentionOptions::causal().max_score_bytes(512 << 20);
457/// let plan = plan_attention(&q, &k, &k, DType::BF16, &opts)?;
458/// assert!(plan.max_intermediate_bytes <= 512 << 20);
459/// assert!(matches!(
460/// plan.kernel,
461/// AttentionKernel::Tiled | AttentionKernel::ComposedBlocks { .. }
462/// ));
463/// # Ok(())
464/// # }
465/// ```
466pub fn plan_attention(
467 q: &[i32],
468 k: &[i32],
469 v: &[i32],
470 dtype: crate::DType,
471 options: &AttentionOptions<'_>,
472) -> crate::Result<AttentionPlan> {
473 imp::plan_attention(q, k, v, dtype, options)
474}
475
476/// Scaled dot-product attention, as MLX's `fast.scaled_dot_product_attention`:
477/// `softmax(scale · q kᵀ + mask) v` over `[B, H, L, D]` arrays, with the mask, sinks,
478/// window and score cap in [`AttentionOptions`].
479///
480/// `k` and `v` may have fewer heads than `q` (grouped-query attention; `Hq` a multiple of
481/// `Hkv`), and `v` its own head size. Sinks (`[Hq]`) add one logit per head that takes part
482/// in the softmax but attends to nothing.
483///
484/// ```
485/// use nuvec_core::{DType, fast::{self, AttentionOptions}, ops};
486///
487/// let q = ops::ones([1, 4, 3, 64], DType::F32);
488/// let kv = ops::ones([1, 2, 5, 64], DType::F32);
489/// let out = fast::scaled_dot_product_attention(&q, &kv, &kv, 0.125, &AttentionOptions::causal());
490/// assert_eq!(out.shape(), [1, 4, 3, 64]);
491/// ```
492///
493/// # Routing
494///
495/// [`plan_attention`] decides, and reports, how the call runs. On the GPU, up to 8 queries
496/// use MLX's vector kernels, more use its tiled kernel or the composition (as MLX: the
497/// composition is faster for some head sizes and shapes); the CPU runs the composition.
498/// At head size 512, causal prefill of 1,024 queries or more in half precision (no array
499/// mask or sinks) runs as MLX's blocked path: the composition per block of 256 queries,
500/// each over only the keys its rows see, a key head's grouped queries in one product (bit
501/// for bit MLX's result, one block's scores live). A window narrows the keys of every
502/// composition block and is skipped inside the vector and tiled kernels.
503///
504/// **The serial rule.** With 2 to 8 queries (speculative verification), each query row
505/// takes the configuration (passes, key partitions, kernel variant) of the same row run
506/// alone as one query over its causal key prefix, and rows with equal configurations share
507/// a dispatch. So each row's result equals, bit for bit, the one-query call over its
508/// prefix (MLX picks one configuration for all rows instead). Rows that need the
509/// one-key-per-step kernel variant (float32 at head size 256) and do not fit one
510/// threadgroup together keep MLX's composition instead.
511///
512/// **The cap.** With `max_score_bytes = c`, no score-sized intermediate larger than `c`
513/// bytes is allocated (element size 4 with sinks or float32): either the tiled kernel
514/// runs (it materialises no scores) or the composition runs in blocks of query rows, one
515/// block after another (each block's queries depend on the previous block's output, so one
516/// block's scratch is live at a time), whichever measured faster. The result equals the
517/// uncapped composition within rounding. A cap below one query row's intermediate selects
518/// the tiled kernel where it supports the shape and is an `InvalidArgument` error naming
519/// the smallest admissible cap otherwise; a cap of 0 is invalid. Vector kernels (decode)
520/// never materialise scores, so the cap does not affect them.
521///
522/// # Memory
523///
524/// Scratch beyond inputs and output ([`AttentionPlan::scratch_bytes`]), with `s` the
525/// dtype's size, `r` the rows of a block (`Lq` unblocked), `L` its keys and `n = B·Hq·r·L`:
526/// vector one pass 0; vector two passes `B·Hq·rows·blocks·(Dv·s + 8)` for the largest
527/// dispatch (plus the gathered queries and outputs with several dispatches); tiled 0;
528/// composition `n·s` (the scores, masked and normalised in place) plus the larger of the
529/// scaled queries (`B·Hq·r·D·s`) and, with sinks below float32, the float32 softmax copy
530/// (`n·4`; three of them in blocks under 16 MiB), plus `r·L` bytes of causal mask; blocked
531/// (capped or head-512), one block's composition plus the output's bytes (the blocks
532/// before concatenation).
533#[track_caller]
534pub fn scaled_dot_product_attention(
535 q: &Array,
536 k: &Array,
537 v: &Array,
538 scale: f32,
539 options: &AttentionOptions<'_>,
540) -> Array {
541 let mask_array = match options.mask {
542 AttentionMask::Array(m) => Some(m),
543 _ => None,
544 };
545 let ins = operands(&[q, k, v], &[mask_array, options.sinks]);
546 Array::sticky(
547 "scaled_dot_product_attention",
548 Location::caller(),
549 &ins,
550 || imp::scaled_dot_product_attention(q, k, v, scale, options),
551 )
552}
553
554/// The gated delta rule, Gated DeltaNet's recurrence (as mlx-lm's `gated_delta_update`
555/// once its gates are computed). For each step `t` and value head, with `S` its state
556/// (`[Dv, Dk]`):
557///
558/// `S ← g_t · S`, then `S ← S + β_t (v_t − S k_t) k_tᵀ`, and `y_t = S q_t`.
559///
560/// Shapes: `q` and `k` are `[B, T, Hk, Dk]`, `v` is `[B, T, Hv, Dv]` (value head `h` reads
561/// key head `h / (Hv / Hk)`), `g` and `beta` are `[B, T, Hv]` and `state` is
562/// `[B, Hv, Dv, Dk]`. Returns `y` (`[B, T, Hv, Dv]`, in `q`'s dtype) and the final state
563/// (in `state`'s dtype); the recurrence runs in float32. On the GPU it is one kernel
564/// (mlx-lm's, for `Dk` a multiple of 32), else the steps as ordinary operations, which also
565/// define the derivatives.
566///
567/// ```
568/// use nuvec_core::{DType, fast, ops::{ones, zeros}};
569///
570/// let q = ones([1, 2, 1, 32], DType::F32);
571/// let v = ones([1, 2, 1, 4], DType::F32);
572/// let (g, beta) = (ones([1, 2, 1], DType::F32), ones([1, 2, 1], DType::F32));
573/// let state = zeros([1, 1, 4, 32], DType::F32);
574/// let (y, s) = fast::gated_delta(&(&q * 0.25), &(&q * 0.125), &v, &g, &(&beta * 0.5), &state);
575/// // 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,
576/// // so S gains (1 − 0.25) · 0.5 · v kᵀ, and y = 0.875.
577/// assert_eq!(y.to_vec::<f32>().unwrap(), [0.5, 0.5, 0.5, 0.5, 0.875, 0.875, 0.875, 0.875]);
578/// assert_eq!(s.shape(), [1, 1, 4, 32]);
579/// ```
580#[track_caller]
581pub fn gated_delta(
582 q: &Array,
583 k: &Array,
584 v: &Array,
585 g: &Array,
586 beta: &Array,
587 state: &Array,
588) -> (Array, Array) {
589 let [y, s] = Array::sticky_n(
590 "gated_delta",
591 Location::caller(),
592 &[q, k, v, g, beta, state],
593 || {
594 let (y, s) = imp::gated_delta(q, k, v, g, beta, state)?;
595 Ok([y, s])
596 },
597 );
598 (y, s)
599}
600
601/// [`gated_delta`] returning the state after every step, `[B, T, Hv, Dv, Dk]` (step `t`'s
602/// state at index `t`; the last is [`gated_delta`]'s final state), so that a caller can
603/// roll the recurrence back to any step, as speculative decoding does after a rejected
604/// draft. The outputs equal [`gated_delta`]'s bit for bit; writing every step's state
605/// costs `T` times the state's bytes.
606#[track_caller]
607pub fn gated_delta_steps(
608 q: &Array,
609 k: &Array,
610 v: &Array,
611 g: &Array,
612 beta: &Array,
613 state: &Array,
614) -> (Array, Array) {
615 let [y, s] = Array::sticky_n(
616 "gated_delta_steps",
617 Location::caller(),
618 &[q, k, v, g, beta, state],
619 || {
620 let (y, s) = imp::gated_delta_steps(q, k, v, g, beta, state)?;
621 Ok([y, s])
622 },
623 );
624 (y, s)
625}
626
627/// Options of [`gated_delta_steps_with`].
628#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
629#[non_exhaustive]
630pub struct StepOptions {
631 /// The dtype states are stored in between steps (default: `state`'s).
632 pub storage: Option<DType>,
633}
634
635impl StepOptions {
636 /// Stores states in `dtype` between steps.
637 pub fn storage(mut self, dtype: DType) -> Self {
638 self.storage = Some(dtype);
639 self
640 }
641}
642
643/// [`gated_delta_steps`] for a runtime that stores its states in another dtype than
644/// float32 (bfloat16, say): each step's output is computed from the state the previous
645/// step stored, and its state is then stored in `options.storage`, so `states[t]` and the
646/// outputs equal `t + 1` serial [`gated_delta`] calls on a state kept in that dtype, bit
647/// for bit (each step runs the kernel serial decode runs). With float32 storage it is
648/// [`gated_delta_steps`].
649#[track_caller]
650pub fn gated_delta_steps_with(
651 q: &Array,
652 k: &Array,
653 v: &Array,
654 g: &Array,
655 beta: &Array,
656 state: &Array,
657 options: &StepOptions,
658) -> (Array, Array) {
659 let storage = options.storage.unwrap_or(state.dtype());
660 let [y, s] = Array::sticky_n(
661 "gated_delta_steps_with",
662 Location::caller(),
663 &[q, k, v, g, beta, state],
664 || {
665 if !matches!(storage, DType::F32 | DType::F16 | DType::BF16) {
666 return Err(crate::Error::op(
667 "gated_delta_steps_with",
668 crate::OpErrorKind::UnsupportedDType(storage),
669 ));
670 }
671 let state = state.try_astype(storage)?;
672 if storage == DType::F32 {
673 let (y, s) = imp::gated_delta_steps(q, k, v, g, beta, &state)?;
674 return Ok([y, s]);
675 }
676 let steps = q.dims().get(1).copied().unwrap_or(0) as i32;
677 let (mut ys, mut states) = (Vec::new(), Vec::new());
678 let mut s = state;
679 for t in 0..steps {
680 let at = |a: &Array| a.i((.., t..t + 1));
681 let (y, next) = gated_delta(&at(q), &at(k), &at(v), &at(g), &at(beta), &s);
682 ys.push(y);
683 states.push(next.clone());
684 s = next;
685 }
686 if steps == 0 {
687 let (y, s) = imp::gated_delta_steps(q, k, v, g, beta, &s)?;
688 return Ok([y, s]);
689 }
690 Ok([
691 crate::ops::concatenate(&ys, 1),
692 crate::ops::stack(&states, 1),
693 ])
694 },
695 );
696 (y, s)
697}
698
699/// [`gated_delta`] for a batch of sequences whose states live in a pool: sequence `b`
700/// starts from `pool[slots[b]]` (`pool` is `[slots, Hv, Dv, Dk]`, `slots` `[B]` integers,
701/// distinct) and its final state replaces it. Returns the outputs and the updated pool,
702/// which is written in place when the evaluation owns it, so decoding many sequences
703/// moves no states. Each sequence's results equal [`gated_delta`]'s on its state alone.
704#[track_caller]
705#[allow(clippy::too_many_arguments)]
706pub fn gated_delta_slots(
707 q: &Array,
708 k: &Array,
709 v: &Array,
710 g: &Array,
711 beta: &Array,
712 pool: &Array,
713 slots: &Array,
714) -> (Array, Array) {
715 let [y, p] = Array::sticky_n(
716 "gated_delta_slots",
717 Location::caller(),
718 &[q, k, v, g, beta, pool, slots],
719 || {
720 let (y, p) = imp::gated_delta_slots(q, k, v, g, beta, pool, slots)?;
721 Ok([y, p])
722 },
723 );
724 (y, p)
725}