Skip to main content

kopitiam_loader/
gguf.rs

1//! Native GGUF parsing.
2//!
3//! GGUF (the `llama.cpp`/`ggml` model format) is documented in
4//! `crates/kopitiam-ai/vendor/ggml/docs/gguf.md`; this module is an
5//! original Rust implementation of that spec, not a translation of
6//! `vendor/ggml/src/gguf.cpp` — the C++ reference was read for the wire
7//! format, not copied for the code.
8//!
9//! # File layout
10//!
11//! ```text
12//! magic(4="GGUF") version(u32) tensor_count(u64) metadata_kv_count(u64)
13//! metadata_kv[metadata_kv_count]
14//! tensor_info[tensor_count]
15//! <padding to align_offset(position, ALIGNMENT)>
16//! tensor_data[]   -- each tensor's bytes at tensor_data_start + info.offset
17//! ```
18//!
19//! # The dimension-order trap
20//!
21//! GGUF's per-tensor `dimensions` array is ggml's `ne[]`: **fastest-varying
22//! dimension first** (`ne[0]` is the contiguous/innermost axis). That is
23//! the *opposite* of [`kopitiam_core::Shape`]'s convention — outermost
24//! first, row-major, last dimension contiguous — which is also the
25//! convention every NumPy/PyTorch-descended tool uses. A 2D weight matrix
26//! that a Python export script calls `shape = [n_out, n_in]` is written to
27//! a GGUF file as `ne = [n_in, n_out]`.
28//!
29//! This module reverses `dimensions` while building each [`TensorEntry`]'s
30//! [`Shape`] (see [`parse`] below), so every `Shape` this loader ever
31//! produces is already in `kopitiam_core`'s outermost-first convention.
32//! Getting this backwards does not fail to load — it loads a tensor with
33//! its axes silently swapped, which is a correctness bug invisible until
34//! inference produces garbage. If you are debugging a GGUF-sourced model
35//! that "loads fine but computes nonsense," check this reversal first.
36//!
37//! # Big-endian GGUF
38//!
39//! The spec allows big-endian files but "there is no way to determine if a
40//! model is big-endian" from the file itself (v3 spec text). This loader
41//! only supports little-endian files, which is what every GGUF file
42//! actually distributed in the wild uses; a big-endian file will fail
43//! bounds/sanity checks (most likely the version or tensor/KV counts will
44//! decode to an absurd value) and surface as [`Error::MalformedModel`]
45//! rather than a silent misread.
46//!
47//! # GGUF v1
48//!
49//! v1 used `uint32` for string/array lengths and the header's tensor/KV
50//! counts, where v2 and v3 use `uint64` throughout (the only difference
51//! between v2 and v3 is that v3 adds big-endian support at the spec-text
52//! level). v1 files are old enough to be effectively extinct, so this
53//! loader accepts v2 and v3 (structurally identical for our purposes) and
54//! rejects v1 with [`Error::UnsupportedModelFeature`] rather than adding a
55//! second, permanently-unexercised parsing path for it.
56
57use std::path::Path;
58
59use indexmap::IndexMap;
60use kopitiam_core::{DType, Error, Result, Shape};
61
62use crate::byte_source::ByteSource;
63use crate::metadata::{GgufMetadata, GgufValue, ModelMetadata};
64use crate::model::{LoadedModel, ModelLoader, TensorEntry};
65
66const FORMAT: &str = "gguf";
67const MAGIC: [u8; 4] = *b"GGUF";
68const DEFAULT_ALIGNMENT: u64 = 32;
69/// GGUF caps tensor rank at 4 today but reserves room to raise that; 8 is a
70/// generous ceiling that rejects obviously-hostile `n_dimensions` values
71/// (e.g. a corrupted field decoding to millions) without risking rejecting
72/// a legitimate future file.
73const MAX_TENSOR_DIMS: u32 = 8;
74/// Guards [`read_value`]'s recursion for nested `Array` values. Legitimate
75/// GGUF files never nest arrays more than one level deep; this exists so a
76/// hostile file cannot exhaust the stack by chaining thousands of
77/// one-element `Array`-of-`Array` headers (each only ~12 bytes on disk).
78const MAX_ARRAY_NESTING_DEPTH: u32 = 32;
79
80fn malformed(reason: impl Into<String>) -> Error {
81    Error::MalformedModel { format: FORMAT, reason: reason.into() }
82}
83
84fn unsupported(feature: impl Into<String>) -> Error {
85    Error::UnsupportedModelFeature { format: FORMAT, feature: feature.into() }
86}
87
88/// A bounds-checked cursor over a GGUF file's bytes.
89///
90/// Every primitive read goes through [`Cursor::take`], which is the single
91/// place that turns "not enough bytes left" into
92/// [`Error::MalformedModel`] instead of a slice-index panic. A truncated
93/// file, a metadata count that overshoots the real content, or a
94/// deliberately hostile file all fail here, gracefully, on whichever read
95/// first runs off the end.
96struct Cursor<'a> {
97    bytes: &'a [u8],
98    pos: usize,
99}
100
101impl<'a> Cursor<'a> {
102    fn new(bytes: &'a [u8]) -> Self {
103        Self { bytes, pos: 0 }
104    }
105
106    fn position(&self) -> usize {
107        self.pos
108    }
109
110    fn take(&mut self, n: usize) -> Result<&'a [u8]> {
111        let end = self
112            .pos
113            .checked_add(n)
114            .ok_or_else(|| malformed("byte offset overflow while reading"))?;
115        let slice = self.bytes.get(self.pos..end).ok_or_else(|| {
116            malformed(format!(
117                "unexpected end of file: needed {n} bytes at offset {}, file has {} bytes",
118                self.pos,
119                self.bytes.len()
120            ))
121        })?;
122        self.pos = end;
123        Ok(slice)
124    }
125
126    fn u8(&mut self) -> Result<u8> {
127        Ok(self.take(1)?[0])
128    }
129
130    fn i8(&mut self) -> Result<i8> {
131        Ok(self.take(1)?[0] as i8)
132    }
133
134    fn u16(&mut self) -> Result<u16> {
135        Ok(u16::from_le_bytes(self.take(2)?.try_into().expect("take(2) returns 2 bytes")))
136    }
137
138    fn i16(&mut self) -> Result<i16> {
139        Ok(i16::from_le_bytes(self.take(2)?.try_into().expect("take(2) returns 2 bytes")))
140    }
141
142    fn u32(&mut self) -> Result<u32> {
143        Ok(u32::from_le_bytes(self.take(4)?.try_into().expect("take(4) returns 4 bytes")))
144    }
145
146    fn i32(&mut self) -> Result<i32> {
147        Ok(i32::from_le_bytes(self.take(4)?.try_into().expect("take(4) returns 4 bytes")))
148    }
149
150    fn u64(&mut self) -> Result<u64> {
151        Ok(u64::from_le_bytes(self.take(8)?.try_into().expect("take(8) returns 8 bytes")))
152    }
153
154    fn i64(&mut self) -> Result<i64> {
155        Ok(i64::from_le_bytes(self.take(8)?.try_into().expect("take(8) returns 8 bytes")))
156    }
157
158    fn f32(&mut self) -> Result<f32> {
159        Ok(f32::from_le_bytes(self.take(4)?.try_into().expect("take(4) returns 4 bytes")))
160    }
161
162    fn f64(&mut self) -> Result<f64> {
163        Ok(f64::from_le_bytes(self.take(8)?.try_into().expect("take(8) returns 8 bytes")))
164    }
165
166    fn bool(&mut self) -> Result<bool> {
167        match self.u8()? {
168            0 => Ok(false),
169            1 => Ok(true),
170            other => Err(malformed(format!(
171                "invalid bool byte {other:#04x} (must be 0x00 or 0x01)"
172            ))),
173        }
174    }
175
176    /// A `gguf_string_t`: a `u64` length prefix followed by that many UTF-8
177    /// bytes (not null-terminated).
178    fn string(&mut self) -> Result<String> {
179        let len = self.u64()?;
180        let len = usize::try_from(len)
181            .map_err(|_| malformed(format!("string length {len} does not fit in memory")))?;
182        // No `Vec::with_capacity(len)` before validating: `take` bounds-checks
183        // `len` against the bytes actually remaining first, so a hostile
184        // huge length fails on the bounds check below rather than
185        // triggering a huge allocation.
186        let bytes = self.take(len)?;
187        String::from_utf8(bytes.to_vec())
188            .map_err(|_| malformed("metadata string is not valid UTF-8"))
189    }
190}
191
192/// Reads one typed metadata value, recursing for `Array` (value type `9`).
193///
194/// `depth` guards against [`MAX_ARRAY_NESTING_DEPTH`]; see that constant's
195/// doc for why a recursion cap is needed here specifically.
196fn read_value(cursor: &mut Cursor, value_type: u32, depth: u32) -> Result<GgufValue> {
197    match value_type {
198        0 => Ok(GgufValue::U8(cursor.u8()?)),
199        1 => Ok(GgufValue::I8(cursor.i8()?)),
200        2 => Ok(GgufValue::U16(cursor.u16()?)),
201        3 => Ok(GgufValue::I16(cursor.i16()?)),
202        4 => Ok(GgufValue::U32(cursor.u32()?)),
203        5 => Ok(GgufValue::I32(cursor.i32()?)),
204        6 => Ok(GgufValue::F32(cursor.f32()?)),
205        7 => Ok(GgufValue::Bool(cursor.bool()?)),
206        8 => Ok(GgufValue::String(cursor.string()?)),
207        9 => {
208            if depth >= MAX_ARRAY_NESTING_DEPTH {
209                return Err(malformed("array nesting exceeds sanity limit"));
210            }
211            let elem_type = cursor.u32()?;
212            let len = cursor.u64()?;
213            let len = usize::try_from(len)
214                .map_err(|_| malformed(format!("array length {len} does not fit in memory")))?;
215            // Reserve a small hint, not `len` itself: `len` is untrusted,
216            // and each element read is bounds-checked against the file's
217            // real remaining bytes, so a hostile huge `len` fails on the
218            // first missing byte instead of pre-allocating gigabytes.
219            let mut values = Vec::with_capacity(len.min(1024));
220            for _ in 0..len {
221                values.push(read_value(cursor, elem_type, depth + 1)?);
222            }
223            Ok(GgufValue::Array(values))
224        }
225        10 => Ok(GgufValue::U64(cursor.u64()?)),
226        11 => Ok(GgufValue::I64(cursor.i64()?)),
227        12 => Ok(GgufValue::F64(cursor.f64()?)),
228        other => Err(unsupported(format!("metadata value type id {other}"))),
229    }
230}
231
232/// Maps a `ggml_type` id to [`DType`].
233///
234/// The ids are ggml's `enum ggml_type` discriminants (see `ggml.h`); only
235/// the types [`kopitiam_core::DType`] can represent are accepted. This
236/// covers the classic quants (Q4_0/Q4_1/Q5_0/Q5_1/Q8_0) and the modern
237/// K-quant super-block formats (Q2_K..Q8_K, ids 10-15) that files like
238/// `Q4_K_M`/`Q5_K_M`/`Q6_K` are built from. GGUF/ggml defines still more
239/// (the IQ-quants, MXFP4, I16/I64/F64, ...); until `kopitiam-core` grows
240/// variants for those, a tensor using one of them returns
241/// [`Error::UnsupportedModelFeature`] rather than being misread as one of
242/// the supported types (a wrong-but-plausible-looking dequantization is far
243/// worse than a load failure).
244fn dtype_from_ggml_type(ggml_type: u32) -> Result<DType> {
245    match ggml_type {
246        0 => Ok(DType::F32),
247        1 => Ok(DType::F16),
248        2 => Ok(DType::Q4_0),
249        3 => Ok(DType::Q4_1),
250        6 => Ok(DType::Q5_0),
251        7 => Ok(DType::Q5_1),
252        8 => Ok(DType::Q8_0),
253        10 => Ok(DType::Q2_K),
254        11 => Ok(DType::Q3_K),
255        12 => Ok(DType::Q4_K),
256        13 => Ok(DType::Q5_K),
257        14 => Ok(DType::Q6_K),
258        15 => Ok(DType::Q8_K),
259        30 => Ok(DType::BF16),
260        other => Err(unsupported(format!("ggml tensor type id {other}"))),
261    }
262}
263
264/// `align_offset` from the GGUF spec: rounds `offset` up to the next
265/// multiple of `alignment`.
266fn align_up(offset: u64, alignment: u64) -> Result<u64> {
267    if alignment == 0 {
268        return Err(malformed("general.alignment must not be zero"));
269    }
270    let remainder = offset % alignment;
271    if remainder == 0 {
272        return Ok(offset);
273    }
274    offset
275        .checked_add(alignment - remainder)
276        .ok_or_else(|| malformed("alignment padding overflows a u64 offset"))
277}
278
279/// A tensor's on-disk description before its final, GGUF-independent
280/// [`TensorEntry`] is built — dimensions here are still in ggml's
281/// fastest-varying-first order and `offset` is still relative to
282/// `tensor_data_start` rather than absolute.
283struct RawTensorInfo {
284    name: String,
285    /// ggml `ne[]` order: fastest-varying dimension first.
286    dims: Vec<u64>,
287    ggml_type: u32,
288    /// Relative to the start of the tensor data section.
289    relative_offset: u64,
290}
291
292/// Parses a GGUF file, already opened as `source`, into a [`LoadedModel`].
293fn parse(source: ByteSource) -> Result<LoadedModel> {
294    let bytes = source.as_slice();
295    let mut cursor = Cursor::new(bytes);
296
297    let magic = cursor.take(4)?;
298    if magic != MAGIC {
299        return Err(malformed(format!(
300            "bad magic {magic:02x?}, expected {MAGIC:02x?} (\"GGUF\")"
301        )));
302    }
303
304    let version = cursor.u32()?;
305    if version != 2 && version != 3 {
306        return Err(unsupported(format!(
307            "gguf version {version} (only v2 and v3 are supported; see module docs re: v1)"
308        )));
309    }
310
311    let tensor_count = cursor.u64()?;
312    let metadata_kv_count = cursor.u64()?;
313
314    let mut kv_map = IndexMap::new();
315    for _ in 0..metadata_kv_count {
316        let key = cursor.string()?;
317        let value_type = cursor.u32()?;
318        let value = read_value(&mut cursor, value_type, 0)?;
319        if kv_map.insert(key.clone(), value).is_some() {
320            // A duplicate key means either a corrupt file or two
321            // conflicting values for the same hyperparameter — silently
322            // keeping "whichever came last" could quietly pick the wrong
323            // one (e.g. two different `block_count`s), so this is treated
324            // as malformed rather than resolved by convention.
325            return Err(malformed(format!("duplicate metadata key {key:?}")));
326        }
327    }
328    let kv = GgufMetadata(kv_map);
329
330    let mut raw_infos = Vec::new();
331    for _ in 0..tensor_count {
332        let name = cursor.string()?;
333        let n_dims = cursor.u32()?;
334        if n_dims > MAX_TENSOR_DIMS {
335            return Err(malformed(format!(
336                "tensor {name:?} declares {n_dims} dimensions, more than the {MAX_TENSOR_DIMS} this loader accepts"
337            )));
338        }
339        let mut dims = Vec::with_capacity(n_dims as usize);
340        for _ in 0..n_dims {
341            dims.push(cursor.u64()?);
342        }
343        let ggml_type = cursor.u32()?;
344        let relative_offset = cursor.u64()?;
345        raw_infos.push(RawTensorInfo { name, dims, ggml_type, relative_offset });
346    }
347
348    // Default is 32 per spec ("Some writers may not write the alignment.
349    // If the alignment is not specified, assume it is 32."); must be a
350    // positive multiple of 8 when present.
351    let alignment = kv
352        .get_u32("general.alignment")
353        .map(u64::from)
354        .unwrap_or(DEFAULT_ALIGNMENT);
355    if alignment == 0 || !alignment.is_multiple_of(8) {
356        return Err(malformed(format!(
357            "general.alignment {alignment} must be a positive multiple of 8"
358        )));
359    }
360
361    let tensor_data_start = align_up(cursor.position() as u64, alignment)?;
362    let file_len = bytes.len() as u64;
363
364    let mut tensors = IndexMap::new();
365    for info in raw_infos {
366        let dtype = dtype_from_ggml_type(info.ggml_type)?;
367
368        // Reverse ggml's fastest-varying-first `ne[]` order into
369        // kopitiam_core::Shape's outermost-first order. See the module
370        // doc's "dimension-order trap" section.
371        let mut dims = Vec::with_capacity(info.dims.len());
372        for &d in info.dims.iter().rev() {
373            let d = usize::try_from(d).map_err(|_| {
374                malformed(format!(
375                    "tensor {:?} has a dimension ({d}) too large to represent",
376                    info.name
377                ))
378            })?;
379            dims.push(d);
380        }
381        let shape = Shape::new(dims);
382
383        let elem_count = shape.elem_count();
384        let byte_len = dtype.storage_bytes(elem_count).ok_or(Error::PartialQuantizedBlock {
385            dtype,
386            count: elem_count,
387            block_size: dtype.block_size(),
388        })?;
389
390        if !info.relative_offset.is_multiple_of(alignment) {
391            return Err(malformed(format!(
392                "tensor {:?} offset {} is not a multiple of alignment {alignment}",
393                info.name, info.relative_offset
394            )));
395        }
396
397        let abs_offset = tensor_data_start.checked_add(info.relative_offset).ok_or_else(|| {
398            malformed(format!("tensor {:?} data offset overflows a u64", info.name))
399        })?;
400        let abs_end = abs_offset.checked_add(byte_len as u64).ok_or_else(|| {
401            malformed(format!("tensor {:?} data end offset overflows a u64", info.name))
402        })?;
403        if abs_end > file_len {
404            return Err(malformed(format!(
405                "tensor {:?} data range [{abs_offset}, {abs_end}) extends past end of file ({file_len} bytes)",
406                info.name
407            )));
408        }
409        let abs_offset = usize::try_from(abs_offset)
410            .map_err(|_| malformed(format!("tensor {:?} offset does not fit in memory", info.name)))?;
411
412        let entry = TensorEntry {
413            name: info.name.clone(),
414            dtype,
415            shape,
416            offset: abs_offset,
417            len: byte_len,
418        };
419        if tensors.insert(info.name.clone(), entry).is_some() {
420            return Err(malformed(format!("duplicate tensor name {:?}", info.name)));
421        }
422    }
423
424    let metadata = build_metadata(kv);
425
426    Ok(LoadedModel { metadata, tensors, source, format: FORMAT })
427}
428
429/// Promotes the well-known `[arch].*` hyperparameter keys into
430/// [`ModelMetadata`]'s named fields; everything else stays reachable
431/// through `raw`. See [`ModelMetadata`]'s doc for why the field list is
432/// what it is.
433fn build_metadata(kv: GgufMetadata) -> ModelMetadata {
434    let architecture = kv.get_str("general.architecture").map(str::to_owned);
435    let name = kv.get_str("general.name").map(str::to_owned);
436
437    // GGUF namespaces most hyperparameters under the architecture name
438    // (`llama.block_count`, `qwen2.block_count`, ...), so every lookup
439    // below needs the architecture prefix; without one, none of these keys
440    // can be resolved and the fields are simply left `None`.
441    let prefixed = |suffix: &str| architecture.as_deref().map(|arch| format!("{arch}.{suffix}"));
442
443    let n_layers = prefixed("block_count").and_then(|k| kv.get_u64(&k));
444    let n_heads = prefixed("attention.head_count").and_then(|k| kv.get_u64(&k));
445    let n_kv_heads = prefixed("attention.head_count_kv").and_then(|k| kv.get_u64(&k));
446    let embedding_length = prefixed("embedding_length").and_then(|k| kv.get_u64(&k));
447    let feed_forward_length = prefixed("feed_forward_length").and_then(|k| kv.get_u64(&k));
448    let context_length = prefixed("context_length").and_then(|k| kv.get_u64(&k));
449    let rope_theta = prefixed("rope.freq_base").and_then(|k| kv.get_f32(&k));
450    let rope_dimension_count = prefixed("rope.dimension_count").and_then(|k| kv.get_u64(&k));
451    let norm_epsilon = prefixed("attention.layer_norm_rms_epsilon")
452        .and_then(|k| kv.get_f32(&k))
453        .or_else(|| prefixed("attention.layer_norm_epsilon").and_then(|k| kv.get_f32(&k)));
454
455    // GGUF has no dedicated vocab-size key; the vocabulary's length *is*
456    // the vocab size. Fall back to an explicit `[arch].vocab_size` key for
457    // files that carry one without an embedded tokenizer.
458    let vocab_size = kv
459        .get_array("tokenizer.ggml.tokens")
460        .map(|tokens| tokens.len() as u64)
461        .or_else(|| prefixed("vocab_size").and_then(|k| kv.get_u64(&k)));
462
463    let quantization_version = kv.get_u32("general.quantization_version");
464    let file_type = kv.get_u32("general.file_type");
465
466    ModelMetadata {
467        architecture,
468        name,
469        n_layers,
470        n_heads,
471        n_kv_heads,
472        embedding_length,
473        feed_forward_length,
474        context_length,
475        vocab_size,
476        rope_theta,
477        rope_dimension_count,
478        norm_epsilon,
479        quantization_version,
480        file_type,
481        raw: kv,
482    }
483}
484
485/// Parses GGUF (`llama.cpp`/`ggml`) model files. See the module docs for
486/// the format and the dimension-order convention this loader corrects for.
487pub struct GgufLoader;
488
489impl ModelLoader for GgufLoader {
490    fn format_name(&self) -> &'static str {
491        FORMAT
492    }
493
494    fn probe(&self, bytes: &[u8]) -> bool {
495        bytes.len() >= MAGIC.len() && bytes[..MAGIC.len()] == MAGIC
496    }
497
498    fn load(&self, path: &Path) -> Result<LoadedModel> {
499        let source = ByteSource::open(path)?;
500        parse(source)
501    }
502}