Skip to main content

vyre_emit_spirv/
lib.rs

1#![allow(
2    clippy::doc_lazy_continuation,
3    clippy::double_must_use,
4    clippy::manual_div_ceil,
5    clippy::needless_range_loop,
6    clippy::collapsible_if,
7    clippy::match_like_matches_macro,
8    clippy::redundant_closure,
9    clippy::too_many_arguments,
10    clippy::nonminimal_bool,
11    clippy::derivable_impls
12)]
13//! SPIR-V binary emitter for vyre `KernelDescriptor`.
14//!
15//! Substrate parity strategy: route the descriptor through
16//! `vyre-emit-naga` to get a `naga::Module`, then use
17//! `naga::back::spv::Writer` to produce a SPIR-V binary. This shares
18//! the lossless lowering work with the wgpu/naga path  -  both backends
19//! see the exact same naga::Module  -  and avoids forking a second
20//! KernelOp → SPIR-V translation table.
21//!
22//! ## Op coverage
23//!
24//! Inherits from `vyre-emit-naga`. Anything that emit-naga refuses
25//! also fails here. Anything emit-naga accepts gets converted to a
26//! valid SPIR-V binary if naga's spv-out can lower it (essentially
27//! everything except SPIR-V-specific extensions naga doesn't model).
28//!
29//! ## Validation gate
30//!
31//! `naga::valid::Validator` runs before `naga::back::spv::Writer`, so
32//! any invalid module is rejected at the boundary. The emitted SPIR-V
33//! binary is guaranteed to satisfy SPIR-V's structural requirements
34//! per naga's spec compliance. Optional external `spirv-val` validation
35//! sits in the integration-test surface (added when CI has spirv-tools).
36
37use thiserror::Error;
38use vyre_lower::KernelDescriptor;
39
40/// SPIR-V anchor-DFA offload evidence.
41pub mod anchor_dfa_offload;
42pub mod patterns;
43
44pub use anchor_dfa_offload::{
45    SpirvAnchorDfaOffloadEvidence, SPIRV_ANCHOR_DFA_OFFLOAD_SCHEMA_VERSION,
46};
47
48#[derive(Debug, Error)]
49pub enum EmitError {
50    #[error("naga emission failed: {0}")]
51    NagaEmit(#[from] vyre_emit_naga::EmitError),
52
53    #[error("naga validation failed: {0}")]
54    NagaValidation(String),
55
56    #[error("SPIR-V writer construction failed: {0}")]
57    WriterConstruction(String),
58
59    #[error("SPIR-V writer.write failed: {0}")]
60    WriterWrite(String),
61}
62
63/// Emit a SPIR-V binary from a `KernelDescriptor`.
64///
65/// Returns the raw SPIR-V words as a `Vec<u32>` (the canonical SPIR-V
66/// representation per the spec). Callers that need bytes can convert
67/// via `bytemuck::cast_slice` or by writing each word as little-endian
68/// (SPIR-V is host-endian per spec but most consumers expect LE).
69///
70/// Use [`emit_optimized`] to run the `vyre_lower::rewrites::run_all`
71/// pipeline before emission.
72pub fn emit(desc: &KernelDescriptor) -> Result<Vec<u32>, EmitError> {
73    let module = vyre_emit_naga::emit(desc).map_err(EmitError::NagaEmit)?;
74    emit_from_naga_module(&module)
75}
76
77/// Emit a SPIR-V binary from an optimized form of `desc`  -  runs the
78/// full vyre rewrite stack before lowering. Recommended over [`emit`]
79/// for production use.
80pub fn emit_optimized(desc: &KernelDescriptor) -> Result<Vec<u32>, EmitError> {
81    emit_optimized_with_stats(desc).map(|(w, _)| w)
82}
83
84/// Like [`emit_optimized`] but also returns
85/// [`vyre_lower::rewrites::OptimizationStats`].
86pub fn emit_optimized_with_stats(
87    desc: &KernelDescriptor,
88) -> Result<(Vec<u32>, vyre_lower::rewrites::OptimizationStats), EmitError> {
89    // Verify the INPUT descriptor before the rewrite pipeline. Each rewrite
90    // pass assumes a valid descriptor and, in debug builds, `assert!`s validity
91    // after every pass; an already-invalid descriptor would panic inside a pass
92    // rather than surface as a structured error. Fail closed here so invalid
93    // input is rejected identically in debug and release, before any pass runs.
94    vyre_lower::verify::verify(desc).map_err(|errors| {
95        EmitError::NagaEmit(vyre_emit_naga::EmitError::InvalidDescriptor(format!(
96            "invalid descriptor: input failed verification before optimization. {} error(s). \
97             Fix: see vyre_lower::verify for the invariants the descriptor violated. \
98             First error: {:?}",
99            errors.len(),
100            errors.first()
101        )))
102    })?;
103    let (optimized, stats) = vyre_lower::rewrites::run_all_with_stats(desc);
104    // Unconditionally verify the rewrite pipeline output. A `debug_assert!`
105    // here silently skips this gate in release builds, where a buggy rewrite
106    // could produce an invalid descriptor that proceeds to SPIR-V emission and
107    // yields a binary with semantics different from the original, a
108    // silently-wrong result with no diagnostics. Fail closed instead.
109    vyre_lower::verify::verify(&optimized).map_err(|errors| {
110        EmitError::NagaEmit(vyre_emit_naga::EmitError::InvalidDescriptor(format!(
111            "rewrite pipeline produced an invalid descriptor: {} error(s). \
112             Fix: see vyre_lower::verify for the invariants the rewrite violated. \
113             First error: {:?}",
114            errors.len(),
115            errors.first()
116        )))
117    })?;
118    let words = emit(&optimized)?;
119    Ok((words, stats))
120}
121
122/// Lower-level entry: emit SPIR-V from a pre-built naga::Module.
123/// Useful when callers want to apply naga-level analyses or rewrites
124/// between `vyre-emit-naga::emit` and SPIR-V conversion.
125pub fn emit_from_naga_module(module: &naga::Module) -> Result<Vec<u32>, EmitError> {
126    use naga::back::spv::{Options, PipelineOptions, Writer, WriterFlags};
127    use naga::valid::{Capabilities, ValidationFlags, Validator};
128
129    let mut validator = Validator::new(ValidationFlags::all(), Capabilities::all());
130    let info = validator
131        .validate(module)
132        .map_err(|e| EmitError::NagaValidation(format!("{e:?}")))?;
133
134    let options = Options {
135        lang_version: (1, 3),
136        capabilities: None,
137        flags: WriterFlags::empty(),
138        binding_map: Default::default(),
139        zero_initialize_workgroup_memory:
140            naga::back::spv::ZeroInitializeWorkgroupMemoryMode::Polyfill,
141        force_loop_bounding: true,
142        bounds_check_policies: naga::proc::BoundsCheckPolicies::default(),
143        debug_info: None,
144    };
145    let pipeline = PipelineOptions {
146        shader_stage: naga::ShaderStage::Compute,
147        entry_point: "main".to_string(),
148    };
149
150    let mut writer = Writer::new(&options).map_err(|e| {
151        EmitError::WriterConstruction(format!(
152            "{e:?}. Fix: upgrade naga or relax spv-out feature flags."
153        ))
154    })?;
155    let mut out = Vec::new();
156    writer
157        .write(module, &info, Some(&pipeline), &None, &mut out)
158        .map_err(|e| EmitError::WriterWrite(format!("{e:?}")))?;
159    Ok(out)
160}
161
162/// Convenience: emit SPIR-V as raw little-endian bytes (the form most
163/// runtime loaders accept directly).
164pub fn emit_bytes(desc: &KernelDescriptor) -> Result<Vec<u8>, EmitError> {
165    words_to_le_bytes(emit(desc)?)
166}
167
168/// Like [`emit_bytes`] but runs the optimization pipeline first.
169/// Recommended for production loaders that want minimal SPIR-V binary
170/// size + already-optimized contents.
171pub fn emit_optimized_bytes(desc: &KernelDescriptor) -> Result<Vec<u8>, EmitError> {
172    words_to_le_bytes(emit_optimized(desc)?)
173}
174
175/// Combined optimization + bytes + stats.
176pub fn emit_optimized_bytes_with_stats(
177    desc: &KernelDescriptor,
178) -> Result<(Vec<u8>, vyre_lower::rewrites::OptimizationStats), EmitError> {
179    let (words, stats) = emit_optimized_with_stats(desc)?;
180    Ok((words_to_le_bytes(words)?, stats))
181}
182
183fn words_to_le_bytes(words: Vec<u32>) -> Result<Vec<u8>, EmitError> {
184    let mut bytes = Vec::with_capacity(words.len() * 4);
185    for word in words {
186        bytes.extend_from_slice(&word.to_le_bytes());
187    }
188    Ok(bytes)
189}
190
191/// SPIR-V magic number  -  `0x07230203` per the spec. Useful for
192/// integration tests and consumer-side sanity checks.
193pub const SPIRV_MAGIC: u32 = 0x07230203;
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use vyre_emit_naga::vyre_lower;
199    use vyre_foundation::ir::DataType;
200    use vyre_lower::{
201        BindingLayout, BindingSlot, BindingVisibility, Dispatch, KernelBody, KernelDescriptor,
202        KernelOp, KernelOpKind, LiteralValue, MemoryClass,
203    };
204
205    fn one_store_kernel() -> KernelDescriptor {
206        KernelDescriptor {
207            id: "store_one".into(),
208            bindings: BindingLayout {
209                slots: vec![BindingSlot {
210                    slot: 0,
211                    element_type: DataType::U32,
212                    element_count: None,
213                    memory_class: MemoryClass::Global,
214                    visibility: BindingVisibility::ReadWrite,
215                    name: "out".into(),
216                }],
217            },
218            dispatch: Dispatch::new(64, 1, 1),
219            body: KernelBody {
220                ops: vec![
221                    KernelOp {
222                        kind: KernelOpKind::Literal,
223                        operands: vec![0],
224                        result: Some(0),
225                    },
226                    KernelOp {
227                        kind: KernelOpKind::Literal,
228                        operands: vec![1],
229                        result: Some(1),
230                    },
231                    KernelOp {
232                        kind: KernelOpKind::StoreGlobal,
233                        operands: vec![0, 0, 1],
234                        result: None,
235                    },
236                ],
237                child_bodies: vec![],
238                literals: vec![LiteralValue::U32(0), LiteralValue::U32(7)],
239            },
240        }
241    }
242
243    #[test]
244    fn empty_kernel_emits_valid_spirv_with_magic_header() {
245        let desc = KernelDescriptor {
246            id: "empty".into(),
247            bindings: BindingLayout { slots: vec![] },
248            dispatch: Dispatch::new(64, 1, 1),
249            body: KernelBody {
250                ops: vec![],
251                child_bodies: vec![],
252                literals: vec![],
253            },
254        };
255        let words = emit(&desc).unwrap();
256        assert!(!words.is_empty());
257        assert_eq!(
258            words[0], SPIRV_MAGIC,
259            "first word must be the SPIR-V magic number"
260        );
261    }
262
263    #[test]
264    fn one_store_kernel_emits_non_trivial_spirv() {
265        let words = emit(&one_store_kernel()).unwrap();
266        assert!(
267            words.len() > 16,
268            "real kernel should produce more than the header"
269        );
270        assert_eq!(words[0], SPIRV_MAGIC);
271    }
272
273    #[test]
274    fn emit_bytes_matches_words_in_le() {
275        let desc = KernelDescriptor {
276            id: "empty".into(),
277            bindings: BindingLayout { slots: vec![] },
278            dispatch: Dispatch::new(64, 1, 1),
279            body: KernelBody {
280                ops: vec![],
281                child_bodies: vec![],
282                literals: vec![],
283            },
284        };
285        let words = emit(&desc).unwrap();
286        let bytes = emit_bytes(&desc).unwrap();
287        assert_eq!(bytes.len(), words.len() * 4);
288        let first_word = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
289        assert_eq!(first_word, SPIRV_MAGIC);
290    }
291
292    #[test]
293    fn emit_optimized_bytes_produces_valid_spirv() {
294        let desc = KernelDescriptor {
295            id: "ob".into(),
296            bindings: BindingLayout { slots: vec![] },
297            dispatch: Dispatch::new(64, 1, 1),
298            body: KernelBody {
299                ops: vec![],
300                child_bodies: vec![],
301                literals: vec![],
302            },
303        };
304        let bytes = emit_optimized_bytes(&desc).unwrap();
305        assert!(bytes.len() >= 4);
306        let first_word = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
307        assert_eq!(first_word, SPIRV_MAGIC);
308    }
309
310    #[test]
311    fn emit_optimized_bytes_with_stats_returns_both() {
312        let desc = KernelDescriptor {
313            id: "obs".into(),
314            bindings: BindingLayout { slots: vec![] },
315            dispatch: Dispatch::new(64, 1, 1),
316            body: KernelBody {
317                ops: vec![],
318                child_bodies: vec![],
319                literals: vec![],
320            },
321        };
322        let (bytes, stats) = emit_optimized_bytes_with_stats(&desc).unwrap();
323        assert!(bytes.len() >= 4);
324        let first_word = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
325        assert_eq!(first_word, SPIRV_MAGIC);
326        assert!(stats.iterations >= 1);
327    }
328
329    #[test]
330    fn emit_with_unsupported_op_propagates_naga_error() {
331        let desc = KernelDescriptor {
332            id: "bad".into(),
333            bindings: BindingLayout { slots: vec![] },
334            dispatch: Dispatch::new(1, 1, 1),
335            body: KernelBody {
336                ops: vec![KernelOp {
337                    kind: KernelOpKind::SubgroupReduce { op: vyre_lower::SubgroupReduceOp::Add },
338                    operands: vec![0],
339                    result: Some(0),
340                }],
341                child_bodies: vec![],
342                literals: vec![],
343            },
344        };
345        let r = emit(&desc);
346        assert!(matches!(r, Err(EmitError::NagaEmit(_))));
347    }
348
349    #[test]
350    fn binop_add_emits_valid_spirv() {
351        let kernel = KernelDescriptor {
352            id: "add".into(),
353            bindings: BindingLayout { slots: vec![] },
354            dispatch: Dispatch::new(1, 1, 1),
355            body: KernelBody {
356                ops: vec![
357                    KernelOp {
358                        kind: KernelOpKind::Literal,
359                        operands: vec![0],
360                        result: Some(0),
361                    },
362                    KernelOp {
363                        kind: KernelOpKind::Literal,
364                        operands: vec![1],
365                        result: Some(1),
366                    },
367                    KernelOp {
368                        kind: KernelOpKind::BinOpKind(vyre_foundation::ir::BinOp::Add),
369                        operands: vec![0, 1],
370                        result: Some(2),
371                    },
372                ],
373                child_bodies: vec![],
374                literals: vec![LiteralValue::U32(3), LiteralValue::U32(4)],
375            },
376        };
377        let words = emit(&kernel).unwrap();
378        assert_eq!(words[0], SPIRV_MAGIC);
379        assert!(words.len() > 16);
380    }
381
382    #[test]
383    fn spirv_magic_constant_matches_spec() {
384        assert_eq!(SPIRV_MAGIC, 0x0723_0203);
385    }
386
387    #[test]
388    fn emit_optimized_succeeds_and_produces_valid_spirv() {
389        let words = emit_optimized(&one_store_kernel()).unwrap();
390        assert_eq!(words[0], SPIRV_MAGIC);
391        assert!(words.len() > 16);
392    }
393
394    #[test]
395    fn emit_optimized_drops_dead_arithmetic() {
396        // Same shape  -  identity + absorbing zero → dead after run_all.
397        // Optimized SPIR-V should be no longer than raw.
398        use vyre_foundation::ir::BinOp as Bo;
399        let desc = KernelDescriptor {
400            id: "k".into(),
401            bindings: BindingLayout {
402                slots: vec![BindingSlot {
403                    slot: 0,
404                    element_type: DataType::U32,
405                    element_count: None,
406                    memory_class: MemoryClass::Global,
407                    visibility: BindingVisibility::ReadWrite,
408                    name: "buf".into(),
409                }],
410            },
411            dispatch: Dispatch::new(1, 1, 1),
412            body: KernelBody {
413                ops: vec![
414                    KernelOp {
415                        kind: KernelOpKind::Literal,
416                        operands: vec![0],
417                        result: Some(0),
418                    },
419                    KernelOp {
420                        kind: KernelOpKind::Literal,
421                        operands: vec![1],
422                        result: Some(1),
423                    },
424                    KernelOp {
425                        kind: KernelOpKind::BinOpKind(Bo::Add),
426                        operands: vec![1, 0],
427                        result: Some(2),
428                    },
429                    KernelOp {
430                        kind: KernelOpKind::BinOpKind(Bo::Mul),
431                        operands: vec![1, 0],
432                        result: Some(3),
433                    },
434                    KernelOp {
435                        kind: KernelOpKind::StoreGlobal,
436                        operands: vec![0, 0, 1],
437                        result: None,
438                    },
439                ],
440                child_bodies: vec![],
441                literals: vec![LiteralValue::U32(0), LiteralValue::U32(99)],
442            },
443        };
444        let raw = emit(&desc).unwrap();
445        let optimized = emit_optimized(&desc).unwrap();
446        assert!(
447            optimized.len() <= raw.len(),
448            "optimized SPIR-V ({} words) should not exceed raw ({} words)",
449            optimized.len(),
450            raw.len()
451        );
452    }
453
454    #[test]
455    fn emit_from_naga_module_independently_consumable() {
456        // Build a valid naga::Module via emit-naga, then convert.
457        let module = vyre_emit_naga::emit(&KernelDescriptor {
458            id: "k".into(),
459            bindings: BindingLayout { slots: vec![] },
460            dispatch: Dispatch::new(1, 1, 1),
461            body: KernelBody {
462                ops: vec![],
463                child_bodies: vec![],
464                literals: vec![],
465            },
466        })
467        .unwrap();
468        let words = emit_from_naga_module(&module).unwrap();
469        assert_eq!(words[0], SPIRV_MAGIC);
470    }
471
472    /// `emit_optimized` must return a structured error for a descriptor that
473    /// fails the post-rewrite verification gate. Previously `debug_assert!`
474    /// compiled to nothing in release builds, letting an invalid optimized
475    /// descriptor silently proceed to SPIR-V emission and produce a binary
476    /// with different semantics from the original. Replacing it with an
477    /// unconditional `?` propagation makes this an observable failure in
478    /// all build profiles.
479    #[test]
480    fn emit_optimized_errors_on_invalid_rewrite_output() {
481        // A zero workgroup dimension is preserved unchanged by the rewrite
482        // pipeline and always triggers VerifyErrorKind::DispatchZeroDim.
483        let bad = KernelDescriptor {
484            id: "zero_dim".into(),
485            bindings: BindingLayout { slots: vec![] },
486            dispatch: Dispatch::new(0, 1, 1),
487            body: KernelBody {
488                ops: vec![],
489                child_bodies: vec![],
490                literals: vec![],
491            },
492        };
493        let result = emit_optimized(&bad);
494        assert!(
495            result.is_err(),
496            "emit_optimized must return Err for a descriptor that fails post-rewrite verify; \
497             the old debug_assert! would silently proceed in release builds"
498        );
499        let err_msg = format!("{}", result.unwrap_err());
500        assert!(
501            err_msg.contains("invalid descriptor"),
502            "error message must mention 'invalid descriptor', got: {err_msg}"
503        );
504    }
505}