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}