qualia_core_db/wgsl_forge/
schedule.rs1use serde::{Deserialize, Serialize};
2
3use super::ir::IntrinsicClass;
4use super::{ForgeError, KernelSpec};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
7pub struct Schedule {
8 pub workgroup_size: u32,
9 pub items_per_invocation: u32,
10 pub vector_width: u32,
11}
12
13impl Default for Schedule {
14 fn default() -> Self {
15 Self {
16 workgroup_size: 64,
17 items_per_invocation: 1,
18 vector_width: 1,
19 }
20 }
21}
22
23impl Schedule {
24 pub fn validate(
25 self,
26 kernel: &KernelSpec,
27 constraints: &AdapterConstraints,
28 ) -> Result<(), ForgeError> {
29 kernel.validate()?;
30 if !matches!(self.workgroup_size, 1..=1024) || !self.workgroup_size.is_power_of_two() {
31 return Err(ForgeError::InvalidSchedule(
32 "workgroup size must be a power of two in 1..=1024".to_string(),
33 ));
34 }
35 if self.workgroup_size > constraints.max_workgroup_size_x
36 || self.workgroup_size > constraints.max_invocations_per_workgroup
37 {
38 return Err(ForgeError::InvalidSchedule(format!(
39 "workgroup {} exceeds adapter limit {}",
40 self.workgroup_size,
41 constraints
42 .max_workgroup_size_x
43 .min(constraints.max_invocations_per_workgroup)
44 )));
45 }
46 if !matches!(self.items_per_invocation, 1 | 2 | 4 | 8) {
47 return Err(ForgeError::InvalidSchedule(
48 "items per invocation must be one of 1, 2, 4, 8".to_string(),
49 ));
50 }
51 if !matches!(self.vector_width, 1 | 2 | 4) {
52 return Err(ForgeError::InvalidSchedule(
53 "vector width must be one of 1, 2, 4".to_string(),
54 ));
55 }
56
57 Ok(())
58 }
59
60 pub const fn elements_per_workgroup(self) -> u32 {
61 self.workgroup_size * self.items_per_invocation * self.vector_width
62 }
63
64 pub fn dispatch_workgroups(self, element_count: usize) -> u32 {
65 let width = self.elements_per_workgroup().max(1) as usize;
66 element_count.div_ceil(width).max(1) as u32
67 }
68
69 pub const fn sort_key(self) -> (u32, u32, u32) {
70 (
71 self.workgroup_size,
72 self.items_per_invocation,
73 self.vector_width,
74 )
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79pub struct AdapterConstraints {
80 pub max_workgroup_size_x: u32,
81 pub max_invocations_per_workgroup: u32,
82 pub max_workgroups_per_dimension: u32,
83 pub supports_subgroups: bool,
84 pub supports_coopmat: bool,
86 pub supports_rt_cores: bool,
88 pub warp_size: u32,
91}
92
93impl AdapterConstraints {
94 pub const fn portable() -> Self {
95 Self {
96 max_workgroup_size_x: 256,
97 max_invocations_per_workgroup: 256,
98 max_workgroups_per_dimension: 65_535,
99 supports_subgroups: false,
100 supports_coopmat: false,
101 supports_rt_cores: false,
102 warp_size: 32,
103 }
104 }
105
106 pub fn supports_kernel(&self, kernel: &KernelSpec) -> Result<(), ForgeError> {
113 for intrinsic in kernel.required_intrinsics() {
114 match intrinsic.class() {
115 IntrinsicClass::CooperativeMatrix if !self.supports_coopmat => {
116 return Err(ForgeError::InvalidSchedule(
117 "kernel requires cooperative-matrix (tensor cores) unavailable on this adapter".to_string(),
118 ));
119 }
120 IntrinsicClass::RayTracing if !self.supports_rt_cores => {
121 return Err(ForgeError::InvalidSchedule(
122 "kernel requires ray-query (RT cores) unavailable on this adapter"
123 .to_string(),
124 ));
125 }
126 _ => {}
127 }
128 }
129 Ok(())
130 }
131
132 #[cfg(feature = "gpu-runtime")]
133 pub fn from_wgpu_limits(limits: &wgpu::Limits) -> Self {
134 Self {
137 max_workgroup_size_x: limits.max_compute_workgroup_size_x,
138 max_invocations_per_workgroup: limits.max_compute_invocations_per_workgroup,
139 max_workgroups_per_dimension: limits.max_compute_workgroups_per_dimension,
140 supports_subgroups: false,
141 supports_coopmat: false,
142 supports_rt_cores: false,
143 warp_size: 32,
144 }
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct ScheduleSpace {
150 pub workgroup_sizes: Vec<u32>,
151 pub items_per_invocation: Vec<u32>,
152 pub vector_widths: Vec<u32>,
153}
154
155impl Default for ScheduleSpace {
156 fn default() -> Self {
157 Self {
158 workgroup_sizes: vec![32, 64, 128, 256],
159 items_per_invocation: vec![1, 2, 4, 8],
160 vector_widths: vec![1, 2, 4],
161 }
162 }
163}
164
165impl ScheduleSpace {
166 pub fn candidates(
179 &self,
180 kernel: &KernelSpec,
181 constraints: &AdapterConstraints,
182 ) -> Vec<Schedule> {
183 let mut candidates = Vec::new();
184 for &workgroup_size in &self.workgroup_sizes {
185 for &items_per_invocation in &self.items_per_invocation {
186 for &vector_width in &self.vector_widths {
187 let schedule = Schedule {
188 workgroup_size,
189 items_per_invocation,
190 vector_width,
191 ..Default::default()
192 };
193 let warp_aligned =
197 constraints.warp_size <= 1 || workgroup_size % constraints.warp_size == 0;
198 if warp_aligned && schedule.validate(kernel, constraints).is_ok() {
199 candidates.push(schedule);
200 }
201 }
202 }
203 }
204 candidates.sort_by_key(|schedule| schedule.sort_key());
205 candidates.dedup();
206 candidates
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::wgsl_forge::BuiltinKernel;
214
215 #[test]
216 fn candidates_are_bounded_and_deterministic() {
217 let kernel = BuiltinKernel::AffineF32.spec();
218 let constraints = AdapterConstraints::portable();
219 let first = ScheduleSpace::default().candidates(&kernel, &constraints);
220 let second = ScheduleSpace::default().candidates(&kernel, &constraints);
221 assert_eq!(first, second);
222 assert_eq!(first.len(), 48);
223 assert!(first.iter().all(|schedule| schedule.workgroup_size <= 256));
224 }
225
226 #[test]
227 fn dispatch_covers_tail_elements() {
228 let schedule = Schedule {
229 workgroup_size: 64,
230 items_per_invocation: 2,
231 vector_width: 4,
232 ..Default::default()
233 };
234 assert_eq!(schedule.dispatch_workgroups(513), 2);
235 }
236
237 #[test]
238 fn invalid_schedule_is_rejected_before_emission() {
239 use crate::wgsl_forge::{generate_builtin, ForgeError, TargetBackend};
240
241 let invalid = Schedule {
244 workgroup_size: 100,
245 items_per_invocation: 1,
246 vector_width: 1,
247 };
248 let kernel = BuiltinKernel::AffineF32.spec();
250 assert!(invalid
251 .validate(&kernel, &AdapterConstraints::portable())
252 .is_err());
253
254 let result = generate_builtin(BuiltinKernel::AffineF32, invalid, TargetBackend::Wgsl);
255 match result {
258 Err(ForgeError::InvalidSchedule(message)) => {
259 assert!(
260 message.contains("power of two"),
261 "expected a power-of-two schedule error, got: {message}"
262 );
263 }
264 other => panic!("expected InvalidSchedule before emission, got {other:?}"),
265 }
266 }
267
268 #[test]
269 fn rt_kernel_pruned_without_rt_cores() {
270 use crate::wgsl_forge::ir::{
271 BufferAccess, BufferElement, BufferSpec, Intrinsic, KernelSpec, Op, ScalarType,
272 };
273
274 let kernel = KernelSpec {
275 id: "rt-probe".to_string(),
276 semantic_version: 1,
277 entry_point: "rt_probe".to_string(),
278 description: "ray-query smoke kernel".to_string(),
279 buffers: vec![BufferSpec {
280 group: 0,
281 binding: 0,
282 name: "output".to_string(),
283 element: BufferElement::Scalar(ScalarType::F32),
284 access: BufferAccess::StorageReadWrite,
285 }],
286 ops: vec![Op::Intrinsic(Intrinsic::RayQuery {
287 acceleration_structure: "tlas".to_string(),
288 origin: "o".to_string(),
289 direction: "d".to_string(),
290 t_min: "tmin".to_string(),
291 t_max: "tmax".to_string(),
292 destination: "hit".to_string(),
293 })],
294 shared_memory: Vec::new(),
295 };
296
297 assert!(Schedule::default()
299 .validate(&kernel, &AdapterConstraints::portable())
300 .is_ok());
301
302 let without = AdapterConstraints::portable();
304 assert!(without.supports_kernel(&kernel).is_err());
305
306 let with = AdapterConstraints {
308 supports_rt_cores: true,
309 ..AdapterConstraints::portable()
310 };
311 assert!(with.supports_kernel(&kernel).is_ok());
312 }
313}