Skip to main content

rlx_ir/
equivalence.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//! Structural fingerprints and deep equality for [`Graph`]s.
6//!
7//! [`Graph`]'s own `PartialEq` is deliberately shallow — it compares name,
8//! node count and outputs only, so that [`Op`] (which embeds `Box<Graph>`
9//! bodies for `Scan` / `While` / `CustomFn` / …) can keep a cheap derived
10//! `PartialEq`. That is the right trade for `Op`, but it leaves the codebase
11//! with **no** way to ask "are these two graphs actually the same IR?".
12//!
13//! This module supplies that, in two forms:
14//!
15//! * [`structural_hash`] — a `u64` fingerprint, allocation-free on the common
16//!   path. Cheap enough to call after every pass, which is what lets
17//!   [`Pass`](../../rlx_fusion/pass/trait.Pass.html) report whether it changed
18//!   anything and lets the analysis cache decide what to invalidate.
19//! * [`structurally_eq`] — exact deep comparison, for golden tests where a
20//!   hash match is not good enough.
21//!
22//! Both take an [`IgnoreConfig`] so that incidental labelling — the graph
23//! name, per-node debug names, pass-provenance stamps — can be excluded. Two
24//! graphs that differ only in which pass stamped them are the same IR, and a
25//! fingerprint that says otherwise would invalidate every cached analysis on
26//! every pass.
27//!
28//! # Keying strategy
29//!
30//! Ops are keyed by their [`Debug`] rendering, streamed straight into the
31//! hasher without building a `String`. This is the same identity CSE has
32//! always used (`format!("{:?}", node.op)` as a value number), generalised:
33//! every field of every present and future variant participates automatically,
34//! with no `Hash`/`Eq` bound on `Op` and no per-op key function to keep in sync.
35//!
36//! Nested bodies are handled explicitly rather than falling out of `Debug`:
37//! an op that carries subgraphs is hashed as a *body-free surrogate* (bodies
38//! blanked via [`Op::subgraphs_mut`]) followed by a recursive hash of each
39//! real body under the same [`IgnoreConfig`]. Without that, `Debug` would
40//! inline the bodies verbatim and the ignore flags would stop applying one
41//! level down — a `Scan` whose body nodes were merely renamed would read as
42//! changed.
43//!
44//! # Stability
45//!
46//! Fingerprints are stable within a process and across processes for a given
47//! build, but they are **not** a persistence format: they ride on `Debug`
48//! output and on `DefaultHasher`, either of which may shift between compiler
49//! or crate versions. Use them for caching, change detection and test
50//! assertions — not for on-disk artifacts. [`crate::serialize`] is the
51//! durable format.
52
53use std::collections::hash_map::DefaultHasher;
54use std::fmt::Write as _;
55use std::hash::{Hash, Hasher};
56
57use crate::graph::{Graph, Node, NodeId};
58use crate::op::Op;
59use crate::shape::Shape;
60
61/// Which incidental fields to exclude from a structural comparison.
62///
63/// Construct via the [`EXACT`](Self::EXACT) / [`SEMANTIC`](Self::SEMANTIC)
64/// presets, or set the flags directly.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub struct IgnoreConfig {
67    /// Ignore [`Graph::name`].
68    pub graph_name: bool,
69    /// Ignore [`Node::name`] (debug labels; they carry no semantics).
70    pub node_names: bool,
71    /// Ignore [`Node::origin`] (pass-provenance stamps).
72    pub origins: bool,
73}
74
75impl IgnoreConfig {
76    /// Everything participates, including names and provenance. Use when you
77    /// want to assert that a graph is byte-for-byte what you built.
78    pub const EXACT: Self = Self {
79        graph_name: false,
80        node_names: false,
81        origins: false,
82    };
83
84    /// Ignore debug-only labelling: graph name, node names, pass provenance.
85    ///
86    /// This is what "same computation" means in practice, and it is the
87    /// default — [`stamp_pass_origins`](crate::stamp_pass_origins) rewrites
88    /// every node's origin after every pass, so an origin-sensitive
89    /// fingerprint would report a change even for a pass that did nothing.
90    pub const SEMANTIC: Self = Self {
91        graph_name: true,
92        node_names: true,
93        origins: true,
94    };
95}
96
97impl Default for IgnoreConfig {
98    fn default() -> Self {
99        Self::SEMANTIC
100    }
101}
102
103/// Streams `Debug`/`Display` output straight into a [`Hasher`], so hashing an
104/// op costs no `String` allocation.
105struct HashWriter<'a, H: Hasher>(&'a mut H);
106
107impl<H: Hasher> std::fmt::Write for HashWriter<'_, H> {
108    fn write_str(&mut self, s: &str) -> std::fmt::Result {
109        self.0.write(s.as_bytes());
110        Ok(())
111    }
112}
113
114/// Field separator. Without it `("ab", "c")` and `("a", "bc")` would hash
115/// alike, which is exactly how a renamed node could collide with a rewired one.
116const SEP: u8 = 0xff;
117
118/// Hash one [`Op`] (including any nested bodies) into `h`.
119pub fn hash_op_into<H: Hasher>(op: &Op, cfg: IgnoreConfig, h: &mut H) {
120    let bodies = op.subgraphs();
121    if bodies.is_empty() {
122        // Fast path — no clone, no allocation, whole payload via Debug.
123        let _ = write!(HashWriter(h), "{op:?}");
124    } else {
125        // Blank the bodies so `Debug` renders only this op's own fields, then
126        // recurse into the real bodies where the ignore flags still apply.
127        let mut surrogate = op.clone();
128        for body in surrogate.subgraphs_mut() {
129            *body = Graph::new("");
130        }
131        let _ = write!(HashWriter(h), "{surrogate:?}");
132        h.write_u8(SEP);
133        (bodies.len() as u64).hash(h);
134        for body in bodies {
135            hash_graph_into(body, cfg, h);
136            h.write_u8(SEP);
137        }
138    }
139    h.write_u8(SEP);
140}
141
142/// Hash one [`Node`] into `h`, honouring `cfg`.
143fn hash_node_into<H: Hasher>(node: &Node, cfg: IgnoreConfig, h: &mut H) {
144    node.id.0.hash(h);
145    hash_op_into(&node.op, cfg, h);
146    node.inputs.len().hash(h);
147    for input in &node.inputs {
148        input.0.hash(h);
149    }
150    node.shape.hash(h);
151    if !cfg.node_names {
152        node.name.hash(h);
153        h.write_u8(SEP);
154    }
155    if !cfg.origins {
156        let _ = write!(HashWriter(h), "{:?}", node.origin);
157        h.write_u8(SEP);
158    }
159}
160
161/// Hash a whole [`Graph`] into `h`, honouring `cfg`.
162pub fn hash_graph_into<H: Hasher>(graph: &Graph, cfg: IgnoreConfig, h: &mut H) {
163    if !cfg.graph_name {
164        graph.name.hash(h);
165        h.write_u8(SEP);
166    }
167    graph.len().hash(h);
168    for node in graph.nodes() {
169        hash_node_into(node, cfg, h);
170    }
171    graph.outputs.len().hash(h);
172    for out in &graph.outputs {
173        out.0.hash(h);
174    }
175}
176
177/// Structural fingerprint of `graph` under `cfg`.
178///
179/// Equal IR always yields equal fingerprints. Unequal IR yields unequal
180/// fingerprints with overwhelming probability, but this is a 64-bit hash —
181/// use [`structurally_eq`] when a false "unchanged" would be a correctness
182/// bug rather than a missed optimization.
183pub fn structural_hash(graph: &Graph, cfg: IgnoreConfig) -> u64 {
184    let mut hasher = DefaultHasher::new();
185    hash_graph_into(graph, cfg, &mut hasher);
186    hasher.finish()
187}
188
189/// [`structural_hash`] with [`IgnoreConfig::SEMANTIC`] — the form used for
190/// pass change-detection and analysis-cache validation.
191pub fn fingerprint(graph: &Graph) -> u64 {
192    structural_hash(graph, IgnoreConfig::SEMANTIC)
193}
194
195/// Value-number key for a single node, given its inputs *after* remapping.
196///
197/// This is the CSE key: two interior nodes with the same op, the same
198/// remapped inputs and the same shape compute bit-identically. Returns a
199/// `u64` rather than the tuple-of-`String`s CSE used to build, which drops
200/// two allocations per node.
201pub fn node_value_key(op: &Op, remapped_inputs: &[NodeId], shape: &Shape) -> u64 {
202    let mut hasher = DefaultHasher::new();
203    hash_op_into(op, IgnoreConfig::SEMANTIC, &mut hasher);
204    remapped_inputs.len().hash(&mut hasher);
205    for input in remapped_inputs {
206        input.0.hash(&mut hasher);
207    }
208    shape.hash(&mut hasher);
209    hasher.finish()
210}
211
212/// Exact deep equality of two ops, including nested bodies.
213///
214/// Unlike `Op`'s derived `PartialEq`, this does not stop at the shallow
215/// `Graph` comparison for body-carrying variants.
216pub fn ops_deep_eq(a: &Op, b: &Op, cfg: IgnoreConfig) -> bool {
217    // Cheap reject first. The derived `PartialEq` is shallow for bodies, so a
218    // `true` here proves nothing — but a `false` does prove inequality.
219    if a.kind() != b.kind() {
220        return false;
221    }
222
223    let (a_bodies, b_bodies) = (a.subgraphs(), b.subgraphs());
224    if a_bodies.len() != b_bodies.len() {
225        return false;
226    }
227
228    if a_bodies.is_empty() {
229        return format!("{a:?}") == format!("{b:?}");
230    }
231
232    let blank = |op: &Op| {
233        let mut surrogate = op.clone();
234        for body in surrogate.subgraphs_mut() {
235            *body = Graph::new("");
236        }
237        format!("{surrogate:?}")
238    };
239    if blank(a) != blank(b) {
240        return false;
241    }
242    a_bodies
243        .iter()
244        .zip(b_bodies.iter())
245        .all(|(x, y)| structurally_eq(x, y, cfg))
246}
247
248/// Exact deep equality of two graphs under `cfg`.
249///
250/// Cheap discriminators (node count, outputs, per-node arity and shape) are
251/// checked before any op payload is rendered, so mismatched graphs fail fast.
252pub fn structurally_eq(a: &Graph, b: &Graph, cfg: IgnoreConfig) -> bool {
253    if !cfg.graph_name && a.name != b.name {
254        return false;
255    }
256    if a.len() != b.len() || a.outputs != b.outputs {
257        return false;
258    }
259
260    for (x, y) in a.nodes().iter().zip(b.nodes().iter()) {
261        if x.id != y.id || x.inputs != y.inputs || x.shape != y.shape {
262            return false;
263        }
264        if !cfg.node_names && x.name != y.name {
265            return false;
266        }
267        if !cfg.origins && format!("{:?}", x.origin) != format!("{:?}", y.origin) {
268            return false;
269        }
270        if !ops_deep_eq(&x.op, &y.op, cfg) {
271            return false;
272        }
273    }
274    true
275}
276
277impl Graph {
278    /// Structural fingerprint under [`IgnoreConfig::SEMANTIC`].
279    /// See [`fingerprint`].
280    pub fn fingerprint(&self) -> u64 {
281        fingerprint(self)
282    }
283
284    /// Exact deep equality under `cfg`. See [`structurally_eq`].
285    ///
286    /// Prefer this over `==`, which is a deliberately shallow
287    /// name/count/outputs check.
288    pub fn structurally_eq(&self, other: &Graph, cfg: IgnoreConfig) -> bool {
289        structurally_eq(self, other, cfg)
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::op::{Activation, BinaryOp};
297    use crate::{DType, Shape};
298
299    fn two_layer(name: &str) -> Graph {
300        let mut g = Graph::new(name);
301        let x = g.input("x", Shape::new(&[2, 4], DType::F32));
302        let w = g.param("w", Shape::new(&[4, 4], DType::F32));
303        let mm = g.matmul(x, w, Shape::new(&[2, 4], DType::F32));
304        let act = g.add_node(
305            Op::Activation(Activation::Gelu),
306            vec![mm],
307            Shape::new(&[2, 4], DType::F32),
308        );
309        g.set_outputs(vec![act]);
310        g
311    }
312
313    #[test]
314    fn identical_graphs_agree() {
315        let a = two_layer("m");
316        let b = two_layer("m");
317        assert_eq!(a.fingerprint(), b.fingerprint());
318        assert!(a.structurally_eq(&b, IgnoreConfig::EXACT));
319    }
320
321    #[test]
322    fn op_payload_change_is_detected() {
323        let a = two_layer("m");
324        let mut b = two_layer("m");
325        // Same kind, same shape, same wiring — only the activation differs.
326        b.node_mut(NodeId(3)).op = Op::Activation(Activation::Relu);
327        assert_ne!(a.fingerprint(), b.fingerprint());
328        assert!(!a.structurally_eq(&b, IgnoreConfig::SEMANTIC));
329    }
330
331    #[test]
332    fn rewiring_is_detected() {
333        let a = two_layer("m");
334        let mut b = two_layer("m");
335        b.set_inputs(NodeId(3), vec![NodeId(0)]);
336        assert_ne!(a.fingerprint(), b.fingerprint());
337        assert!(!a.structurally_eq(&b, IgnoreConfig::SEMANTIC));
338    }
339
340    #[test]
341    fn semantic_config_ignores_labels_exact_does_not() {
342        let a = two_layer("m");
343        let mut b = two_layer("other_name");
344        b.node_mut(NodeId(3)).name = Some("relabelled".into());
345
346        assert_eq!(
347            structural_hash(&a, IgnoreConfig::SEMANTIC),
348            structural_hash(&b, IgnoreConfig::SEMANTIC)
349        );
350        assert!(a.structurally_eq(&b, IgnoreConfig::SEMANTIC));
351
352        assert_ne!(
353            structural_hash(&a, IgnoreConfig::EXACT),
354            structural_hash(&b, IgnoreConfig::EXACT)
355        );
356        assert!(!a.structurally_eq(&b, IgnoreConfig::EXACT));
357    }
358
359    #[test]
360    fn shallow_partial_eq_is_the_gap_this_module_closes() {
361        // Same name, same node count, same outputs — but different ops.
362        // `==` says equal; the structural comparison correctly says otherwise.
363        let a = two_layer("m");
364        let mut b = two_layer("m");
365        b.node_mut(NodeId(3)).op = Op::Binary(BinaryOp::Add);
366        b.set_inputs(NodeId(3), vec![NodeId(2), NodeId(2)]);
367
368        assert_eq!(a, b, "Graph::eq is documented-shallow");
369        assert!(!a.structurally_eq(&b, IgnoreConfig::SEMANTIC));
370        assert_ne!(a.fingerprint(), b.fingerprint());
371    }
372
373    #[test]
374    fn nested_bodies_participate() {
375        let scan = |act: Activation| {
376            let mut body = Graph::new("body");
377            let c = body.input("carry", Shape::new(&[4], DType::F32));
378            let y = body.add_node(Op::Activation(act), vec![c], Shape::new(&[4], DType::F32));
379            body.set_outputs(vec![y]);
380
381            let mut g = Graph::new("outer");
382            let init = g.input("init", Shape::new(&[4], DType::F32));
383            let s = g.add_node(
384                Op::Scan {
385                    body: Box::new(body),
386                    length: 8,
387                    save_trajectory: false,
388                    num_bcast: 0,
389                    num_xs: 0,
390                    num_checkpoints: 0,
391                },
392                vec![init],
393                Shape::new(&[4], DType::F32),
394            );
395            g.set_outputs(vec![s]);
396            g
397        };
398
399        let a = scan(Activation::Gelu);
400        let b = scan(Activation::Gelu);
401        let c = scan(Activation::Relu);
402
403        assert_eq!(a.fingerprint(), b.fingerprint());
404        assert!(a.structurally_eq(&b, IgnoreConfig::SEMANTIC));
405
406        // A change *inside* the body must surface at the outer graph.
407        assert_ne!(a.fingerprint(), c.fingerprint());
408        assert!(!a.structurally_eq(&c, IgnoreConfig::SEMANTIC));
409    }
410
411    #[test]
412    fn ignore_flags_reach_into_nested_bodies() {
413        // The point of the body-free-surrogate encoding: renaming a node
414        // inside a `Scan` body must not read as a change under SEMANTIC.
415        let build = |body_node_name: &str| {
416            let mut body = Graph::new("body");
417            let c = body.input("carry", Shape::new(&[4], DType::F32));
418            let y = body.add_node(
419                Op::Activation(Activation::Gelu),
420                vec![c],
421                Shape::new(&[4], DType::F32),
422            );
423            body.node_mut(y).name = Some(body_node_name.to_string());
424            body.set_outputs(vec![y]);
425
426            let mut g = Graph::new("outer");
427            let init = g.input("init", Shape::new(&[4], DType::F32));
428            let s = g.add_node(
429                Op::Scan {
430                    body: Box::new(body),
431                    length: 8,
432                    save_trajectory: false,
433                    num_bcast: 0,
434                    num_xs: 0,
435                    num_checkpoints: 0,
436                },
437                vec![init],
438                Shape::new(&[4], DType::F32),
439            );
440            g.set_outputs(vec![s]);
441            g
442        };
443
444        let a = build("before");
445        let b = build("after");
446        assert_eq!(a.fingerprint(), b.fingerprint());
447        assert!(a.structurally_eq(&b, IgnoreConfig::SEMANTIC));
448        assert!(!a.structurally_eq(&b, IgnoreConfig::EXACT));
449    }
450
451    #[test]
452    fn value_key_separates_operand_order() {
453        let shape = Shape::new(&[4], DType::F32);
454        let op = Op::Binary(BinaryOp::Sub);
455        let ab = node_value_key(&op, &[NodeId(1), NodeId(2)], &shape);
456        let ba = node_value_key(&op, &[NodeId(2), NodeId(1)], &shape);
457        assert_ne!(ab, ba, "Sub is not commutative — order must key distinctly");
458    }
459}