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}