Skip to main content

vyre_primitives/math/
prefix_scan.rs

1//! Subgroup prefix-sum (inclusive / exclusive scan)  -  core 1000×
2//! primitive for variable-length compaction.
3//!
4//! # Use cases
5//!
6//! * **Hit-buffer compaction:** each lane produces 0 or 1 live
7//!   flag; an exclusive scan over the flag vector gives the
8//!   destination slot for each live hit. One dispatch provides the
9//!   parallel compaction primitive used by PHASE9_EMIT.
10//! * **Histogram prefix:** turn a bin-count vector into the CDF
11//!   lookup used by the radix-sort primitive.
12//! * **Segmented-reduce baseline:** classical parallel-scan is
13//!   the inner kernel of a `(segment_offsets, values)` pair.
14//!
15//! # Algorithm
16//!
17//! Hillis-Steele scan over `N` elements, O(N log N) work,
18//! `log2(N)` rounds. One invocation per output lane. Round `k`:
19//!
20//! ```text
21//!   if lane >= 2^k:
22//!       out[lane] = in[lane - 2^k] op in[lane]
23//!   else:
24//!       out[lane] = in[lane]
25//! ```
26//!
27//! `op` is `+` for sum-scan; the emitted Program ping-pongs through
28//! two workgroup-local scratch buffers with a barrier after every
29//! round. The public builder accepts any `N` in `1..=1024` and pads
30//! the workgroup to the next power of two internally.
31
32use std::sync::Arc;
33
34use vyre_foundation::ir::model::expr::Ident;
35use vyre_foundation::ir::{BufferAccess, BufferDecl, DataType, Expr, Node, Program};
36
37use crate::reduce::multi_block_prefix_scan::multi_block_prefix_scan_sum_u32;
38
39/// Canonical op id for inclusive sum-scan.
40pub const OP_ID_INCLUSIVE_SUM: &str = "vyre-primitives::math::prefix_scan_inclusive_sum";
41/// Canonical op id for exclusive sum-scan.
42pub const OP_ID_EXCLUSIVE_SUM: &str = "vyre-primitives::math::prefix_scan_exclusive_sum";
43
44/// Which scan variant to emit.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ScanKind {
47    /// `out[i] = sum(in[0..=i])`.
48    InclusiveSum,
49    /// `out[i] = sum(in[0..i])`  -  identity element (`0`) at slot 0.
50    ExclusiveSum,
51}
52
53/// Emit a Hillis-Steele prefix-sum Program.
54///
55/// `n` is the number of input slots. The emitted workgroup size is
56/// `n.next_power_of_two()` so non-power-of-two lengths execute with
57/// inactive padded lanes.
58#[must_use]
59pub fn prefix_scan(in_buf: &str, out_buf: &str, n: u32, kind: ScanKind) -> Program {
60    let op_id = match kind {
61        ScanKind::InclusiveSum => OP_ID_INCLUSIVE_SUM,
62        ScanKind::ExclusiveSum => OP_ID_EXCLUSIVE_SUM,
63    };
64    prefix_scan_with_op_id(in_buf, out_buf, n, kind, op_id)
65}
66
67/// Emit a Hillis-Steele prefix-sum Program with an explicit region generator id.
68#[must_use]
69pub fn prefix_scan_with_op_id(
70    in_buf: &str,
71    out_buf: &str,
72    n: u32,
73    kind: ScanKind,
74    op_id: &'static str,
75) -> Program {
76    if n == 0 || n > 1024 {
77        return crate::invalid_output_program(
78            op_id,
79            out_buf,
80            DataType::U32,
81            format!("Fix: prefix_scan requires n in 1..=1024, got {n}."),
82        );
83    }
84
85    let lanes = n.next_power_of_two();
86    let lane = Expr::InvocationId { axis: 0 };
87    let scratch_a = format!("__{out_buf}_scan_a");
88    let scratch_b = format!("__{out_buf}_scan_b");
89
90    let mut body: Vec<Node> = Vec::new();
91    body.push(Node::store(&scratch_a, lane.clone(), Expr::u32(0)));
92    match kind {
93        ScanKind::InclusiveSum => body.push(Node::if_then(
94            Expr::lt(lane.clone(), Expr::u32(n)),
95            vec![Node::store(
96                &scratch_a,
97                lane.clone(),
98                Expr::load(in_buf, lane.clone()),
99            )],
100        )),
101        ScanKind::ExclusiveSum => body.push(Node::if_then(
102            Expr::and(
103                Expr::lt(Expr::u32(0), lane.clone()),
104                Expr::lt(lane.clone(), Expr::u32(n)),
105            ),
106            vec![Node::store(
107                &scratch_a,
108                lane.clone(),
109                Expr::load(in_buf, Expr::add(lane.clone(), Expr::u32(u32::MAX))),
110            )],
111        )),
112    }
113    body.push(Node::Barrier {
114        ordering: vyre_foundation::MemoryOrdering::SeqCst,
115    });
116
117    let mut stride = 1_u32;
118    while stride < lanes {
119        let previous_lane = Expr::add(lane.clone(), Expr::u32(u32::MAX.wrapping_sub(stride - 1)));
120        body.push(Node::store(
121            &scratch_b,
122            lane.clone(),
123            Expr::load(&scratch_a, lane.clone()),
124        ));
125        body.push(Node::if_then(
126            Expr::lt(Expr::u32(stride - 1), lane.clone()),
127            vec![Node::store(
128                &scratch_b,
129                lane.clone(),
130                Expr::add(
131                    Expr::load(&scratch_a, lane.clone()),
132                    Expr::load(&scratch_a, previous_lane),
133                ),
134            )],
135        ));
136        body.push(Node::Barrier {
137            ordering: vyre_foundation::MemoryOrdering::SeqCst,
138        });
139        body.push(Node::store(
140            &scratch_a,
141            lane.clone(),
142            Expr::load(&scratch_b, lane.clone()),
143        ));
144        body.push(Node::Barrier {
145            ordering: vyre_foundation::MemoryOrdering::SeqCst,
146        });
147        stride *= 2;
148    }
149
150    body.push(Node::if_then(
151        Expr::lt(lane.clone(), Expr::u32(n)),
152        vec![Node::store(
153            out_buf,
154            lane.clone(),
155            Expr::load(&scratch_a, lane.clone()),
156        )],
157    ));
158
159    let output_bytes = usize::try_from(n).unwrap_or(usize::MAX).saturating_mul(4);
160    let buffers = vec![
161        BufferDecl::storage(in_buf, 0, BufferAccess::ReadOnly, DataType::U32).with_count(n),
162        BufferDecl::output(out_buf, 1, DataType::U32)
163            .with_count(n)
164            .with_output_byte_range(0..output_bytes),
165        BufferDecl::workgroup(&scratch_a, lanes, DataType::U32),
166        BufferDecl::workgroup(&scratch_b, lanes, DataType::U32),
167    ];
168
169    Program::wrapped(
170        buffers,
171        [lanes, 1, 1],
172        vec![Node::Region {
173            generator: Ident::from(op_id),
174            source_region: None,
175            body: Arc::new(body),
176        }],
177    )
178}
179
180/// Emit a parallel inclusive scan for inputs too large for one workgroup.
181///
182/// The returned program uses the reduce-domain multi-block scan and wraps it
183/// with the math-domain op id so existing callers keep a stable builder
184/// identity while large buffers execute through the GPU prefix-scan chain.
185#[must_use]
186pub fn prefix_scan_large(in_buf: &str, out_buf: &str, n: u32) -> Program {
187    prefix_scan_large_with_op_id(in_buf, out_buf, n, OP_ID_INCLUSIVE_SUM)
188}
189
190/// Emit a parallel inclusive scan with an explicit region generator id.
191#[must_use]
192pub fn prefix_scan_large_with_op_id(
193    in_buf: &str,
194    out_buf: &str,
195    n: u32,
196    op_id: &'static str,
197) -> Program {
198    if n == 0 {
199        return empty_large_scan_program(in_buf, out_buf, op_id);
200    }
201    if n <= 1024 {
202        return prefix_scan_with_op_id(in_buf, out_buf, n, ScanKind::InclusiveSum, op_id);
203    }
204
205    wrap_large_scan_program(multi_block_prefix_scan_sum_u32(in_buf, out_buf, n), op_id)
206}
207
208fn empty_large_scan_program(in_buf: &str, out_buf: &str, op_id: &'static str) -> Program {
209    let input_decl = BufferDecl::storage(in_buf, 0, BufferAccess::ReadOnly, DataType::U32);
210    let output_decl = BufferDecl::output(out_buf, 1, DataType::U32)
211        .with_count(1)
212        .with_output_byte_range(0..0);
213
214    Program::wrapped(
215        vec![input_decl, output_decl],
216        [1, 1, 1],
217        vec![Node::Region {
218            generator: Ident::from(op_id),
219            source_region: None,
220            body: Arc::new(Vec::new()),
221        }],
222    )
223}
224
225fn wrap_large_scan_program(program: Program, op_id: &'static str) -> Program {
226    // Only the entry changes, so rebuild only the entry. `Program::wrapped`
227    // would deep-clone the buffer table and reset the metadata flags.
228    let tagged = vec![Node::Region {
229        generator: Ident::from(op_id),
230        source_region: None,
231        body: Arc::new(program.entry().to_vec()),
232    }];
233    program.with_rewritten_wrapped_entry(tagged)
234}
235
236/// CPU-reference prefix scan. Conformance tests verify the GPU
237/// Program produces the same output for every input.
238#[must_use]
239#[cfg(any(test, feature = "cpu-parity"))]
240pub fn cpu_ref(input: &[u32], kind: ScanKind) -> Vec<u32> {
241    let mut out = Vec::new();
242    try_cpu_ref_into(input, kind, &mut out)
243        .expect("Fix: replace expect with fallible API or document caller precondition; panic only on programmer error - prefix_scan cpu_ref failed: output allocation failed");
244    out
245}
246
247/// Fallible CPU-reference prefix scan.
248#[cfg(any(test, feature = "cpu-parity"))]
249pub fn try_cpu_ref(input: &[u32], kind: ScanKind) -> Result<Vec<u32>, String> {
250    let mut out = Vec::new();
251    try_cpu_ref_into(input, kind, &mut out)?;
252    Ok(out)
253}
254
255/// CPU-reference prefix scan using a caller-owned output buffer.
256#[cfg(any(test, feature = "cpu-parity"))]
257pub fn cpu_ref_into(input: &[u32], kind: ScanKind, out: &mut Vec<u32>) {
258    try_cpu_ref_into(input, kind, out)
259        .expect("Fix: replace expect with fallible API or document caller precondition; panic only on programmer error - prefix_scan cpu_ref_into failed: output allocation failed");
260}
261
262/// Fallible CPU-reference prefix scan using a caller-owned output buffer.
263#[cfg(any(test, feature = "cpu-parity"))]
264pub fn try_cpu_ref_into(input: &[u32], kind: ScanKind, out: &mut Vec<u32>) -> Result<(), String> {
265    if input.len() > out.capacity() {
266        crate::graph::scratch::reserve_graph_items(
267            out,
268            input.len() - out.len(),
269            "prefix scan CPU oracle",
270            "scan output",
271        )?;
272    }
273    out.clear();
274    let mut acc = 0_u32;
275    match kind {
276        ScanKind::InclusiveSum => {
277            for &x in input {
278                acc = acc.wrapping_add(x);
279                out.push(acc);
280            }
281        }
282        ScanKind::ExclusiveSum => {
283            for &x in input {
284                out.push(acc);
285                acc = acc.wrapping_add(x);
286            }
287        }
288    }
289    Ok(())
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn inclusive_cpu_ref_matches_textbook() {
298        assert_eq!(
299            cpu_ref(&[1, 2, 3, 4], ScanKind::InclusiveSum),
300            vec![1, 3, 6, 10],
301        );
302    }
303
304    #[test]
305    fn exclusive_cpu_ref_matches_textbook() {
306        assert_eq!(
307            cpu_ref(&[1, 2, 3, 4], ScanKind::ExclusiveSum),
308            vec![0, 1, 3, 6],
309        );
310    }
311
312    #[test]
313    fn empty_cpu_ref_returns_empty() {
314        assert_eq!(cpu_ref(&[], ScanKind::InclusiveSum), Vec::<u32>::new());
315        assert_eq!(cpu_ref(&[], ScanKind::ExclusiveSum), Vec::<u32>::new());
316    }
317
318    #[test]
319    fn wrap_on_overflow() {
320        // Overflow check: wrapping_add semantics.
321        assert_eq!(
322            cpu_ref(&[u32::MAX, 1], ScanKind::InclusiveSum),
323            vec![u32::MAX, 0],
324        );
325    }
326
327    #[test]
328    fn cpu_ref_into_reuses_output_buffer() {
329        let mut out = Vec::with_capacity(16);
330        let ptr = out.as_ptr();
331        cpu_ref_into(&[1, 2, 3, 4], ScanKind::ExclusiveSum, &mut out);
332        assert_eq!(out, vec![0, 1, 3, 6]);
333        assert_eq!(out.as_ptr(), ptr);
334    }
335
336    #[test]
337    fn cpu_ref_into_truncates_stale_tail_without_reallocating() {
338        let mut out = Vec::with_capacity(16);
339        out.extend([99u32; 16]);
340        let ptr = out.as_ptr();
341
342        try_cpu_ref_into(&[1, 2, 3, 4], ScanKind::InclusiveSum, &mut out).unwrap();
343
344        assert_eq!(out, vec![1, 3, 6, 10]);
345        assert_eq!(out.as_ptr(), ptr);
346    }
347
348    #[test]
349    fn generated_cpu_ref_matches_independent_wrapping_scan() {
350        for len in 0..128usize {
351            let input: Vec<u32> = (0..len)
352                .map(|idx| {
353                    (idx as u32)
354                        .wrapping_mul(0x9E37_79B9)
355                        .wrapping_add(len as u32)
356                })
357                .collect();
358            for kind in [ScanKind::InclusiveSum, ScanKind::ExclusiveSum] {
359                let mut out = Vec::with_capacity(len + 3);
360                try_cpu_ref_into(&input, kind, &mut out).unwrap();
361                let mut expected = Vec::with_capacity(len);
362                let mut acc = 0u32;
363                for &value in &input {
364                    match kind {
365                        ScanKind::InclusiveSum => {
366                            acc = acc.wrapping_add(value);
367                            expected.push(acc);
368                        }
369                        ScanKind::ExclusiveSum => {
370                            expected.push(acc);
371                            acc = acc.wrapping_add(value);
372                        }
373                    }
374                }
375                assert_eq!(
376                    out, expected,
377                    "generated prefix scan len={len} kind={kind:?}"
378                );
379            }
380        }
381    }
382
383    #[test]
384    fn emitted_inclusive_program_has_expected_buffers() {
385        let p = prefix_scan("in", "out", 32, ScanKind::InclusiveSum);
386        assert_eq!(p.workgroup_size, [32, 1, 1]);
387        let names: Vec<&str> = p.buffers.iter().map(|b| b.name()).collect();
388        assert_eq!(names, vec!["in", "out", "__out_scan_a", "__out_scan_b"]);
389    }
390
391    #[test]
392    fn emitted_exclusive_program_has_expected_buffers() {
393        let p = prefix_scan("in", "out", 64, ScanKind::ExclusiveSum);
394        assert_eq!(p.workgroup_size, [64, 1, 1]);
395    }
396
397    #[test]
398    fn non_power_of_two_n_pads_to_next_power_of_two() {
399        let p = prefix_scan("in", "out", 5, ScanKind::InclusiveSum);
400        assert_eq!(p.workgroup_size, [8, 1, 1]);
401    }
402
403    #[test]
404    fn zero_n_traps() {
405        let p = prefix_scan("in", "out", 0, ScanKind::InclusiveSum);
406        assert!(p.stats().trap());
407    }
408
409    #[test]
410    fn over_limit_n_traps() {
411        let p = prefix_scan("in", "out", 2048, ScanKind::InclusiveSum);
412        assert!(p.stats().trap());
413    }
414
415    #[test]
416    fn binary_power_of_two_sizes_accepted() {
417        for n in &[1_u32, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024] {
418            let program = prefix_scan("in", "out", *n, ScanKind::InclusiveSum);
419            let names: Vec<&str> = program.buffers().iter().map(|b| b.name()).collect();
420            assert!(
421                names.contains(&"in"),
422                "prefix_scan must declare in for n={n}"
423            );
424            assert!(
425                names.contains(&"out"),
426                "prefix_scan must declare out for n={n}"
427            );
428        }
429    }
430}