Skip to main content

fuel_tensor/
awq.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! HuggingFace `*-AWQ` (GEMM-version) weight loading (ROADMAP row 31).
3//!
4//! Companion to [`fuel_graph::NodeHandle::awq_matmul`] (registered as
5//! [`fuel_graph::registry::FusedOps::AWQ_MATMUL`]). The fused op takes the
6//! quantized operands in baracuda's W4A16 kernel layout; a HuggingFace
7//! checkpoint stores the transpose with AutoAWQ's interleaved nibble order.
8//! [`load_awq_layer`] reads the three tensors of one `WQLinear_GEMM` layer and
9//! converts them with [`fuel_quantized::awq_repack::repack_hf_awq_for_kernel`].
10//!
11//! ## On-disk layout (per linear layer, `prefix` = e.g.
12//! `model.layers.0.self_attn.q_proj`)
13//!
14//! | key               | dtype       | shape                  |
15//! |-------------------|-------------|------------------------|
16//! | `{prefix}.qweight`| I32 (or U32)| `[IC, OC/8]`           |
17//! | `{prefix}.qzeros` | I32 (or U32)| `[IC/group_size, OC/8]`|
18//! | `{prefix}.scales` | F16         | `[IC/group_size, OC]`  |
19//!
20//! `group_size` is `quantization_config.group_size` in the checkpoint's
21//! `config.json`; it is not in the safetensors file, so the caller passes it.
22//! Only 4-bit GEMM-version checkpoints are meaningful here (GEMV-version packs
23//! differently and is not read).
24
25use crate::Device;
26use crate::lazy::Tensor;
27use fuel_ir::{Result, Shape};
28use fuel_quantized::awq_repack::repack_hf_awq_for_kernel;
29use half::f16;
30use safetensors::tensor::TensorView;
31use std::sync::Arc;
32
33/// An AWQ-quantized linear weight, materialized as the three `Tensor` inputs
34/// [`fuel_graph::NodeHandle::awq_matmul`] expects (kernel layout), plus the
35/// geometry the matmul builder needs.
36pub struct AwqWeight {
37    /// `[OC, IC/8]` U32, sequential nibble order.
38    pub weight: Tensor,
39    /// `[OC, zeros_w]` U32.
40    pub zeros: Tensor,
41    /// `[OC, 8 * zeros_w]` F16.
42    pub scales: Tensor,
43    /// Output features.
44    pub oc: usize,
45    /// Input features.
46    pub ic: usize,
47    /// Quantization group size (64 or 128).
48    pub group_size: usize,
49}
50
51impl AwqWeight {
52    /// `activations @ dequant(weight)ᵀ` via the fused `AWQ_MATMUL` op.
53    /// `activations` must live on the same graph as the weight tensors (build
54    /// it from `self.weight.graph_tensor()`). Output `[..., M, OC]` in the
55    /// activations' dtype.
56    ///
57    /// `split_k_iters` is a CUDA launch-tuning parameter (8 is AutoAWQ's
58    /// default); the decompose path ignores it, so it is validated here rather
59    /// than left to a kernel launch: it must be positive. The kernel's own
60    /// constraints (`ic % (32 * split_k_iters) == 0`, `oc % 64 == 0`, sm_80+)
61    /// are baracuda's `can_implement`'s to enforce at launch.
62    pub fn matmul(&self, activations: &Tensor, split_k_iters: i32) -> Result<Tensor> {
63        if split_k_iters <= 0 {
64            return Err(fuel_ir::Error::Msg(format!(
65                "AwqWeight::matmul: split_k_iters={split_k_iters} must be positive"
66            ))
67            .bt());
68        }
69        Ok(Tensor::from_graph_tensor(
70            activations.graph_tensor().awq_matmul(
71                self.weight.graph_tensor(),
72                self.zeros.graph_tensor(),
73                self.scales.graph_tensor(),
74                self.group_size,
75                split_k_iters,
76            ),
77        ))
78    }
79}
80
81/// Build an [`AwqWeight`] from HF-layout buffers (`qweight [IC, OC/8]`,
82/// `qzeros [IC/g, OC/8]`, `scales [IC/g, OC]`), repacking to the kernel layout.
83pub fn awq_from_hf_buffers(
84    qweight: &[u32],
85    qzeros: &[u32],
86    scales: &[f16],
87    ic: usize,
88    oc: usize,
89    group_size: usize,
90    device: &Device,
91) -> Result<AwqWeight> {
92    let b = repack_hf_awq_for_kernel(qweight, qzeros, scales, ic, oc, group_size)
93        .map_err(|e| fuel_ir::Error::Msg(format!("awq_from_hf_buffers: {e}")).bt())?;
94    let anchor = Tensor::from_f32(vec![0.0_f32], Shape::from_dims(&[1]), device)?;
95    let g = anchor.graph_tensor();
96    let weight = g.const_u32_like(b.weight, Shape::from_dims(&[oc, ic / 8]))?;
97    let zeros = g.const_u32_like(b.zeros, Shape::from_dims(&[oc, b.zeros_w]))?;
98    let scales = g.const_f16_like(b.scales, Shape::from_dims(&[oc, 8 * b.zeros_w]))?;
99    Ok(AwqWeight {
100        weight: Tensor::from_graph_tensor(weight),
101        zeros: Tensor::from_graph_tensor(zeros),
102        scales: Tensor::from_graph_tensor(scales),
103        oc,
104        ic,
105        group_size,
106    })
107}
108
109fn read_u32_words(view: TensorView<'_>, key: &str, rows: usize, cols: usize) -> Result<Vec<u32>> {
110    if !matches!(
111        view.dtype(),
112        safetensors::Dtype::I32 | safetensors::Dtype::U32
113    ) {
114        return Err(fuel_ir::Error::Msg(format!(
115            "load_awq_layer: {key:?} dtype={:?}, expected I32 or U32",
116            view.dtype()
117        ))
118        .bt());
119    }
120    if view.shape() != [rows, cols] {
121        return Err(fuel_ir::Error::Msg(format!(
122            "load_awq_layer: {key:?} shape={:?}, expected [{rows}, {cols}]",
123            view.shape()
124        ))
125        .bt());
126    }
127    // I32 and U32 words are the same bits; the nibbles are read as unsigned.
128    Ok(view
129        .data()
130        .as_chunks::<4>()
131        .0
132        .iter()
133        .map(|c| u32::from_le_bytes(*c))
134        .collect())
135}
136
137fn read_f16_scales(view: TensorView<'_>, key: &str, groups: usize, oc: usize) -> Result<Vec<f16>> {
138    if view.dtype() != safetensors::Dtype::F16 {
139        return Err(fuel_ir::Error::Msg(format!(
140            "load_awq_layer: {key:?} dtype={:?}, expected F16",
141            view.dtype()
142        ))
143        .bt());
144    }
145    if view.shape() != [groups, oc] {
146        return Err(fuel_ir::Error::Msg(format!(
147            "load_awq_layer: {key:?} shape={:?}, expected [{groups}, {oc}]",
148            view.shape()
149        ))
150        .bt());
151    }
152    Ok(view
153        .data()
154        .as_chunks::<2>()
155        .0
156        .iter()
157        .map(|c| f16::from_bits(u16::from_le_bytes(*c)))
158        .collect())
159}
160
161/// Load one `WQLinear_GEMM` layer from a parsed safetensors view. `ic`/`oc` are
162/// the layer's logical input/output features (from the model config);
163/// `group_size` is `quantization_config.group_size`. Every tensor's dtype and
164/// shape is checked against them, so a mismatched geometry errors here rather
165/// than reading a plausible-looking wrong layer.
166pub fn load_awq_layer(
167    st: &safetensors::SafeTensors,
168    prefix: &str,
169    ic: usize,
170    oc: usize,
171    group_size: usize,
172    device: &Device,
173) -> Result<AwqWeight> {
174    let (qweight, qzeros, scales) = read_hf_layer(
175        |key| {
176            st.tensor(key).map_err(|e| {
177                fuel_ir::Error::Msg(format!(
178                    "load_awq_layer: tensor {key:?} not found in safetensors: {e}"
179                ))
180                .bt()
181            })
182        },
183        prefix,
184        ic,
185        oc,
186        group_size,
187    )?;
188    awq_from_hf_buffers(&qweight, &qzeros, &scales, ic, oc, group_size, device)
189}
190
191fn check_geometry(ic: usize, oc: usize, group_size: usize) -> Result<()> {
192    if group_size == 0 || !ic.is_multiple_of(group_size) || !oc.is_multiple_of(8) {
193        return Err(fuel_ir::Error::Msg(format!(
194            "load_awq_layer: ic={ic} must be a multiple of group_size={group_size} \
195             and oc={oc} a multiple of 8"
196        ))
197        .bt());
198    }
199    Ok(())
200}
201
202/// Fetch and validate the three tensors of one `WQLinear_GEMM` layer through
203/// `get`, which resolves a full tensor key to its view (a parsed
204/// `SafeTensors`, or a memory-mapped file). Returns the HF-layout buffers.
205fn read_hf_layer<'a>(
206    get: impl Fn(&str) -> Result<TensorView<'a>>,
207    prefix: &str,
208    ic: usize,
209    oc: usize,
210    group_size: usize,
211) -> Result<(Vec<u32>, Vec<u32>, Vec<f16>)> {
212    check_geometry(ic, oc, group_size)?;
213    let groups = ic / group_size;
214    let (kw, kz, ks) = (
215        format!("{prefix}.qweight"),
216        format!("{prefix}.qzeros"),
217        format!("{prefix}.scales"),
218    );
219    let qweight = read_u32_words(get(&kw)?, &kw, ic, oc / 8)?;
220    let qzeros = read_u32_words(get(&kz)?, &kz, groups, oc / 8)?;
221    let scales = read_f16_scales(get(&ks)?, &ks, groups, oc)?;
222    Ok((qweight, qzeros, scales))
223}
224
225/// An AWQ linear weight as host buffers in the kernel layout, bound to no
226/// graph: what a model's weight set stores (`WeightStorage::Awq`) so the same
227/// weights can be placed on any graph or device. The matmul builds its
228/// constants on the activations' graph.
229#[derive(Debug, Clone)]
230pub struct AwqHostWeight {
231    /// `[OC, IC/8]` U32, sequential nibble order.
232    pub weight: Arc<[u32]>,
233    /// `[OC, zeros_w]` U32.
234    pub zeros: Arc<[u32]>,
235    /// `[OC, 8 * zeros_w]` F16.
236    pub scales: Arc<[f16]>,
237    /// Words per `zeros` row.
238    pub zeros_w: usize,
239    /// Output features.
240    pub oc: usize,
241    /// Input features.
242    pub ic: usize,
243    /// Quantization group size (64 or 128).
244    pub group_size: usize,
245}
246
247impl AwqHostWeight {
248    /// Repack HF-layout buffers (`qweight [IC, OC/8]`, `qzeros [IC/g, OC/8]`,
249    /// `scales [IC/g, OC]`) into the kernel layout.
250    pub fn from_hf_buffers(
251        qweight: &[u32],
252        qzeros: &[u32],
253        scales: &[f16],
254        ic: usize,
255        oc: usize,
256        group_size: usize,
257    ) -> Result<Self> {
258        let b =
259            repack_hf_awq_for_kernel(qweight, qzeros, scales, ic, oc, group_size).map_err(|e| {
260                fuel_ir::Error::Msg(format!("AwqHostWeight::from_hf_buffers: {e}")).bt()
261            })?;
262        Ok(Self {
263            weight: Arc::from(b.weight),
264            zeros: Arc::from(b.zeros),
265            scales: Arc::from(b.scales),
266            zeros_w: b.zeros_w,
267            oc,
268            ic,
269            group_size,
270        })
271    }
272
273    /// Read one `WQLinear_GEMM` layer (`{prefix}.qweight/.qzeros/.scales`)
274    /// through `get`, validating every dtype and shape against the geometry.
275    pub fn load<'a>(
276        get: impl Fn(&str) -> Result<TensorView<'a>>,
277        prefix: &str,
278        ic: usize,
279        oc: usize,
280        group_size: usize,
281    ) -> Result<Self> {
282        let (qweight, qzeros, scales) = read_hf_layer(get, prefix, ic, oc, group_size)?;
283        Self::from_hf_buffers(&qweight, &qzeros, &scales, ic, oc, group_size)
284    }
285
286    /// `activations @ dequant(weight)ᵀ` via the fused `AWQ_MATMUL` op, with the
287    /// weight constants built on `activations`' graph. Output `[..., M, OC]` in
288    /// the activations' dtype. `split_k_iters` must be positive (see
289    /// [`AwqWeight::matmul`]).
290    pub fn matmul(&self, activations: &Tensor, split_k_iters: i32) -> Result<Tensor> {
291        if split_k_iters <= 0 {
292            return Err(fuel_ir::Error::Msg(format!(
293                "AwqHostWeight::matmul: split_k_iters={split_k_iters} must be positive"
294            ))
295            .bt());
296        }
297        let g = activations.graph_tensor();
298        let weight = g.const_u32_like(
299            Arc::clone(&self.weight),
300            Shape::from_dims(&[self.oc, self.ic / 8]),
301        )?;
302        let zeros = g.const_u32_like(
303            Arc::clone(&self.zeros),
304            Shape::from_dims(&[self.oc, self.zeros_w]),
305        )?;
306        let scales = g.const_f16_like(
307            Arc::clone(&self.scales),
308            Shape::from_dims(&[self.oc, 8 * self.zeros_w]),
309        )?;
310        Ok(Tensor::from_graph_tensor(g.awq_matmul(
311            &weight,
312            &zeros,
313            &scales,
314            self.group_size,
315            split_k_iters,
316        )))
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use fuel_quantized::awq_repack::HF_AWQ_PACK_ORDER;
324    use safetensors::tensor::TensorView;
325    use std::collections::HashMap;
326
327    const IC: usize = 128;
328    const OC: usize = 64;
329    const GROUP: usize = 64;
330
331    struct Logical {
332        q: Vec<u8>, // [IC, OC]
333        z: Vec<u8>, // [IC/G, OC]
334        s: Vec<f16>,
335    }
336
337    fn logical() -> Logical {
338        let groups = IC / GROUP;
339        let q = (0..IC * OC).map(|i| ((i * 7 + i / 5) % 16) as u8).collect();
340        let z = (0..groups * OC).map(|i| ((i * 3 + 5) % 16) as u8).collect();
341        let s = (0..groups * OC)
342            .map(|i| f16::from_f32(0.02 + 0.005 * (i % 11) as f32))
343            .collect();
344        Logical { q, z, s }
345    }
346
347    /// AutoAWQ's writer: word `col` of a row holds logical column
348    /// `col*8 + order[i]` at nibble `i`. Written independently of the loader.
349    fn pack_hf(codes: &[u8], rows: usize, oc: usize) -> Vec<u8> {
350        let mut out = Vec::with_capacity(rows * oc / 2);
351        for r in 0..rows {
352            for col in 0..oc / 8 {
353                let mut w = 0u32;
354                for i in 0..8 {
355                    w |= u32::from(codes[r * oc + col * 8 + HF_AWQ_PACK_ORDER[i]]) << (i * 4);
356                }
357                out.extend_from_slice(&w.to_le_bytes());
358            }
359        }
360        out
361    }
362
363    fn checkpoint(l: &Logical, qdtype: safetensors::Dtype, sdtype: safetensors::Dtype) -> Vec<u8> {
364        let groups = IC / GROUP;
365        let qw = pack_hf(&l.q, IC, OC);
366        let qz = pack_hf(&l.z, groups, OC);
367        let sc: Vec<u8> = l.s.iter().flat_map(|h| h.to_bits().to_le_bytes()).collect();
368        let mut t: HashMap<String, TensorView<'_>> = HashMap::new();
369        t.insert(
370            "l.qweight".into(),
371            TensorView::new(qdtype, vec![IC, OC / 8], &qw).unwrap(),
372        );
373        t.insert(
374            "l.qzeros".into(),
375            TensorView::new(qdtype, vec![groups, OC / 8], &qz).unwrap(),
376        );
377        t.insert(
378            "l.scales".into(),
379            TensorView::new(sdtype, vec![groups, OC], &sc).unwrap(),
380        );
381        safetensors::serialize(&t, None::<HashMap<String, String>>).unwrap()
382    }
383
384    /// `y[m][o] = Σ_i x[m][i] · (q[i][o] − z[i/G][o]) · s[i/G][o]` in f64.
385    fn reference(l: &Logical, x: &[f32], m: usize) -> Vec<f64> {
386        let mut y = vec![0.0f64; m * OC];
387        for r in 0..m {
388            for o in 0..OC {
389                for i in 0..IC {
390                    let g = i / GROUP;
391                    let w = (f64::from(l.q[i * OC + o]) - f64::from(l.z[g * OC + o]))
392                        * f64::from(l.s[g * OC + o].to_f32());
393                    y[r * OC + o] += f64::from(x[r * IC + i]) * w;
394                }
395            }
396        }
397        y
398    }
399
400    fn run(bytes: &[u8], x: &[f32], m: usize) -> Vec<f32> {
401        let st = safetensors::SafeTensors::deserialize(bytes).unwrap();
402        let w = load_awq_layer(&st, "l", IC, OC, GROUP, &Device::cpu()).unwrap();
403        assert_eq!((w.oc, w.ic, w.group_size), (OC, IC, GROUP));
404        let act = Tensor::from_graph_tensor(
405            w.weight
406                .graph_tensor()
407                .const_f32_like(x.to_vec(), Shape::from_dims(&[m, IC]))
408                .unwrap(),
409        );
410        // No CPU kernel for the fused op: realize its decompose (the correctness
411        // floor), as the lowering-only rule set rewrites it.
412        let y = w.matmul(&act, 8).unwrap();
413        let graph = y.graph_tensor().graph().clone();
414        let roots = fuel_graph::opt::RuleRegistry::lowering_only()
415            .optimize_to_fixpoint(&graph, &[y.graph_tensor().id()]);
416        crate::pipelined_bridge::realize_one_as::<f32>(&graph, roots[0], &Device::cpu())
417            .expect("realize lowered awq_matmul")
418    }
419
420    fn host_weight(bytes: &[u8]) -> AwqHostWeight {
421        let st = safetensors::SafeTensors::deserialize(bytes).unwrap();
422        AwqHostWeight::load(
423            |k| {
424                st.tensor(k)
425                    .map_err(|e| fuel_ir::Error::Msg(format!("{k}: {e}")).bt())
426            },
427            "l",
428            IC,
429            OC,
430            GROUP,
431        )
432        .unwrap()
433    }
434
435    fn realize_lowered(y: &Tensor) -> Vec<f32> {
436        let graph = y.graph_tensor().graph().clone();
437        let roots = fuel_graph::opt::RuleRegistry::lowering_only()
438            .optimize_to_fixpoint(&graph, &[y.graph_tensor().id()]);
439        crate::pipelined_bridge::realize_one_as::<f32>(&graph, roots[0], &Device::cpu())
440            .expect("realize lowered awq_matmul")
441    }
442
443    /// `WeightStorage::Awq` through `apply_linear` (the path every model
444    /// projection takes) equals the f64 reference, takes activations of any
445    /// leading rank, and refuses a geometry that is not the stored one.
446    #[test]
447    fn weight_storage_awq_apply_linear_matches_the_reference() {
448        use crate::lazy::WeightStorage;
449        let l = logical();
450        let w = WeightStorage::Awq(Arc::new(host_weight(&checkpoint(
451            &l,
452            safetensors::Dtype::I32,
453            safetensors::Dtype::F16,
454        ))));
455        assert_eq!(w.elem_count(), IC * OC);
456        let m = 3;
457        let x: Vec<f32> = (0..m * IC)
458            .map(|i| ((i * 5) % 17) as f32 * 0.1 - 0.8)
459            .collect();
460        let anchor = Tensor::from_f32(vec![0.0], Shape::from_dims(&[1]), &Device::cpu()).unwrap();
461        // [1, m, IC]: the rank-3 shape a transformer feeds a projection.
462        let act = anchor
463            .const_f32_like(x.clone(), Shape::from_dims(&[1, m, IC]))
464            .unwrap();
465        let got = realize_lowered(&w.apply_linear(&act, IC, OC).unwrap());
466        let want = reference(&l, &x, m);
467        assert_eq!(got.len(), m * OC);
468        for (g, w) in got.iter().zip(&want) {
469            assert!(
470                (f64::from(*g) - w).abs() <= 1e-3 * w.abs().max(1.0),
471                "{g} vs {w}"
472            );
473        }
474        // Refusals: a wrong requested geometry is a typed error, and so is a
475        // plain `const_like` (an AWQ weight is three operands).
476        assert!(w.apply_linear(&act, IC, OC + 8).is_err());
477        assert!(w.const_like(&act, Shape::from_dims(&[IC, OC])).is_err());
478    }
479
480    #[test]
481    fn host_weight_refuses_a_non_positive_split_k() {
482        let l = logical();
483        let w = host_weight(&checkpoint(
484            &l,
485            safetensors::Dtype::I32,
486            safetensors::Dtype::F16,
487        ));
488        let anchor = Tensor::from_f32(vec![0.0], Shape::from_dims(&[1]), &Device::cpu()).unwrap();
489        let act = anchor
490            .const_f32_like(vec![0.0; IC], Shape::from_dims(&[1, IC]))
491            .unwrap();
492        assert!(w.matmul(&act, 0).is_err());
493        assert!(w.matmul(&act, -1).is_err());
494        assert!(
495            w.matmul(&act, 1).is_ok(),
496            "control: a positive value is accepted"
497        );
498    }
499
500    #[test]
501    fn loaded_layer_matches_the_dequantise_then_matmul_reference() {
502        let l = logical();
503        let m = 3;
504        let x: Vec<f32> = (0..m * IC)
505            .map(|i| ((i * 5) % 17) as f32 * 0.1 - 0.8)
506            .collect();
507        let want = reference(&l, &x, m);
508        for qd in [safetensors::Dtype::I32, safetensors::Dtype::U32] {
509            let got = run(&checkpoint(&l, qd, safetensors::Dtype::F16), &x, m);
510            assert_eq!(got.len(), m * OC);
511            let max_err = got
512                .iter()
513                .zip(&want)
514                .map(|(g, w)| (f64::from(*g) - w).abs())
515                .fold(0.0, f64::max);
516            let scale = want.iter().fold(0.0f64, |a, w| a.max(w.abs()));
517            assert!(
518                max_err <= 1e-3 * scale.max(1.0),
519                "{qd:?}: max_err={max_err} scale={scale}"
520            );
521        }
522    }
523
524    /// Sabotage sibling: a loader that skipped the HF de-interleave (treated the
525    /// checkpoint as sequential) must disagree with the reference, so the test
526    /// above cannot pass for the wrong reason.
527    #[test]
528    fn a_sequentially_read_checkpoint_disagrees_with_the_reference() {
529        let l = logical();
530        let x: Vec<f32> = (0..IC).map(|i| ((i * 5) % 17) as f32 * 0.1 - 0.8).collect();
531        let want = reference(&l, &x, 1);
532        // Pack the SAME logical codes sequentially (order = identity) and load
533        // them through the HF-order loader.
534        let groups = IC / GROUP;
535        let seq = |codes: &[u8], rows: usize| -> Vec<u8> {
536            let mut out = Vec::new();
537            for r in 0..rows {
538                for col in 0..OC / 8 {
539                    let mut w = 0u32;
540                    for i in 0..8 {
541                        w |= u32::from(codes[r * OC + col * 8 + i]) << (i * 4);
542                    }
543                    out.extend_from_slice(&w.to_le_bytes());
544                }
545            }
546            out
547        };
548        let (qw, qz) = (seq(&l.q, IC), seq(&l.z, groups));
549        let sc: Vec<u8> = l.s.iter().flat_map(|h| h.to_bits().to_le_bytes()).collect();
550        let mut t: HashMap<String, TensorView<'_>> = HashMap::new();
551        t.insert(
552            "l.qweight".into(),
553            TensorView::new(safetensors::Dtype::I32, vec![IC, OC / 8], &qw).unwrap(),
554        );
555        t.insert(
556            "l.qzeros".into(),
557            TensorView::new(safetensors::Dtype::I32, vec![groups, OC / 8], &qz).unwrap(),
558        );
559        t.insert(
560            "l.scales".into(),
561            TensorView::new(safetensors::Dtype::F16, vec![groups, OC], &sc).unwrap(),
562        );
563        let bytes = safetensors::serialize(&t, None::<HashMap<String, String>>).unwrap();
564        let got = run(&bytes, &x, 1);
565        let max_err = got
566            .iter()
567            .zip(&want)
568            .map(|(g, w)| (f64::from(*g) - w).abs())
569            .fold(0.0, f64::max);
570        assert!(max_err > 1e-2, "sequential read matched: max_err={max_err}");
571    }
572
573    #[test]
574    fn wrong_dtype_shape_and_missing_tensor_are_refused() {
575        let l = logical();
576        let ok = checkpoint(&l, safetensors::Dtype::I32, safetensors::Dtype::F16);
577        let st = safetensors::SafeTensors::deserialize(&ok).unwrap();
578        // Geometry that disagrees with the file.
579        assert!(load_awq_layer(&st, "l", IC, OC * 2, GROUP, &Device::cpu()).is_err());
580        assert!(load_awq_layer(&st, "l", IC, OC, 128, &Device::cpu()).is_err());
581        // Missing prefix.
582        assert!(load_awq_layer(&st, "nope", IC, OC, GROUP, &Device::cpu()).is_err());
583        // Bad group size.
584        assert!(load_awq_layer(&st, "l", IC, OC, 0, &Device::cpu()).is_err());
585        // Wrong scales dtype.
586        let bad = checkpoint(&l, safetensors::Dtype::I32, safetensors::Dtype::BF16);
587        let st = safetensors::SafeTensors::deserialize(&bad).unwrap();
588        let e = load_awq_layer(&st, "l", IC, OC, GROUP, &Device::cpu()).err();
589        assert!(e.is_some_and(|e| format!("{e}").contains("expected F16")));
590    }
591    #[test]
592    fn non_positive_split_k_iters_is_a_typed_error() {
593        let l = logical();
594        let bytes = checkpoint(&l, safetensors::Dtype::I32, safetensors::Dtype::F16);
595        let st = safetensors::SafeTensors::deserialize(&bytes).unwrap();
596        let w = load_awq_layer(&st, "l", IC, OC, GROUP, &Device::cpu()).unwrap();
597        let act = Tensor::from_graph_tensor(
598            w.weight
599                .graph_tensor()
600                .const_f32_like(vec![0.0f32; IC], Shape::from_dims(&[1, IC]))
601                .unwrap(),
602        );
603        for bad in [0, -1, i32::MIN] {
604            let Err(e) = w.matmul(&act, bad) else {
605                panic!("split_k_iters={bad} must be refused");
606            };
607            assert!(format!("{e}").contains("split_k_iters"), "{e}");
608        }
609        assert!(
610            w.matmul(&act, 1).is_ok(),
611            "control: a positive value is accepted"
612        );
613    }
614
615    /// REAL BYTES, read at test time and never committed: the checkpoint
616    /// `Orion-zhen/Qwen3-0.6B-AWQ` is GPL-3.0, so none of its weights live in
617    /// this repository. `FUEL_QWEN3_AWQ_DIR` names a directory holding its
618    /// `model.safetensors`; when unset the test declares a [`Hardware::Hub`]
619    /// skip, which is fatal under `FUEL_REQUIRE_HUB` (armed on the self-hosted
620    /// runner). The slice is rows 0..256 / output columns 0..64 of
621    /// `model.layers.0.mlp.gate_proj`, re-cut in memory under the tensor prefix
622    /// `l`, an AutoAWQ GEMM checkpoint.
623    /// Decides whether `qzeros` is the logical zero-point as stored (AutoAWQ) or
624    /// the logical zero minus one (GPTQ v1): trained weights are roughly
625    /// zero-centred, so the mean of `q - z` in code units is ~0 for the right
626    /// reading and ~-1 for a zero stored minus one. The decode here is written
627    /// independently of `awq_repack`.
628    /// The checkpoint file named by `FUEL_QWEN3_AWQ_DIR`, or `None` after
629    /// declaring the skip.
630    fn checkpoint_file() -> Option<std::path::PathBuf> {
631        use fuel_test_support::hardware::{Hardware, Missing, skip};
632        let Some(dir) = std::env::var_os("FUEL_QWEN3_AWQ_DIR") else {
633            skip(
634                Hardware::Hub,
635                Missing::device(
636                    "FUEL_QWEN3_AWQ_DIR is not set (Orion-zhen/Qwen3-0.6B-AWQ, GPL-3.0, never committed)",
637                ),
638            );
639            return None;
640        };
641        let file = std::path::Path::new(&dir).join("model.safetensors");
642        if !file.is_file() {
643            skip(
644                Hardware::Hub,
645                Missing::device(format!("{} does not exist", file.display())),
646            );
647            return None;
648        }
649        Some(file)
650    }
651
652    /// Leading `rows` rows of the first `cols` columns of a row-major tensor.
653    fn cut(st: &safetensors::SafeTensors<'_>, name: &str, rows: usize, cols: usize) -> Vec<u8> {
654        let view = st.tensor(name).expect(name);
655        let elem = view.dtype().bitsize() / 8;
656        let stride = view.shape()[1] * elem;
657        let data = view.data();
658        let mut out = Vec::with_capacity(rows * cols * elem);
659        for r in 0..rows {
660            out.extend_from_slice(&data[r * stride..r * stride + cols * elem]);
661        }
662        out
663    }
664
665    /// The in-memory safetensors slice described above (the file is read whole,
666    /// read whole, with no memory mapping), or `None` after declaring the skip.
667    fn real_slice_bytes() -> Option<Vec<u8>> {
668        let file = checkpoint_file()?;
669        let bytes = std::fs::read(&file).expect("read model.safetensors");
670        let st = safetensors::SafeTensors::deserialize(&bytes).expect("parse model.safetensors");
671        let p = "model.layers.0.mlp.gate_proj";
672        let qw = cut(&st, &format!("{p}.qweight"), 256, 8);
673        let qz = cut(&st, &format!("{p}.qzeros"), 2, 8);
674        let sc = cut(&st, &format!("{p}.scales"), 2, 64);
675        let mut t: HashMap<String, TensorView<'_>> = HashMap::new();
676        let i32s = safetensors::Dtype::I32;
677        t.insert(
678            "l.qweight".into(),
679            TensorView::new(i32s, vec![256, 8], &qw).unwrap(),
680        );
681        t.insert(
682            "l.qzeros".into(),
683            TensorView::new(i32s, vec![2, 8], &qz).unwrap(),
684        );
685        t.insert(
686            "l.scales".into(),
687            TensorView::new(safetensors::Dtype::F16, vec![2, 64], &sc).unwrap(),
688        );
689        Some(safetensors::serialize(&t, None::<HashMap<String, String>>).unwrap())
690    }
691
692    fn real_slice() -> Option<(Vec<u8>, Vec<u8>, Vec<f16>)> {
693        let bytes = real_slice_bytes()?;
694        let st = safetensors::SafeTensors::deserialize(&bytes).unwrap();
695        let words = |k: &str| -> Vec<u32> {
696            st.tensor(k)
697                .unwrap()
698                .data()
699                .as_chunks::<4>()
700                .0
701                .iter()
702                .map(|c| u32::from_le_bytes(*c))
703                .collect()
704        };
705        let unpack = |w: &[u32], rows: usize| -> Vec<u8> {
706            let mut out = vec![0u8; rows * 64];
707            for r in 0..rows {
708                for col in 0..8 {
709                    for i in 0..8 {
710                        out[r * 64 + col * 8 + HF_AWQ_PACK_ORDER[i]] =
711                            ((w[r * 8 + col] >> (4 * i)) & 0xF) as u8;
712                    }
713                }
714            }
715            out
716        };
717        let scales = st
718            .tensor("l.scales")
719            .unwrap()
720            .data()
721            .as_chunks::<2>()
722            .0
723            .iter()
724            .map(|c| f16::from_bits(u16::from_le_bytes(*c)))
725            .collect();
726        Some((
727            unpack(&words("l.qweight"), 256),
728            unpack(&words("l.qzeros"), 2),
729            scales,
730        ))
731    }
732
733    #[test]
734    fn real_checkpoint_zeros_are_stored_as_is_not_minus_one() {
735        let Some((q, z, _)) = real_slice() else {
736            return;
737        };
738        let mean_shift = |shift: f64| -> f64 {
739            let mut sum = 0.0;
740            for i in 0..256 {
741                for o in 0..64 {
742                    sum += f64::from(q[i * 64 + o]) - (f64::from(z[(i / 128) * 64 + o]) + shift);
743                }
744            }
745            sum / (256.0 * 64.0)
746        };
747        let as_is = mean_shift(0.0);
748        assert!(as_is.abs() < 0.35, "mean(q - z) = {as_is}, expected ~0");
749        // Sabotage siblings: reading the stored zero as (logical - 1) or
750        // (logical + 1) moves the mean by a full code, so the bound above
751        // discriminates the three readings.
752        assert!(mean_shift(1.0) < -0.65, "plus-one reading should be ~-1");
753        assert!(mean_shift(-1.0) > 0.65, "minus-one reading should be ~+1");
754    }
755
756    #[test]
757    fn real_checkpoint_slice_loads_and_matches_the_as_is_reference() {
758        let Some((q, z, s)) = real_slice() else {
759            return;
760        };
761        let bytes = real_slice_bytes().expect("the slice was available a line ago");
762        let st = safetensors::SafeTensors::deserialize(&bytes).unwrap();
763        let w = load_awq_layer(&st, "l", 256, 64, 128, &Device::cpu()).unwrap();
764        let x: Vec<f32> = (0..256)
765            .map(|i| ((i * 5) % 17) as f32 * 0.1 - 0.8)
766            .collect();
767        let act = Tensor::from_graph_tensor(
768            w.weight
769                .graph_tensor()
770                .const_f32_like(x.clone(), Shape::from_dims(&[1, 256]))
771                .unwrap(),
772        );
773        let y = w.matmul(&act, 8).unwrap();
774        let graph = y.graph_tensor().graph().clone();
775        let roots = fuel_graph::opt::RuleRegistry::lowering_only()
776            .optimize_to_fixpoint(&graph, &[y.graph_tensor().id()]);
777        let got = crate::pipelined_bridge::realize_one_as::<f32>(&graph, roots[0], &Device::cpu())
778            .unwrap();
779        for o in 0..64 {
780            let mut want = 0.0f64;
781            for i in 0..256 {
782                let g = i / 128;
783                want += f64::from(x[i])
784                    * (f64::from(q[i * 64 + o]) - f64::from(z[g * 64 + o]))
785                    * f64::from(s[g * 64 + o].to_f32());
786            }
787            assert!(
788                (f64::from(got[o]) - want).abs() <= 1e-3 * want.abs().max(1.0),
789                "col {o}: got {} want {want}",
790                got[o]
791            );
792        }
793    }
794}