Skip to main content

vyre_primitives/graph/
union_find.rs

1//! Lock-free union-find (disjoint-set) alias tracking as Vyre IR.
2//!
3//! This module deliberately emits `Program` / `Node` IR, not target shader
4//! text. Concrete drivers own target spelling; primitives own the backend-
5//! neutral algorithm.
6
7use std::sync::Arc;
8
9use vyre_foundation::ir::model::expr::Ident;
10use vyre_foundation::ir::{BufferAccess, BufferDecl, DataType, Expr, Node, Program};
11
12/// Canonical operation id for one union-find merge pass.
13pub const OP_ID: &str = "vyre-primitives::graph::union_find";
14/// One lane per union edge in a batch.
15pub const UNION_FIND_WORKGROUP_SIZE: [u32; 3] = [256, 1, 1];
16
17/// Dispatch grid that covers every union edge lane.
18#[must_use]
19pub const fn union_find_dispatch_grid(edge_count: u32) -> [u32; 3] {
20    let lanes_per_block = UNION_FIND_WORKGROUP_SIZE[0];
21    let full_blocks = edge_count / lanes_per_block;
22    let tail_block = if edge_count % lanes_per_block == 0 {
23        0
24    } else {
25        1
26    };
27    let blocks = full_blocks + tail_block;
28    [if blocks == 0 { 1 } else { blocks }, 1, 1]
29}
30
31/// Build the path-halving body used by [`union_roots_body`].
32///
33/// `id_var` is read at entry. On exit `root_var` contains the discovered root
34/// and `scratch_parent_var` contains the last parent read. The loop is bounded
35/// by `node_count` so malformed parent arrays cannot create an infinite kernel.
36#[must_use]
37pub fn find_root_body(
38    parent: &str,
39    id_var: &str,
40    root_var: &str,
41    scratch_parent_var: &str,
42    node_count: u32,
43) -> Vec<Node> {
44    vec![
45        // Loop invariant: `root` is the current node and `scratch` is `parent[root]`, so the
46        // guard `root != scratch` == "root is not yet its own parent (not a root)". `scratch`
47        // MUST be seeded with `parent[id]`, NOT `id`: seeding it with `id` makes the guard
48        // false on iteration 0 (root == scratch == id) and, since nothing else mutates them,
49        // the loop is a permanent no-op that returns `id` unwalked. That silently made every
50        // multi-hop union operate on raw endpoints instead of roots (no connectivity closure);
51        // the 1-hop registration fixture could not catch it because there the endpoint IS the
52        // root and the fixture only checks the CAS-written parent array, never `find()`.
53        Node::let_bind(root_var, Expr::var(id_var)),
54        Node::let_bind(
55            scratch_parent_var,
56            Expr::atomic_or(parent, Expr::var(id_var), Expr::u32(0)),
57        ),
58        Node::loop_for(
59            "uf_find_iter",
60            Expr::u32(0),
61            Expr::u32(node_count.max(1)),
62            vec![Node::if_then(
63                Expr::ne(Expr::var(root_var), Expr::var(scratch_parent_var)),
64                vec![
65                    Node::assign(root_var, Expr::var(scratch_parent_var)),
66                    Node::if_then(
67                        Expr::ge(Expr::var(root_var), Expr::u32(node_count)),
68                        vec![Node::trap(Expr::var(root_var), "union-find-parent-oob")],
69                    ),
70                    Node::assign(
71                        scratch_parent_var,
72                        Expr::atomic_or(parent, Expr::var(root_var), Expr::u32(0)),
73                    ),
74                    // Bind uf_grandparent and the atomic_min that consumes
75                    // it in the SAME if_then so the binding scope covers
76                    // the use. Splitting them into two sibling if_then
77                    // blocks ends uf_grandparent's binding lifetime
78                    // before atomic_min needs it (CUDA backend reports
79                    // "uf_grandparent referenced before binding").
80                    Node::if_then(
81                        Expr::lt(Expr::var(scratch_parent_var), Expr::u32(node_count)),
82                        vec![
83                            Node::let_bind(
84                                "uf_grandparent",
85                                Expr::atomic_or(
86                                    parent,
87                                    Expr::var(scratch_parent_var),
88                                    Expr::u32(0),
89                                ),
90                            ),
91                            Node::let_bind(
92                                "uf_path_old",
93                                Expr::atomic_min(
94                                    parent,
95                                    Expr::var(root_var),
96                                    Expr::var("uf_grandparent"),
97                                ),
98                            ),
99                        ],
100                    ),
101                ],
102            )],
103        ),
104    ]
105}
106
107/// Build one deterministic lock-free union pass for edge `edge_index_var`.
108///
109/// `edge_a[edge_index]` and `edge_b[edge_index]` are merged into the shared
110/// `parent` array using ordered root selection (the lower-index root always
111/// wins) and compare-exchange.
112///
113/// The retry loop is the canonical lock-free union: every iteration RE-FINDS
114/// both roots from the *original* endpoints, then points the higher-index root
115/// at the lower via a single `CAS(parent[high], high, low)`. Re-finding both
116/// each pass, rather than caching one root and patching it after a lost CAS
117/// is what makes it converge: a lost CAS (another lane moved `parent[high]`)
118/// simply retries against freshly observed roots, and because ordered selection
119/// only ever lowers a root, the pair reaches its shared minimum within
120/// `node_count` iterations. Once the roots coincide the `ne` guard turns every
121/// remaining iteration into a no-op, so running the full bound is harmless.
122///
123/// The previous formulation cached `uf_root_a`/`uf_root_b` before the loop and,
124/// on a *successful* CAS, updated only `uf_root_b`. When `uf_root_a` was the
125/// higher root that left the loop condition permanently true (spinning to the
126/// bound) and, worse, dropped merges under the interpreter's lane ordering, the
127/// `union_find_program` connectivity defect. All working vars here are bound
128/// INSIDE the loop body, shadowing nothing in the enclosing scope (V008-clean;
129/// two sequential `find_root_body` calls are already proven shadow-free).
130#[must_use]
131pub fn union_roots_body(
132    parent: &str,
133    edge_a: &str,
134    edge_b: &str,
135    edge_index_var: &str,
136    node_count: u32,
137) -> Vec<Node> {
138    let mut body = vec![
139        Node::let_bind("uf_a", Expr::load(edge_a, Expr::var(edge_index_var))),
140        Node::let_bind("uf_b", Expr::load(edge_b, Expr::var(edge_index_var))),
141        Node::if_then(
142            Expr::or(
143                Expr::ge(Expr::var("uf_a"), Expr::u32(node_count)),
144                Expr::ge(Expr::var("uf_b"), Expr::u32(node_count)),
145            ),
146            vec![Node::trap(Expr::var(edge_index_var), "union-find-edge-oob")],
147        ),
148    ];
149    body.push(Node::loop_for(
150        "uf_union_iter",
151        Expr::u32(0),
152        Expr::u32(node_count.max(1)),
153        {
154            // Re-find BOTH roots from the immutable endpoints every iteration. These
155            // let-binds live only inside the loop body (no enclosing binding of the same
156            // name), so re-binding them per iteration is not a shadow, the same way
157            // `find_root_body`'s own inner-loop `uf_grandparent`/`uf_path_old` re-bind.
158            let mut iter_body =
159                find_root_body(parent, "uf_a", "uf_root_a", "uf_parent_a", node_count);
160            iter_body.extend(find_root_body(
161                parent,
162                "uf_b",
163                "uf_root_b",
164                "uf_parent_b",
165                node_count,
166            ));
167            iter_body.push(Node::if_then(
168                Expr::ne(Expr::var("uf_root_a"), Expr::var("uf_root_b")),
169                vec![
170                    Node::let_bind(
171                        "uf_low",
172                        Expr::select(
173                            Expr::lt(Expr::var("uf_root_a"), Expr::var("uf_root_b")),
174                            Expr::var("uf_root_a"),
175                            Expr::var("uf_root_b"),
176                        ),
177                    ),
178                    Node::let_bind(
179                        "uf_high",
180                        Expr::select(
181                            Expr::lt(Expr::var("uf_root_a"), Expr::var("uf_root_b")),
182                            Expr::var("uf_root_b"),
183                            Expr::var("uf_root_a"),
184                        ),
185                    ),
186                    // Point the higher-index root at the lower. The result is bound but
187                    // intentionally unread (like `find_root_body`'s `uf_path_old`): on
188                    // success `parent[high]=low`; on a lost CAS the next iteration re-finds
189                    // fresh roots and retries. Binding it keeps the atomic as a statement.
190                    Node::let_bind(
191                        "uf_observed",
192                        Expr::atomic_compare_exchange(
193                            parent,
194                            Expr::var("uf_high"),
195                            Expr::var("uf_high"),
196                            Expr::var("uf_low"),
197                        ),
198                    ),
199                ],
200            ));
201            iter_body
202        },
203    ));
204    body
205}
206
207/// Build a Program that applies a batch of union operations.
208#[must_use]
209pub fn union_find_program(
210    parent: &str,
211    edge_a: &str,
212    edge_b: &str,
213    node_count: u32,
214    edge_count: u32,
215) -> Program {
216    let lane = Expr::gid_x();
217    let body = vec![Node::if_then(
218        Expr::lt(lane.clone(), Expr::u32(edge_count)),
219        union_roots_body(parent, edge_a, edge_b, "uf_edge", node_count),
220    )];
221    Program::wrapped(
222        vec![
223            BufferDecl::storage(parent, 0, BufferAccess::ReadWrite, DataType::U32)
224                .with_count(node_count.max(1)),
225            BufferDecl::storage(edge_a, 1, BufferAccess::ReadOnly, DataType::U32)
226                .with_count(edge_count.max(1)),
227            BufferDecl::storage(edge_b, 2, BufferAccess::ReadOnly, DataType::U32)
228                .with_count(edge_count.max(1)),
229        ],
230        UNION_FIND_WORKGROUP_SIZE,
231        vec![Node::Region {
232            generator: Ident::from(OP_ID),
233            source_region: None,
234            body: Arc::new({
235                let mut entry = vec![Node::let_bind("uf_edge", lane)];
236                entry.extend(body);
237                entry
238            }),
239        }],
240    )
241}
242
243/// Validated dispatch layout for the union-find primitive.
244///
245/// The primitive owns these derived counts so dispatch wrappers do not fork
246/// parent output sizing or padded edge-buffer policy.
247#[derive(Clone, Copy, Debug, Eq, PartialEq)]
248pub struct UnionFindLayout {
249    /// Number of parent nodes accepted by the primitive.
250    pub node_count: u32,
251    /// Number of union edges accepted by the primitive.
252    pub edge_count: u32,
253    /// Number of parent words expected in the backend output.
254    pub node_words: usize,
255    /// Number of edge words to upload for each edge endpoint buffer.
256    pub edge_storage_words: usize,
257}
258
259/// Validate the parent/edge arrays consumed by the union-find primitive.
260///
261/// Returns the full primitive-compatible dispatch layout so dispatch wrappers
262/// can build the IR program without duplicating boundary checks or padding
263/// rules.
264///
265/// # Errors
266///
267/// Returns an actionable diagnostic when edge arrays differ in length, counts
268/// exceed the primitive's u32 index space, parent links are malformed, or edge
269/// endpoints reference nodes outside the parent set.
270pub fn validate_union_find_inputs(
271    parent_init: &[u32],
272    edge_a: &[u32],
273    edge_b: &[u32],
274) -> Result<UnionFindLayout, String> {
275    if edge_a.len() != edge_b.len() {
276        return Err(format!(
277            "Fix: union_find requires edge_a.len() == edge_b.len(), got {} vs {}.",
278            edge_a.len(),
279            edge_b.len()
280        ));
281    }
282    let node_count = u32::try_from(parent_init.len()).map_err(|_| {
283        format!(
284            "Fix: union_find parent length {} exceeds u32 index space.",
285            parent_init.len()
286        )
287    })?;
288    let edge_count = u32::try_from(edge_a.len()).map_err(|_| {
289        format!(
290            "Fix: union_find edge count {} exceeds u32 index space.",
291            edge_a.len()
292        )
293    })?;
294    if node_count == 0 {
295        if edge_count == 0 {
296            return Ok(UnionFindLayout {
297                node_count: 0,
298                edge_count: 0,
299                node_words: 0,
300                edge_storage_words: 1,
301            });
302        }
303        return Err("Fix: union_find cannot union edges against an empty parent set.".to_string());
304    }
305    for (idx, &parent) in parent_init.iter().enumerate() {
306        if parent >= node_count {
307            return Err(format!(
308                "Fix: union_find parent_init[{idx}]={parent} is outside node_count {node_count}."
309            ));
310        }
311    }
312    for (idx, (&a, &b)) in edge_a.iter().zip(edge_b.iter()).enumerate() {
313        if a >= node_count || b >= node_count {
314            return Err(format!(
315                "Fix: union_find edge {idx} endpoint ({a}, {b}) is outside node_count {node_count}."
316            ));
317        }
318    }
319    Ok(UnionFindLayout {
320        node_count,
321        edge_count,
322        node_words: parent_init.len(),
323        edge_storage_words: edge_a.len().max(1),
324    })
325}
326
327#[cfg(feature = "inventory-registry")]
328inventory::submit! {
329    vyre_foundation::operation::OperationRegistration::primitive(
330        OP_ID,
331        || union_find_program("parent", "edge_a", "edge_b", 4, 2),
332        Some(|| {
333            // 4 singleton nodes seeded with the identity parent [0,1,2,3]; two DISJOINT
334            // union edges 0–1 and 2–3. Ordered root selection keeps the smaller root, so
335            // parent[1]→0 and parent[3]→2. The two edges touch disjoint parent slots (1 and
336            // 3), so the pass is race-clean under lane reversal while still exercising the
337            // full find-root path walk + the compare-exchange union scatter.
338            let to_bytes = |w: &[u32]| crate::wire::pack_u32_slice(w);
339            vec![vec![
340                to_bytes(&[0, 1, 2, 3]), // parent seed (identity: each node its own root)
341                to_bytes(&[0, 2]),       // edge_a
342                to_bytes(&[1, 3]),       // edge_b
343            ]]
344        }),
345        Some(|| {
346            let to_bytes = |w: &[u32]| crate::wire::pack_u32_slice(w);
347            // {0,1} merges under root 0, {2,3} under root 2.
348            vec![vec![to_bytes(&[0, 0, 2, 2])]]
349        }),
350    )
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn union_find_program_uses_atomic_ir_not_target_text() {
359        let program = union_find_program("parent", "edge_a", "edge_b", 8, 4);
360        let dump = format!("{program:#?}");
361        assert!(dump.contains("CompareExchange"));
362        assert!(dump.contains("Min"));
363        assert!(!dump.contains("atomicCAS"));
364        assert!(!dump.contains("ptr<storage"));
365    }
366
367    #[test]
368    fn union_find_program_declares_batch_buffers() {
369        let program = union_find_program("parent", "edge_a", "edge_b", 8, 4);
370        assert_eq!(program.buffers().len(), 3);
371        assert_eq!(program.workgroup_size(), UNION_FIND_WORKGROUP_SIZE);
372    }
373
374    #[test]
375    fn dispatch_grid_packs_union_edges_into_workgroups() {
376        assert_eq!(union_find_dispatch_grid(0), [1, 1, 1]);
377        assert_eq!(union_find_dispatch_grid(1), [1, 1, 1]);
378        assert_eq!(union_find_dispatch_grid(256), [1, 1, 1]);
379        assert_eq!(union_find_dispatch_grid(257), [2, 1, 1]);
380        assert_eq!(union_find_dispatch_grid(1025), [5, 1, 1]);
381    }
382
383    #[test]
384    fn validate_union_find_inputs_accepts_empty_and_canonical_inputs() {
385        assert_eq!(
386            validate_union_find_inputs(&[], &[], &[]).unwrap(),
387            UnionFindLayout {
388                node_count: 0,
389                edge_count: 0,
390                node_words: 0,
391                edge_storage_words: 1,
392            }
393        );
394        assert_eq!(
395            validate_union_find_inputs(&[0, 1, 2, 3], &[0, 2], &[1, 3]).unwrap(),
396            UnionFindLayout {
397                node_count: 4,
398                edge_count: 2,
399                node_words: 4,
400                edge_storage_words: 2,
401            }
402        );
403    }
404
405    #[test]
406    fn validate_union_find_inputs_rejects_malformed_inputs() {
407        let err = validate_union_find_inputs(&[0, 1], &[0], &[1, 0]).unwrap_err();
408        assert!(err.contains("edge_a.len() == edge_b.len()"));
409
410        let err = validate_union_find_inputs(&[], &[0], &[0]).unwrap_err();
411        assert!(err.contains("empty parent set"));
412
413        let err = validate_union_find_inputs(&[0, 9], &[0], &[1]).unwrap_err();
414        assert!(err.contains("parent_init[1]=9"));
415
416        let err = validate_union_find_inputs(&[0, 1], &[0], &[2]).unwrap_err();
417        assert!(err.contains("outside node_count"));
418    }
419}