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    fn packed_values<'source>(
47        &'source self,
48        component: &WeightComponentSpec,
49        quantization: &QuantizationSpec,
50    ) -> std::result::Result<WeightComponentPayload<'source>, VNextError> {
51        let group_size = usize::try_from(validate_marlin_quantization(component, quantization)?)
52            .map_err(|_| invalid_component(component, "GPTQ group size exceeds address space"))?;
53        let groups = packed_source_groups(component)?;
54        let first_qweight = self.tensor(component, groups[0].qweight)?;
55        let (k, n) = validate_qweight_shape(component, &first_qweight)?;
56        let (expert_count, projections_per_expert) = if groups.len() == 1 {
57            let expected_bytes = component.physical_bytes()?;
58            if usize::try_from(expected_bytes).ok() != Some(first_qweight.bytes().len()) {
59                return Err(invalid_component(
60                    component,
61                    "qweight byte size differs from the typed packed component",
62                ));
63            }
64            (1, 1)
65        } else {
66            aggregate_axes(component, groups.len(), n, k / 2, "packed")?
67        };
68        let fused_n = n.checked_mul(projections_per_expert).ok_or_else(|| {
69            invalid_component(component, "fused qweight N dimension exceeds address space")
70        })?;
71        let expected_bytes = usize::try_from(component.physical_bytes()?).map_err(|_| {
72            invalid_component(
73                component,
74                "packed component byte size exceeds address space",
75            )
76        })?;
77        let mut bytes = Vec::with_capacity(expected_bytes);
78        let mut source_files = Vec::with_capacity(component.external_names.len());
79
80        for expert_groups in groups.chunks(projections_per_expert) {
81            let mut projections = Vec::with_capacity(projections_per_expert);
82            for group in expert_groups {
83                let qweight = self.tensor(component, group.qweight)?;
84                let shape = validate_qweight_shape(component, &qweight)?;
85                if shape != (k, n) {
86                    return Err(invalid_component(
87                        component,
88                        format!(
89                            "qweight source `{}` shape K={}, N={} drifts from K={k}, N={n}",
90                            group.qweight, shape.0, shape.1
91                        ),
92                    ));
93                }
94                let qzeros = self.tensor(component, group.qzeros)?;
95                validate_symmetric_qzeros_shape(component, &qzeros, k, n, group_size)?;
96                source_files.push(qweight.source_file().to_owned());
97                source_files.push(qzeros.source_file().to_owned());
98                if let Some(g_idx_name) = group.g_idx {
99                    let g_idx = self.tensor(component, g_idx_name)?;
100                    validate_canonical_g_idx(component, &g_idx, k, group_size)?;
101                    source_files.push(g_idx.source_file().to_owned());
102                }
103                projections.push(decode_i32(qweight.bytes(), component, "qweight")?);
104            }
105            let fused = concatenate_equal_width_rows(&projections, k / 8, n);
106            let start = bytes.len();
107            let byte_length = fused
108                .len()
109                .checked_mul(std::mem::size_of::<i32>())
110                .ok_or_else(|| {
111                    invalid_component(component, "repacked qweight byte length overflows")
112                })?;
113            let end = start.checked_add(byte_length).ok_or_else(|| {
114                invalid_component(component, "aggregate qweight byte length overflows")
115            })?;
116            bytes.resize(end, 0);
117            repack_gptq_to_marlin_bytes_into(&fused, k, fused_n, &mut bytes[start..end]);
118        }
119        debug_assert_eq!(groups.len(), expert_count * projections_per_expert);
120        WeightComponentPayload::from_ordered_sources(
121            component,
122            component.external_names.clone(),
123            source_files,
124            component.dimensions.clone(),
125            ElementType::U8,
126            bytes,
127        )
128    }
129
130    fn scales<'source>(
131        &'source self,
132        component: &WeightComponentSpec,
133    ) -> std::result::Result<WeightComponentPayload<'source>, VNextError> {
134        if component.external_names.is_empty() {
135            return Err(invalid_component(
136                component,
137                "Marlin scales require at least one safetensors source",
138            ));
139        }
140        if component
141            .external_names
142            .iter()
143            .any(|external_name| !external_name.ends_with(".scales"))
144        {
145            return Err(invalid_component(
146                component,
147                "every Marlin scale source must end with .scales",
148            ));
149        }
150        let external_name = &component.external_names[0];
151        let scales = self.tensor(component, external_name)?;
152        let (group_count, n) = validate_scale_shape(component, &scales)?;
153        let (expert_count, projections_per_expert) = if component.external_names.len() == 1 {
154            let mut expected_dimensions = vec![1_u64; component.dimensions.len().saturating_sub(2)];
155            expected_dimensions.extend([n as u64, group_count as u64]);
156            if component.dimensions != expected_dimensions {
157                return Err(invalid_component(
158                    component,
159                    format!(
160                        "typed scale shape {:?} must be {:?} for source shape [{group_count}, {n}]",
161                        component.dimensions, expected_dimensions,
162                    ),
163                ));
164            }
165            (1, 1)
166        } else {
167            aggregate_axes(
168                component,
169                component.external_names.len(),
170                n,
171                group_count,
172                "scale",
173            )?
174        };
175        let fused_n = n.checked_mul(projections_per_expert).ok_or_else(|| {
176            invalid_component(component, "fused scale N dimension exceeds address space")
177        })?;
178        let expected_bytes = usize::try_from(component.physical_bytes()?).map_err(|_| {
179            invalid_component(component, "scale component byte size exceeds address space")
180        })?;
181        let mut bytes = Vec::with_capacity(expected_bytes);
182        let mut source_files = Vec::with_capacity(component.external_names.len());
183
184        for expert_names in component.external_names.chunks(projections_per_expert) {
185            let mut projections = Vec::with_capacity(projections_per_expert);
186            for external_name in expert_names {
187                let scales = self.tensor(component, external_name)?;
188                let shape = validate_scale_shape(component, &scales)?;
189                if shape != (group_count, n) {
190                    return Err(invalid_component(
191                        component,
192                        format!(
193                            "scale source `{external_name}` shape [{}, {}] drifts from [{group_count}, {n}]",
194                            shape.0, shape.1
195                        ),
196                    ));
197                }
198                let source_type = scales.element_type().ok_or_else(|| {
199                    invalid_component(
200                        component,
201                        format!("scales have unsupported dtype {:?}", scales.dtype()),
202                    )
203                })?;
204                let f16_bytes = transcode_dense_bytes(
205                    scales.bytes(),
206                    source_type,
207                    ElementType::F16,
208                    external_name,
209                    None,
210                )?;
211                projections.push(decode_f16(&f16_bytes, component)?);
212                source_files.push(scales.source_file().to_owned());
213            }
214            let fused = concatenate_equal_width_rows(&projections, group_count, n);
215            let repacked = repack_scales_to_marlin(&fused, group_count, fused_n, 1);
216            bytes.extend_from_slice(encode_f16(repacked).as_ref());
217        }
218        debug_assert_eq!(
219            component.external_names.len(),
220            expert_count * projections_per_expert
221        );
222        WeightComponentPayload::from_ordered_sources(
223            component,
224            component.external_names.clone(),
225            source_files,
226            component.dimensions.clone(),
227            ElementType::F16,
228            bytes,
229        )
230    }
231
232    fn tensor<'source>(
233        &'source self,
234        component: &WeightComponentSpec,
235        external_name: &str,
236    ) -> std::result::Result<SafetensorsTensor<'source>, VNextError> {
237        self.archive
238            .tensor(external_name)
239            .map_err(|error| invalid_component(component, error.to_string()))
240    }
241}
242
243impl WeightComponentSource for GptqMarlinSafetensorsSource {
244    fn component<'source>(
245        &'source self,
246        component: &WeightComponentSpec,
247    ) -> std::result::Result<WeightComponentPayload<'source>, VNextError> {
248        match (&component.role, &component.encoding) {
249            (WeightComponentRole::PackedValues, WeightEncoding::Quantized(quantization)) => {
250                self.packed_values(component, quantization)
251            }
252            (
253                WeightComponentRole::Scales,
254                WeightEncoding::Dense {
255                    element_type: ElementType::F16,
256                },
257            ) => self.scales(component),
258            (_, WeightEncoding::Dense { .. } | WeightEncoding::DenseAffine { .. }) => {
259                self.archive.component(component)
260            }
261            _ => Err(invalid_component(
262                component,
263                "GPTQ Marlin adapter received an unsupported component encoding",
264            )),
265        }
266    }
267}
268
269#[derive(Clone, Copy)]
270struct PackedSourceGroup<'name> {
271    qweight: &'name str,
272    qzeros: &'name str,
273    g_idx: Option<&'name str>,
274}
275
276fn packed_source_groups(
277    component: &WeightComponentSpec,
278) -> std::result::Result<Vec<PackedSourceGroup<'_>>, VNextError> {
279    if component.external_names.is_empty() {
280        return Err(invalid_component(
281            component,
282            "packed GPTQ values require ordered qweight and qzeros sources",
283        ));
284    }
285    let mut groups = Vec::new();
286    let mut cursor = 0;
287    let mut expected_g_idx_presence = None;
288    while cursor < component.external_names.len() {
289        let qweight = &component.external_names[cursor];
290        let stem = qweight.strip_suffix(".qweight").unwrap_or_default();
291        let Some(qzeros) = component.external_names.get(cursor + 1) else {
292            return Err(invalid_component(
293                component,
294                "each packed GPTQ source group requires qweight followed by qzeros",
295            ));
296        };
297        if stem.is_empty() || qzeros != &format!("{stem}.qzeros") {
298            return Err(invalid_component(
299                component,
300                "packed GPTQ source groups must share one stem and be ordered qweight, qzeros, then optional g_idx",
301            ));
302        }
303        let expected_g_idx = format!("{stem}.g_idx");
304        let g_idx = component
305            .external_names
306            .get(cursor + 2)
307            .filter(|name| name.as_str() == expected_g_idx)
308            .map(String::as_str);
309        let has_g_idx = g_idx.is_some();
310        if expected_g_idx_presence
311            .replace(has_g_idx)
312            .is_some_and(|expected| expected != has_g_idx)
313        {
314            return Err(invalid_component(
315                component,
316                "packed GPTQ source groups cannot mix g_idx presence",
317            ));
318        }
319        groups.push(PackedSourceGroup {
320            qweight,
321            qzeros,
322            g_idx,
323        });
324        cursor += if has_g_idx { 3 } else { 2 };
325    }
326    Ok(groups)
327}
328
329fn aggregate_axes(
330    component: &WeightComponentSpec,
331    source_group_count: usize,
332    source_n: usize,
333    source_tail: usize,
334    label: &str,
335) -> std::result::Result<(usize, usize), VNextError> {
336    if component.dimensions.len() < 3 {
337        return Err(invalid_component(
338            component,
339            format!(
340                "aggregate {label} shape must be [E, projection_axes..., N, physical_K], got {:?}",
341                component.dimensions
342            ),
343        ));
344    }
345    let tail_start = component.dimensions.len() - 2;
346    let expert_count = component.dimensions[0];
347    let typed_n = component.dimensions[tail_start];
348    let typed_tail = component.dimensions[tail_start + 1];
349    let expected_tail = [source_n as u64, source_tail as u64];
350    if [typed_n, typed_tail] != expected_tail {
351        return Err(invalid_component(
352            component,
353            format!(
354                "aggregate {label} tail [{typed_n}, {typed_tail}] must match single-source physical shape {expected_tail:?}"
355            ),
356        ));
357    }
358    let projections_per_expert = component.dimensions[1..tail_start]
359        .iter()
360        .try_fold(1_u64, |count, extent| count.checked_mul(*extent))
361        .ok_or_else(|| {
362            invalid_component(component, "aggregate projection axis product overflows u64")
363        })?;
364    let declared_groups = expert_count
365        .checked_mul(projections_per_expert)
366        .ok_or_else(|| {
367            invalid_component(component, "aggregate source group count overflows u64")
368        })?;
369    if expert_count == 0
370        || projections_per_expert == 0
371        || usize::try_from(declared_groups).ok() != Some(source_group_count)
372    {
373        return Err(invalid_component(
374            component,
375            format!(
376                "aggregate {label} prefix E={expert_count}, projections_per_expert={projections_per_expert} must describe {source_group_count} ordered source groups"
377            ),
378        ));
379    }
380    Ok((
381        usize::try_from(expert_count).map_err(|_| {
382            invalid_component(component, "aggregate expert count exceeds address space")
383        })?,
384        usize::try_from(projections_per_expert).map_err(|_| {
385            invalid_component(
386                component,
387                "aggregate projection count exceeds address space",
388            )
389        })?,
390    ))
391}
392
393fn validate_qweight_shape(
394    component: &WeightComponentSpec,
395    qweight: &SafetensorsTensor<'_>,
396) -> std::result::Result<(usize, usize), VNextError> {
397    if qweight.dtype() != Dtype::I32 {
398        return Err(invalid_component(
399            component,
400            format!("qweight must be I32, got {:?}", qweight.dtype()),
401        ));
402    }
403    let [packed_k, n] = qweight.shape() else {
404        return Err(invalid_component(
405            component,
406            format!(
407                "qweight must have shape [K/8, N], got {:?}",
408                qweight.shape()
409            ),
410        ));
411    };
412    let k = packed_k.checked_mul(8).ok_or_else(|| {
413        invalid_component(component, "qweight K dimension overflows address space")
414    })?;
415    let (k, n) = (
416        usize::try_from(k).map_err(|_| {
417            invalid_component(component, "qweight K dimension exceeds address space")
418        })?,
419        usize::try_from(*n).map_err(|_| {
420            invalid_component(component, "qweight N dimension exceeds address space")
421        })?,
422    );
423    if k % 16 != 0 || n % 16 != 0 || k.checked_mul(n).is_none_or(|elements| elements % 1024 != 0) {
424        return Err(invalid_component(
425            component,
426            format!("qweight shape K={k}, N={n} is not Marlin tile aligned"),
427        ));
428    }
429    Ok((k, n))
430}
431
432fn validate_scale_shape(
433    component: &WeightComponentSpec,
434    scales: &SafetensorsTensor<'_>,
435) -> std::result::Result<(usize, usize), VNextError> {
436    let [group_count, n] = scales.shape() else {
437        return Err(invalid_component(
438            component,
439            format!(
440                "scales must have source shape [K/G, N], got {:?}",
441                scales.shape()
442            ),
443        ));
444    };
445    Ok((
446        usize::try_from(*group_count)
447            .map_err(|_| invalid_component(component, "scale group count exceeds address space"))?,
448        usize::try_from(*n)
449            .map_err(|_| invalid_component(component, "scale N dimension exceeds address space"))?,
450    ))
451}
452
453fn concatenate_equal_width_rows<T: Copy>(
454    parts: &[Vec<T>],
455    row_count: usize,
456    columns_per_part: usize,
457) -> Vec<T> {
458    let mut fused = Vec::with_capacity(row_count * columns_per_part * parts.len());
459    for row in 0..row_count {
460        for part in parts {
461            let start = row * columns_per_part;
462            fused.extend_from_slice(&part[start..start + columns_per_part]);
463        }
464    }
465    fused
466}
467
468fn validate_marlin_quantization(
469    component: &WeightComponentSpec,
470    quantization: &QuantizationSpec,
471) -> std::result::Result<u32, VNextError> {
472    quantization.validate()?;
473    let Some(group_size) = quantization.grouping.fixed_size() else {
474        return Err(invalid_component(
475            component,
476            "typed GPTQ source requires fixed-size quantization groups",
477        ));
478    };
479    if quantization.format_id.as_str() != GPTQ_MARLIN_INT4_FORMAT_ID
480        || quantization.bits_per_weight != 4
481        || quantization.scale_type != ElementType::F16
482        || quantization.zero_point_type.is_some()
483    {
484        return Err(invalid_component(
485            component,
486            "typed GPTQ source requires symmetric INT4 Marlin packing with F16 scales",
487        ));
488    }
489    Ok(group_size)
490}
491
492fn validate_symmetric_qzeros_shape(
493    component: &WeightComponentSpec,
494    qzeros: &SafetensorsTensor<'_>,
495    k: usize,
496    n: usize,
497    group_size: usize,
498) -> std::result::Result<(), VNextError> {
499    if qzeros.dtype() != Dtype::I32
500        || group_size == 0
501        || qzeros.shape() != [k as u64 / group_size as u64, n as u64 / 8]
502    {
503        return Err(invalid_component(
504            component,
505            format!(
506                "qzeros shape/dtype differs from symmetric GPTQ K={k}, N={n}, group_size={group_size}"
507            ),
508        ));
509    }
510    // `sym=true` selects Marlin's fixed uint4b8 bias. GPTQ writers use more
511    // than one historical qzeros convention even though the sidecar is not
512    // consumed for symmetric inference, so its contents must not define the
513    // physical ABI. Identity, dtype, and shape remain strict.
514    Ok(())
515}
516
517fn validate_canonical_g_idx(
518    component: &WeightComponentSpec,
519    g_idx: &SafetensorsTensor<'_>,
520    k: usize,
521    group_size: usize,
522) -> std::result::Result<(), VNextError> {
523    if g_idx.dtype() != Dtype::I32 || g_idx.shape() != [k as u64] {
524        return Err(invalid_component(
525            component,
526            format!("g_idx must be I32[{k}] for desc_act=false"),
527        ));
528    }
529    let values = decode_i32(g_idx.bytes(), component, "g_idx")?;
530    if values
531        .iter()
532        .enumerate()
533        .any(|(index, value)| *value != (index / group_size) as i32)
534    {
535        return Err(invalid_component(
536            component,
537            "g_idx is activation-ordered; the current typed Marlin ABI requires desc_act=false",
538        ));
539    }
540    Ok(())
541}
542
543fn decode_i32(
544    bytes: &[u8],
545    component: &WeightComponentSpec,
546    label: &str,
547) -> std::result::Result<Vec<i32>, VNextError> {
548    if !bytes.len().is_multiple_of(4) {
549        return Err(invalid_component(
550            component,
551            format!("{label} byte length is not I32 aligned"),
552        ));
553    }
554    Ok(bytes
555        .chunks_exact(4)
556        .map(|word| i32::from_le_bytes([word[0], word[1], word[2], word[3]]))
557        .collect())
558}
559
560fn decode_f16(
561    bytes: &[u8],
562    component: &WeightComponentSpec,
563) -> std::result::Result<Vec<f16>, VNextError> {
564    if !bytes.len().is_multiple_of(2) {
565        return Err(invalid_component(
566            component,
567            "scale byte length is not F16 aligned",
568        ));
569    }
570    Ok(bytes
571        .chunks_exact(2)
572        .map(|word| f16::from_bits(u16::from_le_bytes([word[0], word[1]])))
573        .collect())
574}
575
576fn encode_f16(values: Vec<f16>) -> Cow<'static, [u8]> {
577    Cow::Owned(
578        values
579            .into_iter()
580            .flat_map(|value| value.to_bits().to_le_bytes())
581            .collect::<Vec<_>>(),
582    )
583}
584
585fn invalid_component(component: &WeightComponentSpec, reason: impl AsRef<str>) -> VNextError {
586    VNextError::InvalidExecutionPlan {
587        reason: format!(
588            "GPTQ Marlin component `{}`: {}",
589            component.id,
590            reason.as_ref()
591        ),
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use std::collections::BTreeMap;
598
599    use ferrum_interfaces::vnext::{QuantizationFormatId, QuantizationPacking, WeightId};
600    use safetensors::tensor::{serialize_to_file, TensorView};
601    use tempfile::tempdir;
602
603    use super::*;
604
605    fn write_fixture(qzeros_word: i32) -> tempfile::TempDir {
606        let directory = tempdir().unwrap();
607        let k = 128_usize;
608        let n = 64_usize;
609        let qweight_words = vec![0x7654_3210_i32; (k / 8) * n];
610        let qzeros_words = vec![qzeros_word; n / 8];
611        let g_idx = (0..k).map(|_| 0_i32).collect::<Vec<_>>();
612        let scales = vec![f16::from_f32(0.5); n];
613        let qweight_bytes = qweight_words
614            .iter()
615            .flat_map(|value| value.to_le_bytes())
616            .collect::<Vec<_>>();
617        let qzeros_bytes = qzeros_words
618            .iter()
619            .flat_map(|value| value.to_le_bytes())
620            .collect::<Vec<_>>();
621        let g_idx_bytes = g_idx
622            .iter()
623            .flat_map(|value| value.to_le_bytes())
624            .collect::<Vec<_>>();
625        let scale_bytes = scales
626            .iter()
627            .flat_map(|value| value.to_bits().to_le_bytes())
628            .collect::<Vec<_>>();
629        let views = BTreeMap::from([
630            (
631                "layer.proj.g_idx",
632                TensorView::new(Dtype::I32, vec![k], &g_idx_bytes).unwrap(),
633            ),
634            (
635                "layer.proj.qweight",
636                TensorView::new(Dtype::I32, vec![k / 8, n], &qweight_bytes).unwrap(),
637            ),
638            (
639                "layer.proj.qzeros",
640                TensorView::new(Dtype::I32, vec![1, n / 8], &qzeros_bytes).unwrap(),
641            ),
642            (
643                "layer.proj.scales",
644                TensorView::new(Dtype::F16, vec![1, n], &scale_bytes).unwrap(),
645            ),
646        ]);
647        serialize_to_file(views, &None, &directory.path().join("model.safetensors")).unwrap();
648        directory
649    }
650
651    struct GateUpFixture {
652        directory: tempfile::TempDir,
653        qweights: [Vec<i32>; 2],
654        scales: [Vec<f16>; 2],
655        k: usize,
656        n: usize,
657    }
658
659    fn write_gate_up_fixture() -> GateUpFixture {
660        let directory = tempdir().unwrap();
661        let k = 128_usize;
662        let n = 16_usize;
663        let qweights = [
664            (0..(k / 8) * n)
665                .map(|index| (index as u32).wrapping_mul(0x1020_4081) as i32)
666                .collect::<Vec<_>>(),
667            (0..(k / 8) * n)
668                .map(|index| {
669                    (index as u32)
670                        .wrapping_mul(0x0810_2041)
671                        .wrapping_add(0x7654_3210) as i32
672                })
673                .collect::<Vec<_>>(),
674        ];
675        let scales = [
676            (0..n)
677                .map(|index| f16::from_f32(index as f32 + 1.0))
678                .collect::<Vec<_>>(),
679            (0..n)
680                .map(|index| f16::from_f32(index as f32 + 101.0))
681                .collect::<Vec<_>>(),
682        ];
683        let qweight_bytes = qweights.each_ref().map(|values| {
684            values
685                .iter()
686                .flat_map(|value| value.to_le_bytes())
687                .collect::<Vec<_>>()
688        });
689        let scale_bytes = scales.each_ref().map(|values| {
690            values
691                .iter()
692                .flat_map(|value| value.to_bits().to_le_bytes())
693                .collect::<Vec<_>>()
694        });
695        let qzeros = vec![0x8888_8888_u32 as i32; n / 8];
696        let qzeros_bytes = qzeros
697            .iter()
698            .flat_map(|value| value.to_le_bytes())
699            .collect::<Vec<_>>();
700        let g_idx = vec![0_i32; k];
701        let g_idx_bytes = g_idx
702            .iter()
703            .flat_map(|value| value.to_le_bytes())
704            .collect::<Vec<_>>();
705        let views = BTreeMap::from([
706            (
707                "layer.gate.g_idx",
708                TensorView::new(Dtype::I32, vec![k], &g_idx_bytes).unwrap(),
709            ),
710            (
711                "layer.gate.qweight",
712                TensorView::new(Dtype::I32, vec![k / 8, n], &qweight_bytes[0]).unwrap(),
713            ),
714            (
715                "layer.gate.qzeros",
716                TensorView::new(Dtype::I32, vec![1, n / 8], &qzeros_bytes).unwrap(),
717            ),
718            (
719                "layer.gate.scales",
720                TensorView::new(Dtype::F16, vec![1, n], &scale_bytes[0]).unwrap(),
721            ),
722            (
723                "layer.up.g_idx",
724                TensorView::new(Dtype::I32, vec![k], &g_idx_bytes).unwrap(),
725            ),
726            (
727                "layer.up.qweight",
728                TensorView::new(Dtype::I32, vec![k / 8, n], &qweight_bytes[1]).unwrap(),
729            ),
730            (
731                "layer.up.qzeros",
732                TensorView::new(Dtype::I32, vec![1, n / 8], &qzeros_bytes).unwrap(),
733            ),
734            (
735                "layer.up.scales",
736                TensorView::new(Dtype::F16, vec![1, n], &scale_bytes[1]).unwrap(),
737            ),
738        ]);
739        serialize_to_file(views, &None, &directory.path().join("model.safetensors")).unwrap();
740        GateUpFixture {
741            directory,
742            qweights,
743            scales,
744            k,
745            n,
746        }
747    }
748
749    fn quantization() -> QuantizationSpec {
750        QuantizationSpec {
751            format_id: QuantizationFormatId::new(GPTQ_MARLIN_INT4_FORMAT_ID).unwrap(),
752            bits_per_weight: 4,
753            grouping: QuantizationGrouping::fixed(128),
754            packing: QuantizationPacking::Tiled,
755            scale_type: ElementType::F16,
756            zero_point_type: None,
757        }
758    }
759
760    fn packed_component() -> WeightComponentSpec {
761        WeightComponentSpec {
762            id: WeightId::new("component.layer.proj.packed").unwrap(),
763            role: WeightComponentRole::PackedValues,
764            external_names: vec![
765                "layer.proj.qweight".to_owned(),
766                "layer.proj.qzeros".to_owned(),
767                "layer.proj.g_idx".to_owned(),
768            ],
769            dimensions: vec![4096],
770            encoding: WeightEncoding::Quantized(quantization()),
771            required: true,
772        }
773    }
774
775    #[test]
776    fn repacks_valid_symmetric_gptq_components_once_at_source_boundary() {
777        let directory = write_fixture(0x8888_8888_u32 as i32);
778        let source = GptqMarlinSafetensorsSource::open(directory.path()).unwrap();
779        let packed = packed_component();
780        let payload = source.component(&packed).unwrap();
781        assert_eq!(payload.bytes().len(), 4096);
782        assert_eq!(payload.external_names(), packed.external_names);
783
784        let scales = WeightComponentSpec {
785            id: WeightId::new("component.layer.proj.scales").unwrap(),
786            role: WeightComponentRole::Scales,
787            external_names: vec!["layer.proj.scales".to_owned()],
788            dimensions: vec![64, 1],
789            encoding: WeightEncoding::Dense {
790                element_type: ElementType::F16,
791            },
792            required: true,
793        };
794        let payload = source.component(&scales).unwrap();
795        assert_eq!(payload.bytes().len(), 128);
796        assert_eq!(payload.dimensions(), [64, 1]);
797    }
798
799    #[test]
800    fn symmetric_qzeros_convention_does_not_change_marlin_payload() {
801        let code7 = write_fixture(0x7777_7777);
802        let code8 = write_fixture(0x8888_8888_u32 as i32);
803        let source7 = GptqMarlinSafetensorsSource::open(code7.path()).unwrap();
804        let source8 = GptqMarlinSafetensorsSource::open(code8.path()).unwrap();
805        let component = packed_component();
806
807        assert_eq!(
808            source7.component(&component).unwrap().bytes(),
809            source8.component(&component).unwrap().bytes()
810        );
811    }
812
813    #[test]
814    fn aggregate_gate_up_fuses_raw_columns_before_marlin_repack() {
815        let fixture = write_gate_up_fixture();
816        let source = GptqMarlinSafetensorsSource::open(fixture.directory.path()).unwrap();
817        let packed = WeightComponentSpec {
818            id: WeightId::new("component.layer.gate_up.packed").unwrap(),
819            role: WeightComponentRole::PackedValues,
820            external_names: vec![
821                "layer.gate.qweight".to_owned(),
822                "layer.gate.qzeros".to_owned(),
823                "layer.gate.g_idx".to_owned(),
824                "layer.up.qweight".to_owned(),
825                "layer.up.qzeros".to_owned(),
826                "layer.up.g_idx".to_owned(),
827            ],
828            dimensions: vec![1, 2, fixture.n as u64, (fixture.k / 2) as u64],
829            encoding: WeightEncoding::Quantized(quantization()),
830            required: true,
831        };
832        let raw_fused = concatenate_equal_width_rows(&fixture.qweights, fixture.k / 8, fixture.n);
833        let expected = repack_gptq_to_marlin(&raw_fused, fixture.k, fixture.n * 2)
834            .into_iter()
835            .flat_map(i32::to_le_bytes)
836            .collect::<Vec<_>>();
837        let independently_repacked = fixture
838            .qweights
839            .iter()
840            .flat_map(|values| {
841                repack_gptq_to_marlin(values, fixture.k, fixture.n)
842                    .into_iter()
843                    .flat_map(i32::to_le_bytes)
844            })
845            .collect::<Vec<_>>();
846        assert_ne!(expected, independently_repacked);
847        let payload = source.component(&packed).unwrap();
848        assert_eq!(payload.bytes(), expected);
849        assert_eq!(payload.external_names(), packed.external_names);
850
851        let scales = WeightComponentSpec {
852            id: WeightId::new("component.layer.gate_up.scales").unwrap(),
853            role: WeightComponentRole::Scales,
854            external_names: vec!["layer.gate.scales".to_owned(), "layer.up.scales".to_owned()],
855            dimensions: vec![1, 2, fixture.n as u64, 1],
856            encoding: WeightEncoding::Dense {
857                element_type: ElementType::F16,
858            },
859            required: true,
860        };
861        let raw_fused_scales = concatenate_equal_width_rows(&fixture.scales, 1, fixture.n);
862        let expected_scales = encode_f16(repack_scales_to_marlin(
863            &raw_fused_scales,
864            1,
865            fixture.n * 2,
866            1,
867        ));
868        let independently_repacked_scales = fixture
869            .scales
870            .iter()
871            .flat_map(|values| {
872                repack_scales_to_marlin(values, 1, fixture.n, 1)
873                    .into_iter()
874                    .flat_map(|value| value.to_bits().to_le_bytes())
875            })
876            .collect::<Vec<_>>();
877        assert_ne!(expected_scales.as_ref(), independently_repacked_scales);
878        let payload = source.component(&scales).unwrap();
879        assert_eq!(payload.bytes(), expected_scales.as_ref());
880        assert_eq!(payload.external_names(), scales.external_names);
881    }
882
883    #[test]
884    fn aggregate_experts_without_projection_axis_repack_independently() {
885        let fixture = write_gate_up_fixture();
886        let source = GptqMarlinSafetensorsSource::open(fixture.directory.path()).unwrap();
887        let packed = WeightComponentSpec {
888            id: WeightId::new("component.layer.experts.packed").unwrap(),
889            role: WeightComponentRole::PackedValues,
890            external_names: vec![
891                "layer.gate.qweight".to_owned(),
892                "layer.gate.qzeros".to_owned(),
893                "layer.gate.g_idx".to_owned(),
894                "layer.up.qweight".to_owned(),
895                "layer.up.qzeros".to_owned(),
896                "layer.up.g_idx".to_owned(),
897            ],
898            dimensions: vec![2, fixture.n as u64, (fixture.k / 2) as u64],
899            encoding: WeightEncoding::Quantized(quantization()),
900            required: true,
901        };
902        let expected = fixture
903            .qweights
904            .iter()
905            .flat_map(|values| {
906                repack_gptq_to_marlin(values, fixture.k, fixture.n)
907                    .into_iter()
908                    .flat_map(i32::to_le_bytes)
909            })
910            .collect::<Vec<_>>();
911        let payload = source.component(&packed).unwrap();
912        assert_eq!(payload.bytes(), expected);
913        assert_eq!(payload.dimensions(), packed.dimensions);
914
915        let scales = WeightComponentSpec {
916            id: WeightId::new("component.layer.experts.scales").unwrap(),
917            role: WeightComponentRole::Scales,
918            external_names: vec!["layer.gate.scales".to_owned(), "layer.up.scales".to_owned()],
919            dimensions: vec![2, fixture.n as u64, 1],
920            encoding: WeightEncoding::Dense {
921                element_type: ElementType::F16,
922            },
923            required: true,
924        };
925        let expected_scales = fixture
926            .scales
927            .iter()
928            .flat_map(|values| {
929                repack_scales_to_marlin(values, 1, fixture.n, 1)
930                    .into_iter()
931                    .flat_map(|value| value.to_bits().to_le_bytes())
932            })
933            .collect::<Vec<_>>();
934        let payload = source.component(&scales).unwrap();
935        assert_eq!(payload.bytes(), expected_scales);
936        assert_eq!(payload.dimensions(), scales.dimensions);
937    }
938}