Skip to main content

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}