vyre_self_substrate/analysis/persistent_fixpoint_program.rs
1//! Persistent-fixpoint Program builder for runtime and driver scheduling loops.
2
3use vyre_foundation::ir::{Node, Program};
4use vyre_primitives::fixpoint::persistent_fixpoint::{
5 persistent_fixpoint, persistent_fixpoint_grid, PERSISTENT_FIXPOINT_WORKGROUP_SIZE,
6};
7
8/// Build a persistent-fixpoint Program around a caller-supplied transfer body.
9///
10/// The generated program runs `transfer_body`, ping-pongs `current` and `next`,
11/// and stops when the convergence flag reads zero or `max_iterations` is
12/// reached. Runtime and driver crates call this self-substrate wrapper instead
13/// of depending on the primitive catalog directly.
14///
15/// # Convergence-flag form
16///
17/// `words` sizes the widest buffer this wrapper declares, and
18/// `dispatch_element_count_for_program`
19/// (`vyre-driver/src/program_walks/dispatch_params.rs:19`) sizes an
20/// atomic-carrying program's launch from its widest declared buffer, so `words`
21/// is the launch span and it selects the harness:
22///
23/// - `words <= PERSISTENT_FIXPOINT_WORKGROUP_SIZE[0]`: one workgroup covers the
24/// launch, so `persistent_fixpoint` runs with its single shared `changed[0]`
25/// word and stops when that word reads zero. The word is cleared by a plain
26/// store fenced only by a workgroup-scope barrier; with one group the fence is
27/// incidentally grid-wide, so the clear cannot race the `atomic_or` that sets
28/// the flag.
29/// - `words` above that width: `persistent_fixpoint_grid`, which never clears
30/// the flag, gives each iteration its own `changed` word, and separates waves
31/// with `MemoryOrdering::GridSync`. The single-word form is limited to one
32/// workgroup precisely because its clear and its set are unordered across
33/// groups: group 0's clear can erase another group's set, that group then
34/// reads zero and returns early with unconverged state, and the flag the host
35/// reads afterwards reports a convergence no group agreed to.
36///
37/// The caller supplies `changed`, so it must be `max_iterations` zero-filled
38/// words once `words` exceeds one workgroup width and one word at or below it.
39///
40/// This wrapper can only see the buffers the harness declares. A caller whose
41/// `transfer_body` reads buffers wider than `words`, or that widens the launch
42/// through `DispatchConfig`, owns that span itself.
43#[must_use]
44pub fn persistent_fixpoint_program(
45 transfer_body: Vec<Node>,
46 current: &str,
47 next: &str,
48 changed: &str,
49 words: u32,
50 max_iterations: u32,
51) -> Program {
52 if words > PERSISTENT_FIXPOINT_WORKGROUP_SIZE[0] {
53 return persistent_fixpoint_grid(
54 transfer_body,
55 current,
56 next,
57 changed,
58 words,
59 max_iterations,
60 );
61 }
62 persistent_fixpoint(transfer_body, current, next, changed, words, max_iterations)
63}
64
65#[cfg(test)]
66mod tests {
67 use super::persistent_fixpoint_program;
68 use vyre_foundation::ir::{Expr, Node, Program};
69 use vyre_foundation::MemoryOrdering;
70 use vyre_primitives::fixpoint::persistent_fixpoint::{
71 persistent_fixpoint, PERSISTENT_FIXPOINT_WORKGROUP_SIZE,
72 };
73
74 /// Workgroups a host must launch to cover `program`.
75 ///
76 /// The wrapped primitive emits the convergence flag's `atomic_or`, and for an
77 /// atomic-carrying program `vyre-driver`'s `dispatch_element_count_for_program`
78 /// spans the LARGEST declared buffer, so the launch width is `words` rounded up
79 /// to whole workgroups.
80 fn required_workgroups(program: &Program) -> u32 {
81 let elements = program
82 .buffers()
83 .iter()
84 .map(|buffer| buffer.count())
85 .max()
86 .unwrap_or(1);
87 elements.div_ceil(program.workgroup_size()[0])
88 }
89
90 /// Declared word count of the convergence-flag buffer.
91 fn changed_words(program: &Program) -> u32 {
92 program
93 .buffers()
94 .iter()
95 .find(|buffer| buffer.name() == "changed")
96 .expect("Fix: persistent_fixpoint_program must declare its convergence-flag buffer.")
97 .count()
98 }
99
100 #[test]
101 fn builds_program_with_caller_buffers() {
102 let program = persistent_fixpoint_program(Vec::new(), "current", "next", "changed", 4, 8);
103 let names = program
104 .buffers()
105 .iter()
106 .map(|buffer| buffer.name())
107 .collect::<Vec<_>>();
108
109 assert!(names.contains(&"current"));
110 assert!(names.contains(&"next"));
111 assert!(names.contains(&"changed"));
112 }
113
114 /// Locks out the multi-workgroup convergence-flag race.
115 ///
116 /// The single-word primitive keeps ONE `changed[0]` word, clears it from global
117 /// lane 0 with a plain store, and orders that clear against every other lane's
118 /// `atomic_or` with a workgroup-scoped `SeqCst` barrier only. Once the launch
119 /// spans more than one workgroup nothing orders the clear against the sets:
120 /// workgroup 0's next clear can erase workgroup 1's set, so workgroup 1 reads 0
121 /// and `Return`s with unconverged state, and the post-dispatch flag read reports
122 /// a convergence verdict no group agreed to. A multi-workgroup build must
123 /// therefore never be handed one shared cleared word.
124 #[test]
125 fn multi_workgroup_wrapper_never_shares_one_cleared_convergence_word() {
126 let program = persistent_fixpoint_program(Vec::new(), "current", "next", "changed", 257, 8);
127
128 assert_eq!(
129 required_workgroups(&program),
130 2,
131 "Fix: 257 words over a 256-wide workgroup must need two workgroups."
132 );
133 assert_eq!(
134 changed_words(&program),
135 8,
136 "Fix: a multi-workgroup fixpoint dispatch must use the per-iteration convergence-word protocol, not one shared cleared word."
137 );
138 }
139
140 /// Grid-wide fences in `nodes`, counted through every nesting construct.
141 fn count_grid_sync(nodes: &[Node]) -> usize {
142 nodes
143 .iter()
144 .map(|node| match node {
145 Node::Barrier {
146 ordering: MemoryOrdering::GridSync,
147 } => 1,
148 Node::If {
149 then, otherwise, ..
150 } => count_grid_sync(then) + count_grid_sync(otherwise),
151 Node::Loop { body, .. } | Node::Block(body) => count_grid_sync(body),
152 Node::Region { body, .. } => count_grid_sync(body),
153 _ => 0,
154 })
155 .sum()
156 }
157
158 /// A transfer body in which lane 0 publishes the value of the LAST element and
159 /// nothing else writes: `if t == 0 { next[last] = 9 }`.
160 ///
161 /// Partitioned by global invocation id in the strictest sense: exactly one lane
162 /// produces `next[last]`, and under the harness's ping-pong exactly one lane
163 /// (the one whose compare covers `last`) copies it into `current[last]`. Above
164 /// one workgroup those are lanes in DIFFERENT groups, which is what makes the
165 /// shared convergence flag observable rather than masked. The fixpoint is
166 /// unambiguous: the store is idempotent, so `current[last] == 9`.
167 fn publish_last_element_body(next: &str, last: u32) -> Vec<Node> {
168 vec![Node::if_then(
169 Expr::eq(Expr::InvocationId { axis: 0 }, Expr::u32(0)),
170 vec![Node::store(next, Expr::u32(last), Expr::u32(9))],
171 )]
172 }
173
174 /// Run `program` on the reference interpreter and return the final `current`
175 /// vector paired with the final `changed` words. `reversed` steps the workgroups
176 /// back to front; both orders are schedules real hardware is free to pick,
177 /// because nothing in the IR orders one workgroup against another.
178 fn run_fixpoint(
179 program: &Program,
180 reversed: bool,
181 words: u32,
182 changed_word_count: u32,
183 ) -> (Vec<u32>, Vec<u32>) {
184 use vyre_reference::value::Value;
185
186 let to_value = |data: &[u32]| {
187 Value::Bytes(std::sync::Arc::from(vyre_primitives::wire::pack_u32_slice(
188 data,
189 )))
190 };
191 let zeros = vec![0_u32; words as usize];
192 let inputs = vec![
193 to_value(&zeros),
194 to_value(&zeros),
195 to_value(&vec![0_u32; changed_word_count as usize]),
196 ];
197 let results = if reversed {
198 vyre_reference::reference_eval_lane_reversed(program, &inputs)
199 } else {
200 vyre_reference::reference_eval(program, &inputs)
201 }
202 .expect("Fix: the reference interpreter must execute the fixpoint program.");
203 let decode = |value: &vyre_reference::value::Value| -> Vec<u32> {
204 value
205 .to_bytes()
206 .chunks_exact(4)
207 .map(|chunk| u32::from_le_bytes(chunk.try_into().unwrap()))
208 .collect()
209 };
210 (decode(&results[0]), decode(&results[2]))
211 }
212
213 /// Pins the routing threshold to the declared workgroup width.
214 ///
215 /// The threshold is `> PERSISTENT_FIXPOINT_WORKGROUP_SIZE[0]`, read from the
216 /// same constant the emitted program declares as its workgroup size, so the two
217 /// can never drift apart. At exactly that width the launch is one workgroup and
218 /// the compact single-word protocol is sound, so it stays in use; one word past
219 /// it the launch is two workgroups and must switch. An off-by-one here puts a
220 /// multi-workgroup dispatch back on the racing flag.
221 #[test]
222 fn routing_threshold_is_the_declared_workgroup_width() {
223 let width = PERSISTENT_FIXPOINT_WORKGROUP_SIZE[0];
224
225 let at_width =
226 persistent_fixpoint_program(Vec::new(), "current", "next", "changed", width, 8);
227 assert_eq!(
228 at_width.workgroup_size(),
229 PERSISTENT_FIXPOINT_WORKGROUP_SIZE
230 );
231 assert_eq!(required_workgroups(&at_width), 1);
232 assert_eq!(
233 changed_words(&at_width),
234 1,
235 "Fix: a single-workgroup launch must keep the compact one-word convergence flag."
236 );
237
238 let past_width =
239 persistent_fixpoint_program(Vec::new(), "current", "next", "changed", width + 1, 8);
240 assert_eq!(required_workgroups(&past_width), 2);
241 assert_eq!(
242 changed_words(&past_width),
243 8,
244 "Fix: one word past the workgroup width already needs the per-iteration convergence words."
245 );
246 }
247
248 /// The two routes must not silently converge to the same emission.
249 ///
250 /// The grid form's soundness IS its `MemoryOrdering::GridSync` fences: they
251 /// order the per-iteration flag write against every group's read. The
252 /// single-workgroup form must carry none of them, because emitting one there
253 /// would impose a cooperative launch on a dispatch that does not need it.
254 #[test]
255 fn grid_route_fences_the_grid_and_single_workgroup_route_does_not() {
256 let width = PERSISTENT_FIXPOINT_WORKGROUP_SIZE[0];
257
258 let single =
259 persistent_fixpoint_program(Vec::new(), "current", "next", "changed", width, 4);
260 assert_eq!(
261 count_grid_sync(single.entry()),
262 0,
263 "Fix: a single-workgroup fixpoint program must not force a cooperative grid launch."
264 );
265
266 let grid =
267 persistent_fixpoint_program(Vec::new(), "current", "next", "changed", width + 1, 4);
268 assert_eq!(
269 count_grid_sync(grid.entry()),
270 8,
271 "Fix: the grid form must fence each of its 4 waves twice, once after the transfer step and once after the compare."
272 );
273 }
274
275 /// The grid form indexes `changed[iteration]`, so a one-word buffer there would
276 /// be an out-of-bounds atomic write on iteration 1. The caller supplies that
277 /// buffer, so the declared count is the contract it has to satisfy.
278 #[test]
279 fn grid_route_sizes_changed_to_one_word_per_iteration() {
280 let width = PERSISTENT_FIXPOINT_WORKGROUP_SIZE[0];
281 for max_iterations in [1_u32, 2, 8, 64] {
282 let program = persistent_fixpoint_program(
283 Vec::new(),
284 "current",
285 "next",
286 "changed",
287 width + 1,
288 max_iterations,
289 );
290 assert_eq!(
291 changed_words(&program),
292 max_iterations,
293 "Fix: the grid route needs one convergence word per iteration; {max_iterations} iterations need {max_iterations} words."
294 );
295 }
296 }
297
298 /// OBSERVED divergence: the pre-routing single-word harness returns WRONG state
299 /// above one workgroup, it does not merely look unsound.
300 ///
301 /// 257 words, so the launch is two workgroups. Lane 0, in group 0, is the only
302 /// producer of `next[256]`; lane 256, in group 1, is the only lane whose compare
303 /// covers element 256, so it is the only writer of `current[256]` and the only
304 /// lane that can set the convergence flag for it. Nothing orders the groups.
305 ///
306 /// Step group 1 first and it compares `current[256]` against a `next[256]` group
307 /// 0 has not yet written, sees no change, reads the still-zero shared flag and
308 /// retires for good. Group 0 then writes `next[256] = 9`, finds no change among
309 /// the elements IT covers, and also retires. `current[256]` is never published:
310 /// the dispatch reports convergence and yields 0 where the fixpoint is 9.
311 #[test]
312 fn single_word_harness_returns_wrong_state_above_one_workgroup() {
313 let words = PERSISTENT_FIXPOINT_WORKGROUP_SIZE[0] + 1;
314 let last = words - 1;
315 let max_iterations = 4_u32;
316
317 let unsound = persistent_fixpoint(
318 publish_last_element_body("next", last),
319 "current",
320 "next",
321 "changed",
322 words,
323 max_iterations,
324 );
325 assert_eq!(
326 changed_words(&unsound),
327 1,
328 "Fix: this fixture must exercise the single shared convergence word."
329 );
330
331 let (forward, forward_flag) = run_fixpoint(&unsound, false, words, 1);
332 let (reversed, reversed_flag) = run_fixpoint(&unsound, true, words, 1);
333
334 assert_eq!(
335 forward[last as usize], 9,
336 "Fix: stepping group 0 first must reach the fixpoint, proving the divergence is cross-workgroup ordering."
337 );
338 assert_eq!(
339 forward_flag[0], 1,
340 "Fix: the correct schedule must leave the flag set, since group 1 sets it and nobody clears it afterwards."
341 );
342 assert_eq!(
343 reversed[last as usize],
344 0,
345 "Fix: this test records the OBSERVED wrong value the racing shared flag produces; if the single-word harness stops diverging here, re-derive the defect before deleting this test."
346 );
347 assert_eq!(
348 reversed_flag[0], 0,
349 "Fix: the shared flag must be observed claiming convergence while the last element is unpublished, which is what makes the wrong answer silent."
350 );
351 }
352
353 /// The routed program is correct under BOTH workgroup orders at the size where
354 /// the single-word harness diverges, which is the fix working end to end.
355 #[test]
356 fn grid_routed_wrapper_is_order_independent_where_single_word_diverges() {
357 let words = PERSISTENT_FIXPOINT_WORKGROUP_SIZE[0] + 1;
358 let last = words - 1;
359 let max_iterations = 4_u32;
360
361 let routed = persistent_fixpoint_program(
362 publish_last_element_body("next", last),
363 "current",
364 "next",
365 "changed",
366 words,
367 max_iterations,
368 );
369 assert_eq!(
370 changed_words(&routed),
371 max_iterations,
372 "Fix: this size must route to the grid harness."
373 );
374
375 for reversed in [false, true] {
376 let (current, _) = run_fixpoint(&routed, reversed, words, max_iterations);
377 assert_eq!(
378 current[last as usize], 9,
379 "Fix: the grid-routed program must reach the fixpoint in both workgroup orders (reversed={reversed})."
380 );
381 }
382 }
383}