Skip to main content

rlx_tensor/
transform.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3
4//! JAX-shaped composable function transforms.
5//!
6//! Where [`Tensor`] is the eager, NumPy-style surface, [`Func`] is the
7//! *functional* one: a pure function of named inputs, traced once into a
8//! graph. The transforms ([`Func::grad`], [`Func::vmap`], [`Func::jvp`],
9//! [`Func::hvp`]) each take a `Func` and return a `Func`, so they compose —
10//! exactly like `jax.jit(jax.vmap(jax.grad(f)))`:
11//!
12//! ```ignore
13//! use rlx_tensor::{Func, shape};
14//!
15//! // f(x) = sum(x * x)
16//! let f = Func::new("f", |s| {
17//!     let x = s.input("x", shape![3]);
18//!     (&x * &x).sum([0], false)
19//! });
20//!
21//! // ∂f/∂x = 2x, then batch it over 4 rows — one fused graph.
22//! let batched_grad = f.grad(&["x"]).vmap(&["x"], 4);
23//! let g = batched_grad.run(&[("x", &xs)]); // with the `eval` feature
24//! ```
25
26use rlx_ir::Graph;
27
28use crate::{GraphScope, Tensor};
29
30/// A function of named inputs and parameters, traced into a graph. Unit of
31/// composition for the [`grad`](Func::grad)/[`vmap`](Func::vmap)/
32/// [`jvp`](Func::jvp)/[`hvp`](Func::hvp) transforms.
33///
34/// **Inputs vs parameters.** Declare per-call data with
35/// [`GraphScope::input`] and trainable weights with [`GraphScope::param`].
36/// Bind weight values once with [`with_param`](Func::with_param); they are
37/// applied on every run and survive transforms — so `f.grad(&["w"])` gives
38/// gradients w.r.t. the weights (training), runnable with just the inputs.
39#[derive(Clone, Debug)]
40pub struct Func {
41    graph: Graph,
42    /// Bound parameter values (`name -> data`), applied before each run.
43    params: Vec<(String, Vec<f32>)>,
44}
45
46impl Func {
47    /// Trace a single-output function. The closure builds the body from inputs
48    /// declared via [`GraphScope::input`]; the returned tensor is the output.
49    pub fn new(name: impl Into<String>, build: impl FnOnce(&mut GraphScope) -> Tensor) -> Self {
50        Self::multi(name, |s| vec![build(s)])
51    }
52
53    /// Trace a multi-output function (the closure returns all outputs in order).
54    pub fn multi(
55        name: impl Into<String>,
56        build: impl FnOnce(&mut GraphScope) -> Vec<Tensor>,
57    ) -> Self {
58        let mut scope = GraphScope::new(name);
59        let outs = build(&mut scope);
60        scope.set_outputs(outs);
61        Self {
62            graph: scope.finish(),
63            params: Vec::new(),
64        }
65    }
66
67    /// Wrap an already-built graph (its outputs are this function's outputs).
68    pub fn from_graph(graph: Graph) -> Self {
69        Self {
70            graph,
71            params: Vec::new(),
72        }
73    }
74
75    /// Bind a parameter (weight) value by name. Overwrites any prior binding
76    /// for that name. Builder-style: chain several, then `run`/`grad`/`jit`.
77    pub fn with_param(mut self, name: impl Into<String>, data: impl Into<Vec<f32>>) -> Self {
78        let name = name.into();
79        let data = data.into();
80        if let Some(slot) = self.params.iter_mut().find(|(n, _)| *n == name) {
81            slot.1 = data;
82        } else {
83            self.params.push((name, data));
84        }
85        self
86    }
87
88    /// The underlying graph.
89    pub fn graph(&self) -> &Graph {
90        &self.graph
91    }
92
93    /// Number of outputs this function produces.
94    pub fn output_count(&self) -> usize {
95        self.graph.outputs.len()
96    }
97
98    /// The currently bound value of a parameter, if any. After
99    /// [`train_step`](Func::train_step) this is the updated weight.
100    pub fn param_binding(&self, name: &str) -> Option<&[f32]> {
101        self.params
102            .iter()
103            .find(|(n, _)| n == name)
104            .map(|(_, d)| d.as_slice())
105    }
106
107    /// Carry the bound params onto a transformed graph.
108    #[cfg(feature = "autodiff")]
109    fn derive(&self, graph: Graph) -> Func {
110        Func {
111            graph,
112            params: self.params.clone(),
113        }
114    }
115
116    /// Resolve `Op::Input` / `Op::Param` names to node ids, in request order.
117    /// Differentiating w.r.t. a `Param` name is how training gradients work.
118    #[cfg(feature = "autodiff")]
119    fn node_ids_by_name(&self, names: &[&str]) -> Vec<rlx_ir::NodeId> {
120        names
121            .iter()
122            .map(|want| {
123                self.graph
124                    .nodes()
125                    .iter()
126                    .find_map(|n| match &n.op {
127                        rlx_ir::Op::Input { name } | rlx_ir::Op::Param { name } if name == want => {
128                            Some(n.id)
129                        }
130                        _ => None,
131                    })
132                    .unwrap_or_else(|| panic!("Func: no input or param named {want:?}"))
133            })
134            .collect()
135    }
136}
137
138/// Composable transforms (require `rlx_autodiff`; enabled by `grad` /
139/// `transforms` / `autodiff`).
140#[cfg(feature = "autodiff")]
141impl Func {
142    /// Reverse-mode gradient w.r.t. the named inputs. The first output is
143    /// treated as the (scalar) loss; the unit seed is baked in, so the result
144    /// is a function `inputs -> [∂loss/∂wrt …]` runnable with no extra feeds.
145    pub fn grad(&self, wrt: &[&str]) -> Func {
146        let ids = self.node_ids_by_name(wrt);
147        let mut bwd = rlx_autodiff::grad(&self.graph, &ids);
148        crate::tensor::bake_unit_seed(&mut bwd);
149        self.derive(bwd)
150    }
151
152    /// Like [`grad`](Func::grad) but the result emits the loss **and** the
153    /// gradients: outputs `[loss, ∂loss/∂wrt[0], …]`. The JAX `value_and_grad`.
154    pub fn value_and_grad(&self, wrt: &[&str]) -> Func {
155        let ids = self.node_ids_by_name(wrt);
156        let mut bwd = rlx_autodiff::grad_with_loss(&self.graph, &ids);
157        crate::tensor::bake_unit_seed(&mut bwd);
158        self.derive(bwd)
159    }
160
161    /// Vectorize over a leading batch axis on the named inputs (`out_axes = 0`).
162    /// Inputs not listed are shared across the batch.
163    pub fn vmap(&self, batched: &[&str], batch_size: usize) -> Func {
164        self.derive(rlx_autodiff::vmap(&self.graph, batched, batch_size))
165    }
166
167    /// Forward-mode Jacobian-vector product w.r.t. the named inputs/params. The
168    /// returned function gains a tangent input per `tangent_for` entry.
169    pub fn jvp(&self, tangent_for: &[&str]) -> Func {
170        let ids = self.node_ids_by_name(tangent_for);
171        self.derive(rlx_autodiff::jvp(&self.graph, &ids))
172    }
173
174    /// Hessian-vector product w.r.t. the named inputs/params.
175    pub fn hvp(&self, wrt: &[&str]) -> Func {
176        let ids = self.node_ids_by_name(wrt);
177        self.derive(rlx_autodiff::hvp(&self.graph, &ids))
178    }
179}
180
181/// Execution (requires the `eval` feature).
182#[cfg(feature = "eval")]
183impl Func {
184    /// Compile + run with named inputs, one `Vec<f32>` per output. The device
185    /// is auto-selected (fastest compiled-in backend that can run the graph).
186    pub fn run(&self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
187        self.run_on(rlx_runtime::fastest_device_for(&self.graph), inputs)
188    }
189
190    /// Compile + run on an explicit device. The compiled graph is cached, so
191    /// repeated `run` calls with different inputs recompile nothing — a real
192    /// `jit`. Bound params are applied before each run.
193    pub fn run_on(&self, device: rlx_runtime::Device, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
194        let compiled = crate::cache::compiled(&self.graph, device);
195        let mut c = compiled.borrow_mut();
196        for (name, data) in &self.params {
197            c.set_param(name, data);
198        }
199        c.run(inputs)
200    }
201
202    /// Compile now and return a callable that holds the compiled graph, so
203    /// each [`Jitted::run`] skips even the cache lookup. The JAX-shaped
204    /// `jit(f)`; composes after the other transforms:
205    /// `f.grad(&["x"]).vmap(&["x"], 4).jit()`.
206    pub fn jit(&self) -> Jitted {
207        self.jit_on(rlx_runtime::fastest_device_for(&self.graph))
208    }
209
210    /// [`Func::jit`] targeting an explicit device.
211    pub fn jit_on(&self, device: rlx_runtime::Device) -> Jitted {
212        Jitted {
213            compiled: crate::cache::compiled(&self.graph, device),
214            params: self.params.clone(),
215        }
216    }
217}
218
219/// Training (requires the `optim` feature: autodiff + eval + `rlx_optim`).
220#[cfg(feature = "optim")]
221impl Func {
222    /// One optimization step. Computes value + gradients over `wrt` (which must
223    /// name bound params), applies `opt` to each param, and returns the updated
224    /// function plus the loss. Drive a loop by reassigning the returned `Func`:
225    ///
226    /// ```ignore
227    /// let mut opt = Sgd::new(0.1);
228    /// for _ in 0..100 {
229    ///     let (next, loss) = model.train_step(&mut opt, &["w"], &[("x", &xs)]);
230    ///     model = next;
231    /// }
232    /// ```
233    /// [`train_step`](Func::train_step) with a learning-rate schedule: sets
234    /// `opt`'s LR to `schedule.lr_at(step)` before stepping. Drive a loop by
235    /// passing the iteration index as `step`.
236    pub fn train_step_at(
237        &self,
238        opt: &mut dyn rlx_optim::Optimizer,
239        schedule: &crate::LrSchedule,
240        step: usize,
241        wrt: &[&str],
242        inputs: &[(&str, &[f32])],
243    ) -> (Func, Vec<f32>) {
244        opt.set_lr(schedule.lr_at(step));
245        self.train_step(opt, wrt, inputs)
246    }
247
248    pub fn train_step(
249        &self,
250        opt: &mut dyn rlx_optim::Optimizer,
251        wrt: &[&str],
252        inputs: &[(&str, &[f32])],
253    ) -> (Func, Vec<f32>) {
254        let outputs = self.value_and_grad(wrt).run(inputs);
255        let loss = outputs[0].clone();
256        let mut updated = self.clone();
257        for (i, name) in wrt.iter().enumerate() {
258            let grad = &outputs[i + 1];
259            let shape = self.param_shape(name);
260            let mut data = self
261                .param_binding(name)
262                .unwrap_or_else(|| panic!("train_step: param {name:?} is not bound"))
263                .to_vec();
264            opt.step(name, &shape, &mut data, grad);
265            updated = updated.with_param(*name, data);
266        }
267        opt.end_iteration();
268        (updated, loss)
269    }
270
271    /// Static dims of a `Param` node by name.
272    fn param_shape(&self, name: &str) -> Vec<usize> {
273        self.graph
274            .nodes()
275            .iter()
276            .find_map(|n| match &n.op {
277                rlx_ir::Op::Param { name: pn } if pn == name => Some(
278                    n.shape
279                        .dims()
280                        .iter()
281                        .map(|d| match d {
282                            rlx_ir::Dim::Static(s) => *s,
283                            rlx_ir::Dim::Dynamic(_) => 0,
284                        })
285                        .collect(),
286                ),
287                _ => None,
288            })
289            .unwrap_or_else(|| panic!("train_step: no param named {name:?}"))
290    }
291}
292
293/// An already-compiled [`Func`], returned by [`Func::jit`]. Cloning is cheap
294/// (shared compiled graph); calls run on the same backend artifact.
295#[cfg(feature = "eval")]
296#[derive(Clone)]
297pub struct Jitted {
298    compiled: std::rc::Rc<std::cell::RefCell<rlx_runtime::CompiledGraph>>,
299    params: Vec<(String, Vec<f32>)>,
300}
301
302#[cfg(feature = "eval")]
303impl Jitted {
304    /// Run with named inputs, one `Vec<f32>` per output. No recompile, no
305    /// cache lookup. Bound params are applied before each run.
306    pub fn run(&self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
307        let mut c = self.compiled.borrow_mut();
308        for (name, data) in &self.params {
309            c.set_param(name, data);
310        }
311        c.run(inputs)
312    }
313}