Skip to main content

vyre_driver_wgpu/emit/
mod.rs

1//! Wgpu artifact emission.
2//!
3//! Core IR is lowered through `vyre-lower`; this module only applies wgpu
4//! runtime policy, emits a Naga module via `vyre-emit-naga`, and writes WGSL
5//! accepted by `wgpu`.
6
7pub(crate) mod descriptor_gate;
8
9use crate::descriptor_mapping::{
10    descriptor_bind_group, descriptor_buffer_access, descriptor_memory_kind,
11};
12use crate::WgpuBackend;
13use naga::valid::{Capabilities, ValidationFlags, Validator};
14use std::sync::Arc;
15use vyre_foundation::lower::LoweringError;
16
17/// Binding assignment made by the wgpu lowering pipeline.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct WgpuBindingAssignment {
20    /// Program buffer name.
21    pub name: Arc<str>,
22    /// Bind group index. Vyre wgpu programs currently use group 0.
23    pub group: u32,
24    /// Binding slot inside the group.
25    pub binding: u32,
26    /// Memory tier used to choose the wgpu address space.
27    pub kind: vyre_foundation::ir::MemoryKind,
28    /// Access mode declared by core IR.
29    pub access: vyre_foundation::ir::BufferAccess,
30    /// Element type carried by the binding.
31    pub element: vyre_foundation::ir::DataType,
32}
33
34/// Dispatch geometry captured during backend IR lowering.
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36pub struct WgpuDispatchGeometry {
37    /// Shader workgroup size.
38    pub workgroup_size: [u32; 3],
39    /// Static x workgroup count when it is derivable from output shape.
40    pub workgroups: [u32; 3],
41}
42
43/// Backend-owned wgpu IR.
44#[derive(Clone, Debug)]
45pub struct WgpuProgram {
46    /// Structurally emitted Naga module.
47    pub module: naga::Module,
48    /// Resource binding decisions.
49    pub bindings: Vec<WgpuBindingAssignment>,
50    /// Workgroup sizing chosen for the shader entry point.
51    pub workgroup_size: [u32; 3],
52    /// Dispatch geometry derived from program output declarations.
53    pub dispatch_geometry: WgpuDispatchGeometry,
54}
55
56/// Lower a certified program to WGSL.
57///
58/// The shader text is produced only after structural Naga IR construction and
59/// validation. Callers that need the module itself should use
60/// [`vyre_lower::lower_for_emit`] and [`vyre_emit_naga::emit`].
61///
62/// # Errors
63///
64/// Returns [`LoweringError`] when the program cannot be represented in Naga,
65/// validation fails, or the final writer fails.
66#[inline]
67pub fn lower(program: &vyre_foundation::ir::Program) -> Result<String, LoweringError> {
68    lower_with_config(program, &vyre_driver::DispatchConfig::default())
69}
70
71/// Lower a program to WGSL with explicit dispatch policy.
72///
73/// # Errors
74///
75/// Returns [`LoweringError`] for invalid IR, failed Naga validation, or failed
76/// WGSL writing.
77pub fn lower_with_config(
78    program: &vyre_foundation::ir::Program,
79    config: &vyre_driver::DispatchConfig,
80) -> Result<String, LoweringError> {
81    let default_features = crate::runtime::device::EnabledFeatures::default();
82    lower_with_features(program, config, &default_features)
83}
84
85/// Lower a program to WGSL with explicit dispatch policy and adapter features.
86///
87/// # Errors
88///
89/// Returns [`LoweringError`] for invalid IR, failed Naga validation, or failed
90/// WGSL writing.
91pub(crate) fn lower_with_features(
92    program: &vyre_foundation::ir::Program,
93    config: &vyre_driver::DispatchConfig,
94    enabled_features: &crate::runtime::device::EnabledFeatures,
95) -> Result<String, LoweringError> {
96    let bir = WgpuProgram::from_program(program, config, enabled_features)?;
97    write_wgsl(&bir.module)
98}
99
100/// Heuristic for selecting the optimal workgroup size for a program.
101///
102/// Innovation I.6: Adaptive workgroup sizing.
103///
104/// Takes the requested size from the program and the adapter capability
105/// reports, and returns a size that maximizes occupancy and throughput.
106/// Multi-axis workgroups are flattened to 1D [N, 1, 1] for current
107/// scan-based vyre opcodes.
108pub(crate) fn optimal_workgroup_size(
109    program: &vyre_foundation::ir::Program,
110    enabled_features: &crate::runtime::device::EnabledFeatures,
111) -> [u32; 3] {
112    let requested = program.workgroup_size;
113
114    // If the program specified a non-default concrete size, honor it.
115    // [1, 1, 1] is the legacy scalar default used by many builders.
116    if requested != [1, 1, 1] && requested != [0, 0, 0] {
117        return requested;
118    }
119
120    // Heuristic: use a multiple of the subgroup size.
121    // If unknown (0), default to 64.
122    let subgroup = enabled_features.min_subgroup_size.max(32);
123    let size = if program.is_explicit_noop() {
124        1
125    } else {
126        // For scan-heavy workloads, 4x subgroup size often yields
127        // good occupancy without hitting register pressure.
128        (subgroup * 4).min(256)
129    };
130
131    let max_x = enabled_features.max_workgroup_size[0].max(1);
132    [size.min(max_x), 1, 1]
133}
134
135impl WgpuProgram {
136    /// Build backend IR from a core program.
137    ///
138    /// # Errors
139    ///
140    /// Returns [`LoweringError`] when the program cannot be represented as
141    /// wgpu/Naga IR.
142    pub fn from_program(
143        program: &vyre_foundation::ir::Program,
144        config: &vyre_driver::DispatchConfig,
145        enabled_features: &crate::runtime::device::EnabledFeatures,
146    ) -> Result<Self, LoweringError> {
147        let mut descriptor = descriptor_gate::validate_and_analyze(program)?;
148        let workgroup_size = config
149            .workgroup_override
150            .unwrap_or_else(|| optimal_workgroup_size(program, enabled_features));
151        descriptor.dispatch.workgroup_size = workgroup_size;
152
153        if std::env::var("VYRE_DUMP_KDESC").is_ok() {
154            dump_kdesc_if_requested(&descriptor, None);
155        }
156
157        if let Err(errors) = vyre_lower::verify::verify(&descriptor) {
158            if std::env::var("VYRE_CAPTURE_FAILED_DESCRIPTOR").is_ok() {
159                dump_kdesc_if_requested(&descriptor, None);
160            }
161            return Err(LoweringError::invalid(format!(
162                "KernelDescriptor verification failed after wgpu workgroup selection: {}. Fix: keep DispatchConfig.workgroup_override within descriptor limits.",
163                format_descriptor_verify_errors(&errors)
164            )));
165        }
166        let module = match vyre_emit_naga::emit(&descriptor) {
167            Ok(m) => m,
168            Err(error) => {
169                if std::env::var("VYRE_CAPTURE_FAILED_DESCRIPTOR").is_ok() {
170                    dump_kdesc_if_requested(&descriptor, None);
171                }
172                return Err(LoweringError::invalid(format!(
173                    "KernelDescriptor Naga emission failed before wgpu WGSL writing: {error}. Fix: extend vyre-emit-naga descriptor emission; do not route around it with driver-local lowering."
174                )));
175            }
176        };
177
178        if std::env::var("VYRE_CAPTURE_FAILED_DESCRIPTOR").is_ok() {
179            // Also capture success if specifically requested, or just have it ready if WGSL writing fails downstream.
180            // Let's just capture it now, because from_program succeeds and WGSL writing might fail.
181            // Wait, we only want to capture on failure.
182            // Actually, we can just save it to a temporary location or just write it if the env var is set.
183            // The spec says "On dispatch failure... serialize in-flight".
184            // Since we don't know if WGSL will fail here, we can just proactively dump it if the feature is on.
185            dump_kdesc_if_requested(&descriptor, Some(&module));
186        }
187
188        let bindings = binding_assignments(&descriptor);
189        let dispatch_geometry = WgpuDispatchGeometry {
190            workgroup_size,
191            workgroups: static_workgroups(&descriptor, workgroup_size),
192        };
193        Ok(Self {
194            module,
195            bindings,
196            workgroup_size,
197            dispatch_geometry,
198        })
199    }
200}
201
202fn dump_kdesc_if_requested(
203    descriptor: &vyre_lower::KernelDescriptor,
204    module: Option<&naga::Module>,
205) {
206    if let Ok(dir) = std::env::var("VYRE_DUMP_KDESC")
207        .or_else(|_| std::env::var("VYRE_CAPTURE_FAILED_DESCRIPTOR"))
208    {
209        let path = std::path::Path::new(&dir);
210        if let Err(error) = std::fs::create_dir_all(path) {
211            tracing::warn!(
212                "Fix: failed to create WGPU descriptor dump directory `{}`: {error}",
213                path.display()
214            );
215            return;
216        }
217        let id = &descriptor.id;
218
219        let kdesc_path = path.join(format!("{id}.kdesc.bin"));
220        match std::fs::File::create(&kdesc_path) {
221            Ok(mut file) => {
222                if let Err(error) = bincode::serde::encode_into_std_write(
223                    descriptor,
224                    &mut file,
225                    bincode::config::standard(),
226                ) {
227                    tracing::warn!(
228                        "Fix: failed to serialize WGPU KernelDescriptor dump `{}`: {error}",
229                        kdesc_path.display()
230                    );
231                }
232            }
233            Err(error) => tracing::warn!(
234                "Fix: failed to create WGPU KernelDescriptor dump `{}`: {error}",
235                kdesc_path.display()
236            ),
237        }
238
239        if let Some(m) = module {
240            let module_path = path.join(format!("{id}.module.ron"));
241            match std::fs::File::create(&module_path) {
242                Ok(mut file) => {
243                    use std::io::Write;
244                    if let Err(error) = write!(file, "{m:#?}") {
245                        tracing::warn!(
246                            "Fix: failed to write WGPU Naga module dump `{}`: {error}",
247                            module_path.display()
248                        );
249                    }
250                }
251                Err(error) => tracing::warn!(
252                    "Fix: failed to create WGPU Naga module dump `{}`: {error}",
253                    module_path.display()
254                ),
255            }
256        }
257    }
258}
259
260impl WgpuBackend {
261    /// Lower core IR into the backend-owned wgpu IR.
262    pub fn lower_to_backend_ir(
263        &self,
264        program: &vyre_foundation::ir::Program,
265    ) -> Result<WgpuProgram, LoweringError> {
266        WgpuProgram::from_program(
267            program,
268            &vyre_driver::DispatchConfig::default(),
269            &self.enabled_features,
270        )
271    }
272
273    /// Borrow the Naga module produced by lowering ([`WgpuProgram::from_program`]).
274    ///
275    /// This avoids cloning the entire [`naga::Module`]; callers that need an owned
276    /// copy can call `.clone()` explicitly.
277    #[must_use]
278    pub fn lower_to_target<'a>(&self, bir: &'a WgpuProgram) -> &'a naga::Module {
279        &bir.module
280    }
281}
282
283fn write_wgsl(module: &naga::Module) -> Result<String, LoweringError> {
284    let mut validator = Validator::new(ValidationFlags::all(), Capabilities::all());
285    let info = match validator.validate(module) {
286        Ok(info) => info,
287        Err(e) => {
288            // VYRE_NAGA_LOWER MEDIUM: replace `println!` with
289            // structured tracing so shader constants and buffer
290            // layouts don't leak to application stdout. `trace!`
291            // level keeps the diagnostic available under
292            // `RUST_LOG=vyre_driver_wgpu=trace` without shipping
293            // it to normal logs.
294            if let Some(func) = module.functions.iter().next() {
295                tracing::trace!(
296                    target: "vyre_driver_wgpu::naga",
297                    function_expressions = ?func.1.expressions,
298                    "naga validation failed  -  function expressions",
299                );
300            }
301            if let Some(ep) = module.entry_points.first() {
302                tracing::trace!(
303                    target: "vyre_driver_wgpu::naga",
304                    entrypoint_expressions = ?ep.function.expressions,
305                    "naga validation failed  -  entrypoint expressions",
306                );
307                tracing::trace!(
308                    target: "vyre_driver_wgpu::naga",
309                    entrypoint_locals = ?ep.function.local_variables,
310                    "naga validation failed  -  entrypoint local variables",
311                );
312                tracing::trace!(
313                    target: "vyre_driver_wgpu::naga",
314                    entrypoint_body = ?ep.function.body,
315                    "naga validation failed  -  entrypoint body",
316                );
317            }
318            return Err(LoweringError::validation(e));
319        }
320    };
321    let wgsl =
322        naga::back::wgsl::write_string(module, &info, naga::back::wgsl::WriterFlags::empty())
323            .map_err(LoweringError::writer)?;
324    // Emission size cap (Task #65): adapter shader-binary-size limits
325    // are finite. At 1000+ fused arms WGSL source can exceed the
326    // ceiling. Fail-fast at write_wgsl with a clear diagnostic
327    // naming the byte count, instead of opaque pipeline-creation
328    // failure downstream. The 32 MiB cap below is the safe floor  -
329    // most adapters allow 256 MiB but Metal-on-iOS is the strictest.
330    // Production adapters report their limit via wgpu::Limits; if the
331    // FusionPlan partitioning harness is wired (Task #65 callers),
332    // it consults the adapter limit and partitions before reaching
333    // here. This guard is the last-line failsafe.
334    const MAX_WGSL_BYTES: usize = 32 * 1024 * 1024;
335    if wgsl.len() > MAX_WGSL_BYTES {
336        return Err(LoweringError::invalid(format!(
337            "emitted WGSL is {} bytes, exceeding the {MAX_WGSL_BYTES}-byte safety cap. Fix: partition the FusionPlan into multiple megakernels (group_a / group_b / ...) with shared standard pack, or split the source Program into smaller compilation units. Adapter shader-binary-size limits are finite at scale.",
338            wgsl.len()
339        )));
340    }
341    Ok(wgsl)
342}
343
344fn binding_assignments(descriptor: &vyre_lower::KernelDescriptor) -> Vec<WgpuBindingAssignment> {
345    let mut assignments = Vec::with_capacity(descriptor.bindings.slots.len());
346    for slot in &descriptor.bindings.slots {
347        let Some(group) = descriptor_bind_group(slot.memory_class) else {
348            continue;
349        };
350        assignments.push(WgpuBindingAssignment {
351            name: Arc::from(slot.name.as_str()),
352            group,
353            binding: slot.slot,
354            kind: descriptor_memory_kind(slot.memory_class),
355            access: descriptor_buffer_access(slot.visibility),
356            element: slot.element_type.clone(),
357        });
358    }
359    assignments
360}
361
362fn static_workgroups(
363    descriptor: &vyre_lower::KernelDescriptor,
364    workgroup_size: [u32; 3],
365) -> [u32; 3] {
366    let output_words = descriptor
367        .bindings
368        .slots
369        .iter()
370        .filter(|slot| {
371            matches!(slot.memory_class, vyre_lower::MemoryClass::Global)
372                && matches!(
373                    slot.visibility,
374                    vyre_lower::BindingVisibility::WriteOnly
375                        | vyre_lower::BindingVisibility::ReadWrite
376                )
377        })
378        .filter_map(|slot| slot.element_count)
379        .map(|count| count.max(1))
380        .max()
381        .unwrap_or(1);
382    // Use the product of all workgroup dimensions as total thread count.
383    // Previously only workgroup_size[0] was used, causing multi-dimensional
384    // workgroups (e.g. [8,8,1] = 64 threads) to over-dispatch by the
385    // product of the ignored dimensions.
386    let total_threads =
387        workgroup_size[0].max(1) * workgroup_size[1].max(1) * workgroup_size[2].max(1);
388    [output_words.div_ceil(total_threads).max(1), 1, 1]
389}
390
391fn format_descriptor_verify_errors(errors: &[vyre_lower::VerifyError]) -> String {
392    let mut out = String::new();
393    for (index, error) in errors.iter().take(4).enumerate() {
394        if index != 0 {
395            out.push_str("; ");
396        }
397        out.push_str(&format!("{error:?}"));
398    }
399    if errors.len() > 4 {
400        out.push_str("; ...");
401    }
402    out
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use vyre_foundation::ir::{BufferAccess, BufferDecl, DataType, Expr, Node, Program};
409
410    #[test]
411    fn wgpu_program_lowers_through_kernel_descriptor() {
412        let program = Program::wrapped(
413            vec![
414                BufferDecl::storage("out", 0, BufferAccess::ReadWrite, DataType::U32)
415                    .with_count(64),
416            ],
417            [1, 1, 1],
418            vec![Node::store("out", Expr::u32(0), Expr::u32(7))],
419        );
420        let mut config = vyre_driver::DispatchConfig::default();
421        config.workgroup_override = Some([32, 1, 1]);
422        let lowered = WgpuProgram::from_program(
423            &program,
424            &config,
425            &crate::runtime::device::EnabledFeatures::default(),
426        )
427        .expect("Fix: wgpu lowering must use descriptor Naga emission");
428
429        assert_eq!(lowered.workgroup_size, [32, 1, 1]);
430        assert_eq!(lowered.dispatch_geometry.workgroups, [2, 1, 1]);
431        assert_eq!(lowered.bindings.len(), 1);
432        assert_eq!(lowered.bindings[0].name.as_ref(), "out");
433        assert_eq!(lowered.bindings[0].group, 0);
434        assert_eq!(lowered.bindings[0].binding, 0);
435    }
436
437    #[test]
438    fn descriptor_binding_assignments_skip_non_resource_slots() {
439        let descriptor = vyre_lower::KernelDescriptor {
440            id: "bindings".into(),
441            bindings: vyre_lower::BindingLayout {
442                slots: vec![
443                    vyre_lower::BindingSlot {
444                        slot: 0,
445                        element_type: DataType::U32,
446                        element_count: Some(8),
447                        memory_class: vyre_lower::MemoryClass::Shared,
448                        visibility: vyre_lower::BindingVisibility::ReadWrite,
449                        name: "scratch".to_owned(),
450                    },
451                    vyre_lower::BindingSlot {
452                        slot: 1,
453                        element_type: DataType::U32,
454                        element_count: Some(8),
455                        memory_class: vyre_lower::MemoryClass::Global,
456                        visibility: vyre_lower::BindingVisibility::WriteOnly,
457                        name: "out".to_owned(),
458                    },
459                ],
460            },
461            dispatch: vyre_lower::Dispatch::new(8, 1, 1),
462            body: vyre_lower::KernelBody {
463                ops: vec![],
464                child_bodies: vec![],
465                literals: vec![],
466            },
467        };
468        let assignments = binding_assignments(&descriptor);
469        assert_eq!(assignments.len(), 1);
470        assert_eq!(assignments[0].name.as_ref(), "out");
471        assert_eq!(static_workgroups(&descriptor, [4, 1, 1]), [2, 1, 1]);
472    }
473
474    /// Regression test: multi-dimensional workgroup sizes must use the
475    /// product of all three dimensions as total thread count.
476    /// Before the fix, only `workgroup_size[0]` was used, so a
477    /// `[8, 8, 1]` workgroup (64 total threads) was treated as 8 threads,
478    /// dispatching 8× too many workgroups.
479    #[test]
480    fn static_workgroups_multi_dimensional_uses_total_threads() {
481        let descriptor = vyre_lower::KernelDescriptor {
482            id: "multidim".into(),
483            bindings: vyre_lower::BindingLayout {
484                slots: vec![vyre_lower::BindingSlot {
485                    slot: 0,
486                    element_type: DataType::U32,
487                    element_count: Some(256),
488                    memory_class: vyre_lower::MemoryClass::Global,
489                    visibility: vyre_lower::BindingVisibility::ReadWrite,
490                    name: "out".to_owned(),
491                }],
492            },
493            dispatch: vyre_lower::Dispatch::new(8, 8, 1),
494            body: vyre_lower::KernelBody {
495                ops: vec![],
496                child_bodies: vec![],
497                literals: vec![],
498            },
499        };
500        // [8, 8, 1] = 64 total threads → 256 / 64 = 4 workgroups
501        assert_eq!(static_workgroups(&descriptor, [8, 8, 1]), [4, 1, 1]);
502        // [4, 4, 4] = 64 total threads → 256 / 64 = 4 workgroups
503        assert_eq!(static_workgroups(&descriptor, [4, 4, 4]), [4, 1, 1]);
504        // [16, 1, 1] = 16 total threads → 256 / 16 = 16 workgroups
505        assert_eq!(static_workgroups(&descriptor, [16, 1, 1]), [16, 1, 1]);
506    }
507}