Skip to main content

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    /// ```ignore
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        opt.set_lr(schedule.lr_at(step));
498        let names = self.param_names();
499        let refs: Vec<&str> = names.iter().map(String::as_str).collect();
500        // Quantized forward model (STE): weights rounded to the target grid.
501        let mut q = self.clone();
502        for name in &names {
503            let mut w = self
504                .param_binding(name)
505                .unwrap_or_else(|| panic!("qat: param {name:?} is not bound"))
506                .to_vec();
507            quant(&mut w);
508            q = q.with_param(name.clone(), w);
509        }
510        let mut outputs = q.value_and_grad(&refs).run_on(device, inputs);
511        let scale = grad_clip_scale(&outputs, max_grad_norm);
512        if scale < 1.0 {
513            for g in outputs.iter_mut().skip(1) {
514                for x in g.iter_mut() {
515                    *x *= scale;
516                }
517            }
518        }
519        // Optimizer updates SELF's f32 masters (not the quantized copy).
520        self.apply_optimizer_step(opt, &refs, &outputs)
521    }
522
523    /// Apply `opt` to each `wrt` param given a `[loss, grad…]` output vector,
524    /// returning the updated `Func` and the loss. Shared by every `train_step*`.
525    fn apply_optimizer_step(
526        &self,
527        opt: &mut dyn rlx_optim::Optimizer,
528        wrt: &[&str],
529        outputs: &[Vec<f32>],
530    ) -> (Func, Vec<f32>) {
531        let loss = outputs[0].clone();
532        // Collect every parameter's data + shape up front, then hand the whole
533        // batch to `step_batch` (default = the same serial loop; overriding
534        // optimizers can parallelize independent groups). Keeping the owned data
535        // alive lets us build `&mut` OptItems without per-param `with_param`
536        // clones inside the hot loop.
537        let mut datas: Vec<Vec<f32>> = wrt
538            .iter()
539            .map(|name| {
540                self.param_binding(name)
541                    .unwrap_or_else(|| panic!("train_step: param {name:?} is not bound"))
542                    .to_vec()
543            })
544            .collect();
545        let shapes: Vec<Vec<usize>> = wrt.iter().map(|name| self.param_shape(name)).collect();
546        let mut items: Vec<rlx_optim::OptItem> = datas
547            .iter_mut()
548            .enumerate()
549            .map(|(i, data)| rlx_optim::OptItem {
550                name: wrt[i],
551                shape: &shapes[i],
552                param: data.as_mut_slice(),
553                grad: &outputs[i + 1],
554            })
555            .collect();
556        opt.step_batch(&mut items);
557        drop(items);
558        opt.end_iteration();
559        let mut updated = self.clone();
560        for (name, data) in wrt.iter().zip(datas) {
561            updated = updated.with_param(*name, data);
562        }
563        (updated, loss)
564    }
565
566    /// Static dims of a `Param` node by name.
567    fn param_shape(&self, name: &str) -> Vec<usize> {
568        self.graph
569            .nodes()
570            .iter()
571            .find_map(|n| match &n.op {
572                rlx_ir::Op::Param { name: pn } if pn == name => Some(
573                    n.shape
574                        .dims()
575                        .iter()
576                        .map(|d| match d {
577                            rlx_ir::Dim::Static(s) => *s,
578                            rlx_ir::Dim::Dynamic(_) => 0,
579                        })
580                        .collect(),
581                ),
582                _ => None,
583            })
584            .unwrap_or_else(|| panic!("train_step: no param named {name:?}"))
585    }
586}
587
588/// Global-L2-norm gradient-clip factor for a `[loss, grad…]` output vector:
589/// `min(1, max_norm / ‖grads‖₂)` over the concatenation of every gradient
590/// tensor (indices `1..`). Returns `1.0` (no clip) when `max_norm <= 0` or the
591/// grad norm is within budget. Accumulated in `f64` for numerical safety.
592#[cfg(feature = "optim")]
593fn grad_clip_scale(outputs: &[Vec<f32>], max_norm: f32) -> f32 {
594    if max_norm <= 0.0 {
595        return 1.0;
596    }
597    let mut sumsq = 0f64;
598    for g in outputs.iter().skip(1) {
599        for &x in g {
600            sumsq += (x as f64) * (x as f64);
601        }
602    }
603    let norm = sumsq.sqrt() as f32;
604    if norm.is_finite() && norm > max_norm {
605        max_norm / norm
606    } else {
607        1.0
608    }
609}
610
611/// An already-compiled [`Func`], returned by [`Func::jit`]. Cloning is cheap
612/// (shared compiled graph); calls run on the same backend artifact.
613#[cfg(feature = "eval")]
614#[derive(Clone)]
615pub struct Jitted {
616    compiled: std::rc::Rc<std::cell::RefCell<rlx_runtime::CompiledGraph>>,
617    params: Vec<(String, Vec<f32>)>,
618}
619
620#[cfg(feature = "eval")]
621impl Jitted {
622    /// Run with named inputs, one `Vec<f32>` per output. No recompile, no
623    /// cache lookup. Bound params are applied before each run.
624    pub fn run(&self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
625        let mut c = self.compiled.borrow_mut();
626        for (name, data) in &self.params {
627            c.set_param(name, data);
628        }
629        c.run(inputs)
630    }
631}