1use std::collections::HashMap;
18use std::fs::File;
19use std::path::Path;
20
21use ferrum_kernels::backend::{Backend, BackendQuantMarlin, SrcDtype};
22use ferrum_kernels::LinearMetadata;
23use ferrum_types::{FerrumError, Result};
24use half::{bf16, f16};
25use memmap2::Mmap;
26use safetensors::{Dtype, SafeTensors};
27
28fn map_src_dtype(dtype: Dtype) -> Result<SrcDtype> {
30 match dtype {
31 Dtype::F32 => Ok(SrcDtype::F32),
32 Dtype::F16 => Ok(SrcDtype::F16),
33 Dtype::BF16 => Ok(SrcDtype::BF16),
34 other => Err(FerrumError::model(format!(
35 "dtype {other:?} not supported; Dense path expects F32/F16/BF16"
36 ))),
37 }
38}
39
40use crate::config::{QuantConfig, QuantMethod};
41use crate::dense::DenseLinear;
42use crate::gptq::GptqLinear;
43use crate::loader::WeightLoader;
44use crate::traits::Linear;
45
46struct TensorMeta {
52 dtype: Dtype,
53 shape: Vec<usize>,
54 data_start: usize,
56 data_end: usize,
57}
58
59struct Shard {
61 mmap: Mmap,
62 names: Vec<String>,
63 meta: HashMap<String, TensorMeta>,
66}
67
68impl Shard {
69 fn open(path: &Path) -> Result<Self> {
70 let file = File::open(path).map_err(|e| FerrumError::io(format!("open {path:?}: {e}")))?;
71 let mmap = unsafe {
72 Mmap::map(&file).map_err(|e| FerrumError::io(format!("mmap {path:?}: {e}")))?
73 };
74 let st = SafeTensors::deserialize(&mmap)
78 .map_err(|e| FerrumError::model(format!("parse {path:?}: {e}")))?;
79 debug_assert!(mmap.len() >= 8, "safetensors smaller than 8 bytes");
84 let header_len = u64::from_le_bytes(
85 mmap[0..8]
86 .try_into()
87 .expect("8-byte header len read failed"),
88 ) as usize;
89 let data_base = 8 + header_len;
90 let names: Vec<String> = st.names().iter().map(|s| s.to_string()).collect();
91 let mut meta = HashMap::with_capacity(names.len());
92 for name in &names {
93 let view = st.tensor(name).map_err(|e| {
94 FerrumError::model(format!("tensor '{name}' missing during preindex: {e}"))
95 })?;
96 let view_data = view.data();
100 let start = view_data.as_ptr() as usize - mmap.as_ptr() as usize;
101 let end = start + view_data.len();
102 debug_assert!(start >= data_base);
103 meta.insert(
104 name.clone(),
105 TensorMeta {
106 dtype: view.dtype(),
107 shape: view.shape().to_vec(),
108 data_start: start,
109 data_end: end,
110 },
111 );
112 }
113 let _ = data_base;
114 Ok(Self { mmap, names, meta })
115 }
116
117 fn get_cached(&self, name: &str) -> Result<(&[u8], Dtype, &[usize])> {
120 let m = self
121 .meta
122 .get(name)
123 .ok_or_else(|| FerrumError::model(format!("tensor '{name}' not in shard")))?;
124 Ok((&self.mmap[m.data_start..m.data_end], m.dtype, &m.shape))
125 }
126}
127
128pub struct NativeSafetensorsLoader<B: Backend + BackendQuantMarlin> {
131 shards: Vec<Shard>,
133 index: HashMap<String, usize>,
135 quant_config: Option<QuantConfig>,
137 _m: std::marker::PhantomData<B>,
138}
139
140impl<B: Backend + BackendQuantMarlin> NativeSafetensorsLoader<B> {
141 pub fn open(model_dir: impl AsRef<Path>) -> Result<Self> {
143 let dir = model_dir.as_ref();
144
145 let shard_paths = if dir.join("model.safetensors").exists() {
146 vec![dir.join("model.safetensors")]
147 } else if dir.join("model.safetensors.index.json").exists() {
148 Self::parse_sharded_index(&dir.join("model.safetensors.index.json"))?
149 .into_iter()
150 .map(|name| dir.join(name))
151 .collect()
152 } else {
153 return Err(FerrumError::model(format!(
154 "no safetensors files in {dir:?}"
155 )));
156 };
157
158 let mut shards = Vec::with_capacity(shard_paths.len());
159 let mut index: HashMap<String, usize> = HashMap::new();
160 for (i, p) in shard_paths.iter().enumerate() {
161 let shard = Shard::open(p)?;
162 for name in &shard.names {
163 index.insert(name.clone(), i);
164 }
165 shards.push(shard);
166 }
167
168 let quant_config = load_quantize_config(dir)?;
169
170 Ok(Self {
171 shards,
172 index,
173 quant_config,
174 _m: std::marker::PhantomData,
175 })
176 }
177
178 fn parse_sharded_index(index_path: &Path) -> Result<Vec<String>> {
179 let data = std::fs::read_to_string(index_path)
180 .map_err(|e| FerrumError::io(format!("read {index_path:?}: {e}")))?;
181 let json: serde_json::Value = serde_json::from_str(&data)
182 .map_err(|e| FerrumError::serialization(format!("index json: {e}")))?;
183 let weight_map = json
184 .get("weight_map")
185 .and_then(|v| v.as_object())
186 .ok_or_else(|| FerrumError::model("index missing weight_map"))?;
187 let mut files: Vec<String> = weight_map
188 .values()
189 .filter_map(|v| v.as_str().map(|s| s.to_string()))
190 .collect();
191 files.sort();
192 files.dedup();
193 Ok(files)
194 }
195
196 fn read_f32(&self, name: &str) -> Result<(Vec<f32>, Vec<usize>)> {
198 let shard_idx = *self
199 .index
200 .get(name)
201 .ok_or_else(|| FerrumError::model(format!("tensor '{name}' not in index")))?;
202 let (data_bytes, dtype, shape) = self.shards[shard_idx].get_cached(name)?;
203 let data = dtype_to_f32(dtype, data_bytes)?;
204 Ok((data, shape.to_vec()))
205 }
206
207 fn read_bytes_typed(&self, name: &str) -> Result<(&[u8], SrcDtype, Vec<usize>)> {
211 let shard_idx = *self
212 .index
213 .get(name)
214 .ok_or_else(|| FerrumError::model(format!("tensor '{name}' not in index")))?;
215 let (data_bytes, st_dtype, shape) = self.shards[shard_idx].get_cached(name)?;
216 let dtype = map_src_dtype(st_dtype)?;
217 Ok((data_bytes, dtype, shape.to_vec()))
218 }
219
220 fn cat_rows_bytes(&self, names: &[String]) -> Result<(Vec<u8>, SrcDtype, (usize, usize))> {
224 let mut total_rows = 0usize;
225 let mut cols = 0usize;
226 let mut dtype: Option<SrcDtype> = None;
227 let mut bytes: Vec<u8> = Vec::new();
228 for n in names {
229 let (raw, d, shape) = self.read_bytes_typed(n)?;
230 if shape.len() != 2 {
231 return Err(FerrumError::model(format!(
232 "cat_rows_bytes: '{n}' is {shape:?}, need 2D"
233 )));
234 }
235 match dtype {
236 Some(prev) if prev != d => {
237 return Err(FerrumError::model(format!(
238 "cat_rows_bytes: dtype mismatch on '{n}'"
239 )))
240 }
241 _ => dtype = Some(d),
242 }
243 if cols == 0 {
244 cols = shape[1];
245 } else if cols != shape[1] {
246 return Err(FerrumError::model(format!(
247 "cat_rows_bytes: col mismatch {cols} vs {}",
248 shape[1]
249 )));
250 }
251 total_rows += shape[0];
252 bytes.extend_from_slice(raw);
253 }
254 Ok((bytes, dtype.expect("at least one part"), (total_rows, cols)))
255 }
256
257 fn cat_optional_biases(
261 &self,
262 weight_names: &[String],
263 out_features: usize,
264 ) -> Result<Option<Vec<f32>>> {
265 let bias_names: Vec<String> = weight_names
266 .iter()
267 .map(|name| {
268 name.strip_suffix(".weight")
269 .map(|stem| format!("{stem}.bias"))
270 .unwrap_or_else(|| format!("{name}.bias"))
271 })
272 .collect();
273 let any_bias = bias_names.iter().any(|name| self.has(name));
274 if !any_bias {
275 return Ok(None);
276 }
277 if let Some(missing) = bias_names.iter().find(|name| !self.has(name)) {
278 return Err(FerrumError::model(format!(
279 "dense fusion bias mix: '{missing}' missing while another fused part has bias"
280 )));
281 }
282 let mut fused = Vec::new();
283 for name in &bias_names {
284 let (bias, shape) = self.read_f32(name)?;
285 if shape.len() != 1 {
286 return Err(FerrumError::model(format!(
287 "dense fusion bias '{name}': expected 1D, got {shape:?}"
288 )));
289 }
290 fused.extend_from_slice(&bias);
291 }
292 if fused.len() != out_features {
293 return Err(FerrumError::model(format!(
294 "dense fusion bias length {} != out_features {out_features}",
295 fused.len()
296 )));
297 }
298 Ok(Some(fused))
299 }
300
301 fn read_i32(&self, name: &str) -> Result<(Vec<i32>, Vec<usize>)> {
306 let shard_idx = *self
307 .index
308 .get(name)
309 .ok_or_else(|| FerrumError::model(format!("tensor '{name}' not in index")))?;
310 let (bytes, dtype, shape) = self.shards[shard_idx].get_cached(name)?;
311 if dtype != Dtype::I32 {
312 return Err(FerrumError::model(format!(
313 "'{name}': expected I32, got {:?}",
314 dtype
315 )));
316 }
317 debug_assert_eq!(bytes.len() % 4, 0);
318 let count = bytes.len() / 4;
319 let mut out = Vec::<i32>::with_capacity(count);
320 unsafe {
325 std::ptr::copy_nonoverlapping(bytes.as_ptr(), out.as_mut_ptr() as *mut u8, bytes.len());
326 out.set_len(count);
327 }
328 Ok((out, shape.to_vec()))
329 }
330
331 fn has(&self, name: &str) -> bool {
332 self.index.contains_key(name)
333 }
334
335 pub fn read_gptq_raw(
343 &self,
344 name: &str,
345 ) -> Result<(Vec<i32>, Vec<f32>, Vec<i32>, Option<Vec<i32>>, usize, usize)> {
346 let (qweight, qw_shape) = self.read_i32(&format!("{name}.qweight"))?;
347 let (scales, _) = self.read_f32(&format!("{name}.scales"))?;
348 let (mut qzeros, _) = self.read_i32(&format!("{name}.qzeros"))?;
349 if let Some(qcfg) = self.quant_config.as_ref() {
350 canonicalize_gptq_qzeros_for_sym(qcfg, &mut qzeros);
351 }
352 let g_idx = if self.has(&format!("{name}.g_idx")) {
353 Some(self.read_i32(&format!("{name}.g_idx"))?.0)
354 } else {
355 None
356 };
357 if qw_shape.len() != 2 {
358 return Err(FerrumError::model(format!(
359 "'{name}.qweight' expected 2D, got {qw_shape:?}"
360 )));
361 }
362 let k = qw_shape[0] * 8;
363 let n = qw_shape[1];
364 Ok((qweight, scales, qzeros, g_idx, k, n))
365 }
366
367 pub fn quant_config_ref(&self) -> Option<&crate::config::QuantConfig> {
368 self.quant_config.as_ref()
369 }
370
371 pub fn load_stacked_gptq_experts(
389 &self,
390 expert_prefix_fmt: &str,
391 num_experts: usize,
392 proj_names: &[&str],
393 ) -> Result<(
394 std::sync::Arc<dyn ferrum_kernels::MarlinExpertStack<B>>,
395 usize,
396 usize,
397 )> {
398 let qcfg = self.quant_config.as_ref().ok_or_else(|| {
399 FerrumError::model(
400 "load_stacked_gptq_experts requires quantize_config.json".to_string(),
401 )
402 })?;
403 if qcfg.method != QuantMethod::Gptq {
404 return Err(FerrumError::model(format!(
405 "stacked GPTQ load but quant_method={:?}",
406 qcfg.method
407 )));
408 }
409
410 let mut qw_rows = 0usize;
411 let mut sc_rows = 0usize;
412 let mut qz_rows = 0usize;
413 let mut n_per_expert = 0usize;
414 let mut n_per_expert_scales = 0usize;
415 let mut n_per_expert_zeros = 0usize;
416 let mut k_shared = 0usize;
417 let mut g_idx_first: Option<Vec<i32>> = None;
418
419 let total_pairs = num_experts * proj_names.len();
421 let mut qw_parts: Vec<(Vec<i32>, usize)> = Vec::with_capacity(total_pairs); let mut sc_parts: Vec<(Vec<f32>, usize)> = Vec::with_capacity(total_pairs);
423 let mut qz_parts: Vec<(Vec<i32>, usize)> = Vec::with_capacity(total_pairs);
424
425 for e in 0..num_experts {
426 let prefix = expert_prefix_fmt.replace("{e}", &e.to_string());
427 let mut e_n = 0usize;
428 let mut e_n_scales = 0usize;
429 let mut e_n_zeros = 0usize;
430 for proj in proj_names {
431 let name = format!("{prefix}{proj}");
432 let (qw, qw_sh) = self.read_i32(&format!("{name}.qweight"))?;
433 let (sc, sc_sh) = self.read_f32(&format!("{name}.scales"))?;
434 let (mut qz, qz_sh) = self.read_i32(&format!("{name}.qzeros"))?;
435 canonicalize_gptq_qzeros_for_sym(qcfg, &mut qz);
436 if qw_sh.len() != 2 || sc_sh.len() != 2 || qz_sh.len() != 2 {
437 return Err(FerrumError::model(format!(
438 "stacked GPTQ '{name}': expected 2D, got qw {qw_sh:?} sc {sc_sh:?} qz {qz_sh:?}"
439 )));
440 }
441 if qw_rows == 0 {
442 qw_rows = qw_sh[0];
443 sc_rows = sc_sh[0];
444 qz_rows = qz_sh[0];
445 k_shared = qw_sh[0] * 8;
446 } else if qw_sh[0] != qw_rows || sc_sh[0] != sc_rows || qz_sh[0] != qz_rows {
447 return Err(FerrumError::model(format!(
448 "stacked GPTQ '{name}': row mismatch qw {} sc {} qz {} vs ref {qw_rows}/{sc_rows}/{qz_rows}",
449 qw_sh[0], sc_sh[0], qz_sh[0]
450 )));
451 }
452 e_n += qw_sh[1];
453 e_n_scales += sc_sh[1];
454 e_n_zeros += qz_sh[1];
455 qw_parts.push((qw, qw_sh[1]));
456 sc_parts.push((sc, sc_sh[1]));
457 qz_parts.push((qz, qz_sh[1]));
458
459 let g_key = format!("{name}.g_idx");
465 if self.has(&g_key) {
466 let (gx, _) = self.read_i32(&g_key)?;
467 match &g_idx_first {
468 None => g_idx_first = Some(gx),
469 Some(prev) => {
470 if prev.len() != gx.len() || prev.iter().zip(&gx).any(|(a, b)| a != b) {
471 return Err(FerrumError::model(format!(
472 "stacked GPTQ '{name}': g_idx mismatch with first \
473 expert — Marlin requires identical act-order across \
474 experts in the same stacked tile"
475 )));
476 }
477 }
478 }
479 }
480 }
481 if e == 0 {
482 n_per_expert = e_n;
483 n_per_expert_scales = e_n_scales;
484 n_per_expert_zeros = e_n_zeros;
485 } else if e_n != n_per_expert
486 || e_n_scales != n_per_expert_scales
487 || e_n_zeros != n_per_expert_zeros
488 {
489 return Err(FerrumError::model(format!(
490 "stacked GPTQ expert {e} N mismatch: qw {e_n} sc {e_n_scales} qz {e_n_zeros} vs expert 0 {n_per_expert}/{n_per_expert_scales}/{n_per_expert_zeros}"
491 )));
492 }
493 }
494
495 let proj_count = proj_names.len();
496 let pairs_per_expert = proj_count;
497 debug_assert_eq!(total_pairs, num_experts * pairs_per_expert);
498
499 let mut per_expert_qw: Vec<Vec<i32>> = Vec::with_capacity(num_experts);
512 let mut per_expert_sc: Vec<Vec<f32>> = Vec::with_capacity(num_experts);
513 let mut per_expert_qz: Vec<Vec<i32>> = Vec::with_capacity(num_experts);
514 for e in 0..num_experts {
515 let mut qw: Vec<i32> = Vec::with_capacity(qw_rows * n_per_expert);
516 let mut sc: Vec<f32> = Vec::with_capacity(sc_rows * n_per_expert_scales);
517 let mut qz: Vec<i32> = Vec::with_capacity(qz_rows * n_per_expert_zeros);
518 for r in 0..qw_rows {
519 for j in 0..pairs_per_expert {
520 let pair_idx = e * pairs_per_expert + j;
521 let (data, cols) = &qw_parts[pair_idx];
522 qw.extend_from_slice(&data[r * cols..(r + 1) * cols]);
523 }
524 }
525 for r in 0..sc_rows {
526 for j in 0..pairs_per_expert {
527 let pair_idx = e * pairs_per_expert + j;
528 let (data, cols) = &sc_parts[pair_idx];
529 sc.extend_from_slice(&data[r * cols..(r + 1) * cols]);
530 }
531 }
532 for r in 0..qz_rows {
533 for j in 0..pairs_per_expert {
534 let pair_idx = e * pairs_per_expert + j;
535 let (data, cols) = &qz_parts[pair_idx];
536 qz.extend_from_slice(&data[r * cols..(r + 1) * cols]);
537 }
538 }
539 per_expert_qw.push(qw);
540 per_expert_sc.push(sc);
541 per_expert_qz.push(qz);
542 }
543
544 drop(qw_parts);
546 drop(sc_parts);
547 drop(qz_parts);
548
549 let qw_refs: Vec<&[i32]> = per_expert_qw.iter().map(|v| v.as_slice()).collect();
550 let sc_refs: Vec<&[f32]> = per_expert_sc.iter().map(|v| v.as_slice()).collect();
551 let qz_refs: Vec<&[i32]> = per_expert_qz.iter().map(|v| v.as_slice()).collect();
552
553 let is_desc_act = validate_gptq_g_idx(
554 "stacked GPTQ experts",
555 qcfg,
556 g_idx_first.as_deref(),
557 k_shared,
558 )?;
559 #[cfg(feature = "cuda")]
560 if is_desc_act {
561 validate_cuda_marlin_desc_act_g_idx(
562 "stacked GPTQ experts",
563 qcfg,
564 g_idx_first
565 .as_deref()
566 .expect("desc_act=true requires g_idx"),
567 k_shared,
568 )?;
569 }
570 #[cfg(not(feature = "cuda"))]
571 let _ = is_desc_act;
572
573 let store = B::load_gptq_stacked(
574 &qw_refs,
575 &sc_refs,
576 &qz_refs,
577 g_idx_first.as_deref(),
578 qcfg.bits,
579 qcfg.group_size,
580 k_shared,
581 n_per_expert,
582 )?;
583 Ok((store, n_per_expert, k_shared))
584 }
585}
586
587impl<B: Backend + BackendQuantMarlin> WeightLoader<B> for NativeSafetensorsLoader<B> {
588 fn load_tensor(&self, name: &str) -> Result<B::Buffer> {
589 let (raw, src_dtype, _) = self.read_bytes_typed(name)?;
594 Ok(B::from_weight_bytes(raw, src_dtype))
595 }
596
597 fn load_linear(&self, name: &str) -> Result<Box<dyn Linear<B>>> {
598 let qw_key = format!("{name}.qweight");
600 if self.has(&qw_key) {
601 return self.load_gptq_linear(name);
602 }
603 if let Some(prefix) = name.strip_suffix("qkv_proj") {
607 let parts = [
608 format!("{prefix}q_proj"),
609 format!("{prefix}k_proj"),
610 format!("{prefix}v_proj"),
611 ];
612 if parts.iter().all(|p| self.has(&format!("{p}.qweight"))) {
613 return self.load_gptq_linear_fused(&parts);
614 }
615 }
616 if let Some(prefix) = name.strip_suffix("gate_up_proj") {
617 let parts = [format!("{prefix}gate_proj"), format!("{prefix}up_proj")];
618 if parts.iter().all(|p| self.has(&format!("{p}.qweight"))) {
619 return self.load_gptq_linear_fused(&parts);
620 }
621 }
622 if let Some(prefix) = name.strip_suffix("in_proj_qkvz") {
623 let parts = [format!("{prefix}in_proj_qkv"), format!("{prefix}in_proj_z")];
624 if parts.iter().all(|p| self.has(&format!("{p}.qweight"))) {
625 return self.load_gptq_linear_fused(&parts);
626 }
627 }
628 if let Some(prefix) = name.strip_suffix("in_proj_ba") {
629 let parts = [format!("{prefix}in_proj_b"), format!("{prefix}in_proj_a")];
630 if parts.iter().all(|p| self.has(&format!("{p}.qweight"))) {
631 return self.load_gptq_linear_fused(&parts);
632 }
633 }
634
635 let direct = format!("{name}.weight");
638 if self.has(&direct) {
639 let (raw, src_dtype, shape) = self.read_bytes_typed(&direct)?;
640 if shape.len() != 2 {
641 return Err(FerrumError::model(format!(
642 "linear '{name}': expected 2D weight, got {shape:?}"
643 )));
644 }
645 let weight = B::from_weight_bytes(raw, src_dtype);
646 return Ok(Box::new(
647 DenseLinear::<B>::from_buffer(weight, shape[0], shape[1])
648 .with_metadata(LinearMetadata::from_name(name)),
649 ));
650 }
651
652 if let Some(prefix) = name.strip_suffix("qkv_proj") {
657 let parts = [
658 format!("{prefix}q_proj.weight"),
659 format!("{prefix}k_proj.weight"),
660 format!("{prefix}v_proj.weight"),
661 ];
662 if parts.iter().all(|p| self.has(p)) {
663 let (bytes, dtype, (rows, cols)) = self.cat_rows_bytes(&parts)?;
664 let weight = B::from_weight_bytes(&bytes, dtype);
665 let mut linear = DenseLinear::<B>::from_buffer(weight, rows, cols).with_metadata(
666 LinearMetadata::from_fused_names(parts.iter().map(String::as_str)),
667 );
668 if let Some(bias) = self.cat_optional_biases(&parts, rows)? {
669 linear = linear.with_bias(B::from_slice(&bias));
670 }
671 return Ok(Box::new(linear));
672 }
673 }
674 if let Some(prefix) = name.strip_suffix("gate_up_proj") {
675 let parts = [
676 format!("{prefix}gate_proj.weight"),
677 format!("{prefix}up_proj.weight"),
678 ];
679 if parts.iter().all(|p| self.has(p)) {
680 let (bytes, dtype, (rows, cols)) = self.cat_rows_bytes(&parts)?;
681 let weight = B::from_weight_bytes(&bytes, dtype);
682 let mut linear = DenseLinear::<B>::from_buffer(weight, rows, cols).with_metadata(
683 LinearMetadata::from_fused_names(parts.iter().map(String::as_str)),
684 );
685 if let Some(bias) = self.cat_optional_biases(&parts, rows)? {
686 linear = linear.with_bias(B::from_slice(&bias));
687 }
688 return Ok(Box::new(linear));
689 }
690 }
691 if let Some(prefix) = name.strip_suffix("in_proj_qkvz") {
692 let parts = [
693 format!("{prefix}in_proj_qkv.weight"),
694 format!("{prefix}in_proj_z.weight"),
695 ];
696 if parts.iter().all(|p| self.has(p)) {
697 let (bytes, dtype, (rows, cols)) = self.cat_rows_bytes(&parts)?;
698 let weight = B::from_weight_bytes(&bytes, dtype);
699 let mut linear = DenseLinear::<B>::from_buffer(weight, rows, cols).with_metadata(
700 LinearMetadata::from_fused_names(parts.iter().map(String::as_str)),
701 );
702 if let Some(bias) = self.cat_optional_biases(&parts, rows)? {
703 linear = linear.with_bias(B::from_slice(&bias));
704 }
705 return Ok(Box::new(linear));
706 }
707 }
708 if let Some(prefix) = name.strip_suffix("in_proj_ba") {
709 let parts = [
710 format!("{prefix}in_proj_b.weight"),
711 format!("{prefix}in_proj_a.weight"),
712 ];
713 if parts.iter().all(|p| self.has(p)) {
714 let (bytes, dtype, (rows, cols)) = self.cat_rows_bytes(&parts)?;
715 let weight = B::from_weight_bytes(&bytes, dtype);
716 let mut linear = DenseLinear::<B>::from_buffer(weight, rows, cols).with_metadata(
717 LinearMetadata::from_fused_names(parts.iter().map(String::as_str)),
718 );
719 if let Some(bias) = self.cat_optional_biases(&parts, rows)? {
720 linear = linear.with_bias(B::from_slice(&bias));
721 }
722 return Ok(Box::new(linear));
723 }
724 }
725
726 Err(FerrumError::model(format!(
727 "could not load linear '{name}' — no direct `.weight`, no split components"
728 )))
729 }
730
731 fn has_tensor(&self, name: &str) -> bool {
732 self.has(name)
733 }
734
735 fn quant_config(&self) -> Option<&QuantConfig> {
736 self.quant_config.as_ref()
737 }
738
739 fn load_stacked_gptq_experts(
740 &self,
741 expert_prefix_fmt: &str,
742 num_experts: usize,
743 proj_names: &[&str],
744 ) -> Result<(
745 std::sync::Arc<dyn ferrum_kernels::MarlinExpertStack<B>>,
746 usize,
747 usize,
748 )> {
749 NativeSafetensorsLoader::load_stacked_gptq_experts(
750 self,
751 expert_prefix_fmt,
752 num_experts,
753 proj_names,
754 )
755 }
756}
757
758impl<B: Backend + BackendQuantMarlin> NativeSafetensorsLoader<B> {
759 fn load_gptq_linear(&self, name: &str) -> Result<Box<dyn Linear<B>>> {
764 let qcfg = self.quant_config.as_ref().ok_or_else(|| {
765 FerrumError::model(format!(
766 "'{name}.qweight' present but no quantize_config.json — \
767 can't determine bits/group_size"
768 ))
769 })?;
770 if qcfg.method != QuantMethod::Gptq {
771 return Err(FerrumError::model(format!(
772 "'{name}.qweight' present but quant_method={:?} (expected GPTQ)",
773 qcfg.method
774 )));
775 }
776
777 let (qweight, qw_shape) = self.read_i32(&format!("{name}.qweight"))?;
778 let (scales_f32, sc_shape) = self.read_f32(&format!("{name}.scales"))?;
779 let (mut qzeros, _qz_shape) = self.read_i32(&format!("{name}.qzeros"))?;
780 canonicalize_gptq_qzeros_for_sym(qcfg, &mut qzeros);
781 let g_idx = if self.has(&format!("{name}.g_idx")) {
782 Some(self.read_i32(&format!("{name}.g_idx"))?.0)
783 } else {
784 None
785 };
786
787 if qw_shape.len() != 2 {
790 return Err(FerrumError::model(format!(
791 "'{name}.qweight' expected 2D, got {qw_shape:?}"
792 )));
793 }
794 let in_features = qw_shape[0] * 8;
795 let out_features = qw_shape[1];
796
797 let is_desc_act = validate_gptq_g_idx(name, qcfg, g_idx.as_deref(), in_features)?;
798 trace_gptq_g_idx_if_requested(name, qcfg, g_idx.as_deref(), in_features, is_desc_act);
799 trace_gptq_qzeros_if_requested(name, qcfg, &qzeros, out_features);
800
801 #[cfg(not(feature = "cuda"))]
806 if is_desc_act {
807 let dequant_f32 = dequantize_gptq_with_g_idx(
808 &qweight,
809 &scales_f32,
810 &qzeros,
811 g_idx.as_ref().expect("desc_act=true requires g_idx"),
812 qcfg.group_size,
813 in_features,
814 out_features,
815 );
816 let mut linear =
817 crate::dense::DenseLinear::<B>::from_rows(&dequant_f32, out_features, in_features)
818 .with_metadata(LinearMetadata::from_name(name));
819 let bias_key = format!("{name}.bias");
820 if self.has(&bias_key) {
821 let (bias, _) = self.read_f32(&bias_key)?;
822 linear = linear.with_bias(B::from_slice(&bias));
823 }
824 tracing::info!(
825 "GPTQ load (desc_act dequant→DenseLinear, non-cuda): name={name} K={in_features} N={out_features}"
826 );
827 return Ok(Box::new(linear));
828 }
829 #[cfg(feature = "cuda")]
830 if is_desc_act {
831 validate_cuda_marlin_desc_act_g_idx(
832 name,
833 qcfg,
834 g_idx.as_deref().expect("desc_act=true requires g_idx"),
835 in_features,
836 )?;
837 }
838 if sc_shape.len() != 2 || sc_shape[1] != out_features {
839 return Err(FerrumError::model(format!(
840 "'{name}.scales' {sc_shape:?} incompatible with qweight {qw_shape:?}"
841 )));
842 }
843
844 let bias_key = format!("{name}.bias");
848 let bias_vec = if self.has(&bias_key) {
849 let (bias, bias_shape) = self.read_f32(&bias_key)?;
850 if bias_shape != [out_features] {
851 return Err(FerrumError::model(format!(
852 "'{bias_key}' {bias_shape:?} != [{out_features}]"
853 )));
854 }
855 Some(bias)
856 } else {
857 None
858 };
859
860 let linear = GptqLinear::<B>::from_raw_with_metadata(
861 &qweight,
862 &scales_f32,
863 &qzeros,
864 g_idx.as_deref(),
865 bias_vec.as_deref(),
866 qcfg.bits,
867 qcfg.group_size,
868 in_features,
869 out_features,
870 LinearMetadata::from_name(name),
871 )?;
872 Ok(Box::new(linear))
873 }
874
875 fn load_gptq_linear_fused(&self, parts: &[String]) -> Result<Box<dyn Linear<B>>> {
888 let qcfg = self.quant_config.as_ref().ok_or_else(|| {
889 FerrumError::model("GPTQ fusion requires quantize_config.json".to_string())
890 })?;
891 if qcfg.method != QuantMethod::Gptq {
892 return Err(FerrumError::model(format!(
893 "GPTQ fusion but quant_method={:?}",
894 qcfg.method
895 )));
896 }
897
898 let mut qw_acc: Vec<i32> = Vec::new();
899 let mut sc_acc: Vec<f32> = Vec::new();
900 let mut qz_acc: Vec<i32> = Vec::new();
901 let mut qw_rows = 0usize;
902 let mut sc_rows = 0usize;
903 let mut qz_rows = 0usize;
904 let mut total_n = 0usize;
905 let mut total_n_scales = 0usize;
906 let mut total_n_zeros = 0usize;
907 let mut g_idx: Option<Vec<i32>> = None;
908 let mut g_idx_presence: Vec<(String, bool)> = Vec::with_capacity(parts.len());
909 let mut qw_parts: Vec<(Vec<i32>, usize, usize)> = Vec::new(); let mut sc_parts: Vec<(Vec<f32>, usize, usize)> = Vec::new();
912 let mut qz_parts: Vec<(Vec<i32>, usize, usize)> = Vec::new();
913
914 for p in parts {
915 let (qw, qw_sh) = self.read_i32(&format!("{p}.qweight"))?;
916 let (sc, sc_sh) = self.read_f32(&format!("{p}.scales"))?;
917 let (mut qz, qz_sh) = self.read_i32(&format!("{p}.qzeros"))?;
918 canonicalize_gptq_qzeros_for_sym(qcfg, &mut qz);
919 if qw_sh.len() != 2 || sc_sh.len() != 2 || qz_sh.len() != 2 {
920 return Err(FerrumError::model(format!(
921 "GPTQ fusion '{p}': expected 2D tensors, got qw {qw_sh:?} sc {sc_sh:?} qz {qz_sh:?}"
922 )));
923 }
924 if qw_rows == 0 {
925 qw_rows = qw_sh[0];
926 sc_rows = sc_sh[0];
927 qz_rows = qz_sh[0];
928 } else if qw_sh[0] != qw_rows || sc_sh[0] != sc_rows || qz_sh[0] != qz_rows {
929 return Err(FerrumError::model(format!(
930 "GPTQ fusion row mismatch on '{p}'"
931 )));
932 }
933 total_n += qw_sh[1];
934 total_n_scales += sc_sh[1];
935 total_n_zeros += qz_sh[1];
936 qw_parts.push((qw, qw_sh[0], qw_sh[1]));
937 sc_parts.push((sc, sc_sh[0], sc_sh[1]));
938 qz_parts.push((qz, qz_sh[0], qz_sh[1]));
939
940 let g_key = format!("{p}.g_idx");
941 if self.has(&g_key) {
942 let (gx, gx_shape) = self.read_i32(&g_key)?;
943 if gx_shape != [qw_rows * 8] {
944 return Err(FerrumError::model(format!(
945 "GPTQ fusion '{p}': g_idx shape {gx_shape:?} incompatible with K={}",
946 qw_rows * 8
947 )));
948 }
949 match &g_idx {
950 None => g_idx = Some(gx),
951 Some(prev) => {
952 if prev.len() != gx.len() || prev.iter().zip(&gx).any(|(a, b)| a != b) {
953 return Err(FerrumError::model(format!(
954 "GPTQ fusion '{p}': g_idx mismatch with first part; \
955 fused qkv/gate_up requires identical act-order across parts"
956 )));
957 }
958 }
959 }
960 g_idx_presence.push((p.clone(), true));
961 } else {
962 g_idx_presence.push((p.clone(), false));
963 }
964 }
965
966 qw_acc.reserve(qw_rows * total_n);
968 for r in 0..qw_rows {
969 for (part, _rows, cols) in &qw_parts {
970 qw_acc.extend_from_slice(&part[r * cols..r * cols + cols]);
971 }
972 }
973 sc_acc.reserve(sc_rows * total_n_scales);
974 for r in 0..sc_rows {
975 for (part, _rows, cols) in &sc_parts {
976 sc_acc.extend_from_slice(&part[r * cols..r * cols + cols]);
977 }
978 }
979 qz_acc.reserve(qz_rows * total_n_zeros);
980 for r in 0..qz_rows {
981 for (part, _rows, cols) in &qz_parts {
982 qz_acc.extend_from_slice(&part[r * cols..r * cols + cols]);
983 }
984 }
985
986 let in_features = qw_rows * 8;
987 let out_features = total_n;
988
989 if g_idx.is_some() {
990 let missing = g_idx_presence
991 .iter()
992 .filter_map(|(part, present)| (!present).then_some(part.as_str()))
993 .collect::<Vec<_>>();
994 if !missing.is_empty() {
995 return Err(FerrumError::model(format!(
996 "GPTQ fusion requires all parts to carry g_idx when any part does; \
997 missing g_idx for {missing:?}"
998 )));
999 }
1000 }
1001 let fused_name = format!("GPTQ fusion {}", parts.join("+"));
1002 let is_desc_act = validate_gptq_g_idx(&fused_name, qcfg, g_idx.as_deref(), in_features)?;
1003 trace_gptq_g_idx_if_requested(
1004 &fused_name,
1005 qcfg,
1006 g_idx.as_deref(),
1007 in_features,
1008 is_desc_act,
1009 );
1010 trace_gptq_qzeros_if_requested(&fused_name, qcfg, &qz_acc, out_features);
1011 #[cfg(not(feature = "cuda"))]
1013 if is_desc_act {
1014 let dequant_f32 = dequantize_gptq_with_g_idx(
1015 &qw_acc,
1016 &sc_acc,
1017 &qz_acc,
1018 g_idx.as_ref().expect("desc_act=true requires g_idx"),
1019 qcfg.group_size,
1020 in_features,
1021 out_features,
1022 );
1023 let mut linear =
1024 crate::dense::DenseLinear::<B>::from_rows(&dequant_f32, out_features, in_features)
1025 .with_metadata(LinearMetadata::from_fused_names(
1026 parts.iter().map(String::as_str),
1027 ));
1028 let mut bias_acc: Vec<f32> = Vec::new();
1029 let mut any_bias = false;
1030 for p in parts {
1031 let bk = format!("{p}.bias");
1032 if self.has(&bk) {
1033 any_bias = true;
1034 bias_acc.extend_from_slice(&self.read_f32(&bk)?.0);
1035 } else if any_bias {
1036 return Err(FerrumError::model(format!(
1037 "GPTQ fusion bias mix: '{p}' has no bias but earlier part did"
1038 )));
1039 }
1040 }
1041 if any_bias {
1042 linear = linear.with_bias(B::from_slice(&bias_acc));
1043 }
1044 tracing::info!(
1045 "GPTQ fused load (desc_act dequant→DenseLinear, non-cuda): K={in_features} N={out_features} parts={}",
1046 parts.len()
1047 );
1048 return Ok(Box::new(linear));
1049 }
1050 #[cfg(feature = "cuda")]
1051 if is_desc_act {
1052 validate_cuda_marlin_desc_act_g_idx(
1053 &fused_name,
1054 qcfg,
1055 g_idx.as_deref().expect("desc_act=true requires g_idx"),
1056 in_features,
1057 )?;
1058 }
1059
1060 let bias_keys: Vec<String> = parts.iter().map(|p| format!("{p}.bias")).collect();
1064 let any = bias_keys.iter().any(|k| self.has(k));
1065 let all = bias_keys.iter().all(|k| self.has(k));
1066 if any && !all {
1067 return Err(FerrumError::model(
1068 "GPTQ fusion: inconsistent bias presence across parts".to_string(),
1069 ));
1070 }
1071 let fused_bias = if all {
1072 let mut fused: Vec<f32> = Vec::with_capacity(out_features);
1073 for k in &bias_keys {
1074 let (b, _) = self.read_f32(k)?;
1075 fused.extend_from_slice(&b);
1076 }
1077 if fused.len() != out_features {
1078 return Err(FerrumError::model(format!(
1079 "GPTQ fusion bias length {} != out_features {out_features}",
1080 fused.len()
1081 )));
1082 }
1083 Some(fused)
1084 } else {
1085 None
1086 };
1087
1088 let linear = GptqLinear::<B>::from_raw_with_metadata(
1089 &qw_acc,
1090 &sc_acc,
1091 &qz_acc,
1092 g_idx.as_deref(),
1093 fused_bias.as_deref(),
1094 qcfg.bits,
1095 qcfg.group_size,
1096 in_features,
1097 out_features,
1098 LinearMetadata::from_fused_names(parts.iter().map(String::as_str)),
1099 )?;
1100
1101 Ok(Box::new(linear))
1102 }
1103
1104 #[allow(dead_code)]
1108 fn cat_rows(&self, names: &[String]) -> Result<(usize, usize, Vec<f32>)> {
1109 let mut total_rows = 0usize;
1110 let mut cols = 0usize;
1111 let mut out: Vec<f32> = Vec::new();
1112 for n in names {
1113 let (data, shape) = self.read_f32(n)?;
1114 if shape.len() != 2 {
1115 return Err(FerrumError::model(format!(
1116 "cat_rows: '{n}' is {shape:?}, need 2D"
1117 )));
1118 }
1119 if cols == 0 {
1120 cols = shape[1];
1121 } else if cols != shape[1] {
1122 return Err(FerrumError::model(format!(
1123 "cat_rows: col mismatch {cols} vs {}",
1124 shape[1]
1125 )));
1126 }
1127 total_rows += shape[0];
1128 out.extend_from_slice(&data);
1129 }
1130 Ok((total_rows, cols, out))
1131 }
1132}
1133
1134fn gptq_g_idx_is_desc_act(g_idx: &[i32], group_size: usize) -> bool {
1135 g_idx
1136 .iter()
1137 .enumerate()
1138 .any(|(i, &g)| g != (i as i32) / group_size as i32)
1139}
1140
1141fn trace_gptq_g_idx_if_requested(
1142 name: &str,
1143 qcfg: &QuantConfig,
1144 g_idx: Option<&[i32]>,
1145 in_features: usize,
1146 is_desc_act: bool,
1147) {
1148 if !gptq_gidx_trace_enabled() {
1149 return;
1150 }
1151
1152 let Some(g_idx) = g_idx else {
1153 tracing::info!(
1154 "GPTQ g_idx trace: name={name} K={in_features} desc_act_config={} no_g_idx",
1155 qcfg.desc_act
1156 );
1157 return;
1158 };
1159 if qcfg.group_size == 0 {
1160 tracing::info!("GPTQ g_idx trace: name={name} K={in_features} group_size=0 invalid");
1161 return;
1162 }
1163
1164 let expected_groups = in_features.div_ceil(qcfg.group_size);
1165 let mut counts = vec![0usize; expected_groups];
1166 for &group in g_idx {
1167 if group >= 0 {
1168 let group = group as usize;
1169 if group < counts.len() {
1170 counts[group] += 1;
1171 }
1172 }
1173 }
1174 let nonzero_groups = counts.iter().filter(|&&count| count > 0).count();
1175 let count_min = counts.iter().copied().min().unwrap_or(0);
1176 let count_max = counts.iter().copied().max().unwrap_or(0);
1177 let unbalanced_groups = counts
1178 .iter()
1179 .filter(|&&count| count != qcfg.group_size)
1180 .count();
1181 let preview_len = g_idx.len().min(16);
1182 tracing::info!(
1183 "GPTQ g_idx trace: name={name} desc_act={is_desc_act} K={in_features} \
1184 group_size={} groups={expected_groups} nonzero_groups={nonzero_groups} \
1185 count_min={count_min} count_max={count_max} unbalanced_groups={unbalanced_groups} \
1186 first{preview_len}={:?}",
1187 qcfg.group_size,
1188 &g_idx[..preview_len]
1189 );
1190}
1191
1192#[derive(Debug, Clone, PartialEq, Eq)]
1193struct GptqQzeroStats {
1194 words: usize,
1195 total_codes: usize,
1196 min_code: u8,
1197 max_code: u8,
1198 code7_count: usize,
1199 histogram: [usize; 16],
1200}
1201
1202impl GptqQzeroStats {
1203 fn all_code7(&self) -> bool {
1204 self.total_codes > 0 && self.code7_count == self.total_codes
1205 }
1206}
1207
1208fn gptq_qzero_stats(qzeros: &[i32]) -> GptqQzeroStats {
1209 let mut histogram = [0usize; 16];
1210 for &word in qzeros {
1211 let word = word as u32;
1212 for nibble in 0..8 {
1213 let code = ((word >> (nibble * 4)) & 0xF) as usize;
1214 histogram[code] += 1;
1215 }
1216 }
1217
1218 let mut min_code = 0u8;
1219 let mut max_code = 0u8;
1220 let mut found = false;
1221 for (code, &count) in histogram.iter().enumerate() {
1222 if count == 0 {
1223 continue;
1224 }
1225 if !found {
1226 min_code = code as u8;
1227 found = true;
1228 }
1229 max_code = code as u8;
1230 }
1231
1232 GptqQzeroStats {
1233 words: qzeros.len(),
1234 total_codes: qzeros.len() * 8,
1235 min_code,
1236 max_code,
1237 code7_count: histogram[7],
1238 histogram,
1239 }
1240}
1241
1242fn canonicalize_gptq_qzeros_for_sym(qcfg: &QuantConfig, qzeros: &mut [i32]) {
1243 if qcfg.method == QuantMethod::Gptq && qcfg.sym && qcfg.bits == 4 {
1244 qzeros.fill(0x7777_7777);
1245 }
1246}
1247
1248fn trace_gptq_qzeros_if_requested(
1249 name: &str,
1250 qcfg: &QuantConfig,
1251 qzeros: &[i32],
1252 out_features: usize,
1253) {
1254 if !gptq_gidx_trace_enabled() {
1255 return;
1256 }
1257
1258 let stats = gptq_qzero_stats(qzeros);
1259 let expected_words_per_group = out_features.div_ceil(8);
1260 tracing::info!(
1261 "GPTQ qzeros trace: name={name} sym={} N={out_features} \
1262 expected_words_per_group={expected_words_per_group} words={} total_codes={} \
1263 min_code={} max_code={} code7={}/{} all_code7={} histogram={:?}",
1264 qcfg.sym,
1265 stats.words,
1266 stats.total_codes,
1267 stats.min_code,
1268 stats.max_code,
1269 stats.code7_count,
1270 stats.total_codes,
1271 stats.all_code7(),
1272 stats.histogram
1273 );
1274}
1275
1276fn gptq_gidx_trace_enabled() -> bool {
1277 runtime_snapshot_value("FERRUM_GPTQ_GIDX_TRACE").as_deref() == Some("1")
1278}
1279
1280fn runtime_snapshot_value(key: &str) -> Option<String> {
1281 ferrum_types::active_runtime_snapshot()
1282 .entries
1283 .iter()
1284 .find(|entry| entry.key == key)
1285 .map(|entry| entry.effective_value.clone())
1286}
1287
1288fn validate_gptq_g_idx(
1289 name: &str,
1290 qcfg: &QuantConfig,
1291 g_idx: Option<&[i32]>,
1292 in_features: usize,
1293) -> Result<bool> {
1294 if qcfg.desc_act && g_idx.is_none() {
1295 return Err(FerrumError::model(format!(
1296 "{name}: quantize_config desc_act=true but no g_idx tensor was found"
1297 )));
1298 }
1299
1300 let Some(g_idx) = g_idx else {
1301 return Ok(false);
1302 };
1303 if qcfg.group_size == 0 {
1304 return Err(FerrumError::model(format!(
1305 "{name}: GPTQ g_idx present but group_size is 0"
1306 )));
1307 }
1308 if g_idx.len() != in_features {
1309 return Err(FerrumError::model(format!(
1310 "{name}: g_idx length {} must match K={in_features}",
1311 g_idx.len()
1312 )));
1313 }
1314 let expected_groups = in_features.div_ceil(qcfg.group_size);
1315 for (idx, &group) in g_idx.iter().enumerate() {
1316 if group < 0 || group as usize >= expected_groups {
1317 return Err(FerrumError::model(format!(
1318 "{name}: g_idx[{idx}]={group} outside expected group range 0..{}",
1319 expected_groups.saturating_sub(1)
1320 )));
1321 }
1322 }
1323 Ok(gptq_g_idx_is_desc_act(g_idx, qcfg.group_size))
1324}
1325
1326#[cfg_attr(not(feature = "cuda"), allow(dead_code))]
1327fn validate_cuda_marlin_desc_act_g_idx(
1328 name: &str,
1329 qcfg: &QuantConfig,
1330 g_idx: &[i32],
1331 in_features: usize,
1332) -> Result<()> {
1333 if qcfg.group_size == 0 {
1334 return Err(FerrumError::model(format!(
1335 "{name}: CUDA Marlin desc_act requires non-zero group_size"
1336 )));
1337 }
1338 if in_features % qcfg.group_size != 0 {
1339 return Err(FerrumError::unsupported(format!(
1340 "{name}: CUDA Marlin desc_act requires K={in_features} to be divisible by \
1341 group_size={}",
1342 qcfg.group_size
1343 )));
1344 }
1345
1346 let expected_groups = in_features / qcfg.group_size;
1347 let mut counts = vec![0usize; expected_groups];
1348 for (idx, &group) in g_idx.iter().enumerate() {
1349 if group < 0 || group as usize >= expected_groups {
1350 return Err(FerrumError::model(format!(
1351 "{name}: g_idx[{idx}]={group} outside expected group range 0..{}",
1352 expected_groups.saturating_sub(1)
1353 )));
1354 }
1355 counts[group as usize] += 1;
1356 }
1357
1358 if let Some((group, count)) = counts
1359 .iter()
1360 .copied()
1361 .enumerate()
1362 .find(|&(_, count)| count != qcfg.group_size)
1363 {
1364 return Err(FerrumError::unsupported(format!(
1365 "{name}: CUDA Marlin desc_act requires balanced full groups; \
1366 group {group} has {count} rows, expected {}",
1367 qcfg.group_size
1368 )));
1369 }
1370
1371 Ok(())
1372}
1373
1374#[cfg(not(feature = "cuda"))]
1391fn dequantize_gptq_with_g_idx(
1392 qweight: &[i32], scales: &[f32], qzeros: &[i32], g_idx: &[i32], _group_size: usize,
1397 k: usize,
1398 n: usize,
1399) -> Vec<f32> {
1400 debug_assert_eq!(g_idx.len(), k);
1401
1402 let mut w = vec![0.0f32; n * k];
1404 let packed_rows = k / 8;
1405 for pr in 0..packed_rows {
1406 for col in 0..n {
1407 let packed = qweight[pr * n + col] as u32;
1408 for bi in 0..8 {
1409 let ki = pr * 8 + bi;
1410 let q = ((packed >> (bi * 4)) & 0xF) as i32;
1411 let g = g_idx[ki] as usize;
1412 let scale = scales[g * n + col];
1413 let z_packed = qzeros[g * (n / 8) + (col / 8)] as u32;
1414 let zero = (((z_packed >> ((col % 8) * 4)) & 0xF) as i32) + 1;
1415 w[col * k + ki] = (q - zero) as f32 * scale;
1416 }
1417 }
1418 }
1419 w
1420}
1421
1422fn dtype_to_f32(dtype: Dtype, raw: &[u8]) -> Result<Vec<f32>> {
1423 match dtype {
1424 Dtype::F32 => {
1425 debug_assert_eq!(raw.len() % 4, 0);
1430 let n = raw.len() / 4;
1431 let mut out = Vec::<f32>::with_capacity(n);
1432 unsafe {
1433 std::ptr::copy_nonoverlapping(raw.as_ptr(), out.as_mut_ptr() as *mut u8, raw.len());
1434 out.set_len(n);
1435 }
1436 Ok(out)
1437 }
1438 Dtype::F16 => {
1439 debug_assert_eq!(raw.len() % 2, 0);
1440 let n = raw.len() / 2;
1441 let mut tmp = Vec::<f16>::with_capacity(n);
1444 unsafe {
1445 std::ptr::copy_nonoverlapping(raw.as_ptr(), tmp.as_mut_ptr() as *mut u8, raw.len());
1446 tmp.set_len(n);
1447 }
1448 let mut out = Vec::with_capacity(n);
1449 for h in &tmp {
1450 out.push(h.to_f32());
1451 }
1452 Ok(out)
1453 }
1454 Dtype::BF16 => {
1455 debug_assert_eq!(raw.len() % 2, 0);
1456 let n = raw.len() / 2;
1457 let mut tmp = Vec::<bf16>::with_capacity(n);
1458 unsafe {
1459 std::ptr::copy_nonoverlapping(raw.as_ptr(), tmp.as_mut_ptr() as *mut u8, raw.len());
1460 tmp.set_len(n);
1461 }
1462 let mut out = Vec::with_capacity(n);
1463 for h in &tmp {
1464 out.push(h.to_f32());
1465 }
1466 Ok(out)
1467 }
1468 other => Err(FerrumError::model(format!(
1469 "dtype {other:?} not supported by NativeSafetensorsLoader's f32 path; \
1470 use a format-specific loader (GPTQ / AWQ / GGUF)",
1471 ))),
1472 }
1473}
1474
1475fn load_quantize_config(dir: &Path) -> Result<Option<QuantConfig>> {
1476 let p = dir.join("quantize_config.json");
1478 if p.exists() {
1479 let data =
1480 std::fs::read_to_string(&p).map_err(|e| FerrumError::io(format!("read {p:?}: {e}")))?;
1481 let qc: QuantConfig = serde_json::from_str(&data)
1482 .map_err(|e| FerrumError::serialization(format!("parse quantize_config.json: {e}")))?;
1483 return Ok(Some(qc));
1484 }
1485 let cfg = dir.join("config.json");
1488 if cfg.exists() {
1489 let data = std::fs::read_to_string(&cfg)
1490 .map_err(|e| FerrumError::io(format!("read {cfg:?}: {e}")))?;
1491 let root: serde_json::Value = serde_json::from_str(&data)
1492 .map_err(|e| FerrumError::serialization(format!("parse config.json: {e}")))?;
1493 if let Some(qc_val) = root.get("quantization_config") {
1494 let method = qc_val
1496 .get("quant_method")
1497 .and_then(|v| v.as_str())
1498 .unwrap_or("none");
1499 let method = match method.to_lowercase().as_str() {
1500 "gptq" => QuantMethod::Gptq,
1501 "awq" => QuantMethod::Awq,
1502 "gguf" => QuantMethod::Gguf,
1503 _ => QuantMethod::None,
1504 };
1505 let bits = qc_val.get("bits").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
1506 let group_size = qc_val
1507 .get("group_size")
1508 .and_then(|v| v.as_i64())
1509 .unwrap_or(128)
1510 .max(0) as usize;
1511 let desc_act = qc_val
1512 .get("desc_act")
1513 .and_then(|v| v.as_bool())
1514 .unwrap_or(false);
1515 let sym = qc_val.get("sym").and_then(|v| v.as_bool()).unwrap_or(false);
1516 if method != QuantMethod::None {
1517 return Ok(Some(QuantConfig {
1518 method,
1519 bits,
1520 group_size,
1521 desc_act,
1522 sym,
1523 }));
1524 }
1525 }
1526 }
1527 Ok(None)
1528}
1529
1530#[cfg(test)]
1531mod tests {
1532 use super::*;
1533
1534 fn gptq_config(desc_act: bool) -> QuantConfig {
1535 gptq_config_with_sym(desc_act, true)
1536 }
1537
1538 fn gptq_config_with_sym(desc_act: bool, sym: bool) -> QuantConfig {
1539 QuantConfig {
1540 method: QuantMethod::Gptq,
1541 bits: 4,
1542 group_size: 2,
1543 desc_act,
1544 sym,
1545 }
1546 }
1547
1548 #[test]
1549 fn validate_gptq_g_idx_requires_tensor_when_desc_act_configured() {
1550 let err = validate_gptq_g_idx("proj", &gptq_config(true), None, 4)
1551 .unwrap_err()
1552 .to_string();
1553
1554 assert!(err.contains("desc_act=true"));
1555 assert!(err.contains("no g_idx"));
1556 }
1557
1558 #[test]
1559 fn validate_gptq_g_idx_accepts_trivial_non_desc_act_order() {
1560 let is_desc_act =
1561 validate_gptq_g_idx("proj", &gptq_config(false), Some(&[0, 0, 1, 1]), 4).unwrap();
1562
1563 assert!(!is_desc_act);
1564 }
1565
1566 #[test]
1567 fn validate_gptq_g_idx_detects_nontrivial_act_order() {
1568 let is_desc_act =
1569 validate_gptq_g_idx("proj", &gptq_config(false), Some(&[1, 1, 0, 0]), 4).unwrap();
1570
1571 assert!(is_desc_act);
1572 }
1573
1574 #[test]
1575 fn validate_gptq_g_idx_rejects_invalid_shape_and_group() {
1576 let short = validate_gptq_g_idx("proj", &gptq_config(false), Some(&[0, 0, 1]), 4)
1577 .unwrap_err()
1578 .to_string();
1579 assert!(short.contains("must match K=4"));
1580
1581 let out_of_range = validate_gptq_g_idx("proj", &gptq_config(false), Some(&[0, 0, 2, 1]), 4)
1582 .unwrap_err()
1583 .to_string();
1584 assert!(out_of_range.contains("outside expected group range"));
1585 }
1586
1587 #[test]
1588 fn validate_cuda_marlin_desc_act_accepts_balanced_full_groups() {
1589 validate_cuda_marlin_desc_act_g_idx("proj", &gptq_config(true), &[1, 1, 0, 0], 4).unwrap();
1590 }
1591
1592 #[test]
1593 fn validate_cuda_marlin_desc_act_rejects_unbalanced_groups() {
1594 let err = validate_cuda_marlin_desc_act_g_idx("proj", &gptq_config(true), &[0, 0, 0, 1], 4)
1595 .unwrap_err()
1596 .to_string();
1597
1598 assert!(err.contains("balanced full groups"));
1599 assert!(err.contains("group 0 has 3 rows"));
1600 }
1601
1602 #[test]
1603 fn validate_cuda_marlin_desc_act_rejects_partial_last_group() {
1604 let err = validate_cuda_marlin_desc_act_g_idx("proj", &gptq_config(true), &[0, 0, 1], 3)
1605 .unwrap_err()
1606 .to_string();
1607
1608 assert!(err.contains("K=3"));
1609 assert!(err.contains("group_size=2"));
1610 }
1611
1612 #[test]
1613 fn qzero_stats_detects_symmetric_code7_packing() {
1614 let stats = gptq_qzero_stats(&[0x7777_7777u32 as i32, 0x7777_7777u32 as i32]);
1615
1616 assert_eq!(stats.words, 2);
1617 assert_eq!(stats.total_codes, 16);
1618 assert_eq!(stats.min_code, 7);
1619 assert_eq!(stats.max_code, 7);
1620 assert_eq!(stats.code7_count, 16);
1621 assert!(stats.all_code7());
1622 }
1623
1624 #[test]
1625 fn qzero_stats_reports_mixed_codes() {
1626 let stats = gptq_qzero_stats(&[0x0123_4567]);
1627
1628 assert_eq!(stats.total_codes, 8);
1629 assert_eq!(stats.min_code, 0);
1630 assert_eq!(stats.max_code, 7);
1631 assert_eq!(stats.code7_count, 1);
1632 assert!(!stats.all_code7());
1633 }
1634
1635 #[test]
1636 fn symmetric_gptq_qzeros_are_canonicalized_to_code7() {
1637 let mut qzeros = vec![0x8888_8888u32 as i32, 0x0123_4567];
1638
1639 canonicalize_gptq_qzeros_for_sym(&gptq_config(true), &mut qzeros);
1640
1641 assert_eq!(qzeros, vec![0x7777_7777, 0x7777_7777]);
1642 assert!(gptq_qzero_stats(&qzeros).all_code7());
1643 }
1644
1645 #[test]
1646 fn asymmetric_gptq_qzeros_are_preserved() {
1647 let mut qzeros = vec![0x8888_8888u32 as i32, 0x0123_4567];
1648
1649 canonicalize_gptq_qzeros_for_sym(&gptq_config_with_sym(false, false), &mut qzeros);
1650
1651 assert_eq!(qzeros, vec![0x8888_8888u32 as i32, 0x0123_4567]);
1652 }
1653}