Skip to main content

ferrox_vulkan/
spirv.rs

1//! A minimal SPIR-V binary emitter: enough to write one compute shader
2//! by hand, and nothing more.
3//!
4//! # Why hand-emit SPIR-V
5//!
6//! The beachhead had to answer a question the roadmap asks explicitly:
7//! *can ferrox's shaders be built without a C++ toolchain at build
8//! time?* There are three ways to get SPIR-V into a Rust binary and
9//! only one of them says yes without qualification:
10//!
11//! 1. **`glslangValidator` / `glslc` in `build.rs`.** This is what
12//!    llama.cpp's Vulkan backend does (it builds a `vulkan-shaders-gen`
13//!    C++ program at configure time). It makes a C++ toolchain a build
14//!    prerequisite for every ferrox user who enables the feature, on
15//!    every platform, forever. `ferrox-cuda` deliberately refused the
16//!    equivalent (cudarc in dynamic-loading mode, no nvcc) and this
17//!    crate refuses it for the same reason.
18//! 2. **Commit a pre-built `.spv` blob.** No build dependency, but the
19//!    repo then carries an opaque binary that no reviewer can read and
20//!    that silently drifts from whatever source it was generated from.
21//!    This codebase's whole test culture is against that.
22//! 3. **Emit the words from Rust.** The shader is ordinary reviewable
23//!    Rust, there is no build step at all, and the emitter is small
24//!    enough to hold in your head. That is this file.
25//!
26//! The cost is stated honestly in the verdict: option 3 does not scale
27//! to a hundred kernels. It scales to *one*, which is exactly the size
28//! of a GO/NO-GO.
29//!
30//! # What this is not
31//!
32//! Not an SSA builder, not a validator, not a type deduplicator. It
33//! writes words in the order it is told to, into the five sections
34//! SPIR-V's logical layout requires. Getting the layout or the types
35//! wrong produces an invalid module, and the crate's answer to that is
36//! to run `spirv-val` over the emitted words in a test whenever the
37//! tool is on `PATH` (see `q8_0_matvec`'s tests).
38
39/// SPIR-V magic number (first word of every module).
40pub const MAGIC: u32 = 0x0723_0203;
41
42/// Target SPIR-V version, encoded `0 | major<<16 | minor<<8 | 0`.
43///
44/// **1.0 deliberately.** Vulkan 1.0 implementations are only required
45/// to accept SPIR-V 1.0, and the beachhead's whole point is reaching
46/// hardware ferrox cannot reach today -- which includes old Intel iGPUs
47/// and whatever Android ships. Staying at 1.0 costs the `StorageBuffer`
48/// storage class (this module uses the legacy `BufferBlock` + `Uniform`
49/// spelling instead) and buys the widest possible device set.
50pub const VERSION_1_0: u32 = 0x0001_0000;
51
52/// Generator magic. 0 is "unregistered"; tools accept it.
53pub const GENERATOR: u32 = 0;
54
55// --- opcodes, only the ones used ---------------------------------
56
57pub const OP_NAME: u16 = 5;
58pub const OP_MEMBER_NAME: u16 = 6;
59pub const OP_MEMORY_MODEL: u16 = 14;
60pub const OP_ENTRY_POINT: u16 = 15;
61pub const OP_EXECUTION_MODE: u16 = 16;
62pub const OP_CAPABILITY: u16 = 17;
63pub const OP_TYPE_VOID: u16 = 19;
64pub const OP_TYPE_BOOL: u16 = 20;
65pub const OP_TYPE_INT: u16 = 21;
66pub const OP_TYPE_FLOAT: u16 = 22;
67pub const OP_TYPE_VECTOR: u16 = 23;
68pub const OP_TYPE_RUNTIME_ARRAY: u16 = 29;
69pub const OP_TYPE_STRUCT: u16 = 30;
70pub const OP_TYPE_POINTER: u16 = 32;
71pub const OP_TYPE_FUNCTION: u16 = 33;
72pub const OP_CONSTANT: u16 = 43;
73pub const OP_FUNCTION: u16 = 54;
74pub const OP_FUNCTION_END: u16 = 56;
75pub const OP_VARIABLE: u16 = 59;
76pub const OP_LOAD: u16 = 61;
77pub const OP_STORE: u16 = 62;
78pub const OP_ACCESS_CHAIN: u16 = 65;
79pub const OP_DECORATE: u16 = 71;
80pub const OP_MEMBER_DECORATE: u16 = 72;
81pub const OP_COMPOSITE_EXTRACT: u16 = 81;
82pub const OP_CONVERT_S_TO_F: u16 = 111;
83pub const OP_CONVERT_U_TO_F: u16 = 112;
84pub const OP_BITCAST: u16 = 124;
85pub const OP_F_NEGATE: u16 = 127;
86pub const OP_I_ADD: u16 = 128;
87pub const OP_F_ADD: u16 = 129;
88pub const OP_I_SUB: u16 = 130;
89pub const OP_I_MUL: u16 = 132;
90pub const OP_F_MUL: u16 = 133;
91pub const OP_SELECT: u16 = 169;
92pub const OP_I_EQUAL: u16 = 170;
93pub const OP_I_NOT_EQUAL: u16 = 171;
94pub const OP_U_LESS_THAN: u16 = 176;
95pub const OP_SHIFT_RIGHT_LOGICAL: u16 = 194;
96pub const OP_SHIFT_LEFT_LOGICAL: u16 = 196;
97pub const OP_BITWISE_OR: u16 = 197;
98pub const OP_BITWISE_XOR: u16 = 198;
99pub const OP_BITWISE_AND: u16 = 199;
100pub const OP_LOOP_MERGE: u16 = 246;
101pub const OP_SELECTION_MERGE: u16 = 247;
102pub const OP_LABEL: u16 = 248;
103pub const OP_BRANCH: u16 = 249;
104pub const OP_BRANCH_CONDITIONAL: u16 = 250;
105pub const OP_RETURN: u16 = 253;
106
107// --- enumerants, only the ones used ------------------------------
108
109pub const CAP_SHADER: u32 = 1;
110pub const ADDRESSING_LOGICAL: u32 = 0;
111pub const MEMORY_MODEL_GLSL450: u32 = 1;
112pub const EXEC_MODEL_GL_COMPUTE: u32 = 5;
113pub const EXEC_MODE_LOCAL_SIZE: u32 = 17;
114
115pub const SC_INPUT: u32 = 1;
116pub const SC_UNIFORM: u32 = 2;
117pub const SC_FUNCTION: u32 = 7;
118pub const SC_PUSH_CONSTANT: u32 = 9;
119
120pub const DEC_BLOCK: u32 = 2;
121pub const DEC_BUFFER_BLOCK: u32 = 3;
122pub const DEC_ARRAY_STRIDE: u32 = 6;
123pub const DEC_BUILTIN: u32 = 11;
124pub const DEC_BINDING: u32 = 33;
125pub const DEC_DESCRIPTOR_SET: u32 = 34;
126pub const DEC_OFFSET: u32 = 35;
127
128pub const BUILTIN_GLOBAL_INVOCATION_ID: u32 = 28;
129
130pub const NONE: u32 = 0;
131
132/// Where an instruction goes in SPIR-V's required logical layout.
133///
134/// The order of the variants is the order of the sections in the
135/// emitted module, and [`Builder::finish`] concatenates them in
136/// declaration order -- so a section cannot be emitted out of place by
137/// forgetting to sort.
138#[derive(Clone, Copy, PartialEq, Eq, Debug)]
139pub enum Section {
140    /// Capabilities, memory model, entry points, execution modes.
141    Prelude,
142    /// `OpName` / `OpMemberName`. Stripped by nothing here, but they
143    /// make `spirv-dis` output readable, which is the only way to
144    /// review a hand-built module.
145    Debug,
146    /// `OpDecorate` / `OpMemberDecorate`.
147    Annotations,
148    /// Types, constants, and every non-`Function` `OpVariable`.
149    Types,
150    /// Function definitions.
151    Code,
152}
153
154/// Accumulates SPIR-V words per section and hands out result ids.
155#[derive(Default)]
156pub struct Builder {
157    next_id: u32,
158    prelude: Vec<u32>,
159    debug: Vec<u32>,
160    annotations: Vec<u32>,
161    types: Vec<u32>,
162    code: Vec<u32>,
163}
164
165impl Builder {
166    pub fn new() -> Self {
167        Self {
168            next_id: 1,
169            ..Default::default()
170        }
171    }
172
173    /// A fresh result `<id>`. Ids start at 1; 0 is never valid.
174    pub fn id(&mut self) -> u32 {
175        let id = self.next_id;
176        self.next_id += 1;
177        id
178    }
179
180    fn section(&mut self, s: Section) -> &mut Vec<u32> {
181        match s {
182            Section::Prelude => &mut self.prelude,
183            Section::Debug => &mut self.debug,
184            Section::Annotations => &mut self.annotations,
185            Section::Types => &mut self.types,
186            Section::Code => &mut self.code,
187        }
188    }
189
190    /// Emit one instruction with no result id.
191    pub fn inst(&mut self, s: Section, op: u16, operands: &[u32]) {
192        let out = self.section(s);
193        push_inst(out, op, operands);
194    }
195
196    /// Emit one instruction whose operands are `[result_type,
197    /// result_id, ..rest]` and return `result_id`.
198    pub fn typed(&mut self, s: Section, op: u16, result_type: u32, rest: &[u32]) -> u32 {
199        let id = self.id();
200        let mut ops = vec![result_type, id];
201        ops.extend_from_slice(rest);
202        self.inst(s, op, &ops);
203        id
204    }
205
206    /// Emit one instruction whose only leading operand is its result id
207    /// (`OpTypeInt`, `OpLabel`, ...) and return that id.
208    pub fn result(&mut self, s: Section, op: u16, rest: &[u32]) -> u32 {
209        let id = self.id();
210        let mut ops = vec![id];
211        ops.extend_from_slice(rest);
212        self.inst(s, op, &ops);
213        id
214    }
215
216    /// Emit an instruction that carries a trailing SPIR-V literal
217    /// string (`OpName`, `OpEntryPoint`).
218    pub fn inst_str(&mut self, s: Section, op: u16, head: &[u32], text: &str, tail: &[u32]) {
219        let mut ops = head.to_vec();
220        ops.extend(encode_string(text));
221        ops.extend_from_slice(tail);
222        self.inst(s, op, &ops);
223    }
224
225    /// The finished module: header followed by every section in
226    /// layout order.
227    pub fn finish(self) -> Vec<u32> {
228        let mut out = Vec::with_capacity(
229            5 + self.prelude.len()
230                + self.debug.len()
231                + self.annotations.len()
232                + self.types.len()
233                + self.code.len(),
234        );
235        out.push(MAGIC);
236        out.push(VERSION_1_0);
237        out.push(GENERATOR);
238        // Bound: ids are `1..next_id`, and the bound is exclusive.
239        out.push(self.next_id);
240        out.push(0); // schema, reserved
241        out.extend_from_slice(&self.prelude);
242        out.extend_from_slice(&self.debug);
243        out.extend_from_slice(&self.annotations);
244        out.extend_from_slice(&self.types);
245        out.extend_from_slice(&self.code);
246        out
247    }
248}
249
250fn push_inst(out: &mut Vec<u32>, op: u16, operands: &[u32]) {
251    let word_count = operands.len() + 1;
252    assert!(
253        word_count < 0x1_0000,
254        "SPIR-V instruction longer than the 16-bit word count field"
255    );
256    out.push(((word_count as u32) << 16) | op as u32);
257    out.extend_from_slice(operands);
258}
259
260/// SPIR-V literal string: UTF-8, NUL-terminated, zero-padded to a whole
261/// number of little-endian words.
262pub fn encode_string(text: &str) -> Vec<u32> {
263    let mut bytes = text.as_bytes().to_vec();
264    bytes.push(0);
265    while !bytes.len().is_multiple_of(4) {
266        bytes.push(0);
267    }
268    // `as_chunks` rather than `chunks_exact`: the padding above makes
269    // the remainder provably empty, and clippy prefers the form that
270    // hands back fixed-size arrays.
271    let (words, rest) = bytes.as_chunks::<4>();
272    debug_assert!(rest.is_empty(), "padding loop left a partial word");
273    words.iter().map(|c| u32::from_le_bytes(*c)).collect()
274}
275
276/// The emitted words as the little-endian byte stream a `.spv` file
277/// holds, for handing to an external validator.
278pub fn to_bytes(words: &[u32]) -> Vec<u8> {
279    let mut out = Vec::with_capacity(words.len() * 4);
280    for w in words {
281        out.extend_from_slice(&w.to_le_bytes());
282    }
283    out
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn header_is_well_formed_and_bound_covers_every_id() {
292        let mut b = Builder::new();
293        let a = b.id();
294        let c = b.id();
295        assert_eq!((a, c), (1, 2));
296        let words = b.finish();
297        assert_eq!(words[0], MAGIC);
298        assert_eq!(words[1], VERSION_1_0);
299        assert_eq!(words[3], 3, "bound must be one past the largest id");
300        assert_eq!(words[4], 0);
301        assert_eq!(words.len(), 5, "no sections were written");
302    }
303
304    #[test]
305    fn instruction_word_count_is_operands_plus_one() {
306        let mut b = Builder::new();
307        b.inst(Section::Prelude, OP_CAPABILITY, &[CAP_SHADER]);
308        let words = b.finish();
309        assert_eq!(words[5], (2 << 16) | OP_CAPABILITY as u32);
310        assert_eq!(words[6], CAP_SHADER);
311    }
312
313    #[test]
314    fn sections_are_concatenated_in_layout_order_regardless_of_write_order() {
315        let mut b = Builder::new();
316        // Written backwards on purpose.
317        b.inst(Section::Code, OP_RETURN, &[]);
318        b.inst(Section::Annotations, OP_DECORATE, &[1, DEC_BLOCK]);
319        b.inst(Section::Prelude, OP_CAPABILITY, &[CAP_SHADER]);
320        let words = b.finish();
321        let ops: Vec<u16> = decode_opcodes(&words[5..]);
322        assert_eq!(ops, vec![OP_CAPABILITY, OP_DECORATE, OP_RETURN]);
323    }
324
325    #[test]
326    fn strings_are_nul_terminated_and_word_padded() {
327        // "main" is 4 bytes, so the NUL forces a second word.
328        assert_eq!(encode_string("main").len(), 2);
329        assert_eq!(encode_string("main")[1], 0);
330        // 3 bytes + NUL fits exactly one word.
331        assert_eq!(encode_string("abc").len(), 1);
332        assert_eq!(encode_string("abc")[0], 0x0063_6261);
333        assert_eq!(encode_string("").len(), 1);
334        assert_eq!(encode_string("")[0], 0);
335    }
336
337    #[test]
338    fn to_bytes_is_little_endian() {
339        assert_eq!(to_bytes(&[MAGIC]), vec![0x03, 0x02, 0x23, 0x07]);
340    }
341
342    /// Split a word stream into opcodes by walking the word counts.
343    /// Shared with `q8_0_matvec`'s structural tests.
344    pub(crate) fn decode_opcodes(words: &[u32]) -> Vec<u16> {
345        let mut ops = Vec::new();
346        let mut i = 0;
347        while i < words.len() {
348            let count = (words[i] >> 16) as usize;
349            assert!(count > 0, "zero-length instruction at word {i}");
350            ops.push((words[i] & 0xffff) as u16);
351            i += count;
352        }
353        assert_eq!(i, words.len(), "instruction stream overran the module");
354        ops
355    }
356}