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