Skip to main content

ferrum_quantization/
compressed_tensors_marlin_source.rs

1//! Exact compressed-tensors W4 adapter for the Marlin physical ABI.
2//!
3//! The supported checkpoint subset is intentionally narrow: pack-quantized
4//! INT4 weights, fixed group size, optional format-typed asymmetric zero
5//! points, and no activation quantization. Repacking is cold-path CPU work
6//! performed while static plan resources are initialized.
7
8use std::borrow::Cow;
9use std::path::Path;
10
11use ferrum_interfaces::vnext::{
12    ElementType, QuantizationSpec, VNextError, WeightComponentPayload, WeightComponentRole,
13    WeightComponentSource, WeightComponentSpec, WeightEncoding,
14};
15use ferrum_kernels::marlin_repack::{
16    repack_compressed_tensors_zero_points_to_marlin, repack_gptq_to_marlin_bytes_into,
17    repack_scales_to_marlin,
18};
19use ferrum_types::Result;
20use half::f16;
21use safetensors::Dtype;
22
23use crate::safetensors_archive::{transcode_dense_bytes, SafetensorsArchive, SafetensorsTensor};
24
25pub const COMPRESSED_TENSORS_MARLIN_INT4_FORMAT_ID: &str =
26    "quantization.marlin.compressed-tensors-int4-asymmetric";
27pub const COMPRESSED_TENSORS_MARLIN_INT4_SYMMETRIC_FORMAT_ID: &str =
28    "quantization.marlin.compressed-tensors-int4-symmetric";
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31enum CompressedTensorsInt4Mode {
32    Asymmetric,
33    Symmetric,
34}
35
36struct ValidatedQuantization {
37    group_size: usize,
38    mode: CompressedTensorsInt4Mode,
39}
40
41/// Mmap-backed safetensors archive with an exact compressed-tensors-to-Marlin
42/// adapter. Dense components keep the archive's zero-copy/transcode behavior.
43pub struct CompressedTensorsMarlinSafetensorsSource {
44    archive: SafetensorsArchive,
45}
46
47impl CompressedTensorsMarlinSafetensorsSource {
48    pub fn open(model_dir: impl AsRef<Path>) -> Result<Self> {
49        SafetensorsArchive::open(model_dir).map(Self::new)
50    }
51
52    pub const fn new(archive: SafetensorsArchive) -> Self {
53        Self { archive }
54    }
55
56    pub const fn archive(&self) -> &SafetensorsArchive {
57        &self.archive
58    }
59
60    fn packed_values<'source>(
61        &'source self,
62        component: &WeightComponentSpec,
63        quantization: &QuantizationSpec,
64    ) -> std::result::Result<WeightComponentPayload<'source>, VNextError> {
65        let validated = validate_quantization(component, quantization)?;
66        let group_size = validated.group_size;
67        let [packed_name, shape_name] = component.external_names.as_slice() else {
68            return Err(invalid_component(
69                component,
70                "packed values require ordered weight_packed and weight_shape sources",
71            ));
72        };
73        let stem = packed_name
74            .strip_suffix(".weight_packed")
75            .unwrap_or_default();
76        if stem.is_empty() || shape_name != &format!("{stem}.weight_shape") {
77            return Err(invalid_component(
78                component,
79                "packed values and shape metadata must share one compressed-tensors stem",
80            ));
81        }
82        if validated.mode == CompressedTensorsInt4Mode::Symmetric
83            && self.archive.contains(&format!("{stem}.weight_zero_point"))
84        {
85            return Err(invalid_component(
86                component,
87                "symmetric compressed-tensors must not provide weight_zero_point",
88            ));
89        }
90        let packed = self.tensor(component, packed_name)?;
91        let shape = self.tensor(component, shape_name)?;
92        let (n, k) = validate_shape_metadata(component, &shape)?;
93        if packed.dtype() != Dtype::I32 || packed.shape() != [n as u64, (k / 8) as u64] {
94            return Err(invalid_component(
95                component,
96                format!(
97                    "weight_packed must be I32[{n}, {}], got {:?} {:?}",
98                    k / 8,
99                    packed.dtype(),
100                    packed.shape()
101                ),
102            ));
103        }
104        if k % group_size != 0 || k % 16 != 0 || n % 64 != 0 {
105            return Err(invalid_component(
106                component,
107                format!("logical [N={n}, K={k}] is not group/Marlin aligned"),
108            ));
109        }
110        let expected_dimensions = [n as u64, (k / 2) as u64];
111        if !has_unit_prefix_and_tail(&component.dimensions, &expected_dimensions) {
112            return Err(invalid_component(
113                component,
114                format!(
115                    "packed component shape {:?} must be {expected_dimensions:?}",
116                    component.dimensions
117                ),
118            ));
119        }
120
121        let source = decode_i32(packed.bytes(), component, "weight_packed")?;
122        let mut gptq_words = vec![0_i32; source.len()];
123        for output in 0..n {
124            for packed_input in 0..k / 8 {
125                gptq_words[packed_input * n + output] = source[output * (k / 8) + packed_input];
126            }
127        }
128        // compressed-tensors pack-quantized INT4 adds the signed-domain bias
129        // of eight before packing and stores the first input in the low
130        // nibble. Those codes are already Marlin U4B8 codes, so symmetric
131        // weights need only the [N, K/8] -> [K/8, N] transpose above and the
132        // normal Marlin tile permutation. Applying another bias/XOR here
133        // would corrupt the signed-domain meaning of every symmetric code.
134        let expected_bytes = usize::try_from(component.physical_bytes()?)
135            .map_err(|_| invalid_component(component, "packed byte count exceeds address space"))?;
136        let mut bytes = vec![0_u8; expected_bytes];
137        repack_gptq_to_marlin_bytes_into(&gptq_words, k, n, &mut bytes);
138        WeightComponentPayload::from_ordered_sources(
139            component,
140            component.external_names.clone(),
141            vec![
142                packed.source_file().to_owned(),
143                shape.source_file().to_owned(),
144            ],
145            component.dimensions.clone(),
146            ElementType::U8,
147            bytes,
148        )
149    }
150
151    fn scales<'source>(
152        &'source self,
153        component: &WeightComponentSpec,
154    ) -> std::result::Result<WeightComponentPayload<'source>, VNextError> {
155        let [external_name] = component.external_names.as_slice() else {
156            return Err(invalid_component(
157                component,
158                "scales require exactly one weight_scale source",
159            ));
160        };
161        if !external_name.ends_with(".weight_scale") {
162            return Err(invalid_component(
163                component,
164                "scale source must end with .weight_scale",
165            ));
166        }
167        let stem = external_name
168            .strip_suffix(".weight_scale")
169            .unwrap_or_default();
170        if stem.is_empty() {
171            return Err(invalid_component(
172                component,
173                "scale source must have a non-empty compressed-tensors stem",
174            ));
175        }
176        let tensor = self.tensor(component, external_name)?;
177        // Scale components are dense and therefore do not carry the packed
178        // component's QuantizationSpec. Absence of the same-stem zero-point
179        // sidecar is the physical symmetric-bundle discriminator. Keep the
180        // established asymmetric transcode path unchanged, but require the
181        // exact BF16[N, K/32] header for a zero-point-free bundle.
182        if !self.archive.contains(&format!("{stem}.weight_zero_point")) {
183            let shape = self.tensor(component, &format!("{stem}.weight_shape"))?;
184            let (shape_n, shape_k) = validate_shape_metadata(component, &shape)?;
185            if tensor.dtype() != Dtype::BF16
186                || !shape_k.is_multiple_of(32)
187                || tensor.shape() != [shape_n as u64, (shape_k / 32) as u64]
188            {
189                return Err(invalid_component(
190                    component,
191                    format!(
192                        "symmetric weight_scale must be BF16[{shape_n}, {}], got {:?} {:?}",
193                        shape_k / 32,
194                        tensor.dtype(),
195                        tensor.shape()
196                    ),
197                ));
198            }
199        }
200        let [n, groups] = tensor.shape() else {
201            return Err(invalid_component(
202                component,
203                format!(
204                    "weight_scale must have shape [N, K/G], got {:?}",
205                    tensor.shape()
206                ),
207            ));
208        };
209        let n = usize::try_from(*n)
210            .map_err(|_| invalid_component(component, "scale N exceeds address space"))?;
211        let groups = usize::try_from(*groups)
212            .map_err(|_| invalid_component(component, "scale group count exceeds address space"))?;
213        if !has_unit_prefix_and_tail(&component.dimensions, &[n as u64, groups as u64]) {
214            return Err(invalid_component(
215                component,
216                "typed scale dimensions differ from the checkpoint header",
217            ));
218        }
219        let source_type = tensor.element_type().ok_or_else(|| {
220            invalid_component(
221                component,
222                format!("weight_scale has unsupported dtype {:?}", tensor.dtype()),
223            )
224        })?;
225        let f16_bytes = transcode_dense_bytes(
226            tensor.bytes(),
227            source_type,
228            ElementType::F16,
229            external_name,
230            None,
231        )?;
232        let source = decode_f16(&f16_bytes, component)?;
233        let mut group_major = vec![f16::ZERO; source.len()];
234        for output in 0..n {
235            for group in 0..groups {
236                group_major[group * n + output] = source[output * groups + group];
237            }
238        }
239        let repacked = repack_scales_to_marlin(&group_major, groups, n, 1);
240        WeightComponentPayload::from_ordered_sources(
241            component,
242            component.external_names.clone(),
243            vec![tensor.source_file().to_owned()],
244            component.dimensions.clone(),
245            ElementType::F16,
246            encode_f16(repacked),
247        )
248    }
249
250    fn zero_points<'source>(
251        &'source self,
252        component: &WeightComponentSpec,
253    ) -> std::result::Result<WeightComponentPayload<'source>, VNextError> {
254        let [external_name] = component.external_names.as_slice() else {
255            return Err(invalid_component(
256                component,
257                "zero points require exactly one weight_zero_point source",
258            ));
259        };
260        if !external_name.ends_with(".weight_zero_point") {
261            return Err(invalid_component(
262                component,
263                "zero-point source must end with .weight_zero_point",
264            ));
265        }
266        let tensor = self.tensor(component, external_name)?;
267        let [packed_n, groups] = tensor.shape() else {
268            return Err(invalid_component(
269                component,
270                format!(
271                    "weight_zero_point must have shape [N/8, K/G], got {:?}",
272                    tensor.shape()
273                ),
274            ));
275        };
276        if tensor.dtype() != Dtype::I32 {
277            return Err(invalid_component(
278                component,
279                format!("weight_zero_point must be I32, got {:?}", tensor.dtype()),
280            ));
281        }
282        let packed_n = usize::try_from(*packed_n)
283            .map_err(|_| invalid_component(component, "zero-point N exceeds address space"))?;
284        let groups = usize::try_from(*groups).map_err(|_| {
285            invalid_component(component, "zero-point group count exceeds address space")
286        })?;
287        if !has_unit_prefix_and_tail(&component.dimensions, &[groups as u64, packed_n as u64]) {
288            return Err(invalid_component(
289                component,
290                "typed zero-point dimensions must be Marlin [K/G, N/8]",
291            ));
292        }
293        let source = decode_i32(tensor.bytes(), component, "weight_zero_point")?;
294        let repacked =
295            repack_compressed_tensors_zero_points_to_marlin(&source, groups, packed_n * 8);
296        let bytes = repacked
297            .into_iter()
298            .flat_map(i32::to_le_bytes)
299            .collect::<Vec<_>>();
300        WeightComponentPayload::from_ordered_sources(
301            component,
302            component.external_names.clone(),
303            vec![tensor.source_file().to_owned()],
304            component.dimensions.clone(),
305            ElementType::I32,
306            bytes,
307        )
308    }
309
310    fn tensor<'source>(
311        &'source self,
312        component: &WeightComponentSpec,
313        external_name: &str,
314    ) -> std::result::Result<SafetensorsTensor<'source>, VNextError> {
315        self.archive
316            .tensor(external_name)
317            .map_err(|error| invalid_component(component, error.to_string()))
318    }
319}
320
321impl WeightComponentSource for CompressedTensorsMarlinSafetensorsSource {
322    fn component<'source>(
323        &'source self,
324        component: &WeightComponentSpec,
325    ) -> std::result::Result<WeightComponentPayload<'source>, VNextError> {
326        match (&component.role, &component.encoding) {
327            (WeightComponentRole::PackedValues, WeightEncoding::Quantized(quantization)) => {
328                self.packed_values(component, quantization)
329            }
330            (
331                WeightComponentRole::Scales,
332                WeightEncoding::Dense {
333                    element_type: ElementType::F16,
334                },
335            ) => self.scales(component),
336            (
337                WeightComponentRole::ZeroPoints,
338                WeightEncoding::Dense {
339                    element_type: ElementType::I32,
340                },
341            ) => self.zero_points(component),
342            (_, WeightEncoding::Dense { .. } | WeightEncoding::DenseAffine { .. }) => {
343                self.archive.component(component)
344            }
345            _ => Err(invalid_component(
346                component,
347                "compressed-tensors Marlin adapter received an unsupported component encoding",
348            )),
349        }
350    }
351}
352
353fn validate_quantization(
354    component: &WeightComponentSpec,
355    quantization: &QuantizationSpec,
356) -> std::result::Result<ValidatedQuantization, VNextError> {
357    quantization.validate()?;
358    let Some(group_size) = quantization.grouping.fixed_size() else {
359        return Err(invalid_component(
360            component,
361            "compressed-tensors requires fixed-size groups",
362        ));
363    };
364    let mode = match quantization.format_id.as_str() {
365        COMPRESSED_TENSORS_MARLIN_INT4_FORMAT_ID
366            if quantization.bits_per_weight == 4
367                && quantization.scale_type == ElementType::F16
368                && quantization.zero_point_type == Some(ElementType::I32) =>
369        {
370            CompressedTensorsInt4Mode::Asymmetric
371        }
372        COMPRESSED_TENSORS_MARLIN_INT4_SYMMETRIC_FORMAT_ID
373            if quantization.bits_per_weight == 4
374                && group_size == 32
375                && quantization.packing == ferrum_interfaces::vnext::QuantizationPacking::Tiled
376                && quantization.scale_type == ElementType::F16
377                && quantization.zero_point_type.is_none() =>
378        {
379            CompressedTensorsInt4Mode::Symmetric
380        }
381        COMPRESSED_TENSORS_MARLIN_INT4_FORMAT_ID => {
382            return Err(invalid_component(
383                component,
384                "asymmetric compressed-tensors requires INT4 Marlin packing with F16 scales and packed I32 zero points",
385            ));
386        }
387        COMPRESSED_TENSORS_MARLIN_INT4_SYMMETRIC_FORMAT_ID => {
388            return Err(invalid_component(
389                component,
390                "symmetric compressed-tensors requires group32 INT4 tiled Marlin packing with F16 scales and no zero points",
391            ));
392        }
393        _ => {
394            return Err(invalid_component(
395                component,
396                "compressed-tensors quantization format id is unsupported",
397            ));
398        }
399    };
400    Ok(ValidatedQuantization {
401        group_size: usize::try_from(group_size)
402            .map_err(|_| invalid_component(component, "group size exceeds address space"))?,
403        mode,
404    })
405}
406
407fn has_unit_prefix_and_tail(dimensions: &[u64], tail: &[u64; 2]) -> bool {
408    dimensions.len() >= 2
409        && dimensions[dimensions.len() - 2..] == *tail
410        && dimensions[..dimensions.len() - 2]
411            .iter()
412            .all(|extent| *extent == 1)
413}
414
415fn validate_shape_metadata(
416    component: &WeightComponentSpec,
417    tensor: &SafetensorsTensor<'_>,
418) -> std::result::Result<(usize, usize), VNextError> {
419    if tensor.dtype() != Dtype::I64 || tensor.shape() != [2] || tensor.bytes().len() != 16 {
420        return Err(invalid_component(component, "weight_shape must be I64[2]"));
421    }
422    let values = tensor
423        .bytes()
424        .chunks_exact(8)
425        .map(|bytes| {
426            i64::from_le_bytes([
427                bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
428            ])
429        })
430        .collect::<Vec<_>>();
431    let n = usize::try_from(values[0])
432        .map_err(|_| invalid_component(component, "weight_shape N must be positive"))?;
433    let k = usize::try_from(values[1])
434        .map_err(|_| invalid_component(component, "weight_shape K must be positive"))?;
435    if n == 0 || k == 0 || !k.is_multiple_of(8) {
436        return Err(invalid_component(
437            component,
438            "weight_shape must contain positive [N, K] with K divisible by 8",
439        ));
440    }
441    Ok((n, k))
442}
443
444fn decode_i32(
445    bytes: &[u8],
446    component: &WeightComponentSpec,
447    label: &str,
448) -> std::result::Result<Vec<i32>, VNextError> {
449    if !bytes.len().is_multiple_of(4) {
450        return Err(invalid_component(
451            component,
452            format!("{label} byte length is not I32 aligned"),
453        ));
454    }
455    Ok(bytes
456        .chunks_exact(4)
457        .map(|word| i32::from_le_bytes([word[0], word[1], word[2], word[3]]))
458        .collect())
459}
460
461fn decode_f16(
462    bytes: &[u8],
463    component: &WeightComponentSpec,
464) -> std::result::Result<Vec<f16>, VNextError> {
465    if !bytes.len().is_multiple_of(2) {
466        return Err(invalid_component(
467            component,
468            "scale byte length is not F16 aligned",
469        ));
470    }
471    Ok(bytes
472        .chunks_exact(2)
473        .map(|value| f16::from_le_bytes([value[0], value[1]]))
474        .collect())
475}
476
477fn encode_f16(values: Vec<f16>) -> Cow<'static, [u8]> {
478    Cow::Owned(
479        values
480            .into_iter()
481            .flat_map(f16::to_le_bytes)
482            .collect::<Vec<_>>(),
483    )
484}
485
486fn invalid_component(component: &WeightComponentSpec, reason: impl AsRef<str>) -> VNextError {
487    VNextError::InvalidExecutionPlan {
488        reason: format!(
489            "compressed-tensors component `{}` is invalid: {}",
490            component.id,
491            reason.as_ref()
492        ),
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use std::collections::BTreeMap;
499
500    use ferrum_interfaces::vnext::{
501        QuantizationFormatId, QuantizationGrouping, QuantizationPacking, WeightId,
502    };
503    use half::bf16;
504    use safetensors::tensor::{serialize_to_file, TensorView};
505    use tempfile::tempdir;
506
507    use super::*;
508
509    const STEM: &str = "model.layers.0.self_attn.q_proj";
510
511    struct Fixture {
512        directory: tempfile::TempDir,
513        packed: Vec<i32>,
514        scales: Vec<bf16>,
515        zero_points: Vec<i32>,
516        n: usize,
517        k: usize,
518        groups: usize,
519    }
520
521    fn write_fixture() -> Fixture {
522        write_fixture_with_options(true, Dtype::BF16)
523    }
524
525    fn write_symmetric_fixture() -> Fixture {
526        write_fixture_with_options(false, Dtype::BF16)
527    }
528
529    fn write_fixture_with_options(include_zero_points: bool, scale_dtype: Dtype) -> Fixture {
530        assert!(matches!(scale_dtype, Dtype::BF16 | Dtype::F16));
531        let directory = tempdir().unwrap();
532        let n = 64_usize;
533        let k = 128_usize;
534        let groups = k / 32;
535        let packed = (0..n)
536            .flat_map(|output| {
537                (0..k / 8).map(move |packed_input| {
538                    (0..8).fold(0_u32, |word, lane| {
539                        let input = packed_input * 8 + lane;
540                        let value = ((output * 3 + input * 5 + 1) % 16) as u32;
541                        word | (value << (lane * 4))
542                    }) as i32
543                })
544            })
545            .collect::<Vec<_>>();
546        let scales = (0..n)
547            .flat_map(|output| {
548                (0..groups).map(move |group| {
549                    bf16::from_f32(0.015625 * (1 + (output + group * 7) % 11) as f32)
550                })
551            })
552            .collect::<Vec<_>>();
553        let zero_points = (0..n / 8)
554            .flat_map(|packed_output| {
555                (0..groups).map(move |group| {
556                    (0..8).fold(0_u32, |word, lane| {
557                        let output = packed_output * 8 + lane;
558                        let value = ((output + group * 3 + 2) % 15) as u32;
559                        word | (value << (lane * 4))
560                    }) as i32
561                })
562            })
563            .collect::<Vec<_>>();
564        let packed_bytes = packed
565            .iter()
566            .flat_map(|value| value.to_le_bytes())
567            .collect::<Vec<_>>();
568        let scale_bytes = scales
569            .iter()
570            .flat_map(|value| {
571                if scale_dtype == Dtype::BF16 {
572                    value.to_bits().to_le_bytes()
573                } else {
574                    f16::from_f32(value.to_f32()).to_bits().to_le_bytes()
575                }
576            })
577            .collect::<Vec<_>>();
578        let zero_point_bytes = zero_points
579            .iter()
580            .flat_map(|value| value.to_le_bytes())
581            .collect::<Vec<_>>();
582        let shape_bytes = [n as i64, k as i64]
583            .into_iter()
584            .flat_map(i64::to_le_bytes)
585            .collect::<Vec<_>>();
586        let mut views = BTreeMap::from([
587            (
588                format!("{STEM}.weight_packed"),
589                TensorView::new(Dtype::I32, vec![n, k / 8], &packed_bytes).unwrap(),
590            ),
591            (
592                format!("{STEM}.weight_scale"),
593                TensorView::new(scale_dtype, vec![n, groups], &scale_bytes).unwrap(),
594            ),
595            (
596                format!("{STEM}.weight_shape"),
597                TensorView::new(Dtype::I64, vec![2], &shape_bytes).unwrap(),
598            ),
599        ]);
600        if include_zero_points {
601            views.insert(
602                format!("{STEM}.weight_zero_point"),
603                TensorView::new(Dtype::I32, vec![n / 8, groups], &zero_point_bytes).unwrap(),
604            );
605        }
606        serialize_to_file(views, &None, &directory.path().join("model.safetensors")).unwrap();
607        Fixture {
608            directory,
609            packed,
610            scales,
611            zero_points,
612            n,
613            k,
614            groups,
615        }
616    }
617
618    fn quantization() -> QuantizationSpec {
619        QuantizationSpec {
620            format_id: QuantizationFormatId::new(COMPRESSED_TENSORS_MARLIN_INT4_FORMAT_ID).unwrap(),
621            bits_per_weight: 4,
622            grouping: QuantizationGrouping::fixed(32),
623            packing: QuantizationPacking::Tiled,
624            scale_type: ElementType::F16,
625            zero_point_type: Some(ElementType::I32),
626        }
627    }
628
629    fn symmetric_quantization() -> QuantizationSpec {
630        QuantizationSpec {
631            format_id: QuantizationFormatId::new(
632                COMPRESSED_TENSORS_MARLIN_INT4_SYMMETRIC_FORMAT_ID,
633            )
634            .unwrap(),
635            bits_per_weight: 4,
636            grouping: QuantizationGrouping::fixed(32),
637            packing: QuantizationPacking::Tiled,
638            scale_type: ElementType::F16,
639            zero_point_type: None,
640        }
641    }
642
643    #[test]
644    fn repacks_all_compressed_tensors_sidecars_at_the_source_boundary() {
645        let fixture = write_fixture();
646        let source =
647            CompressedTensorsMarlinSafetensorsSource::open(fixture.directory.path()).unwrap();
648        let packed_component = WeightComponentSpec {
649            id: WeightId::new("component.q.packed").unwrap(),
650            role: WeightComponentRole::PackedValues,
651            external_names: vec![
652                format!("{STEM}.weight_packed"),
653                format!("{STEM}.weight_shape"),
654            ],
655            dimensions: vec![1, fixture.n as u64, (fixture.k / 2) as u64],
656            encoding: WeightEncoding::Quantized(quantization()),
657            required: true,
658        };
659        let packed_payload = source.component(&packed_component).unwrap();
660        let mut gptq_words = vec![0_i32; fixture.packed.len()];
661        for output in 0..fixture.n {
662            for packed_input in 0..fixture.k / 8 {
663                gptq_words[packed_input * fixture.n + output] =
664                    fixture.packed[output * (fixture.k / 8) + packed_input];
665            }
666        }
667        let mut expected_packed = vec![0_u8; fixture.n * fixture.k / 2];
668        repack_gptq_to_marlin_bytes_into(&gptq_words, fixture.k, fixture.n, &mut expected_packed);
669        assert_eq!(packed_payload.bytes(), expected_packed);
670        assert_eq!(packed_payload.dimensions(), packed_component.dimensions);
671
672        let scales_component = WeightComponentSpec {
673            id: WeightId::new("component.q.scales").unwrap(),
674            role: WeightComponentRole::Scales,
675            external_names: vec![format!("{STEM}.weight_scale")],
676            dimensions: vec![1, fixture.n as u64, fixture.groups as u64],
677            encoding: WeightEncoding::Dense {
678                element_type: ElementType::F16,
679            },
680            required: true,
681        };
682        let scales_payload = source.component(&scales_component).unwrap();
683        let mut group_major = vec![f16::ZERO; fixture.scales.len()];
684        for output in 0..fixture.n {
685            for group in 0..fixture.groups {
686                group_major[group * fixture.n + output] =
687                    f16::from_f32(fixture.scales[output * fixture.groups + group].to_f32());
688            }
689        }
690        let expected_scales = encode_f16(repack_scales_to_marlin(
691            &group_major,
692            fixture.groups,
693            fixture.n,
694            1,
695        ));
696        assert_eq!(scales_payload.bytes(), expected_scales.as_ref());
697
698        let zero_points_component = WeightComponentSpec {
699            id: WeightId::new("component.q.zero_points").unwrap(),
700            role: WeightComponentRole::ZeroPoints,
701            external_names: vec![format!("{STEM}.weight_zero_point")],
702            dimensions: vec![1, fixture.groups as u64, (fixture.n / 8) as u64],
703            encoding: WeightEncoding::Dense {
704                element_type: ElementType::I32,
705            },
706            required: true,
707        };
708        let zero_points_payload = source.component(&zero_points_component).unwrap();
709        let expected_zero_points = repack_compressed_tensors_zero_points_to_marlin(
710            &fixture.zero_points,
711            fixture.groups,
712            fixture.n,
713        )
714        .into_iter()
715        .flat_map(i32::to_le_bytes)
716        .collect::<Vec<_>>();
717        assert_eq!(zero_points_payload.bytes(), expected_zero_points);
718    }
719
720    #[test]
721    fn repacks_symmetric_group32_codes_as_marlin_u4b8_without_a_zero_point() {
722        let fixture = write_symmetric_fixture();
723        let source =
724            CompressedTensorsMarlinSafetensorsSource::open(fixture.directory.path()).unwrap();
725        assert!(!source
726            .archive()
727            .contains(&format!("{STEM}.weight_zero_point")));
728
729        // pack_to_int32 stores signed q in the biased code domain q + 8 and
730        // puts input lane zero in the low nibble. Check the fixture itself so
731        // the expected Marlin bytes below catch any accidental second bias.
732        let expected_first_word = (0..8).fold(0_u32, |word, lane| {
733            let signed = ((lane * 5 + 1) % 16) as i32 - 8;
734            word | ((signed + 8) as u32) << (lane * 4)
735        });
736        assert_eq!(fixture.packed[0] as u32, expected_first_word);
737
738        let packed_component = WeightComponentSpec {
739            id: WeightId::new("component.q.symmetric.packed").unwrap(),
740            role: WeightComponentRole::PackedValues,
741            external_names: vec![
742                format!("{STEM}.weight_packed"),
743                format!("{STEM}.weight_shape"),
744            ],
745            dimensions: vec![fixture.n as u64, (fixture.k / 2) as u64],
746            encoding: WeightEncoding::Quantized(symmetric_quantization()),
747            required: true,
748        };
749        let packed_payload = source.component(&packed_component).unwrap();
750        let mut direct_u4b8_words = vec![0_i32; fixture.packed.len()];
751        for output in 0..fixture.n {
752            for packed_input in 0..fixture.k / 8 {
753                direct_u4b8_words[packed_input * fixture.n + output] =
754                    fixture.packed[output * (fixture.k / 8) + packed_input];
755            }
756        }
757        let mut expected_packed = vec![0_u8; fixture.n * fixture.k / 2];
758        repack_gptq_to_marlin_bytes_into(
759            &direct_u4b8_words,
760            fixture.k,
761            fixture.n,
762            &mut expected_packed,
763        );
764        assert_eq!(packed_payload.bytes(), expected_packed);
765        assert_eq!(packed_payload.element_type(), ElementType::U8);
766
767        let scales_component = WeightComponentSpec {
768            id: WeightId::new("component.q.symmetric.scales").unwrap(),
769            role: WeightComponentRole::Scales,
770            external_names: vec![format!("{STEM}.weight_scale")],
771            dimensions: vec![fixture.n as u64, fixture.groups as u64],
772            encoding: WeightEncoding::Dense {
773                element_type: ElementType::F16,
774            },
775            required: true,
776        };
777        let scales_payload = source.component(&scales_component).unwrap();
778        let mut group_major = vec![f16::ZERO; fixture.scales.len()];
779        for output in 0..fixture.n {
780            for group in 0..fixture.groups {
781                group_major[group * fixture.n + output] =
782                    f16::from_f32(fixture.scales[output * fixture.groups + group].to_f32());
783            }
784        }
785        let expected_scales = encode_f16(repack_scales_to_marlin(
786            &group_major,
787            fixture.groups,
788            fixture.n,
789            1,
790        ));
791        assert_eq!(scales_payload.bytes(), expected_scales.as_ref());
792        assert_eq!(scales_payload.element_type(), ElementType::F16);
793
794        let zero_points_component = WeightComponentSpec {
795            id: WeightId::new("component.q.symmetric.zero_points").unwrap(),
796            role: WeightComponentRole::ZeroPoints,
797            external_names: vec![format!("{STEM}.weight_zero_point")],
798            dimensions: vec![fixture.groups as u64, (fixture.n / 8) as u64],
799            encoding: WeightEncoding::Dense {
800                element_type: ElementType::I32,
801            },
802            required: true,
803        };
804        let error = source
805            .component(&zero_points_component)
806            .err()
807            .expect("symmetric source must not synthesize zero points");
808        assert!(
809            error.to_string().contains("absent from safetensors"),
810            "{error}"
811        );
812    }
813
814    #[test]
815    fn rejects_drifted_symmetric_metadata_and_zero_point_components() {
816        let fixture = write_symmetric_fixture();
817        let source =
818            CompressedTensorsMarlinSafetensorsSource::open(fixture.directory.path()).unwrap();
819        let component = |quantization| WeightComponentSpec {
820            id: WeightId::new("component.q.symmetric.packed").unwrap(),
821            role: WeightComponentRole::PackedValues,
822            external_names: vec![
823                format!("{STEM}.weight_packed"),
824                format!("{STEM}.weight_shape"),
825            ],
826            dimensions: vec![fixture.n as u64, (fixture.k / 2) as u64],
827            encoding: WeightEncoding::Quantized(quantization),
828            required: true,
829        };
830
831        let mut wrong_group = symmetric_quantization();
832        wrong_group.grouping = QuantizationGrouping::fixed(64);
833        let error = source
834            .component(&component(wrong_group))
835            .err()
836            .expect("wrong symmetric group metadata must be rejected");
837        assert!(error.to_string().contains("requires group32"), "{error}");
838
839        let mut fake_zero_point = symmetric_quantization();
840        fake_zero_point.zero_point_type = Some(ElementType::I32);
841        let error = source
842            .component(&component(fake_zero_point))
843            .err()
844            .expect("symmetric zero-point metadata must be rejected");
845        assert!(error.to_string().contains("no zero points"), "{error}");
846
847        let f16_symmetric_fixture = write_fixture_with_options(false, Dtype::F16);
848        let f16_symmetric_source =
849            CompressedTensorsMarlinSafetensorsSource::open(f16_symmetric_fixture.directory.path())
850                .unwrap();
851        let scale_component = WeightComponentSpec {
852            id: WeightId::new("component.q.symmetric.scales").unwrap(),
853            role: WeightComponentRole::Scales,
854            external_names: vec![format!("{STEM}.weight_scale")],
855            dimensions: vec![
856                f16_symmetric_fixture.n as u64,
857                f16_symmetric_fixture.groups as u64,
858            ],
859            encoding: WeightEncoding::Dense {
860                element_type: ElementType::F16,
861            },
862            required: true,
863        };
864        let error = f16_symmetric_source
865            .component(&scale_component)
866            .err()
867            .expect("symmetric F16 source scale must be rejected");
868        assert!(
869            error
870                .to_string()
871                .contains("symmetric weight_scale must be BF16"),
872            "{error}"
873        );
874
875        let f16_asymmetric_fixture = write_fixture_with_options(true, Dtype::F16);
876        let f16_asymmetric_source =
877            CompressedTensorsMarlinSafetensorsSource::open(f16_asymmetric_fixture.directory.path())
878                .unwrap();
879        let scale_component = WeightComponentSpec {
880            dimensions: vec![
881                f16_asymmetric_fixture.n as u64,
882                f16_asymmetric_fixture.groups as u64,
883            ],
884            ..scale_component
885        };
886        assert!(f16_asymmetric_source.component(&scale_component).is_ok());
887
888        let fixture_with_zero_point = write_fixture();
889        let source_with_zero_point = CompressedTensorsMarlinSafetensorsSource::open(
890            fixture_with_zero_point.directory.path(),
891        )
892        .unwrap();
893        let packed_component = WeightComponentSpec {
894            id: WeightId::new("component.q.symmetric.packed").unwrap(),
895            role: WeightComponentRole::PackedValues,
896            external_names: vec![
897                format!("{STEM}.weight_packed"),
898                format!("{STEM}.weight_shape"),
899            ],
900            dimensions: vec![
901                fixture_with_zero_point.n as u64,
902                (fixture_with_zero_point.k / 2) as u64,
903            ],
904            encoding: WeightEncoding::Quantized(symmetric_quantization()),
905            required: true,
906        };
907        let error = source_with_zero_point
908            .component(&packed_component)
909            .err()
910            .expect("symmetric physical zero point must be rejected");
911        assert!(
912            error
913                .to_string()
914                .contains("must not provide weight_zero_point"),
915            "{error}"
916        );
917    }
918
919    #[test]
920    fn rejects_shape_metadata_that_disagrees_with_the_packed_header() {
921        let fixture = write_fixture();
922        let source =
923            CompressedTensorsMarlinSafetensorsSource::open(fixture.directory.path()).unwrap();
924        let component = WeightComponentSpec {
925            id: WeightId::new("component.q.packed").unwrap(),
926            role: WeightComponentRole::PackedValues,
927            external_names: vec![
928                format!("{STEM}.weight_packed"),
929                format!("{STEM}.weight_shape"),
930            ],
931            dimensions: vec![fixture.n as u64, (fixture.k / 2 + 1) as u64],
932            encoding: WeightEncoding::Quantized(quantization()),
933            required: true,
934        };
935        let error = match source.component(&component) {
936            Ok(_) => panic!("mismatched typed packed dimensions must be rejected"),
937            Err(error) => error,
938        };
939        assert!(
940            error.to_string().contains("packed component shape"),
941            "{error}"
942        );
943    }
944}