rlx_tensor/transform.rs
1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! JAX-shaped composable function transforms.
6//!
7//! Where [`Tensor`] is the eager, NumPy-style surface, [`Func`] is the
8//! *functional* one: a pure function of named inputs, traced once into a
9//! graph. The transforms ([`Func::grad`], [`Func::vmap`], [`Func::jvp`],
10//! [`Func::hvp`]) each take a `Func` and return a `Func`, so they compose —
11//! exactly like `jax.jit(jax.vmap(jax.grad(f)))`:
12//!
13//! ```ignore
14//! use rlx_tensor::{Func, shape};
15//!
16//! // f(x) = sum(x * x)
17//! let f = Func::new("f", |s| {
18//! let x = s.input("x", shape![3]);
19//! (&x * &x).sum([0], false)
20//! });
21//!
22//! // ∂f/∂x = 2x, then batch it over 4 rows — one fused graph.
23//! let batched_grad = f.grad(&["x"]).vmap(&["x"], 4);
24//! let g = batched_grad.run(&[("x", &xs)]); // with the `eval` feature
25//! ```
26
27use rlx_ir::Graph;
28
29use crate::{GraphScope, Tensor};
30
31/// A function of named inputs and parameters, traced into a graph. Unit of
32/// composition for the [`grad`](Func::grad)/[`vmap`](Func::vmap)/
33/// [`jvp`](Func::jvp)/[`hvp`](Func::hvp) transforms.
34///
35/// **Inputs vs parameters.** Declare per-call data with
36/// [`GraphScope::input`] and trainable weights with [`GraphScope::param`].
37/// Bind weight values once with [`with_param`](Func::with_param); they are
38/// applied on every run and survive transforms — so `f.grad(&["w"])` gives
39/// gradients w.r.t. the weights (training), runnable with just the inputs.
40#[derive(Clone, Debug)]
41pub struct Func {
42 graph: Graph,
43 /// Bound parameter values (`name -> data`), applied before each run.
44 params: Vec<(String, Vec<f32>)>,
45}
46
47impl Func {
48 /// Trace a single-output function. The closure builds the body from inputs
49 /// declared via [`GraphScope::input`]; the returned tensor is the output.
50 pub fn new(name: impl Into<String>, build: impl FnOnce(&mut GraphScope) -> Tensor) -> Self {
51 Self::multi(name, |s| vec![build(s)])
52 }
53
54 /// Trace a multi-output function (the closure returns all outputs in order).
55 pub fn multi(
56 name: impl Into<String>,
57 build: impl FnOnce(&mut GraphScope) -> Vec<Tensor>,
58 ) -> Self {
59 let mut scope = GraphScope::new(name);
60 let outs = build(&mut scope);
61 scope.set_outputs(outs);
62 Self {
63 graph: scope.finish(),
64 params: Vec::new(),
65 }
66 }
67
68 /// Wrap an already-built graph (its outputs are this function's outputs).
69 pub fn from_graph(graph: Graph) -> Self {
70 Self {
71 graph,
72 params: Vec::new(),
73 }
74 }
75
76 /// Bind a parameter (weight) value by name. Overwrites any prior binding
77 /// for that name. Builder-style: chain several, then `run`/`grad`/`jit`.
78 pub fn with_param(mut self, name: impl Into<String>, data: impl Into<Vec<f32>>) -> Self {
79 let name = name.into();
80 let data = data.into();
81 if let Some(slot) = self.params.iter_mut().find(|(n, _)| *n == name) {
82 slot.1 = data;
83 } else {
84 self.params.push((name, data));
85 }
86 self
87 }
88
89 /// The underlying graph.
90 pub fn graph(&self) -> &Graph {
91 &self.graph
92 }
93
94 /// Number of outputs this function produces.
95 pub fn output_count(&self) -> usize {
96 self.graph.outputs.len()
97 }
98
99 /// The currently bound value of a parameter, if any. After
100 /// [`train_step`](Func::train_step) this is the updated weight.
101 pub fn param_binding(&self, name: &str) -> Option<&[f32]> {
102 self.params
103 .iter()
104 .find(|(n, _)| n == name)
105 .map(|(_, d)| d.as_slice())
106 }
107
108 /// The names of every trainable parameter (`Op::Param`) in the graph, in
109 /// declaration order — exactly the `wrt` list a full-model training step
110 /// needs, so you never hand-maintain it. Powers `init_params`, the
111 /// `*_all` training steps, and checkpoint round-trips.
112 pub fn param_names(&self) -> Vec<String> {
113 self.graph
114 .nodes()
115 .iter()
116 .filter_map(|n| match &n.op {
117 rlx_ir::Op::Param { name } => Some(name.clone()),
118 _ => None,
119 })
120 .collect()
121 }
122
123 /// The static shape (dims) of a parameter by name, or `None` if there is no
124 /// such param. A dynamic axis reads as `0`.
125 pub fn param_shape_of(&self, name: &str) -> Option<Vec<usize>> {
126 self.graph.nodes().iter().find_map(|n| match &n.op {
127 rlx_ir::Op::Param { name: pn } if pn == name => Some(
128 n.shape
129 .dims()
130 .iter()
131 .map(|d| match d {
132 rlx_ir::Dim::Static(s) => *s,
133 rlx_ir::Dim::Dynamic(_) => 0,
134 })
135 .collect(),
136 ),
137 _ => None,
138 })
139 }
140
141 /// Bind every declared parameter by calling `init(name, &static_dims)` and
142 /// using the returned `Vec<f32>` — one call seeds a whole model's weights,
143 /// instead of a [`with_param`](Func::with_param) per tensor. Overwrites any
144 /// existing bindings. Builder-style: chain before `train_step`.
145 ///
146 /// ```text
147 /// let model = Func::from_graph(rlx! { … }).init_params(|name, dims| {
148 /// if name.ends_with(".bias") { vec![0.0; dims.iter().product()] }
149 /// else { he_init(dims) }
150 /// });
151 /// ```
152 pub fn init_params(mut self, mut init: impl FnMut(&str, &[usize]) -> Vec<f32>) -> Self {
153 let specs: Vec<(String, Vec<usize>)> = self
154 .param_names()
155 .into_iter()
156 .map(|n| {
157 let dims = self.param_shape_of(&n).unwrap_or_default();
158 (n, dims)
159 })
160 .collect();
161 for (name, dims) in specs {
162 let data = init(&name, &dims);
163 self = self.with_param(name, data);
164 }
165 self
166 }
167
168 /// Seed every parameter with i.i.d. Gaussian noise `N(0, stddev²)` from a
169 /// deterministic SplitMix64 + Box–Muller stream keyed by `seed`
170 /// (reproducible, no `rand` dependency). A one-liner for a from-scratch
171 /// model; for per-tensor schemes (zeroed biases, fan-in scaling) pass your
172 /// own closure to [`init_params`](Func::init_params).
173 pub fn init_randn(self, seed: u64, stddev: f32) -> Self {
174 // SplitMix64 → uniform in [0, 1).
175 fn u01(state: &mut u64) -> f64 {
176 *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
177 let mut x = *state;
178 x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
179 x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
180 x ^= x >> 31;
181 (x >> 11) as f64 / (1u64 << 53) as f64
182 }
183 let mut state = seed ^ 0x9E37_79B9_7F4A_7C15;
184 self.init_params(move |_name, dims| {
185 let n: usize = dims.iter().map(|&d| d.max(1)).product();
186 (0..n)
187 .map(|_| {
188 let u1 = u01(&mut state).max(1e-12);
189 let u2 = u01(&mut state);
190 let g = (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos();
191 (g as f32) * stddev
192 })
193 .collect()
194 })
195 }
196
197 /// Carry the bound params onto a transformed graph.
198 #[cfg(feature = "autodiff")]
199 fn derive(&self, graph: Graph) -> Func {
200 Func {
201 graph,
202 params: self.params.clone(),
203 }
204 }
205
206 /// Resolve `Op::Input` / `Op::Param` names to node ids, in request order.
207 /// Differentiating w.r.t. a `Param` name is how training gradients work.
208 #[cfg(feature = "autodiff")]
209 fn node_ids_by_name(&self, names: &[&str]) -> Vec<rlx_ir::NodeId> {
210 names
211 .iter()
212 .map(|want| {
213 self.graph
214 .nodes()
215 .iter()
216 .find_map(|n| match &n.op {
217 rlx_ir::Op::Input { name } | rlx_ir::Op::Param { name } if name == want => {
218 Some(n.id)
219 }
220 _ => None,
221 })
222 .unwrap_or_else(|| panic!("Func: no input or param named {want:?}"))
223 })
224 .collect()
225 }
226}
227
228/// Composable transforms (require `rlx_autodiff`; enabled by `grad` /
229/// `transforms` / `autodiff`).
230#[cfg(feature = "autodiff")]
231impl Func {
232 /// Reverse-mode gradient w.r.t. the named inputs. The first output is
233 /// treated as the (scalar) loss; the unit seed is baked in, so the result
234 /// is a function `inputs -> [∂loss/∂wrt …]` runnable with no extra feeds.
235 pub fn grad(&self, wrt: &[&str]) -> Func {
236 let ids = self.node_ids_by_name(wrt);
237 let mut bwd = rlx_autodiff::grad(&self.graph, &ids);
238 crate::tensor::bake_unit_seed(&mut bwd);
239 self.derive(bwd)
240 }
241
242 /// Like [`grad`](Func::grad) but the result emits the loss **and** the
243 /// gradients: outputs `[loss, ∂loss/∂wrt[0], …]`. The JAX `value_and_grad`.
244 pub fn value_and_grad(&self, wrt: &[&str]) -> Func {
245 let ids = self.node_ids_by_name(wrt);
246 let mut bwd = rlx_autodiff::grad_with_loss(&self.graph, &ids);
247 crate::tensor::bake_unit_seed(&mut bwd);
248 self.derive(bwd)
249 }
250
251 /// [`value_and_grad`](Func::value_and_grad) w.r.t. **every** parameter in
252 /// the graph — no hand-maintained name list. Outputs `[loss, ∂loss/∂p …]`
253 /// ordered like [`param_names`](Func::param_names).
254 pub fn value_and_grad_all(&self) -> Func {
255 let names = self.param_names();
256 let refs: Vec<&str> = names.iter().map(String::as_str).collect();
257 self.value_and_grad(&refs)
258 }
259
260 /// [`grad`](Func::grad) w.r.t. every parameter in the graph.
261 pub fn grad_all(&self) -> Func {
262 let names = self.param_names();
263 let refs: Vec<&str> = names.iter().map(String::as_str).collect();
264 self.grad(&refs)
265 }
266
267 /// Vectorize over a leading batch axis on the named inputs (`out_axes = 0`).
268 /// Inputs not listed are shared across the batch.
269 pub fn vmap(&self, batched: &[&str], batch_size: usize) -> Func {
270 self.derive(rlx_autodiff::vmap(&self.graph, batched, batch_size))
271 }
272
273 /// Forward-mode Jacobian-vector product w.r.t. the named inputs/params. The
274 /// returned function gains a tangent input per `tangent_for` entry.
275 pub fn jvp(&self, tangent_for: &[&str]) -> Func {
276 let ids = self.node_ids_by_name(tangent_for);
277 self.derive(rlx_autodiff::jvp(&self.graph, &ids))
278 }
279
280 /// Hessian-vector product w.r.t. the named inputs/params.
281 pub fn hvp(&self, wrt: &[&str]) -> Func {
282 let ids = self.node_ids_by_name(wrt);
283 self.derive(rlx_autodiff::hvp(&self.graph, &ids))
284 }
285}
286
287/// Execution (requires the `eval` feature).
288#[cfg(feature = "eval")]
289impl Func {
290 /// Compile + run with named inputs, one `Vec<f32>` per output. The device
291 /// is auto-selected (fastest compiled-in backend that can run the graph).
292 pub fn run(&self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
293 self.run_on(rlx_runtime::fastest_device_for(&self.graph), inputs)
294 }
295
296 /// Compile + run on an explicit device. The compiled graph is cached, so
297 /// repeated `run` calls with different inputs recompile nothing — a real
298 /// `jit`. Bound params are applied before each run.
299 pub fn run_on(&self, device: rlx_runtime::Device, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
300 let compiled = crate::cache::compiled(&self.graph, device);
301 let mut c = compiled.borrow_mut();
302 for (name, data) in &self.params {
303 c.set_param(name, data);
304 }
305 c.run(inputs)
306 }
307
308 /// Compile now and return a callable that holds the compiled graph, so
309 /// each [`Jitted::run`] skips even the cache lookup. The JAX-shaped
310 /// `jit(f)`; composes after the other transforms:
311 /// `f.grad(&["x"]).vmap(&["x"], 4).jit()`.
312 pub fn jit(&self) -> Jitted {
313 self.jit_on(rlx_runtime::fastest_device_for(&self.graph))
314 }
315
316 /// [`Func::jit`] targeting an explicit device.
317 pub fn jit_on(&self, device: rlx_runtime::Device) -> Jitted {
318 Jitted {
319 compiled: crate::cache::compiled(&self.graph, device),
320 params: self.params.clone(),
321 }
322 }
323}
324
325/// Training (requires the `optim` feature: autodiff + eval + `rlx_optim`).
326#[cfg(feature = "optim")]
327impl Func {
328 /// One optimization step. Computes value + gradients over `wrt` (which must
329 /// name bound params), applies `opt` to each param, and returns the updated
330 /// function plus the loss. Drive a loop by reassigning the returned `Func`:
331 ///
332 /// ```ignore
333 /// let mut opt = Sgd::new(0.1);
334 /// for _ in 0..100 {
335 /// let (next, loss) = model.train_step(&mut opt, &["w"], &[("x", &xs)]);
336 /// model = next;
337 /// }
338 /// ```
339 /// [`train_step`](Func::train_step) with a learning-rate schedule: sets
340 /// `opt`'s LR to `schedule.lr_at(step)` before stepping. Drive a loop by
341 /// passing the iteration index as `step`.
342 pub fn train_step_at(
343 &self,
344 opt: &mut dyn rlx_optim::Optimizer,
345 schedule: &crate::LrSchedule,
346 step: usize,
347 wrt: &[&str],
348 inputs: &[(&str, &[f32])],
349 ) -> (Func, Vec<f32>) {
350 opt.set_lr(schedule.lr_at(step));
351 self.train_step(opt, wrt, inputs)
352 }
353
354 pub fn train_step(
355 &self,
356 opt: &mut dyn rlx_optim::Optimizer,
357 wrt: &[&str],
358 inputs: &[(&str, &[f32])],
359 ) -> (Func, Vec<f32>) {
360 let outputs = self.value_and_grad(wrt).run(inputs);
361 self.apply_optimizer_step(opt, wrt, &outputs)
362 }
363
364 /// [`train_step`](Func::train_step) pinned to an explicit `device` instead
365 /// of the auto-selected fastest backend — the one call you need to train a
366 /// whole run on, say, Metal (no hand-rolled `value_and_grad().run_on()` +
367 /// optimizer loop).
368 pub fn train_step_on(
369 &self,
370 device: rlx_runtime::Device,
371 opt: &mut dyn rlx_optim::Optimizer,
372 wrt: &[&str],
373 inputs: &[(&str, &[f32])],
374 ) -> (Func, Vec<f32>) {
375 let outputs = self.value_and_grad(wrt).run_on(device, inputs);
376 self.apply_optimizer_step(opt, wrt, &outputs)
377 }
378
379 /// [`train_step_on`](Func::train_step_on) with a learning-rate schedule.
380 pub fn train_step_at_on(
381 &self,
382 device: rlx_runtime::Device,
383 opt: &mut dyn rlx_optim::Optimizer,
384 schedule: &crate::LrSchedule,
385 step: usize,
386 wrt: &[&str],
387 inputs: &[(&str, &[f32])],
388 ) -> (Func, Vec<f32>) {
389 opt.set_lr(schedule.lr_at(step));
390 self.train_step_on(device, opt, wrt, inputs)
391 }
392
393 /// [`train_step`](Func::train_step) w.r.t. **every** parameter — the `wrt`
394 /// list is [`param_names`](Func::param_names), so the call is just
395 /// `(opt, inputs)`.
396 pub fn train_step_all(
397 &self,
398 opt: &mut dyn rlx_optim::Optimizer,
399 inputs: &[(&str, &[f32])],
400 ) -> (Func, Vec<f32>) {
401 let names = self.param_names();
402 let refs: Vec<&str> = names.iter().map(String::as_str).collect();
403 self.train_step(opt, &refs, inputs)
404 }
405
406 /// All-parameter [`train_step_at`](Func::train_step_at).
407 pub fn train_step_all_at(
408 &self,
409 opt: &mut dyn rlx_optim::Optimizer,
410 schedule: &crate::LrSchedule,
411 step: usize,
412 inputs: &[(&str, &[f32])],
413 ) -> (Func, Vec<f32>) {
414 opt.set_lr(schedule.lr_at(step));
415 self.train_step_all(opt, inputs)
416 }
417
418 /// All-parameter [`train_step_on`](Func::train_step_on).
419 pub fn train_step_all_on(
420 &self,
421 device: rlx_runtime::Device,
422 opt: &mut dyn rlx_optim::Optimizer,
423 inputs: &[(&str, &[f32])],
424 ) -> (Func, Vec<f32>) {
425 let names = self.param_names();
426 let refs: Vec<&str> = names.iter().map(String::as_str).collect();
427 self.train_step_on(device, opt, &refs, inputs)
428 }
429
430 /// The full-model, device-pinned, scheduled training step — the whole loop
431 /// body for a from-scratch model is `model.train_step_all_at_on(dev, &mut
432 /// opt, &sched, step, feed)`.
433 pub fn train_step_all_at_on(
434 &self,
435 device: rlx_runtime::Device,
436 opt: &mut dyn rlx_optim::Optimizer,
437 schedule: &crate::LrSchedule,
438 step: usize,
439 inputs: &[(&str, &[f32])],
440 ) -> (Func, Vec<f32>) {
441 opt.set_lr(schedule.lr_at(step));
442 self.train_step_all_on(device, opt, inputs)
443 }
444
445 /// [`train_step_all_at_on`](Func::train_step_all_at_on) with **global-L2-norm
446 /// gradient clipping** to `max_grad_norm` — the standard cure for the loss
447 /// spikes that can blow a run up to NaN late in training (~1.0 is typical).
448 /// A non-positive `max_grad_norm` disables clipping (identical to the
449 /// unclipped variant).
450 pub fn train_step_all_at_on_clipped(
451 &self,
452 device: rlx_runtime::Device,
453 opt: &mut dyn rlx_optim::Optimizer,
454 schedule: &crate::LrSchedule,
455 step: usize,
456 max_grad_norm: f32,
457 inputs: &[(&str, &[f32])],
458 ) -> (Func, Vec<f32>) {
459 opt.set_lr(schedule.lr_at(step));
460 let names = self.param_names();
461 let refs: Vec<&str> = names.iter().map(String::as_str).collect();
462 let mut outputs = self.value_and_grad(&refs).run_on(device, inputs);
463 let scale = grad_clip_scale(&outputs, max_grad_norm);
464 if scale < 1.0 {
465 for g in outputs.iter_mut().skip(1) {
466 for x in g.iter_mut() {
467 *x *= scale;
468 }
469 }
470 }
471 self.apply_optimizer_step(opt, &refs, &outputs)
472 }
473
474 /// **Quantization-aware** full-model step: each parameter is passed through
475 /// `quant` (an in-place quantizer) before the forward, and the gradient is
476 /// taken at that quantized point — but applied to the f32 **master** weights
477 /// (a straight-through estimator). This trains the model at whatever emulated
478 /// precision `quant` imposes, on any backend. Pair with [`crate::lowp`] for
479 /// `fXmYeZ` / nvf4 / f8 / bf8 formats:
480 ///
481 /// ```ignore
482 /// let (e, m, max) = rlx_tensor::lowp::parse_format("nvf4").unwrap();
483 /// let (next, loss) = model.train_step_all_at_on_qat(
484 /// dev, &mut opt, &sched, step, 1.0,
485 /// |w| rlx_tensor::lowp::quantize_slice(w, e, m, max), feed);
486 /// ```
487 pub fn train_step_all_at_on_qat(
488 &self,
489 device: rlx_runtime::Device,
490 opt: &mut dyn rlx_optim::Optimizer,
491 schedule: &crate::LrSchedule,
492 step: usize,
493 max_grad_norm: f32,
494 quant: impl Fn(&mut [f32]),
495 inputs: &[(&str, &[f32])],
496 ) -> (Func, Vec<f32>) {
497 self.train_step_all_at_on_qat_with(
498 device,
499 opt,
500 schedule,
501 step,
502 max_grad_norm,
503 |_, _, w| quant(w),
504 inputs,
505 )
506 }
507
508 /// [`train_step_all_at_on_qat`](Self::train_step_all_at_on_qat) where the
509 /// quantizer also sees each parameter's name and shape.
510 ///
511 /// Block formats need this. A per-block scale is only usable by hardware if
512 /// its blocks run along the axis a dot product contracts over, and a flat
513 /// `&mut [f32]` does not say which axis that is — blocking the wrong way
514 /// gives every output its own scale mid-dot-product, which no kernel can
515 /// apply. Per-tensor and per-element formats can keep using the simpler
516 /// entry point.
517 pub fn train_step_all_at_on_qat_with(
518 &self,
519 device: rlx_runtime::Device,
520 opt: &mut dyn rlx_optim::Optimizer,
521 schedule: &crate::LrSchedule,
522 step: usize,
523 max_grad_norm: f32,
524 quant: impl Fn(&str, &[usize], &mut [f32]),
525 inputs: &[(&str, &[f32])],
526 ) -> (Func, Vec<f32>) {
527 opt.set_lr(schedule.lr_at(step));
528 let names = self.param_names();
529 let refs: Vec<&str> = names.iter().map(String::as_str).collect();
530 // Quantized forward model (STE): weights rounded to the target grid.
531 let mut q = self.clone();
532 for name in &names {
533 let mut w = self
534 .param_binding(name)
535 .unwrap_or_else(|| panic!("qat: param {name:?} is not bound"))
536 .to_vec();
537 let shape = self.param_shape_of(name).unwrap_or_else(|| vec![w.len()]);
538 quant(name, &shape, &mut w);
539 q = q.with_param(name.clone(), w);
540 }
541 let mut outputs = q.value_and_grad(&refs).run_on(device, inputs);
542 let scale = grad_clip_scale(&outputs, max_grad_norm);
543 if scale < 1.0 {
544 for g in outputs.iter_mut().skip(1) {
545 for x in g.iter_mut() {
546 *x *= scale;
547 }
548 }
549 }
550 // Optimizer updates SELF's f32 masters (not the quantized copy).
551 self.apply_optimizer_step(opt, &refs, &outputs)
552 }
553
554 /// Apply `opt` to each `wrt` param given a `[loss, grad…]` output vector,
555 /// returning the updated `Func` and the loss. Shared by every `train_step*`.
556 fn apply_optimizer_step(
557 &self,
558 opt: &mut dyn rlx_optim::Optimizer,
559 wrt: &[&str],
560 outputs: &[Vec<f32>],
561 ) -> (Func, Vec<f32>) {
562 let loss = outputs[0].clone();
563 // Collect every parameter's data + shape up front, then hand the whole
564 // batch to `step_batch` (default = the same serial loop; overriding
565 // optimizers can parallelize independent groups). Keeping the owned data
566 // alive lets us build `&mut` OptItems without per-param `with_param`
567 // clones inside the hot loop.
568 let mut datas: Vec<Vec<f32>> = wrt
569 .iter()
570 .map(|name| {
571 self.param_binding(name)
572 .unwrap_or_else(|| panic!("train_step: param {name:?} is not bound"))
573 .to_vec()
574 })
575 .collect();
576 let shapes: Vec<Vec<usize>> = wrt.iter().map(|name| self.param_shape(name)).collect();
577 let mut items: Vec<rlx_optim::OptItem> = datas
578 .iter_mut()
579 .enumerate()
580 .map(|(i, data)| rlx_optim::OptItem {
581 name: wrt[i],
582 shape: &shapes[i],
583 param: data.as_mut_slice(),
584 grad: &outputs[i + 1],
585 })
586 .collect();
587 opt.step_batch(&mut items);
588 drop(items);
589 opt.end_iteration();
590 let mut updated = self.clone();
591 for (name, data) in wrt.iter().zip(datas) {
592 updated = updated.with_param(*name, data);
593 }
594 (updated, loss)
595 }
596
597 /// Static dims of a `Param` node by name.
598 fn param_shape(&self, name: &str) -> Vec<usize> {
599 self.graph
600 .nodes()
601 .iter()
602 .find_map(|n| match &n.op {
603 rlx_ir::Op::Param { name: pn } if pn == name => Some(
604 n.shape
605 .dims()
606 .iter()
607 .map(|d| match d {
608 rlx_ir::Dim::Static(s) => *s,
609 rlx_ir::Dim::Dynamic(_) => 0,
610 })
611 .collect(),
612 ),
613 _ => None,
614 })
615 .unwrap_or_else(|| panic!("train_step: no param named {name:?}"))
616 }
617}
618
619/// Global-L2-norm gradient-clip factor for a `[loss, grad…]` output vector:
620/// `min(1, max_norm / ‖grads‖₂)` over the concatenation of every gradient
621/// tensor (indices `1..`). Returns `1.0` (no clip) when `max_norm <= 0` or the
622/// grad norm is within budget. Accumulated in `f64` for numerical safety.
623#[cfg(feature = "optim")]
624fn grad_clip_scale(outputs: &[Vec<f32>], max_norm: f32) -> f32 {
625 if max_norm <= 0.0 {
626 return 1.0;
627 }
628 let mut sumsq = 0f64;
629 for g in outputs.iter().skip(1) {
630 for &x in g {
631 sumsq += (x as f64) * (x as f64);
632 }
633 }
634 let norm = sumsq.sqrt() as f32;
635 if norm.is_finite() && norm > max_norm {
636 max_norm / norm
637 } else {
638 1.0
639 }
640}
641
642/// An already-compiled [`Func`], returned by [`Func::jit`]. Cloning is cheap
643/// (shared compiled graph); calls run on the same backend artifact.
644#[cfg(feature = "eval")]
645#[derive(Clone)]
646pub struct Jitted {
647 compiled: std::rc::Rc<std::cell::RefCell<rlx_runtime::CompiledGraph>>,
648 params: Vec<(String, Vec<f32>)>,
649}
650
651#[cfg(feature = "eval")]
652impl Jitted {
653 /// Run with named inputs, one `Vec<f32>` per output. No recompile, no
654 /// cache lookup. Bound params are applied before each run.
655 pub fn run(&self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
656 let mut c = self.compiled.borrow_mut();
657 for (name, data) in &self.params {
658 c.set_param(name, data);
659 }
660 c.run(inputs)
661 }
662}