Skip to main content

rlx_runtime/
trace.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//! Tracing API — build IR graphs by recording operations on traced tensors.
6//!
7//! ```rust
8//! use rlx_runtime::trace::*;
9//! use rlx_ir::{DType, shape::Dim};
10//!
11//! let graph = trace("model", |t| {
12//!     let x = t.input("x", &[4, 384], DType::F32);
13//!     let w = t.param("w", &[384, 1536], DType::F32);
14//!     let b = t.param("b", &[1536], DType::F32);
15//!     let mm = t.matmul(x, w);
16//!     let out = (mm + b).gelu();
17//!     vec![out]
18//! });
19//! ```
20
21use rlx_ir::infer::GraphExt;
22use rlx_ir::*;
23use std::cell::RefCell;
24use std::rc::Rc;
25
26/// A traced tensor — records ops instead of executing them.
27#[derive(Clone)]
28pub struct TracedTensor {
29    pub(crate) id: NodeId,
30    graph: Rc<RefCell<Graph>>,
31}
32
33/// Records operations into an IR graph.
34pub struct Tracer {
35    graph: Rc<RefCell<Graph>>,
36}
37
38impl Tracer {
39    fn new(name: &str) -> Self {
40        Self {
41            graph: Rc::new(RefCell::new(Graph::new(name))),
42        }
43    }
44
45    /// Declare a graph input with static dimensions.
46    pub fn input(&self, name: &str, dims: &[usize], dtype: DType) -> TracedTensor {
47        let id = self.graph.borrow_mut().input(name, Shape::new(dims, dtype));
48        TracedTensor {
49            id,
50            graph: self.graph.clone(),
51        }
52    }
53
54    /// Declare a graph input with mixed static/dynamic dimensions.
55    pub fn input_dyn(&self, name: &str, dims: &[Dim], dtype: DType) -> TracedTensor {
56        let id = self
57            .graph
58            .borrow_mut()
59            .input(name, Shape::from_dims(dims, dtype));
60        TracedTensor {
61            id,
62            graph: self.graph.clone(),
63        }
64    }
65
66    /// Declare a parameter (weight) with static dimensions.
67    pub fn param(&self, name: &str, dims: &[usize], dtype: DType) -> TracedTensor {
68        let id = self.graph.borrow_mut().param(name, Shape::new(dims, dtype));
69        TracedTensor {
70            id,
71            graph: self.graph.clone(),
72        }
73    }
74
75    /// Matrix multiply.
76    pub fn matmul(&self, lhs: TracedTensor, rhs: TracedTensor) -> TracedTensor {
77        let id = self.graph.borrow_mut().mm(lhs.id, rhs.id);
78        TracedTensor {
79            id,
80            graph: self.graph.clone(),
81        }
82    }
83
84    /// Layer normalization.
85    pub fn layer_norm(
86        &self,
87        x: TracedTensor,
88        gamma: TracedTensor,
89        beta: TracedTensor,
90        eps: f32,
91    ) -> TracedTensor {
92        let id = self.graph.borrow_mut().ln(x.id, gamma.id, beta.id, eps);
93        TracedTensor {
94            id,
95            graph: self.graph.clone(),
96        }
97    }
98
99    /// Softmax.
100    pub fn softmax(&self, x: TracedTensor, axis: i32) -> TracedTensor {
101        let id = self.graph.borrow_mut().sm(x.id, axis);
102        TracedTensor {
103            id,
104            graph: self.graph.clone(),
105        }
106    }
107
108    /// Gather (embedding lookup).
109    pub fn gather(&self, table: TracedTensor, indices: TracedTensor, axis: usize) -> TracedTensor {
110        let id = self.graph.borrow_mut().gather_(table.id, indices.id, axis);
111        TracedTensor {
112            id,
113            graph: self.graph.clone(),
114        }
115    }
116}
117
118// ── TracedTensor method chaining ────────────────────────────────────────
119
120impl TracedTensor {
121    pub fn matmul(self, rhs: TracedTensor) -> TracedTensor {
122        let id = self.graph.borrow_mut().mm(self.id, rhs.id);
123        TracedTensor {
124            id,
125            graph: self.graph.clone(),
126        }
127    }
128
129    pub fn gelu(self) -> TracedTensor {
130        let id = self.graph.borrow_mut().gelu(self.id);
131        TracedTensor {
132            id,
133            graph: self.graph.clone(),
134        }
135    }
136
137    pub fn silu(self) -> TracedTensor {
138        let id = self.graph.borrow_mut().silu(self.id);
139        TracedTensor {
140            id,
141            graph: self.graph.clone(),
142        }
143    }
144
145    pub fn relu(self) -> TracedTensor {
146        let id = self.graph.borrow_mut().relu(self.id);
147        TracedTensor {
148            id,
149            graph: self.graph.clone(),
150        }
151    }
152
153    pub fn layer_norm(self, gamma: TracedTensor, beta: TracedTensor, eps: f32) -> TracedTensor {
154        let id = self.graph.borrow_mut().ln(self.id, gamma.id, beta.id, eps);
155        TracedTensor {
156            id,
157            graph: self.graph.clone(),
158        }
159    }
160
161    pub fn softmax(self, axis: i32) -> TracedTensor {
162        let id = self.graph.borrow_mut().sm(self.id, axis);
163        TracedTensor {
164            id,
165            graph: self.graph.clone(),
166        }
167    }
168
169    pub fn reshape(self, new_shape: &[i64]) -> TracedTensor {
170        let id = self
171            .graph
172            .borrow_mut()
173            .reshape_(self.id, new_shape.to_vec());
174        TracedTensor {
175            id,
176            graph: self.graph.clone(),
177        }
178    }
179
180    pub fn transpose(self, perm: &[usize]) -> TracedTensor {
181        let id = self.graph.borrow_mut().transpose_(self.id, perm.to_vec());
182        TracedTensor {
183            id,
184            graph: self.graph.clone(),
185        }
186    }
187
188    pub fn narrow(self, axis: usize, start: usize, len: usize) -> TracedTensor {
189        let id = self.graph.borrow_mut().narrow_(self.id, axis, start, len);
190        TracedTensor {
191            id,
192            graph: self.graph.clone(),
193        }
194    }
195
196    // ── PyTorch-shaped ergonomics (plan #60) ────────────────────────
197
198    /// Number of dimensions. PyTorch's `.dim()`.
199    pub fn rank(&self) -> usize {
200        self.graph.borrow().shape(self.id).rank()
201    }
202
203    /// Output shape — useful for derived computations / asserts.
204    pub fn shape(&self) -> rlx_ir::Shape {
205        self.graph.borrow().shape(self.id).clone()
206    }
207
208    /// 2-D transpose shorthand. `t.t()` swaps the last two axes,
209    /// matching PyTorch's `.t()` for matrices.
210    pub fn t(&self) -> TracedTensor {
211        let rank = self.rank();
212        assert!(rank >= 2, ".t() requires rank >= 2");
213        let mut perm: Vec<usize> = (0..rank).collect();
214        perm.swap(rank - 2, rank - 1);
215        let id = self.graph.borrow_mut().transpose_(self.id, perm);
216        TracedTensor {
217            id,
218            graph: self.graph.clone(),
219        }
220    }
221
222    /// Permute dimensions — alias of [`Self::transpose`] under
223    /// PyTorch's name.
224    pub fn permute(&self, perm: &[usize]) -> TracedTensor {
225        let id = self.graph.borrow_mut().transpose_(self.id, perm.to_vec());
226        TracedTensor {
227            id,
228            graph: self.graph.clone(),
229        }
230    }
231
232    /// Insert a length-1 dim at `axis`. Bumps every existing dim
233    /// at `>= axis` by one position.
234    pub fn unsqueeze(&self, axis: usize) -> TracedTensor {
235        let s = self.shape();
236        let rank = s.rank();
237        assert!(
238            axis <= rank,
239            "unsqueeze axis {axis} out of range for rank {rank}"
240        );
241        let mut new_shape: Vec<i64> = (0..rank).map(|i| s.dim(i).unwrap_static() as i64).collect();
242        new_shape.insert(axis, 1);
243        let id = self.graph.borrow_mut().reshape_(self.id, new_shape);
244        TracedTensor {
245            id,
246            graph: self.graph.clone(),
247        }
248    }
249
250    /// Drop a length-1 dim at `axis`. Errors at compile-time-of-
251    /// graph if the dim isn't 1.
252    pub fn squeeze(&self, axis: usize) -> TracedTensor {
253        let s = self.shape();
254        let rank = s.rank();
255        assert!(
256            axis < rank,
257            "squeeze axis {axis} out of range for rank {rank}"
258        );
259        assert_eq!(
260            s.dim(axis).unwrap_static(),
261            1,
262            "squeeze axis {axis} has dim {} (must be 1)",
263            s.dim(axis).unwrap_static()
264        );
265        let new_shape: Vec<i64> = (0..rank)
266            .filter(|&i| i != axis)
267            .map(|i| s.dim(i).unwrap_static() as i64)
268            .collect();
269        let id = self.graph.borrow_mut().reshape_(self.id, new_shape);
270        TracedTensor {
271            id,
272            graph: self.graph.clone(),
273        }
274    }
275
276    /// Reference-friendly matmul. `a.mm(&b)` doesn't move either.
277    pub fn mm(&self, rhs: &TracedTensor) -> TracedTensor {
278        let id = self.graph.borrow_mut().mm(self.id, rhs.id);
279        TracedTensor {
280            id,
281            graph: self.graph.clone(),
282        }
283    }
284}
285
286// ── Operator overloads ──────────────────────────────────────────────────
287
288impl std::ops::Add for TracedTensor {
289    type Output = TracedTensor;
290    fn add(self, rhs: TracedTensor) -> TracedTensor {
291        let id = self.graph.borrow_mut().add(self.id, rhs.id);
292        TracedTensor {
293            id,
294            graph: self.graph.clone(),
295        }
296    }
297}
298
299impl std::ops::Sub for TracedTensor {
300    type Output = TracedTensor;
301    fn sub(self, rhs: TracedTensor) -> TracedTensor {
302        let id = self.graph.borrow_mut().sub(self.id, rhs.id);
303        TracedTensor {
304            id,
305            graph: self.graph.clone(),
306        }
307    }
308}
309
310impl std::ops::Mul for TracedTensor {
311    type Output = TracedTensor;
312    fn mul(self, rhs: TracedTensor) -> TracedTensor {
313        let id = self.graph.borrow_mut().mul(self.id, rhs.id);
314        TracedTensor {
315            id,
316            graph: self.graph.clone(),
317        }
318    }
319}
320
321impl std::ops::Div for TracedTensor {
322    type Output = TracedTensor;
323    fn div(self, rhs: TracedTensor) -> TracedTensor {
324        let id = self.graph.borrow_mut().div(self.id, rhs.id);
325        TracedTensor {
326            id,
327            graph: self.graph.clone(),
328        }
329    }
330}
331
332impl std::ops::Neg for TracedTensor {
333    type Output = TracedTensor;
334    fn neg(self) -> TracedTensor {
335        let id = self.graph.borrow_mut().neg(self.id);
336        TracedTensor {
337            id,
338            graph: self.graph.clone(),
339        }
340    }
341}
342
343// ── Reference-based operator overloads (plan #60) ───────────────
344//
345// Mirror PyTorch's `a + b` behaviour where neither operand is
346// consumed. The `&a + &b` form is the cheapest (one
347// graph.borrow_mut + one Rc::clone); `a + &b` and `&a + b` cover
348// the mixed cases without forcing the caller to add a `.clone()`.
349
350macro_rules! impl_ref_binop {
351    ($trait:ident, $method:ident, $graph_method:ident) => {
352        // &T op &T
353        impl std::ops::$trait<&TracedTensor> for &TracedTensor {
354            type Output = TracedTensor;
355            fn $method(self, rhs: &TracedTensor) -> TracedTensor {
356                let id = self.graph.borrow_mut().$graph_method(self.id, rhs.id);
357                TracedTensor {
358                    id,
359                    graph: self.graph.clone(),
360                }
361            }
362        }
363        // T op &T
364        impl std::ops::$trait<&TracedTensor> for TracedTensor {
365            type Output = TracedTensor;
366            fn $method(self, rhs: &TracedTensor) -> TracedTensor {
367                (&self).$method(rhs)
368            }
369        }
370        // &T op T
371        impl std::ops::$trait<TracedTensor> for &TracedTensor {
372            type Output = TracedTensor;
373            fn $method(self, rhs: TracedTensor) -> TracedTensor {
374                self.$method(&rhs)
375            }
376        }
377    };
378}
379
380impl_ref_binop!(Add, add, add);
381impl_ref_binop!(Sub, sub, sub);
382impl_ref_binop!(Mul, mul, mul);
383impl_ref_binop!(Div, div, div);
384
385impl std::ops::Neg for &TracedTensor {
386    type Output = TracedTensor;
387    fn neg(self) -> TracedTensor {
388        let id = self.graph.borrow_mut().neg(self.id);
389        TracedTensor {
390            id,
391            graph: self.graph.clone(),
392        }
393    }
394}
395
396// ── trace() entry point ─────────────────────────────────────────────────
397
398/// Trace a function into an IR graph.
399///
400/// The closure receives a [`Tracer`] and returns output tensors.
401/// All operations are recorded (not executed) into the graph.
402pub fn trace<F>(name: &str, f: F) -> Graph
403where
404    F: FnOnce(&Tracer) -> Vec<TracedTensor>,
405{
406    let tracer = Tracer::new(name);
407    let outputs = f(&tracer);
408    let output_ids: Vec<NodeId> = outputs.iter().map(|t| t.id).collect();
409    // Drop all TracedTensors (they hold Rc refs to the graph)
410    drop(outputs);
411    let mut graph = Rc::try_unwrap(tracer.graph)
412        .expect("tracer graph still borrowed")
413        .into_inner();
414    graph.set_outputs(output_ids);
415    graph
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use rlx_ir::op::Activation;
422
423    #[test]
424    fn trace_matmul_bias_gelu() {
425        let graph = trace("test", |t| {
426            let x = t.input("x", &[4, 15, 384], DType::F32);
427            let w = t.param("w", &[384, 1536], DType::F32);
428            let b = t.param("b", &[1536], DType::F32);
429            let mm = t.matmul(x, w);
430            let out = (mm + b).gelu();
431            vec![out]
432        });
433
434        assert_eq!(graph.len(), 6); // x, w, b, mm, add, gelu
435        assert_eq!(
436            graph.shape(graph.outputs[0]),
437            &Shape::new(&[4, 15, 1536], DType::F32)
438        );
439        println!("{graph}");
440    }
441
442    #[test]
443    fn trace_operator_overloads() {
444        let graph = trace("ops", |t| {
445            let a = t.input("a", &[4, 384], DType::F32);
446            let b = t.input("b", &[4, 384], DType::F32);
447            let c = a.clone() + b.clone();
448            let d = a.clone() * b.clone();
449            let e = c - d;
450            vec![e]
451        });
452
453        assert_eq!(graph.len(), 5); // a, b, add, mul, sub
454        assert_eq!(
455            graph.shape(graph.outputs[0]),
456            &Shape::new(&[4, 384], DType::F32)
457        );
458    }
459
460    #[test]
461    fn trace_method_chaining() {
462        let graph = trace("chain", |t| {
463            let x = t.input("x", &[4, 15, 384], DType::F32);
464            let w = t.param("w", &[384, 1536], DType::F32);
465            let out = x.matmul(w).gelu();
466            vec![out]
467        });
468
469        assert_eq!(graph.len(), 4); // x, w, mm, gelu
470        assert_eq!(
471            graph.shape(graph.outputs[0]),
472            &Shape::new(&[4, 15, 1536], DType::F32)
473        );
474    }
475
476    #[test]
477    fn pytorch_shaped_ergonomics() {
478        // Reference-based ops + .t() + .permute + .unsqueeze /
479        // .squeeze + .mm — full PyTorch ergonomic surface in one
480        // expression.
481        let graph = trace("ergonomics", |t| {
482            let a = t.input("a", &[4, 8], DType::F32);
483            let b = t.param("b", &[8, 4], DType::F32);
484            // No clones — &+& and method-style chain.
485            let c = a.mm(&b); // [4, 4]
486            let d = &c + &c; // [4, 4]
487            let e = d.t(); // [4, 4] transposed
488            let f = e.unsqueeze(0); // [1, 4, 4]
489            let g = f.squeeze(0); // [4, 4]
490            let h = g.permute(&[1, 0]); // [4, 4]
491            vec![h]
492        });
493        assert_eq!(
494            graph.shape(graph.outputs[0]),
495            &Shape::new(&[4, 4], DType::F32)
496        );
497    }
498
499    #[test]
500    fn trace_produces_fuseable_graph() {
501        use rlx_opt::fusion::FuseMatMulBiasAct;
502        use rlx_opt::pass::Pass;
503
504        let graph = trace("fuseable", |t| {
505            let x = t.input("x", &[4, 15, 384], DType::F32);
506            let w = t.param("w", &[384, 1536], DType::F32);
507            let b = t.param("b", &[1536], DType::F32);
508            let mm = t.matmul(x, w);
509            let out = (mm + b).gelu();
510            vec![out]
511        });
512
513        // Before: 6 nodes
514        assert_eq!(graph.len(), 6);
515
516        // After fusion: 4 nodes (fused_mm_bias_gelu)
517        let fused = FuseMatMulBiasAct.run(graph);
518        assert_eq!(fused.len(), 4);
519
520        let out_node = fused.node(fused.outputs[0]);
521        assert!(matches!(
522            out_node.op,
523            Op::FusedMatMulBiasAct {
524                activation: Some(Activation::Gelu)
525            }
526        ));
527    }
528}