fuel_quantized/dequant.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Byte-to-`f32` dequantization dispatched on [`GgmlDType`], for callers holding a raw GGUF
3//! tensor byte slice and its declared dtype (the common shape at a model-loader's `from_gguf`
4//! site).
5//!
6//! Centralizes what used to be independently hand-rolled in 10 `fuel-transformers` model
7//! files (`dequant_bytes_to_f32` + `cpu_dequant_q4_0_bytes`, copy-pasted identically across
8//! `lazy_quantized_{llama,qwen2,qwen3,qwen3_moe,gemma3,glm4,lfm2,phi3,smollm3,t5}.rs`), each
9//! wiring only `F32`/`F16`/`BF16`/`Q4_0` and erroring on the other 10 `GgmlDType` variants —
10//! including `Q6_K`, llama.cpp's ordinary choice for `output.weight`/`token_embd.weight` even
11//! in an otherwise-`Q4_0` checkpoint, so a real-world GGUF file routinely hit the catch-all.
12//! The `Q4_0` arm those files DID wire was also a hand re-derivation of the block math,
13//! bypassing [`GgmlType::to_float`] entirely — so the tested implementation was not the one
14//! executing.
15//!
16//! 14 of the 15 [`GgmlDType`] variants are wired here. The one exclusion: `Q8_1` declines with
17//! a typed error citing GAP-125, because `BlockQ8_1::to_float` is `unimplemented!()` upstream
18//! (a real numerics gap in this crate, not a dispatch-layer one — wiring it here would only
19//! trade an `Err` for a panic).
20//!
21//! ⚠️ This centralization is not purely subtractive. The ten hand-rolled implementations it
22//! replaces were each numerically incomplete but memory-safe; the first version of the single
23//! function replacing them introduced a real soundness bug (an unaligned `&[u8]` cast to
24//! `&[T]` for a `T` needing alignment 2+ — see [`cast_and_dequant`]'s doc), caught before merge
25//! by review, not by any test in the original diff. Fixed with an alignment check, an
26//! alternate copy-to-`Vec<T>` path for the misaligned case, and a Miri-verified born-red.
27
28use crate::k_quants::{
29 BlockQ2K, BlockQ3K, BlockQ4_0, BlockQ4_1, BlockQ4K, BlockQ5_0, BlockQ5_1, BlockQ5K, BlockQ6K,
30 BlockQ8_0, BlockQ8K, GgmlType,
31};
32use fuel_ir::Result;
33use fuel_ir::quantized::GgmlDType;
34use half::{bf16, f16};
35
36/// Reinterpret `bytes` as a dense `[T]` array (GGUF's on-disk block layout) and dequantize via
37/// [`GgmlType::to_float`] — the tested implementation, not a re-derivation of its math.
38///
39/// `bytes.len()` need not be an exact multiple of `size_of::<T>()`; the remainder is silently
40/// dropped, matching every pre-existing per-model `cpu_dequant_*_bytes` this replaces.
41///
42/// # Alignment
43/// `bytes: &[u8]` guarantees only 1-byte alignment, while every `BlockQX` here contains an
44/// `f16`/`bf16`/`f32` field and so needs alignment 2 or 4. GGUF's *file* offsets being
45/// block-aligned says nothing about the *in-memory* address of a `Vec<u8>` a reader loaded
46/// those bytes into, or of a sub-slice taken at a byte offset within it — those two are
47/// different objects, and only the second is what `from_raw_parts` requires. When the pointer
48/// isn't aligned for `T`, this copies into a `Vec<T>` (whose allocator-provided memory *is*
49/// aligned for `T`) instead of casting the borrowed bytes directly.
50fn cast_and_dequant<T: GgmlType>(bytes: &[u8]) -> Vec<f32> {
51 // `blocks_from_bytes_cow` borrows when `bytes` is aligned for `T` and copies
52 // into an aligned `Vec<T>` otherwise. `T: Pod` (a supertrait of `GgmlType`)
53 // makes both paths safe code, where this function used to hand-write the
54 // `from_raw_parts` / `copy_nonoverlapping` + `set_len` pair and justify
55 // each in a comment.
56 let blocks = crate::k_quants::blocks_from_bytes_cow::<T>(bytes);
57 let mut out = vec![0.0_f32; blocks.len() * T::BLCK_SIZE];
58 T::to_float(&blocks, &mut out);
59 out
60}
61
62/// Dequantize a raw GGUF tensor byte slice of the given [`GgmlDType`] to `f32`.
63///
64/// `name` is used only to name the tensor in an error message on a malformed byte count for
65/// `F32`/`F16`/`BF16` (the three unpacked formats, checked exactly; the block formats silently
66/// drop a partial trailing block, matching prior per-model behavior).
67pub fn dequant_ggml_bytes(bytes: &[u8], dtype: GgmlDType, name: &str) -> Result<Vec<f32>> {
68 Ok(match dtype {
69 GgmlDType::F32 => {
70 if !bytes.len().is_multiple_of(4) {
71 return Err(fuel_ir::Error::Msg(format!(
72 "gguf {name}: F32 byte count {} not a multiple of 4",
73 bytes.len(),
74 ))
75 .bt());
76 }
77 bytes
78 .as_chunks::<4>()
79 .0
80 .iter()
81 .map(|c| f32::from_le_bytes(*c))
82 .collect()
83 }
84 GgmlDType::F16 => {
85 if !bytes.len().is_multiple_of(2) {
86 return Err(fuel_ir::Error::Msg(format!(
87 "gguf {name}: F16 byte count {} not a multiple of 2",
88 bytes.len(),
89 ))
90 .bt());
91 }
92 bytes
93 .as_chunks::<2>()
94 .0
95 .iter()
96 .map(|c| f16::from_le_bytes(*c).to_f32())
97 .collect()
98 }
99 GgmlDType::BF16 => {
100 if !bytes.len().is_multiple_of(2) {
101 return Err(fuel_ir::Error::Msg(format!(
102 "gguf {name}: BF16 byte count {} not a multiple of 2",
103 bytes.len(),
104 ))
105 .bt());
106 }
107 bytes
108 .as_chunks::<2>()
109 .0
110 .iter()
111 .map(|c| bf16::from_le_bytes(*c).to_f32())
112 .collect()
113 }
114 GgmlDType::Q4_0 => cast_and_dequant::<BlockQ4_0>(bytes),
115 GgmlDType::Q4_1 => cast_and_dequant::<BlockQ4_1>(bytes),
116 GgmlDType::Q5_0 => cast_and_dequant::<BlockQ5_0>(bytes),
117 GgmlDType::Q5_1 => cast_and_dequant::<BlockQ5_1>(bytes),
118 GgmlDType::Q8_0 => cast_and_dequant::<BlockQ8_0>(bytes),
119 // NOT wired: `BlockQ8_1::to_float` is `unimplemented!()` upstream (GAP-125) -- this
120 // is the one dtype this crate cannot dequantize today, a real numerics gap, not a
121 // dispatch-layer one like the other 9 this function fixes. Calling `to_float` would
122 // panic (CLAUDE.md: never panic on a production path), so decline with a typed error
123 // that names the real cause instead.
124 GgmlDType::Q8_1 => {
125 return Err(fuel_ir::Error::Msg(format!(
126 "gguf {name}: Q8_1 dequantization is unimplemented upstream (GAP-125) -- \
127 BlockQ8_1::to_float has no body",
128 ))
129 .bt());
130 }
131 GgmlDType::Q2K => cast_and_dequant::<BlockQ2K>(bytes),
132 GgmlDType::Q3K => cast_and_dequant::<BlockQ3K>(bytes),
133 GgmlDType::Q4K => cast_and_dequant::<BlockQ4K>(bytes),
134 GgmlDType::Q5K => cast_and_dequant::<BlockQ5K>(bytes),
135 GgmlDType::Q6K => cast_and_dequant::<BlockQ6K>(bytes),
136 GgmlDType::Q8K => cast_and_dequant::<BlockQ8K>(bytes),
137 GgmlDType::Iq4Nl => cast_and_dequant::<crate::iq4::BlockIq4Nl>(bytes),
138 GgmlDType::Iq4Xs => cast_and_dequant::<crate::iq4::BlockIq4Xs>(bytes),
139 GgmlDType::Iq2Xxs => cast_and_dequant::<crate::iq_grid_types::BlockIq2Xxs>(bytes),
140 GgmlDType::Iq2Xs => cast_and_dequant::<crate::iq_grid_types::BlockIq2Xs>(bytes),
141 GgmlDType::Iq3Xxs => cast_and_dequant::<crate::iq_grid_types::BlockIq3Xxs>(bytes),
142 GgmlDType::Iq1S => cast_and_dequant::<crate::iq_grid_types::BlockIq1S>(bytes),
143 GgmlDType::Iq3S => cast_and_dequant::<crate::iq_grid_types::BlockIq3S>(bytes),
144 GgmlDType::Iq2S => cast_and_dequant::<crate::iq_grid_types::BlockIq2S>(bytes),
145 GgmlDType::Iq1M => cast_and_dequant::<crate::iq_grid_types::BlockIq1M>(bytes),
146 })
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn f32_round_trips() {
155 let original: Vec<f32> = vec![1.0, -2.5, 3.25, 0.0];
156 let bytes: Vec<u8> = original.iter().flat_map(|v| v.to_le_bytes()).collect();
157 let out = dequant_ggml_bytes(&bytes, GgmlDType::F32, "t").unwrap();
158 assert_eq!(out, original);
159 }
160
161 #[test]
162 fn f32_rejects_a_misaligned_byte_count() {
163 let bytes = vec![0_u8; 3];
164 let err = dequant_ggml_bytes(&bytes, GgmlDType::F32, "t").unwrap_err();
165 assert!(err.to_string().contains("not a multiple of 4"));
166 }
167
168 /// Every wired block-quant variant must go through `to_float`, not silently return zeros
169 /// or error — this is the born-red for the centralization: previously every one of these
170 /// except Q4_0 hit a catch-all `Err` in the per-model loaders this function replaces.
171 /// Q8_1 is deliberately excluded — see `q8_1_declines_with_a_typed_error_not_a_panic`.
172 #[test]
173 fn every_wired_block_quant_variant_dequantizes_without_error() {
174 for dtype in [
175 GgmlDType::Q4_0,
176 GgmlDType::Q4_1,
177 GgmlDType::Q5_0,
178 GgmlDType::Q5_1,
179 GgmlDType::Q8_0,
180 GgmlDType::Q2K,
181 GgmlDType::Q3K,
182 GgmlDType::Q4K,
183 GgmlDType::Q5K,
184 GgmlDType::Q6K,
185 GgmlDType::Q8K,
186 GgmlDType::Iq4Nl,
187 GgmlDType::Iq4Xs,
188 GgmlDType::Iq2Xxs,
189 GgmlDType::Iq2Xs,
190 GgmlDType::Iq3Xxs,
191 GgmlDType::Iq1S,
192 GgmlDType::Iq3S,
193 GgmlDType::Iq2S,
194 GgmlDType::Iq1M,
195 ] {
196 // One full block's worth of zeroed bytes is a valid (if degenerate) input for
197 // every block format here -- big enough that `n_blocks >= 1` for every dtype's
198 // block size, which is what exercises `to_float` rather than a `n_blocks == 0`
199 // no-op.
200 let bytes = vec![0_u8; 4096];
201 let out = dequant_ggml_bytes(&bytes, dtype, "t").unwrap_or_else(|e| {
202 panic!("{dtype:?} must dequantize a whole-zero block without error: {e}")
203 });
204 assert!(
205 !out.is_empty(),
206 "{dtype:?}: 4096 zeroed bytes must yield at least one dequantized block",
207 );
208 }
209 }
210
211 /// Alignment born-red: `Vec<u8>` allocations are only guaranteed 1-byte aligned, and every
212 /// `BlockQX` needs alignment >= 2 (an `f16`/`bf16`/`f32` field). A raw
213 /// `from_raw_parts(bytes.as_ptr() as *const T, ...)` on a misaligned slice is immediate UB
214 /// under the stdlib's contract, not "unlikely in practice" -- and it is reachable: any
215 /// tensor sub-slice at an odd byte offset from a `Vec<u8>` base produces exactly this.
216 ///
217 /// This constructs a slice at a DELIBERATELY misaligned offset and checks the dequantized
218 /// VALUES against an aligned copy of the same bytes, not just that the call returns `Ok` --
219 /// a misaligned read that "happens to work" on this platform would still pass an Ok-only
220 /// check. `Q4_0` is used because its alignment requirement (2, from `d: f16`) is exactly
221 /// the boundary the fix must clear.
222 ///
223 /// `Vec<u8>`'s allocator-provided base address parity is NOT specified by the language --
224 /// it can come back even or odd -- so "index 1 into a fresh `Vec<u8>`" is not reliably
225 /// misaligned (this was measured under Miri: the first version of this test asserted that
226 /// and Miri's allocator handed back an odd base, making offset 1 the ALIGNED one). This
227 /// probes both offset 0 and offset 1 at runtime and picks whichever one `align_offset`
228 /// actually reports as misaligned for `T`, rather than assuming a parity.
229 #[test]
230 fn cast_and_dequant_handles_a_misaligned_byte_slice() {
231 let align = std::mem::align_of::<crate::k_quants::BlockQ4_0>();
232 let block_size = std::mem::size_of::<crate::k_quants::BlockQ4_0>();
233 let n_blocks = 3;
234 let total = n_blocks * block_size;
235
236 // One buffer 1 byte larger than needed so BOTH offset 0 and offset 1 are valid
237 // in-bounds `total`-byte windows into it, deterministically filled.
238 let mut buf = vec![0_u8; total + 1];
239 for (i, b) in buf.iter_mut().enumerate() {
240 *b = (i as u8).wrapping_mul(37).wrapping_add(11);
241 }
242 let offset0_aligned = buf[0..total].as_ptr().align_offset(align) == 0;
243 let (aligned_slice, misaligned_slice) = if offset0_aligned {
244 (&buf[0..total], &buf[1..1 + total])
245 } else {
246 (&buf[1..1 + total], &buf[0..total])
247 };
248 assert_eq!(
249 aligned_slice.as_ptr().align_offset(align),
250 0,
251 "test setup bug"
252 );
253 assert_ne!(
254 misaligned_slice.as_ptr().align_offset(align),
255 0,
256 "test setup bug: this slice must actually BE misaligned for the test to mean \
257 anything -- the two offsets in a 1-byte-larger buffer must have opposite parity \
258 relative to `align`, so exactly one of them is always misaligned",
259 );
260
261 let aligned_out = dequant_ggml_bytes(aligned_slice, GgmlDType::Q4_0, "t").unwrap();
262 let misaligned_out = dequant_ggml_bytes(misaligned_slice, GgmlDType::Q4_0, "t").unwrap();
263
264 // The two slices overlap by `total - 1` bytes rather than being byte-identical, so
265 // compare against a THIRD, independently-constructed aligned copy of exactly the
266 // misaligned slice's own bytes -- the actual claim under test.
267 let misaligned_bytes_copy: Vec<u8> = misaligned_slice.to_vec();
268 let reference_out =
269 dequant_ggml_bytes(&misaligned_bytes_copy, GgmlDType::Q4_0, "t").unwrap();
270 assert_eq!(
271 reference_out, misaligned_out,
272 "dequantizing a byte slice from a misaligned pointer must produce identical values \
273 to dequantizing the same bytes from an aligned one",
274 );
275 // Sanity: the aligned_out computed above is exercised too (not dead), confirming this
276 // path also runs through the same function without panicking.
277 assert_eq!(aligned_out.len(), misaligned_out.len());
278 }
279
280 /// Q8_1 must decline typed, never panic -- `BlockQ8_1::to_float` is `unimplemented!()`
281 /// upstream (GAP-125), so this proves the exclusion is a real early return, not a call
282 /// into `cast_and_dequant` that happens to survive on this input.
283 #[test]
284 fn q8_1_declines_with_a_typed_error_not_a_panic() {
285 let bytes = vec![0_u8; 4096];
286 let err = dequant_ggml_bytes(&bytes, GgmlDType::Q8_1, "t").unwrap_err();
287 assert!(err.to_string().contains("GAP-125"));
288 }
289
290 /// Sabotage: confirm the K-quant arms actually call `to_float` and are not accidentally
291 /// dead code returning the same output as a different variant. Q4_0 (legacy, scale+offset
292 /// per block) and Q6_K (k-quant, superblock scales) must NOT dequantize an identical
293 /// non-zero byte pattern to the same values -- that would mean one of the two match arms
294 /// is not reaching its own type's `to_float`.
295 #[test]
296 fn distinct_dtypes_on_the_same_bytes_diverge() {
297 let bytes = vec![0xAB_u8; 4096];
298 let q4_0 = dequant_ggml_bytes(&bytes, GgmlDType::Q4_0, "t").unwrap();
299 let q6_k = dequant_ggml_bytes(&bytes, GgmlDType::Q6K, "t").unwrap();
300 assert_ne!(
301 q4_0.len(),
302 q6_k.len(),
303 "Q4_0 and Q6_K have different block sizes; equal output lengths would mean one \
304 arm is reaching the wrong to_float",
305 );
306 }
307}