Skip to main content

oxmera_tensor/
autograd.rs

1//! Reverse-mode autograd plumbing: the tape nodes tensors carry, gradient
2//! accumulation, and the recording switch.
3//!
4//! The differentiable *rules* (VJPs) live next to the ops that create them
5//! (`crate::ops`); this module owns the graph mechanics only.
6
7use std::cell::Cell;
8use std::collections::HashMap;
9use std::sync::{Arc, Mutex};
10
11use oxmera_core::Result;
12
13use crate::tensor::Tensor;
14
15/// One recorded operation: the inputs it consumed and the vector-Jacobian
16/// product mapping the output gradient to input gradients (aligned with
17/// `inputs`; `None` for non-differentiable inputs such as indices).
18pub struct GradFn {
19    /// The tensors the op consumed, in order.
20    pub inputs: Vec<Tensor>,
21    /// The VJP: output gradient in, one optional gradient per input out.
22    #[allow(clippy::type_complexity)]
23    pub vjp: Box<dyn Fn(&Tensor) -> Result<Vec<Option<Tensor>>> + Send + Sync>,
24}
25
26impl std::fmt::Debug for GradFn {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        f.debug_struct("GradFn")
29            .field("inputs", &self.inputs.len())
30            .finish()
31    }
32}
33
34/// The autograd state a tracked tensor carries.
35#[derive(Debug)]
36pub struct AutogradMeta {
37    /// Whether gradients accumulate on this tensor during `backward`.
38    pub(crate) requires_grad: bool,
39    /// The accumulated gradient, if any backward pass has reached it.
40    pub(crate) grad: Mutex<Option<Tensor>>,
41    /// How this tensor was computed; `None` for leaves.
42    pub(crate) grad_fn: Option<GradFn>,
43}
44
45impl Drop for AutogradMeta {
46    /// Take the tape apart iteratively.
47    ///
48    /// A tape is a linked structure — each node owns the input tensors it
49    /// consumed, and each of those owns its own node — so the derived drop
50    /// recurses once per operation and overflows the stack on a deep graph
51    /// (an unrolled RNN, or any long chain built before `backward`). At
52    /// roughly 65k nodes that aborted the process, which no caller can
53    /// catch. This walks the graph with an explicit worklist instead, so
54    /// depth costs heap rather than stack.
55    fn drop(&mut self) {
56        let mut stack: Vec<Tensor> = Vec::new();
57        let shed = |meta: &mut AutogradMeta, stack: &mut Vec<Tensor>| {
58            if let Some(gf) = meta.grad_fn.take() {
59                stack.extend(gf.inputs);
60            }
61            if let Ok(slot) = meta.grad.get_mut()
62                && let Some(g) = slot.take()
63            {
64                stack.push(g);
65            }
66        };
67        shed(self, &mut stack);
68        while let Some(mut tensor) = stack.pop() {
69            // Only descend where this was the last handle: a node still
70            // shared with a live tensor must stay intact.
71            if let Some(node) = tensor.take_autograd()
72                && let Some(mut owned) = Arc::into_inner(node)
73            {
74                shed(&mut owned, &mut stack);
75            }
76        }
77    }
78}
79
80thread_local! {
81    static RECORDING: Cell<bool> = const { Cell::new(true) };
82}
83
84/// Whether ops on this thread currently record onto the tape.
85pub fn is_recording() -> bool {
86    RECORDING.with(Cell::get)
87}
88
89/// Run `f` with tape recording disabled — the inference/`no_grad` context.
90///
91/// Nested calls are fine; recording resumes when the outermost guard ends.
92pub fn no_grad<R>(f: impl FnOnce() -> R) -> R {
93    let _guard = NoGradGuard::new();
94    f()
95}
96
97/// RAII guard that disables tape recording until dropped.
98pub struct NoGradGuard {
99    previous: bool,
100}
101
102impl NoGradGuard {
103    /// Disable recording on this thread until the guard drops.
104    pub fn new() -> Self {
105        let previous = RECORDING.with(|r| r.replace(false));
106        Self { previous }
107    }
108}
109
110impl Default for NoGradGuard {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116impl Drop for NoGradGuard {
117    fn drop(&mut self) {
118        let previous = self.previous;
119        RECORDING.with(|r| r.set(previous));
120    }
121}
122
123fn meta_id(meta: &Arc<AutogradMeta>) -> usize {
124    Arc::as_ptr(meta) as usize
125}
126
127/// Reverse-topological gradient propagation from `root`, seeding with
128/// `seed`. Called by [`Tensor::backward`].
129pub(crate) fn run_backward(root: &Tensor, seed: Tensor) -> Result<()> {
130    // Everything backward computes is bookkeeping, not new graph.
131    no_grad(|| run_backward_inner(root, seed))
132}
133
134fn run_backward_inner(root: &Tensor, seed: Tensor) -> Result<()> {
135    let Some(root_meta) = root.autograd_meta() else {
136        return Ok(());
137    };
138
139    // Post-order DFS for a topological order over the tape.
140    let mut order: Vec<Tensor> = Vec::new();
141    let mut visited: HashMap<usize, ()> = HashMap::new();
142    let mut stack: Vec<(Tensor, bool)> = vec![(root.clone(), false)];
143    while let Some((t, expanded)) = stack.pop() {
144        let Some(meta) = t.autograd_meta() else {
145            continue;
146        };
147        let id = meta_id(&meta);
148        if expanded {
149            order.push(t);
150            continue;
151        }
152        if visited.contains_key(&id) {
153            continue;
154        }
155        visited.insert(id, ());
156        stack.push((t.clone(), true));
157        if let Some(gf) = &meta.grad_fn {
158            for input in &gf.inputs {
159                stack.push((input.clone(), false));
160            }
161        }
162    }
163
164    let mut pending: HashMap<usize, Tensor> = HashMap::new();
165    pending.insert(meta_id(&root_meta), seed);
166
167    for t in order.into_iter().rev() {
168        let meta = t.autograd_meta().expect("ordered tensors carry meta");
169        let id = meta_id(&meta);
170        let Some(grad) = pending.remove(&id) else {
171            continue;
172        };
173
174        if meta.requires_grad {
175            let mut slot = meta.grad.lock().expect("grad mutex poisoned");
176            *slot = Some(match slot.take() {
177                Some(existing) => existing.add(&grad)?,
178                None => grad.clone(),
179            });
180        }
181
182        if let Some(gf) = &meta.grad_fn {
183            let input_grads = (gf.vjp)(&grad)?;
184            debug_assert_eq!(input_grads.len(), gf.inputs.len());
185            for (input, ig) in gf.inputs.iter().zip(input_grads) {
186                let (Some(im), Some(ig)) = (input.autograd_meta(), ig) else {
187                    continue;
188                };
189                let iid = meta_id(&im);
190                let accumulated = match pending.remove(&iid) {
191                    Some(existing) => existing.add(&ig)?,
192                    None => ig,
193                };
194                pending.insert(iid, accumulated);
195            }
196        }
197    }
198    Ok(())
199}