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        let module = match emit_naga_module_for_descriptor(&descriptor) {
158            Ok(module) => module,
159            Err(error) => {
160                if std::env::var("VYRE_CAPTURE_FAILED_DESCRIPTOR").is_ok() {
161                    dump_kdesc_if_requested(&descriptor, None);
162                }
163                return Err(error);
164            }
165        };
166
167        if std::env::var("VYRE_CAPTURE_FAILED_DESCRIPTOR").is_ok() {
168            // Also capture success if specifically requested, or just have it ready if WGSL writing fails downstream.
169            // Let's just capture it now, because from_program succeeds and WGSL writing might fail.
170            // Wait, we only want to capture on failure.
171            // Actually, we can just save it to a temporary location or just write it if the env var is set.
172            // The spec says "On dispatch failure... serialize in-flight".
173            // Since we don't know if WGSL will fail here, we can just proactively dump it if the feature is on.
174            dump_kdesc_if_requested(&descriptor, Some(&module));
175        }
176
177        let bindings = binding_assignments(&descriptor);
178        let dispatch_geometry = WgpuDispatchGeometry {
179            workgroup_size,
180            workgroups: static_workgroups(&descriptor, workgroup_size),
181        };
182        Ok(Self {
183            module,
184            bindings,
185            workgroup_size,
186            dispatch_geometry,
187        })
188    }
189}
190
191pub(crate) fn emit_naga_module_for_descriptor(
192    descriptor: &vyre_lower::KernelDescriptor,
193) -> Result<naga::Module, LoweringError> {
194    if let Err(errors) = vyre_lower::verify::verify(descriptor) {
195        return Err(LoweringError::invalid(format!(
196            "KernelDescriptor verification failed after wgpu workgroup selection: {}. Fix: keep DispatchConfig.workgroup_override within descriptor limits.",
197            format_descriptor_verify_errors(&errors)
198        )));
199    }
200    vyre_emit_naga::emit(descriptor).map_err(|error| {
201        LoweringError::invalid(format!(
202            "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."
203        ))
204    })
205}
206
207fn dump_kdesc_if_requested(
208    descriptor: &vyre_lower::KernelDescriptor,
209    module: Option<&naga::Module>,
210) {
211    if let Ok(dir) = std::env::var("VYRE_DUMP_KDESC")
212        .or_else(|_| std::env::var("VYRE_CAPTURE_FAILED_DESCRIPTOR"))
213    {
214        let path = std::path::Path::new(&dir);
215        if let Err(error) = std::fs::create_dir_all(path) {
216            tracing::warn!(
217                "Fix: failed to create WGPU descriptor dump directory `{}`: {error}",
218                path.display()
219            );
220            return;
221        }
222        let id = &descriptor.id;
223
224        let kdesc_path = path.join(format!("{id}.kdesc.bin"));
225        match std::fs::File::create(&kdesc_path) {
226            Ok(mut file) => {
227                if let Err(error) = bincode::serde::encode_into_std_write(
228                    descriptor,
229                    &mut file,
230                    bincode::config::standard(),
231                ) {
232                    tracing::warn!(
233                        "Fix: failed to serialize WGPU KernelDescriptor dump `{}`: {error}",
234                        kdesc_path.display()
235                    );
236                }
237            }
238            Err(error) => tracing::warn!(
239                "Fix: failed to create WGPU KernelDescriptor dump `{}`: {error}",
240                kdesc_path.display()
241            ),
242        }
243
244        if let Some(m) = module {
245            let module_path = path.join(format!("{id}.module.ron"));
246            match std::fs::File::create(&module_path) {
247                Ok(mut file) => {
248                    use std::io::Write;
249                    if let Err(error) = write!(file, "{m:#?}") {
250                        tracing::warn!(
251                            "Fix: failed to write WGPU Naga module dump `{}`: {error}",
252                            module_path.display()
253                        );
254                    }
255                }
256                Err(error) => tracing::warn!(
257                    "Fix: failed to create WGPU Naga module dump `{}`: {error}",
258                    module_path.display()
259                ),
260            }
261        }
262    }
263}
264
265impl WgpuBackend {
266    /// Lower core IR into the backend-owned wgpu IR.
267    pub fn lower_to_backend_ir(
268        &self,
269        program: &vyre_foundation::ir::Program,
270    ) -> Result<WgpuProgram, LoweringError> {
271        WgpuProgram::from_program(
272            program,
273            &vyre_driver::DispatchConfig::default(),
274            &self.enabled_features,
275        )
276    }
277
278    /// Borrow the Naga module produced by lowering ([`WgpuProgram::from_program`]).
279    ///
280    /// This avoids cloning the entire [`naga::Module`]; callers that need an owned
281    /// copy can call `.clone()` explicitly.
282    #[must_use]
283    pub fn lower_to_target<'a>(&self, bir: &'a WgpuProgram) -> &'a naga::Module {
284        &bir.module
285    }
286}
287
288fn write_wgsl(module: &naga::Module) -> Result<String, LoweringError> {
289    let mut validator = Validator::new(ValidationFlags::all(), Capabilities::all());
290    let info = match validator.validate(module) {
291        Ok(info) => info,
292        Err(e) => {
293            // VYRE_NAGA_LOWER MEDIUM: replace `println!` with
294            // structured tracing so shader constants and buffer
295            // layouts don't leak to application stdout. `trace!`
296            // level keeps the diagnostic available under
297            // `RUST_LOG=vyre_driver_wgpu=trace` without shipping
298            // it to normal logs.
299            if let Some(func) = module.functions.iter().next() {
300                tracing::trace!(
301                    target: "vyre_driver_wgpu::naga",
302                    function_expressions = ?func.1.expressions,
303                    "naga validation failed  -  function expressions",
304                );
305            }
306            if let Some(ep) = module.entry_points.first() {
307                tracing::trace!(
308                    target: "vyre_driver_wgpu::naga",
309                    entrypoint_expressions = ?ep.function.expressions,
310                    "naga validation failed  -  entrypoint expressions",
311                );
312                tracing::trace!(
313                    target: "vyre_driver_wgpu::naga",
314                    entrypoint_locals = ?ep.function.local_variables,
315                    "naga validation failed  -  entrypoint local variables",
316                );
317                tracing::trace!(
318                    target: "vyre_driver_wgpu::naga",
319                    entrypoint_body = ?ep.function.body,
320                    "naga validation failed  -  entrypoint body",
321                );
322            }
323            return Err(LoweringError::validation(e));
324        }
325    };
326    let wgsl =
327        naga::back::wgsl::write_string(module, &info, naga::back::wgsl::WriterFlags::empty())
328            .map_err(LoweringError::writer)?;
329    // Emission size cap (Task #65): adapter shader-binary-size limits
330    // are finite. At 1000+ fused arms WGSL source can exceed the
331    // ceiling. Fail-fast at write_wgsl with a clear diagnostic
332    // naming the byte count, instead of opaque pipeline-creation
333    // failure downstream. The 32 MiB cap below is the safe floor  -
334    // most adapters allow 256 MiB but Metal-on-iOS is the strictest.
335    // Production adapters report their limit via wgpu::Limits; if the
336    // FusionPlan partitioning harness is wired (Task #65 callers),
337    // it consults the adapter limit and partitions before reaching
338    // here. This guard is the last-line failsafe.
339    const MAX_WGSL_BYTES: usize = 32 * 1024 * 1024;
340    if wgsl.len() > MAX_WGSL_BYTES {
341        return Err(LoweringError::invalid(format!(
342            "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.",
343            wgsl.len()
344        )));
345    }
346    Ok(wgsl)
347}
348
349fn binding_assignments(descriptor: &vyre_lower::KernelDescriptor) -> Vec<WgpuBindingAssignment> {
350    let mut assignments = Vec::with_capacity(descriptor.bindings.slots.len());
351    for slot in &descriptor.bindings.slots {
352        let Some(group) = descriptor_bind_group(slot.memory_class) else {
353            continue;
354        };
355        assignments.push(WgpuBindingAssignment {
356            name: Arc::from(slot.name.as_str()),
357            group,
358            binding: slot.slot,
359            kind: descriptor_memory_kind(slot.memory_class),
360            access: descriptor_buffer_access(slot.visibility),
361            element: slot.element_type.clone(),
362        });
363    }
364    assignments
365}
366
367fn static_workgroups(
368    descriptor: &vyre_lower::KernelDescriptor,
369    workgroup_size: [u32; 3],
370) -> [u32; 3] {
371    let output_words = descriptor
372        .bindings
373        .slots
374        .iter()
375        .filter(|slot| {
376            matches!(slot.memory_class, vyre_lower::MemoryClass::Global)
377                && matches!(
378                    slot.visibility,
379                    vyre_lower::BindingVisibility::WriteOnly
380                        | vyre_lower::BindingVisibility::ReadWrite
381                )
382        })
383        .filter_map(|slot| slot.element_count)
384        .map(|count| count.max(1))
385        .max()
386        .unwrap_or(1);
387    // Use the product of all workgroup dimensions as total thread count.
388    // Previously only workgroup_size[0] was used, causing multi-dimensional
389    // workgroups (e.g. [8,8,1] = 64 threads) to over-dispatch by the
390    // product of the ignored dimensions.
391    let total_threads =
392        workgroup_size[0].max(1) * workgroup_size[1].max(1) * workgroup_size[2].max(1);
393    [output_words.div_ceil(total_threads).max(1), 1, 1]
394}
395
396fn format_descriptor_verify_errors(errors: &[vyre_lower::VerifyError]) -> String {
397    let mut out = String::new();
398    for (index, error) in errors.iter().take(4).enumerate() {
399        if index != 0 {
400            out.push_str("; ");
401        }
402        out.push_str(&format!("{error:?}"));
403    }
404    if errors.len() > 4 {
405        out.push_str("; ...");
406    }
407    out
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use vyre_foundation::ir::{BufferAccess, BufferDecl, DataType, Expr, Node, Program};
414    use vyre_lower::emit_adversarial_corpus::{self, EmitAdversarialBackend};
415
416    #[test]
417    fn wgpu_program_lowers_through_kernel_descriptor() {
418        let program = Program::wrapped(
419            vec![
420                BufferDecl::storage("out", 0, BufferAccess::ReadWrite, DataType::U32)
421                    .with_count(64),
422            ],
423            [1, 1, 1],
424            vec![Node::store("out", Expr::u32(0), Expr::u32(7))],
425        );
426        let mut config = vyre_driver::DispatchConfig::default();
427        config.workgroup_override = Some([32, 1, 1]);
428        let lowered = WgpuProgram::from_program(
429            &program,
430            &config,
431            &crate::runtime::device::EnabledFeatures::default(),
432        )
433        .expect("Fix: wgpu lowering must use descriptor Naga emission");
434
435        assert_eq!(lowered.workgroup_size, [32, 1, 1]);
436        assert_eq!(lowered.dispatch_geometry.workgroups, [2, 1, 1]);
437        assert_eq!(lowered.bindings.len(), 1);
438        assert_eq!(lowered.bindings[0].name.as_ref(), "out");
439        assert_eq!(lowered.bindings[0].group, 0);
440        assert_eq!(lowered.bindings[0].binding, 0);
441    }
442
443    #[test]
444    fn descriptor_binding_assignments_skip_non_resource_slots() {
445        let descriptor = vyre_lower::KernelDescriptor {
446            id: "bindings".into(),
447            bindings: vyre_lower::BindingLayout {
448                slots: vec![
449                    vyre_lower::BindingSlot {
450                        slot: 0,
451                        element_type: DataType::U32,
452                        element_count: Some(8),
453                        memory_class: vyre_lower::MemoryClass::Shared,
454                        visibility: vyre_lower::BindingVisibility::ReadWrite,
455                        name: "scratch".to_owned(),
456                    },
457                    vyre_lower::BindingSlot {
458                        slot: 1,
459                        element_type: DataType::U32,
460                        element_count: Some(8),
461                        memory_class: vyre_lower::MemoryClass::Global,
462                        visibility: vyre_lower::BindingVisibility::WriteOnly,
463                        name: "out".to_owned(),
464                    },
465                ],
466            },
467            dispatch: vyre_lower::Dispatch::new(8, 1, 1),
468            body: vyre_lower::KernelBody {
469                ops: vec![],
470                child_bodies: vec![],
471                literals: vec![],
472            },
473        };
474        let assignments = binding_assignments(&descriptor);
475        assert_eq!(assignments.len(), 1);
476        assert_eq!(assignments[0].name.as_ref(), "out");
477        assert_eq!(static_workgroups(&descriptor, [4, 1, 1]), [2, 1, 1]);
478    }
479
480    #[test]
481    fn adversarial_success_corpus_passes_wgpu_descriptor_emit_path() {
482        assert!(
483            emit_adversarial_corpus::required_backends().contains(&EmitAdversarialBackend::Wgpu),
484            "Fix: shared emit adversarial corpus must register WGPU as a required consumer."
485        );
486
487        for case in emit_adversarial_corpus::success_cases() {
488            let module =
489                emit_naga_module_for_descriptor(&case.descriptor).unwrap_or_else(|error| {
490                    panic!(
491                        "Fix: `{}` ({:?}) must pass WGPU descriptor emission: {}",
492                        case.id,
493                        case.family,
494                        error.message()
495                    )
496                });
497            assert_eq!(
498                module.entry_points[0].name, "main",
499                "{}: WGPU descriptor path must preserve compute entry point",
500                case.id
501            );
502            assert_eq!(
503                module.entry_points[0].workgroup_size, case.descriptor.dispatch.workgroup_size,
504                "{}: WGPU descriptor path must preserve workgroup size before adapter override",
505                case.id
506            );
507            assert!(
508                binding_assignments(&case.descriptor).len() <= case.descriptor.bindings.slots.len(),
509                "{}: WGPU binding assignment projection must not invent resource slots",
510                case.id
511            );
512            assert!(
513                static_workgroups(&case.descriptor, case.descriptor.dispatch.workgroup_size)[0]
514                    >= 1,
515                "{}: WGPU static dispatch geometry must produce at least one workgroup",
516                case.id
517            );
518        }
519    }
520
521    #[test]
522    fn adversarial_rejection_corpus_returns_structured_wgpu_errors() {
523        for case in emit_adversarial_corpus::rejection_cases() {
524            let error = emit_naga_module_for_descriptor(&case.descriptor)
525                .expect_err("Fix: rejection corpus case must fail WGPU descriptor emission");
526            assert!(
527                error.message().contains("KernelDescriptor") && error.message().contains("Fix:"),
528                "Fix: `{}` WGPU descriptor rejection must include structured KernelDescriptor repair text: {}",
529                case.id,
530                error.message()
531            );
532        }
533    }
534
535    /// Regression test: multi-dimensional workgroup sizes must use the
536    /// product of all three dimensions as total thread count.
537    /// Before the fix, only `workgroup_size[0]` was used, so a
538    /// `[8, 8, 1]` workgroup (64 total threads) was treated as 8 threads,
539    /// dispatching 8× too many workgroups.
540    #[test]
541    fn static_workgroups_multi_dimensional_uses_total_threads() {
542        let descriptor = vyre_lower::KernelDescriptor {
543            id: "multidim".into(),
544            bindings: vyre_lower::BindingLayout {
545                slots: vec![vyre_lower::BindingSlot {
546                    slot: 0,
547                    element_type: DataType::U32,
548                    element_count: Some(256),
549                    memory_class: vyre_lower::MemoryClass::Global,
550                    visibility: vyre_lower::BindingVisibility::ReadWrite,
551                    name: "out".to_owned(),
552                }],
553            },
554            dispatch: vyre_lower::Dispatch::new(8, 8, 1),
555            body: vyre_lower::KernelBody {
556                ops: vec![],
557                child_bodies: vec![],
558                literals: vec![],
559            },
560        };
561        // [8, 8, 1] = 64 total threads → 256 / 64 = 4 workgroups
562        assert_eq!(static_workgroups(&descriptor, [8, 8, 1]), [4, 1, 1]);
563        // [4, 4, 4] = 64 total threads → 256 / 64 = 4 workgroups
564        assert_eq!(static_workgroups(&descriptor, [4, 4, 4]), [4, 1, 1]);
565        // [16, 1, 1] = 16 total threads → 256 / 16 = 16 workgroups
566        assert_eq!(static_workgroups(&descriptor, [16, 1, 1]), [16, 1, 1]);
567    }
568}