ferrum_quantization/gguf/file.rs
1//! `GgufFile`: mmap-backed reader for a single GGUF file.
2//!
3//! Lifecycle:
4//! 1. `GgufFile::open(path)` — mmaps the file and parses the header.
5//! No tensor payloads are read at this stage.
6//! 2. `architecture()`, `metadata_*()`, `tensor_names()`, `tensor_info()` —
7//! cheap lookups, all served from the parsed header in memory.
8//! 3. `read_tensor(name, device)` — slices the mmap at the right offset
9//! and asks candle to materialise a `QTensor` (still quantized).
10//!
11//! Tensor reads only need a shared `&self` because the mmap is immutable; the
12//! file is safe to share across threads. (Candle's `Content::tensor` wants
13//! a `&mut R: Read + Seek`, but we satisfy it with a fresh `Cursor<&[u8]>`
14//! on each call — the cursor's mutable state is local to the call.)
15
16use std::fs::File;
17use std::io::Cursor;
18use std::path::Path;
19
20use candle_core::quantized::gguf_file::{Content, TensorInfo, Value};
21use candle_core::quantized::QTensor;
22use candle_core::{Device, Error as CandleError, Result as CandleResult};
23use memmap2::Mmap;
24
25/// Read-only handle to a memory-mapped GGUF file.
26pub struct GgufFile {
27 /// memory-mapped file payload. Kept alive for the lifetime of `self`
28 /// because `read_tensor` slices into it.
29 mmap: Mmap,
30 /// Parsed header / metadata / tensor descriptors. No payload bytes.
31 content: Content,
32}
33
34impl GgufFile {
35 /// Open and parse the header of a GGUF file.
36 ///
37 /// Returns immediately after the descriptor table is read — no tensor
38 /// data is materialised. `read_tensor` lazy-loads individual tensors.
39 pub fn open(path: impl AsRef<Path>) -> CandleResult<Self> {
40 let path_ref = path.as_ref();
41 let file = File::open(path_ref).map_err(|e| {
42 CandleError::Msg(format!(
43 "failed to open GGUF file '{}': {e}",
44 path_ref.display()
45 ))
46 })?;
47 // SAFETY: `Mmap::map` requires that the underlying file is not modified
48 // while the mapping is live. We treat the file as read-only for the
49 // entire lifetime of `self`. `Mmap` itself only exposes `&[u8]`.
50 let mmap = unsafe { Mmap::map(&file) }.map_err(|e| {
51 CandleError::Msg(format!(
52 "failed to mmap GGUF file '{}': {e}",
53 path_ref.display()
54 ))
55 })?;
56 let mut cursor = Cursor::new(&mmap[..]);
57 let content = Content::read(&mut cursor)?;
58 if content
59 .metadata
60 .keys()
61 .any(|key| super::hadamard::declares_transform(key))
62 {
63 return Err(super::hadamard::unsupported_execution());
64 }
65 Ok(Self { mmap, content })
66 }
67
68 /// Raw access to candle's parsed header — for callers that need the full
69 /// `metadata` / `tensor_infos` maps. Prefer the typed accessors below.
70 pub fn content(&self) -> &Content {
71 &self.content
72 }
73
74 // ── Metadata: typed accessors ─────────────────────────────────────────
75 //
76 // GGUF metadata keys are conventionally `<scope>.<field>` strings, e.g.
77 // `general.architecture` or `qwen3.block_count`. Different model families
78 // namespace under their architecture id. `architecture()` is the one key
79 // that's always present and tells you which scope to read the rest from.
80
81 /// Architecture string, e.g. `"qwen3"`, `"llama"`. Read from
82 /// `general.architecture`. Errors if the key is missing or non-string.
83 pub fn architecture(&self) -> CandleResult<&str> {
84 self.metadata_string("general.architecture")
85 }
86
87 /// Raw metadata value lookup. Returns `None` if the key is absent.
88 pub fn metadata(&self, key: &str) -> Option<&Value> {
89 self.content.metadata.get(key)
90 }
91
92 /// Read a string-typed metadata field. Errors if missing or wrong type.
93 pub fn metadata_string(&self, key: &str) -> CandleResult<&str> {
94 self.require_metadata(key)?.to_string().map(|s| s.as_str())
95 }
96
97 /// Read a u32-typed metadata field. Errors if missing or wrong type.
98 pub fn metadata_u32(&self, key: &str) -> CandleResult<u32> {
99 self.require_metadata(key)?.to_u32()
100 }
101
102 /// Read a u64-typed metadata field. Errors if missing or wrong type.
103 pub fn metadata_u64(&self, key: &str) -> CandleResult<u64> {
104 self.require_metadata(key)?.to_u64()
105 }
106
107 /// Read an f32-typed metadata field. Errors if missing or wrong type.
108 pub fn metadata_f32(&self, key: &str) -> CandleResult<f32> {
109 self.require_metadata(key)?.to_f32()
110 }
111
112 /// Read a bool-typed metadata field. Errors if missing or wrong type.
113 pub fn metadata_bool(&self, key: &str) -> CandleResult<bool> {
114 self.require_metadata(key)?.to_bool()
115 }
116
117 fn require_metadata(&self, key: &str) -> CandleResult<&Value> {
118 self.metadata(key)
119 .ok_or_else(|| CandleError::Msg(format!("GGUF metadata key missing: '{key}'")))
120 }
121
122 // ── Tensor enumeration ────────────────────────────────────────────────
123
124 /// Total number of tensors declared in the header.
125 pub fn tensor_count(&self) -> usize {
126 self.content.tensor_infos.len()
127 }
128
129 /// Iterate over every tensor name in the file. Order is whatever the
130 /// underlying `HashMap` yields — do not rely on it being deterministic.
131 pub fn tensor_names(&self) -> impl Iterator<Item = &str> {
132 self.content.tensor_infos.keys().map(|s| s.as_str())
133 }
134
135 /// Look up a tensor descriptor (shape, dtype, byte offset) without
136 /// touching the payload. `None` if the tensor isn't in the file.
137 pub fn tensor_info(&self, name: &str) -> Option<&TensorInfo> {
138 self.content.tensor_infos.get(name)
139 }
140
141 /// Whether a tensor with `name` is declared in the header.
142 pub fn has_tensor(&self, name: &str) -> bool {
143 self.content.tensor_infos.contains_key(name)
144 }
145
146 // ── Tensor read ───────────────────────────────────────────────────────
147
148 /// Materialise a tensor as a candle `QTensor` on the target device.
149 ///
150 /// The returned tensor is **still quantized** — no dequant happens here.
151 /// Wrap it in `QMatMul::from_qtensor` for inference, or call
152 /// `QTensor::dequantize(device)` to get a fp32 `Tensor`.
153 ///
154 /// **Beware:** candle copies the bytes into an owned `Vec<u8>` (see
155 /// `TensorInfo::read`). For the steady-state weight upload path use
156 /// [`Self::tensor_byte_slice`] instead — it returns a slice directly
157 /// into the mmap with no allocation.
158 pub fn read_tensor(&self, name: &str, device: &Device) -> CandleResult<QTensor> {
159 let mut cursor = Cursor::new(&self.mmap[..]);
160 self.content.tensor(&mut cursor, name, device)
161 }
162
163 /// Whole mmap region as a byte slice. Used to wrap the file as a single
164 /// zero-copy `MTLBuffer` on Metal — the lifetime of the slice is tied to
165 /// `&self`, so the caller is expected to keep an `Arc<GgufFile>` alive
166 /// for as long as anything references the mmap.
167 pub fn mmap_bytes(&self) -> &[u8] {
168 &self.mmap[..]
169 }
170
171 /// Byte slice covering exactly tensor `name` inside the mmap. The slice
172 /// points into the file mapping, so reads are demand-paged and there is
173 /// no heap allocation. Returns `None` if the tensor isn't declared.
174 ///
175 /// The byte length is computed from the tensor's `(elem_count, ggml_dtype)`
176 /// using candle's `block_size()` / `type_size()`. For raw quant tensors
177 /// (Q4K / Q6K / etc.), these bytes are exactly what `QTensor::data()`
178 /// would return — but with no copy.
179 pub fn tensor_byte_slice(&self, name: &str) -> Option<&[u8]> {
180 let info = self.content.tensor_infos.get(name)?;
181 let elem_count = info.shape.elem_count();
182 let block_size = info.ggml_dtype.block_size();
183 if !elem_count.is_multiple_of(block_size) {
184 return None;
185 }
186 let size_in_bytes = elem_count / block_size * info.ggml_dtype.type_size();
187 let abs_start = (self.content.tensor_data_offset + info.offset) as usize;
188 let abs_end = abs_start.checked_add(size_in_bytes)?;
189 if abs_end > self.mmap.len() {
190 return None;
191 }
192 Some(&self.mmap[abs_start..abs_end])
193 }
194
195 /// `(byte_offset_in_mmap, byte_length)` for tensor `name`. Same
196 /// computation as [`Self::tensor_byte_slice`] but returns the indices
197 /// rather than the slice — useful when the caller already has the
198 /// mmap base pointer (e.g. when binding a region of a shared buffer
199 /// at a given offset).
200 pub fn tensor_byte_range(&self, name: &str) -> Option<(usize, usize)> {
201 let info = self.content.tensor_infos.get(name)?;
202 let elem_count = info.shape.elem_count();
203 let block_size = info.ggml_dtype.block_size();
204 if !elem_count.is_multiple_of(block_size) {
205 return None;
206 }
207 let size_in_bytes = elem_count / block_size * info.ggml_dtype.type_size();
208 let abs_start = (self.content.tensor_data_offset + info.offset) as usize;
209 let abs_end = abs_start.checked_add(size_in_bytes)?;
210 if abs_end > self.mmap.len() {
211 return None;
212 }
213 Some((abs_start, size_in_bytes))
214 }
215}
216
217impl std::fmt::Debug for GgufFile {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 f.debug_struct("GgufFile")
220 .field("size_bytes", &self.mmap.len())
221 .field("metadata_keys", &self.content.metadata.len())
222 .field("tensor_count", &self.content.tensor_infos.len())
223 .field("tensor_data_offset", &self.content.tensor_data_offset)
224 .finish()
225 }
226}