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    // Reproducing test for: fusion-invocation-overflow-wrong-variant
211    // Before fix: FusionPass returned WorkgroupSizeMismatch{upstream==downstream} when the
212    // real failure was invocations > cap, misreporting the rejection reason to callers.
213    // After fix: returns InvocationBudgetExceeded{workgroup, invocations, cap} instead.
214    #[test]
215    fn invocation_budget_exceeded_returns_distinct_variant_not_workgroup_size_mismatch() {
216        // Workgroup sizes must match (passing the size-mismatch gate) but product > cap.
217        let caps = FusionCaps {
218            max_shared_memory_bytes: 128 * 1024,
219            max_invocations_per_workgroup: 64,
220        };
221        let mut up = dispatch("overinvoke-a", &["in"], &["stage"]);
222        up.workgroup_size = [32, 4, 1]; // 128 invocations > cap 64
223        let mut down = dispatch("overinvoke-b", &["stage"], &["out"]);
224        down.workgroup_size = up.workgroup_size; // sizes are equal, not a mismatch
225
226        let decision = FusionPass::decide(&up, &down, caps, &[]);
227
228        // Must NOT be WorkgroupSizeMismatch (wrong variant from the old code).
229        assert_ne!(
230            decision,
231            FusionDecision::WorkgroupSizeMismatch {
232                upstream: up.workgroup_size,
233                downstream: down.workgroup_size,
234            },
235            "Fix: when invocations exceed the cap and sizes are equal, the decision must not be WorkgroupSizeMismatch"
236        );
237        // Must be the correct InvocationBudgetExceeded variant with exact fields.
238        assert_eq!(
239            decision,
240            FusionDecision::InvocationBudgetExceeded {
241                workgroup: [32, 4, 1],
242                invocations: 128,
243                cap: 64,
244            },
245            "Fix: FusionPass must return InvocationBudgetExceeded{{workgroup=[32,4,1], invocations=128, cap=64}} when invocations > cap"
246        );
247    }
248
249    #[test]
250    fn workgroup_size_mismatch_variant_is_only_returned_when_sizes_actually_differ() {
251        // Mismatch case (must still work correctly).
252        let mut up = dispatch("mismatch-a", &["in"], &["mid"]);
253        up.workgroup_size = [32, 1, 1];
254        let mut down = dispatch("mismatch-b", &["mid"], &["out"]);
255        down.workgroup_size = [64, 1, 1];
256        assert_eq!(
257            FusionPass::decide(&up, &down, FusionCaps::high_end(), &[]),
258            FusionDecision::WorkgroupSizeMismatch {
259                upstream: [32, 1, 1],
260                downstream: [64, 1, 1],
261            },
262            "Fix: WorkgroupSizeMismatch must carry the actual differing sizes"
263        );
264    }
265}