Skip to main content

ferrum_quantization/
gptq_marlin_source.rs

1//! Typed safetensors GPTQ adapter for the Marlin physical ABI.
2//!
3//! The adapter performs validation and repacking once while static plan
4//! resources are initialized. Providers receive only plan-owned device
5//! regions and never parse checkpoint metadata or repack on a request path.
6
7use std::borrow::Cow;
8use std::path::Path;
9
10#[cfg(test)]
11use ferrum_interfaces::vnext::QuantizationGrouping;
12use ferrum_interfaces::vnext::{
13    ElementType, QuantizationSpec, VNextError, WeightComponentPayload, WeightComponentRole,
14    WeightComponentSource, WeightComponentSpec, WeightEncoding,
15};
16#[cfg(test)]
17use ferrum_kernels::marlin_repack::repack_gptq_to_marlin;
18use ferrum_kernels::marlin_repack::{repack_gptq_to_marlin_bytes_into, repack_scales_to_marlin};
19use ferrum_types::Result;
20use half::f16;
21use safetensors::Dtype;
22
23use crate::safetensors_archive::{transcode_dense_bytes, SafetensorsArchive, SafetensorsTensor};
24
25pub const GPTQ_MARLIN_INT4_FORMAT_ID: &str = "quantization.marlin.gptq-int4-symmetric";
26
27/// Mmap-backed safetensors archive with an explicit GPTQ-to-Marlin cold-path
28/// adapter. Dense components retain the archive's zero-copy behavior.
29pub struct GptqMarlinSafetensorsSource {
30    archive: SafetensorsArchive,
31}
32
33impl GptqMarlinSafetensorsSource {
34    pub fn open(model_dir: impl AsRef<Path>) -> Result<Self> {
35        SafetensorsArchive::open(model_dir).map(Self::new)
36    }
37
38    pub const fn new(archive: SafetensorsArchive) -> Self {
39        Self { archive }
40    }
41
42    pub const fn archive(&self) -> &SafetensorsArchive {
43        &self.archive
44    }
45
46    /// Materialize a small symmetric GPTQ matrix as row-major F16. The schema
47    /// explicitly names all four sources in qweight, qzeros, g_idx, scales
48    /// order; ordinary dense tensors and native Marlin matrices keep their
49    /// existing paths. This happens only during static resource initialization.
50    fn dense_gptq<'source>(
51        &'source self,
52        component: &WeightComponentSpec,
53    ) -> std::result::Result<WeightComponentPayload<'source>, VNextError> {
54        let [qweight_name, qzeros_name, g_idx_name, scales_name] =
55            component.external_names.as_slice()
56        else {
57            return Err(invalid_component(
58                component,
59                "dense GPTQ requires four ordered sources",
60            ));
61        };
62        let stem = qweight_name.strip_suffix(".qweight").unwrap_or_default();
63        if stem.is_empty()
64            || qzeros_name != &format!("{stem}.qzeros")
65            || g_idx_name != &format!("{stem}.g_idx")
66            || scales_name != &format!("{stem}.scales")
67        {
68            return Err(invalid_component(component, "dense GPTQ sources must share one stem and be ordered qweight, qzeros, g_idx, scales"));
69        }
70        let qweight = self.tensor(component, qweight_name)?;
71        let qzeros = self.tensor(component, qzeros_name)?;
72        let g_idx = self.tensor(component, g_idx_name)?;
73        let scales = self.tensor(component, scales_name)?;
74        let [n, k] = component.dimensions.as_slice() else {
75            return Err(invalid_component(
76                component,
77                "dense GPTQ output must be a matrix [N, K]",
78            ));
79        };
80        let n = usize::try_from(*n)
81            .map_err(|_| invalid_component(component, "dense GPTQ N overflows"))?;
82        let k = usize::try_from(*k)
83            .map_err(|_| invalid_component(component, "dense GPTQ K overflows"))?;
84        let (group_count, scale_n) = validate_scale_shape(component, &scales)?;
85        if n == 0
86            || k == 0
87            || !n.is_multiple_of(8)
88            || !k.is_multiple_of(8)
89            || group_count == 0
90            || !k.is_multiple_of(group_count)
91            || scale_n != n
92            || qweight.dtype() != Dtype::I32
93            || qweight.shape() != [k as u64 / 8, n as u64]
94            || scales.dtype() != Dtype::F16
95        {
96            return Err(invalid_component(
97                component,
98                "dense GPTQ source shape/dtype differs from its F16 matrix contract",
99            ));
100        }
101        let group_size = k / group_count;
102        validate_symmetric_qzeros_shape(component, &qzeros, k, n, group_size)?;
103        validate_canonical_g_idx(component, &g_idx, k, group_size)?;
104        let packed = decode_i32(qweight.bytes(), component, "qweight")?;
105        let scales_values = decode_f16(scales.bytes(), component)?;
106        let byte_count = usize::try_from(component.physical_bytes()?)
107            .map_err(|_| invalid_component(component, "dense GPTQ byte count overflows"))?;
108        let mut bytes = Vec::with_capacity(byte_count);
109        for output in 0..n {
110            for input in 0..k {
111                let word = packed[(input / 8) * n + output] as u32;
112                let code = ((word >> ((input % 8) * 4)) & 15) as i32;
113                // The adapter's symmetric INT4 contract uses the same uint4b8
114                // bias as Marlin, independently of historical qzeros encoding.
115                let value =
116                    (code - 8) as f32 * scales_values[(input / group_size) * n + output].to_f32();
117                let value = f16::from_f32(value);
118                if !value.is_finite() {
119                    return Err(invalid_component(
120                        component,
121                        "dense GPTQ produced a non-finite F16 weight",
122                    ));
123                }
124                bytes.extend_from_slice(&value.to_bits().to_le_bytes());
125            }
126        }
127        WeightComponentPayload::from_ordered_sources(
128            component,
129            component.external_names.clone(),
130            [&qweight, &qzeros, &g_idx, &scales]
131                .map(|tensor| tensor.source_file().to_owned())
132                .to_vec(),
133            component.dimensions.clone(),
134            ElementType::F16,
135            bytes,
136        )
137    }
138
139    fn packed_values<'source>(
140        &'source self,
141        component: &WeightComponentSpec,
142        quantization: &QuantizationSpec,
143    ) -> std::result::Result<WeightComponentPayload<'source>, VNextError> {
144        let group_size = usize::try_from(validate_marlin_quantization(component, quantization)?)
145            .map_err(|_| invalid_component(component, "GPTQ group size exceeds address space"))?;
146        let groups = packed_source_groups(component)?;
147        let first_qweight = self.tensor(component, groups[0].qweight)?;
148        let (k, n) = validate_qweight_shape(component, &first_qweight)?;
149        let (expert_count, projections_per_expert) = if groups.len() == 1 {
150            let expected_bytes = component.physical_bytes()?;
151            if usize::try_from(expected_bytes).ok() != Some(first_qweight.bytes().len()) {
152                return Err(invalid_component(
153                    component,
154                    "qweight byte size differs from the typed packed component",
155                ));
156            }
157            (1, 1)
158        } else {
159            aggregate_axes(component, groups.len(), n, k / 2, "packed")?
160        };
161        let fused_n = n.checked_mul(projections_per_expert).ok_or_else(|| {
162            invalid_component(component, "fused qweight N dimension exceeds address space")
163        })?;
164        let expected_bytes = usize::try_from(component.physical_bytes()?).map_err(|_| {
165            invalid_component(
166                component,
167                "packed component byte size exceeds address space",
168            )
169        })?;
170        let mut bytes = Vec::with_capacity(expected_bytes);
171        let mut source_files = Vec::with_capacity(component.external_names.len());
172
173        for expert_groups in groups.chunks(projections_per_expert) {
174            let mut projections = Vec::with_capacity(projections_per_expert);
175            for group in expert_groups {
176                let qweight = self.tensor(component, group.qweight)?;
177                let shape = validate_qweight_shape(component, &qweight)?;
178                if shape != (k, n) {
179                    return Err(invalid_component(
180                        component,
181                        format!(
182                            "qweight source `{}` shape K={}, N={} drifts from K={k}, N={n}",
183                            group.qweight, shape.0, shape.1
184                        ),
185                    ));
186                }
187                let qzeros = self.tensor(component, group.qzeros)?;
188                validate_symmetric_qzeros_shape(component, &qzeros, k, n, group_size)?;
189                source_files.push(qweight.source_file().to_owned());
190                source_files.push(qzeros.source_file().to_owned());
191                if let Some(g_idx_name) = group.g_idx {
192                    let g_idx = self.tensor(component, g_idx_name)?;
193                    validate_canonical_g_idx(component, &g_idx, k, group_size)?;
194                    source_files.push(g_idx.source_file().to_owned());
195                }
196                projections.push(decode_i32(qweight.bytes(), component, "qweight")?);
197            }
198            let fused = concatenate_equal_width_rows(&projections, k / 8, n);
199            let start = bytes.len();
200            let byte_length = fused
201                .len()
202                .checked_mul(std::mem::size_of::<i32>())
203                .ok_or_else(|| {
204                    invalid_component(component, "repacked qweight byte length overflows")
205                })?;
206            let end = start.checked_add(byte_length).ok_or_else(|| {
207                invalid_component(component, "aggregate qweight byte length overflows")
208            })?;
209            bytes.resize(end, 0);
210            repack_gptq_to_marlin_bytes_into(&fused, k, fused_n, &mut bytes[start..end]);
211        }
212        debug_assert_eq!(groups.len(), expert_count * projections_per_expert);
213        WeightComponentPayload::from_ordered_sources(
214            component,
215            component.external_names.clone(),
216            source_files,
217            component.dimensions.clone(),
218            ElementType::U8,
219            bytes,
220        )
221    }
222
223    fn scales<'source>(
224        &'source self,
225        component: &WeightComponentSpec,
226    ) -> std::result::Result<WeightComponentPayload<'source>, VNextError> {
227        if component.external_names.is_empty() {
228            return Err(invalid_component(
229                component,
230                "Marlin scales require at least one safetensors source",
231            ));
232        }
233        if component
234            .external_names
235            .iter()
236            .any(|external_name| !external_name.ends_with(".scales"))
237        {
238            return Err(invalid_component(
239                component,
240                "every Marlin scale source must end with .scales",
241            ));
242        }
243        let external_name = &component.external_names[0];
244        let scales = self.tensor(component, external_name)?;
245        let (group_count, n) = validate_scale_shape(component, &scales)?;
246        let (expert_count, projections_per_expert) = if component.external_names.len() == 1 {
247            let mut expected_dimensions = vec![1_u64; component.dimensions.len().saturating_sub(2)];
248            expected_dimensions.extend([n as u64, group_count as u64]);
249            if component.dimensions != expected_dimensions {
250                return Err(invalid_component(
251                    component,
252                    format!(
253                        "typed scale shape {:?} must be {:?} for source shape [{group_count}, {n}]",
254                        component.dimensions, expected_dimensions,
255                    ),
256                ));
257            }
258            (1, 1)
259        } else {
260            aggregate_axes(
261                component,
262                component.external_names.len(),
263                n,
264                group_count,
265                "scale",
266            )?
267        };
268        let fused_n = n.checked_mul(projections_per_expert).ok_or_else(|| {
269            invalid_component(component, "fused scale N dimension exceeds address space")
270        })?;
271        let expected_bytes = usize::try_from(component.physical_bytes()?).map_err(|_| {
272            invalid_component(component, "scale component byte size exceeds address space")
273        })?;
274        let mut bytes = Vec::with_capacity(expected_bytes);
275        let mut source_files = Vec::with_capacity(component.external_names.len());
276
277        for expert_names in component.external_names.chunks(projections_per_expert) {
278            let mut projections = Vec::with_capacity(projections_per_expert);
279            for external_name in expert_names {
280                let scales = self.tensor(component, external_name)?;
281                let shape = validate_scale_shape(component, &scales)?;
282                if shape != (group_count, n) {
283                    return Err(invalid_component(
284                        component,
285                        format!(
286                            "scale source `{external_name}` shape [{}, {}] drifts from [{group_count}, {n}]",
287                            shape.0, shape.1
288                        ),
289                    ));
290                }
291                let source_type = scales.element_type().ok_or_else(|| {
292                    invalid_component(
293                        component,
294                        format!("scales have unsupported dtype {:?}", scales.dtype()),
295                    )
296                })?;
297                let f16_bytes = transcode_dense_bytes(
298                    scales.bytes(),
299                    source_type,
300                    ElementType::F16,
301                    external_name,
302                    None,
303                )?;
304                projections.push(decode_f16(&f16_bytes, component)?);
305                source_files.push(scales.source_file().to_owned());
306            }
307            let fused = concatenate_equal_width_rows(&projections, group_count, n);
308            let repacked = repack_scales_to_marlin(&fused, group_count, fused_n, 1);
309            bytes.extend_from_slice(encode_f16(repacked).as_ref());
310        }
311        debug_assert_eq!(
312            component.external_names.len(),
313            expert_count * projections_per_expert
314        );
315        WeightComponentPayload::from_ordered_sources(
316            component,
317            component.external_names.clone(),
318            source_files,
319            component.dimensions.clone(),
320            ElementType::F16,
321            bytes,
322        )
323    }
324
325    fn tensor<'source>(
326        &'source self,
327        component: &WeightComponentSpec,
328        external_name: &str,
329    ) -> std::result::Result<SafetensorsTensor<'source>, VNextError> {
330        self.archive
331            .tensor(external_name)
332            .map_err(|error| invalid_component(component, error.to_string()))
333    }
334}
335
336impl WeightComponentSource for GptqMarlinSafetensorsSource {
337    fn component<'source>(
338        &'source self,
339        component: &WeightComponentSpec,
340    ) -> std::result::Result<WeightComponentPayload<'source>, VNextError> {
341        match (&component.role, &component.encoding) {
342            (
343                WeightComponentRole::Values,
344                WeightEncoding::Dense {
345                    element_type: ElementType::F16,
346                },
347            ) if component
348                .external_names
349                .first()
350                .is_some_and(|name| name.ends_with(".qweight")) =>
351            {
352                self.dense_gptq(component)
353            }
354            (WeightComponentRole::PackedValues, WeightEncoding::Quantized(quantization)) => {
355                self.packed_values(component, quantization)
356            }
357            (
358                WeightComponentRole::Scales,
359                WeightEncoding::Dense {
360                    element_type: ElementType::F16,
361                },
362            ) => self.scales(component),
363            (_, WeightEncoding::Dense { .. } | WeightEncoding::DenseAffine { .. }) => {
364                self.archive.component(component)
365            }
366            _ => Err(invalid_component(
367                component,
368                "GPTQ Marlin adapter received an unsupported component encoding",
369            )),
370        }
371    }
372}
373
374#[derive(Clone, Copy)]
375struct PackedSourceGroup<'name> {
376    qweight: &'name str,
377    qzeros: &'name str,
378    g_idx: Option<&'name str>,
379}
380
381fn packed_source_groups(
382    component: &WeightComponentSpec,
383) -> std::result::Result<Vec<PackedSourceGroup<'_>>, VNextError> {
384    if component.external_names.is_empty() {
385        return Err(invalid_component(
386            component,
387            "packed GPTQ values require ordered qweight and qzeros sources",
388        ));
389    }
390    let mut groups = Vec::new();
391    let mut cursor = 0;
392    let mut expected_g_idx_presence = None;
393    while cursor < component.external_names.len() {
394        let qweight = &component.external_names[cursor];
395        let stem = qweight.strip_suffix(".qweight").unwrap_or_default();
396        let Some(qzeros) = component.external_names.get(cursor + 1) else {
397            return Err(invalid_component(
398                component,
399                "each packed GPTQ source group requires qweight followed by qzeros",
400            ));
401        };
402        if stem.is_empty() || qzeros != &format!("{stem}.qzeros") {
403            return Err(invalid_component(
404                component,
405                "packed GPTQ source groups must share one stem and be ordered qweight, qzeros, then optional g_idx",
406            ));
407        }
408        let expected_g_idx = format!("{stem}.g_idx");
409        let g_idx = component
410            .external_names
411            .get(cursor + 2)
412            .filter(|name| name.as_str() == expected_g_idx)
413            .map(String::as_str);
414        let has_g_idx = g_idx.is_some();
415        if expected_g_idx_presence
416            .replace(has_g_idx)
417            .is_some_and(|expected| expected != has_g_idx)
418        {
419            return Err(invalid_component(
420                component,
421                "packed GPTQ source groups cannot mix g_idx presence",
422            ));
423        }
424        groups.push(PackedSourceGroup {
425            qweight,
426            qzeros,
427            g_idx,
428        });
429        cursor += if has_g_idx { 3 } else { 2 };
430    }
431    Ok(groups)
432}
433
434fn aggregate_axes(
435    component: &WeightComponentSpec,
436    source_group_count: usize,
437    source_n: usize,
438    source_tail: usize,
439    label: &str,
440) -> std::result::Result<(usize, usize), VNextError> {
441    if component.dimensions.len() < 3 {
442        return Err(invalid_component(
443            component,
444            format!(
445                "aggregate {label} shape must be [E, projection_axes..., N, physical_K], got {:?}",
446                component.dimensions
447            ),
448        ));
449    }
450    let tail_start = component.dimensions.len() - 2;
451    let expert_count = component.dimensions[0];
452    let typed_n = component.dimensions[tail_start];
453    let typed_tail = component.dimensions[tail_start + 1];
454    let expected_tail = [source_n as u64, source_tail as u64];
455    if [typed_n, typed_tail] != expected_tail {
456        return Err(invalid_component(
457            component,
458            format!(
459                "aggregate {label} tail [{typed_n}, {typed_tail}] must match single-source physical shape {expected_tail:?}"
460            ),
461        ));
462    }
463    let projections_per_expert = component.dimensions[1..tail_start]
464        .iter()
465        .try_fold(1_u64, |count, extent| count.checked_mul(*extent))
466        .ok_or_else(|| {
467            invalid_component(component, "aggregate projection axis product overflows u64")
468        })?;
469    let declared_groups = expert_count
470        .checked_mul(projections_per_expert)
471        .ok_or_else(|| {
472            invalid_component(component, "aggregate source group count overflows u64")
473        })?;
474    if expert_count == 0
475        || projections_per_expert == 0
476        || usize::try_from(declared_groups).ok() != Some(source_group_count)
477    {
478        return Err(invalid_component(
479            component,
480            format!(
481                "aggregate {label} prefix E={expert_count}, projections_per_expert={projections_per_expert} must describe {source_group_count} ordered source groups"
482            ),
483        ));
484    }
485    Ok((
486        usize::try_from(expert_count).map_err(|_| {
487            invalid_component(component, "aggregate expert count exceeds address space")
488        })?,
489        usize::try_from(projections_per_expert).map_err(|_| {
490            invalid_component(
491                component,
492                "aggregate projection count exceeds address space",
493            )
494        })?,
495    ))
496}
497
498fn validate_qweight_shape(
499    component: &WeightComponentSpec,
500    qweight: &SafetensorsTensor<'_>,
501) -> std::result::Result<(usize, usize), VNextError> {
502    if qweight.dtype() != Dtype::I32 {
503        return Err(invalid_component(
504            component,
505            format!("qweight must be I32, got {:?}", qweight.dtype()),
506        ));
507    }
508    let [packed_k, n] = qweight.shape() else {
509        return Err(invalid_component(
510            component,
511            format!(
512                "qweight must have shape [K/8, N], got {:?}",
513                qweight.shape()
514            ),
515        ));
516    };
517    let k = packed_k.checked_mul(8).ok_or_else(|| {
518        invalid_component(component, "qweight K dimension overflows address space")
519    })?;
520    let (k, n) = (
521        usize::try_from(k).map_err(|_| {
522            invalid_component(component, "qweight K dimension exceeds address space")
523        })?,
524        usize::try_from(*n).map_err(|_| {
525            invalid_component(component, "qweight N dimension exceeds address space")
526        })?,
527    );
528    if k % 16 != 0 || n % 16 != 0 || k.checked_mul(n).is_none_or(|elements| elements % 1024 != 0) {
529        return Err(invalid_component(
530            component,
531            format!("qweight shape K={k}, N={n} is not Marlin tile aligned"),
532        ));
533    }
534    Ok((k, n))
535}
536
537fn validate_scale_shape(
538    component: &WeightComponentSpec,
539    scales: &SafetensorsTensor<'_>,
540) -> std::result::Result<(usize, usize), VNextError> {
541    let [group_count, n] = scales.shape() else {
542        return Err(invalid_component(
543            component,
544            format!(
545                "scales must have source shape [K/G, N], got {:?}",
546                scales.shape()
547            ),
548        ));
549    };
550    Ok((
551        usize::try_from(*group_count)
552            .map_err(|_| invalid_component(component, "scale group count exceeds address space"))?,
553        usize::try_from(*n)
554            .map_err(|_| invalid_component(component, "scale N dimension exceeds address space"))?,
555    ))
556}
557
558fn concatenate_equal_width_rows<T: Copy>(
559    parts: &[Vec<T>],
560    row_count: usize,
561    columns_per_part: usize,
562) -> Vec<T> {
563    let mut fused = Vec::with_capacity(row_count * columns_per_part * parts.len());
564    for row in 0..row_count {
565        for part in parts {
566            let start = row * columns_per_part;
567            fused.extend_from_slice(&part[start..start + columns_per_part]);
568        }
569    }
570    fused
571}
572
573fn validate_marlin_quantization(
574    component: &WeightComponentSpec,
575    quantization: &QuantizationSpec,
576) -> std::result::Result<u32, VNextError> {
577    quantization.validate()?;
578    let Some(group_size) = quantization.grouping.fixed_size() else {
579        return Err(invalid_component(
580            component,
581            "typed GPTQ source requires fixed-size quantization groups",
582        ));
583    };
584    if quantization.format_id.as_str() != GPTQ_MARLIN_INT4_FORMAT_ID
585        || quantization.bits_per_weight != 4
586        || quantization.scale_type != ElementType::F16
587        || quantization.zero_point_type.is_some()
588    {
589        return Err(invalid_component(
590            component,
591            "typed GPTQ source requires symmetric INT4 Marlin packing with F16 scales",
592        ));
593    }
594    Ok(group_size)
595}
596
597fn validate_symmetric_qzeros_shape(
598    component: &WeightComponentSpec,
599    qzeros: &SafetensorsTensor<'_>,
600    k: usize,
601    n: usize,
602    group_size: usize,
603) -> std::result::Result<(), VNextError> {
604    if qzeros.dtype() != Dtype::I32
605        || group_size == 0
606        || qzeros.shape() != [k as u64 / group_size as u64, n as u64 / 8]
607    {
608        return Err(invalid_component(
609            component,
610            format!(
611                "qzeros shape/dtype differs from symmetric GPTQ K={k}, N={n}, group_size={group_size}"
612            ),
613        ));
614    }
615    // `sym=true` selects Marlin's fixed uint4b8 bias. GPTQ writers use more
616    // than one historical qzeros convention even though the sidecar is not
617    // consumed for symmetric inference, so its contents must not define the
618    // physical ABI. Identity, dtype, and shape remain strict.
619    Ok(())
620}
621
622fn validate_canonical_g_idx(
623    component: &WeightComponentSpec,
624    g_idx: &SafetensorsTensor<'_>,
625    k: usize,
626    group_size: usize,
627) -> std::result::Result<(), VNextError> {
628    if g_idx.dtype() != Dtype::I32 || g_idx.shape() != [k as u64] {
629        return Err(invalid_component(
630            component,
631            format!("g_idx must be I32[{k}] for desc_act=false"),
632        ));
633    }
634    let values = decode_i32(g_idx.bytes(), component, "g_idx")?;
635    if values
636        .iter()
637        .enumerate()
638        .any(|(index, value)| *value != (index / group_size) as i32)
639    {
640        return Err(invalid_component(
641            component,
642            "g_idx is activation-ordered; the current typed Marlin ABI requires desc_act=false",
643        ));
644    }
645    Ok(())
646}
647
648fn decode_i32(
649    bytes: &[u8],
650    component: &WeightComponentSpec,
651    label: &str,
652) -> std::result::Result<Vec<i32>, VNextError> {
653    if !bytes.len().is_multiple_of(4) {
654        return Err(invalid_component(
655            component,
656            format!("{label} byte length is not I32 aligned"),
657        ));
658    }
659    Ok(bytes
660        .chunks_exact(4)
661        .map(|word| i32::from_le_bytes([word[0], word[1], word[2], word[3]]))
662        .collect())
663}
664
665fn decode_f16(
666    bytes: &[u8],
667    component: &WeightComponentSpec,
668) -> std::result::Result<Vec<f16>, VNextError> {
669    if !bytes.len().is_multiple_of(2) {
670        return Err(invalid_component(
671            component,
672            "scale byte length is not F16 aligned",
673        ));
674    }
675    Ok(bytes
676        .chunks_exact(2)
677        .map(|word| f16::from_bits(u16::from_le_bytes([word[0], word[1]])))
678        .collect())
679}
680
681fn encode_f16(values: Vec<f16>) -> Cow<'static, [u8]> {
682    Cow::Owned(
683        values
684            .into_iter()
685            .flat_map(|value| value.to_bits().to_le_bytes())
686            .collect::<Vec<_>>(),
687    )
688}
689
690fn invalid_component(component: &WeightComponentSpec, reason: impl AsRef<str>) -> VNextError {
691    VNextError::InvalidExecutionPlan {
692        reason: format!(
693            "GPTQ Marlin component `{}`: {}",
694            component.id,
695            reason.as_ref()
696        ),
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use std::collections::BTreeMap;
703
704    use ferrum_interfaces::vnext::{QuantizationFormatId, QuantizationPacking, WeightId};
705    use safetensors::tensor::{serialize_to_file, TensorView};
706    use tempfile::tempdir;
707
708    use super::*;
709
710    fn write_fixture(qzeros_word: i32) -> tempfile::TempDir {
711        let directory = tempdir().unwrap();
712        let k = 128_usize;
713        let n = 64_usize;
714        let qweight_words = vec![0x7654_3210_i32; (k / 8) * n];
715        let qzeros_words = vec![qzeros_word; n / 8];
716        let g_idx = (0..k).map(|_| 0_i32).collect::<Vec<_>>();
717        let scales = vec![f16::from_f32(0.5); n];
718        let qweight_bytes = qweight_words
719            .iter()
720            .flat_map(|value| value.to_le_bytes())
721            .collect::<Vec<_>>();
722        let qzeros_bytes = qzeros_words
723            .iter()
724            .flat_map(|value| value.to_le_bytes())
725            .collect::<Vec<_>>();
726        let g_idx_bytes = g_idx
727            .iter()
728            .flat_map(|value| value.to_le_bytes())
729            .collect::<Vec<_>>();
730        let scale_bytes = scales
731            .iter()
732            .flat_map(|value| value.to_bits().to_le_bytes())
733            .collect::<Vec<_>>();
734        let views = BTreeMap::from([
735            (
736                "layer.proj.g_idx",
737                TensorView::new(Dtype::I32, vec![k], &g_idx_bytes).unwrap(),
738            ),
739            (
740                "layer.proj.qweight",
741                TensorView::new(Dtype::I32, vec![k / 8, n], &qweight_bytes).unwrap(),
742            ),
743            (
744                "layer.proj.qzeros",
745                TensorView::new(Dtype::I32, vec![1, n / 8], &qzeros_bytes).unwrap(),
746            ),
747            (
748                "layer.proj.scales",
749                TensorView::new(Dtype::F16, vec![1, n], &scale_bytes).unwrap(),
750            ),
751        ]);
752        serialize_to_file(views, &None, &directory.path().join("model.safetensors")).unwrap();
753        directory
754    }
755
756    struct GateUpFixture {
757        directory: tempfile::TempDir,
758        qweights: [Vec<i32>; 2],
759        scales: [Vec<f16>; 2],
760        k: usize,
761        n: usize,
762    }
763
764    fn write_gate_up_fixture() -> GateUpFixture {
765        let directory = tempdir().unwrap();
766        let k = 128_usize;
767        let n = 16_usize;
768        let qweights = [
769            (0..(k / 8) * n)
770                .map(|index| (index as u32).wrapping_mul(0x1020_4081) as i32)
771                .collect::<Vec<_>>(),
772            (0..(k / 8) * n)
773                .map(|index| {
774                    (index as u32)
775                        .wrapping_mul(0x0810_2041)
776                        .wrapping_add(0x7654_3210) as i32
777                })
778                .collect::<Vec<_>>(),
779        ];
780        let scales = [
781            (0..n)
782                .map(|index| f16::from_f32(index as f32 + 1.0))
783                .collect::<Vec<_>>(),
784            (0..n)
785                .map(|index| f16::from_f32(index as f32 + 101.0))
786                .collect::<Vec<_>>(),
787        ];
788        let qweight_bytes = qweights.each_ref().map(|values| {
789            values
790                .iter()
791                .flat_map(|value| value.to_le_bytes())
792                .collect::<Vec<_>>()
793        });
794        let scale_bytes = scales.each_ref().map(|values| {
795            values
796                .iter()
797                .flat_map(|value| value.to_bits().to_le_bytes())
798                .collect::<Vec<_>>()
799        });
800        let qzeros = vec![0x8888_8888_u32 as i32; n / 8];
801        let qzeros_bytes = qzeros
802            .iter()
803            .flat_map(|value| value.to_le_bytes())
804            .collect::<Vec<_>>();
805        let g_idx = vec![0_i32; k];
806        let g_idx_bytes = g_idx
807            .iter()
808            .flat_map(|value| value.to_le_bytes())
809            .collect::<Vec<_>>();
810        let views = BTreeMap::from([
811            (
812                "layer.gate.g_idx",
813                TensorView::new(Dtype::I32, vec![k], &g_idx_bytes).unwrap(),
814            ),
815            (
816                "layer.gate.qweight",
817                TensorView::new(Dtype::I32, vec![k / 8, n], &qweight_bytes[0]).unwrap(),
818            ),
819            (
820                "layer.gate.qzeros",
821                TensorView::new(Dtype::I32, vec![1, n / 8], &qzeros_bytes).unwrap(),
822            ),
823            (
824                "layer.gate.scales",
825                TensorView::new(Dtype::F16, vec![1, n], &scale_bytes[0]).unwrap(),
826            ),
827            (
828                "layer.up.g_idx",
829                TensorView::new(Dtype::I32, vec![k], &g_idx_bytes).unwrap(),
830            ),
831            (
832                "layer.up.qweight",
833                TensorView::new(Dtype::I32, vec![k / 8, n], &qweight_bytes[1]).unwrap(),
834            ),
835            (
836                "layer.up.qzeros",
837                TensorView::new(Dtype::I32, vec![1, n / 8], &qzeros_bytes).unwrap(),
838            ),
839            (
840                "layer.up.scales",
841                TensorView::new(Dtype::F16, vec![1, n], &scale_bytes[1]).unwrap(),
842            ),
843        ]);
844        serialize_to_file(views, &None, &directory.path().join("model.safetensors")).unwrap();
845        GateUpFixture {
846            directory,
847            qweights,
848            scales,
849            k,
850            n,
851        }
852    }
853
854    fn quantization() -> QuantizationSpec {
855        QuantizationSpec {
856            format_id: QuantizationFormatId::new(GPTQ_MARLIN_INT4_FORMAT_ID).unwrap(),
857            bits_per_weight: 4,
858            grouping: QuantizationGrouping::fixed(128),
859            packing: QuantizationPacking::Tiled,
860            scale_type: ElementType::F16,
861            zero_point_type: None,
862        }
863    }
864
865    type OwnedTensor = (Dtype, Vec<usize>, Vec<u8>);
866
867    fn dense_fixture(
868        qzeros_word: u32,
869        mutate: impl FnOnce(&mut BTreeMap<String, OwnedTensor>),
870    ) -> tempfile::TempDir {
871        let (n, k, group_size) = (8_usize, 256_usize, 128_usize);
872        let mut words = vec![0_u32; k / 8 * n];
873        for input in 0..k {
874            for output in 0..n {
875                words[input / 8 * n + output] |=
876                    (((input + 3 * output) % 16) as u32) << (4 * (input % 8));
877            }
878        }
879        let mut tensors = BTreeMap::from([
880            (
881                "layer.proj.qweight".to_owned(),
882                (
883                    Dtype::I32,
884                    vec![k / 8, n],
885                    words.into_iter().flat_map(u32::to_le_bytes).collect(),
886                ),
887            ),
888            (
889                "layer.proj.qzeros".to_owned(),
890                (
891                    Dtype::I32,
892                    vec![k / group_size, n / 8],
893                    (0..k / group_size * n / 8)
894                        .flat_map(|_| qzeros_word.to_le_bytes())
895                        .collect(),
896                ),
897            ),
898            (
899                "layer.proj.g_idx".to_owned(),
900                (
901                    Dtype::I32,
902                    vec![k],
903                    (0..k)
904                        .flat_map(|input| ((input / group_size) as i32).to_le_bytes())
905                        .collect(),
906                ),
907            ),
908            (
909                "layer.proj.scales".to_owned(),
910                (
911                    Dtype::F16,
912                    vec![k / group_size, n],
913                    (0..k / group_size)
914                        .flat_map(|group| {
915                            (0..n).flat_map(move |output| {
916                                f16::from_f32((1 + group * 2 + output) as f32 * 0.25)
917                                    .to_bits()
918                                    .to_le_bytes()
919                            })
920                        })
921                        .collect(),
922                ),
923            ),
924        ]);
925        mutate(&mut tensors);
926        let directory = tempdir().unwrap();
927        let views = tensors
928            .iter()
929            .map(|(name, (dtype, shape, bytes))| {
930                (name, TensorView::new(*dtype, shape.clone(), bytes).unwrap())
931            })
932            .collect::<BTreeMap<_, _>>();
933        serialize_to_file(views, &None, &directory.path().join("model.safetensors")).unwrap();
934        directory
935    }
936
937    fn dense_component() -> WeightComponentSpec {
938        WeightComponentSpec {
939            id: WeightId::new("component.layer.proj.values").unwrap(),
940            role: WeightComponentRole::Values,
941            external_names: ["qweight", "qzeros", "g_idx", "scales"]
942                .map(|suffix| format!("layer.proj.{suffix}"))
943                .to_vec(),
944            dimensions: vec![8, 256],
945            encoding: WeightEncoding::Dense {
946                element_type: ElementType::F16,
947            },
948            required: true,
949        }
950    }
951
952    #[test]
953    fn dense_symmetric_gptq_preserves_rows_groups_signs_and_source_identity() {
954        let component = dense_component();
955        for qzeros in [0x7777_7777, 0x8888_8888] {
956            let directory = dense_fixture(qzeros, |_| {});
957            let source = GptqMarlinSafetensorsSource::open(directory.path()).unwrap();
958            let payload = source.component(&component).unwrap();
959            assert_eq!(payload.dimensions(), [8, 256]);
960            assert_eq!(payload.external_names(), component.external_names);
961            let values = decode_f16(payload.bytes(), &component).unwrap();
962            for output in 0..8 {
963                for input in 0..256 {
964                    let code = ((input + 3 * output) % 16) as i32;
965                    let scale = (1 + (input / 128) * 2 + output) as f32 * 0.25;
966                    assert_eq!(
967                        values[output * 256 + input].to_f32(),
968                        (code - 8) as f32 * scale
969                    );
970                }
971            }
972        }
973    }
974
975    #[test]
976    fn dense_gptq_rejects_invalid_source_recipes_and_dimensions() {
977        let directory = dense_fixture(0x7777_7777, |_| {});
978        let source = GptqMarlinSafetensorsSource::open(directory.path()).unwrap();
979        let component = dense_component();
980        let mut invalid = vec![];
981        let mut wrong_order = component.clone();
982        wrong_order.external_names.swap(1, 2);
983        invalid.push(wrong_order);
984        let mut wrong_stem = component.clone();
985        wrong_stem.external_names[3] = "other.scales".into();
986        invalid.push(wrong_stem);
987        let mut missing = component.clone();
988        missing.external_names.pop();
989        invalid.push(missing);
990        for dimensions in [
991            vec![256, 8],
992            vec![4, 512],
993            vec![0, 256],
994            vec![8, 128],
995            vec![2, 4, 256],
996            vec![8, u64::MAX],
997        ] {
998            let mut wrong_shape = component.clone();
999            wrong_shape.dimensions = dimensions;
1000            invalid.push(wrong_shape);
1001        }
1002        for invalid in invalid {
1003            assert!(source.component(&invalid).is_err(), "{invalid:?}");
1004        }
1005    }
1006
1007    #[test]
1008    fn dense_gptq_rejects_bad_payloads_before_materialization() {
1009        for case in 0..7 {
1010            let directory = dense_fixture(0x7777_7777, |tensors| match case {
1011                0 => tensors.get_mut("layer.proj.g_idx").unwrap().2[..4]
1012                    .copy_from_slice(&1_i32.to_le_bytes()),
1013                1 => tensors.get_mut("layer.proj.scales").unwrap().0 = Dtype::BF16,
1014                2 => tensors.get_mut("layer.proj.scales").unwrap().1 = vec![1, 16],
1015                3 => tensors.get_mut("layer.proj.qzeros").unwrap().0 = Dtype::F32,
1016                4 => {
1017                    tensors.remove("layer.proj.g_idx");
1018                }
1019                5 => tensors.get_mut("layer.proj.scales").unwrap().2[..2]
1020                    .copy_from_slice(&f16::NAN.to_bits().to_le_bytes()),
1021                6 => tensors.get_mut("layer.proj.scales").unwrap().2[..2]
1022                    .copy_from_slice(&f16::MAX.to_bits().to_le_bytes()),
1023                _ => unreachable!(),
1024            });
1025            let source = GptqMarlinSafetensorsSource::open(directory.path()).unwrap();
1026            assert!(source.component(&dense_component()).is_err(), "case {case}");
1027        }
1028    }
1029
1030    fn packed_component() -> WeightComponentSpec {
1031        WeightComponentSpec {
1032            id: WeightId::new("component.layer.proj.packed").unwrap(),
1033            role: WeightComponentRole::PackedValues,
1034            external_names: vec![
1035                "layer.proj.qweight".to_owned(),
1036                "layer.proj.qzeros".to_owned(),
1037                "layer.proj.g_idx".to_owned(),
1038            ],
1039            dimensions: vec![4096],
1040            encoding: WeightEncoding::Quantized(quantization()),
1041            required: true,
1042        }
1043    }
1044
1045    #[test]
1046    fn repacks_valid_symmetric_gptq_components_once_at_source_boundary() {
1047        let directory = write_fixture(0x8888_8888_u32 as i32);
1048        let source = GptqMarlinSafetensorsSource::open(directory.path()).unwrap();
1049        let packed = packed_component();
1050        let payload = source.component(&packed).unwrap();
1051        assert_eq!(payload.bytes().len(), 4096);
1052        assert_eq!(payload.external_names(), packed.external_names);
1053
1054        let scales = WeightComponentSpec {
1055            id: WeightId::new("component.layer.proj.scales").unwrap(),
1056            role: WeightComponentRole::Scales,
1057            external_names: vec!["layer.proj.scales".to_owned()],
1058            dimensions: vec![64, 1],
1059            encoding: WeightEncoding::Dense {
1060                element_type: ElementType::F16,
1061            },
1062            required: true,
1063        };
1064        let payload = source.component(&scales).unwrap();
1065        assert_eq!(payload.bytes().len(), 128);
1066        assert_eq!(payload.dimensions(), [64, 1]);
1067    }
1068
1069    #[test]
1070    fn symmetric_qzeros_convention_does_not_change_marlin_payload() {
1071        let code7 = write_fixture(0x7777_7777);
1072        let code8 = write_fixture(0x8888_8888_u32 as i32);
1073        let source7 = GptqMarlinSafetensorsSource::open(code7.path()).unwrap();
1074        let source8 = GptqMarlinSafetensorsSource::open(code8.path()).unwrap();
1075        let component = packed_component();
1076
1077        assert_eq!(
1078            source7.component(&component).unwrap().bytes(),
1079            source8.component(&component).unwrap().bytes()
1080        );
1081    }
1082
1083    #[test]
1084    fn aggregate_gate_up_fuses_raw_columns_before_marlin_repack() {
1085        let fixture = write_gate_up_fixture();
1086        let source = GptqMarlinSafetensorsSource::open(fixture.directory.path()).unwrap();
1087        let packed = WeightComponentSpec {
1088            id: WeightId::new("component.layer.gate_up.packed").unwrap(),
1089            role: WeightComponentRole::PackedValues,
1090            external_names: vec![
1091                "layer.gate.qweight".to_owned(),
1092                "layer.gate.qzeros".to_owned(),
1093                "layer.gate.g_idx".to_owned(),
1094                "layer.up.qweight".to_owned(),
1095                "layer.up.qzeros".to_owned(),
1096                "layer.up.g_idx".to_owned(),
1097            ],
1098            dimensions: vec![1, 2, fixture.n as u64, (fixture.k / 2) as u64],
1099            encoding: WeightEncoding::Quantized(quantization()),
1100            required: true,
1101        };
1102        let raw_fused = concatenate_equal_width_rows(&fixture.qweights, fixture.k / 8, fixture.n);
1103        let expected = repack_gptq_to_marlin(&raw_fused, fixture.k, fixture.n * 2)
1104            .into_iter()
1105            .flat_map(i32::to_le_bytes)
1106            .collect::<Vec<_>>();
1107        let independently_repacked = fixture
1108            .qweights
1109            .iter()
1110            .flat_map(|values| {
1111                repack_gptq_to_marlin(values, fixture.k, fixture.n)
1112                    .into_iter()
1113                    .flat_map(i32::to_le_bytes)
1114            })
1115            .collect::<Vec<_>>();
1116        assert_ne!(expected, independently_repacked);
1117        let payload = source.component(&packed).unwrap();
1118        assert_eq!(payload.bytes(), expected);
1119        assert_eq!(payload.external_names(), packed.external_names);
1120
1121        let scales = WeightComponentSpec {
1122            id: WeightId::new("component.layer.gate_up.scales").unwrap(),
1123            role: WeightComponentRole::Scales,
1124            external_names: vec!["layer.gate.scales".to_owned(), "layer.up.scales".to_owned()],
1125            dimensions: vec![1, 2, fixture.n as u64, 1],
1126            encoding: WeightEncoding::Dense {
1127                element_type: ElementType::F16,
1128            },
1129            required: true,
1130        };
1131        let raw_fused_scales = concatenate_equal_width_rows(&fixture.scales, 1, fixture.n);
1132        let expected_scales = encode_f16(repack_scales_to_marlin(
1133            &raw_fused_scales,
1134            1,
1135            fixture.n * 2,
1136            1,
1137        ));
1138        let independently_repacked_scales = fixture
1139            .scales
1140            .iter()
1141            .flat_map(|values| {
1142                repack_scales_to_marlin(values, 1, fixture.n, 1)
1143                    .into_iter()
1144                    .flat_map(|value| value.to_bits().to_le_bytes())
1145            })
1146            .collect::<Vec<_>>();
1147        assert_ne!(expected_scales.as_ref(), independently_repacked_scales);
1148        let payload = source.component(&scales).unwrap();
1149        assert_eq!(payload.bytes(), expected_scales.as_ref());
1150        assert_eq!(payload.external_names(), scales.external_names);
1151    }
1152
1153    #[test]
1154    fn aggregate_experts_without_projection_axis_repack_independently() {
1155        let fixture = write_gate_up_fixture();
1156        let source = GptqMarlinSafetensorsSource::open(fixture.directory.path()).unwrap();
1157        let packed = WeightComponentSpec {
1158            id: WeightId::new("component.layer.experts.packed").unwrap(),
1159            role: WeightComponentRole::PackedValues,
1160            external_names: vec![
1161                "layer.gate.qweight".to_owned(),
1162                "layer.gate.qzeros".to_owned(),
1163                "layer.gate.g_idx".to_owned(),
1164                "layer.up.qweight".to_owned(),
1165                "layer.up.qzeros".to_owned(),
1166                "layer.up.g_idx".to_owned(),
1167            ],
1168            dimensions: vec![2, fixture.n as u64, (fixture.k / 2) as u64],
1169            encoding: WeightEncoding::Quantized(quantization()),
1170            required: true,
1171        };
1172        let expected = fixture
1173            .qweights
1174            .iter()
1175            .flat_map(|values| {
1176                repack_gptq_to_marlin(values, fixture.k, fixture.n)
1177                    .into_iter()
1178                    .flat_map(i32::to_le_bytes)
1179            })
1180            .collect::<Vec<_>>();
1181        let payload = source.component(&packed).unwrap();
1182        assert_eq!(payload.bytes(), expected);
1183        assert_eq!(payload.dimensions(), packed.dimensions);
1184
1185        let scales = WeightComponentSpec {
1186            id: WeightId::new("component.layer.experts.scales").unwrap(),
1187            role: WeightComponentRole::Scales,
1188            external_names: vec!["layer.gate.scales".to_owned(), "layer.up.scales".to_owned()],
1189            dimensions: vec![2, fixture.n as u64, 1],
1190            encoding: WeightEncoding::Dense {
1191                element_type: ElementType::F16,
1192            },
1193            required: true,
1194        };
1195        let expected_scales = fixture
1196            .scales
1197            .iter()
1198            .flat_map(|values| {
1199                repack_scales_to_marlin(values, 1, fixture.n, 1)
1200                    .into_iter()
1201                    .flat_map(|value| value.to_bits().to_le_bytes())
1202            })
1203            .collect::<Vec<_>>();
1204        let payload = source.component(&scales).unwrap();
1205        assert_eq!(payload.bytes(), expected_scales);
1206        assert_eq!(payload.dimensions(), scales.dimensions);
1207    }
1208}