Skip to main content

vyre_primitives/reduce/
workgroup_tree.rs

1//! Workgroup-local tree reductions over scratch buffers.
2//!
3//! These helpers are Tier 2.5 LEGO blocks for higher-level library ops that
4//! already stage one partial value per lane into workgroup memory. They emit
5//! child `Region`s so composition audits and traces show the shared reduction
6//! instead of treating every math/NN op as a hand-rolled loop.
7
8use std::sync::Arc;
9
10use vyre_foundation::ir::model::expr::{GeneratorRef, Ident};
11use vyre_foundation::ir::{BufferAccess, BufferDecl, DataType, Expr, Node, Program};
12
13/// Canonical op id for an f32 workgroup sum over a scratch buffer.
14pub const SUM_F32_OP_ID: &str = "vyre-primitives::reduce::workgroup_sum_f32";
15/// Canonical op id for a u32 workgroup sum over a scratch buffer.
16pub const SUM_U32_OP_ID: &str = "vyre-primitives::reduce::workgroup_sum_u32";
17/// Canonical op id for an f32 workgroup maximum over a scratch buffer.
18pub const MAX_F32_OP_ID: &str = "vyre-primitives::reduce::workgroup_max_f32";
19/// Canonical op id for a u32 workgroup maximum over a scratch buffer.
20pub const MAX_U32_OP_ID: &str = "vyre-primitives::reduce::workgroup_max_u32";
21/// Canonical op id for an f32 workgroup minimum over a scratch buffer.
22pub const MIN_F32_OP_ID: &str = "vyre-primitives::reduce::workgroup_min_f32";
23/// Canonical op id for a u32 workgroup minimum over a scratch buffer.
24pub const MIN_U32_OP_ID: &str = "vyre-primitives::reduce::workgroup_min_u32";
25
26/// Scope for a workgroup-local reduction.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum WorkgroupReductionScope {
29    /// Every dispatched workgroup reduces its own scratch buffer.
30    EveryWorkgroup,
31    /// Only workgroup `x == 0` participates in the reduction.
32    FirstWorkgroup,
33}
34
35impl WorkgroupReductionScope {
36    fn lane_guard(self, lane_expr: Expr) -> Expr {
37        match self {
38            Self::EveryWorkgroup => lane_expr,
39            Self::FirstWorkgroup => Expr::and(Expr::is_first_workgroup(), lane_expr),
40        }
41    }
42}
43
44/// Emit a child region that sums f32 lane partials in `scratch`.
45#[must_use]
46pub fn sum_f32_child(
47    parent_op_id: &str,
48    tile: u32,
49    scratch: &'static str,
50    scope: WorkgroupReductionScope,
51) -> Node {
52    child_region(SUM_F32_OP_ID, parent_op_id, sum_body(tile, scratch, scope))
53}
54
55/// Emit a child region that sums u32 lane partials in `scratch`.
56#[must_use]
57pub fn sum_u32_child(
58    parent_op_id: &str,
59    tile: u32,
60    scratch: &'static str,
61    scope: WorkgroupReductionScope,
62) -> Node {
63    child_region(SUM_U32_OP_ID, parent_op_id, sum_body(tile, scratch, scope))
64}
65
66/// Emit a child region that maximizes f32 lane partials in `scratch`.
67#[must_use]
68pub fn max_f32_child(
69    parent_op_id: &str,
70    tile: u32,
71    scratch: &'static str,
72    scope: WorkgroupReductionScope,
73) -> Node {
74    child_region(MAX_F32_OP_ID, parent_op_id, max_body(tile, scratch, scope))
75}
76
77/// Emit a child region that maximizes u32 lane partials in `scratch`.
78#[must_use]
79pub fn max_u32_child(
80    parent_op_id: &str,
81    tile: u32,
82    scratch: &'static str,
83    scope: WorkgroupReductionScope,
84) -> Node {
85    child_region(MAX_U32_OP_ID, parent_op_id, max_body(tile, scratch, scope))
86}
87
88/// Emit a child region that minimizes f32 lane partials in `scratch`.
89#[must_use]
90pub fn min_f32_child(
91    parent_op_id: &str,
92    tile: u32,
93    scratch: &'static str,
94    scope: WorkgroupReductionScope,
95) -> Node {
96    child_region(MIN_F32_OP_ID, parent_op_id, min_body(tile, scratch, scope))
97}
98
99/// Emit a child region that minimizes u32 lane partials in `scratch`.
100#[must_use]
101pub fn min_u32_child(
102    parent_op_id: &str,
103    tile: u32,
104    scratch: &'static str,
105    scope: WorkgroupReductionScope,
106) -> Node {
107    child_region(MIN_U32_OP_ID, parent_op_id, min_body(tile, scratch, scope))
108}
109
110/// Build a standalone f32 workgroup sum Program.
111#[must_use]
112pub fn workgroup_sum_f32(values: &str, out: &str, count: u32, tile: u32) -> Program {
113    reduction_program(
114        SUM_F32_OP_ID,
115        values,
116        out,
117        count,
118        tile,
119        DataType::F32,
120        Expr::f32(0.0),
121        Expr::add,
122        |tile, scratch| sum_body(tile, scratch, WorkgroupReductionScope::FirstWorkgroup),
123    )
124}
125
126/// Build a standalone u32 workgroup sum Program.
127#[must_use]
128pub fn workgroup_sum_u32(values: &str, out: &str, count: u32, tile: u32) -> Program {
129    reduction_program(
130        SUM_U32_OP_ID,
131        values,
132        out,
133        count,
134        tile,
135        DataType::U32,
136        Expr::u32(0),
137        Expr::add,
138        |tile, scratch| sum_body(tile, scratch, WorkgroupReductionScope::FirstWorkgroup),
139    )
140}
141
142/// Build a standalone f32 workgroup maximum Program.
143#[must_use]
144pub fn workgroup_max_f32(values: &str, out: &str, count: u32, tile: u32) -> Program {
145    reduction_program(
146        MAX_F32_OP_ID,
147        values,
148        out,
149        count,
150        tile,
151        DataType::F32,
152        Expr::f32(f32::MIN),
153        Expr::max,
154        |tile, scratch| max_body(tile, scratch, WorkgroupReductionScope::FirstWorkgroup),
155    )
156}
157
158/// Build a standalone u32 workgroup maximum Program.
159///
160/// The u32 twin of [`workgroup_max_f32`], closing the
161/// sum-has-both-types / max-has-only-f32 asymmetry. `0` (`u32::MIN`) is the
162/// neutral for an unsigned max, and the subgroup-first lowering already
163/// recognizes the `workgroup_max_` prefix with a u32 value type, so this gets
164/// the fast warp-reduction path on subgroup-capable backends for free.
165#[must_use]
166pub fn workgroup_max_u32(values: &str, out: &str, count: u32, tile: u32) -> Program {
167    reduction_program(
168        MAX_U32_OP_ID,
169        values,
170        out,
171        count,
172        tile,
173        DataType::U32,
174        Expr::u32(u32::MIN),
175        Expr::max,
176        |tile, scratch| max_body(tile, scratch, WorkgroupReductionScope::FirstWorkgroup),
177    )
178}
179
180/// Build a standalone f32 workgroup minimum Program.
181///
182/// `f32::MAX` is the neutral for a minimum (any real value is smaller). The
183/// subgroup-first lowering recognizes the `workgroup_min_` prefix, so this gets
184/// the native warp `subgroupMin` / `redux.sync.min` path on capable backends.
185#[must_use]
186pub fn workgroup_min_f32(values: &str, out: &str, count: u32, tile: u32) -> Program {
187    reduction_program(
188        MIN_F32_OP_ID,
189        values,
190        out,
191        count,
192        tile,
193        DataType::F32,
194        Expr::f32(f32::MAX),
195        Expr::min,
196        |tile, scratch| min_body(tile, scratch, WorkgroupReductionScope::FirstWorkgroup),
197    )
198}
199
200/// Build a standalone u32 workgroup minimum Program.
201///
202/// `u32::MAX` is the neutral for an unsigned minimum.
203#[must_use]
204pub fn workgroup_min_u32(values: &str, out: &str, count: u32, tile: u32) -> Program {
205    reduction_program(
206        MIN_U32_OP_ID,
207        values,
208        out,
209        count,
210        tile,
211        DataType::U32,
212        Expr::u32(u32::MAX),
213        Expr::min,
214        |tile, scratch| min_body(tile, scratch, WorkgroupReductionScope::FirstWorkgroup),
215    )
216}
217
218#[allow(clippy::too_many_arguments)]
219fn reduction_program<F, R>(
220    op_id: &'static str,
221    values: &str,
222    out: &str,
223    count: u32,
224    tile: u32,
225    dtype: DataType,
226    init: Expr,
227    accumulate: F,
228    reduce: R,
229) -> Program
230where
231    F: Fn(Expr, Expr) -> Expr,
232    R: Fn(u32, &'static str) -> Vec<Node>,
233{
234    let tile = tile.max(1);
235    let chunks = count.div_ceil(tile);
236    let scratch = "__workgroup_reduce_scratch";
237    let local = Expr::var("local");
238    let idx = Expr::var("idx");
239    let mut body = vec![
240        Node::let_bind("local", Expr::LocalId { axis: 0 }),
241        Node::if_then(
242            Expr::is_first_workgroup(),
243            vec![
244                Node::let_bind("acc", init),
245                Node::loop_for(
246                    "chunk",
247                    Expr::u32(0),
248                    Expr::u32(chunks),
249                    vec![
250                        Node::let_bind(
251                            "idx",
252                            Expr::add(
253                                Expr::mul(Expr::var("chunk"), Expr::u32(tile)),
254                                local.clone(),
255                            ),
256                        ),
257                        Node::if_then(
258                            Expr::lt(idx.clone(), Expr::u32(count)),
259                            vec![Node::assign(
260                                "acc",
261                                accumulate(Expr::var("acc"), Expr::load(values, idx.clone())),
262                            )],
263                        ),
264                    ],
265                ),
266                Node::store(scratch, local.clone(), Expr::var("acc")),
267            ],
268        ),
269        Node::barrier(),
270    ];
271    body.extend(reduce(tile, scratch));
272    body.push(Node::if_then(
273        Expr::and(Expr::is_first_workgroup(), Expr::eq(local, Expr::u32(0))),
274        vec![Node::store(
275            out,
276            Expr::u32(0),
277            Expr::load(scratch, Expr::u32(0)),
278        )],
279    ));
280    Program::wrapped(
281        vec![
282            BufferDecl::storage(values, 0, BufferAccess::ReadOnly, dtype.clone()).with_count(count),
283            BufferDecl::workgroup(scratch, tile, dtype.clone()),
284            BufferDecl::output(out, 1, dtype).with_count(1),
285        ],
286        [tile, 1, 1],
287        vec![Node::Region {
288            generator: Ident::from(op_id),
289            source_region: None,
290            body: Arc::new(body),
291        }],
292    )
293}
294
295fn child_region(generator: &'static str, parent_op_id: &str, body: Vec<Node>) -> Node {
296    Node::Region {
297        generator: Ident::from(generator),
298        source_region: Some(GeneratorRef {
299            name: parent_op_id.to_string(),
300        }),
301        body: Arc::new(body),
302    }
303}
304
305fn sum_body(tile: u32, scratch: &'static str, scope: WorkgroupReductionScope) -> Vec<Node> {
306    tree_body(tile, scratch, scope, Expr::add)
307}
308
309fn max_body(tile: u32, scratch: &'static str, scope: WorkgroupReductionScope) -> Vec<Node> {
310    tree_body(tile, scratch, scope, Expr::max)
311}
312
313fn min_body(tile: u32, scratch: &'static str, scope: WorkgroupReductionScope) -> Vec<Node> {
314    tree_body(tile, scratch, scope, Expr::min)
315}
316
317fn tree_body<F>(
318    tile: u32,
319    scratch: &'static str,
320    scope: WorkgroupReductionScope,
321    combine: F,
322) -> Vec<Node>
323where
324    F: Fn(Expr, Expr) -> Expr,
325{
326    let mut nodes = Vec::new();
327    let mut stride = tile.next_power_of_two() / 2;
328    while stride > 0 {
329        let lhs = Expr::load(scratch, Expr::var("local"));
330        let rhs_index = Expr::add(Expr::var("local"), Expr::u32(stride));
331        let rhs = Expr::load(scratch, rhs_index.clone());
332        nodes.push(Node::if_then(
333            scope.lane_guard(Expr::lt(Expr::var("local"), Expr::u32(stride))),
334            vec![Node::if_then(
335                Expr::lt(rhs_index, Expr::u32(tile)),
336                vec![Node::Store {
337                    buffer: scratch.into(),
338                    index: Expr::var("local"),
339                    value: combine(lhs, rhs),
340                }],
341            )],
342        ));
343        nodes.push(Node::barrier());
344        stride /= 2;
345    }
346    nodes
347}
348
349#[cfg(feature = "inventory-registry")]
350fn fixture_f32(values: &[f32]) -> Vec<u8> {
351    crate::wire::pack_f32_slice(values)
352}
353
354#[cfg(feature = "inventory-registry")]
355fn fixture_u32(values: &[u32]) -> Vec<u8> {
356    crate::wire::pack_u32_slice(values)
357}
358
359#[cfg(feature = "inventory-registry")]
360inventory::submit! {
361    vyre_foundation::operation::OperationRegistration::primitive(
362        SUM_F32_OP_ID,
363        || workgroup_sum_f32("values", "out", 4, 4),
364        Some(|| vec![vec![
365            fixture_f32(&[1.25, -2.0, 5.5, 3.25]),
366            fixture_f32(&[0.0]),
367        ]]),
368        Some(|| vec![vec![fixture_f32(&[8.0])]]),
369    )
370}
371
372#[cfg(feature = "inventory-registry")]
373inventory::submit! {
374    vyre_foundation::operation::OperationRegistration::primitive(
375        SUM_U32_OP_ID,
376        || workgroup_sum_u32("values", "out", 4, 4),
377        Some(|| vec![vec![
378            fixture_u32(&[1, 2, 3, 4]),
379            fixture_u32(&[0]),
380        ]]),
381        Some(|| vec![vec![fixture_u32(&[10])]]),
382    )
383}
384
385#[cfg(feature = "inventory-registry")]
386inventory::submit! {
387    vyre_foundation::operation::OperationRegistration::primitive(
388        MAX_F32_OP_ID,
389        || workgroup_max_f32("values", "out", 4, 4),
390        Some(|| vec![vec![
391            fixture_f32(&[-3.0, 9.5, 4.0, 1.25]),
392            fixture_f32(&[0.0]),
393        ]]),
394        Some(|| vec![vec![fixture_f32(&[9.5])]]),
395    )
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use vyre_reference::value::Value;
402
403    #[test]
404    fn child_region_names_parent_and_primitive() {
405        let node = sum_f32_child(
406            "vyre-libs::math::reduce_mean",
407            256,
408            "scratch",
409            WorkgroupReductionScope::FirstWorkgroup,
410        );
411        let Node::Region {
412            generator,
413            source_region,
414            body,
415        } = node
416        else {
417            panic!("Fix: workgroup tree helper must emit a child Region.");
418        };
419        assert_eq!(generator.as_str(), SUM_F32_OP_ID);
420        assert_eq!(
421            source_region
422                .expect("Fix: child Region must name parent.")
423                .name,
424            "vyre-libs::math::reduce_mean"
425        );
426        assert!(!body.is_empty());
427    }
428
429    #[test]
430    fn standalone_sum_f32_matches_reference_arithmetic() {
431        let values = [1.25_f32, -2.0, 5.5, 3.25, 8.0];
432        let program = workgroup_sum_f32("values", "out", values.len() as u32, 4);
433        let outputs = vyre_reference::reference_eval(
434            &program,
435            &[
436                Value::from(crate::wire::pack_f32_slice(&values)),
437                Value::from(vec![0_u8; core::mem::size_of::<f32>()]),
438            ],
439        )
440        .expect("Fix: workgroup_sum_f32 must execute in the reference interpreter.");
441        assert_eq!(
442            crate::wire::decode_f32_le_bytes_all(&outputs[0].to_bytes())[0],
443            values.iter().copied().sum::<f32>()
444        );
445    }
446
447    #[test]
448    fn standalone_sum_u32_matches_reference_arithmetic() {
449        let values = [1_u32, 2, 3, 4, 5, 6, 7];
450        let program = workgroup_sum_u32("values", "out", values.len() as u32, 4);
451        let outputs = vyre_reference::reference_eval(
452            &program,
453            &[
454                Value::from(crate::wire::pack_u32_slice(&values)),
455                Value::from(vec![0_u8; core::mem::size_of::<u32>()]),
456            ],
457        )
458        .expect("Fix: workgroup_sum_u32 must execute in the reference interpreter.");
459        assert_eq!(
460            crate::wire::decode_u32_le_bytes_all(&outputs[0].to_bytes())[0],
461            values.iter().copied().sum::<u32>()
462        );
463    }
464
465    #[test]
466    fn standalone_max_f32_matches_reference_arithmetic() {
467        let values = [-3.0_f32, 9.5, 4.0, 1.25, 8.75];
468        let program = workgroup_max_f32("values", "out", values.len() as u32, 4);
469        let outputs = vyre_reference::reference_eval(
470            &program,
471            &[
472                Value::from(crate::wire::pack_f32_slice(&values)),
473                Value::from(vec![0_u8; core::mem::size_of::<f32>()]),
474            ],
475        )
476        .expect("Fix: workgroup_max_f32 must execute in the reference interpreter.");
477        assert_eq!(
478            crate::wire::decode_f32_le_bytes_all(&outputs[0].to_bytes())[0],
479            9.5
480        );
481    }
482
483    #[test]
484    fn standalone_max_u32_matches_reference_arithmetic() {
485        // Max (42) is at index 3, not 0, so a broken reduction that kept the
486        // first lane or the `0` identity would be caught.
487        let values = [3_u32, 17, 5, 42, 8, 1];
488        let program = workgroup_max_u32("values", "out", values.len() as u32, 4);
489        let outputs = vyre_reference::reference_eval(
490            &program,
491            &[
492                Value::from(crate::wire::pack_u32_slice(&values)),
493                Value::from(vec![0_u8; core::mem::size_of::<u32>()]),
494            ],
495        )
496        .expect("Fix: workgroup_max_u32 must execute in the reference interpreter.");
497        assert_eq!(
498            crate::wire::decode_u32_le_bytes_all(&outputs[0].to_bytes())[0],
499            values.iter().copied().max().expect("non-empty"),
500            "workgroup_max_u32 must compute the unsigned max (42)"
501        );
502    }
503
504    #[test]
505    fn standalone_min_f32_matches_reference_arithmetic() {
506        // Min (-2.5) is not at index 0; an f32::MAX-identity or kept-first bug fails.
507        let values = [3.0_f32, 9.5, -2.5, 1.25, 8.0];
508        let program = workgroup_min_f32("values", "out", values.len() as u32, 4);
509        let outputs = vyre_reference::reference_eval(
510            &program,
511            &[
512                Value::from(crate::wire::pack_f32_slice(&values)),
513                Value::from(vec![0_u8; core::mem::size_of::<f32>()]),
514            ],
515        )
516        .expect("Fix: workgroup_min_f32 must execute in the reference interpreter.");
517        assert_eq!(
518            crate::wire::decode_f32_le_bytes_all(&outputs[0].to_bytes())[0],
519            values.iter().copied().fold(f32::INFINITY, f32::min),
520            "workgroup_min_f32 must compute the min (-2.5)"
521        );
522    }
523
524    #[test]
525    fn standalone_min_u32_matches_reference_arithmetic() {
526        let values = [17_u32, 3, 42, 8, 25];
527        let program = workgroup_min_u32("values", "out", values.len() as u32, 4);
528        let outputs = vyre_reference::reference_eval(
529            &program,
530            &[
531                Value::from(crate::wire::pack_u32_slice(&values)),
532                Value::from(vec![0_u8; core::mem::size_of::<u32>()]),
533            ],
534        )
535        .expect("Fix: workgroup_min_u32 must execute in the reference interpreter.");
536        assert_eq!(
537            crate::wire::decode_u32_le_bytes_all(&outputs[0].to_bytes())[0],
538            values.iter().copied().min().expect("non-empty"),
539            "workgroup_min_u32 must compute the unsigned min (3)"
540        );
541    }
542
543    #[test]
544    fn non_power_of_two_tile_reductions_match_reference_arithmetic() {
545        let values = [4.0_f32, -7.0, 2.5, 9.0, 1.0, 3.25, -2.0];
546        let sum_program = workgroup_sum_f32("values", "out", values.len() as u32, 3);
547        let sum_outputs = vyre_reference::reference_eval(
548            &sum_program,
549            &[
550                Value::from(crate::wire::pack_f32_slice(&values)),
551                Value::from(vec![0_u8; core::mem::size_of::<f32>()]),
552            ],
553        )
554        .expect("Fix: workgroup_sum_f32 must support non-power-of-two tiles.");
555        assert_eq!(
556            crate::wire::decode_f32_le_bytes_all(&sum_outputs[0].to_bytes())[0],
557            values.iter().copied().sum::<f32>()
558        );
559
560        let max_program = workgroup_max_f32("values", "out", values.len() as u32, 3);
561        let max_outputs = vyre_reference::reference_eval(
562            &max_program,
563            &[
564                Value::from(crate::wire::pack_f32_slice(&values)),
565                Value::from(vec![0_u8; core::mem::size_of::<f32>()]),
566            ],
567        )
568        .expect("Fix: workgroup_max_f32 must support non-power-of-two tiles.");
569        assert_eq!(
570            crate::wire::decode_f32_le_bytes_all(&max_outputs[0].to_bytes())[0],
571            9.0
572        );
573    }
574}