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