spark_runtime/weights/
mlx_int8.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! MLX uint32-packed 8-bit weight format support.
4//!
5//! Models published as `mlx-community/<name>-MLX-8bit` ship safetensors
6//! that pack each linear layer's weights into a triplet:
7//!
8//! - `{base}.weight` — `U32` of shape `[out_features, in_features / 4]`.
9//!   Each `uint32` packs four unsigned 8-bit weight bytes (low byte =
10//!   column 0, high byte = column 3 of the four-column block).
11//! - `{base}.scales` — `BF16` of shape `[out_features, in_features / G]`.
12//!   One scale per group of `G` (default 64) columns.
13//! - `{base}.biases` — `BF16` of shape `[out_features, in_features / G]`.
14//!   One additive bias per group.
15//!
16//! The dequantization formula is affine:
17//!
18//!   `w[r, c] = byte * scales[r, c/G] + biases[r, c/G]`
19//!
20//! where `byte` is the `c%4`-th byte of `packed[r, c/4]` (little-endian).
21//!
22//! `MlxInt8Weight` holds the triplet on the GPU; `dequantize_to` runs the
23//! `mlx_int8_dequant` kernel to materialize a contiguous BF16 view, and
24//! `gemv` / `gemm` run the fused dequant-and-multiply kernels for the
25//! decode and prefill paths respectively.
26
27use anyhow::{Context, Result, bail};
28use safetensors::SafeTensors;
29use serde_json::Value as JsonValue;
30
31use crate::gpu::{DevicePtr, GpuBackend, KernelArg};
32
33/// Quantization metadata parsed from the model's `config.json`.
34///
35/// Both top-level `quantization` and `quantization_config` blocks are
36/// recognised — MLX exports the same data under both keys.
37#[derive(Debug, Clone, Copy)]
38pub struct MlxQuantConfig {
39    pub bits: u32,
40    pub group_size: u32,
41}
42
43impl MlxQuantConfig {
44    /// Look for `quantization` (or `quantization_config` as fallback)
45    /// at the top level of `config.json`. Returns `None` if either
46    /// the block is missing or the bits/group_size keys are absent —
47    /// non-MLX checkpoints flow through other detection paths.
48    pub fn from_config(config: &JsonValue) -> Option<Self> {
49        let q = config
50            .get("quantization")
51            .or_else(|| config.get("quantization_config"))?;
52        let bits = q.get("bits")?.as_u64()? as u32;
53        let group_size = q.get("group_size")?.as_u64()? as u32;
54        Some(Self { bits, group_size })
55    }
56}
57
58/// One MLX-int8 quantized linear weight resident on the GPU.
59///
60/// The fields are public because the consumer (transformer layer
61/// implementation) usually owns the pointers and frees them in batch
62/// at model teardown — there's no per-weight Drop here.
63pub struct MlxInt8Weight {
64    /// `[out_features, in_features / 4]` packed bytes (uint32 words).
65    pub packed: DevicePtr,
66    /// `[out_features, in_features / group_size]` per-group BF16 scales.
67    pub scales: DevicePtr,
68    /// `[out_features, in_features / group_size]` per-group BF16 biases.
69    pub biases: DevicePtr,
70    pub out_features: u32,
71    pub in_features: u32,
72    pub group_size: u32,
73}
74
75impl MlxInt8Weight {
76    /// Load a `(.weight, .scales, .biases)` triplet from a parsed
77    /// safetensors blob and upload to the GPU. `base` is the tensor
78    /// name minus the suffix (e.g. `"language_model.model.embed_tokens"`).
79    pub fn load(
80        gpu: &dyn GpuBackend,
81        st: &SafeTensors,
82        base: &str,
83        group_size: u32,
84    ) -> Result<Self> {
85        let weight_name = format!("{base}.weight");
86        let scales_name = format!("{base}.scales");
87        let biases_name = format!("{base}.biases");
88
89        let weight = st
90            .tensor(&weight_name)
91            .with_context(|| format!("missing tensor {weight_name}"))?;
92        let scales = st
93            .tensor(&scales_name)
94            .with_context(|| format!("missing tensor {scales_name}"))?;
95        let biases = st
96            .tensor(&biases_name)
97            .with_context(|| format!("missing tensor {biases_name}"))?;
98
99        if weight.dtype() != safetensors::Dtype::U32 {
100            bail!(
101                "{weight_name}: expected U32 (MLX 8-bit packed), got {:?}",
102                weight.dtype()
103            );
104        }
105        if scales.dtype() != safetensors::Dtype::BF16 || biases.dtype() != safetensors::Dtype::BF16
106        {
107            bail!(
108                "{base}.scales/biases: expected BF16, got scales={:?}, biases={:?}",
109                scales.dtype(),
110                biases.dtype()
111            );
112        }
113
114        let weight_shape = weight.shape();
115        if weight_shape.len() != 2 {
116            bail!(
117                "{weight_name}: expected 2-D weight tensor, got rank {}",
118                weight_shape.len()
119            );
120        }
121        let out_features = weight_shape[0] as u32;
122        let packed_cols = weight_shape[1] as u32;
123        let in_features = packed_cols * 4;
124
125        let groups_per_row = in_features
126            .checked_div(group_size)
127            .filter(|&g| g * group_size == in_features)
128            .ok_or_else(|| {
129                anyhow::anyhow!(
130                    "{base}: in_features {in_features} not divisible by group_size {group_size}"
131                )
132            })?;
133
134        let expected = [out_features as usize, groups_per_row as usize];
135        if scales.shape() != expected {
136            bail!(
137                "{}.scales: expected shape {:?}, got {:?}",
138                base,
139                expected,
140                scales.shape()
141            );
142        }
143        if biases.shape() != expected {
144            bail!(
145                "{}.biases: expected shape {:?}, got {:?}",
146                base,
147                expected,
148                biases.shape()
149            );
150        }
151
152        let packed_ptr = gpu.alloc(weight.data().len())?;
153        gpu.copy_h2d(weight.data(), packed_ptr)?;
154        let scales_ptr = gpu.alloc(scales.data().len())?;
155        gpu.copy_h2d(scales.data(), scales_ptr)?;
156        let biases_ptr = gpu.alloc(biases.data().len())?;
157        gpu.copy_h2d(biases.data(), biases_ptr)?;
158
159        Ok(Self {
160            packed: packed_ptr,
161            scales: scales_ptr,
162            biases: biases_ptr,
163            out_features,
164            in_features,
165            group_size,
166        })
167    }
168
169    /// Materialize the full dequantized weight as BF16 into `out`,
170    /// which must be a `DevicePtr` to a buffer of at least
171    /// `out_features * in_features * 2` bytes. Runs the
172    /// `mlx_int8_dequant` Metal kernel under the hood.
173    pub fn dequantize_to(&self, gpu: &dyn GpuBackend, out: DevicePtr, stream: u64) -> Result<()> {
174        let kernel = gpu.kernel("mlx_int8_dequant", "mlx_int8_dequant")?;
175        // 16×1 thread grid per (col_tile, row); covers all (r, c)
176        // with bounds checks inside the kernel.
177        let block_x: u32 = 16;
178        let block_y: u32 = 1;
179        let grid_x = self.in_features.div_ceil(block_x);
180        let grid_y = self.out_features;
181        gpu.launch_typed(
182            kernel,
183            [grid_x, grid_y, 1],
184            [block_x, block_y, 1],
185            0,
186            stream,
187            &[
188                KernelArg::Bytes(&self.out_features.to_le_bytes()),
189                KernelArg::Bytes(&self.in_features.to_le_bytes()),
190                KernelArg::Bytes(&self.group_size.to_le_bytes()),
191                KernelArg::Buffer(self.packed),
192                KernelArg::Buffer(self.scales),
193                KernelArg::Buffer(self.biases),
194                KernelArg::Buffer(out),
195            ],
196        )
197    }
198
199    /// Decode-path matvec: `y = self_dequant @ x`. `x` must be BF16
200    /// `[in_features]`; `y` must be a BF16 buffer with at least
201    /// `out_features` slots. Runs the fused `mlx_int8_gemv` kernel.
202    pub fn gemv(
203        &self,
204        gpu: &dyn GpuBackend,
205        x: DevicePtr,
206        y: DevicePtr,
207        stream: u64,
208    ) -> Result<()> {
209        let kernel = gpu.kernel("mlx_int8_gemv", "mlx_int8_gemv")?;
210        // 4 rows per threadgroup, one simdgroup (32 threads) per row.
211        // Sharing `x[]` across 4 rows via L2 cache cuts input-side
212        // bandwidth by 4×; row-local simd_sum avoids cross-simdgroup
213        // reductions entirely.
214        const ROWS_PER_TG: u32 = 4;
215        const SIMDGROUP_SIZE: u32 = 32;
216        let threads_per_tg: u32 = ROWS_PER_TG * SIMDGROUP_SIZE; // 128
217        let row_groups = self.out_features.div_ceil(ROWS_PER_TG);
218        gpu.launch_typed(
219            kernel,
220            [row_groups, 1, 1],
221            [threads_per_tg, 1, 1],
222            0,
223            stream,
224            &[
225                KernelArg::Bytes(&self.out_features.to_le_bytes()),
226                KernelArg::Bytes(&self.in_features.to_le_bytes()),
227                KernelArg::Bytes(&self.group_size.to_le_bytes()),
228                KernelArg::Buffer(self.packed),
229                KernelArg::Buffer(self.scales),
230                KernelArg::Buffer(self.biases),
231                KernelArg::Buffer(x),
232                KernelArg::Buffer(y),
233            ],
234        )
235    }
236
237    /// Like `gemv_silu_gate`, but additionally folds the residual
238    /// stream addition into the same kernel:
239    ///   `y[n] = x_resid[n] + sum_k self[n, k] * (silu(gate[k]) ⊙ up[k])`
240    /// Eliminates the trailing `bf16_add` and the FFN-out staging
241    /// buffer on the decoder layer's exit.
242    pub fn gemv_silu_gate_resid(
243        &self,
244        gpu: &dyn GpuBackend,
245        gate: DevicePtr,
246        up: DevicePtr,
247        x_resid: DevicePtr,
248        y: DevicePtr,
249        stream: u64,
250    ) -> Result<()> {
251        let kernel = gpu.kernel("mlx_int8_gemv_silu_gate", "mlx_int8_gemv_silu_gate_resid")?;
252        const ROWS_PER_TG: u32 = 4;
253        const SIMDGROUP_SIZE: u32 = 32;
254        let threads_per_tg: u32 = ROWS_PER_TG * SIMDGROUP_SIZE;
255        let row_groups = self.out_features.div_ceil(ROWS_PER_TG);
256        gpu.launch_typed(
257            kernel,
258            [row_groups, 1, 1],
259            [threads_per_tg, 1, 1],
260            0,
261            stream,
262            &[
263                KernelArg::Bytes(&self.out_features.to_le_bytes()),
264                KernelArg::Bytes(&self.in_features.to_le_bytes()),
265                KernelArg::Bytes(&self.group_size.to_le_bytes()),
266                KernelArg::Buffer(self.packed),
267                KernelArg::Buffer(self.scales),
268                KernelArg::Buffer(self.biases),
269                KernelArg::Buffer(gate),
270                KernelArg::Buffer(up),
271                KernelArg::Buffer(x_resid),
272                KernelArg::Buffer(y),
273            ],
274        )
275    }
276
277    /// Decode-path FFN-residual fusion:
278    ///   `y = self @ (silu(gate) ⊙ up)`
279    /// Runs the fused `mlx_int8_gemv_silu_gate` kernel — replaces the
280    /// `silu_gate → gemv(down_proj)` pair with a single launch and
281    /// no INTERMEDIATE-sized staging buffer.
282    pub fn gemv_silu_gate(
283        &self,
284        gpu: &dyn GpuBackend,
285        gate: DevicePtr,
286        up: DevicePtr,
287        y: DevicePtr,
288        stream: u64,
289    ) -> Result<()> {
290        let kernel = gpu.kernel("mlx_int8_gemv_silu_gate", "mlx_int8_gemv_silu_gate")?;
291        const ROWS_PER_TG: u32 = 4;
292        const SIMDGROUP_SIZE: u32 = 32;
293        let threads_per_tg: u32 = ROWS_PER_TG * SIMDGROUP_SIZE;
294        let row_groups = self.out_features.div_ceil(ROWS_PER_TG);
295        gpu.launch_typed(
296            kernel,
297            [row_groups, 1, 1],
298            [threads_per_tg, 1, 1],
299            0,
300            stream,
301            &[
302                KernelArg::Bytes(&self.out_features.to_le_bytes()),
303                KernelArg::Bytes(&self.in_features.to_le_bytes()),
304                KernelArg::Bytes(&self.group_size.to_le_bytes()),
305                KernelArg::Buffer(self.packed),
306                KernelArg::Buffer(self.scales),
307                KernelArg::Buffer(self.biases),
308                KernelArg::Buffer(gate),
309                KernelArg::Buffer(up),
310                KernelArg::Buffer(y),
311            ],
312        )
313    }
314
315    /// Prefill-path GEMM: `Y = X @ self_dequant^T`. `X` is BF16
316    /// `[m, in_features]`; `Y` is BF16 `[m, out_features]`. Runs
317    /// the fused `mlx_int8_gemm` kernel — straightforward correctness
318    /// reference; tile-optimised replacement is a follow-on PR.
319    pub fn gemm(
320        &self,
321        gpu: &dyn GpuBackend,
322        x: DevicePtr,
323        y: DevicePtr,
324        m: u32,
325        stream: u64,
326    ) -> Result<()> {
327        let kernel = gpu.kernel("mlx_int8_gemm", "mlx_int8_gemm")?;
328        let block_x: u32 = 16;
329        let block_y: u32 = 16;
330        let grid_x = self.out_features.div_ceil(block_x);
331        let grid_y = m.div_ceil(block_y);
332        gpu.launch_typed(
333            kernel,
334            [grid_x, grid_y, 1],
335            [block_x, block_y, 1],
336            0,
337            stream,
338            &[
339                KernelArg::Bytes(&m.to_le_bytes()),
340                KernelArg::Bytes(&self.out_features.to_le_bytes()),
341                KernelArg::Bytes(&self.in_features.to_le_bytes()),
342                KernelArg::Bytes(&self.group_size.to_le_bytes()),
343                KernelArg::Buffer(x),
344                KernelArg::Buffer(self.packed),
345                KernelArg::Buffer(self.scales),
346                KernelArg::Buffer(self.biases),
347                KernelArg::Buffer(y),
348            ],
349        )
350    }
351
352    /// Free the three GPU buffers backing this weight. Idempotent if
353    /// the pointers are null. Call this at model teardown — there's
354    /// no Drop because `MlxInt8Weight` is intentionally Copy-friendly
355    /// (the `DevicePtr`s are u64 handles, not owners).
356    pub fn release(&self, gpu: &dyn GpuBackend) -> Result<()> {
357        gpu.free(self.packed)?;
358        gpu.free(self.scales)?;
359        gpu.free(self.biases)?;
360        Ok(())
361    }
362}
363
364/// Dual-output GEMV: `gate_y = gate @ x` and `up_y = up @ x` in one
365/// kernel launch. Halves the x-side memory bandwidth and removes one
366/// kernel-launch round-trip per FFN. Both projections must share
367/// `(out_features, in_features, group_size)`.
368pub fn gemv_gate_up(
369    gpu: &dyn GpuBackend,
370    gate: &MlxInt8Weight,
371    up: &MlxInt8Weight,
372    x: DevicePtr,
373    gate_y: DevicePtr,
374    up_y: DevicePtr,
375    stream: u64,
376) -> Result<()> {
377    debug_assert_eq!(gate.out_features, up.out_features);
378    debug_assert_eq!(gate.in_features, up.in_features);
379    debug_assert_eq!(gate.group_size, up.group_size);
380    let kernel = gpu.kernel("mlx_int8_gemv_gate_up", "mlx_int8_gemv_gate_up")?;
381    const ROWS_PER_TG: u32 = 4;
382    const SIMDGROUP_SIZE: u32 = 32;
383    let threads_per_tg: u32 = ROWS_PER_TG * SIMDGROUP_SIZE;
384    let row_groups = gate.out_features.div_ceil(ROWS_PER_TG);
385    gpu.launch_typed(
386        kernel,
387        [row_groups, 1, 1],
388        [threads_per_tg, 1, 1],
389        0,
390        stream,
391        &[
392            KernelArg::Bytes(&gate.out_features.to_le_bytes()),
393            KernelArg::Bytes(&gate.in_features.to_le_bytes()),
394            KernelArg::Bytes(&gate.group_size.to_le_bytes()),
395            KernelArg::Buffer(gate.packed),
396            KernelArg::Buffer(gate.scales),
397            KernelArg::Buffer(gate.biases),
398            KernelArg::Buffer(up.packed),
399            KernelArg::Buffer(up.scales),
400            KernelArg::Buffer(up.biases),
401            KernelArg::Buffer(x),
402            KernelArg::Buffer(gate_y),
403            KernelArg::Buffer(up_y),
404        ],
405    )
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn parse_quant_config_from_mlx_layout() {
414        let cfg: JsonValue = serde_json::json!({
415            "quantization": { "bits": 8, "group_size": 64, "mode": "affine" },
416            "model_type": "qwen3_5",
417        });
418        let q = MlxQuantConfig::from_config(&cfg).expect("expected quant block");
419        assert_eq!(q.bits, 8);
420        assert_eq!(q.group_size, 64);
421    }
422
423    #[test]
424    fn parse_quant_config_falls_back_to_quantization_config() {
425        let cfg: JsonValue = serde_json::json!({
426            "quantization_config": { "bits": 8, "group_size": 64 },
427        });
428        let q = MlxQuantConfig::from_config(&cfg).expect("expected quant_config block");
429        assert_eq!(q.bits, 8);
430        assert_eq!(q.group_size, 64);
431    }
432
433    #[test]
434    fn parse_quant_config_returns_none_when_absent() {
435        let cfg: JsonValue = serde_json::json!({ "model_type": "qwen3_5" });
436        assert!(MlxQuantConfig::from_config(&cfg).is_none());
437    }
438}