Skip to main content

ferrum_quantization/gguf/
loader.rs

1//! `GgufLoader<B>`: implements `WeightLoader<B>` against a GGUF file.
2//!
3//! Bridges the model layer (which addresses weights by ferrum's HuggingFace-
4//! style names) to the on-disk GGUF format (llama.cpp's `blk.{i}.attn_q.weight`
5//! shorthand). Three responsibilities:
6//!
7//!   1. **Name translation** — delegates to `gguf::names::ferrum_to_gguf`
8//!   2. **Tensor materialisation** — uses Phase 1A's `GgufFile::read_tensor`
9//!      then dequant on CPU into `B::Buffer` for `load_tensor`, or wraps
10//!      the QTensor in `GgufLinear<B>` for `load_linear`.
11//!   3. **Fusion** — reproduces the `qkv_proj` / `gate_up_proj` shims the
12//!      model expects: q/k/v split tensors are concatenated row-wise into
13//!      a single fused weight before the Linear is built.
14//!
15//! All paths go through eager dequant-to-fp32 (Phase 1B's strategy).
16//! Phase 1D will add a quant-aware shortcut so Q4_K_M weights can stay
17//! quantised in backend memory; the public `WeightLoader<B>` API stays
18//! the same.
19
20use std::path::Path;
21use std::sync::Arc;
22
23use candle_core::Device;
24use ferrum_kernels::backend::{Backend, BackendQuantGguf, BackendQuantMarlin};
25use ferrum_types::{FerrumError, Result};
26
27use crate::config::QuantConfig;
28use crate::gguf::file::GgufFile;
29use crate::gguf::linear::GgufLinear;
30use crate::gguf::names::{gate_up_split_parts, qkv_split_parts};
31use crate::loader::WeightLoader;
32use crate::traits::Linear;
33
34const GGUF_LOAD_TRACE_ENV: &str = "FERRUM_GGUF_LOAD_TRACE";
35
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
37struct GgufLoaderRuntimeConfig {
38    load_trace: bool,
39}
40
41impl GgufLoaderRuntimeConfig {
42    fn from_env() -> Self {
43        Self::from_env_vars(std::env::vars())
44    }
45
46    fn from_env_vars<I, K, V>(vars: I) -> Self
47    where
48        I: IntoIterator<Item = (K, V)>,
49        K: Into<String>,
50        V: Into<String>,
51    {
52        Self {
53            load_trace: vars
54                .into_iter()
55                .any(|(name, _value)| name.into() == GGUF_LOAD_TRACE_ENV),
56        }
57    }
58}
59
60/// Backend-generic weight loader for GGUF files.
61///
62/// Build with [`GgufLoader::open`]. The underlying file stays mmap'd for
63/// the lifetime of the loader so per-tensor reads only do byte slicing,
64/// not file I/O.
65pub struct GgufLoader<B: Backend + BackendQuantGguf + BackendQuantMarlin> {
66    gguf: Arc<GgufFile>,
67    /// Decode device for `QTensor::dequantize`. We always use CPU here:
68    /// the dequant is followed by `B::from_slice`, which uploads to the
69    /// backend's preferred memory. Going through Metal/CUDA candle paths
70    /// would add a cross-allocator hop with no benefit (Phase 1D revisits).
71    decode_device: Device,
72    runtime_config: GgufLoaderRuntimeConfig,
73    /// `general.architecture` from the file. Name translation is
74    /// arch-aware: Gemma 3 reuses the `post_attention_layernorm` ferrum
75    /// name for a different GGUF tensor than the Llama families.
76    arch: String,
77    _marker: std::marker::PhantomData<B>,
78}
79
80impl<B: Backend + BackendQuantGguf + BackendQuantMarlin> GgufLoader<B> {
81    /// Open and parse a `.gguf` file. Tensor payloads stay on disk (mmap'd)
82    /// until each `load_tensor` / `load_linear` call.
83    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
84        let gguf = GgufFile::open(path).map_err(candle_to_ferrum)?;
85        let arch = gguf.architecture().unwrap_or_default().to_string();
86        Ok(Self {
87            gguf: Arc::new(gguf),
88            decode_device: Device::Cpu,
89            runtime_config: GgufLoaderRuntimeConfig::from_env(),
90            arch,
91            _marker: std::marker::PhantomData,
92        })
93    }
94
95    /// Build from an already-opened [`GgufFile`] (test helper, also useful
96    /// when several loaders share the same mmap).
97    pub fn from_file(gguf: Arc<GgufFile>) -> Self {
98        let arch = gguf.architecture().unwrap_or_default().to_string();
99        Self {
100            gguf,
101            decode_device: Device::Cpu,
102            runtime_config: GgufLoaderRuntimeConfig::from_env(),
103            arch,
104            _marker: std::marker::PhantomData,
105        }
106    }
107
108    /// Arch-aware ferrum→GGUF name translation.
109    fn translate(&self, ferrum_name: &str) -> Option<String> {
110        crate::gguf::names::ferrum_to_gguf_with_arch(&self.arch, ferrum_name)
111    }
112
113    /// Direct access to the underlying file — exposes metadata + tensor
114    /// descriptor lookups for callers that need them (e.g. a config helper
115    /// that reads `general.architecture` and `<arch>.block_count`).
116    pub fn gguf(&self) -> &GgufFile {
117        &self.gguf
118    }
119
120    // ── Internals ────────────────────────────────────────────────────────
121
122    /// Look up a ferrum-named tensor in the GGUF, returning the GGUF tensor
123    /// name on success.
124    fn locate(&self, ferrum_name: &str) -> Result<String> {
125        let gguf_name = self.translate(ferrum_name).ok_or_else(|| {
126            FerrumError::model(format!(
127                "GgufLoader: unrecognised tensor name '{ferrum_name}' (no GGUF mapping)"
128            ))
129        })?;
130        if !self.gguf.has_tensor(&gguf_name) {
131            return Err(FerrumError::model(format!(
132                "GgufLoader: tensor '{ferrum_name}' (mapped to '{gguf_name}') not present in GGUF"
133            )));
134        }
135        Ok(gguf_name)
136    }
137
138    /// Read a quantized tensor and dequantize to fp32 row-major. Used by
139    /// both `load_tensor` (raw buffer) and the fusion path (concat sources).
140    fn read_dequant(&self, gguf_name: &str) -> Result<Vec<f32>> {
141        let qt = self
142            .gguf
143            .read_tensor(gguf_name, &self.decode_device)
144            .map_err(candle_to_ferrum)?;
145        let dense = qt
146            .dequantize(&self.decode_device)
147            .map_err(candle_to_ferrum)?;
148        let flat = dense.flatten_all().map_err(candle_to_ferrum)?;
149        flat.to_vec1::<f32>().map_err(candle_to_ferrum)
150    }
151
152    /// Look up a tensor's `[rows, cols]` (2-D) without reading the payload.
153    /// Errors if the tensor isn't 2-D — fusion needs row counts to compute
154    /// the combined output dim.
155    fn rows_cols(&self, gguf_name: &str) -> Result<(usize, usize)> {
156        let info = self
157            .gguf
158            .tensor_info(gguf_name)
159            .ok_or_else(|| FerrumError::model(format!("tensor info missing for '{gguf_name}'")))?;
160        let dims = info.shape.dims();
161        if dims.len() != 2 {
162            return Err(FerrumError::model(format!(
163                "expected 2-D tensor for '{gguf_name}', got rank {}",
164                dims.len()
165            )));
166        }
167        Ok((dims[0], dims[1]))
168    }
169
170    /// Build a fused `Linear<B>` by row-concatenating several sub-tensors.
171    /// All parts must share `cols` (in_features); rows (out_features) sum.
172    ///
173    /// Two paths:
174    ///   1. **Fast (quant-fused)** — every part is Q4_K with no bias. The
175    ///      raw super-block bytes are byte-concatenated and handed to
176    ///      `QuantLinear::from_gguf_bytes`, so weights stay quantised in
177    ///      backend memory.
178    ///   2. **Eager (dense-fused)** — fallback. Each part is dequanted to
179    ///      fp32 and concatenated; the result wraps a dense fp16 weight
180    ///      via `GgufLinear::from_dense_rows`.
181    ///
182    /// Why the dual path: an 8B Qwen3 has 36 layers × (qkv + gate_up) of
183    /// ~140M weights apiece — eager-fp32-fusing them inflates 5 GB on disk
184    /// to 25+ GB in RAM, defeating Q4_K_M entirely. The fast path only
185    /// works for Q4K-without-bias which is the vast majority of dense
186    /// transformers; bias-bearing fusions (rare) take the eager hit.
187    fn load_fused(&self, parts: &[String]) -> Result<Box<dyn Linear<B>>> {
188        if let Some(fast) = self.try_load_fused_q4k(parts)? {
189            if self.runtime_config.load_trace {
190                eprintln!("[gguf-load] {:?} → fused-Q4 (homogeneous)", parts);
191            }
192            return Ok(fast);
193        }
194        if let Some(multi) = self.try_load_fused_multi_quant(parts)? {
195            if self.runtime_config.load_trace {
196                eprintln!("[gguf-load] {:?} → MultiQuant (mixed dtype)", parts);
197            }
198            return Ok(multi);
199        }
200        if self.runtime_config.load_trace {
201            eprintln!("[gguf-load] {:?} → eager fp32 fallback ⚠", parts);
202        }
203        self.load_fused_eager(parts)
204    }
205
206    /// Multi-quant fused fast path: each part is a Q4_K or Q6_K tensor
207    /// with no bias. Parts may have **different** quant types (e.g.
208    /// Qwen3 qkv_proj where q+k are Q4_K but v is Q6_K). Builds a
209    /// `MetalQuantStore::Fused` (or whatever the backend's `Fused`
210    /// variant is) so each part stays compact in backend memory and
211    /// gemv dispatches per part with output offsets.
212    fn try_load_fused_multi_quant(&self, parts: &[String]) -> Result<Option<Box<dyn Linear<B>>>> {
213        let mut spec: Vec<(ferrum_kernels::backend::GgufQuantType, &[u8], usize)> = Vec::new();
214        let mut cols_check: Option<usize> = None;
215
216        for stem in parts {
217            let weight_name = format!("{stem}.weight");
218            let gguf_name = self.translate(&weight_name).ok_or_else(|| {
219                FerrumError::model(format!(
220                    "GgufLoader: fusion source '{weight_name}' has no GGUF mapping"
221                ))
222            })?;
223            if !self.gguf.has_tensor(&gguf_name) {
224                return Err(FerrumError::model(format!(
225                    "GgufLoader: fusion source '{weight_name}' (gguf '{gguf_name}') missing"
226                )));
227            }
228
229            // Bias on a fused part disqualifies the whole multi-quant
230            // path; fall back to eager fusion which already handles bias.
231            let has_bias = self
232                .translate(&format!("{stem}.bias"))
233                .map(|n| self.gguf.has_tensor(&n))
234                .unwrap_or(false);
235            if has_bias {
236                return Ok(None);
237            }
238
239            let info = self.gguf.tensor_info(&gguf_name).ok_or_else(|| {
240                FerrumError::model(format!("tensor_info missing for '{gguf_name}'"))
241            })?;
242            let kind = match info.ggml_dtype {
243                candle_core::quantized::GgmlDType::Q4K => {
244                    ferrum_kernels::backend::GgufQuantType::Q4K
245                }
246                candle_core::quantized::GgmlDType::Q6K => {
247                    ferrum_kernels::backend::GgufQuantType::Q6K
248                }
249                _ => return Ok(None), // unsupported quant in this part
250            };
251
252            let dims = info.shape.dims();
253            if dims.len() != 2 {
254                return Ok(None);
255            }
256            let (rows, cols) = (dims[0], dims[1]);
257            if cols % 256 != 0 {
258                return Ok(None);
259            }
260            match cols_check {
261                Some(c) if c != cols => {
262                    return Err(FerrumError::model(format!(
263                        "GgufLoader: fusion in_features mismatch ({c} vs {cols} for '{stem}')"
264                    )))
265                }
266                _ => cols_check = Some(cols),
267            }
268
269            // Slice the mmap directly. The slice's lifetime is tied to
270            // `&self.gguf`, which outlives this scope, so the backend
271            // can read the bytes safely without us owning a copy.
272            let bytes = self.gguf.tensor_byte_slice(&gguf_name).ok_or_else(|| {
273                FerrumError::model(format!(
274                    "GgufLoader: tensor_byte_slice failed for '{gguf_name}'"
275                ))
276            })?;
277            spec.push((kind, bytes, rows));
278        }
279
280        let cols = cols_check.ok_or_else(|| FerrumError::model("fusion: no parts"))?;
281        let parts_view: Vec<(_, &[u8], _)> = spec
282            .iter()
283            .map(|(kind, bytes, rows)| (*kind, *bytes, *rows))
284            .collect();
285        let quant = match crate::QuantLinear::<B>::from_gguf_fused(&parts_view, cols) {
286            Ok(q) => q,
287            Err(_) => return Ok(None), // backend doesn't support Fused
288        };
289        Ok(Some(Box::new(quant)))
290    }
291
292    /// Q4_K fast path for `load_fused`. Returns `Ok(None)` if any part
293    /// disqualifies (non-Q4K dtype, rank != 2, has bias, cols mismatch).
294    fn try_load_fused_q4k(&self, parts: &[String]) -> Result<Option<Box<dyn Linear<B>>>> {
295        let mut fused_bytes: Vec<u8> = Vec::new();
296        let mut total_rows = 0usize;
297        let mut cols_check: Option<usize> = None;
298
299        for stem in parts {
300            let weight_name = format!("{stem}.weight");
301            let gguf_name = self.translate(&weight_name).ok_or_else(|| {
302                FerrumError::model(format!(
303                    "GgufLoader: fusion source '{weight_name}' has no GGUF mapping"
304                ))
305            })?;
306            if !self.gguf.has_tensor(&gguf_name) {
307                return Err(FerrumError::model(format!(
308                    "GgufLoader: fusion source '{weight_name}' (gguf '{gguf_name}') missing"
309                )));
310            }
311
312            // Disqualifier 1: bias on this part — can't byte-concat that
313            // into a single QuantLinear.
314            let bias_name = self
315                .translate(&format!("{stem}.bias"))
316                .map(|n| self.gguf.has_tensor(&n))
317                .unwrap_or(false);
318            if bias_name {
319                return Ok(None);
320            }
321
322            let info = self.gguf.tensor_info(&gguf_name).ok_or_else(|| {
323                FerrumError::model(format!("tensor_info missing for '{gguf_name}'"))
324            })?;
325
326            // Disqualifier 2: not Q4K dtype.
327            if !matches!(info.ggml_dtype, candle_core::quantized::GgmlDType::Q4K) {
328                return Ok(None);
329            }
330
331            let dims = info.shape.dims();
332            if dims.len() != 2 {
333                return Ok(None);
334            }
335            let (rows, cols) = (dims[0], dims[1]);
336
337            // Disqualifier 3: cols not a multiple of 256 (Q4K super-block
338            // boundary) — should not happen for Q4K tensors, but guard
339            // anyway so byte-concat produces a valid block stream.
340            if cols % 256 != 0 {
341                return Ok(None);
342            }
343
344            match cols_check {
345                Some(c) if c != cols => {
346                    return Err(FerrumError::model(format!(
347                        "GgufLoader: fusion in_features mismatch ({c} vs {cols} for '{stem}')"
348                    )))
349                }
350                _ => cols_check = Some(cols),
351            }
352
353            // Read raw block bytes directly from the mmap (no candle
354            // QTensor intermediate copy). Fused tensors must still be
355            // byte-concatenated into a single buffer, so the fused
356            // payload itself remains a heap allocation — but it's
357            // a one-shot total ≪ MoE expert weights, so the
358            // consequence is negligible.
359            let bytes = self.gguf.tensor_byte_slice(&gguf_name).ok_or_else(|| {
360                FerrumError::model(format!(
361                    "GgufLoader: tensor_byte_slice failed for '{gguf_name}'"
362                ))
363            })?;
364            // Sanity: 144 bytes per super-block, super-blocks = rows * (cols / 256).
365            let expected = rows * (cols / 256) * 144;
366            debug_assert_eq!(
367                bytes.len(),
368                expected,
369                "Q4K byte count mismatch for '{gguf_name}': got {} expected {}",
370                bytes.len(),
371                expected
372            );
373
374            fused_bytes.extend_from_slice(bytes);
375            total_rows += rows;
376        }
377
378        let cols = cols_check.ok_or_else(|| FerrumError::model("fusion: no parts"))?;
379        let quant = crate::QuantLinear::<B>::from_gguf_bytes(
380            ferrum_kernels::backend::GgufQuantType::Q4K,
381            &fused_bytes,
382            total_rows,
383            cols,
384        )?;
385        Ok(Some(Box::new(quant)))
386    }
387
388    /// Eager (dequant-to-fp32 then concat) fusion. Used for non-Q4K parts
389    /// or parts with bias. See `load_fused` doc for the trade-off.
390    fn load_fused_eager(&self, parts: &[String]) -> Result<Box<dyn Linear<B>>> {
391        let mut fused: Vec<f32> = Vec::new();
392        let mut total_rows = 0usize;
393        let mut cols_check: Option<usize> = None;
394
395        for stem in parts {
396            let weight_name = format!("{stem}.weight");
397            let gguf_name = self.translate(&weight_name).ok_or_else(|| {
398                FerrumError::model(format!(
399                    "GgufLoader: fusion source '{weight_name}' has no GGUF mapping"
400                ))
401            })?;
402            if !self.gguf.has_tensor(&gguf_name) {
403                return Err(FerrumError::model(format!(
404                    "GgufLoader: fusion source '{weight_name}' (gguf '{gguf_name}') missing"
405                )));
406            }
407            let (rows, cols) = self.rows_cols(&gguf_name)?;
408            match cols_check {
409                Some(c) if c != cols => {
410                    return Err(FerrumError::model(format!(
411                        "GgufLoader: fusion in_features mismatch ({c} vs {cols} for '{stem}')"
412                    )))
413                }
414                _ => cols_check = Some(cols),
415            }
416            let data = self.read_dequant(&gguf_name)?;
417            debug_assert_eq!(data.len(), rows * cols);
418            fused.extend_from_slice(&data);
419            total_rows += rows;
420        }
421
422        let cols = cols_check.ok_or_else(|| FerrumError::model("fusion: no parts"))?;
423        Ok(Box::new(GgufLinear::<B>::from_dense_rows(
424            &fused, total_rows, cols,
425        )))
426    }
427}
428
429impl<B: Backend + BackendQuantGguf + BackendQuantMarlin> WeightLoader<B> for GgufLoader<B> {
430    fn load_tensor(&self, name: &str) -> Result<B::Buffer> {
431        let gguf_name = self.locate(name)?;
432        let raw = self.read_dequant(&gguf_name)?;
433        Ok(B::from_slice(&raw))
434    }
435
436    fn load_linear(&self, name: &str) -> Result<Box<dyn Linear<B>>> {
437        // 1) Direct path: <name>.weight exists as a single GGUF tensor.
438        if let Some(gguf_weight) = self.translate(&format!("{name}.weight")) {
439            if self.gguf.has_tensor(&gguf_weight) {
440                // Inspect the on-disk dtype before reading the payload.
441                // Q4_K_M (and future k-quant flavours) get the QuantLinear
442                // path that keeps weights quantised in backend memory;
443                // F16 / F32 / non-Q4-K dtypes fall through to GgufLinear's
444                // eager-dequant DenseLinear path.
445                let info = self.gguf.tensor_info(&gguf_weight).ok_or_else(|| {
446                    FerrumError::model(format!("tensor_info missing for '{gguf_weight}'"))
447                })?;
448                let dims = info.shape.dims();
449                if dims.len() != 2 {
450                    return Err(FerrumError::model(format!(
451                        "GgufLoader::load_linear '{name}': expected rank-2 weight, got rank {}",
452                        dims.len()
453                    )));
454                }
455                let (n_rows, n_cols) = (dims[0], dims[1]);
456
457                let quant_kind = match info.ggml_dtype {
458                    candle_core::quantized::GgmlDType::Q4K => {
459                        Some(ferrum_kernels::backend::GgufQuantType::Q4K)
460                    }
461                    candle_core::quantized::GgmlDType::Q6K => {
462                        Some(ferrum_kernels::backend::GgufQuantType::Q6K)
463                    }
464                    _ => None,
465                };
466                if let Some(kind) = quant_kind {
467                    // Read raw block bytes and hand to QuantLinear.
468                    // Bias on quantised projections is rare in GGUF
469                    // (Qwen2.5 attention biases land as F32), so we
470                    // currently take the bias path only when the bias
471                    // tensor is present AND the weight is non-quantised.
472                    // For quantised weights with bias, fall back to
473                    // eager dequant so Phase 1B's bias support keeps
474                    // working.
475                    let has_bias = self
476                        .translate(&format!("{name}.bias"))
477                        .map(|n| self.gguf.has_tensor(&n))
478                        .unwrap_or(false);
479                    if !has_bias {
480                        // Zero-copy: slice the mmap directly. The
481                        // backend's registry (`register_gguf_mmap`)
482                        // recognises the slice as belonging to the
483                        // shared file buffer and returns a `QuantStore`
484                        // that bind-references the big buffer with an
485                        // offset, instead of allocating a fresh device
486                        // copy. Falls back to copy if no registration
487                        // covers this slice.
488                        let bytes = self.gguf.tensor_byte_slice(&gguf_weight).ok_or_else(|| {
489                            FerrumError::model(format!(
490                                "GgufLoader: tensor_byte_slice failed for '{gguf_weight}'"
491                            ))
492                        })?;
493                        let quant =
494                            crate::QuantLinear::<B>::from_gguf_bytes(kind, bytes, n_rows, n_cols)?;
495                        return Ok(Box::new(quant));
496                    }
497                    // else fall through to eager-dequant bias path below
498                }
499
500                let qt = self
501                    .gguf
502                    .read_tensor(&gguf_weight, &self.decode_device)
503                    .map_err(candle_to_ferrum)?;
504                if let Some(gguf_bias) = self.translate(&format!("{name}.bias")) {
505                    if self.gguf.has_tensor(&gguf_bias) {
506                        let bqt = self
507                            .gguf
508                            .read_tensor(&gguf_bias, &self.decode_device)
509                            .map_err(candle_to_ferrum)?;
510                        let linear = GgufLinear::<B>::from_qtensor_with_bias(&qt, &bqt)
511                            .map_err(candle_to_ferrum)?;
512                        return Ok(Box::new(linear));
513                    }
514                }
515                let linear = GgufLinear::<B>::from_qtensor(&qt).map_err(candle_to_ferrum)?;
516                return Ok(Box::new(linear));
517            }
518        }
519
520        // 2) Fusion path: qkv_proj from q_proj/k_proj/v_proj
521        if let Some(layer_prefix) = name.strip_suffix("self_attn.qkv_proj") {
522            let parts = qkv_split_parts(layer_prefix);
523            return self.load_fused(&parts);
524        }
525        // 3) Fusion path: gate_up_proj from gate_proj/up_proj
526        if let Some(layer_prefix) = name.strip_suffix("mlp.gate_up_proj") {
527            let parts = gate_up_split_parts(layer_prefix);
528            return self.load_fused(&parts);
529        }
530
531        Err(FerrumError::model(format!(
532            "GgufLoader: could not load Linear '{name}' — no direct weight, no split components"
533        )))
534    }
535
536    fn has_tensor(&self, name: &str) -> bool {
537        match self.translate(name) {
538            Some(g) => self.gguf.has_tensor(&g),
539            None => false,
540        }
541    }
542
543    fn quant_config(&self) -> Option<&QuantConfig> {
544        // Phase 1C doesn't surface a QuantConfig — every tensor in a GGUF
545        // declares its own dtype (`GgmlDType`) per descriptor, so the
546        // model's existing branching on QuantConfig::method isn't useful
547        // here. Phase 1D may add a derived config if downstream code grows
548        // a need for it.
549        None
550    }
551}
552
553fn candle_to_ferrum(e: candle_core::Error) -> FerrumError {
554    FerrumError::model(format!("candle: {e}"))
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn gguf_loader_runtime_config_parses_load_trace_presence() {
563        let cfg =
564            GgufLoaderRuntimeConfig::from_env_vars([(GGUF_LOAD_TRACE_ENV, ""), ("OTHER", "1")]);
565        assert!(cfg.load_trace);
566
567        let cfg = GgufLoaderRuntimeConfig::from_env_vars([("OTHER", "1")]);
568        assert!(!cfg.load_trace);
569    }
570}