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