Skip to main content

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}