Skip to main content

rlx_ir/
graph.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//! The computation graph — a DAG of typed tensor operations.
6//!
7//! Graphs are append-only during construction (like SSA). Nodes reference
8//! inputs by [`NodeId`], forming a directed acyclic graph. The graph
9//! owns all nodes and provides traversal, printing, and validation.
10
11use crate::{Op, Shape};
12
13use crate::provenance::NodeOrigin;
14
15/// Stable identifier for a node in the graph. Indices are never reused.
16#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
18pub struct NodeId(pub u32);
19
20impl std::fmt::Display for NodeId {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        write!(f, "%{}", self.0)
23    }
24}
25
26/// A single node in the computation graph.
27#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
28#[derive(Debug, Clone)]
29pub struct Node {
30    pub id: NodeId,
31    /// The operation this node performs.
32    pub op: Op,
33    /// Input node IDs (operands). Order matches `Op::num_inputs()`.
34    pub inputs: Vec<NodeId>,
35    /// Output tensor shape (computed at construction time).
36    pub shape: Shape,
37    /// Human-readable name for debugging.
38    pub name: Option<String>,
39    /// Cross-stage provenance (HIR block, fusion pass, …).
40    pub origin: Option<NodeOrigin>,
41}
42
43/// A computation graph — the core IR data structure.
44///
45/// # Example
46/// ```
47/// use rlx_ir::*;
48///
49/// let mut g = Graph::new("bert_layer");
50///
51/// // Inputs
52/// let x = g.input("hidden", Shape::new(&[4, 15, 384], DType::F32));
53/// let w = g.param("qkv_weight", Shape::new(&[384, 1152], DType::F32));
54/// let b = g.param("qkv_bias", Shape::new(&[1152], DType::F32));
55///
56/// // QKV projection: matmul + bias
57/// let mm = g.matmul(x, w, Shape::new(&[4, 15, 1152], DType::F32));
58/// let qkv = g.binary(op::BinaryOp::Add, mm, b, Shape::new(&[4, 15, 1152], DType::F32));
59///
60/// assert_eq!(g.len(), 5);
61/// println!("{g}");
62/// ```
63#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
64#[derive(Clone, Debug)]
65pub struct Graph {
66    pub name: String,
67    nodes: Vec<Node>,
68    /// Output node IDs (the graph's results).
69    pub outputs: Vec<NodeId>,
70}
71
72// Subgraph equality is structural: same name, same node count, same outputs.
73// Full deep equality would require comparing every node and is rarely useful;
74// this gives Op derives `PartialEq` cheap structural comparison.
75impl PartialEq for Graph {
76    fn eq(&self, other: &Self) -> bool {
77        self.name == other.name
78            && self.nodes.len() == other.nodes.len()
79            && self.outputs == other.outputs
80    }
81}
82
83impl Graph {
84    pub fn new(name: impl Into<String>) -> Self {
85        Self {
86            name: name.into(),
87            nodes: Vec::new(),
88            outputs: Vec::new(),
89        }
90    }
91
92    /// Number of nodes in the graph.
93    pub fn len(&self) -> usize {
94        self.nodes.len()
95    }
96    pub fn is_empty(&self) -> bool {
97        self.nodes.is_empty()
98    }
99
100    /// Get a node by ID.
101    pub fn node(&self, id: NodeId) -> &Node {
102        &self.nodes[id.0 as usize]
103    }
104
105    /// Iterate all nodes in topological order (insertion order = topo order).
106    pub fn nodes(&self) -> &[Node] {
107        &self.nodes
108    }
109
110    /// Get the shape of a node's output.
111    pub fn shape(&self, id: NodeId) -> &Shape {
112        &self.nodes[id.0 as usize].shape
113    }
114
115    /// Set the graph outputs.
116    pub fn set_outputs(&mut self, outputs: Vec<NodeId>) {
117        self.outputs = outputs;
118    }
119
120    /// Replace the input list of a node in place. Used by post-
121    /// construction passes (`quant_propagate`, `dce`, etc.) that
122    /// rewire consumers without inserting new nodes.
123    /// Caller is responsible for shape consistency — this does no
124    /// re-inference.
125    pub fn set_inputs(&mut self, id: NodeId, inputs: Vec<NodeId>) {
126        self.nodes[id.0 as usize].inputs = inputs;
127    }
128
129    pub fn node_mut(&mut self, id: NodeId) -> &mut Node {
130        &mut self.nodes[id.0 as usize]
131    }
132
133    pub fn nodes_mut(&mut self) -> &mut [Node] {
134        &mut self.nodes
135    }
136
137    // ── Node constructors ───────────────────────────────────────
138
139    /// Append a node to the graph. `pub(crate)` so per-op builder
140    /// files in `rlx_ir::ops::*` can call it (plan #53).
141    /// Append a node for backend graph slicing (e.g. TPU HLO segments).
142    pub fn append_node(
143        &mut self,
144        op: Op,
145        inputs: Vec<NodeId>,
146        shape: Shape,
147        name: Option<String>,
148    ) -> NodeId {
149        self.push(op, inputs, shape, name)
150    }
151
152    pub(crate) fn push(
153        &mut self,
154        op: Op,
155        inputs: Vec<NodeId>,
156        shape: Shape,
157        name: Option<String>,
158    ) -> NodeId {
159        self.push_ext(op, inputs, shape, name, None)
160    }
161
162    pub(crate) fn push_ext(
163        &mut self,
164        op: Op,
165        inputs: Vec<NodeId>,
166        shape: Shape,
167        name: Option<String>,
168        origin: Option<NodeOrigin>,
169    ) -> NodeId {
170        let id = NodeId(self.nodes.len() as u32);
171        self.nodes.push(Node {
172            id,
173            op,
174            inputs,
175            shape,
176            name,
177            origin,
178        });
179        id
180    }
181
182    // Per-op builders moved to `crate::ops::*` (plan #53).
183    // Adding new op families = drop a new file in `ops/`, no edits here.
184
185    // ── Analysis helpers ────────────────────────────────────────
186
187    /// Find all nodes that use a given node's output.
188    pub fn users(&self, id: NodeId) -> Vec<NodeId> {
189        self.nodes
190            .iter()
191            .filter(|n| n.inputs.contains(&id))
192            .map(|n| n.id)
193            .collect()
194    }
195
196    /// Count how many nodes use a given node's output.
197    pub fn use_count(&self, id: NodeId) -> usize {
198        self.nodes.iter().filter(|n| n.inputs.contains(&id)).count()
199    }
200
201    /// Find a node by the name on its [`Op::Input`] or [`Op::Param`].
202    ///
203    /// Input/Param leaves are the graph's roots and survive optimizer passes
204    /// (fusion, DCE), so a *name* is the stable way to recover a handle into a
205    /// **rewritten** graph whose `NodeId`s have been renumbered — e.g. after
206    /// [`fuse`](../../rlx_compile/fusion_pipeline/fn.fuse.html). Outputs are
207    /// already positionally stable in [`outputs`](Self::outputs). Returns the
208    /// first match (names are expected unique).
209    pub fn node_id_by_name(&self, name: &str) -> Option<NodeId> {
210        self.nodes.iter().find_map(|n| match &n.op {
211            Op::Input { name: nm } | Op::Param { name: nm } if nm == name => Some(n.id),
212            _ => None,
213        })
214    }
215
216    /// [`node_id_by_name`](Self::node_id_by_name) restricted to graph inputs.
217    pub fn input_id(&self, name: &str) -> Option<NodeId> {
218        self.nodes.iter().find_map(|n| match &n.op {
219            Op::Input { name: nm } if nm == name => Some(n.id),
220            _ => None,
221        })
222    }
223
224    /// [`node_id_by_name`](Self::node_id_by_name) restricted to parameters.
225    pub fn param_id(&self, name: &str) -> Option<NodeId> {
226        self.nodes.iter().find_map(|n| match &n.op {
227            Op::Param { name: nm } if nm == name => Some(n.id),
228            _ => None,
229        })
230    }
231
232    /// Topological order (already guaranteed by construction — just node indices).
233    pub fn topo_order(&self) -> impl Iterator<Item = NodeId> + '_ {
234        (0..self.nodes.len()).map(|i| NodeId(i as u32))
235    }
236
237    /// Reverse topological order (outputs first).
238    pub fn reverse_topo(&self) -> impl Iterator<Item = NodeId> + '_ {
239        (0..self.nodes.len()).rev().map(|i| NodeId(i as u32))
240    }
241
242    // ── HIR / MIR / LIR pipeline (higher-order DX) ─────────────────
243
244    /// Fusion-first model definition at HIR level.
245    ///
246    /// Returns a `GraphModule` at HIR stage; call `GraphModule::lower`
247    /// or pass to `rlx_opt::CompilePipeline::compile_module`.
248    pub fn define(
249        name: impl Into<String>,
250        build: impl FnOnce(&mut crate::hir::HirModule) -> crate::hir::HirNodeId,
251    ) -> crate::GraphModule {
252        crate::GraphModule::define(name, build)
253    }
254
255    /// Start an empty HIR-stage `GraphModule`.
256    pub fn hir(name: impl Into<String>) -> crate::GraphModule {
257        crate::GraphModule::hir(name)
258    }
259
260    /// Wrap this MIR graph in a `GraphModule` for pipeline operations.
261    pub fn module(self) -> crate::GraphModule {
262        crate::GraphModule::from_graph(self)
263    }
264
265    /// Lower a HIR module to a MIR graph.
266    pub fn from_hir(hir: crate::hir::HirModule) -> Result<Self, crate::hir::LowerError> {
267        hir.lower_to_mir().map(|m| m.into_graph())
268    }
269
270    /// View as `MirModule`.
271    pub fn to_mir(self) -> crate::MirModule {
272        crate::MirModule::from_graph(self)
273    }
274
275    /// Extract the MIR graph from optimized LIR.
276    pub fn from_lir(lir: crate::LirModule) -> Self {
277        lir.into_graph()
278    }
279
280    /// Annotated text dump (`inspect_graph`).
281    pub fn inspect(&self) -> String {
282        crate::inspect_graph(self)
283    }
284
285    /// True if any node shape uses a `Dim::Dynamic` symbol.
286    pub fn has_dynamic_dims(&self) -> bool {
287        crate::dynamic::has_dynamic_dims(self)
288    }
289
290    /// All dynamic symbols referenced in this graph.
291    pub fn dynamic_symbols(&self) -> Vec<u32> {
292        crate::dynamic::collect_dynamic_symbols(self)
293    }
294
295    /// Specialize symbolic dims to concrete sizes.
296    pub fn bind(&self, bindings: &crate::DimBinding) -> Self {
297        crate::dynamic::bind_graph(self, bindings)
298    }
299
300    /// Stage-aware dump when wrapped in `GraphModule`.
301    pub fn inspect_module(module: &crate::GraphModule) -> String {
302        module.inspect()
303    }
304}
305
306/// Pretty-print the graph in a readable IR format.
307impl std::fmt::Display for Graph {
308    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
309        writeln!(f, "graph @{} {{", self.name)?;
310        for node in &self.nodes {
311            write!(f, "  {} = {}", node.id, node.op)?;
312            if !node.inputs.is_empty() {
313                write!(f, "(")?;
314                for (i, inp) in node.inputs.iter().enumerate() {
315                    if i > 0 {
316                        write!(f, ", ")?;
317                    }
318                    write!(f, "{inp}")?;
319                }
320                write!(f, ")")?;
321            }
322            writeln!(f, " : {}", node.shape)?;
323        }
324        if !self.outputs.is_empty() {
325            write!(f, "  return ")?;
326            for (i, o) in self.outputs.iter().enumerate() {
327                if i > 0 {
328                    write!(f, ", ")?;
329                }
330                write!(f, "{o}")?;
331            }
332            writeln!(f)?;
333        }
334        writeln!(f, "}}")
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::{
342        DType,
343        op::{Activation, BinaryOp},
344    };
345
346    #[test]
347    fn build_simple_graph() {
348        let mut g = Graph::new("test");
349
350        let x = g.input("x", Shape::new(&[4, 15, 384], DType::F32));
351        let w = g.param("weight", Shape::new(&[384, 1536], DType::F32));
352        let b = g.param("bias", Shape::new(&[1536], DType::F32));
353
354        let mm = g.matmul(x, w, Shape::new(&[4, 15, 1536], DType::F32));
355        let add = g.binary(BinaryOp::Add, mm, b, Shape::new(&[4, 15, 1536], DType::F32));
356        let out = g.activation(
357            Activation::Gelu,
358            add,
359            Shape::new(&[4, 15, 1536], DType::F32),
360        );
361
362        g.set_outputs(vec![out]);
363
364        assert_eq!(g.len(), 6);
365        assert_eq!(g.use_count(mm), 1); // matmul used by add
366        assert_eq!(g.use_count(x), 1); // x used by matmul
367
368        let printed = format!("{g}");
369        assert!(printed.contains("matmul(%0, %1)"));
370        assert!(printed.contains("Gelu(%4)"));
371        assert!(printed.contains("return %5"));
372    }
373
374    /// Build a BERT layer to verify the IR can represent real models.
375    #[test]
376    fn bert_layer_graph() {
377        let mut g = Graph::new("bert_layer");
378        let f = DType::F32;
379        let h = 384;
380        let int = 1536;
381
382        // Input
383        let x = g.input("hidden", Shape::new(&[4, 15, h], f));
384
385        // QKV
386        let qkv_w = g.param("qkv.weight", Shape::new(&[h, 3 * h], f));
387        let qkv_b = g.param("qkv.bias", Shape::new(&[3 * h], f));
388        let qkv = g.matmul(x, qkv_w, Shape::new(&[4, 15, 3 * h], f));
389        let _qkv = g.binary(BinaryOp::Add, qkv, qkv_b, Shape::new(&[4, 15, 3 * h], f));
390
391        // (would split Q/K/V, attention, out_proj here — simplified)
392
393        // FFN
394        let int_w = g.param("ffn.weight", Shape::new(&[h, int], f));
395        let int_b = g.param("ffn.bias", Shape::new(&[int], f));
396        let ffn = g.matmul(x, int_w, Shape::new(&[4, 15, int], f));
397        let ffn = g.binary(BinaryOp::Add, ffn, int_b, Shape::new(&[4, 15, int], f));
398        let ffn = g.activation(Activation::Gelu, ffn, Shape::new(&[4, 15, int], f));
399
400        let out_w = g.param("ffn_out.weight", Shape::new(&[int, h], f));
401        let ffn_out = g.matmul(ffn, out_w, Shape::new(&[4, 15, h], f));
402
403        g.set_outputs(vec![ffn_out]);
404
405        assert!(g.len() > 10);
406        println!("{g}");
407    }
408}