Skip to main content

vyre_driver/grid_sync/
host_dispatch.rs

1//! Host-split dispatch: every segment launched as its own kernel, with live
2//! buffers rotated between launches and the sequence looped to a fixpoint.
3
4use std::collections::HashMap;
5
6use vyre_foundation::ir::{Ident, Program};
7
8use super::barrier_split::contains_grid_sync;
9use super::live_buffers::{
10    borrowed_grid_sync_inputs_by_name, collect_final_named_outputs, owned_accumulator_fingerprint,
11    refresh_named_outputs, GridSyncInput,
12};
13use super::segment_buffers::{
14    original_input_names, original_output_names, plan_host_grid_sync_segments,
15    PlannedGridSyncSegment,
16};
17use super::{
18    elapsed_wall_ns, grid_sync_segment_error, reserve_grid_sync_hash_map, reserve_grid_sync_vec,
19};
20use crate::backend::{
21    BackendError, DispatchConfig, OutputBuffers, TimedDispatchResult, VyreBackend,
22};
23
24/// Universal dispatch helper that satisfies `Node::Barrier { ordering:
25/// GridSync }` on any backend by splitting at the barrier and running
26/// each segment as its own kernel launch.
27///
28/// Backends with native cooperative-launch grid sync (advertised via
29/// [`VyreBackend::supports_grid_sync`]) bypass the split  -  the
30/// program is dispatched once. Backends without it route here so the
31/// kernel-launch boundary becomes the grid-level fence: every prior
32/// write is globally visible to subsequent launches.
33///
34/// # Inputs
35/// `inputs` matches the input slice the caller would have passed to
36/// `dispatch_borrowed`. After each segment, the helper refreshes
37/// every ReadWrite buffer's slot from the segment's readback so the
38/// next segment sees the prior writes.
39///
40/// # Errors
41/// Propagates any `BackendError` raised by `dispatch_borrowed` on a
42/// segment, prefixed with the segment index for diagnosability.
43pub fn dispatch_with_grid_sync_split(
44    backend: &dyn VyreBackend,
45    program: &Program,
46    inputs: &[&[u8]],
47    config: &DispatchConfig,
48) -> Result<Vec<Vec<u8>>, BackendError> {
49    let mut outputs = Vec::new();
50    reserve_grid_sync_vec(
51        &mut outputs,
52        program.output_buffer_indices().len().max(1),
53        "grid-sync final outputs",
54    )?;
55    dispatch_with_grid_sync_split_into(backend, program, inputs, config, &mut outputs)?;
56    Ok(outputs)
57}
58
59/// Timed variant of [`dispatch_with_grid_sync_split`].
60///
61/// # Errors
62/// Propagates any [`BackendError`] raised by a segment dispatch.
63pub fn dispatch_with_grid_sync_split_timed(
64    backend: &dyn VyreBackend,
65    program: &Program,
66    inputs: &[&[u8]],
67    config: &DispatchConfig,
68) -> Result<TimedDispatchResult, BackendError> {
69    let started = std::time::Instant::now();
70    let outputs = dispatch_with_grid_sync_split(backend, program, inputs, config)?;
71    Ok(TimedDispatchResult {
72        outputs,
73        wall_ns: elapsed_wall_ns(started)?,
74        device_ns: None,
75        enqueue_ns: None,
76        wait_ns: None,
77    })
78}
79
80fn seed_backend_allocated_segment_inputs<'a>(
81    program: &Program,
82    segments: &[PlannedGridSyncSegment],
83    current_inputs: &mut HashMap<Ident, GridSyncInput<'a>>,
84) -> Result<(), BackendError> {
85    for name in segments
86        .iter()
87        .flat_map(|segment| segment.input_names.iter())
88    {
89        if current_inputs.contains_key(name) {
90            continue;
91        }
92        let Some(buffer) = program.buffer(name.as_str()) else {
93            return Err(BackendError::InvalidProgram {
94                fix: format!(
95                    "Fix: grid-sync segment references undeclared input `{name}`. Rebuild the split from a Program whose buffer table covers every expression dependency."
96                ),
97            });
98        };
99        if !buffer.is_backend_allocated_output() {
100            continue;
101        }
102        let static_len =
103            buffer
104                .static_byte_len()
105                .map_err(|error| BackendError::InvalidProgram {
106                    fix: format!("Fix: cannot seed grid-sync output `{name}`: {error}"),
107                })?;
108        let byte_len = static_len
109            .or_else(|| buffer.output_byte_range().map(|range| range.end))
110            .ok_or_else(|| BackendError::InvalidProgram {
111                fix: format!(
112                    "Fix: grid-sync output `{name}` is read-modify-written before its first split output but has no static byte size. Declare a count or output byte range."
113                ),
114            })?;
115        let mut zeroed = Vec::new();
116        reserve_grid_sync_vec(
117            &mut zeroed,
118            byte_len,
119            "grid-sync backend-allocated output seed",
120        )?;
121        zeroed.resize(byte_len, 0);
122        current_inputs.insert(name.clone(), GridSyncInput::Owned(zeroed));
123    }
124    Ok(())
125}
126
127/// Variant of [`dispatch_with_grid_sync_split`] that writes final outputs into
128/// caller-owned storage.
129///
130/// # Errors
131/// Propagates any `BackendError` raised by a segment dispatch.
132fn dispatch_grid_sync_split_generic<D>(
133    program: &Program,
134    inputs: &[&[u8]],
135    config: &DispatchConfig,
136    outputs: &mut OutputBuffers,
137    mut dispatch_segment: D,
138) -> Result<(), BackendError>
139where
140    D: FnMut(&Program, &[&[u8]], &DispatchConfig, &mut OutputBuffers) -> Result<(), BackendError>,
141{
142    // These are the explicit non-native grid-sync routes (host split /
143    // resident fixpoint). They split unconditionally when the program carries a
144    // grid-sync barrier: native cooperative launch has a residency ceiling, so
145    // `supports_grid_sync()` no longer implies "this program runs natively".
146    // The orchestrator (or the registry's `should_split_grid_sync`) decides
147    // native-vs-split per program; once here, always split.
148    if !contains_grid_sync(program) {
149        return dispatch_segment(program, inputs, config, outputs);
150    }
151    let segments = plan_host_grid_sync_segments(program)?;
152    if segments.is_empty() {
153        return Err(BackendError::InvalidProgram {
154            fix: "Fix: program contains GridSync barrier but split_on_grid_sync produced 0 \
155                  segments. This is a grid_sync invariant bug  -  split_on_grid_sync must \
156                  always return at least one segment."
157                .to_string(),
158        });
159    }
160    crate::observability::record_grid_sync_split(segments.len());
161    // Build a mutable input set we rotate between segments. ReadOnly
162    // inputs stay borrowed from the caller for the whole split; only
163    // ReadWrite buffers become owned after a segment produces updated
164    // bytes. The previous implementation cloned every input before
165    // the first launch, which turned large read-only buffers into a
166    // host-memory copy on the slow path.
167    let initial_input_names = original_input_names(program)?;
168    if inputs.len() != initial_input_names.len() {
169        return Err(BackendError::InvalidProgram {
170            fix: format!(
171                "Fix: grid-sync split expected {} initial input buffer(s) but received {}. Rebuild the dispatch inputs from the Program buffer declarations before splitting.",
172                initial_input_names.len(),
173                inputs.len()
174            ),
175        });
176    }
177    let mut current_inputs: HashMap<Ident, GridSyncInput<'_>> = HashMap::new();
178    reserve_grid_sync_hash_map(
179        &mut current_inputs,
180        program.buffers().len(),
181        "grid-sync rotating input map",
182    )?;
183    for (name, bytes) in initial_input_names.into_iter().zip(inputs.iter().copied()) {
184        current_inputs.insert(name, GridSyncInput::Borrowed(bytes));
185    }
186    seed_backend_allocated_segment_inputs(program, &segments, &mut current_inputs)?;
187    let mut segment_outputs = Vec::new();
188    reserve_grid_sync_vec(
189        &mut segment_outputs,
190        outputs.capacity().max(1),
191        "grid-sync intermediate outputs",
192    )?;
193    let final_output_names = original_output_names(program)?;
194
195    // Honor the program's fixpoint contract across the split. The
196    // non-split dispatch path (`dispatch_borrowed`) re-runs the WHOLE
197    // program `fixpoint_iterations` times with persistent ReadWrite
198    // buffers, so a program authored as a fixpoint closure converges
199    // a multi-hop reachability/dataflow closure is exactly this shape: a
200    // `seed (acc |= source) → hop (acc' = step(acc)) → merge (acc |= acc')`
201    // body whose accumulator grows by ONE dataflow hop per whole-program
202    // pass, relying on the dispatcher to iterate it to a fixpoint.
203    //
204    // GridSync barriers split that body across segments, so ONE pass over
205    // the segment sequence advances the accumulator by exactly one hop.
206    // Re-running an individual SEGMENT N times (the previous behavior:
207    // `config` with its fixpoint count reached each segment) does NOT
208    // converge, re-launching the isolated `hop` segment recomputes the
209    // same frontier from an unchanged `acc`. The whole SEQUENCE must be
210    // looped instead, with each segment run once per pass. Net device work
211    // is identical (sequence_len × iterations launches either way); only
212    // the nesting order changes, which is what makes the closure converge.
213    // A flow that needs k hops through k-1 intermediate variables (the
214    // dominant launch-rule shape: `q = src; sink(q)`) silently returned an
215    // empty frontier under the old single-pass split (recall=0).
216    let iterations =
217        crate::fixpoint_iterations::resolve_fixpoint_iterations(config, "grid-sync split")?;
218    let mut segment_config = config.clone();
219    segment_config.fixpoint_iterations = Some(1);
220
221    // Adaptive convergence: `iterations` is an UPPER bound (the worst-case hop
222    // depth, one hop per whole-sequence pass). The segment sequence is a
223    // deterministic function of its live buffers, so once a full pass leaves
224    // every evolving (Owned) accumulator unchanged the closure has reached a
225    // fixpoint, every remaining pass would re-dispatch the entire segment
226    // sequence (hundreds of launches on a large fused program) for zero new
227    // dataflow. Stop as soon as two consecutive passes produce the same state.
228    let mut prev_fingerprint: Option<u64> = None;
229    for _ in 0..iterations {
230        for (segment_idx, segment) in segments.iter().enumerate() {
231            let borrowed = borrowed_grid_sync_inputs_by_name(segment, &current_inputs)?;
232            dispatch_segment(
233                &segment.program,
234                borrowed.as_slice(),
235                &segment_config,
236                &mut segment_outputs,
237            )
238            .map_err(|error| grid_sync_segment_error(error, segment_idx, segments.len()))?;
239            drop(borrowed);
240            refresh_named_outputs(segment, &mut segment_outputs, &mut current_inputs)?;
241        }
242        let fingerprint = owned_accumulator_fingerprint(&current_inputs);
243        if prev_fingerprint == Some(fingerprint) {
244            break;
245        }
246        prev_fingerprint = Some(fingerprint);
247    }
248    collect_final_named_outputs(&final_output_names, &mut current_inputs, outputs)?;
249    Ok(())
250}
251
252/// Split a grid-sync program at its barriers and dispatch every segment through
253/// `backend`, looping the segment sequence to a fixpoint.
254///
255/// This is the `&dyn VyreBackend` entry; the split, refresh, and adaptive
256/// convergence logic lives in the internal `dispatch_grid_sync_split_generic`, shared with
257/// the closure entry [`dispatch_with_grid_sync_split_via_into`].
258///
259/// # Errors
260/// Propagates any [`BackendError`] from splitting or a segment dispatch,
261/// prefixed with the segment index.
262pub fn dispatch_with_grid_sync_split_into(
263    backend: &dyn VyreBackend,
264    program: &Program,
265    inputs: &[&[u8]],
266    config: &DispatchConfig,
267    outputs: &mut OutputBuffers,
268) -> Result<(), BackendError> {
269    dispatch_grid_sync_split_generic(program, inputs, config, outputs, |p, i, c, o| {
270        backend.dispatch_borrowed_into(p, i, c, o)
271    })
272}
273
274/// Closure-driven counterpart of [`dispatch_with_grid_sync_split_into`] for
275/// callers that hold an opaque single-launch dispatch closure instead of a
276/// `&dyn VyreBackend`.
277///
278/// This is the entry a host-loop fixpoint solver (an IFDS or dataflow solve)
279/// uses to move its convergence loop onto the device without taking a backend
280/// handle: it plugs any backend (CPU reference, CUDA, wgpu) as a
281/// `Fn(&Program, &[&[u8]], Option<[u32; 3]>, &mut Vec<Vec<u8>>) -> Result<(),
282/// String>` closure. The closure receives each segment's program, its rotated
283/// inputs, the whole-grid workgroup count (`config.grid_override`), and a
284/// per-segment output slot to fill in the segment program's output order. The
285/// split, refresh, and convergence logic is the SAME code as the backend entry
286/// (both call the internal `dispatch_grid_sync_split_generic`), so the two paths converge
287/// to identical output.
288///
289/// # Errors
290/// Propagates any error the closure returns (wrapped through
291/// [`BackendError::new`]) and any structural split error, prefixed with the
292/// segment index.
293pub fn dispatch_with_grid_sync_split_via_into<F>(
294    program: &Program,
295    inputs: &[&[u8]],
296    config: &DispatchConfig,
297    dispatch: &F,
298    outputs: &mut OutputBuffers,
299) -> Result<(), BackendError>
300where
301    F: Fn(&Program, &[&[u8]], Option<[u32; 3]>, &mut Vec<Vec<u8>>) -> Result<(), String>,
302{
303    dispatch_grid_sync_split_generic(program, inputs, config, outputs, |p, i, c, o| {
304        dispatch(p, i, c.grid_override, o).map_err(BackendError::new)
305    })
306}
307
308/// Allocating wrapper over [`dispatch_with_grid_sync_split_via_into`].
309///
310/// # Errors
311/// Propagates any error from [`dispatch_with_grid_sync_split_via_into`].
312pub fn dispatch_with_grid_sync_split_via<F>(
313    program: &Program,
314    inputs: &[&[u8]],
315    config: &DispatchConfig,
316    dispatch: &F,
317) -> Result<Vec<Vec<u8>>, BackendError>
318where
319    F: Fn(&Program, &[&[u8]], Option<[u32; 3]>, &mut Vec<Vec<u8>>) -> Result<(), String>,
320{
321    let mut outputs = Vec::new();
322    reserve_grid_sync_vec(
323        &mut outputs,
324        program.output_buffer_indices().len().max(1),
325        "grid-sync via final outputs",
326    )?;
327    dispatch_with_grid_sync_split_via_into(program, inputs, config, dispatch, &mut outputs)?;
328    Ok(outputs)
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::grid_sync::barrier_split::entry_sequence;
335    use crate::grid_sync::segment_buffers::{
336        segment_buffer_consumes_input, segment_buffer_produces_output, segment_output_names,
337    };
338    use crate::grid_sync::test_programs::{buffer, region};
339    use std::sync::atomic::{AtomicUsize, Ordering};
340    use vyre_foundation::ir::{BufferAccess, BufferDecl, DataType, Expr, Node};
341    use vyre_foundation::memory_model::MemoryOrdering;
342
343    struct ReuseCheckingBackend {
344        calls: AtomicUsize,
345        final_outputs_addr: usize,
346        final_slot_addr: usize,
347    }
348
349    impl crate::backend::private::Sealed for ReuseCheckingBackend {}
350
351    impl VyreBackend for ReuseCheckingBackend {
352        fn id(&self) -> &'static str {
353            "grid-sync-reuse-checking"
354        }
355
356        fn dispatch(
357            &self,
358            _program: &Program,
359            _inputs: &[Vec<u8>],
360            _config: &DispatchConfig,
361        ) -> Result<Vec<Vec<u8>>, BackendError> {
362            unreachable!("test uses dispatch_borrowed_into")
363        }
364
365        fn dispatch_borrowed_into(
366            &self,
367            _program: &Program,
368            inputs: &[&[u8]],
369            _config: &DispatchConfig,
370            outputs: &mut OutputBuffers,
371        ) -> Result<(), BackendError> {
372            let call = self.calls.fetch_add(1, Ordering::SeqCst);
373            if call == 1 && self.final_outputs_addr != 0 {
374                assert_eq!(outputs.as_ptr() as usize, self.final_outputs_addr);
375                assert_eq!(outputs[0].as_ptr() as usize, self.final_slot_addr);
376            }
377            if outputs.is_empty() {
378                outputs.push(Vec::new());
379            }
380            outputs[0].clear();
381            outputs[0].extend_from_slice(inputs[0]);
382            if call == 0 {
383                outputs[0][0] = 7;
384            } else {
385                outputs[0][0] = outputs[0][0].saturating_add(1);
386            }
387            Ok(())
388        }
389    }
390
391    #[test]
392    fn split_into_preserves_caller_output_slot_after_named_output_collection() {
393        let program = Program::wrapped(
394            vec![buffer()],
395            [1, 1, 1],
396            vec![
397                region("a", vec![Node::Return]),
398                Node::barrier_with_ordering(MemoryOrdering::GridSync),
399                region("b", vec![Node::Return]),
400            ],
401        );
402        let mut outputs = vec![Vec::with_capacity(8)];
403        let outputs_addr = outputs.as_ptr() as usize;
404        let slot_addr = outputs[0].as_ptr() as usize;
405        let backend = ReuseCheckingBackend {
406            calls: AtomicUsize::new(0),
407            final_outputs_addr: 0,
408            final_slot_addr: 0,
409        };
410        let input = [0u8, 0, 0, 0];
411        dispatch_with_grid_sync_split_into(
412            &backend,
413            &program,
414            &[input.as_slice()],
415            &DispatchConfig::default(),
416            &mut outputs,
417        )
418        .expect("Fix: grid-sync split should write into caller-owned output storage");
419
420        assert_eq!(backend.calls.load(Ordering::SeqCst), 2);
421        assert_eq!(outputs, vec![vec![8, 0, 0, 0]]);
422        assert_eq!(outputs.as_ptr() as usize, outputs_addr);
423        assert_eq!(outputs[0].as_ptr() as usize, slot_addr);
424    }
425
426    /// Each `dispatch_borrowed_into` reads `inputs[0][0]`, writes `+1`. With the
427    /// ReadWrite buffer rotating between segments, a single pass over a
428    /// two-segment program advances the accumulator by 2. The multi-hop
429    /// `flows_to` closure relies on the WHOLE sequence being re-run
430    /// `fixpoint_iterations` times (one dataflow hop per pass); a single pass
431    /// is one hop, which silently dropped every flow through an intermediate
432    /// variable to recall=0.
433    struct IncrementingBackend {
434        calls: AtomicUsize,
435    }
436
437    impl crate::backend::private::Sealed for IncrementingBackend {}
438
439    impl VyreBackend for IncrementingBackend {
440        fn id(&self) -> &'static str {
441            "grid-sync-incrementing"
442        }
443
444        fn dispatch(
445            &self,
446            _program: &Program,
447            _inputs: &[Vec<u8>],
448            _config: &DispatchConfig,
449        ) -> Result<Vec<Vec<u8>>, BackendError> {
450            unreachable!("test uses dispatch_borrowed_into")
451        }
452
453        fn dispatch_borrowed_into(
454            &self,
455            _program: &Program,
456            inputs: &[&[u8]],
457            config: &DispatchConfig,
458            outputs: &mut OutputBuffers,
459        ) -> Result<(), BackendError> {
460            self.calls.fetch_add(1, Ordering::SeqCst);
461            // Each segment must run exactly once per outer pass: the whole
462            // sequence carries the fixpoint, not any single segment.
463            assert_eq!(
464                config.fixpoint_iterations,
465                Some(1),
466                "segment dispatch must receive fixpoint_iterations=1; the outer split loop owns the iteration count"
467            );
468            if outputs.is_empty() {
469                outputs.push(Vec::new());
470            }
471            outputs[0].clear();
472            outputs[0].extend_from_slice(inputs[0]);
473            outputs[0][0] = outputs[0][0].saturating_add(1);
474            Ok(())
475        }
476    }
477
478    #[test]
479    fn split_into_loops_whole_sequence_fixpoint_iterations_times() {
480        // Two segments separated by a GridSync barrier.
481        let program = Program::wrapped(
482            vec![buffer()],
483            [1, 1, 1],
484            vec![
485                region("a", vec![Node::Return]),
486                Node::barrier_with_ordering(MemoryOrdering::GridSync),
487                region("b", vec![Node::Return]),
488            ],
489        );
490
491        // Single pass (default): 2 segment launches, accumulator = 2.
492        let backend = IncrementingBackend {
493            calls: AtomicUsize::new(0),
494        };
495        let mut outputs = vec![Vec::new()];
496        dispatch_with_grid_sync_split_into(
497            &backend,
498            &program,
499            &[[0u8, 0, 0, 0].as_slice()],
500            &DispatchConfig::default(),
501            &mut outputs,
502        )
503        .expect("single-pass split dispatch");
504        assert_eq!(backend.calls.load(Ordering::SeqCst), 2);
505        assert_eq!(outputs, vec![vec![2, 0, 0, 0]]);
506
507        // Three fixpoint iterations: 3 passes × 2 segments = 6 launches, and
508        // the accumulator advances one hop per pass to 6. This is the exact
509        // property the multi-hop `flows_to` split depended on and the
510        // single-pass implementation lacked.
511        let backend = IncrementingBackend {
512            calls: AtomicUsize::new(0),
513        };
514        let config = DispatchConfig {
515            fixpoint_iterations: Some(3),
516            ..DispatchConfig::default()
517        };
518        let mut outputs = vec![Vec::new()];
519        dispatch_with_grid_sync_split_into(
520            &backend,
521            &program,
522            &[[0u8, 0, 0, 0].as_slice()],
523            &config,
524            &mut outputs,
525        )
526        .expect("multi-pass split dispatch");
527        assert_eq!(
528            backend.calls.load(Ordering::SeqCst),
529            6,
530            "split must re-run the whole 2-segment sequence 3 times"
531        );
532        assert_eq!(
533            outputs,
534            vec![vec![6, 0, 0, 0]],
535            "accumulator must advance one hop per fixpoint pass (2 segments × 3 passes)"
536        );
537    }
538
539    /// A backend-allocated atomic output starts from zero even when split
540    /// liveness rewrites its first writer as a read-write segment input.
541    #[test]
542    fn split_seeds_first_atomic_output_without_caller_bytes() {
543        let program = Program::wrapped(
544            vec![BufferDecl::output("out", 0, DataType::U32).with_count(1)],
545            [1, 1, 1],
546            vec![
547                Node::let_bind("prior", Expr::atomic_add("out", Expr::u32(0), Expr::u32(1))),
548                Node::barrier_with_ordering(MemoryOrdering::GridSync),
549                Node::Return,
550            ],
551        );
552        let dispatch = |segment: &Program,
553                        inputs: &[&[u8]],
554                        _grid: Option<[u32; 3]>,
555                        outputs: &mut Vec<Vec<u8>>|
556         -> Result<(), String> {
557            outputs.clear();
558            if !segment_output_names(segment)
559                .map_err(|error| error.to_string())?
560                .is_empty()
561            {
562                assert_eq!(inputs, &[&[0, 0, 0, 0][..]]);
563                outputs.push(1_u32.to_le_bytes().to_vec());
564            }
565            Ok(())
566        };
567
568        let outputs =
569            dispatch_with_grid_sync_split_via(&program, &[], &DispatchConfig::default(), &dispatch)
570                .expect("backend-allocated atomic output must receive its zero seed");
571
572        assert_eq!(outputs, vec![1_u32.to_le_bytes().to_vec()]);
573    }
574
575    #[test]
576    fn split_via_closure_entry_matches_backend_entry_on_the_same_grid_sync_program() {
577        // The `&dyn VyreBackend` entry and the closure entry both delegate to
578        // `dispatch_grid_sync_split_generic`, so on the same grid-sync program,
579        // config, and inputs they must drive the same segment dispatches and
580        // produce byte-identical output. This is the ONE-PLACE contract that
581        // lets a host-loop dataflow solver route its fixpoint through the closure
582        // entry with no separate split implementation.
583        let program = Program::wrapped(
584            vec![buffer()],
585            [1, 1, 1],
586            vec![
587                region("a", vec![Node::Return]),
588                Node::barrier_with_ordering(MemoryOrdering::GridSync),
589                region("b", vec![Node::Return]),
590            ],
591        );
592        let config = DispatchConfig {
593            fixpoint_iterations: Some(3),
594            ..DispatchConfig::default()
595        };
596        let inputs: [&[u8]; 1] = [[0u8, 0, 0, 0].as_slice()];
597
598        let backend = IncrementingBackend {
599            calls: AtomicUsize::new(0),
600        };
601        let mut backend_outputs = vec![Vec::new()];
602        dispatch_with_grid_sync_split_into(
603            &backend,
604            &program,
605            &inputs,
606            &config,
607            &mut backend_outputs,
608        )
609        .expect("backend split dispatch");
610
611        // The closure delegates to an identical backend through the opaque
612        // single-launch closure shape a host-loop solver supplies (grid override
613        // only, per-segment fixpoint fixed at 1 by the shared core).
614        let closure_backend = IncrementingBackend {
615            calls: AtomicUsize::new(0),
616        };
617        let dispatch = |program: &Program,
618                        inputs: &[&[u8]],
619                        grid: Option<[u32; 3]>,
620                        outputs: &mut Vec<Vec<u8>>|
621         -> Result<(), String> {
622            let segment_config = DispatchConfig {
623                grid_override: grid,
624                fixpoint_iterations: Some(1),
625                ..DispatchConfig::default()
626            };
627            closure_backend
628                .dispatch_borrowed_into(program, inputs, &segment_config, outputs)
629                .map_err(|error| error.to_string())
630        };
631        let via_outputs = dispatch_with_grid_sync_split_via(&program, &inputs, &config, &dispatch)
632            .expect("closure split dispatch");
633
634        assert_eq!(
635            via_outputs, backend_outputs,
636            "closure and backend split entries must produce identical output"
637        );
638        assert_eq!(
639            closure_backend.calls.load(Ordering::SeqCst),
640            backend.calls.load(Ordering::SeqCst),
641            "both entries must drive the same number of segment dispatches (3 passes x 2 segments)"
642        );
643        assert_eq!(via_outputs, vec![vec![6u8, 0, 0, 0]]);
644    }
645
646    struct OwnedFinalReserveBackend {
647        calls: AtomicUsize,
648    }
649
650    impl crate::backend::private::Sealed for OwnedFinalReserveBackend {}
651
652    impl VyreBackend for OwnedFinalReserveBackend {
653        fn id(&self) -> &'static str {
654            "grid-sync-owned-final-reserve"
655        }
656
657        fn dispatch(
658            &self,
659            _program: &Program,
660            _inputs: &[Vec<u8>],
661            _config: &DispatchConfig,
662        ) -> Result<Vec<Vec<u8>>, BackendError> {
663            unreachable!("test uses dispatch_borrowed_into")
664        }
665
666        fn dispatch_borrowed_into(
667            &self,
668            _program: &Program,
669            inputs: &[&[u8]],
670            _config: &DispatchConfig,
671            outputs: &mut OutputBuffers,
672        ) -> Result<(), BackendError> {
673            let call = self.calls.fetch_add(1, Ordering::SeqCst);
674            if call == 1 {
675                assert!(
676                    outputs.capacity() >= 1,
677                    "owned grid-sync split wrapper must pre-reserve final output slots before the final segment dispatch"
678                );
679            }
680            if outputs.is_empty() {
681                outputs.push(Vec::new());
682            }
683            outputs[0].clear();
684            outputs[0].extend_from_slice(inputs[0]);
685            outputs[0][0] = outputs[0][0].saturating_add(1);
686            Ok(())
687        }
688    }
689
690    #[test]
691    fn split_owned_wrapper_reserves_final_output_vector_before_final_segment() {
692        let program = Program::wrapped(
693            vec![buffer()],
694            [1, 1, 1],
695            vec![
696                region("a", vec![Node::Return]),
697                Node::barrier_with_ordering(MemoryOrdering::GridSync),
698                region("b", vec![Node::Return]),
699            ],
700        );
701        let backend = OwnedFinalReserveBackend {
702            calls: AtomicUsize::new(0),
703        };
704        let input = [4u8, 0, 0, 0];
705
706        let outputs = dispatch_with_grid_sync_split(
707            &backend,
708            &program,
709            &[input.as_slice()],
710            &DispatchConfig::default(),
711        )
712        .expect("Fix: owned grid-sync split should reserve and return final outputs");
713
714        assert_eq!(backend.calls.load(Ordering::SeqCst), 2);
715        assert_eq!(outputs, vec![vec![6, 0, 0, 0]]);
716    }
717
718    #[test]
719    fn grid_sync_split_records_segment_telemetry() {
720        let program = Program::wrapped(
721            vec![buffer()],
722            [1, 1, 1],
723            vec![
724                region("a", vec![Node::Return]),
725                Node::barrier_with_ordering(MemoryOrdering::GridSync),
726                region("b", vec![Node::Return]),
727                Node::barrier_with_ordering(MemoryOrdering::GridSync),
728                region("c", vec![Node::Return]),
729            ],
730        );
731        let backend = ReuseCheckingBackend {
732            calls: AtomicUsize::new(0),
733            final_outputs_addr: 0,
734            final_slot_addr: 0,
735        };
736        let before = crate::observability::snapshot_dispatch_telemetry();
737        let input = [0u8, 0, 0, 0];
738        let mut outputs = Vec::new();
739
740        dispatch_with_grid_sync_split_into(
741            &backend,
742            &program,
743            &[input.as_slice()],
744            &DispatchConfig::default(),
745            &mut outputs,
746        )
747        .expect("Fix: grid-sync split should dispatch every segment");
748
749        let after = crate::observability::snapshot_dispatch_telemetry();
750        assert_eq!(backend.calls.load(Ordering::SeqCst), 3);
751        assert!(after.grid_sync_splits > before.grid_sync_splits);
752        assert!(after.grid_sync_segments >= before.grid_sync_segments + 3);
753        assert!(after.grid_sync_points >= before.grid_sync_points + 2);
754    }
755
756    struct IntermediateReuseBackend {
757        calls: AtomicUsize,
758        first_outputs_addr: AtomicUsize,
759        first_slot_addr: AtomicUsize,
760    }
761
762    impl crate::backend::private::Sealed for IntermediateReuseBackend {}
763
764    impl VyreBackend for IntermediateReuseBackend {
765        fn id(&self) -> &'static str {
766            "grid-sync-intermediate-reuse"
767        }
768
769        fn dispatch(
770            &self,
771            _program: &Program,
772            _inputs: &[Vec<u8>],
773            _config: &DispatchConfig,
774        ) -> Result<Vec<Vec<u8>>, BackendError> {
775            unreachable!("test uses dispatch_borrowed_into")
776        }
777
778        fn dispatch_borrowed_into(
779            &self,
780            _program: &Program,
781            inputs: &[&[u8]],
782            _config: &DispatchConfig,
783            outputs: &mut OutputBuffers,
784        ) -> Result<(), BackendError> {
785            let call = self.calls.fetch_add(1, Ordering::SeqCst);
786            if outputs.is_empty() {
787                outputs.push(Vec::with_capacity(8));
788            }
789            if call == 0 {
790                self.first_outputs_addr
791                    .store(outputs.as_ptr() as usize, Ordering::SeqCst);
792                self.first_slot_addr
793                    .store(outputs[0].as_ptr() as usize, Ordering::SeqCst);
794            } else if call == 1 {
795                assert_eq!(
796                    outputs.as_ptr() as usize,
797                    self.first_outputs_addr.load(Ordering::SeqCst)
798                );
799                assert_eq!(
800                    outputs[0].as_ptr() as usize,
801                    self.first_slot_addr.load(Ordering::SeqCst)
802                );
803            }
804            outputs[0].clear();
805            outputs[0].extend_from_slice(inputs[0]);
806            outputs[0][0] = outputs[0][0].saturating_add(1);
807            Ok(())
808        }
809    }
810
811    #[test]
812    fn split_reuses_intermediate_output_slot_between_segments() {
813        let program = Program::wrapped(
814            vec![buffer()],
815            [1, 1, 1],
816            vec![
817                region("a", vec![Node::Return]),
818                Node::barrier_with_ordering(MemoryOrdering::GridSync),
819                region("b", vec![Node::Return]),
820                Node::barrier_with_ordering(MemoryOrdering::GridSync),
821                region("c", vec![Node::Return]),
822            ],
823        );
824        let backend = IntermediateReuseBackend {
825            calls: AtomicUsize::new(0),
826            first_outputs_addr: AtomicUsize::new(0),
827            first_slot_addr: AtomicUsize::new(0),
828        };
829        let input = [1u8, 0, 0, 0];
830        let mut outputs = vec![Vec::with_capacity(8)];
831
832        dispatch_with_grid_sync_split_into(
833            &backend,
834            &program,
835            &[input.as_slice()],
836            &DispatchConfig::default(),
837            &mut outputs,
838        )
839        .expect("Fix: grid-sync split should reuse intermediate output scratch");
840
841        assert_eq!(backend.calls.load(Ordering::SeqCst), 3);
842        assert_eq!(outputs, vec![vec![4, 0, 0, 0]]);
843    }
844
845    /// Emulates a backend that lacks native grid-sync: for the single output
846    /// buffer `out`, it starts from the forwarded prior value (when the segment
847    /// consumes it) or zeros, then applies that segment's literal `Store out[i]
848    /// = v` writes, exactly the per-slot store shape a fused multi-rule program
849    /// produces. Proves end-to-end that earlier segments' slots survive.
850    struct SlotStoringBackend {
851        calls: AtomicUsize,
852    }
853
854    impl crate::backend::private::Sealed for SlotStoringBackend {}
855
856    impl VyreBackend for SlotStoringBackend {
857        fn id(&self) -> &'static str {
858            "grid-sync-slot-storing"
859        }
860
861        fn dispatch(
862            &self,
863            _program: &Program,
864            _inputs: &[Vec<u8>],
865            _config: &DispatchConfig,
866        ) -> Result<Vec<Vec<u8>>, BackendError> {
867            unreachable!("test uses dispatch_borrowed_into")
868        }
869
870        fn dispatch_borrowed_into(
871            &self,
872            program: &Program,
873            inputs: &[&[u8]],
874            _config: &DispatchConfig,
875            outputs: &mut OutputBuffers,
876        ) -> Result<(), BackendError> {
877            // Locate `out`'s positional input/output slots using the SAME
878            // role convention the host split planner uses.
879            let mut in_pos = None;
880            let mut cur_in = 0usize;
881            let mut out_pos = None;
882            let mut cur_out = 0usize;
883            for buffer in program.buffers() {
884                if matches!(buffer.access(), BufferAccess::Workgroup) {
885                    continue;
886                }
887                let consumes = segment_buffer_consumes_input(buffer);
888                let produces = segment_buffer_produces_output(buffer);
889                if buffer.name() == "out" {
890                    if consumes {
891                        in_pos = Some(cur_in);
892                    }
893                    if produces {
894                        out_pos = Some(cur_out);
895                    }
896                }
897                if consumes {
898                    cur_in += 1;
899                }
900                if produces {
901                    cur_out += 1;
902                }
903            }
904            let out_pos = out_pos.expect("every writing segment must produce `out`");
905            let mut state = match in_pos {
906                Some(i) => inputs[i].to_vec(),
907                None => vec![0u8; 16],
908            };
909
910            fn apply(nodes: &[Node], state: &mut [u8]) {
911                for node in nodes {
912                    match node {
913                        Node::Store {
914                            buffer,
915                            index: Expr::LitU32(i),
916                            value: Expr::LitU32(v),
917                        } if buffer.as_str() == "out" => {
918                            let off = (*i as usize) * 4;
919                            state[off] = (*v & 0xff) as u8;
920                        }
921                        Node::Region { body, .. } => apply(body, state),
922                        Node::Block(body) => apply(body, state),
923                        Node::If {
924                            then, otherwise, ..
925                        } => {
926                            apply(then, state);
927                            apply(otherwise, state);
928                        }
929                        Node::Loop { body, .. } => apply(body, state),
930                        _ => {}
931                    }
932                }
933            }
934            apply(entry_sequence(program), &mut state);
935
936            self.calls.fetch_add(1, Ordering::SeqCst);
937            while outputs.len() <= out_pos {
938                outputs.push(Vec::new());
939            }
940            outputs[out_pos].clear();
941            outputs[out_pos].extend_from_slice(&state);
942            Ok(())
943        }
944    }
945
946    #[test]
947    fn split_preserves_earlier_segment_output_slots_end_to_end() {
948        // Regression: a fused multi-arm program where arm A's result-store is in
949        // segment 0 (slot at element 0) and arm B's in the final segment (slot
950        // at element 2). Before the accumulator fix the final segment's
951        // write-only `out` zeroed element 0, dropping arm A entirely (a co-fused
952        // rule whose result-store does not land in the final grid-sync segment
953        // returned recall=0). Both slots must now survive.
954        let out = BufferDecl::output("out", 0, DataType::U32).with_count(4);
955        let program = Program::wrapped(
956            vec![out],
957            [1, 1, 1],
958            vec![
959                region("a", vec![Node::store("out", Expr::u32(0), Expr::u32(0xAA))]),
960                Node::barrier_with_ordering(MemoryOrdering::GridSync),
961                region("b", vec![Node::store("out", Expr::u32(2), Expr::u32(0xBB))]),
962            ],
963        );
964        let backend = SlotStoringBackend {
965            calls: AtomicUsize::new(0),
966        };
967        let mut outputs = vec![Vec::new()];
968        dispatch_with_grid_sync_split_into(
969            &backend,
970            &program,
971            &[],
972            &DispatchConfig::default(),
973            &mut outputs,
974        )
975        .expect("split dispatch");
976        assert_eq!(
977            backend.calls.load(Ordering::SeqCst),
978            2,
979            "two segments, single fixpoint pass"
980        );
981        assert_eq!(outputs.len(), 1);
982        assert_eq!(outputs[0].len(), 16, "output buffer is 4 × u32 = 16 bytes");
983        assert_eq!(
984            outputs[0][0], 0xAA,
985            "segment 0's slot (element 0) must survive the final segment's write"
986        );
987        assert_eq!(
988            outputs[0][8], 0xBB,
989            "the final segment's slot (element 2) is also present"
990        );
991    }
992
993    /// Copies its input to its output and bumps byte 0 toward a saturation cap.
994    /// Once the cap is reached the output equals the input, so a full pass over
995    /// the split leaves the carried accumulator unchanged (a fixpoint).
996    struct SaturatingBackend {
997        calls: AtomicUsize,
998        cap: u8,
999    }
1000
1001    impl crate::backend::private::Sealed for SaturatingBackend {}
1002
1003    impl VyreBackend for SaturatingBackend {
1004        fn id(&self) -> &'static str {
1005            "grid-sync-saturating"
1006        }
1007
1008        fn dispatch(
1009            &self,
1010            _program: &Program,
1011            _inputs: &[Vec<u8>],
1012            _config: &DispatchConfig,
1013        ) -> Result<Vec<Vec<u8>>, BackendError> {
1014            unreachable!("test uses dispatch_borrowed_into")
1015        }
1016
1017        fn dispatch_borrowed_into(
1018            &self,
1019            _program: &Program,
1020            inputs: &[&[u8]],
1021            _config: &DispatchConfig,
1022            outputs: &mut OutputBuffers,
1023        ) -> Result<(), BackendError> {
1024            self.calls.fetch_add(1, Ordering::SeqCst);
1025            if outputs.is_empty() {
1026                outputs.push(Vec::new());
1027            }
1028            outputs[0].clear();
1029            outputs[0].extend_from_slice(inputs[0]);
1030            if outputs[0][0] < self.cap {
1031                outputs[0][0] += 1;
1032            }
1033            Ok(())
1034        }
1035    }
1036
1037    #[test]
1038    fn split_outer_loop_early_exits_when_accumulator_reaches_fixpoint() {
1039        // Two segments (one GridSync barrier). With a generous iteration budget
1040        // of 10, byte 0 saturates at 3, after which a whole pass leaves the
1041        // accumulator unchanged. The outer loop must stop once two consecutive
1042        // passes match instead of burning all 10 iterations.
1043        let program = Program::wrapped(
1044            vec![buffer()],
1045            [1, 1, 1],
1046            vec![
1047                region("a", vec![Node::Return]),
1048                Node::barrier_with_ordering(MemoryOrdering::GridSync),
1049                region("b", vec![Node::Return]),
1050            ],
1051        );
1052        let backend = SaturatingBackend {
1053            calls: AtomicUsize::new(0),
1054            cap: 3,
1055        };
1056        let config = DispatchConfig {
1057            fixpoint_iterations: Some(10),
1058            ..DispatchConfig::default()
1059        };
1060        let mut outputs = vec![Vec::new()];
1061        dispatch_with_grid_sync_split_into(
1062            &backend,
1063            &program,
1064            &[[0u8, 0, 0, 0].as_slice()],
1065            &config,
1066            &mut outputs,
1067        )
1068        .expect("converging split dispatch");
1069        // pass0 -> 2, pass1 -> 3 (saturates mid-pass), pass2 -> 3 (unchanged) =>
1070        // break after pass2. 3 passes x 2 segments = 6 launches, NOT 10x2=20.
1071        assert_eq!(
1072            backend.calls.load(Ordering::SeqCst),
1073            6,
1074            "outer loop must early-exit one pass after the accumulator stops changing, not run all 10 iterations"
1075        );
1076        assert_eq!(
1077            outputs,
1078            vec![vec![3, 0, 0, 0]],
1079            "early-exit must return the converged fixpoint value, identical to running every iteration"
1080        );
1081    }
1082
1083    #[test]
1084    fn split_non_converging_accumulator_runs_full_iteration_budget() {
1085        // The dual of the early-exit test: an accumulator that changes every
1086        // pass (never reaches a fixpoint within budget) must run all
1087        // iterations (early-exit must not fire on a still-advancing closure).
1088        let program = Program::wrapped(
1089            vec![buffer()],
1090            [1, 1, 1],
1091            vec![
1092                region("a", vec![Node::Return]),
1093                Node::barrier_with_ordering(MemoryOrdering::GridSync),
1094                region("b", vec![Node::Return]),
1095            ],
1096        );
1097        // cap=255 so it never saturates within 4 passes (8 increments).
1098        let backend = SaturatingBackend {
1099            calls: AtomicUsize::new(0),
1100            cap: 255,
1101        };
1102        let config = DispatchConfig {
1103            fixpoint_iterations: Some(4),
1104            ..DispatchConfig::default()
1105        };
1106        let mut outputs = vec![Vec::new()];
1107        dispatch_with_grid_sync_split_into(
1108            &backend,
1109            &program,
1110            &[[0u8, 0, 0, 0].as_slice()],
1111            &config,
1112            &mut outputs,
1113        )
1114        .expect("non-converging split dispatch");
1115        assert_eq!(
1116            backend.calls.load(Ordering::SeqCst),
1117            8,
1118            "a still-advancing accumulator must run the full 4 iterations x 2 segments"
1119        );
1120        assert_eq!(outputs, vec![vec![8, 0, 0, 0]]);
1121    }
1122}