Skip to main content

nuvec_core/
custom.rs

1//! Functions with custom transform rules ([`custom_function`]) and gradient
2//! checkpointing ([`checkpoint`]).
3
4use std::fmt;
5use std::hash::{Hash, Hasher};
6use std::sync::Arc;
7
8use crate::primitive::Primitive;
9use crate::{Array, Result};
10
11type Body = dyn Fn(&[Array]) -> Result<Vec<Array>> + Send + Sync;
12type VjpRule = dyn Fn(&[Array], &[Array], &[Array]) -> Result<Vec<Array>> + Send + Sync;
13type JvpRule = dyn Fn(&[Array], &[Array]) -> Result<Vec<Array>> + Send + Sync;
14type VmapRule = dyn Fn(&[Array], &[Option<usize>]) -> Result<(Vec<Array>, usize)> + Send + Sync;
15
16/// A function and its optional custom rules.
17#[derive(Clone)]
18pub(crate) struct Rules {
19    pub(crate) body: Arc<Body>,
20    pub(crate) vjp: Option<Arc<VjpRule>>,
21    pub(crate) jvp: Option<Arc<JvpRule>>,
22    pub(crate) vmap: Option<Arc<VmapRule>>,
23}
24
25/// One call of a custom function, as recorded in the graph: its rules and the number of
26/// primal inputs. Equal (and hashed) by the identity of the function.
27#[derive(Clone)]
28pub struct CustomCall {
29    pub(crate) rules: Arc<Rules>,
30    pub(crate) primals: usize,
31}
32
33impl PartialEq for CustomCall {
34    fn eq(&self, other: &Self) -> bool {
35        Arc::ptr_eq(&self.rules, &other.rules) && self.primals == other.primals
36    }
37}
38
39impl Eq for CustomCall {}
40
41impl Hash for CustomCall {
42    fn hash<H: Hasher>(&self, state: &mut H) {
43        (Arc::as_ptr(&self.rules) as *const () as usize).hash(state);
44        self.primals.hash(state);
45    }
46}
47
48impl fmt::Debug for CustomCall {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(f, "CustomCall({} primals)", self.primals)
51    }
52}
53
54/// A function with custom transform rules; see [`custom_function`].
55#[derive(Clone)]
56pub struct CustomFunction {
57    rules: Arc<Rules>,
58}
59
60/// Wraps `f` so that transforms use custom rules for it, set with
61/// [`CustomFunction::with_vjp`], [`CustomFunction::with_jvp`] and
62/// [`CustomFunction::with_vmap`].
63///
64/// Without a rule, a transform differentiates or batches `f` itself, re-running it. `f`'s
65/// intermediates are never kept for a transform, so a custom function without rules is a
66/// gradient checkpoint (see [`checkpoint`]).
67///
68/// ```
69/// use nuvec_core::{Array, Result, custom_function, grad, multiply, sum};
70///
71/// # fn main() -> Result<()> {
72/// // A straight-through estimator: forward x², gradient as if the identity.
73/// let square = custom_function(|xs| Ok(vec![multiply(&xs[0], &xs[0])?]))
74///     .with_vjp(|_primals, _outputs, cotangents| Ok(cotangents.to_vec()));
75/// let g = grad(move |x: &Array| sum(&square.call(std::slice::from_ref(x))?[0], &[], false));
76/// let x = Array::from_slice(&[3.0f32, -1.0], &[2])?;
77/// assert_eq!(g(&x)?.to_vec::<f32>()?, [1.0, 1.0]);
78/// # Ok(())
79/// # }
80/// ```
81pub fn custom_function(
82    f: impl Fn(&[Array]) -> Result<Vec<Array>> + Send + Sync + 'static,
83) -> CustomFunction {
84    CustomFunction {
85        rules: Arc::new(Rules {
86            body: Arc::new(f),
87            vjp: None,
88            jvp: None,
89            vmap: None,
90        }),
91    }
92}
93
94impl CustomFunction {
95    fn with(self, set: impl FnOnce(&mut Rules)) -> Self {
96        let mut rules = (*self.rules).clone();
97        set(&mut rules);
98        Self {
99            rules: Arc::new(rules),
100        }
101    }
102
103    /// Sets the reverse-mode rule: `(primals, outputs, cotangents)` → one cotangent per
104    /// primal.
105    pub fn with_vjp(
106        self,
107        rule: impl Fn(&[Array], &[Array], &[Array]) -> Result<Vec<Array>> + Send + Sync + 'static,
108    ) -> Self {
109        self.with(|r| r.vjp = Some(Arc::new(rule)))
110    }
111
112    /// Sets the forward-mode rule: `(primals, tangents)` → one tangent per output (missing
113    /// input tangents are passed as zeros).
114    pub fn with_jvp(
115        self,
116        rule: impl Fn(&[Array], &[Array]) -> Result<Vec<Array>> + Send + Sync + 'static,
117    ) -> Self {
118        self.with(|r| r.jvp = Some(Arc::new(rule)))
119    }
120
121    /// Sets the batching rule: `(inputs, batch axes)` (`None` for unbatched inputs) →
122    /// the batched outputs and their common batch axis.
123    pub fn with_vmap(
124        self,
125        rule: impl Fn(&[Array], &[Option<usize>]) -> Result<(Vec<Array>, usize)> + Send + Sync + 'static,
126    ) -> Self {
127        self.with(|r| r.vmap = Some(Arc::new(rule)))
128    }
129
130    /// Calls the function on `inputs`. Values equal `f(inputs)`; transforms of the
131    /// results use the custom rules.
132    pub fn call(&self, inputs: &[Array]) -> Result<Vec<Array>> {
133        // `f` runs on untraced stop-gradient views: its graph detaches after evaluation
134        // even inside a transform, and transforms never walk into it.
135        let views = inputs
136            .iter()
137            .map(|x| Ok(Array::record(Primitive::StopGradient, vec![x.clone()], false)?.remove(0)))
138            .collect::<Result<Vec<_>>>()?;
139        let outputs = (self.rules.body)(&views)?;
140        let tracer = inputs.iter().any(|a| a.node.tracer);
141        let call = CustomCall {
142            rules: self.rules.clone(),
143            primals: inputs.len(),
144        };
145        let all = inputs.iter().chain(&outputs).cloned().collect();
146        Array::record(Primitive::Custom(call), all, tracer)
147    }
148}
149
150/// Returns `f` with gradient checkpointing: values and derivatives equal `f`'s, but `f`'s
151/// intermediates are not kept for the backward pass; `f` runs again to differentiate.
152///
153/// ```
154/// use nuvec_core::{Array, Result, checkpoint, grad, sin, sum};
155///
156/// # fn main() -> Result<()> {
157/// let block = checkpoint(|xs| Ok(vec![sin(&sin(&xs[0])?)?]));
158/// let g = grad(move |x: &Array| sum(&block(std::slice::from_ref(x))?[0], &[], false));
159/// let x = Array::scalar(0.5f32);
160/// let expect = 0.5f32.sin().cos() * 0.5f32.cos();
161/// assert!((g(&x)?.item::<f32>()? - expect).abs() < 1e-6);
162/// # Ok(())
163/// # }
164/// ```
165pub fn checkpoint(
166    f: impl Fn(&[Array]) -> Result<Vec<Array>> + Send + Sync + 'static,
167) -> impl Fn(&[Array]) -> Result<Vec<Array>> + Send + Sync {
168    let function = custom_function(f);
169    move |inputs| function.call(inputs)
170}
171
172#[cfg(test)]
173mod tests {
174    use std::sync::Mutex;
175
176    use super::*;
177    use crate::array::State;
178
179    /// Whether `f`'s forward intermediate keeps its graph after `grad` evaluates the
180    /// forward pass inside the transform.
181    fn intermediate_retained(checkpointed: bool) -> bool {
182        let first = Arc::new(Mutex::new(None::<Array>));
183        let stored = first.clone();
184        let body = move |xs: &[Array]| {
185            let h = crate::sin(&xs[0])?;
186            stored.lock().unwrap().get_or_insert_with(|| h.clone());
187            Ok(vec![crate::sin(&h)?])
188        };
189        let f: Box<Body> = if checkpointed {
190            Box::new(checkpoint(body))
191        } else {
192            Box::new(body)
193        };
194        let g = crate::grad(move |x: &Array| {
195            let y = f(std::slice::from_ref(x))?.remove(0);
196            y.eval()?;
197            crate::sum(&y, &[], false)
198        });
199        g(&Array::scalar(0.5f32)).unwrap();
200        let h = first.lock().unwrap().take().unwrap();
201        let retained = matches!(&*h.node.lock(), State::Retained { .. });
202        retained
203    }
204
205    #[test]
206    fn checkpoint_does_not_keep_intermediates() {
207        assert!(
208            intermediate_retained(false),
209            "without checkpoint the graph is kept"
210        );
211        assert!(
212            !intermediate_retained(true),
213            "with checkpoint it is released"
214        );
215    }
216}