Skip to main content

vyre_foundation/analysis/
graph_view.rs

1//! P7.5 — Graph-IR compatibility bridge.
2//!
3//! Statement-IR (`Program` + `Node` + `Expr`) is the canonical
4//! form in 0.6 — wire-format stable, every backend speaks it.
5//! Graph-IR is a pure VIEW on top: lets whole-program optimization
6//! passes (kernel fusion, dead-subregion elimination) walk the IR
7//! as a DAG of `DataflowNode`s connected by `DataEdge`s.
8//!
9//! **Contract freeze:** the graph-view types (`GraphNode`,
10//! `DataflowKind`, `DataEdge`, `NodeGraph`) are `#[non_exhaustive]`
11//! and live in this module permanently. External crates that
12//! want a graph-IR pass (sparse-graph coloring, auto-fusion,
13//! parallelism scheduling) pin against this surface without
14//! forcing a statement-IR rewrite.
15//!
16//! **No wire-format impact.** `to_graph(program)` is a
17//! lossless analysis; `from_graph(graph)` rebuilds an equivalent
18//! statement-IR Program. Round-trip is byte-identity under
19//! canonicalization.
20//!
21//! Today's deliverable: the types + a minimal `to_graph` /
22//! `from_graph` walker that treats each top-level Node as a
23//! graph node with explicit ordering edges. Richer dataflow
24//! analysis (reaching-definition edges, implicit-dependency
25//! discovery) land as optimization passes without changing the
26//! graph types.
27
28use crate::ir_inner::model::expr::Ident;
29use crate::ir_inner::model::node::Node;
30use crate::ir_inner::model::program::Program;
31use core::fmt;
32
33/// Validation error returned when a `NodeGraph` fails structural
34/// checks before lowering to statement-IR.
35#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum GraphValidateError {
38    /// A cycle was detected in the graph.
39    Cycle {
40        /// Node ids forming the cycle.
41        path: Vec<u32>,
42    },
43    /// An edge references a node id that does not exist.
44    DanglingEdge {
45        /// Source node id.
46        from: u32,
47        /// Target node id.
48        to: u32,
49    },
50    /// A Phi node has no valid predecessors.
51    OrphanPhi {
52        /// The Phi node id.
53        node_id: u32,
54    },
55}
56
57impl fmt::Display for GraphValidateError {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match self {
60            Self::Cycle { path } => {
61                write!(
62                    f,
63                    "graph contains a cycle involving nodes {:?}. Fix: remove cyclic dependencies so the graph is a valid DAG.",
64                    path
65                )
66            }
67            Self::DanglingEdge { from, to } => {
68                write!(
69                    f,
70                    "edge from {} to {} references a non-existent node. Fix: ensure all edge endpoints exist in the graph's node list.",
71                    from, to
72                )
73            }
74            Self::OrphanPhi { node_id } => {
75                write!(
76                    f,
77                    "Phi node {} has no valid predecessors. Fix: ensure every Phi node references at least one existing predecessor node.",
78                    node_id
79                )
80            }
81        }
82    }
83}
84
85impl std::error::Error for GraphValidateError {}
86
87/// One node in the graph-IR view.
88#[derive(Debug, Clone)]
89#[non_exhaustive]
90pub struct GraphNode {
91    /// Stable id inside this graph. Issued sequentially during
92    /// `to_graph` for reproducibility across runs.
93    pub id: u32,
94    /// Discriminant + payload. Currently carries a reference back
95    /// to the statement-IR Node; graph-native variants land in
96    /// follow-on passes (autodiff-tape, sparse-region, etc.).
97    pub kind: DataflowKind,
98}
99
100impl GraphNode {
101    /// Construct a `GraphNode` from explicit fields (V7-EXT-025).
102    #[must_use]
103    pub fn new(id: u32, kind: DataflowKind) -> Self {
104        Self { id, kind }
105    }
106}
107
108/// Kind of a graph node.
109#[derive(Debug, Clone)]
110#[non_exhaustive]
111pub enum DataflowKind {
112    /// A statement-IR node executed as-is.
113    Statement(Node),
114    /// A synthetic "phi" introduced by later dataflow-analysis
115    /// passes. Carries the set of graph-node ids that feed it.
116    Phi(Vec<u32>),
117    /// An explicit no-op barrier for scheduling passes that want
118    /// to pin ordering without executing anything.
119    Barrier,
120}
121
122/// Directed edge between two `GraphNode`s.
123#[derive(Debug, Clone, PartialEq, Eq)]
124#[non_exhaustive]
125pub struct DataEdge {
126    /// Source node id.
127    pub from: u32,
128    /// Destination node id.
129    pub to: u32,
130    /// Kind of the dependency.
131    pub kind: EdgeKind,
132}
133
134impl DataEdge {
135    /// Construct a `DataEdge` from explicit fields (V7-EXT-026).
136    #[must_use]
137    pub fn new(from: u32, to: u32, kind: EdgeKind) -> Self {
138        Self { from, to, kind }
139    }
140}
141
142/// Dependency kinds an edge can express.
143#[derive(Debug, Clone, PartialEq, Eq)]
144#[non_exhaustive]
145pub enum EdgeKind {
146    /// Pure ordering dependency (statement-IR sequence).
147    Ordering,
148    /// Reaching-definition: `to` reads a variable defined by `from`.
149    /// Producers that run reaching-defs analysis populate this edge.
150    Def {
151        /// The variable name that flows along this edge.
152        name: Ident,
153    },
154    /// Control-flow dependency (branch → body).
155    Control,
156}
157
158/// Graph-IR view over a statement-IR Program.
159#[derive(Debug, Clone, Default)]
160#[non_exhaustive]
161pub struct NodeGraph {
162    /// Nodes indexed by their `id`.
163    pub nodes: Vec<GraphNode>,
164    /// Edges between nodes.
165    pub edges: Vec<DataEdge>,
166    /// Original workgroup size. Preserved through view conversion.
167    pub workgroup_size: [u32; 3],
168    /// Original buffer declarations. Preserved through view conversion.
169    pub buffers: Vec<crate::ir_inner::model::program::BufferDecl>,
170}
171
172impl NodeGraph {
173    /// Construct a `NodeGraph` from explicit node / edge vectors.
174    /// Used by external tooling that synthesizes graphs without
175    /// going through `from_program` (V7-EXT-027).
176    ///
177    /// workgroup_size defaults to [1, 1, 1] and buffers defaults to
178    /// empty. For full control use struct-literal syntax inside the
179    /// defining crate.
180    #[must_use]
181    pub fn new(nodes: Vec<GraphNode>, edges: Vec<DataEdge>) -> Self {
182        Self {
183            nodes,
184            edges,
185            workgroup_size: [1, 1, 1],
186            buffers: Vec::new(),
187        }
188    }
189
190    /// Lift a statement-IR `Program` into its graph-IR view.
191    ///
192    /// The 0.6 lifting emits one `GraphNode::Statement` per top-
193    /// level `Node::entry()` node, connected by `Ordering` edges in
194    /// document order. Later passes refine edges with reaching-
195    /// definition / control-flow / dataflow analyses.
196    ///
197    /// VYRE_IR_HOTSPOTS HIGH (graph_view.rs:205): the previous
198    /// implementation cloned every top-level node via `n.clone()`.
199    /// This helper now delegates to `from_program_owned` after
200    /// cloning the inner structure cheaply via `Arc` refcount bumps,
201    /// so the hot path (when the caller owns the Program) can move
202    /// directly into the graph without the per-node clone.
203    #[must_use]
204    pub fn from_program(program: &Program) -> Self {
205        Self::from_program_owned(program.clone())
206    }
207
208    /// Build the graph by consuming the Program — moves the entry
209    /// `Vec<Node>` out of its `Arc` when uniquely owned and avoids
210    /// cloning each node. Use this whenever the caller holds the
211    /// only `Program` reference.
212    #[must_use]
213    pub fn from_program_owned(program: Program) -> Self {
214        let workgroup_size = program.workgroup_size();
215        let buffers = program.buffers().to_vec();
216        let entry_vec = program.into_entry_vec();
217        let mut nodes = Vec::with_capacity(entry_vec.len());
218        let mut edges = Vec::with_capacity(entry_vec.len().saturating_sub(1));
219        for (i, n) in entry_vec.into_iter().enumerate() {
220            #[allow(clippy::cast_possible_truncation)]
221            let id = i as u32;
222            nodes.push(GraphNode {
223                id,
224                kind: DataflowKind::Statement(n),
225            });
226            if id > 0 {
227                edges.push(DataEdge {
228                    from: id - 1,
229                    to: id,
230                    kind: EdgeKind::Ordering,
231                });
232            }
233        }
234        Self {
235            nodes,
236            edges,
237            workgroup_size,
238            buffers,
239        }
240    }
241
242    /// Lower the graph view back into a statement-IR Program.
243    /// Preserves document-order of `GraphNode::Statement` variants;
244    /// `Phi` and synthetic `Barrier` variants are dropped (they
245    /// don't round-trip to statement-IR by design).
246    ///
247    /// # Errors
248    ///
249    /// Returns `GraphValidateError::DanglingEdge` if an edge references
250    /// a non-existent node id, `GraphValidateError::Cycle` if the graph
251    /// contains a directed cycle, or `GraphValidateError::OrphanPhi` if
252    /// a Phi node has no predecessors.
253    pub fn try_into_program(self) -> Result<Program, GraphValidateError> {
254        let node_count = self.nodes.len() as u32;
255
256        // 1. Check for dangling edges.
257        for edge in &self.edges {
258            if edge.from >= node_count || edge.to >= node_count {
259                return Err(GraphValidateError::DanglingEdge {
260                    from: edge.from,
261                    to: edge.to,
262                });
263            }
264        }
265
266        // 2. Check for orphan Phi nodes.
267        for node in &self.nodes {
268            if let DataflowKind::Phi(predecessors) = &node.kind {
269                if predecessors.is_empty() {
270                    return Err(GraphValidateError::OrphanPhi { node_id: node.id });
271                }
272                for &pred in predecessors {
273                    if pred >= node_count {
274                        return Err(GraphValidateError::OrphanPhi { node_id: node.id });
275                    }
276                }
277            }
278        }
279
280        // 3. Check for cycles via DFS.
281        let mut adj: Vec<Vec<u32>> = vec![Vec::new(); self.nodes.len()];
282        for edge in &self.edges {
283            adj[edge.from as usize].push(edge.to);
284        }
285
286        let mut state = vec![0u8; self.nodes.len()]; // 0 = unvisited, 1 = visiting, 2 = done
287        let mut path = Vec::new();
288
289        fn dfs(
290            node: u32,
291            adj: &[Vec<u32>],
292            state: &mut [u8],
293            path: &mut Vec<u32>,
294        ) -> Option<Vec<u32>> {
295            let idx = node as usize;
296            if state[idx] == 1 {
297                let cycle_start = path.iter().position(|&n| n == node).unwrap_or(0);
298                return Some(path[cycle_start..].to_vec());
299            }
300            if state[idx] == 2 {
301                return None;
302            }
303            state[idx] = 1;
304            path.push(node);
305            for &next in &adj[idx] {
306                if let Some(cycle) = dfs(next, adj, state, path) {
307                    return Some(cycle);
308                }
309            }
310            path.pop();
311            state[idx] = 2;
312            None
313        }
314
315        for i in 0..self.nodes.len() as u32 {
316            if state[i as usize] == 0 {
317                if let Some(cycle_path) = dfs(i, &adj, &mut state, &mut path) {
318                    return Err(GraphValidateError::Cycle { path: cycle_path });
319                }
320            }
321        }
322
323        let entry: Vec<Node> = self
324            .nodes
325            .into_iter()
326            .filter_map(|gn| match gn.kind {
327                DataflowKind::Statement(n) => Some(n),
328                DataflowKind::Phi(_) => None,
329                DataflowKind::Barrier => Some(Node::barrier()),
330            })
331            .collect();
332        Ok(Program::wrapped(self.buffers, self.workgroup_size, entry))
333    }
334}
335
336/// Convenience — `to_graph(program)` style call.
337#[must_use]
338pub fn to_graph(program: &Program) -> NodeGraph {
339    NodeGraph::from_program(program)
340}
341
342/// Convenience — `from_graph(graph)` style call.
343///
344/// # Errors
345///
346/// Returns `GraphValidateError` if the graph contains dangling edges,
347/// cycles, or orphan Phi nodes.
348pub fn from_graph(graph: NodeGraph) -> Result<Program, GraphValidateError> {
349    graph.try_into_program()
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::ir::{BufferDecl, DataType, Expr, Node, Program};
356
357    fn trivial() -> Program {
358        Program::wrapped(
359            vec![BufferDecl::read_write("out", 0, DataType::U32).with_count(1)],
360            [1, 1, 1],
361            vec![
362                Node::let_bind("x", Expr::u32(42)),
363                Node::store("out", Expr::u32(0), Expr::var("x")),
364            ],
365        )
366    }
367
368    #[test]
369    fn graph_view_mirrors_top_level_nodes() {
370        let p = trivial();
371        let g = to_graph(&p);
372        assert_eq!(g.nodes.len(), p.entry().len());
373        assert_eq!(g.workgroup_size, p.workgroup_size());
374    }
375
376    #[test]
377    fn graph_edges_are_ordering_in_sequence() {
378        let p = trivial();
379        let g = to_graph(&p);
380        assert_eq!(g.edges.len(), g.nodes.len() - 1);
381        for (i, e) in g.edges.iter().enumerate() {
382            assert_eq!(e.from, i as u32);
383            assert_eq!(e.to, (i + 1) as u32);
384            assert!(matches!(e.kind, EdgeKind::Ordering));
385        }
386    }
387
388    #[test]
389    fn round_trip_is_byte_identical_under_canonicalize() {
390        let p = trivial();
391        let g = to_graph(&p);
392        let p2 = from_graph(g).unwrap();
393        // canonicalize both (to normalize operand ordering etc.)
394        let p_c = crate::optimizer::passes::algebraic::canonicalize_engine::run(p);
395        let p2_c = crate::optimizer::passes::algebraic::canonicalize_engine::run(p2);
396        assert_eq!(p_c.to_wire().unwrap(), p2_c.to_wire().unwrap());
397    }
398
399    #[test]
400    fn phi_node_dropped_on_lowering() {
401        let mut g = NodeGraph {
402            workgroup_size: [1, 1, 1],
403            ..Default::default()
404        };
405        g.buffers
406            .push(BufferDecl::read_write("out", 0, DataType::U32).with_count(1));
407        g.nodes.push(GraphNode {
408            id: 0,
409            kind: DataflowKind::Statement(Node::store("out", Expr::u32(0), Expr::u32(1))),
410        });
411        g.nodes.push(GraphNode {
412            id: 1,
413            kind: DataflowKind::Phi(vec![0]),
414        });
415        let p = from_graph(g).unwrap();
416        assert_eq!(
417            p.entry().len(),
418            1,
419            "Phi must not round-trip to statement-IR"
420        );
421    }
422}