Skip to main content

nuvec_nn/
lib.rs

1//! Neural-network building blocks on nuvec, following MLX's `mlx.nn` and `mlx.optimizers`.
2//!
3//! A model is a plain struct of layers with `#[derive(Module)]`: its parameters get MLX's
4//! names from the field names (`layers.0.weight`), and every model is a [`Tree`], so the
5//! core transforms differentiate it and return gradients of the model's own type
6//! (`grads.layers[0].weight`).
7//!
8//! - [`Module`]: names and counts ([`Module::parameters`], [`Module::num_params`],
9//!   [`Module::summary`], [`Module::display`]), freezing by path ([`Module::freeze`],
10//!   [`Module::unfreeze`]), train/eval mode ([`Module::train`]), loading and saving by name
11//!   ([`Module::load_weights`], [`Module::save_weights`]) and replacing submodules
12//!   ([`Module::map_modules`]).
13//! - Layers ([`layers`]): [`Linear`], [`QuantizedLinear`], [`Embedding`],
14//!   [`QuantizedEmbedding`], [`RmsNorm`], [`LayerNorm`], [`Rope`], [`LoraLinear`] and
15//!   [`Dropout`]; activations [`relu`], [`silu`], [`gelu_approx`] and [`gelu_fast_approx`].
16//!   Constructors take no `bool` and return no `Result`: `Linear::new(784, 32)`,
17//!   `Linear::new(32, 10).without_bias()`.
18//! - [`losses::cross_entropy`] and [`optim`]: SGD, Adam, AdamW, Lion and Muon with
19//!   learning-rate schedules, parameter groups ([`optim::MultiOptimizer`]) and gradient
20//!   clipping; the optimizer state is a tree keyed by parameter path.
21//!
22//! Layers' `forward`s, activations and losses return an [`Array`], as every nuvec op: an
23//! invalid input gives a failed array whose error surfaces at a boundary (`check()`,
24//! `eval()`, `item()`, a transform) and reports where `forward` was called.
25//!
26//! ```
27//! use nuvec::{Array, Result, array, random, value_and_grad};
28//! use nuvec_nn::optim::{Optimizer, Sgd};
29//! use nuvec_nn::{Linear, Module, Tree, losses, relu};
30//!
31//! #[derive(Clone, Module)]
32//! struct Mlp {
33//!     hidden: Linear,
34//!     out: Linear,
35//! }
36//!
37//! impl Mlp {
38//!     fn forward(&self, x: &Array) -> Array {
39//!         self.out.forward(&relu(&self.hidden.forward(x)))
40//!     }
41//! }
42//!
43//! # fn main() -> Result<()> {
44//! random::seed(0);
45//! let mut model = Mlp { hidden: Linear::new(2, 8), out: Linear::new(8, 2) };
46//! assert_eq!(model.paths(), ["hidden.weight", "hidden.bias", "out.weight", "out.bias"]);
47//!
48//! let (x, y) = (array![[1.0, -1.0], [0.5, 2.0]], array![0u32, 1]);
49//! let loss = |m: &Mlp| losses::cross_entropy(&m.forward(&x), &y, losses::Reduction::Mean);
50//! let mut opt = Sgd::new(0.5);
51//! let (before, _) = value_and_grad(loss)(&model)?;
52//! for _ in 0..20 {
53//!     let (_, grads) = value_and_grad(loss)(&model)?;   // grads: Mlp
54//!     opt.update(&mut model, &grads)?;
55//! }
56//! let (after, _) = value_and_grad(loss)(&model)?;
57//! assert!(after.item::<f32>()? < before.item::<f32>()?);
58//! # Ok(())
59//! # }
60//! ```
61//!
62//! The derive is syntactic: a field typed `Array` (or `Option<Array>`, `Vec<Array>`)
63//! needs a role (`Param` for a parameter, `#[tree(buffer)]`, `#[tree(skip)]` for a computed
64//! constant); a type alias of `Array` is not recognised. Trait-object fields are not
65//! trees: use an enum of the concrete layer types.
66//!
67//! **Stability:** no promise when this crate is depended on directly; depend on `nuvec`,
68//! whose re-exports are semver-tracked.
69//!
70//! **Status:** pre-alpha.
71
72#![forbid(unsafe_code)]
73
74use std::any::Any;
75use std::path::Path;
76
77use nuvec::tree::{LeafKind, LoadMode, Visit, VisitMut, Visitor, VisitorMut};
78use nuvec::{Array, Category, Error, Result};
79
80mod display;
81pub mod layers;
82pub mod losses;
83pub mod optim;
84mod rope_scaling;
85pub mod sharded;
86mod weights;
87
88pub use display::{ModuleDisplay, Summary, SummaryRow};
89pub use layers::{
90    Dropout, Embedding, LayerNorm, Linear, LoraBase, LoraLinear, QuantizedEmbedding,
91    QuantizedLinear, RmsNorm, Rope,
92};
93/// Derives a model's [`Tree`] impl, with a check against bare `Array` fields (see the
94/// crate documentation). Every tree is a [`Module`] (a blanket impl).
95pub use nuvec::macros::Module;
96pub use nuvec::{Param, Tree};
97pub use rope_scaling::{Llama3, LongRope, RopeScaling, Yarn};
98pub use weights::WeightSource;
99
100/// The Rust snippets of the user guide's neural-network and distributed pages, run as
101/// doctests.
102#[cfg(doctest)]
103mod site_doctests {
104    #[doc = include_str!("../../../site/content/docs/neural-networks.md")]
105    pub struct NeuralNetworks;
106    #[doc = include_str!("../../../site/content/docs/distributed.md")]
107    pub struct Distributed;
108}
109
110/// The version of this crate.
111pub const VERSION: &str = env!("CARGO_PKG_VERSION");
112
113/// Model operations, as MLX's `nn.Module`, for every [`Tree`] (a blanket impl): a model is
114/// a struct of layers with `#[derive(Module)]`, and its leaves' paths are MLX's names.
115///
116/// Leaves are parameters ([`Param`], trainable unless frozen), buffers (never trained,
117/// such as quantized weights) or plain arrays.
118pub trait Module: Tree {
119    /// Every leaf (parameters, buffers and plain arrays) with its path, in order.
120    fn parameters(&self) -> Vec<(String, Array)> {
121        self.flatten()
122    }
123
124    /// The trainable leaves with their paths, in order.
125    fn trainable_parameters(&self) -> Vec<(String, Array)> {
126        let mut out = Vec::new();
127        visit_leaves(self, |p, a, _, trainable| {
128            if trainable {
129                out.push((p.to_owned(), a.clone()));
130            }
131        });
132        out
133    }
134
135    /// The number of elements over every leaf (MLX's `tree_flatten` total).
136    fn num_params(&self) -> usize {
137        let mut n = 0;
138        visit_leaves(self, |_, a, _, _| n += a.size());
139        n
140    }
141
142    /// The number of elements over the trainable leaves.
143    fn num_trainable(&self) -> usize {
144        let mut n = 0;
145        visit_leaves(self, |_, a, _, trainable| {
146            if trainable {
147                n += a.size();
148            }
149        });
150        n
151    }
152
153    /// A table of the leaves (path, shape, dtype, trainable, bytes) with totals.
154    fn summary(&self) -> Summary {
155        Summary::of(self)
156    }
157
158    /// The nested modules with their parameter counts, for printing:
159    /// `println!("{}", model.display())`.
160    fn display(&self) -> ModuleDisplay<'_, Self> {
161        ModuleDisplay::new(self)
162    }
163
164    /// Freezes the parameters whose path matches `filter`, returning how many changed.
165    /// A matching plain `Array` leaf cannot be frozen (only [`Param`]s can): that is an
166    /// error naming the paths, and nothing changes. Buffers are never trainable anyway.
167    fn freeze(&mut self, filter: impl Fn(&str) -> bool) -> Result<usize> {
168        set_frozen(self, &filter, true)
169    }
170
171    /// Unfreezes the parameters whose path matches `filter`, returning how many changed.
172    /// Buffers (quantized weights) and integer parameters stay non-trainable.
173    fn unfreeze(&mut self, filter: impl Fn(&str) -> bool) -> Result<usize> {
174        set_frozen(self, &filter, false)
175    }
176
177    /// Sets every mode flag of the tree: training (`true`, the default) or evaluation
178    /// (`false`, where dropout is the identity), as MLX's `Module.train(mode)`. (There is
179    /// no `eval()`: it would read like `x.eval()`, which evaluates.)
180    fn train(&mut self, on: bool) {
181        struct Train(bool);
182        impl VisitorMut for Train {
183            fn leaf(&mut self, _: &str, _: &mut Array, _: LeafKind, _: Option<&mut bool>) {}
184            fn training(&mut self, _: &str, flag: &mut bool) {
185                *flag = self.0;
186            }
187        }
188        self.visit_mut(&mut VisitMut::new(&mut Train(on)));
189    }
190
191    /// Whether the tree is in training mode (its first mode flag; `true` without any).
192    fn training(&self) -> bool {
193        struct Mode(Option<bool>);
194        impl Visitor for Mode {
195            fn leaf(&mut self, _: &str, _: &Array, _: LeafKind, _: bool) {}
196            fn mode(&mut self, _: &str, training: bool) {
197                self.0.get_or_insert(training);
198            }
199        }
200        let mut m = Mode(None);
201        self.visit(&mut Visit::new(&mut m));
202        m.0.unwrap_or(true)
203    }
204
205    /// Replaces leaves by name, strictly (MLX's `load_weights(strict=True)`): every leaf
206    /// must be given with its shape and nothing else (loaded dtypes are kept). On any
207    /// problem the error lists every missing, unknown and mismatched name, and the model
208    /// is unchanged. `src` is a safetensors path or named arrays.
209    fn load_weights(&mut self, src: impl WeightSource) -> Result<()> {
210        weights::load(self, src, LoadMode::Strict)
211    }
212
213    /// Replaces the leaves given by name (MLX's `load_weights(strict=False)`): unknown
214    /// names are ignored, missing leaves keep their values, shapes must match.
215    fn load_weights_partial(&mut self, src: impl WeightSource) -> Result<()> {
216        weights::load(self, src, LoadMode::Partial)
217    }
218
219    /// Writes every leaf as safetensors with MLX's names and dtypes (MLX's
220    /// `save_weights`), evaluating them. Unloaded placeholders are an error naming them.
221    fn save_weights(&self, path: impl AsRef<Path>) -> Result<()> {
222        weights::save(self, path.as_ref(), false)
223    }
224
225    /// Writes only the trainable leaves (an adapter's weights, say).
226    fn save_trainable(&self, path: impl AsRef<Path>) -> Result<()> {
227        weights::save(self, path.as_ref(), true)
228    }
229
230    /// Calls `f(path, submodule)` on every submodule of type `T` whose path matches
231    /// `filter` (`f` may replace it, as when applying LoRA), returning how many. It stops at
232    /// the first error from `f`; submodules changed before it keep their change. Matching
233    /// submodules are not searched for further matches.
234    fn map_modules<T: Tree>(
235        &mut self,
236        filter: impl Fn(&str) -> bool,
237        mut f: impl FnMut(&str, &mut T) -> Result<()>,
238    ) -> Result<usize> {
239        struct Map<'a, T> {
240            filter: &'a dyn Fn(&str) -> bool,
241            f: &'a mut dyn FnMut(&str, &mut T) -> Result<()>,
242            count: usize,
243            error: Option<Error>,
244        }
245        impl<T: 'static> VisitorMut for Map<'_, T> {
246            fn leaf(&mut self, _: &str, _: &mut Array, _: LeafKind, _: Option<&mut bool>) {}
247            fn child(&mut self, path: &str, child: &mut dyn Any) -> bool {
248                if self.error.is_some() {
249                    return false;
250                }
251                match child.downcast_mut::<T>() {
252                    Some(m) if (self.filter)(path) => {
253                        match (self.f)(path, m) {
254                            Ok(()) => self.count += 1,
255                            Err(e) => self.error = Some(e),
256                        }
257                        false
258                    }
259                    _ => true,
260                }
261            }
262        }
263        let mut map = Map {
264            filter: &filter,
265            f: &mut f,
266            count: 0,
267            error: None,
268        };
269        self.visit_mut(&mut VisitMut::new(&mut map));
270        match map.error {
271            Some(e) => Err(e),
272            None => Ok(map.count),
273        }
274    }
275}
276
277impl<T: Tree> Module for T {}
278
279/// Calls `f(path, leaf, kind, trainable)` for every leaf.
280fn visit_leaves<T: Tree>(tree: &T, f: impl FnMut(&str, &Array, LeafKind, bool)) {
281    struct V<F>(F);
282    impl<F: FnMut(&str, &Array, LeafKind, bool)> Visitor for V<F> {
283        fn leaf(&mut self, path: &str, a: &Array, kind: LeafKind, trainable: bool) {
284            (self.0)(path, a, kind, trainable);
285        }
286    }
287    tree.visit(&mut Visit::new(&mut V(f)));
288}
289
290fn set_frozen<T: Tree>(tree: &mut T, filter: &dyn Fn(&str) -> bool, frozen: bool) -> Result<usize> {
291    if frozen {
292        let mut plain = Vec::new();
293        visit_leaves(tree, |p, _, kind, _| {
294            if kind == LeafKind::Array && filter(p) {
295                plain.push(p.to_owned());
296            }
297        });
298        if !plain.is_empty() {
299            return Err(Error::InvalidArgument(format!(
300                "freeze: plain arrays cannot be frozen (make them `Param`s): {}",
301                plain.join(", ")
302            )));
303        }
304    }
305    struct Set<'a> {
306        filter: &'a dyn Fn(&str) -> bool,
307        frozen: bool,
308        changed: usize,
309    }
310    impl VisitorMut for Set<'_> {
311        fn leaf(&mut self, path: &str, a: &mut Array, kind: LeafKind, flag: Option<&mut bool>) {
312            let Some(flag) = flag else { return };
313            if kind != LeafKind::Param || !(self.filter)(path) || *flag == self.frozen {
314                return;
315            }
316            // Integer parameters (packed weights, say) are never trained.
317            if !self.frozen && !matches!(a.dtype().category(), Category::Float | Category::Complex)
318            {
319                return;
320            }
321            *flag = self.frozen;
322            self.changed += 1;
323        }
324    }
325    let mut set = Set {
326        filter,
327        frozen,
328        changed: 0,
329    };
330    tree.visit_mut(&mut VisitMut::new(&mut set));
331    Ok(set.changed)
332}
333
334/// `max(x, 0)`, as MLX's `nn.relu`.
335#[track_caller]
336pub fn relu(x: &Array) -> Array {
337    x.maximum(0.0)
338}
339
340/// `x · sigmoid(x)`, as MLX's `nn.silu`.
341#[track_caller]
342pub fn silu(x: &Array) -> Array {
343    x.multiply(x.sigmoid())
344}
345
346/// GELU with the tanh approximation, as MLX's `nn.gelu_approx`:
347/// `0.5 x (1 + tanh(√(2/π) (x + 0.044715 x³)))`.
348#[track_caller]
349pub fn gelu_approx(x: &Array) -> Array {
350    // `x³` as MLX's `x**3` (one power, one rounding; not two products).
351    let cube = x.power(3.0);
352    let inner = x
353        .add(cube.multiply(0.044715))
354        .multiply((2.0 / std::f64::consts::PI).sqrt());
355    x.multiply(0.5).multiply(inner.tanh().add(1.0))
356}
357
358/// `x · sigmoid(1.702 x)`, as MLX's `nn.gelu_fast_approx`.
359#[track_caller]
360pub fn gelu_fast_approx(x: &Array) -> Array {
361    x.multiply(x.multiply(1.702).sigmoid())
362}
363
364/// A compiled function of a tuple of arrays (traced once per input signature, element-wise
365/// chains fused into one kernel), as mlx-lm compiles its activations.
366type Compiled<T> = Box<dyn Fn(&T) -> Result<Array> + Send + Sync>;
367
368/// `x` as a 0-d array of `like`'s dtype: a weak scalar (MLX rounds a Python float to the
369/// array's dtype), passed to compiled functions as an input so one trace serves every value.
370fn weak(value: f32, like: &Array) -> Array {
371    Array::from(value).astype(like.dtype())
372}
373
374/// `silu(gate) · x` in one kernel, as mlx-lm's compiled `swiglu`.
375#[track_caller]
376pub fn swiglu(gate: &Array, x: &Array) -> Array {
377    static F: std::sync::LazyLock<Compiled<(Array, Array)>> = std::sync::LazyLock::new(|| {
378        Box::new(nuvec::compile(|(g, x): &(Array, Array)| {
379            silu(g).multiply(x)
380        }))
381    });
382    F(&(gate.clone(), x.clone())).unwrap_or_else(|_| silu(gate).multiply(x))
383}
384
385/// gpt-oss's clamped SwiGLU in one kernel, as mlx-lm's compiled `swiglu_oai`:
386/// `(clip(x, −limit, limit) + 1) · g · sigmoid(alpha · g)` with `g = min(gate, limit)`
387/// (gpt-oss: `alpha` 1.702, `limit` 7).
388///
389/// ```
390/// use nuvec::array;
391///
392/// let y = nuvec_nn::swiglu_oai(&array![10.0f32, -1.0], &array![9.0f32, 0.5], 1.702, 7.0);
393/// let v = y.to_vec::<f32>().unwrap();
394/// assert!((v[0] - 8.0 * 7.0 / (1.0 + (-1.702f32 * 7.0).exp())).abs() < 1e-4);
395/// ```
396#[track_caller]
397pub fn swiglu_oai(gate: &Array, x: &Array, alpha: f32, limit: f32) -> Array {
398    fn graph(g: &Array, x: &Array, alpha: &Array, limit: &Array) -> Array {
399        let g = g.minimum(limit);
400        let x = x.maximum(limit.negative()).minimum(limit);
401        x.add(1.0)
402            .multiply(g.multiply(alpha.multiply(&g).sigmoid()))
403    }
404    static F: std::sync::LazyLock<Compiled<(Array, Array, Array, Array)>> =
405        std::sync::LazyLock::new(|| {
406            Box::new(nuvec::compile(
407                |(g, x, a, l): &(Array, Array, Array, Array)| graph(g, x, a, l),
408            ))
409        });
410    let (a, l) = (weak(alpha, gate), weak(limit, gate));
411    F(&(gate.clone(), x.clone(), a.clone(), l.clone())).unwrap_or_else(|_| graph(gate, x, &a, &l))
412}
413
414/// `gelu_approx(gate) · x` in one kernel, as mlx-lm's compiled `geglu` (Gemma 4).
415#[track_caller]
416pub fn geglu(gate: &Array, x: &Array) -> Array {
417    static F: std::sync::LazyLock<Compiled<(Array, Array)>> = std::sync::LazyLock::new(|| {
418        Box::new(nuvec::compile(|(g, x): &(Array, Array)| {
419            gelu_approx(g).multiply(x)
420        }))
421    });
422    F(&(gate.clone(), x.clone())).unwrap_or_else(|_| gelu_approx(gate).multiply(x))
423}
424
425/// `tanh(x / cap) · cap` in one kernel, as mlx-lm's compiled `logit_softcap` (Gemma 4's
426/// final logits: cap 30). A cap that is not positive and finite gives a failed array.
427///
428/// No attention option caps scores; a soft-capped attention is composed from public ops:
429///
430/// ```
431/// use nuvec::{DType, ops, random};
432///
433/// let (q, k, v) = (random::normal([1, 2, 4, 8], DType::F32, 0.0, 1.0, None),
434///                  random::normal([1, 2, 6, 8], DType::F32, 0.0, 1.0, None),
435///                  random::normal([1, 2, 6, 8], DType::F32, 0.0, 1.0, None));
436/// let (scale, cap) = (8f32.sqrt().recip(), 50.0);
437/// let scores = q.matmul(&k.transpose([0, 1, 3, 2])).multiply(scale);
438/// let probs = nuvec_nn::logit_softcap(&scores, cap).softmax(-1); // precise for half types
439/// let out = probs.matmul(&v);
440/// assert_eq!(out.shape(), [1, 2, 4, 8]);
441/// ```
442#[track_caller]
443pub fn logit_softcap(x: &Array, cap: f32) -> Array {
444    if !(cap > 0.0 && cap.is_finite()) {
445        return Array::failure(
446            "logit_softcap",
447            nuvec::OpErrorKind::InvalidArgument(format!(
448                "the cap must be positive and finite, got {cap}"
449            )),
450        );
451    }
452    fn graph(x: &Array, cap: &Array) -> Array {
453        x.divide(cap).tanh().multiply(cap)
454    }
455    static F: std::sync::LazyLock<Compiled<(Array, Array)>> = std::sync::LazyLock::new(|| {
456        Box::new(nuvec::compile(|(x, c): &(Array, Array)| graph(x, c)))
457    });
458    let c = weak(cap, x);
459    F(&(x.clone(), c.clone())).unwrap_or_else(|_| graph(x, &c))
460}