Skip to main content

nuvec_core/
transforms.rs

1//! Function transforms: reverse-mode ([`vjp`], [`grad`], [`value_and_grad`]) and
2//! forward-mode ([`jvp`]) automatic differentiation.
3//!
4//! As in MLX, the function runs on *tracer* inputs and records a lazy graph. The derivative
5//! is another lazy graph, built from each primitive's rule, so transforms compose: the
6//! gradient of a gradient is a second derivative. Tracers are zero-copy identity views of
7//! the inputs. While a transform runs, evaluating a node that depends on a tracer keeps its
8//! graph instead of detaching it, so calling `eval` inside the function is allowed.
9
10use std::cell::Cell;
11use std::collections::HashSet;
12use std::sync::Arc;
13
14use crate::array::{Node, NodeMap, NodeSet, State};
15use crate::cast::Canon;
16use crate::derivatives::zeros_like;
17use crate::primitive::Primitive;
18use crate::{Array, Error, Result, ops};
19
20thread_local!(static DEPTH: Cell<usize> = const { Cell::new(0) });
21
22/// Whether a transform is running on this thread.
23pub(crate) fn tracing() -> bool {
24    DEPTH.get() > 0
25}
26
27/// Marks this thread as tracing while alive (nests).
28struct Tracing;
29
30impl Tracing {
31    fn enter() -> Self {
32        DEPTH.set(DEPTH.get() + 1);
33        Tracing
34    }
35}
36
37impl Drop for Tracing {
38    fn drop(&mut self) {
39        DEPTH.set(DEPTH.get() - 1);
40    }
41}
42
43/// A tracer for `primal`: an identity view (a broadcast to its own shape). It is
44/// differentiable, so an enclosing transform differentiates through it.
45fn tracer(primal: &Array) -> Result<Array> {
46    let shape = primal.shape().to_vec();
47    Ok(Array::record(Primitive::Broadcast(shape), vec![primal.clone()], true)?.swap_remove(0))
48}
49
50type Key = (*const Node, u32);
51
52fn key(a: &Array) -> Key {
53    (Arc::as_ptr(&a.node), a.output)
54}
55
56/// A recorded node on a path from the tracers to the outputs.
57struct Step {
58    node: Arc<Node>,
59    primitive: Primitive,
60    inputs: Vec<Array>,
61    /// Which inputs depend on a tracer.
62    wanted: Vec<bool>,
63}
64
65impl Step {
66    fn outputs(&self) -> Vec<Array> {
67        (0..self.node.outputs.len() as u32)
68            .map(|output| Array {
69                node: self.node.clone(),
70                output,
71            })
72            .collect()
73    }
74}
75
76/// A node's primitive and inputs.
77type Graph = (Primitive, Vec<Array>);
78
79/// The primitive and inputs of a node whose graph is still known.
80fn graph(node: &Node) -> Option<Graph> {
81    match &*node.lock() {
82        State::Pending { primitive, inputs } | State::Retained { primitive, inputs } => {
83            Some((primitive.clone(), inputs.clone()))
84        }
85        State::Done => None,
86    }
87}
88
89/// The inputs a transform follows: a custom function's own outputs (its trailing inputs)
90/// are handled by its rules, so its graph is never walked.
91fn followed(primitive: &Primitive, inputs: &[Array]) -> usize {
92    match primitive {
93        Primitive::Custom(call) => call.primals,
94        _ => inputs.len(),
95    }
96}
97
98/// Nodes between `sources` and `outputs` (those that depend on a source), inputs before
99/// consumers. For differentiation (`stop_gradient` true), `StopGradient` cuts the graph.
100/// Iterative, so depth is limited only by memory.
101fn tape(outputs: &[Array], sources: &NodeSet, stop_gradient: bool) -> Vec<Step> {
102    let mut depends: NodeMap<*const Node, bool> = NodeMap::default();
103    let mut visited = NodeSet::default();
104    let mut order = Vec::new();
105    let mut stack: Vec<(Arc<Node>, Option<Graph>)> =
106        outputs.iter().map(|a| (a.node.clone(), None)).collect();
107    while let Some((node, expanded)) = stack.pop() {
108        let ptr = Arc::as_ptr(&node);
109        if let Some((primitive, inputs)) = expanded {
110            let n = followed(&primitive, &inputs);
111            let wanted: Vec<bool> = inputs
112                .iter()
113                .enumerate()
114                .map(|(i, a)| i < n && depends.get(&Arc::as_ptr(&a.node)).copied().unwrap_or(false))
115                .collect();
116            let cut = stop_gradient && primitive == Primitive::StopGradient;
117            let dep = !cut && wanted.contains(&true);
118            depends.insert(ptr, dep);
119            if dep {
120                order.push(Step {
121                    node,
122                    primitive,
123                    inputs,
124                    wanted,
125                });
126            }
127            continue;
128        }
129        if !visited.insert(ptr) {
130            continue;
131        }
132        if sources.contains(&ptr) {
133            depends.insert(ptr, true);
134            continue;
135        }
136        let Some((primitive, inputs)) = graph(&node) else {
137            continue; // evaluated data: a constant
138        };
139        let next: Vec<Arc<Node>> = inputs[..followed(&primitive, &inputs)]
140            .iter()
141            .map(|a| a.node.clone())
142            .filter(|n| !visited.contains(&Arc::as_ptr(n)))
143            .collect();
144        stack.push((node, Some((primitive, inputs))));
145        stack.extend(next.into_iter().map(|n| (n, None)));
146    }
147    order
148}
149
150fn accumulate(map: &mut NodeMap<Key, Array>, key: Key, value: Array) -> Result<()> {
151    let value = match map.remove(&key) {
152        Some(previous) => ops::add(&previous, &value)?,
153        None => value,
154    };
155    map.insert(key, value);
156    Ok(())
157}
158
159fn check_shapes(op: &str, what: &str, arrays: &[Array], given: &[Array]) -> Result<()> {
160    if arrays.len() != given.len() {
161        return Err(Error::InvalidArgument(format!(
162            "{op}: {} {what}s for {} arrays",
163            given.len(),
164            arrays.len()
165        )));
166    }
167    for (a, g) in arrays.iter().zip(given) {
168        if a.shape() != g.shape() {
169            return Err(Error::InvalidArgument(format!(
170                "{op}: shape {:?} does not match {what} shape {:?}",
171                a.shape(),
172                g.shape()
173            )));
174        }
175    }
176    Ok(())
177}
178
179/// Reverse pass: the cotangents of `tracers`, given cotangents for `outputs`.
180fn reverse(outputs: &[Array], cotangents: &[Array], tracers: &[Array]) -> Result<Vec<Array>> {
181    let sources = tracers.iter().map(|t| Arc::as_ptr(&t.node)).collect();
182    let mut cot = NodeMap::default();
183    for (o, c) in outputs.iter().zip(cotangents) {
184        accumulate(&mut cot, key(o), c.clone())?;
185    }
186    for step in tape(outputs, &sources, true).iter().rev() {
187        let outs = step.outputs();
188        if !outs.iter().any(|o| cot.contains_key(&key(o))) {
189            continue;
190        }
191        let cots = outs
192            .iter()
193            .map(|o| cot.remove(&key(o)).map_or_else(|| zeros_like(o), Ok))
194            .collect::<Result<Vec<_>>>()?;
195        let grads = step
196            .primitive
197            .vjp(&step.inputs, &outs, &cots, &step.wanted)?;
198        for ((input, grad), &wanted) in step.inputs.iter().zip(grads).zip(&step.wanted) {
199            if let (Some(grad), true) = (grad, wanted) {
200                accumulate(&mut cot, key(input), grad)?;
201            }
202        }
203    }
204    tracers
205        .iter()
206        .map(|t| cot.remove(&key(t)).map_or_else(|| zeros_like(t), Ok))
207        .collect()
208}
209
210/// Reverse mode: runs `f` on `primals` and returns its outputs together with the
211/// vector-Jacobian product, one gradient per primal, for one cotangent per output.
212///
213/// The results are lazy arrays. A primal that the outputs do not depend on gets a zero
214/// gradient.
215///
216/// ```
217/// use nuvec_core::{Array, Result, multiply, vjp};
218///
219/// # fn main() -> Result<()> {
220/// let x = Array::from_slice(&[1.0f32, 2.0, 3.0], &[3])?;
221/// let ones = Array::from_slice(&[1.0f32; 3], &[3])?;
222/// let (_, grads) = vjp(|xs| Ok(vec![multiply(&xs[0], &xs[0])?]), &[x], &[ones])?;
223/// assert_eq!(grads[0].to_vec::<f32>()?, [2.0, 4.0, 6.0]);
224/// # Ok(())
225/// # }
226/// ```
227pub fn vjp(
228    f: impl FnOnce(&[Array]) -> Result<Vec<Array>>,
229    primals: &[Array],
230    cotangents: &[Array],
231) -> Result<(Vec<Array>, Vec<Array>)> {
232    let _tracing = Tracing::enter();
233    let tracers = primals.iter().map(tracer).collect::<Result<Vec<_>>>()?;
234    let outputs = f(&tracers)?;
235    check_shapes("vjp", "cotangent", &outputs, cotangents)?;
236    let grads = reverse(&outputs, cotangents, &tracers)?;
237    Ok((outputs, grads))
238}
239
240/// Forward mode: runs `f` on `primals` and returns its outputs together with the
241/// Jacobian-vector product, one tangent per output, for one tangent per primal.
242///
243/// ```
244/// use nuvec_core::{Array, Result, jvp, sin};
245///
246/// # fn main() -> Result<()> {
247/// let x = Array::scalar(0.0f32);
248/// let (_, tangents) = jvp(|xs| Ok(vec![sin(&xs[0])?]), &[x], &[Array::scalar(1.0f32)])?;
249/// assert_eq!(tangents[0].item::<f32>()?, 1.0); // cos(0)
250/// # Ok(())
251/// # }
252/// ```
253pub fn jvp(
254    f: impl FnOnce(&[Array]) -> Result<Vec<Array>>,
255    primals: &[Array],
256    tangents: &[Array],
257) -> Result<(Vec<Array>, Vec<Array>)> {
258    check_shapes("jvp", "tangent", primals, tangents)?;
259    let _tracing = Tracing::enter();
260    let tracers = primals.iter().map(tracer).collect::<Result<Vec<_>>>()?;
261    let outputs = f(&tracers)?;
262    let sources = tracers.iter().map(|t| Arc::as_ptr(&t.node)).collect();
263    let mut tan: NodeMap<Key, Array> = tracers
264        .iter()
265        .zip(tangents)
266        .map(|(t, v)| (key(t), v.clone()))
267        .collect();
268    for step in tape(&outputs, &sources, true) {
269        let inputs: Vec<Option<Array>> = step
270            .inputs
271            .iter()
272            .map(|a| tan.get(&key(a)).cloned())
273            .collect();
274        if inputs.iter().all(Option::is_none) {
275            continue;
276        }
277        let outs = step.outputs();
278        for (o, t) in outs
279            .iter()
280            .zip(step.primitive.jvp(&step.inputs, &outs, &inputs)?)
281        {
282            tan.insert(key(o), t);
283        }
284    }
285    let tangents = outputs
286        .iter()
287        .map(|o| tan.get(&key(o)).cloned().map_or_else(|| zeros_like(o), Ok))
288        .collect::<Result<Vec<_>>>()?;
289    Ok((outputs, tangents))
290}
291
292/// Returns a function computing `f`'s value and its gradients with respect to the
293/// arguments at positions `argnums`.
294///
295/// `f` must return a 0-D array. Only the selected arguments are traced; the others are
296/// treated as constants.
297///
298/// ```
299/// use nuvec_core::{Array, Result, multiply, subtract, sum, value_and_grad};
300///
301/// # fn main() -> Result<()> {
302/// // loss(w, x) = sum((w * x - 1)^2)
303/// let loss = |xs: &[Array]| {
304///     let r = subtract(multiply(&xs[0], &xs[1])?, 1.0)?;
305///     sum(&multiply(&r, &r)?, &[], false)
306/// };
307/// let w = Array::from_slice(&[2.0f32], &[1])?;
308/// let x = Array::from_slice(&[3.0f32], &[1])?;
309/// let (value, grads) = value_and_grad(loss, &[0])(&[w, x])?;
310/// assert_eq!(value.item::<f32>()?, 25.0);
311/// assert_eq!(grads[0].to_vec::<f32>()?, [30.0]); // 2 (w x - 1) x
312/// # Ok(())
313/// # }
314/// ```
315pub fn value_and_grad<F>(
316    f: F,
317    argnums: &[usize],
318) -> impl Fn(&[Array]) -> Result<(Array, Vec<Array>)>
319where
320    F: Fn(&[Array]) -> Result<Array>,
321{
322    let argnums = argnums.to_vec();
323    move |args| {
324        if argnums.is_empty() {
325            return Err(Error::InvalidArgument(
326                "value_and_grad: no argument to differentiate".into(),
327            ));
328        }
329        let mut seen = HashSet::new();
330        for &i in &argnums {
331            if i >= args.len() || !seen.insert(i) {
332                return Err(Error::InvalidArgument(format!(
333                    "value_and_grad: invalid or repeated argument {i} for {} arguments",
334                    args.len()
335                )));
336            }
337        }
338        let _tracing = Tracing::enter();
339        let mut inputs = args.to_vec();
340        let mut tracers = Vec::with_capacity(argnums.len());
341        for &i in &argnums {
342            inputs[i] = tracer(&args[i])?;
343            tracers.push(inputs[i].clone());
344        }
345        let value = f(&inputs)?;
346        if value.ndim() != 0 {
347            return Err(Error::InvalidArgument(format!(
348                "value_and_grad: the function must return a scalar (0-D) array, got shape {:?}",
349                value.shape()
350            )));
351        }
352        let seed = ops::filled(&[], value.dtype(), |_| Canon::Int(1))?;
353        let grads = reverse(std::slice::from_ref(&value), &[seed], &tracers)?;
354        Ok((value, grads))
355    }
356}
357
358/// Returns the gradient function of a scalar function of one array.
359///
360/// Gradient functions can be differentiated again:
361///
362/// ```
363/// use nuvec_core::{Array, Result, grad, sin};
364///
365/// # fn main() -> Result<()> {
366/// let d2 = grad(grad(|x: &Array| sin(x)));
367/// let x = Array::scalar(1.0f32);
368/// assert!((d2(&x)?.item::<f32>()? + 1f32.sin()).abs() < 1e-6); // −sin(x)
369/// # Ok(())
370/// # }
371/// ```
372pub fn grad<F>(f: F) -> impl Fn(&Array) -> Result<Array>
373where
374    F: Fn(&Array) -> Result<Array>,
375{
376    let value_and_grad = value_and_grad(move |xs: &[Array]| f(&xs[0]), &[0]);
377    move |x| Ok(value_and_grad(std::slice::from_ref(x))?.1.swap_remove(0))
378}
379
380/// A per-example placeholder for a batched `vmap` input: ops can be recorded on it, but
381/// evaluating anything that depends on it fails (as in MLX).
382fn placeholder(like: &Array, shape: &[usize]) -> Result<Array> {
383    let failed = Array::failed(
384        Error::InvalidArgument("vmap: arrays cannot be evaluated inside vmap".into()),
385        like.dtype(),
386        &[],
387    );
388    let primitive = Primitive::Broadcast(shape.to_vec());
389    Ok(Array::record_unchecked(primitive, vec![failed], true)?.swap_remove(0))
390}
391
392/// Runs `f` batched over `args` (batch axes `axes`, `None` for unbatched arguments) and
393/// returns its outputs with the batch axis first.
394pub(crate) fn batch(
395    f: &dyn Fn(&[Array]) -> Result<Vec<Array>>,
396    args: &[Array],
397    axes: &[Option<usize>],
398) -> Result<Vec<Array>> {
399    let mut size = None;
400    for (a, axis) in args.iter().zip(axes) {
401        let Some(axis) = *axis else { continue };
402        let n = *a.shape().get(axis).ok_or_else(|| {
403            Error::InvalidArgument(format!(
404                "vmap: axis {axis} out of range for shape {:?}",
405                a.shape()
406            ))
407        })?;
408        if size.is_some_and(|s| s != n) {
409            return Err(Error::InvalidArgument(format!(
410                "vmap: inconsistent batch sizes {} and {n}",
411                size.unwrap_or(0)
412            )));
413        }
414        size = Some(n);
415    }
416    let size = size
417        .ok_or_else(|| Error::InvalidArgument("vmap: at least one input must be batched".into()))?;
418    let _tracing = Tracing::enter();
419    let mut batched: NodeMap<Key, (Array, usize)> = NodeMap::default();
420    let mut sources = NodeSet::default();
421    let mut inputs = Vec::with_capacity(args.len());
422    for (a, axis) in args.iter().zip(axes) {
423        inputs.push(match *axis {
424            Some(axis) => {
425                let mut shape = a.shape().to_vec();
426                shape.remove(axis);
427                let p = placeholder(a, &shape)?;
428                sources.insert(Arc::as_ptr(&p.node));
429                batched.insert(key(&p), (a.clone(), axis));
430                p
431            }
432            None => a.clone(),
433        });
434    }
435    let outputs = f(&inputs)?;
436    for step in tape(&outputs, &sources, false) {
437        let (arrays, axes): (Vec<Array>, Vec<Option<usize>>) = step
438            .inputs
439            .iter()
440            .map(|a| match batched.get(&key(a)) {
441                Some((b, axis)) => (b.clone(), Some(*axis)),
442                None => (a.clone(), None),
443            })
444            .unzip();
445        let (results, axis) = step.primitive.vmap(&arrays, &axes)?;
446        for (o, r) in step.outputs().iter().zip(results) {
447            batched.insert(key(o), (r, axis));
448        }
449    }
450    outputs
451        .iter()
452        .map(|o| match batched.get(&key(o)) {
453            Some((b, axis)) => crate::batching::move_axis(b, *axis, 0),
454            // Independent of every batched input: the same for each example.
455            None => {
456                let mut shape = vec![size];
457                shape.extend_from_slice(o.shape());
458                ops::broadcast_to(&ops::expand_dims(o, 0)?, &shape)
459            }
460        })
461        .collect()
462}
463
464/// `axis` (negative counts from the end) checked against `ndim` dimensions.
465fn normalize_axis(axis: isize, ndim: usize) -> Result<usize> {
466    let a = if axis < 0 { axis + ndim as isize } else { axis };
467    if (0..ndim as isize).contains(&a) {
468        Ok(a as usize)
469    } else {
470        Err(Error::InvalidArgument(format!(
471            "vmap: axis {axis} out of range for {ndim} dimensions"
472        )))
473    }
474}
475
476/// Returns `f` vectorized over a batch axis.
477///
478/// `in_axes` gives each argument's batch axis (`None` for arguments shared by every
479/// example) and `out_axes` each output's; empty slices mean axis 0 everywhere. Negative
480/// axes count from the end. Outputs that do not depend on a batched argument are broadcast
481/// along the batch axis. Arrays cannot be evaluated inside `f`.
482///
483/// ```
484/// use nuvec_core::{Array, Result, matmul, vmap};
485///
486/// # fn main() -> Result<()> {
487/// // A matrix-vector product per example, sharing the matrix.
488/// let mv = vmap(|xs| Ok(vec![matmul(&xs[0], &xs[1])?]), &[None, Some(0)], &[]);
489/// let m = Array::from_slice(&[1.0f32, 0.0, 0.0, 2.0], &[2, 2])?;
490/// let vs = Array::from_slice(&[1.0f32, 1.0, 3.0, 4.0], &[2, 2])?; // two vectors
491/// assert_eq!(mv(&[m, vs])?[0].to_vec::<f32>()?, [1.0, 2.0, 3.0, 8.0]);
492/// # Ok(())
493/// # }
494/// ```
495pub fn vmap<F>(
496    f: F,
497    in_axes: &[Option<isize>],
498    out_axes: &[isize],
499) -> impl Fn(&[Array]) -> Result<Vec<Array>>
500where
501    F: Fn(&[Array]) -> Result<Vec<Array>>,
502{
503    let (in_axes, out_axes) = (in_axes.to_vec(), out_axes.to_vec());
504    move |args| {
505        let axes = if in_axes.is_empty() {
506            vec![Some(0); args.len()]
507        } else if in_axes.len() == args.len() {
508            in_axes
509                .iter()
510                .zip(args)
511                .map(|(axis, a)| axis.map(|axis| normalize_axis(axis, a.ndim())).transpose())
512                .collect::<Result<Vec<_>>>()?
513        } else {
514            return Err(Error::InvalidArgument(format!(
515                "vmap: {} input axes for {} arguments",
516                in_axes.len(),
517                args.len()
518            )));
519        };
520        let outputs = batch(&f, args, &axes)?;
521        if !out_axes.is_empty() && out_axes.len() != outputs.len() {
522            return Err(Error::InvalidArgument(format!(
523                "vmap: {} output axes for {} outputs",
524                out_axes.len(),
525                outputs.len()
526            )));
527        }
528        outputs
529            .iter()
530            .enumerate()
531            .map(|(i, o)| {
532                let axis = normalize_axis(out_axes.get(i).copied().unwrap_or(0), o.ndim())?;
533                crate::batching::move_axis(o, 0, axis)
534            })
535            .collect()
536    }
537}