Skip to main content

nuvec_core/
custom.rs

1//! Functions with custom transform rules ([`custom_function`]) and gradient
2//! checkpointing ([`checkpoint`]), over trees and (the `*_flat` forms) positional arrays.
3
4use std::fmt;
5use std::hash::{Hash, Hasher};
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Mutex, PoisonError};
8
9use crate::primitive::Primitive;
10use crate::transforms::IntoResult;
11use crate::tree::Tree;
12use crate::{Array, Error, Result};
13
14type Body = dyn Fn(&[Array]) -> Result<Vec<Array>> + Send + Sync;
15type VjpRule = dyn Fn(&[Array], &[Array], &[Array]) -> Result<Vec<Array>> + Send + Sync;
16type JvpRule = dyn Fn(&[Array], &[Array]) -> Result<Vec<Array>> + Send + Sync;
17type VmapRule = dyn Fn(&[Array], &[Option<usize>]) -> Result<(Vec<Array>, usize)> + Send + Sync;
18
19/// A function and its optional custom rules.
20#[derive(Clone)]
21pub(crate) struct Rules {
22    pub(crate) body: Arc<Body>,
23    pub(crate) vjp: Option<Arc<VjpRule>>,
24    pub(crate) jvp: Option<Arc<JvpRule>>,
25    pub(crate) vmap: Option<Arc<VmapRule>>,
26}
27
28/// One call of a custom function, as recorded in the graph: its rules and the number of
29/// primal inputs. Equal (and hashed) by the identity of the function.
30#[derive(Clone)]
31pub struct CustomCall {
32    pub(crate) rules: Arc<Rules>,
33    pub(crate) primals: usize,
34}
35
36impl PartialEq for CustomCall {
37    fn eq(&self, other: &Self) -> bool {
38        Arc::ptr_eq(&self.rules, &other.rules) && self.primals == other.primals
39    }
40}
41
42impl Eq for CustomCall {}
43
44impl Hash for CustomCall {
45    fn hash<H: Hasher>(&self, state: &mut H) {
46        (Arc::as_ptr(&self.rules) as *const () as usize).hash(state);
47        self.primals.hash(state);
48    }
49}
50
51impl fmt::Debug for CustomCall {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        write!(f, "CustomCall({} primals)", self.primals)
54    }
55}
56
57/// A function of positional arrays with custom transform rules; see
58/// [`custom_function_flat`].
59#[derive(Clone)]
60pub struct CustomFunctionFlat {
61    rules: Arc<Rules>,
62}
63
64/// Wraps `f` (a function of positional arrays) so that transforms use custom rules for it,
65/// set with [`CustomFunctionFlat::with_vjp`], [`CustomFunctionFlat::with_jvp`] and
66/// [`CustomFunctionFlat::with_vmap`]; the flat form of [`custom_function`].
67///
68/// Without a rule, a transform differentiates or batches `f` itself, re-running it. `f`'s
69/// intermediates are never kept for a transform, so a custom function without rules is a
70/// gradient checkpoint (see [`checkpoint_flat`]).
71///
72/// ```
73/// use nuvec_core::{Array, Result, array, custom_function_flat, grad};
74///
75/// # fn main() -> Result<()> {
76/// // A straight-through estimator: forward x², gradient as if the identity.
77/// let square = custom_function_flat(|xs| Ok(vec![xs[0].multiply(&xs[0])]))
78///     .with_vjp(|_primals, _outputs, cotangents| Ok(cotangents.to_vec()));
79/// let g = grad(move |x: &Array| Ok(square.call(std::slice::from_ref(x))?[0].sum(..)));
80/// let x = array![3.0, -1.0];
81/// assert_eq!(g(&x)?.to_vec::<f32>()?, [1.0, 1.0]);
82/// # Ok(())
83/// # }
84/// ```
85pub fn custom_function_flat(
86    f: impl Fn(&[Array]) -> Result<Vec<Array>> + Send + Sync + 'static,
87) -> CustomFunctionFlat {
88    CustomFunctionFlat {
89        rules: Arc::new(Rules {
90            body: Arc::new(f),
91            vjp: None,
92            jvp: None,
93            vmap: None,
94        }),
95    }
96}
97
98impl CustomFunctionFlat {
99    fn with(self, set: impl FnOnce(&mut Rules)) -> Self {
100        let mut rules = (*self.rules).clone();
101        set(&mut rules);
102        Self {
103            rules: Arc::new(rules),
104        }
105    }
106
107    /// Sets the reverse-mode rule: `(primals, outputs, cotangents)` → one cotangent per
108    /// primal.
109    pub fn with_vjp(
110        self,
111        rule: impl Fn(&[Array], &[Array], &[Array]) -> Result<Vec<Array>> + Send + Sync + 'static,
112    ) -> Self {
113        self.with(|r| r.vjp = Some(Arc::new(rule)))
114    }
115
116    /// Sets the forward-mode rule: `(primals, tangents)` → one tangent per output (missing
117    /// input tangents are passed as zeros).
118    pub fn with_jvp(
119        self,
120        rule: impl Fn(&[Array], &[Array]) -> Result<Vec<Array>> + Send + Sync + 'static,
121    ) -> Self {
122        self.with(|r| r.jvp = Some(Arc::new(rule)))
123    }
124
125    /// Sets the batching rule: `(inputs, batch axes)` (`None` for unbatched inputs) →
126    /// the batched outputs and their common batch axis.
127    pub fn with_vmap(
128        self,
129        rule: impl Fn(&[Array], &[Option<i32>]) -> Result<(Vec<Array>, i32)> + Send + Sync + 'static,
130    ) -> Self {
131        let rule = move |xs: &[Array], axes: &[Option<usize>]| {
132            let axes: Vec<Option<i32>> = axes
133                .iter()
134                .map(|a| a.map(|a| i32::try_from(a).unwrap_or(i32::MAX)))
135                .collect();
136            let (out, axis) = rule(xs, &axes)?;
137            let axis = usize::try_from(axis).map_err(|_| {
138                Error::InvalidArgument(format!(
139                    "custom_function: the vmap rule returned the negative output axis {axis}"
140                ))
141            })?;
142            Ok((out, axis))
143        };
144        self.with(|r| r.vmap = Some(Arc::new(rule)))
145    }
146
147    /// Calls the function on `inputs`. Values equal `f(inputs)`; transforms of the
148    /// results use the custom rules.
149    pub fn call(&self, inputs: &[Array]) -> Result<Vec<Array>> {
150        // `f` runs on untraced stop-gradient views: its graph detaches after evaluation
151        // even inside a transform, and transforms never walk into it.
152        let views = inputs
153            .iter()
154            .map(|x| Ok(Array::record(Primitive::StopGradient, vec![x.clone()], None)?.remove(0)))
155            .collect::<Result<Vec<_>>>()?;
156        let outputs = (self.rules.body)(&views)?;
157        let trace = crate::transforms::inherited(inputs);
158        let call = CustomCall {
159            rules: self.rules.clone(),
160            primals: inputs.len(),
161        };
162        let all = inputs.iter().chain(&outputs).cloned().collect();
163        Array::record(Primitive::Custom(call), all, trace)
164    }
165}
166
167/// Returns `f` (a function of positional arrays) with gradient checkpointing; the flat form
168/// of [`checkpoint`].
169///
170/// ```
171/// use nuvec_core::{Array, Result, checkpoint_flat, grad};
172///
173/// # fn main() -> Result<()> {
174/// let block = checkpoint_flat(|xs| Ok(vec![xs[0].sin().sin()]));
175/// let g = grad(move |x: &Array| Ok(block(std::slice::from_ref(x))?[0].sum(..)));
176/// let x = Array::from(0.5f32);
177/// let expect = 0.5f32.sin().cos() * 0.5f32.cos();
178/// assert!((g(&x)?.item::<f32>()? - expect).abs() < 1e-6);
179/// # Ok(())
180/// # }
181/// ```
182pub fn checkpoint_flat(
183    f: impl Fn(&[Array]) -> Result<Vec<Array>> + Send + Sync + 'static,
184) -> impl Fn(&[Array]) -> Result<Vec<Array>> + Send + Sync {
185    let f = Arc::new(f);
186    move |inputs| {
187        // The recomputation draws the same random numbers as the forward pass (as
188        // `checkpoint`): the thread's key at this call is restored around every later run.
189        let key = crate::random::sequence_key();
190        let first = AtomicBool::new(true);
191        let f = f.clone();
192        let function = custom_function_flat(move |xs: &[Array]| {
193            let current = crate::random::sequence_key();
194            crate::random::set_sequence_key(key.clone());
195            let restore = (!first.swap(false, Ordering::AcqRel)).then_some(current);
196            let out = f(xs);
197            if let Some(current) = restore {
198                crate::random::set_sequence_key(current);
199            }
200            out
201        });
202        function.call(inputs)
203    }
204}
205
206// ── Over trees ──────────────────────────────────────────────────────────────
207
208type TreeBody<I, O> = dyn Fn(&I) -> Result<O> + Send + Sync;
209type TreeVjp<I, O> = dyn Fn(&I, &O, &O) -> Result<I> + Send + Sync;
210type TreeJvp<I, O> = dyn Fn(&I, &I) -> Result<O> + Send + Sync;
211type TreeVmap<I, O> = dyn Fn(&I, &[Option<i32>]) -> Result<(O, i32)> + Send + Sync;
212
213/// A function of a tree with custom transform rules; see [`custom_function`].
214pub struct CustomFunction<I, O> {
215    body: Arc<TreeBody<I, O>>,
216    vjp: Option<Arc<TreeVjp<I, O>>>,
217    jvp: Option<Arc<TreeJvp<I, O>>>,
218    vmap: Option<Arc<TreeVmap<I, O>>>,
219    /// Restore the thread's random key around recomputations (checkpoints).
220    replay_random: bool,
221}
222
223impl<I, O> Clone for CustomFunction<I, O> {
224    fn clone(&self) -> Self {
225        Self {
226            body: self.body.clone(),
227            vjp: self.vjp.clone(),
228            jvp: self.jvp.clone(),
229            vmap: self.vmap.clone(),
230            replay_random: self.replay_random,
231        }
232    }
233}
234
235/// Wraps `f` (a function of a tree, returning a tree) so that transforms use custom rules
236/// for it, set with [`CustomFunction::with_vjp`], [`CustomFunction::with_jvp`] and
237/// [`CustomFunction::with_vmap`].
238///
239/// Without a rule, a transform differentiates or batches `f` itself, re-running it; `f`'s
240/// intermediates are never kept for a transform. The rules are stored in the graph (and
241/// may run later, on another thread), so `f` and the rules are `Send + Sync + 'static`.
242///
243/// ```
244/// use nuvec_core::{Array, Result, array, custom_function, grad};
245///
246/// # fn main() -> Result<()> {
247/// // A straight-through estimator: forward x², gradient as if the identity.
248/// let square = custom_function(|x: &Array| x.multiply(x))
249///     .with_vjp(|_x: &Array, _y: &Array, cotangent: &Array| Ok(cotangent.clone()));
250/// let g = grad(move |x: &Array| Ok(square.call(x)?.sum(..)));
251/// assert_eq!(g(&array![3.0, -1.0])?.to_vec::<f32>()?, [1.0, 1.0]);
252/// # Ok(())
253/// # }
254/// ```
255pub fn custom_function<I, O, F, R>(f: F) -> CustomFunction<I, O>
256where
257    I: Tree + Send + Sync,
258    O: Tree + Send + Sync,
259    F: Fn(&I) -> R + Send + Sync + 'static,
260    R: IntoResult<Ok = O>,
261{
262    CustomFunction {
263        body: Arc::new(move |i: &I| f(i).into_result()),
264        vjp: None,
265        jvp: None,
266        vmap: None,
267        replay_random: false,
268    }
269}
270
271impl<I, O> CustomFunction<I, O>
272where
273    I: Tree + Send + Sync,
274    O: Tree + Send + Sync,
275{
276    /// Sets the reverse-mode rule: `(primals, outputs, cotangents)` → the cotangent of the
277    /// primals (a tree like them).
278    pub fn with_vjp(
279        mut self,
280        rule: impl Fn(&I, &O, &O) -> Result<I> + Send + Sync + 'static,
281    ) -> Self {
282        self.vjp = Some(Arc::new(rule));
283        self
284    }
285
286    /// Sets the forward-mode rule: `(primals, tangents)` → the outputs' tangents (missing
287    /// input tangents are passed as zeros).
288    pub fn with_jvp(mut self, rule: impl Fn(&I, &I) -> Result<O> + Send + Sync + 'static) -> Self {
289        self.jvp = Some(Arc::new(rule));
290        self
291    }
292
293    /// Sets the batching rule: `(inputs, batch axis of each input leaf)` (`None` for
294    /// unbatched leaves) → the batched outputs and their common batch axis.
295    pub fn with_vmap(
296        mut self,
297        rule: impl Fn(&I, &[Option<i32>]) -> Result<(O, i32)> + Send + Sync + 'static,
298    ) -> Self {
299        self.vmap = Some(Arc::new(rule));
300        self
301    }
302
303    /// Calls the function on `inputs`. Values equal `f(inputs)`; transforms of the results
304    /// use the custom rules.
305    pub fn call(&self, inputs: &I) -> Result<O> {
306        let template = Arc::new(inputs.clone());
307        let output: Arc<Mutex<Option<O>>> = Arc::new(Mutex::new(None));
308        let out_template = {
309            let output = output.clone();
310            move || {
311                output
312                    .lock()
313                    .unwrap_or_else(PoisonError::into_inner)
314                    .clone()
315                    .ok_or_else(|| Error::InvalidArgument("custom_function: no output yet".into()))
316            }
317        };
318        let body = {
319            let (f, template, output) = (self.body.clone(), template.clone(), output.clone());
320            // A checkpoint's recomputation draws the same random numbers as its forward
321            // pass: the thread's key is restored around every run after the first.
322            let key = self.replay_random.then(crate::random::sequence_key);
323            let first = AtomicBool::new(true);
324            move |xs: &[Array]| -> Result<Vec<Array>> {
325                let restore = match &key {
326                    Some(k) => {
327                        let current = crate::random::sequence_key();
328                        crate::random::set_sequence_key(k.clone());
329                        (!first.swap(false, Ordering::AcqRel)).then_some(current)
330                    }
331                    None => None,
332                };
333                let out = template.unflatten(xs.iter().cloned()).and_then(|i| f(&i));
334                if let Some(current) = restore {
335                    crate::random::set_sequence_key(current);
336                }
337                let out = out?;
338                let leaves = out.leaves();
339                *output.lock().unwrap_or_else(PoisonError::into_inner) = Some(out);
340                Ok(leaves)
341            }
342        };
343        let vjp: Option<Arc<VjpRule>> = self.vjp.clone().map(|rule| {
344            let (template, out_template) = (template.clone(), out_template.clone());
345            Arc::new(move |p: &[Array], o: &[Array], c: &[Array]| {
346                let tpl = out_template()?;
347                let i = template.unflatten(p.iter().cloned())?;
348                let o = tpl.unflatten(o.iter().cloned())?;
349                let c = tpl.unflatten(c.iter().cloned())?;
350                Ok(rule(&i, &o, &c)?.leaves())
351            }) as Arc<VjpRule>
352        });
353        let jvp: Option<Arc<JvpRule>> = self.jvp.clone().map(|rule| {
354            let template = template.clone();
355            Arc::new(move |p: &[Array], t: &[Array]| {
356                let i = template.unflatten(p.iter().cloned())?;
357                let t = template.unflatten(t.iter().cloned())?;
358                Ok(rule(&i, &t)?.leaves())
359            }) as Arc<JvpRule>
360        });
361        let vmap: Option<Arc<VmapRule>> = self.vmap.clone().map(|rule| {
362            let (template, out_template) = (template.clone(), out_template.clone());
363            Arc::new(move |xs: &[Array], axes: &[Option<usize>]| {
364                let axes: Vec<Option<i32>> = axes
365                    .iter()
366                    .map(|a| a.map(|a| i32::try_from(a).unwrap_or(i32::MAX)))
367                    .collect();
368                let i = template.unflatten(xs.iter().cloned())?;
369                let (o, axis) = rule(&i, &axes)?;
370                let axis = usize::try_from(axis).map_err(|_| {
371                    Error::InvalidArgument(format!(
372                        "custom_function: the vmap rule returned the negative output axis {axis}"
373                    ))
374                })?;
375                let _ = out_template()?;
376                Ok((o.leaves(), axis))
377            }) as Arc<VmapRule>
378        });
379        let flat = CustomFunctionFlat {
380            rules: Arc::new(Rules {
381                body: Arc::new(body),
382                vjp,
383                jvp,
384                vmap,
385            }),
386        };
387        let leaves = flat.call(&inputs.leaves())?;
388        out_template()?.unflatten(leaves)
389    }
390}
391
392/// Returns `f` with gradient checkpointing: values and derivatives equal `f`'s, but `f`'s
393/// intermediates are not kept for the backward pass; `f` runs again to differentiate.
394/// Random numbers drawn inside `f` (dropout) are drawn again with the same key, so the
395/// recomputation sees the same masks.
396///
397/// `f` is stored in the graph for the recomputation, so it is `Send + Sync + 'static`
398/// (it cannot borrow locals: move them in).
399///
400/// ```
401/// use nuvec_core::{Array, Result, checkpoint, grad};
402///
403/// # fn main() -> Result<()> {
404/// let block = checkpoint(|x: &Array| x.sin().sin());
405/// let g = grad(move |x: &Array| Ok(block(x)?.sum(..)));
406/// let x = Array::from(0.5f32);
407/// let expect = 0.5f32.sin().cos() * 0.5f32.cos();
408/// assert!((g(&x)?.item::<f32>()? - expect).abs() < 1e-6);
409/// # Ok(())
410/// # }
411/// ```
412pub fn checkpoint<I, O, F, R>(f: F) -> impl Fn(&I) -> Result<O> + Send + Sync
413where
414    I: Tree + Send + Sync,
415    O: Tree + Send + Sync,
416    F: Fn(&I) -> R + Send + Sync + 'static,
417    R: IntoResult<Ok = O>,
418{
419    let mut function = custom_function(f);
420    function.replay_random = true;
421    move |inputs| function.call(inputs)
422}
423
424#[cfg(test)]
425mod tests {
426    use std::sync::Mutex;
427
428    use super::*;
429    use crate::array::State;
430
431    /// Whether `f`'s forward intermediate keeps its graph after `grad` evaluates the
432    /// forward pass inside the transform.
433    fn intermediate_retained(checkpointed: bool) -> bool {
434        let first = Arc::new(Mutex::new(None::<Array>));
435        let stored = first.clone();
436        let body = move |xs: &[Array]| {
437            let h = crate::ops::imp::sin(&xs[0])?;
438            stored.lock().unwrap().get_or_insert_with(|| h.clone());
439            Ok(vec![crate::ops::imp::sin(&h)?])
440        };
441        let f: Box<Body> = if checkpointed {
442            Box::new(checkpoint_flat(body))
443        } else {
444            Box::new(body)
445        };
446        // Checked inside the transform: retained nodes detach when it exits.
447        let retained = Arc::new(Mutex::new(false));
448        let seen = retained.clone();
449        let g = crate::grad(move |x: &Array| {
450            let y = f(std::slice::from_ref(x))?.remove(0);
451            y.eval()?;
452            let h = first.lock().unwrap().take().unwrap();
453            *seen.lock().unwrap() = matches!(&*h.node.lock(), State::Retained { .. });
454            crate::ops::imp::sum(&y, &[], false)
455        });
456        let _ = g(&Array::from(0.5f32)).unwrap();
457        *retained.lock().unwrap()
458    }
459
460    #[test]
461    fn checkpoint_does_not_keep_intermediates() {
462        assert!(
463            intermediate_retained(false),
464            "without checkpoint the graph is kept"
465        );
466        assert!(
467            !intermediate_retained(true),
468            "with checkpoint it is released"
469        );
470    }
471}