ferrum_quantization/gguf/
loader.rs1use 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
60pub struct GgufLoader<B: Backend + BackendQuantGguf + BackendQuantMarlin> {
66 gguf: Arc<GgufFile>,
67 decode_device: Device,
72 runtime_config: GgufLoaderRuntimeConfig,
73 arch: String,
77 _marker: std::marker::PhantomData<B>,
78}
79
80impl<B: Backend + BackendQuantGguf + BackendQuantMarlin> GgufLoader<B> {
81 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 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 fn translate(&self, ferrum_name: &str) -> Option<String> {
110 crate::gguf::names::ferrum_to_gguf_with_arch(&self.arch, ferrum_name)
111 }
112
113 pub fn gguf(&self) -> &GgufFile {
117 &self.gguf
118 }
119
120 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 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 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 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 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 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), };
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 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), };
289 Ok(Some(Box::new(quant)))
290 }
291
292 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 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 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 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 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 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 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 if let Some(gguf_weight) = self.translate(&format!("{name}.weight")) {
439 if self.gguf.has_tensor(&gguf_weight) {
440 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 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 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 }
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 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 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 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}