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}