1use crate::specialization::SpecMap;
4
5#[derive(Debug, Clone)]
7pub struct DispatchShape {
8 pub id: &'static str,
10 pub workgroup_size: [u32; 3],
12 pub shared_memory_bytes: u32,
14 pub inputs: Vec<&'static str>,
16 pub outputs: Vec<&'static str>,
18 pub specs: SpecMap,
20}
21
22#[derive(Debug, Clone, Copy)]
24pub struct FusionCaps {
25 pub max_shared_memory_bytes: u32,
27 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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum FusionDecision {
55 Accept,
57 WorkgroupSizeMismatch {
59 upstream: [u32; 3],
61 downstream: [u32; 3],
63 },
64 InvocationBudgetExceeded {
66 workgroup: [u32; 3],
68 invocations: u64,
70 cap: u32,
72 },
73 SharedMemoryBudget {
75 needed: u64,
77 cap: u32,
79 },
80 OutputConsumedElsewhere,
82 NoPipelineDependency,
84}
85
86pub struct FusionPass;
88
89impl FusionPass {
90 #[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 #[test]
224 fn invocation_budget_exceeded_returns_distinct_variant_not_workgroup_size_mismatch() {
225 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]; let mut down = dispatch("overinvoke-b", &["stage"], &["out"]);
233 down.workgroup_size = up.workgroup_size; let decision = FusionPass::decide(&up, &down, caps, &[]);
236
237 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 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 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}