Skip to main content

vyre_driver/
fusion.rs

1//! Cross-dispatch fusion decisions shared by concrete backends.
2
3use crate::specialization::SpecMap;
4
5/// One dispatch's pre-fusion description.
6#[derive(Debug, Clone)]
7pub struct DispatchShape {
8    /// Stable id for this dispatch inside the containing program.
9    pub id: &'static str,
10    /// Workgroup size `[x, y, z]`.
11    pub workgroup_size: [u32; 3],
12    /// Per-dispatch shared memory bytes.
13    pub shared_memory_bytes: u32,
14    /// Buffers this dispatch reads.
15    pub inputs: Vec<&'static str>,
16    /// Buffers this dispatch writes.
17    pub outputs: Vec<&'static str>,
18    /// Specialization constants baked into this dispatch.
19    pub specs: SpecMap,
20}
21
22/// Adapter caps honored by the generic fusion pass.
23#[derive(Debug, Clone, Copy)]
24pub struct FusionCaps {
25    /// Maximum workgroup-shared memory the adapter can serve.
26    pub max_shared_memory_bytes: u32,
27    /// Maximum workgroup invocation count.
28    pub max_invocations_per_workgroup: u32,
29}
30
31impl Default for FusionCaps {
32    fn default() -> Self {
33        Self {
34            max_shared_memory_bytes: 16 * 1024,
35            max_invocations_per_workgroup: 256,
36        }
37    }
38}
39
40impl FusionCaps {
41    /// High-end profile for tests and capability probes.
42    #[must_use]
43    pub const fn high_end() -> Self {
44        Self {
45            max_shared_memory_bytes: 128 * 1024,
46            max_invocations_per_workgroup: 1024,
47        }
48    }
49}
50
51/// Why the fusion pass accepted or rejected a pair.
52#[derive(Debug, Clone, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum FusionDecision {
55    /// Fusion is legal; the concrete backend may stitch its target modules.
56    Accept,
57    /// Upstream and downstream workgroup sizes differ.
58    WorkgroupSizeMismatch {
59        /// Upstream size.
60        upstream: [u32; 3],
61        /// Downstream size.
62        downstream: [u32; 3],
63    },
64    /// Combined workgroup invocations exceed the adapter cap.
65    InvocationBudgetExceeded {
66        /// Workgroup shape whose product exceeds the cap.
67        workgroup: [u32; 3],
68        /// Computed invocation product (saturated to `u64`).
69        invocations: u64,
70        /// Adapter cap.
71        cap: u32,
72    },
73    /// Shared-memory budget would exceed adapter caps.
74    SharedMemoryBudget {
75        /// Combined bytes the fused kernel would request.
76        needed: u64,
77        /// Adapter cap.
78        cap: u32,
79    },
80    /// A flow-through output is still consumed by a third dispatch.
81    OutputConsumedElsewhere,
82    /// No buffer flows from upstream outputs to downstream inputs.
83    NoPipelineDependency,
84}
85
86/// Pure cross-dispatch fusion analysis.
87pub struct FusionPass;
88
89impl FusionPass {
90    /// Decide whether `upstream` -> `downstream` is legal to fuse.
91    #[must_use]
92    pub fn decide(
93        upstream: &DispatchShape,
94        downstream: &DispatchShape,
95        caps: FusionCaps,
96        other_consumers: &[&str],
97    ) -> FusionDecision {
98        if upstream.workgroup_size != downstream.workgroup_size {
99            return FusionDecision::WorkgroupSizeMismatch {
100                upstream: upstream.workgroup_size,
101                downstream: downstream.workgroup_size,
102            };
103        }
104        let invocations = u128::from(upstream.workgroup_size[0])
105            * u128::from(upstream.workgroup_size[1])
106            * u128::from(upstream.workgroup_size[2]);
107        if invocations > u128::from(caps.max_invocations_per_workgroup) {
108            return FusionDecision::InvocationBudgetExceeded {
109                workgroup: upstream.workgroup_size,
110                invocations: u64::try_from(invocations).unwrap_or(u64::MAX),
111                cap: caps.max_invocations_per_workgroup,
112            };
113        }
114        let needed =
115            u64::from(upstream.shared_memory_bytes) + u64::from(downstream.shared_memory_bytes);
116        if needed > u64::from(caps.max_shared_memory_bytes) {
117            return FusionDecision::SharedMemoryBudget {
118                needed,
119                cap: caps.max_shared_memory_bytes,
120            };
121        }
122
123        let mut has_pipeline_dependency = false;
124        for output in &upstream.outputs {
125            if !downstream.inputs.iter().any(|input| input == output) {
126                continue;
127            }
128            has_pipeline_dependency = true;
129            if other_consumers.iter().any(|consumer| consumer == output) {
130                return FusionDecision::OutputConsumedElsewhere;
131            }
132        }
133        if !has_pipeline_dependency {
134            return FusionDecision::NoPipelineDependency;
135        }
136        FusionDecision::Accept
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn dispatch(
145        id: &'static str,
146        inputs: &[&'static str],
147        outputs: &[&'static str],
148    ) -> DispatchShape {
149        DispatchShape {
150            id,
151            workgroup_size: [64, 1, 1],
152            shared_memory_bytes: 1024,
153            inputs: inputs.to_vec(),
154            outputs: outputs.to_vec(),
155            specs: SpecMap::new(),
156        }
157    }
158
159    #[test]
160    fn straight_producer_consumer_fuses() {
161        let up = dispatch("load", &["in"], &["stage"]);
162        let down = dispatch("xor", &["stage"], &["out"]);
163        assert_eq!(
164            FusionPass::decide(&up, &down, FusionCaps::high_end(), &[]),
165            FusionDecision::Accept
166        );
167    }
168
169    #[test]
170    fn third_consumer_rejects() {
171        let up = dispatch("a", &[], &["x"]);
172        let down = dispatch("b", &["x"], &[]);
173        assert_eq!(
174            FusionPass::decide(&up, &down, FusionCaps::high_end(), &["x"]),
175            FusionDecision::OutputConsumedElsewhere
176        );
177    }
178
179    #[test]
180    fn workgroup_invocation_overflow_rejects_instead_of_wrapping_or_clamping() {
181        let mut up = dispatch("wide-a", &["in"], &["stage"]);
182        up.workgroup_size = [u32::MAX, u32::MAX, 2];
183        let mut down = dispatch("wide-b", &["stage"], &["out"]);
184        down.workgroup_size = up.workgroup_size;
185        assert_eq!(
186            FusionPass::decide(&up, &down, FusionCaps::high_end(), &[]),
187            FusionDecision::InvocationBudgetExceeded {
188                workgroup: up.workgroup_size,
189                invocations: u64::MAX,
190                cap: FusionCaps::high_end().max_invocations_per_workgroup,
191            }
192        );
193    }
194
195    #[test]
196    fn shared_memory_overflow_rejects_instead_of_appearing_under_cap() {
197        let mut up = dispatch("smem-a", &["in"], &["stage"]);
198        up.shared_memory_bytes = u32::MAX;
199        let mut down = dispatch("smem-b", &["stage"], &["out"]);
200        down.shared_memory_bytes = 1;
201        assert_eq!(
202            FusionPass::decide(&up, &down, FusionCaps::high_end(), &[]),
203            FusionDecision::SharedMemoryBudget {
204                needed: u64::from(u32::MAX) + 1,
205                cap: FusionCaps::high_end().max_shared_memory_bytes,
206            }
207        );
208    }
209
210    #[test]
211    fn source_has_no_clamped_fusion_admission_math() {
212        let source = include_str!("fusion.rs");
213        assert!(
214            !source.contains(concat!(".", "saturating_")),
215            "fusion admission must use widened exact arithmetic, not silent clamps"
216        );
217    }
218
219    // Reproducing test for: fusion-invocation-overflow-wrong-variant
220    // Before fix: FusionPass returned WorkgroupSizeMismatch{upstream==downstream} when the
221    // real failure was invocations > cap, misreporting the rejection reason to callers.
222    // After fix: returns InvocationBudgetExceeded{workgroup, invocations, cap} instead.
223    #[test]
224    fn invocation_budget_exceeded_returns_distinct_variant_not_workgroup_size_mismatch() {
225        // Workgroup sizes must match (passing the size-mismatch gate) but product > cap.
226        let caps = FusionCaps {
227            max_shared_memory_bytes: 128 * 1024,
228            max_invocations_per_workgroup: 64,
229        };
230        let mut up = dispatch("overinvoke-a", &["in"], &["stage"]);
231        up.workgroup_size = [32, 4, 1]; // 128 invocations > cap 64
232        let mut down = dispatch("overinvoke-b", &["stage"], &["out"]);
233        down.workgroup_size = up.workgroup_size; // sizes are equal, not a mismatch
234
235        let decision = FusionPass::decide(&up, &down, caps, &[]);
236
237        // Must NOT be WorkgroupSizeMismatch (wrong variant from the old code).
238        assert_ne!(
239            decision,
240            FusionDecision::WorkgroupSizeMismatch {
241                upstream: up.workgroup_size,
242                downstream: down.workgroup_size,
243            },
244            "Fix: when invocations exceed the cap and sizes are equal, the decision must not be WorkgroupSizeMismatch"
245        );
246        // Must be the correct InvocationBudgetExceeded variant with exact fields.
247        assert_eq!(
248            decision,
249            FusionDecision::InvocationBudgetExceeded {
250                workgroup: [32, 4, 1],
251                invocations: 128,
252                cap: 64,
253            },
254            "Fix: FusionPass must return InvocationBudgetExceeded{{workgroup=[32,4,1], invocations=128, cap=64}} when invocations > cap"
255        );
256    }
257
258    #[test]
259    fn workgroup_size_mismatch_variant_is_only_returned_when_sizes_actually_differ() {
260        // Mismatch case (must still work correctly).
261        let mut up = dispatch("mismatch-a", &["in"], &["mid"]);
262        up.workgroup_size = [32, 1, 1];
263        let mut down = dispatch("mismatch-b", &["mid"], &["out"]);
264        down.workgroup_size = [64, 1, 1];
265        assert_eq!(
266            FusionPass::decide(&up, &down, FusionCaps::high_end(), &[]),
267            FusionDecision::WorkgroupSizeMismatch {
268                upstream: [32, 1, 1],
269                downstream: [64, 1, 1],
270            },
271            "Fix: WorkgroupSizeMismatch must carry the actual differing sizes"
272        );
273    }
274}