1use 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
41pub 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 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 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 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}