Skip to main content

ferrox_vulkan/
q8_0_reference.rs

1//! The scalar twin of [`crate::q8_0_shader`], and the byte→word packing
2//! both the twin and the real upload path share.
3//!
4//! # What this is for
5//!
6//! This repo's rule for a kernel it cannot run is the one every
7//! `unsafe` SIMD arm here already follows and that `ferrox-cuda`'s
8//! `mul_mm_ref` follows for CUDA: a scalar twin implementing *identical
9//! arithmetic*, held against an independent reference.
10//!
11//! [`matvec_reference`] is not "a Q8_0 matvec that should agree". It is
12//! a transcription of the shader: same word-indexed byte extraction,
13//! same integer f16 decode, same `(d * q) * x` term shape, same
14//! block-ascending then element-ascending accumulation order, same
15//! `row < rows` guard. Read it beside `emit_main`; a line here with no
16//! counterpart there is a bug in one of them.
17//!
18//! Two independent references check it, and neither shares any code
19//! with it:
20//!
21//! - [`f16_to_f32`] against the `half` crate, over **all 65,536** f16
22//!   bit patterns.
23//! - the whole matvec against `ferrox_quant::dequant_q8_0` followed by
24//!   a plain dot product -- a different unpacker, a different f16
25//!   decoder, and the same numbers.
26//!
27//! What that does *not* establish is that the emitted SPIR-V says what
28//! this file says; the two are hand-transcribed from each other. On a
29//! machine with a Vulkan driver, `crate::device` closes that gap by
30//! running the real shader and comparing. See the crate docs for
31//! whether that has happened.
32
33use crate::q8_0_shader::{BLOCK_BYTES, BLOCK_ELEMS};
34
35/// Pack raw bytes into the `uint[]` a storage buffer holds, zero-padding
36/// the tail.
37///
38/// A Q8_0 block is 34 bytes, so a row of an odd number of blocks is not
39/// 4-byte aligned and the *last* word of the buffer is usually partial.
40/// Both the twin and the device upload go through here so the padding
41/// cannot differ between them.
42pub fn pack_words(bytes: &[u8]) -> Vec<u32> {
43    let mut words = Vec::with_capacity(bytes.len().div_ceil(4));
44    for chunk in bytes.chunks(4) {
45        let mut w = [0u8; 4];
46        w[..chunk.len()].copy_from_slice(chunk);
47        words.push(u32::from_le_bytes(w));
48    }
49    words
50}
51
52/// `(w[k >> 2] >> ((k & 3) * 8)) & 0xff` -- the shader's byte read.
53#[inline]
54fn weight_byte(words: &[u32], k: usize) -> u32 {
55    (words[k >> 2] >> ((k & 3) * 8)) & 0xff
56}
57
58/// The shader's integer f16 decode, in Rust.
59///
60/// No `f16` type, no `half`: this is the same `OpSelect` chain
61/// [`crate::q8_0_shader::Kernel::decode_f16`] emits, so a mistake in
62/// either shows up as a disagreement with `half` in the tests below.
63pub fn f16_to_f32(h: u32) -> f32 {
64    let sign = h >> 15;
65    let exp = (h >> 10) & 0x1f;
66    let mant = h & 0x3ff;
67    let mant_hi = mant << 13;
68
69    let normal = f32::from_bits(((exp + 112) << 23) | mant_hi);
70    let subnormal = mant as f32 * f32::from_bits(0x3380_0000);
71    let inf_or_nan = f32::from_bits(0x7f80_0000 | mant_hi);
72
73    let magnitude = if exp == 0 {
74        subnormal
75    } else if exp == 31 {
76        inf_or_nan
77    } else {
78        normal
79    };
80    if sign != 0 {
81        -magnitude
82    } else {
83        magnitude
84    }
85}
86
87/// Host emulation of the Q8_0 matvec shader.
88///
89/// `weight_words` is [`pack_words`] applied to `rows * row_bytes` of
90/// GGUF Q8_0 data; `x` holds `n_blocks_per_row * 32` activations.
91/// Returns `rows` floats. `row_bytes` is passed rather than derived,
92/// mirroring the push-constant block.
93///
94/// This is a correctness reference, not a fast path.
95pub fn matvec_reference(
96    weight_words: &[u32],
97    x: &[f32],
98    rows: usize,
99    row_bytes: usize,
100    n_blocks_per_row: usize,
101) -> Vec<f32> {
102    let mut out = vec![0f32; rows];
103    for (row, y) in out.iter_mut().enumerate() {
104        // The shader's `row < rows` guard; every row inside the buffer
105        // is in range by construction, and the guard exists for the
106        // padding invocations a 64-wide workgroup dispatches.
107        let row_base = row * row_bytes;
108        let mut acc = 0f32;
109        for b in 0..n_blocks_per_row {
110            let off = row_base + b * BLOCK_BYTES;
111            let lo = weight_byte(weight_words, off);
112            let hi = weight_byte(weight_words, off + 1);
113            let scale = f16_to_f32(lo | (hi << 8));
114            let x_base = b * BLOCK_ELEMS;
115            let q_base = off + 2;
116            for j in 0..BLOCK_ELEMS {
117                let q_byte = weight_byte(weight_words, q_base + j);
118                // (b ^ 0x80) - 0x80, wrapping, is int8 sign extension.
119                let biased = (q_byte ^ 128).wrapping_sub(128);
120                let q = (biased as i32) as f32;
121                acc += scale * q * x[x_base + j];
122            }
123        }
124        *y = acc;
125    }
126    out
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    /// Deterministic, dependency-free values in `[-1, 1)`.
134    fn pseudo_random(seed: u64, n: usize) -> Vec<f32> {
135        let mut s = seed | 1;
136        (0..n)
137            .map(|_| {
138                s = s
139                    .wrapping_mul(6364136223846793005)
140                    .wrapping_add(1442695040888963407);
141                ((s >> 33) as u32 as f32 / u32::MAX as f32) * 2.0 - 1.0
142            })
143            .collect()
144    }
145
146    fn build_rows(rows: usize, cols: usize, seed: u64) -> (Vec<u8>, Vec<Vec<f32>>) {
147        let mut bytes = Vec::new();
148        let mut dense = Vec::new();
149        for r in 0..rows {
150            let row = pseudo_random(seed + r as u64 * 7919, cols);
151            bytes.extend(ferrox_quant::quantize_q8_0(&row));
152            dense.push(row);
153        }
154        (bytes, dense)
155    }
156
157    #[test]
158    fn f16_decode_matches_half_crate_on_every_bit_pattern() {
159        let mut checked = 0u32;
160        for bits in 0..=u16::MAX {
161            let want = half::f16::from_bits(bits).to_f32();
162            let got = f16_to_f32(bits as u32);
163            if want.is_nan() {
164                assert!(got.is_nan(), "0x{bits:04x}: expected NaN, got {got}");
165            } else {
166                assert_eq!(
167                    got.to_bits(),
168                    want.to_bits(),
169                    "0x{bits:04x}: {got} != {want}"
170                );
171            }
172            checked += 1;
173        }
174        assert_eq!(checked, 65_536);
175    }
176
177    #[test]
178    fn pack_words_zero_pads_a_partial_tail() {
179        assert_eq!(pack_words(&[1, 2, 3, 4]), vec![0x0403_0201]);
180        assert_eq!(pack_words(&[1, 2, 3]), vec![0x0003_0201]);
181        assert_eq!(pack_words(&[]), Vec::<u32>::new());
182        // A single Q8_0 block is 34 bytes -> 9 words, last one half full.
183        assert_eq!(pack_words(&[0u8; BLOCK_BYTES]).len(), 9);
184    }
185
186    /// The twin against an independent unpacker: `ferrox_quant`'s
187    /// `dequant_q8_0` plus a plain dot product. Both do the same
188    /// multiplications in the same order, so this is exact rather than
189    /// approximate; a tolerance here would hide a transcription error.
190    #[test]
191    fn reference_matches_ferrox_quant_dequant_then_dot() {
192        for (rows, blocks) in [(1usize, 1usize), (5, 3), (64, 8), (7, 2)] {
193            let cols = blocks * BLOCK_ELEMS;
194            let (bytes, _) = build_rows(rows, cols, 0xfe11 + rows as u64);
195            let row_bytes = blocks * BLOCK_BYTES;
196            assert_eq!(bytes.len(), rows * row_bytes);
197            let x = pseudo_random(0xa5a5, cols);
198            let got = matvec_reference(&pack_words(&bytes), &x, rows, row_bytes, blocks);
199
200            for (r, g) in got.iter().enumerate() {
201                let dequantized =
202                    ferrox_quant::dequant_q8_0(&bytes[r * row_bytes..(r + 1) * row_bytes]).unwrap();
203                let want: f32 = dequantized
204                    .iter()
205                    .zip(&x)
206                    .fold(0f32, |acc, (w, xv)| acc + w * xv);
207                assert_eq!(
208                    g.to_bits(),
209                    want.to_bits(),
210                    "rows={rows} blocks={blocks} row={r}: {g} != {want}"
211                );
212            }
213        }
214    }
215
216    /// The alignment case the whole byte-extraction design exists for:
217    /// with an odd block count `row_bytes` is 34 * odd, so every row
218    /// after the first starts at a byte offset that is not a multiple
219    /// of 4. Deleting the `(k & 3) * 8` shift makes this red while the
220    /// even-block cases stay green.
221    #[test]
222    fn reference_is_correct_when_rows_are_not_word_aligned() {
223        let blocks = 3;
224        let cols = blocks * BLOCK_ELEMS;
225        let row_bytes = blocks * BLOCK_BYTES;
226        assert_eq!(row_bytes % 4, 2, "this test needs a misaligned row stride");
227        let rows = 9;
228        let (bytes, _) = build_rows(rows, cols, 0x0dd0);
229        let x = pseudo_random(0x1234, cols);
230        let got = matvec_reference(&pack_words(&bytes), &x, rows, row_bytes, blocks);
231        for (r, g) in got.iter().enumerate() {
232            let dequantized =
233                ferrox_quant::dequant_q8_0(&bytes[r * row_bytes..(r + 1) * row_bytes]).unwrap();
234            let want: f32 = dequantized
235                .iter()
236                .zip(&x)
237                .fold(0f32, |acc, (w, xv)| acc + w * xv);
238            assert_eq!(g.to_bits(), want.to_bits(), "row {r}");
239        }
240    }
241
242    #[test]
243    fn sign_extension_covers_the_whole_int8_range() {
244        for v in 0..=255u32 {
245            let got = ((v ^ 128).wrapping_sub(128) as i32) as f32;
246            let want = (v as u8 as i8) as f32;
247            assert_eq!(got, want, "byte {v}");
248        }
249    }
250}