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}