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