Skip to main content

ferrum_quantization/
native_safetensors.rs

1//! Native safetensors `WeightLoader<B>` — mmap + `safetensors` crate, no
2//! candle dependency on the LLM hot path.
3//!
4//! What this owns:
5//!   - Discovering `model.safetensors` vs sharded `model.safetensors.index.json`.
6//!   - Mmapping each shard file.
7//!   - Per-tensor lookup: returns shape + dtype + a byte slice into the mmap.
8//!   - f32 materialisation for Dense weights (bf16 / f16 / f32 accepted).
9//!   - The Qwen3 / Llama fusion trick: `qkv_proj` / `gate_up_proj` synthesised
10//!     on the fly from split `q_proj`+`k_proj`+`v_proj` etc.
11//!
12//! What it deliberately doesn't do:
13//!   - GPTQ / AWQ / GGUF packed weights. Those need `B::from_slice_i32` /
14//!     `B::from_slice_f16` which aren't on the Backend trait yet. A dedicated
15//!     loader per quant format lands in Phase E.
16
17use 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
28/// Map a safetensors Dtype to ferrum's SrcDtype.
29fn 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
46/// Tensor metadata extracted ONCE from the safetensors header at open time.
47/// Avoids the per-tensor `SafeTensors::deserialize(&mmap)` re-parse that
48/// previously dominated cold-load on stacked-MoE (>= 18 000 calls per
49/// model, ~30 ms each on a 7 000-tensor header → 9+ minutes of header
50/// re-parse alone).
51struct TensorMeta {
52    dtype: Dtype,
53    shape: Vec<usize>,
54    /// Byte range in the shard's mmap that holds the raw tensor data.
55    data_start: usize,
56    data_end: usize,
57}
58
59/// A single shard file: mmap + name→TensorMeta cache.
60struct Shard {
61    mmap: Mmap,
62    names: Vec<String>,
63    /// Pre-extracted tensor metadata. Looked up by name; no header
64    /// re-parse on every read.
65    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        // Parse the header ONCE and cache (offset, len, dtype, shape) for
75        // every tensor — re-deriving the data slice from the cache is a
76        // simple `&mmap[start..end]` rather than a full header re-parse.
77        let st = SafeTensors::deserialize(&mmap)
78            .map_err(|e| FerrumError::model(format!("parse {path:?}: {e}")))?;
79        // SafeTensors stores: 8-byte little-endian header_len + header_json
80        // + data_blob. The TensorView's `data()` returns a slice into the
81        // data_blob region. We compute the data_blob base by reading the
82        // 8-byte header_len.
83        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            // TensorView::data() is &[u8] into the mmap; we recompute its
97            // [start, end) byte range relative to the mmap base via
98            // pointer arithmetic.
99            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    /// Returns (data_bytes, dtype, shape) for the named tensor without
118    /// re-parsing the safetensors header.
119    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
128/// Native safetensors loader. Generic over `Backend` so every tensor is
129/// materialised directly into backend-native buffers.
130pub struct NativeSafetensorsLoader<B: Backend + BackendQuantMarlin> {
131    /// All shards keyed by file; each tensor's name maps to its shard here.
132    shards: Vec<Shard>,
133    /// Name → shard index. Populated once at construction.
134    index: HashMap<String, usize>,
135    /// Optional `quantize_config.json` contents.
136    quant_config: Option<QuantConfig>,
137    _m: std::marker::PhantomData<B>,
138}
139
140impl<B: Backend + BackendQuantMarlin> NativeSafetensorsLoader<B> {
141    /// Discover shards under `model_dir` and build the name → shard index.
142    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    /// Read a tensor as f32 (converting from bf16 / f16 / f32) + its shape.
197    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    /// Read the raw on-disk byte slice plus dtype and shape. Zero-copy into
208    /// the mmap — used to hand weights straight to `B::from_weight_bytes` so
209    /// a fp16-preferring backend can skip the transient f32 Vec.
210    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    /// Concatenate several tensors along dim 0 at the byte level. All parts
221    /// must share the same dtype and trailing-dim width. Returns the fused
222    /// raw bytes + common dtype + `(total_rows, cols)` shape.
223    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    /// Concatenate optional projection biases in the same order as split
258    /// weight fusion. Biases must be all-or-none; silently dropping one part's
259    /// bias corrupts Qwen2.5 attention logits.
260    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    /// Read a tensor as i32 (for GPTQ qweight / qzeros / g_idx).
302    /// Bulk memcpy from the LE-stored bytes (safetensors guarantees LE)
303    /// — the previous per-element `from_le_bytes` was 4 ms for a single
304    /// 768 KB tensor and dominated stacked-MoE load.
305    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        // SAFETY: Vec<i32>'s buffer is 4-byte aligned by allocator
321        // contract. `bytes` is a raw u8 slice; copy_nonoverlapping
322        // doesn't require src alignment. We're on x86_64 LE, and
323        // safetensors stores LE i32 — bit pattern is identical.
324        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    /// Read the four raw GPTQ tensors for a named projection without
336    /// triggering a Backend repack. Used by MoE batch loading: callers
337    /// stack many experts host-side then issue a single `B::load_gptq`,
338    /// avoiding the 12 288× per-expert Marlin repack overhead.
339    ///
340    /// Returns `(qweight, scales, qzeros, g_idx, k, n)`.
341    /// `g_idx` is `None` when desc_act=false (no act-order perm needed).
342    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    /// Load a STACKED GPTQ tile that concatenates `num_experts` experts'
372    /// raw GPTQ tensors along the N (column) axis and runs ONE backend
373    /// repack — instead of `num_experts × proj_names.len()` repacks.
374    ///
375    /// Layout: per row `r`, the cols are emitted in expert-major order:
376    /// `expert_0[proj_0|proj_1|...] | expert_1[...] | ... | expert_{N-1}[...]`.
377    /// Caller can therefore index expert `e` at column offset
378    /// `e * n_per_expert`, where `n_per_expert = Σ n(proj)` across the
379    /// `proj_names` for one expert.
380    ///
381    /// `expert_prefix_fmt` should be a closure-style `&str` that contains
382    /// `"{e}"` placeholder (replaced by the expert index) and ends *just
383    /// before* the proj name — e.g. `"model.layers.5.mlp.experts.{e}."`.
384    /// The full tensor name probed is `{expert_prefix}{proj}`.
385    ///
386    /// Returns `(store, n_per_expert, k)` where `n_per_expert` is the
387    /// per-expert column width and `k = in_features` (shared by all).
388    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        // Per (expert, proj) raw slices — row-major (rows × cols).
420        let total_pairs = num_experts * proj_names.len();
421        let mut qw_parts: Vec<(Vec<i32>, usize)> = Vec::with_capacity(total_pairs); // (data, cols)
422        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                // g_idx is a permutation over K — Marlin assumes ONE g_idx
460                // for the whole stacked tile. Validate all experts share
461                // identical g_idx if any has it (which they should, since
462                // K = hidden_size is the same across experts and GPTQ's
463                // act-order is computed on the input distribution).
464                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        // PER-EXPERT layout: build num_experts independent
500        // `[K/8, n_per_expert]` qweight tiles + scales + qzeros, each
501        // a row-major concat of the proj_names within that expert.
502        // Hand them to `B::load_gptq_stacked` which repacks PER-EXPERT
503        // and concats the resulting Marlin-format tiles into one
504        // contiguous buffer. Each expert's packed bytes are then
505        // contiguous, so the offset GEMM dispatches correctly via
506        // pointer arithmetic alone.
507        //
508        // Without per-expert repack, a single concat-then-repack of
509        // the stacked tile mangles per-expert tile boundaries (Marlin
510        // permutes in K-tile-major order across the whole tile).
511        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 the original part buffers — we own copies in per_expert_*.
545        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        // Route through `from_weight_bytes` so fp16-preferring backends can
590        // materialise big tensors (embed table) directly as half-precision
591        // without the transient f32 Vec. Tiny tensors (norm weights) still
592        // end up as f32 because backends size-threshold inside the override.
593        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        // GPTQ first: `<name>.qweight` + `<name>.scales` + `<name>.qzeros`.
599        let qw_key = format!("{name}.qweight");
600        if self.has(&qw_key) {
601            return self.load_gptq_linear(name);
602        }
603        // GPTQ fusion shims: synthesise qkv_proj / gate_up_proj from split
604        // components — same pattern as Dense but concatenating the GPTQ
605        // tensors (qweight/scales/qzeros) along the N dim.
606        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        // Direct fused `<name>.weight` next. Load straight from raw bytes
636        // so fp16-preferring backends can skip the f32 Vec intermediate.
637        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        // Llama-family fusion shims: synthesise qkv_proj / gate_up_proj from
653        // split q_proj+k_proj+v_proj / gate_proj+up_proj if present. The cat
654        // happens at the byte level so fused-weight memory is the same size
655        // as the per-part weights — no expansion to f32.
656        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    /// Load a GPTQ-packed linear projection: reads `<name>.qweight`,
760    /// `<name>.scales`, `<name>.qzeros`, optionally `<name>.g_idx`, and
761    /// hands the raw host-side tensors to `Backend::load_gptq` which
762    /// repacks + uploads per its own strategy.
763    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        // Shape inference: qweight is [K/8, N]; scales is [K/group, N].
788        // → K = qw_shape[0] * 8, N = qw_shape[1].
789        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        // Act-order GPTQ. CUDA backend has perm-aware Marlin (load_gptq
802        // builds perm = argsort(g_idx) + permutes qweight rows at load;
803        // gemm_gptq gathers input columns before the standard Marlin call).
804        // CPU/Metal still need the dequant→DenseLinear fallback.
805        #[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        // Read optional bias FIRST (Qwen2.5 attention projections, some
845        // Llama variants). Phase 3e/2: load_gptq takes the bias eagerly
846        // because the boxed Linear bakes it in.
847        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    /// Fuse multiple GPTQ projections by concatenating qweight/scales/qzeros
876    /// along the output (N) dim. Matches the Dense fusion shim used for
877    /// non-quantized models: q_proj + k_proj + v_proj → qkv_proj.
878    ///
879    /// All parts must share:
880    /// - in_features (K)
881    /// - bits, group_size
882    /// - qzeros N-packing (which the GPTQ format always honours: qzeros[-1]
883    ///   = N/8, concat along that axis works)
884    ///
885    /// g_idx: only present when desc_act=true. When present, all parts
886    /// share it (same K rows, same activation permutation).
887    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        // Segments: (qw_slice, sc_slice, qz_slice) per part, needed for N-major layout concat
910        let mut qw_parts: Vec<(Vec<i32>, usize, usize)> = Vec::new(); // (data, rows, cols)
911        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        // Interleave row-major concatenation: for each row, write all parts' cols.
967        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        // CUDA: perm-aware Marlin via load_gptq. CPU/Metal: dequant→Dense.
1012        #[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        // Biases: concatenate `<part>.bias` across parts in the same
1061        // order as qweights. All-or-none; if any part has a bias, all
1062        // must. Phase 3e/2: read first, pass into load_gptq.
1063        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    /// Read each name, assert shape width matches, concatenate along dim 0.
1105    /// Kept for diagnostic / fallback paths; DenseLinear fusion prefers the
1106    /// byte-level `cat_rows_bytes` above.
1107    #[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/// Dequantise GPTQ INT4 weights with desc_act=true (act-order) g_idx and
1375/// return original-order f32 weights laid out `[N, K]` row-major (matches
1376/// `DenseLinear::from_rows`).
1377///
1378/// Key insight: in AutoGPTQ desc_act format, qweight rows are NOT
1379/// permuted from original-K order. The act-order trick is encoded purely
1380/// in `g_idx[k]` — which records the QUANTISATION GROUP (not column
1381/// position) chosen for disk row k. Different rows that originally
1382/// belonged to far-apart positions can share a group via g_idx.
1383///
1384/// Verified against vLLM's exllama path (gptq.py:368): for desc_act it
1385/// runs `g_idx ← argsort(g_idx)` then `gptq_shuffle(qweight, g_idx)`,
1386/// which physically reorders qweight by argsort and gathers x by argsort
1387/// at GEMM. Net effect: y[n] = Σⱼ x[j] · dequant(qweight[j, n],
1388/// scales[g_idx_orig[j], n], qzeros[g_idx_orig[j], n]).
1389/// → disk_k IS original_k; only the (scale, zero) LOOKUP differs.
1390#[cfg(not(feature = "cuda"))]
1391fn dequantize_gptq_with_g_idx(
1392    qweight: &[i32], // [K/8, N] packed int4
1393    scales: &[f32],  // [num_groups, N]
1394    qzeros: &[i32],  // [num_groups, N/8] packed int4
1395    g_idx: &[i32],   // [K]
1396    _group_size: usize,
1397    k: usize,
1398    n: usize,
1399) -> Vec<f32> {
1400    debug_assert_eq!(g_idx.len(), k);
1401
1402    // Output: [N, K] row-major → out[col * k + k_idx] = value.
1403    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            // Bulk memcpy from LE-stored bytes (safetensors is LE; we're
1426            // on x86_64 LE). Per-element from_le_bytes was the bottleneck
1427            // for stacked-MoE load (4-5 ms per call * 384 calls/layer *
1428            // 48 layers = ~80 sec just for f32 reads).
1429            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            // Reinterpret raw bytes as f16, then convert. This avoids
1442            // the per-element from_le_bytes byte-array construction.
1443            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    // AutoGPTQ / gptq-for-llama format: separate quantize_config.json.
1477    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    // Qwen GPTQ / transformers-style: embedded in config.json under
1486    // "quantization_config": { "quant_method": "gptq", "bits": 4, ... }.
1487    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            // The embedded block has "quant_method" (not "method"); remap.
1495            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}