Skip to main content

rlx_tensor/
scope.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//! Graph builder scope.
6
7use rlx_ir::{DType, Dim, Graph, GraphExt, NodeId, Shape};
8
9use crate::Tensor;
10use crate::handle::GraphHandle;
11use crate::scalar::promote_scalar;
12
13/// Owns a traced graph while you build symbolic tensors.
14#[derive(Debug)]
15pub struct GraphScope {
16    handle: GraphHandle,
17    /// Monotonic counter for fresh dynamic-dim symbols.
18    next_dyn: u32,
19}
20
21impl GraphScope {
22    pub fn new(name: impl Into<String>) -> Self {
23        Self {
24            handle: GraphHandle::new(Graph::new(name)),
25            next_dyn: 0,
26        }
27    }
28
29    pub fn graph(&self) -> Graph {
30        self.handle.with_graph(|g| g.clone())
31    }
32
33    /// Fresh dynamic-dimension symbol (for variable batch / sequence length).
34    pub fn fresh_symbol(&mut self) -> u32 {
35        let s = self.next_dyn;
36        self.next_dyn += 1;
37        s
38    }
39
40    pub fn input(&mut self, name: impl Into<String>, shape: impl Into<Shape>) -> Tensor {
41        let id = self.handle.with_graph(|g| g.input(name, shape.into()));
42        Tensor::new(self.handle.clone(), id)
43    }
44
45    /// Input with a caller-chosen dynamic symbol on one axis.
46    pub fn input_dyn(&mut self, name: impl Into<String>, dims: &[Dim], dtype: DType) -> Tensor {
47        let shape = Shape::from_dims(dims, dtype);
48        self.input(name, shape)
49    }
50
51    pub fn param(&mut self, name: impl Into<String>, shape: impl Into<Shape>) -> Tensor {
52        let id = self.handle.with_graph(|g| g.param(name, shape.into()));
53        Tensor::new(self.handle.clone(), id)
54    }
55
56    /// Rank-0 literal broadcastable in binary ops.
57    pub fn scalar(&mut self, value: f64) -> Tensor {
58        let id = self
59            .handle
60            .with_graph(|g| promote_scalar(g, value.into(), DType::F32));
61        Tensor::new(self.handle.clone(), id)
62    }
63
64    pub fn constant(&mut self, value: f64, dtype: DType) -> Tensor {
65        let id = self.handle.with_graph(|g| g.constant(value, dtype));
66        Tensor::new(self.handle.clone(), id)
67    }
68
69    /// Materialize an N-dimensional constant tensor from row-major `data`
70    /// (given as `f64`, encoded to `dtype`'s element width). Backs the `rlx!`
71    /// array-`const` form (`const mask = [[0,1],[1,0]] : F32;`). `data.len()`
72    /// must equal the product of `dims`. Supports `F32`/`F64`/`I32`/`I64`.
73    pub fn constant_nd(&mut self, data: Vec<f64>, dims: Vec<usize>, dtype: DType) -> Tensor {
74        let n: usize = dims.iter().product();
75        assert_eq!(
76            data.len(),
77            n,
78            "constant_nd: {} values for shape {:?} ({n} expected)",
79            data.len(),
80            dims
81        );
82        let bytes: Vec<u8> = match dtype {
83            DType::F32 => data
84                .iter()
85                .flat_map(|v| (*v as f32).to_le_bytes())
86                .collect(),
87            DType::F64 => data.iter().flat_map(|v| v.to_le_bytes()).collect(),
88            DType::I32 => data
89                .iter()
90                .flat_map(|v| (*v as i32).to_le_bytes())
91                .collect(),
92            DType::I64 => data
93                .iter()
94                .flat_map(|v| (*v as i64).to_le_bytes())
95                .collect(),
96            DType::U8 => data.iter().map(|v| *v as u8).collect(),
97            other => panic!("constant_nd: unsupported dtype {other:?} (use F32/F64/I32/I64/U8)"),
98        };
99        let shape = Shape::new(&dims, dtype);
100        let id = self
101            .handle
102            .with_graph(|g| g.add_node(rlx_ir::Op::Constant { data: bytes }, vec![], shape));
103        Tensor::new(self.handle.clone(), id)
104    }
105
106    /// Build a bounded sequential scan (`Op::Scan`) — a single compact loop
107    /// node, not an unrolled copy. `init` is the loop carry; `bcasts` are values
108    /// held constant across iterations (typically weights); `length` is the
109    /// iteration count. `body(carry, bcasts) -> next_carry` is traced ONCE into
110    /// a standalone body graph, so the result is `O(1)` IR regardless of
111    /// `length` (unlike an unrolled `repeat`). The next carry must match the
112    /// carry's shape. Backs the `rlx!` `scan` construct.
113    ///
114    /// ```rust
115    /// use rlx_tensor::{graph, shape};
116    /// let g = graph("rnn", |s| {
117    ///     let h0 = s.input("h0", shape![1, 8]);
118    ///     let w  = s.param("w", shape![8, 8]);
119    ///     s.scan_block(&h0, &[&w], 12, |h, bc| (h.matmul(&bc[0])).tanh())
120    /// });
121    /// assert_eq!(g.outputs.len(), 1);
122    /// ```
123    pub fn scan_block(
124        &mut self,
125        init: &Tensor,
126        bcasts: &[&Tensor],
127        length: u32,
128        body: impl FnOnce(&Tensor, &[Tensor]) -> Tensor,
129    ) -> Tensor {
130        // Ensure carry + bcasts live in this graph (no-op if already here).
131        let init_id = self.handle.adopt(&init.handle, init.id);
132        let bcast_ids: Vec<NodeId> = bcasts
133            .iter()
134            .map(|b| self.handle.adopt(&b.handle, b.id))
135            .collect();
136
137        // Trace the loop body once into a standalone graph. Its `Op::Input`s, in
138        // order, are `[carry, bcast_0, …]` — the order `scan_with_bcasts_and_xs`
139        // expects.
140        let mut inner = GraphScope::new("scan_body");
141        let carry = inner.input("carry", init.shape());
142        let bcast_ins: Vec<Tensor> = bcasts
143            .iter()
144            .enumerate()
145            .map(|(i, b)| inner.input(format!("bcast_{i}"), b.shape()))
146            .collect();
147        let next = body(&carry, &bcast_ins);
148        inner.set_outputs([next.id]);
149        let body_graph = inner.finish();
150
151        let id = self.handle.with_graph(|g| {
152            g.scan_with_bcasts_and_xs(init_id, &bcast_ids, &[], body_graph, length)
153        });
154        Tensor::new(self.handle.clone(), id)
155    }
156
157    /// Stack existing tensors along `axis` (zero-copy at IR level).
158    pub fn cat(&mut self, tensors: &[&Tensor], axis: usize) -> Tensor {
159        let ids: Vec<NodeId> = tensors.iter().map(|t| t.id).collect();
160        let id = self.handle.with_graph(|g| g.concat_(ids, axis));
161        Tensor::new(self.handle.clone(), id)
162    }
163
164    pub fn set_outputs(&mut self, outputs: impl IntoIterator<Item = impl Into<NodeId>>) {
165        let ids: Vec<NodeId> = outputs.into_iter().map(Into::into).collect();
166        self.handle.with_graph(|g| g.set_outputs(ids));
167    }
168
169    pub fn finish(self) -> Graph {
170        self.handle.with_graph(|g| g.clone())
171    }
172}
173
174/// Build a single-output graph from a closure.
175///
176/// ```rust
177/// use rlx_tensor::{graph, shape};
178///
179/// let g = graph("mlp", |g| {
180///     let x = g.input("x", shape![2, 4]);
181///     let w = g.param("w", shape![4, 3]);
182///     let b = g.param("b", shape![3]);
183///     (&x.matmul(&w) + &b).gelu() * 2.0
184/// });
185/// assert_eq!(g.outputs.len(), 1);
186/// ```
187pub fn graph<F>(name: impl Into<String>, build: F) -> Graph
188where
189    F: FnOnce(&mut GraphScope) -> Tensor,
190{
191    let mut scope = GraphScope::new(name);
192    let out = build(&mut scope);
193    scope.set_outputs([out]);
194    scope.finish()
195}
196
197/// Build a graph and return extra metadata from the closure.
198pub fn graph_with<F, R>(name: impl Into<String>, build: F) -> (Graph, R)
199where
200    F: FnOnce(&mut GraphScope) -> R,
201{
202    let mut scope = GraphScope::new(name);
203    let result = build(&mut scope);
204    (scope.finish(), result)
205}