spark_model/layers/ops/
q2_0_mmq.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//
3// Launcher for the native Ternary-Bonsai Q2_0 MMQ prefill GEMM (Tier-2).
4// Kernel: kernels/gb10/qwen3.6-27b/nvfp4/q2_0_mmq.cu (module `q2_0_mmq`,
5// entries `atlas_q2_0_mmq128_nc/_wc`). Keeps the 2-bit weight PACKED and does
6// the prefill matmul as a tensor-core int8 MMA with dequant-in-register
7// (`(code-1)*d`) against a q8_1-quantized activation, producing BF16 — no BF16
8// weight scratch, no dequant tax, no co-dispatch race.
9//
10// The q8_1 activation quantize is SHARED with Q4_K: reuse
11// `super::quantize_act_q8_1` (kernel `atlas_q8_1_quantize_ds4_bf16`, DS4 layout)
12// and `super::q8_1_scratch_bytes` — Q2_0 also uses DS4 (the `(code-1)*d` dequant
13// never reads q8_1's `s` term). The only Q2_0-specific launch difference vs
14// `q4k_mmq_gemm` is `stride_row_x = k/QK2_0` (K/128, not K/256).
15use anyhow::Result;
16use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
17use spark_runtime::kernel_args::{KernelLaunch, div_ceil};
18
19use crate::weight_map::PackedQ2Weight;
20
21/// Q2_0 MMQ block size: 128 weights per `block_q2_0`.
22pub const QK2_0: u32 = 128;
23/// sizeof(block_q2_0) bytes: fp16 scale d (2) + 128 codes @ 4/byte (32) = 34.
24pub const Q2_0_BLOCK_BYTES: usize = 34;
25
26/// Sub-flag gating the native Q2_0 MMQ prefill path (`ATLAS_GGUF_NATIVE_Q2_MMQ=1`).
27/// Default off: keep the transient-dequant stopgap so the two can be A/B'd on GPU.
28/// (`ATLAS_GGUF_NATIVE_Q2` still gates keep-packing overall — this only chooses
29/// how the kept-packed weight is consumed in PREFILL.)
30pub fn native_q2_mmq_enabled() -> bool {
31    std::env::var("ATLAS_GGUF_NATIVE_Q2_MMQ").ok().as_deref() == Some("1")
32}
33
34/// Bytes for the packed `block_q2_0` form of an `[n, k]` weight (`k % 128 == 0`).
35pub fn q2_0_weight_bytes(n: u32, k: u32) -> usize {
36    (n as usize) * (k as usize / QK2_0 as usize) * Q2_0_BLOCK_BYTES
37}
38
39/// Q2_0 MMQ GEMM: `C[m,n]` (bf16) = `A_q8[m,k]` x `W_q2_0[n,k]`. Fused bf16 store.
40///
41/// `a_q8` is the q8_1_mmq (DS4) activation produced by
42/// [`super::quantize_act_q8_1`]; `w_q2_0` is the packed `block_q2_0` weight
43/// `[n, k]` (the same buffer resident for the decode GEMV — no repack). Grid /
44/// block / smem mirror the Q4_K MMQ (same tile geometry, mmq_x=mmq_y=128).
45#[allow(clippy::too_many_arguments)]
46pub fn q2_0_mmq_gemm(
47    gpu: &dyn GpuBackend,
48    kernel_nc: KernelHandle, // atlas_q2_0_mmq128_nc
49    kernel_wc: KernelHandle, // atlas_q2_0_mmq128_wc
50    a_q8: DevicePtr,         // q8_1_mmq activations
51    w_q2_0: DevicePtr,       // block_q2_0 weights [n, k]
52    out_bf16: DevicePtr,
53    m: u32,
54    n: u32,
55    k: u32,
56    stream: u64,
57) -> Result<()> {
58    let kernel = if !n.is_multiple_of(128) {
59        kernel_wc
60    } else {
61        kernel_nc
62    };
63    KernelLaunch::new(gpu, kernel)
64        .grid([div_ceil(n, 128), div_ceil(m, 128), 1])
65        .block([32, 8, 1])
66        .shared_mem(super::q4k_mmq::Q4K_MMQ_SMEM)
67        .arg_ptr(w_q2_0) // x = weights
68        .arg_ptr(a_q8) // y = q8_1 activations
69        .arg_ptr(out_bf16) // dst
70        .arg_u32(n) // nrows_x
71        .arg_u32(m) // ncols_dst
72        .arg_u32(k) // ncols_x
73        .arg_u32(k / QK2_0) // stride_row_x = K/128
74        .arg_u32(m) // ncols_y
75        .arg_u32(n) // stride_col_dst
76        .launch(stream)
77}
78
79/// Q2_0 MMQ GEMM against a [`PackedQ2Weight`] (asserts `group == 128`, the only
80/// group the MMQ block layout supports — callers fall back to transient-dequant
81/// for group 64). Convenience over [`q2_0_mmq_gemm`].
82pub fn q2_0_mmq_gemm_packed(
83    gpu: &dyn GpuBackend,
84    kernel_nc: KernelHandle,
85    kernel_wc: KernelHandle,
86    a_q8: DevicePtr,
87    w: &PackedQ2Weight,
88    out_bf16: DevicePtr,
89    m: u32,
90    stream: u64,
91) -> Result<()> {
92    anyhow::ensure!(
93        w.group == 128,
94        "Q2_0 MMQ requires group 128 (got {}); use the transient-dequant path for group 64",
95        w.group
96    );
97    q2_0_mmq_gemm(
98        gpu, kernel_nc, kernel_wc, a_q8, w.weight, out_bf16, m, w.n, w.k, stream,
99    )
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    use half::f16;
107
108    // fp16 round-trip identical to the kernel's inline scale read (fp16 store at
109    // load, fp16->f32 in-kernel): the weight scale `d` is only ever fp16-precise.
110    fn f32_to_f16_bits(x: f32) -> u16 {
111        f16::from_f32(x).to_bits()
112    }
113    fn f16_bits_to_f32(bits: u16) -> f32 {
114        f16::from_bits(bits).to_f32()
115    }
116
117    /// Pack an `[n, k]` ternary code matrix (values in {0,1,2}, dequant (code-1))
118    /// with per-(row, group-of-128) fp16 scale `d` into `block_q2_0` bytes,
119    /// exactly matching the on-disk layout the kernel consumes.
120    fn pack_q2_0(codes: &[u8], scales: &[f32], n: usize, k: usize) -> Vec<u8> {
121        assert_eq!(k % 128, 0);
122        let blocks_per_row = k / 128;
123        let mut out = vec![0u8; n * blocks_per_row * Q2_0_BLOCK_BYTES];
124        for row in 0..n {
125            for b in 0..blocks_per_row {
126                let blk = (row * blocks_per_row + b) * Q2_0_BLOCK_BYTES;
127                let dbits = f32_to_f16_bits(scales[row * blocks_per_row + b]);
128                out[blk] = (dbits & 0xff) as u8;
129                out[blk + 1] = (dbits >> 8) as u8;
130                for j in 0..128 {
131                    let c = codes[row * k + b * 128 + j] & 0x3;
132                    let byte = blk + 2 + j / 4;
133                    out[byte] |= c << (2 * (j % 4));
134                }
135            }
136        }
137        out
138    }
139
140    /// CPU model of the kernel's Q2_0 MMQ arithmetic: per-32 q8_1 activation
141    /// quantize (d = absmax/127, int8 round-to-nearest), int8 MAC, fold
142    /// `(code-1)*d_w * a_int8*d_a`. Mirrors `load_tiles_q2_0` +
143    /// `vec_dot_q8_0_q8_1_mma`. Output row-major `[m, n]`.
144    fn mmq_cpu(
145        act: &[f32],
146        codes: &[u8],
147        scales: &[f32],
148        m: usize,
149        n: usize,
150        k: usize,
151    ) -> Vec<f32> {
152        let bpr = k / 128; // blocks per weight row
153        // Quantize activation per 32-lane group: d_a = absmax/127, qs = round(a/d_a).
154        let ng = k / 32;
155        let mut a_q = vec![0i8; m * k];
156        let mut a_d = vec![0f32; m * ng];
157        for r in 0..m {
158            for g in 0..ng {
159                let mut amax = 0f32;
160                for t in 0..32 {
161                    amax = amax.max(act[r * k + g * 32 + t].abs());
162                }
163                let d = amax / 127.0;
164                a_d[r * ng + g] = d;
165                for t in 0..32 {
166                    let q = if d > 0.0 {
167                        (act[r * k + g * 32 + t] / d).round().clamp(-127.0, 127.0)
168                    } else {
169                        0.0
170                    };
171                    a_q[r * k + g * 32 + t] = q as i8;
172                }
173            }
174        }
175        let mut out = vec![0f32; m * n];
176        for r in 0..m {
177            for col in 0..n {
178                let mut acc = 0f32;
179                for g in 0..ng {
180                    let b = g / 4; // which 128-block
181                    let dw = f16_bits_to_f32(f32_to_f16_bits(scales[col * bpr + b]));
182                    let da = a_d[r * ng + g];
183                    let mut isum = 0i32;
184                    for t in 0..32 {
185                        let ki = g * 32 + t;
186                        let w = (codes[col * k + ki] & 0x3) as i32 - 1;
187                        isum += w * a_q[r * k + ki] as i32;
188                    }
189                    acc += isum as f32 * dw * da;
190                }
191                out[r * n + col] = acc;
192            }
193        }
194        out
195    }
196
197    /// FP oracle: direct dequant `(code-1)*d` × full-precision activation.
198    fn oracle(act: &[f32], codes: &[u8], scales: &[f32], m: usize, n: usize, k: usize) -> Vec<f32> {
199        let bpr = k / 128;
200        let mut out = vec![0f32; m * n];
201        for r in 0..m {
202            for col in 0..n {
203                let mut acc = 0f32;
204                for ki in 0..k {
205                    let b = ki / 128;
206                    let dw = f16_bits_to_f32(f32_to_f16_bits(scales[col * bpr + b]));
207                    let w = ((codes[col * k + ki] & 0x3) as i32 - 1) as f32 * dw;
208                    acc += w * act[r * k + ki];
209                }
210                out[r * n + col] = acc;
211            }
212        }
213        out
214    }
215
216    #[test]
217    fn q2_0_block_layout_matches_spec() {
218        // Ternary-Bonsai spec: 34-byte block, 128 weights, 4 codes/byte.
219        assert_eq!(Q2_0_BLOCK_BYTES, 2 + 128 / 4);
220        assert_eq!(QK2_0, 128);
221        assert_eq!(q2_0_weight_bytes(256, 512), 256 * (512 / 128) * 34);
222        // Byte-packing round-trips: code j lands at bits [2*(j%4)] of byte 2+j/4.
223        let codes: Vec<u8> = (0..128).map(|j| (j % 3) as u8).collect();
224        let packed = pack_q2_0(&codes, &[0.5], 1, 128);
225        assert_eq!(packed.len(), 34);
226        for j in 0..128usize {
227            let got = (packed[2 + j / 4] >> (2 * (j % 4))) & 0x3;
228            assert_eq!(got, (j % 3) as u8, "code {j} mispacked");
229        }
230
231        let launcher = include_str!("../../../../../kernels/gb10/qwen3.6-27b/nvfp4/q2_0_mmq.cu");
232        let vendor =
233            include_str!("../../../../../kernels/gb10/qwen3.6-27b/nvfp4/q4k_vendor/mmq.cuh");
234        assert!(launcher.contains("constexpr ggml_type type = GGML_TYPE_Q2_0;"));
235        assert!(launcher.contains("atlas_q2_0_tile<128, false>"));
236        assert!(launcher.contains("atlas_q2_0_tile<128, true>"));
237        assert!(vendor.contains("load_tiles   = load_tiles_q2_0<mmq_y, need_check>;"));
238        assert!(vendor.contains(
239            "vec_dot_mma  = vec_dot_q8_0_q8_1_mma<mmq_x, mmq_y, MMQ_Q8_1_DS_LAYOUT_DS4>;"
240        ));
241        assert_eq!(
242            vendor.matches("& 0x3) - 1;").count(),
243            4,
244            "the Q2 unpack must subtract one from all four packed codes"
245        );
246    }
247
248    #[test]
249    fn q2_0_mmq_reference_arithmetic_matches_fp_oracle() {
250        // Small [M,N,K], K multiple of 128 (two groups per block boundary check).
251        let (m, n, k) = (3usize, 5usize, 256usize);
252        let bpr = k / 128;
253        // Deterministic pseudo-random codes in {0,1,2} and per-block scales.
254        let mut codes = vec![0u8; n * k];
255        for (i, c) in codes.iter_mut().enumerate() {
256            *c = (((i * 2654435761usize) >> 5) % 3) as u8; // {0,1,2}
257        }
258        let mut scales = vec![0f32; n * bpr];
259        for (i, s) in scales.iter_mut().enumerate() {
260            *s = 0.015 + 0.01 * ((i % 7) as f32); // small fp16-friendly magnitudes
261        }
262        let mut act = vec![0f32; m * k];
263        for (i, a) in act.iter_mut().enumerate() {
264            let x = (i as f32 * 0.12345).sin();
265            *a = x * 1.7;
266        }
267
268        let mmq = mmq_cpu(&act, &codes, &scales, m, n, k);
269        let orc = oracle(&act, &codes, &scales, m, n, k);
270
271        // Relative error is bounded by the q8_1 activation quantization (int8,
272        // per-32 absmax) — expect the same ~6-7e-3 band as the verified Q4_K MMQ.
273        let mut max_rel = 0f32;
274        let mut denom = 0f32;
275        let mut num = 0f32;
276        for i in 0..m * n {
277            let e = (mmq[i] - orc[i]).abs();
278            assert!(
279                e <= 0.02 * orc[i].abs().max(0.5),
280                "idx {i}: mmq {} vs oracle {}",
281                mmq[i],
282                orc[i]
283            );
284            num += e * e;
285            denom += orc[i] * orc[i];
286            let r = e / orc[i].abs().max(1e-3);
287            max_rel = max_rel.max(r);
288        }
289        let l2_rel = (num / denom.max(1e-12)).sqrt();
290        assert!(
291            l2_rel < 1e-2,
292            "Q2_0 MMQ L2 rel_err {l2_rel:.4e} exceeds 1e-2 (max pointwise {max_rel:.4e})"
293        );
294    }
295}