Skip to main content

ferrox_quant/
encode.rs

1//! Weight *encoders*: f32 in, GGUF block bytes out.
2//!
3//! The rest of this crate reads quantized blocks. This module is the
4//! only place that writes them, and today it writes exactly one format.
5//! That is not an oversight, it is the scope: llama.cpp's K-quant and
6//! IQ encoders do an iterative scale/min fit (and, for the IQ tiers, a
7//! lattice search), and a naive min/max encoder wearing a K-quant's
8//! name produces a file that loads and generates measurably worse text.
9//! `ferrox quantize` refuses every target this module cannot encode, by
10//! name.
11//!
12//! **Q8_0 here is byte-for-byte llama.cpp's `quantize_row_q8_0_ref`**,
13//! not merely "close enough". The arithmetic below is deliberately the
14//! same shape as the C, including the reciprocal multiply and the
15//! `a > b ? a : b` maximum, because the file this writes is meant to be
16//! indistinguishable from `llama-quantize --type Q8_0`'s. See
17//! `q8_0_matches_llama_cpp_quantize_row_q8_0_ref` for the golden.
18
19use half::f16;
20
21use crate::{Q8_0_BLOCK_BYTES, Q8_0_BLOCK_ELEMS};
22
23/// Encodes one Q8_0 block (exactly [`Q8_0_BLOCK_ELEMS`] values) and
24/// appends its [`Q8_0_BLOCK_BYTES`] bytes to `out`.
25///
26/// Every arithmetic choice here mirrors `ggml-quants.c`:
27///
28/// * `amax` is folded with `a > b ? a : b`, not Rust's `f32::max`.
29///   They differ on NaN -- `f32::max` returns the non-NaN operand,
30///   ggml's macro propagates it -- and a checkpoint with a NaN weight
31///   should produce llama.cpp's bytes, not politely different ones.
32/// * The scale is applied as a multiply by `1/d`, not a divide by `d`.
33///   `v * (1.0/d)` and `v / d` differ by an ulp for many inputs, and an
34///   ulp either side of `.5` is a different `roundf` result, so a
35///   divide here would disagree with llama.cpp on real weights.
36/// * `d == 0` (an all-zero block) yields the reciprocal `0.0`, so the
37///   stored scale is `+0.0` and every quant is 0. The obvious
38///   alternative, storing a scale of 1.0, dequantizes identically and
39///   is therefore invisible to every test that checks values -- and
40///   produces a file that differs from llama.cpp's in bytes.
41#[inline]
42pub fn encode_block_q8_0(block: &[f32; Q8_0_BLOCK_ELEMS], out: &mut Vec<u8>) {
43    let mut amax = 0f32;
44    for &v in block.iter() {
45        let av = v.abs();
46        // Deliberately not `amax.max(av)`: see the doc comment.
47        amax = if amax > av { amax } else { av };
48    }
49    let d = amax / 127.0;
50    let id = if d != 0.0 { 1.0 / d } else { 0.0 };
51    out.extend_from_slice(&f16::from_f32(d).to_le_bytes());
52    for &v in block.iter() {
53        // `as i8` saturates in Rust where C's float->int8 conversion is
54        // undefined out of range; |v * id| <= 127 + an ulp for every
55        // finite input, so the two agree wherever the C is defined, and
56        // this one has no UB where it is not.
57        out.push(((v * id).round() as i8) as u8);
58    }
59}
60
61/// Encodes a whole row (or any slice whose length is a multiple of
62/// [`Q8_0_BLOCK_ELEMS`]) into Q8_0 blocks, appending to `out`.
63///
64/// Returns `None` when `src.len()` is not a multiple of the block size.
65/// llama.cpp `assert`s the same condition and its Q8_0 path has no
66/// fallback type, so a row that cannot be tiled is a refusal, never a
67/// zero-padded block: padding changes the row length the reader
68/// computes from the shape, and the file would decode shifted.
69pub fn encode_row_q8_0(src: &[f32], out: &mut Vec<u8>) -> Option<()> {
70    let (blocks, rest) = src.as_chunks::<Q8_0_BLOCK_ELEMS>();
71    if !rest.is_empty() {
72        return None;
73    }
74    out.reserve(blocks.len() * Q8_0_BLOCK_BYTES);
75    for block in blocks {
76        encode_block_q8_0(block, out);
77    }
78    Some(())
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::dequant_q8_0;
85
86    /// Deterministic pseudo-random f32s in roughly the range real
87    /// weights occupy, from a 32-bit xorshift so the C harness that
88    /// produced the golden below can generate the identical input.
89    fn sample_input(n: usize) -> Vec<f32> {
90        let mut state: u32 = 0x1234_5678;
91        (0..n)
92            .map(|_| {
93                state ^= state << 13;
94                state ^= state >> 17;
95                state ^= state << 5;
96                // [-1, 1), 24 bits of mantissa.
97                ((state >> 8) as f32 / 8_388_608.0) - 1.0
98            })
99            .collect()
100    }
101
102    /// The golden: bytes produced by llama.cpp's own encoder for
103    /// `sample_input(64)`.
104    ///
105    /// Generated by linking `.scratch/llama.cpp/build/bin/libggml-base`
106    /// and calling the exported `quantize_row_q8_0_ref` on the same
107    /// input this test builds; the same C harness also calls
108    /// `ggml_quantize_chunk(GGML_TYPE_Q8_0, ...)` -- the entry point
109    /// `llama-quantize` itself goes through -- and asserts the two
110    /// agree, so this golden is what the real tool writes and not just
111    /// what a reference function does.
112    ///
113    /// An encoder that is merely *within Q8_0's error bound* passes a
114    /// tolerance test and still writes a different file; this is what
115    /// catches that.
116    const LLAMA_CPP_Q8_0_GOLDEN: [u8; 2 * Q8_0_BLOCK_BYTES] = [
117        0xdc, 0x1f, 0x08, 0x93, 0xc7, 0x02, 0xf0, 0xa8, 0x0a, 0x46, 0x55, 0xb9, 0xe9, 0xcd, 0x7f,
118        0xb4, 0x0d, 0x79, 0x4f, 0x71, 0x6a, 0xc0, 0xac, 0x6d, 0xa8, 0x51, 0x7a, 0x77, 0x2d, 0x42,
119        0x6c, 0xcc, 0x8e, 0x7a, 0xe9, 0x1f, 0x42, 0x4d, 0x18, 0x19, 0x05, 0x50, 0x66, 0xfa, 0xe8,
120        0x59, 0xf7, 0xc4, 0xac, 0x9c, 0xb4, 0xa8, 0xe9, 0x93, 0x17, 0x3f, 0xad, 0xef, 0x06, 0x4a,
121        0xf8, 0x3f, 0xa3, 0xea, 0x7f, 0x30, 0x3e, 0x8d,
122    ];
123
124    /// The property that makes `ferrox quantize`'s output a file
125    /// llama.cpp would have written, rather than one that merely
126    /// decodes to similar numbers.
127    #[test]
128    fn q8_0_matches_llama_cpp_quantize_row_q8_0_ref() {
129        let x = sample_input(2 * Q8_0_BLOCK_ELEMS);
130        let mut got = Vec::new();
131        encode_row_q8_0(&x, &mut got).unwrap();
132        assert_eq!(
133            got.as_slice(),
134            &LLAMA_CPP_Q8_0_GOLDEN[..],
135            "ferrox's Q8_0 encoder disagrees with llama.cpp's"
136        );
137    }
138
139    /// One block, as f16 bit patterns, on which `v * (1/d)` and `v / d`
140    /// round to DIFFERENT int8s -- exactly one of its 32 quants, 63
141    /// against 64.
142    ///
143    /// It exists because the obvious golden does not catch the
144    /// difference: over uniform f32 noise the two spellings agree for
145    /// at least 8192 consecutive values. Over real F16 weights they
146    /// disagree constantly -- f16's 11-bit mantissa lands on the `.5`
147    /// boundary far more often than f32's 24-bit one -- and quantizing
148    /// a 135M F16 checkpoint with the divide spelling gave a file whose
149    /// every one of 211 quantized tensors differed from
150    /// `llama-quantize`'s (9877 of token_embd's 30 MB, for instance).
151    ///
152    /// So this block is the small, checked-in stand-in for that whole
153    /// experiment. Found by scanning the same xorshift stream rounded
154    /// through f16 and scaled to where weights actually live.
155    const TIE_BLOCK_F16_BITS: [u16; Q8_0_BLOCK_ELEMS] = [
156        0xadf3, 0x247e, 0x28d0, 0x221a, 0xb017, 0x98ed, 0xa97d, 0x3010, 0xac34, 0x0c3e, 0x2cb8,
157        0x2bf9, 0xa7e5, 0xb0b8, 0x3030, 0xb00a, 0xac33, 0xac39, 0xac46, 0x2b78, 0x3007, 0xa5fe,
158        0x2feb, 0x30b2, 0x3033, 0xad15, 0xb046, 0x2cd7, 0xaff4, 0xaca6, 0x2c7c, 0xaf49,
159    ];
160
161    /// llama.cpp's bytes for [`TIE_BLOCK_F16_BITS`], from the same
162    /// harness (and again cross-checked against `ggml_quantize_chunk`).
163    const LLAMA_CPP_TIE_BLOCK_GOLDEN: [u8; Q8_0_BLOCK_BYTES] = [
164        0xc2, 0x14, 0xb0, 0x0f, 0x20, 0x0a, 0x92, 0xfe, 0xdb, 0x6d, 0xc7, 0x00, 0x3f, 0x36, 0xe5,
165        0x81, 0x71, 0x93, 0xc7, 0xc7, 0xc6, 0x32, 0x6c, 0xec, 0x6b, 0x7e, 0x71, 0xbc, 0x8d, 0x41,
166        0x95, 0xc1, 0x3c, 0x9e,
167    ];
168
169    /// The reciprocal multiply is not a micro-optimisation, it is what
170    /// llama.cpp does, and `v / d` rounds this block differently.
171    #[test]
172    fn the_scale_is_applied_as_llama_cpp_applies_it_not_as_a_division() {
173        let x: Vec<f32> = TIE_BLOCK_F16_BITS
174            .iter()
175            .map(|b| f16::from_bits(*b).to_f32())
176            .collect();
177        let mut got = Vec::new();
178        encode_row_q8_0(&x, &mut got).unwrap();
179        assert_eq!(got.as_slice(), &LLAMA_CPP_TIE_BLOCK_GOLDEN[..]);
180
181        // And the divide spelling really does differ here, so the test
182        // above is asserting something rather than restating an
183        // identity.
184        let amax = x
185            .iter()
186            .fold(0f32, |a, &b| if a > b.abs() { a } else { b.abs() });
187        let d = amax / 127.0;
188        let divided: Vec<u8> = x.iter().map(|v| ((v / d).round() as i8) as u8).collect();
189        assert_ne!(
190            divided.as_slice(),
191            &LLAMA_CPP_TIE_BLOCK_GOLDEN[2..],
192            "this block no longer distinguishes the two spellings"
193        );
194    }
195
196    /// An all-zero block stores a scale of +0.0, which is what
197    /// llama.cpp stores. A scale of 1.0 dequantizes identically, so
198    /// only a byte comparison catches it -- which is why this is its
199    /// own test and not a corollary of a value check.
200    #[test]
201    fn an_all_zero_block_stores_a_zero_scale_the_way_llama_cpp_does() {
202        let mut out = Vec::new();
203        encode_row_q8_0(&[0.0; Q8_0_BLOCK_ELEMS], &mut out).unwrap();
204        assert_eq!(out, vec![0u8; Q8_0_BLOCK_BYTES]);
205    }
206
207    /// A row whose length is not a whole number of blocks is refused,
208    /// not padded. Padding would write more elements than the tensor's
209    /// shape declares and every following row would decode shifted.
210    #[test]
211    fn a_row_that_is_not_a_whole_number_of_blocks_is_refused() {
212        let mut out = Vec::new();
213        assert!(encode_row_q8_0(&[0.5; Q8_0_BLOCK_ELEMS + 1], &mut out).is_none());
214        assert!(encode_row_q8_0(&[0.5; 1], &mut out).is_none());
215        assert!(encode_row_q8_0(&[], &mut out).is_some());
216    }
217
218    /// Round trip through this crate's own reader, bounded by Q8_0's
219    /// own arithmetic rather than by a tolerance picked to make the
220    /// test pass. One step is `d = amax/127`. Rounding to the nearest
221    /// step costs at most `d/2`. The scale is then stored as an f16,
222    /// whose half-ulp is `2^-11` relative, and that error is multiplied
223    /// by the quant, `|q| <= 127`. So the bound is
224    /// `d * (0.5 + 127 * 2^-11)`, about `0.562 d` -- and the observed
225    /// worst case over this input sits just above `0.5 d`, which is why
226    /// the f16 term is not optional.
227    ///
228    /// (A block whose `d` lands in f16's subnormal range would have a
229    /// larger relative scale error; `sample_input` is nowhere near it.)
230    #[test]
231    fn dequantizing_what_this_encodes_lands_within_a_quantization_step() {
232        let x = sample_input(8 * Q8_0_BLOCK_ELEMS);
233        let mut bytes = Vec::new();
234        encode_row_q8_0(&x, &mut bytes).unwrap();
235        let back = dequant_q8_0(&bytes).unwrap();
236        assert_eq!(back.len(), x.len());
237        for (block_i, (chunk, got)) in x
238            .chunks(Q8_0_BLOCK_ELEMS)
239            .zip(back.chunks(Q8_0_BLOCK_ELEMS))
240            .enumerate()
241        {
242            let amax = chunk.iter().fold(0f32, |a, &b| a.max(b.abs()));
243            let step = amax / 127.0;
244            let bound = step * (0.5 + 127.0 * 2f32.powi(-11));
245            for (i, (&want, &have)) in chunk.iter().zip(got.iter()).enumerate() {
246                assert!(
247                    (want - have).abs() <= bound,
248                    "block {block_i} element {i}: {want} -> {have}, error {} > {bound}",
249                    (want - have).abs()
250                );
251            }
252        }
253    }
254}