Skip to main content

nuvec_core/
quantized.rs

1//! Quantization, as in MLX: weights stored with a few bits per value and one scale per group
2//! of values along the last axis.
3//!
4//! - **Affine** (`bits` = 1–6 or 8, groups of 32, 64 or 128): a value `w` is stored as
5//!   `round((w - bias) / scale)`, clamped to `[0, 2^bits - 1]`; scales and biases keep the
6//!   weights' dtype.
7//! - **Microscaling floating point**, no biases: `mxfp4` (E2M1 values, groups of 32, an E8M0
8//!   scale), `mxfp8` (E4M3 values, groups of 32, an E8M0 scale) and `nvfp4` (E2M1 values,
9//!   groups of 16, an E4M3 scale). Scales are uint8.
10//!
11//! The packed form is MLX's, so weights quantized by either library load in the other:
12//! codes form a little-endian bit stream per row, stored as uint32.
13
14use std::fmt;
15use std::str::FromStr;
16
17use crate::ops::imp::{astype, broadcast_to, expand_dims, multiply, reshape, squeeze};
18use crate::primitive::Primitive;
19use crate::{Array, Category, DType, Error, Result, promote_types, view};
20
21/// A quantization mode (MLX's `mode` strings: `"affine"`, `"mxfp4"`, `"mxfp8"`, `"nvfp4"`).
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
23#[non_exhaustive]
24pub enum QuantMode {
25    /// `bits` per value with a scale and a bias per group (MLX's default).
26    #[default]
27    Affine,
28    /// E2M1 values in groups of 32 with an E8M0 (power-of-two) scale.
29    Mxfp4,
30    /// E4M3 values in groups of 32 with an E8M0 scale.
31    Mxfp8,
32    /// E2M1 values in groups of 16 with an E4M3 scale.
33    Nvfp4,
34}
35
36impl QuantMode {
37    /// MLX's name of the mode.
38    pub const fn name(self) -> &'static str {
39        match self {
40            Self::Affine => "affine",
41            Self::Mxfp4 => "mxfp4",
42            Self::Mxfp8 => "mxfp8",
43            Self::Nvfp4 => "nvfp4",
44        }
45    }
46
47    /// The default group size and bit width (MLX's `quantization_params_from_mode`).
48    const fn defaults(self) -> (i32, i32) {
49        match self {
50            Self::Affine => (64, 4),
51            Self::Mxfp4 => (32, 4),
52            Self::Mxfp8 => (32, 8),
53            Self::Nvfp4 => (16, 4),
54        }
55    }
56}
57
58impl fmt::Display for QuantMode {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        f.write_str(self.name())
61    }
62}
63
64impl FromStr for QuantMode {
65    type Err = Error;
66
67    fn from_str(s: &str) -> Result<Self> {
68        Ok(match s {
69            "affine" => Self::Affine,
70            "mxfp4" => Self::Mxfp4,
71            "mxfp8" => Self::Mxfp8,
72            "nvfp4" => Self::Nvfp4,
73            _ => {
74                return Err(Error::InvalidArgument(format!(
75                    "invalid quantization mode '{s}' (affine, mxfp4, mxfp8 or nvfp4)"
76                )));
77            }
78        })
79    }
80}
81
82/// How weights are quantized: a mode, a group size and a bit width. [`Quant::new`] gives
83/// MLX's defaults for a mode; an unsupported combination fails in the operation that uses it
84/// (a failed array, as every operation's errors).
85///
86/// ```
87/// use nuvec_core::{Quant, QuantMode};
88///
89/// assert_eq!(Quant::new(QuantMode::Mxfp4), Quant { mode: QuantMode::Mxfp4, group_size: 32, bits: 4 });
90/// assert_eq!(Quant::affine(32, 8).bits, 8);
91/// assert_eq!(Quant::default(), Quant::affine(64, 4));
92/// ```
93#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
94pub struct Quant {
95    /// The mode.
96    pub mode: QuantMode,
97    /// Values per scale (affine: 32, 64 or 128; mxfp4 and mxfp8: 32; nvfp4: 16).
98    pub group_size: i32,
99    /// Bits per value (affine: 1–6 or 8; mxfp4 and nvfp4: 4; mxfp8: 8).
100    pub bits: i32,
101}
102
103impl Quant {
104    /// `mode` with MLX's default group size and bits: affine 64/4, mxfp4 32/4, mxfp8 32/8,
105    /// nvfp4 16/4.
106    pub const fn new(mode: QuantMode) -> Self {
107        let (group_size, bits) = mode.defaults();
108        Self {
109            mode,
110            group_size,
111            bits,
112        }
113    }
114
115    /// Affine quantization with `group_size` values per scale and bias and `bits` per value.
116    pub const fn affine(group_size: i32, bits: i32) -> Self {
117        Self {
118            mode: QuantMode::Affine,
119            group_size,
120            bits,
121        }
122    }
123
124    /// This quantization with `group_size` values per scale.
125    #[must_use]
126    pub const fn group_size(self, group_size: i32) -> Self {
127        Self { group_size, ..self }
128    }
129
130    /// This quantization with `bits` bits per value.
131    #[must_use]
132    pub const fn bits(self, bits: i32) -> Self {
133        Self { bits, ..self }
134    }
135
136    /// The group size and bit width, after MLX's checks for the mode.
137    pub(crate) fn checked(self, op: &str) -> Result<(usize, usize)> {
138        let invalid = |msg: String| Err(Error::InvalidArgument(format!("{op}: {msg}")));
139        let (group_size, bits) = (self.group_size, self.bits);
140        if self.mode == QuantMode::Affine {
141            if !matches!(group_size, 32 | 64 | 128) {
142                return invalid(format!(
143                    "group size {group_size} is not supported (32, 64 or 128)"
144                ));
145            }
146            if !matches!(bits, 1..=6 | 8) {
147                return invalid(format!(
148                    "{bits} bits are not supported (1, 2, 3, 4, 5, 6 or 8)"
149                ));
150            }
151        } else {
152            let (expected_group, expected_bits) = self.mode.defaults();
153            if group_size != expected_group {
154                return invalid(format!(
155                    "{} quantization requires group size {expected_group} but got {group_size}",
156                    self.mode
157                ));
158            }
159            if bits != expected_bits {
160                return invalid(format!(
161                    "{} quantization requires bits to be {expected_bits} but got {bits}",
162                    self.mode
163                ));
164            }
165        }
166        Ok((group_size as usize, bits as usize))
167    }
168}
169
170impl Default for Quant {
171    /// Affine, 64 values per group, 4 bits.
172    fn default() -> Self {
173        Self::new(QuantMode::Affine)
174    }
175}
176
177impl From<QuantMode> for Quant {
178    fn from(mode: QuantMode) -> Self {
179        Self::new(mode)
180    }
181}
182
183/// Quantized weights: packed codes (uint32), one scale per group (affine: the weights'
184/// dtype; floating-point modes: uint8 codes), affine biases, and how they were quantized.
185///
186/// ```
187/// use nuvec_core::{DType, QuantMode, ops::ones};
188///
189/// let w = ones([2, 64], DType::F32);
190/// let q = w.quantize(QuantMode::Mxfp4);
191/// assert_eq!((q.weight.shape(), q.scales.shape(), q.biases.is_none()), (&[2, 8][..], &[2, 2][..], true));
192/// assert_eq!(q.dequantize(DType::F32).to_vec::<f32>().unwrap(), vec![1.0; 128]);
193/// ```
194#[derive(Clone, Debug)]
195pub struct Quantized {
196    /// The packed codes (uint32, last dimension `values · bits / 32`).
197    pub weight: Array,
198    /// One scale per group (last dimension `values / group_size`).
199    pub scales: Array,
200    /// One bias per group: required for affine, absent for the floating-point modes.
201    pub biases: Option<Array>,
202    /// How the weights were quantized.
203    pub quant: Quant,
204}
205
206impl Quantized {
207    /// Affine weights quantized elsewhere: the biases are required, as MLX requires them
208    /// (a checkpoint without biases quantized with zero offsets: pass
209    /// `ops::zeros_like(&scales)`).
210    pub fn affine(weight: Array, scales: Array, biases: Array, group_size: i32, bits: i32) -> Self {
211        Self::new(
212            weight,
213            scales,
214            Some(biases),
215            Quant::affine(group_size, bits),
216        )
217    }
218
219    /// Floating-point-mode weights quantized elsewhere (MXFP4, MXFP8, NVFP4), which have no
220    /// biases.
221    pub fn fp(weight: Array, scales: Array, mode: QuantMode) -> Self {
222        Self::new(weight, scales, None, Quant::new(mode))
223    }
224
225    /// Weights quantized elsewhere (a checkpoint's tensors). `biases` must be `Some` for
226    /// [`QuantMode::Affine`] and `None` for the floating-point modes, as in MLX; otherwise
227    /// every operation on them fails with an error saying so. [`Quantized::affine`] and
228    /// [`Quantized::fp`] make the rule part of the signature.
229    pub fn new(
230        weight: Array,
231        scales: Array,
232        biases: Option<Array>,
233        quant: impl Into<Quant>,
234    ) -> Self {
235        Self {
236            weight,
237            scales,
238            biases,
239            quant: quant.into(),
240        }
241    }
242
243    /// The weights restored (MLX's `dequantize`), in `dtype` (default: the scales' dtype for
244    /// affine, bfloat16 for the floating-point modes).
245    #[track_caller]
246    pub fn dequantize(&self, dtype: impl Into<Option<DType>>) -> Array {
247        crate::ops::dequantize(self, dtype)
248    }
249
250    /// The first error any of the arrays carries.
251    pub fn check(&self) -> Result<()> {
252        for a in self.arrays() {
253            let _ = a.clone().check()?;
254        }
255        Ok(())
256    }
257
258    /// The arrays, in MLX's operand order: weight, scales and (affine) biases.
259    pub(crate) fn arrays(&self) -> impl Iterator<Item = &Array> {
260        [&self.weight, &self.scales].into_iter().chain(&self.biases)
261    }
262}
263
264impl crate::tree::Static for QuantMode {
265    fn hash_static(&self, mut h: &mut dyn std::hash::Hasher) {
266        std::hash::Hash::hash(self, &mut h);
267    }
268}
269
270impl crate::tree::Static for Quant {
271    fn hash_static(&self, mut h: &mut dyn std::hash::Hasher) {
272        std::hash::Hash::hash(self, &mut h);
273    }
274}
275
276/// A tree of buffers (never trainable, as quantized layers' tensors) with MLX's names
277/// `weight`, `scales` and `biases`, and `quant` as a static field: `eval(&q)`, the
278/// transforms and `save_safetensors` take quantized weights directly.
279impl crate::tree::Leaves for Quantized {
280    fn visit(&self, v: &mut crate::tree::Visit<'_>) {
281        v.buffer("weight", &self.weight);
282        v.buffer("scales", &self.scales);
283        v.buffer("biases", &self.biases);
284        v.static_field("quant", &self.quant);
285    }
286}
287
288impl crate::tree::Tree for Quantized {
289    fn visit_mut(&mut self, v: &mut crate::tree::VisitMut<'_>) {
290        v.buffer("weight", &mut self.weight);
291        v.buffer("scales", &mut self.scales);
292        v.buffer("biases", &mut self.biases);
293    }
294}
295
296/// Options of [`gather_qmm`](crate::ops::gather_qmm), built with setters.
297/// `GatherQmm::default()` multiplies by the transposed weights (MLX's default).
298#[derive(Clone, Copy, Debug)]
299#[non_exhaustive]
300pub struct GatherQmm<'a> {
301    /// `x · wᵀ` (MLX's default) rather than `x · w`.
302    pub transpose: bool,
303    /// The right indices are sorted (rows grouped by expert), so one kernel can walk each
304    /// expert's weights once.
305    pub sorted_indices: bool,
306    /// nvfp4 only: one float32 scale per expert (`w.weight`'s batch shape), multiplying the
307    /// expert's decoded weights by `global_scale / (448 · 6)`. Needs the right indices.
308    pub global_scale: Option<&'a Array>,
309}
310
311impl Default for GatherQmm<'_> {
312    fn default() -> Self {
313        Self {
314            transpose: true,
315            sorted_indices: false,
316            global_scale: None,
317        }
318    }
319}
320
321impl<'a> GatherQmm<'a> {
322    /// Whether a gather of `rows` sorted index rows over `experts` weight batches takes the
323    /// grouped kernel (each expert's rows in tiles), rather than one product per row. Two
324    /// gathers that take the same kernel give a row the same bits, whatever other rows are
325    /// gathered with it: a caller batching gathers keeps each one's bits when its rows and
326    /// the batch's agree here.
327    pub fn groups(rows: usize, experts: usize) -> bool {
328        grouped(rows, experts)
329    }
330
331    /// Whether the weights are used transposed.
332    #[must_use]
333    pub fn transpose(self, transpose: bool) -> Self {
334        Self { transpose, ..self }
335    }
336
337    /// Whether the right indices are sorted.
338    #[must_use]
339    pub fn sorted_indices(self, sorted_indices: bool) -> Self {
340        Self {
341            sorted_indices,
342            ..self
343        }
344    }
345
346    /// The per-expert nvfp4 global scale.
347    #[must_use]
348    pub fn global_scale(self, global_scale: &'a Array) -> Self {
349        Self {
350            global_scale: Some(global_scale),
351            ..self
352        }
353    }
354}
355
356/// Checks the scales (and biases) against the mode, as MLX's `validate_mode_with_type`:
357/// affine needs float biases, the floating-point modes uint8 scales and no biases.
358fn check_groups(op: &str, mode: QuantMode, scales: &Array, biases: Option<&Array>) -> Result<()> {
359    let invalid = |msg: String| Err(Error::InvalidArgument(format!("{op}: {msg}")));
360    match (mode, biases) {
361        (QuantMode::Affine, None) => {
362            invalid("biases must be provided for affine quantization".into())
363        }
364        (QuantMode::Affine, Some(_)) => Ok(()),
365        (_, Some(_)) => invalid(format!(
366            "biases must be null for quantization mode '{mode}'"
367        )),
368        (_, None) if scales.dtype() != DType::U8 => invalid(format!(
369            "scale type must be uint8 but received type {}",
370            scales.dtype()
371        )),
372        (_, None) => Ok(()),
373    }
374}
375
376/// Quantizes the last axis of `w` (float16, bfloat16 or float32, at least 2-D, the last
377/// dimension divisible by the group size). Returns the packed codes (uint32), the scales and,
378/// for affine, the biases, equal to MLX's bit for bit (the floating-point modes: MLX's Metal
379/// quantizer's).
380pub(crate) fn quantize(w: &Array, quant: Quant) -> Result<(Array, Array, Option<Array>)> {
381    quant.checked("quantize")?;
382    let mut out = Array::from_op(Primitive::Quantize { quant }, vec![w.clone()])?;
383    let biases = (quant.mode == QuantMode::Affine).then(|| out.pop().expect("three outputs"));
384    let scales = out.pop().expect("scales");
385    Ok((out.pop().expect("codes"), scales, biases))
386}
387
388/// Restores the values packed by [`quantize`], in `dtype` (default: the scales' dtype for
389/// affine, bfloat16 for the floating-point modes).
390pub(crate) fn dequantize(
391    w: &Array,
392    scales: &Array,
393    biases: Option<&Array>,
394    quant: Quant,
395    dtype: Option<DType>,
396) -> Result<Array> {
397    quant.checked("dequantize")?;
398    check_groups("dequantize", quant.mode, scales, biases)?;
399    if let Some(d) = dtype
400        && d.category() != Category::Float
401    {
402        return Err(Error::InvalidArgument(format!(
403            "dequantize: only real floating types are supported but the output dtype is {d}"
404        )));
405    }
406    match biases {
407        Some(b) => {
408            let out = Array::from_op(
409                Primitive::Dequantize {
410                    quant,
411                    dtype: scales.dtype(),
412                },
413                vec![w.clone(), scales.clone(), b.clone()],
414            )?
415            .swap_remove(0);
416            match dtype {
417                Some(d) if d != out.dtype() => astype(&out, d),
418                _ => Ok(out),
419            }
420        }
421        None => {
422            let dtype = dtype.unwrap_or(DType::BF16);
423            if !matches!(dtype, DType::F16 | DType::BF16 | DType::F32) {
424                return Err(Error::unsupported_dtype("dequantize", dtype));
425            }
426            let primitive = Primitive::Dequantize { quant, dtype };
427            Ok(Array::from_op(primitive, vec![w.clone(), scales.clone()])?.swap_remove(0))
428        }
429    }
430}
431
432/// The matrix product of `x` with quantized weights, `x · dequantize(w)ᵀ` if `transpose`
433/// (MLX's default, for weights stored as `[out, in]`) or `x · dequantize(w)`, without
434/// materialising the dequantized matrix.
435///
436/// As in MLX, affine promotes `x`, `scales` and `biases` to one floating-point type; the
437/// floating-point modes keep `x`'s dtype. Batch dimensions broadcast when `x` and `w` both
438/// have more than two. A one-dimensional `x` is a row vector.
439pub(crate) fn quantized_matmul(
440    x: &Array,
441    w: &Array,
442    scales: &Array,
443    biases: Option<&Array>,
444    transpose: bool,
445    quant: Quant,
446) -> Result<Array> {
447    quant.checked("quantized_matmul")?;
448    check_groups("quantized_matmul", quant.mode, scales, biases)?;
449    let dtype = match biases {
450        Some(b) => promote_types(promote_types(x.dtype(), scales.dtype()), b.dtype()),
451        None => x.dtype(),
452    };
453    if dtype.category() != Category::Float || dtype.size() > 4 {
454        return Err(Error::unsupported_dtype("quantized_matmul", dtype));
455    }
456    let x = astype(x, dtype)?;
457    let mut groups = vec![w.clone()];
458    match biases {
459        Some(b) => groups.extend([astype(scales, dtype)?, astype(b, dtype)?]),
460        None => groups.push(scales.clone()),
461    }
462    let vector = x.ndim() == 1;
463    let mut x = if vector { expand_dims(&x, 0)? } else { x };
464    let (xn, wn) = (x.ndim(), w.ndim());
465    if wn > 2 && xn >= 2 {
466        // Broadcast the batch dimensions of every operand together (MLX broadcasts them only
467        // when both have batch dimensions; nuvec also lifts a 2-D `x` to `w`'s batch).
468        let batch =
469            view::broadcast_shapes("quantized_matmul", &x.dims()[..xn - 2], &w.dims()[..wn - 2])?;
470        let full = |a: &Array| -> Result<Array> {
471            let mut shape = batch.to_vec();
472            shape.extend_from_slice(&a.dims()[a.ndim() - 2..]);
473            broadcast_to(a, &shape)
474        };
475        x = full(&x)?;
476        for g in &mut groups {
477            *g = full(g)?;
478        }
479    }
480    let mut inputs = vec![x];
481    inputs.extend(groups);
482    let primitive = Primitive::QuantizedMatmul { quant, transpose };
483    let out = Array::from_op(primitive, inputs)?.swap_remove(0);
484    if vector {
485        squeeze(&out, &[-2])
486    } else {
487        Ok(out)
488    }
489}
490
491/// The operands of a recorded [`FastOp::GatherQmm`](crate::fast::imp::FastOp::GatherQmm):
492/// `(x, w, scales[, biases][, global_scale], lhs_indices, rhs_indices[, offsets])`.
493pub(crate) struct GatherOperands<'a> {
494    pub x: &'a Array,
495    pub w: &'a Array,
496    pub scales: &'a Array,
497    pub biases: Option<&'a Array>,
498    pub global_scale: Option<&'a Array>,
499    pub lhs: &'a Array,
500    pub rhs: &'a Array,
501    pub offsets: Option<&'a Array>,
502}
503
504impl<'a> GatherOperands<'a> {
505    /// The operand count without the optional offsets.
506    pub(crate) fn arity(mode: QuantMode, global_scale: bool) -> usize {
507        5 + usize::from(mode == QuantMode::Affine) + usize::from(global_scale)
508    }
509
510    /// Splits `inputs` (`arity` or `arity + 1` arrays, checked by the caller).
511    pub(crate) fn split(inputs: &'a [Array], mode: QuantMode, global_scale: bool) -> Self {
512        let mut i = 3;
513        let mut next = |present: bool| {
514            present.then(|| {
515                i += 1;
516                &inputs[i - 1]
517            })
518        };
519        let biases = next(mode == QuantMode::Affine);
520        let global_scale = next(global_scale);
521        let (lhs, rhs) = (&inputs[i], &inputs[i + 1]);
522        Self {
523            x: &inputs[0],
524            w: &inputs[1],
525            scales: &inputs[2],
526            biases,
527            global_scale,
528            lhs,
529            rhs,
530            offsets: inputs.get(i + 2),
531        }
532    }
533}
534
535/// Quantized matrix products of gathered batches, as MLX's `gather_qmm` (mixture-of-experts
536/// layers): output batch `i` is `x[lhs_indices[i]] · dequantize(w)[rhs_indices[i]]ᵀ` (or
537/// without the transpose), indexing the flattened batch dimensions of `x` (`[..., M, K]`)
538/// and of the weights. The indices (integers) broadcast together; either defaults to every
539/// batch of its operand in order. Without indices this is [`quantized_matmul`].
540#[allow(clippy::too_many_arguments)]
541pub(crate) fn gather_qmm(
542    x: &Array,
543    w: &Array,
544    scales: &Array,
545    biases: Option<&Array>,
546    lhs_indices: Option<&Array>,
547    rhs_indices: Option<&Array>,
548    quant: Quant,
549    options: GatherQmm<'_>,
550) -> Result<Array> {
551    let invalid = |msg: String| Err(Error::InvalidArgument(format!("gather_qmm: {msg}")));
552    quant.checked("gather_qmm")?;
553    if lhs_indices.is_none() && rhs_indices.is_none() {
554        if options.global_scale.is_some() {
555            return invalid("global scale is not supported without indices".into());
556        }
557        return quantized_matmul(x, w, scales, biases, options.transpose, quant);
558    }
559    check_groups("gather_qmm", quant.mode, scales, biases)?;
560    let dtype = match biases {
561        Some(b) => promote_types(promote_types(x.dtype(), scales.dtype()), b.dtype()),
562        None => x.dtype(),
563    };
564    if dtype.category() != Category::Float || dtype.size() > 4 {
565        return Err(Error::unsupported_dtype("gather_qmm", dtype));
566    }
567    if x.ndim() < 2 || w.ndim() < 2 || biases.is_some_and(|b| scales.dims() != b.dims()) {
568        return invalid(format!(
569            "x must be [..., M, K] and w [..., N, K'], got {:?} and {:?}",
570            x.dims(),
571            w.dims()
572        ));
573    }
574    if let Some(gs) = options.global_scale {
575        if quant.mode != QuantMode::Nvfp4 {
576            return invalid("global scale is only supported for 'nvfp4' quantization mode".into());
577        }
578        if gs.dtype() != DType::F32 {
579            return invalid(format!(
580                "global scale must have dtype float32 but got {}",
581                gs.dtype()
582            ));
583        }
584        let experts = &w.dims()[..w.ndim() - 2];
585        if gs.dims() != experts {
586            return invalid(format!(
587                "global scale must have one entry per expert with shape {experts:?} but got {:?}",
588                gs.dims()
589            ));
590        }
591    }
592    let x = astype(x, dtype)?;
593    let (lhs, rhs) = gather_indices("gather_qmm", &x, w, lhs_indices, rhs_indices)?;
594    let mut inputs = vec![x.clone(), w.clone()];
595    match biases {
596        Some(b) => inputs.extend([astype(scales, dtype)?, astype(b, dtype)?]),
597        None => inputs.push(scales.clone()),
598    }
599    inputs.extend(options.global_scale.cloned());
600    inputs.extend([lhs, rhs.clone()]);
601    // MLX's rule (`GatherQMM::eval_gpu`): the sorted kernel tiles each expert's rows, so it
602    // pays only with at least 16 rows and 4 per expert on average; fewer take one product
603    // per row. nuvec also keeps one product per row up to `ROW_INDEPENDENT_ROWS` tokens of
604    // top-8 routing, so rows stay independent (the row-independence contract).
605    let experts: usize = w.dims()[..w.ndim() - 2].iter().product();
606    let sorted = lhs_indices.is_none() && options.sorted_indices && grouped(rhs.size(), experts);
607    if let Some(offsets) = sorted_offsets(&x, w, &rhs, sorted)? {
608        inputs.push(offsets);
609    }
610    let op = crate::fast::imp::FastOp::GatherQmm {
611        quant,
612        transpose: options.transpose,
613        global_scale: options.global_scale.is_some(),
614    };
615    // Affine gathers have kernels for transposed weights only.
616    let kernel = (options.transpose || quant.mode != QuantMode::Affine)
617        && crate::fast::imp::on_gpu(dtype, x.size());
618    // With a global scale the primitive runs on the CPU too (its fallback): it refuses the
619    // global scale's derivative, as MLX's `GatherQMM`, where the graph would differentiate
620    // through it.
621    let out = if kernel || options.global_scale.is_some() {
622        Array::from_op(Primitive::Fast(op), inputs)?.swap_remove(0)
623    } else {
624        op.fallback(&inputs)?.swap_remove(0)
625    };
626    Ok(out)
627}
628
629/// The gathered product of [`FastOp::GatherQmm`](crate::fast::imp::FastOp::GatherQmm) as
630/// a graph: each operand's gathered batches, then one batched product. A global scale
631/// multiplies each expert's decoded weights by `global_scale / (448 · 6)` (MLX's point, per
632/// group before the dot product).
633pub(crate) fn gather_qmm_graph(
634    inputs: &[Array],
635    quant: Quant,
636    transpose: bool,
637    global_scale: bool,
638) -> Result<Array> {
639    let ops = GatherOperands::split(inputs, quant.mode, global_scale);
640    let rows = |a: &Array, indices: &Array| -> Result<Array> {
641        let nd = a.ndim();
642        let mut shape = vec![-1];
643        shape.extend(a.dims()[nd.saturating_sub(2)..].iter().map(|&d| d as isize));
644        crate::indexing::take(&reshape(a, &shape)?, indices, Some(0))
645    };
646    let (x, w, s) = (
647        rows(ops.x, ops.lhs)?,
648        rows(ops.w, ops.rhs)?,
649        rows(ops.scales, ops.rhs)?,
650    );
651    let Some(gs) = ops.global_scale else {
652        let b = ops.biases.map(|b| rows(b, ops.rhs)).transpose()?;
653        return quantized_matmul(&x, &w, &s, b.as_ref(), transpose, quant);
654    };
655    let factor = crate::ops::imp::divide(
656        &rows(&expand_dims(&expand_dims(gs, -1)?, -1)?, ops.rhs)?,
657        crate::fp8::E4M3_MAX * crate::fp8::E2M1_MAX,
658    )?;
659    let dense = multiply(&dequantize(&w, &s, None, quant, Some(DType::F32))?, &factor)?;
660    let dense = if transpose {
661        let n = dense.ndim();
662        let mut axes: Vec<usize> = (0..n).collect();
663        axes.swap(n - 2, n - 1);
664        crate::ops::imp::transpose(&dense, &axes)?
665    } else {
666        dense
667    };
668    let product = crate::ops::imp::matmul(&astype(&x, DType::F32)?, &dense)?;
669    astype(&product, x.dtype())
670}
671
672/// Whether a gather with `rows` index rows over `experts` weight batches takes the grouped
673/// (sorted) kernel: MLX's rule, at least 16 rows and 4 per expert; row-exact
674/// ([`crate::with_row_exact`]), more rows than the row-independent region (`ROW_TOKENS`).
675pub(crate) fn grouped(rows: usize, experts: usize) -> bool {
676    let least = if crate::row_exact() {
677        ROW_TOKENS + 1
678    } else {
679        16
680    };
681    rows >= least && rows / experts.max(1) >= 4
682}
683
684/// Gathered rows up to which every row runs as its own product: `ROW_INDEPENDENT_ROWS`
685/// tokens with up to 8 experts each.
686pub(crate) const ROW_TOKENS: usize = crate::ROW_INDEPENDENT_ROWS * 8;
687
688/// The batch indices of a gathered product, broadcast together; either defaults to every
689/// batch of its operand, in order.
690fn gather_indices(
691    op: &'static str,
692    a: &Array,
693    b: &Array,
694    lhs: Option<&Array>,
695    rhs: Option<&Array>,
696) -> Result<(Array, Array)> {
697    let indices = |x: &Array, given: Option<&Array>| -> Result<Array> {
698        let batch = &x.dims()[..x.ndim() - 2];
699        match given {
700            Some(i)
701                if i.dtype().category() == Category::Float || i.dtype() == crate::DType::Bool =>
702            {
703                Err(Error::InvalidArgument(format!(
704                    "{op}: indices must be integers"
705                )))
706            }
707            Some(i) => astype(i, crate::DType::U32),
708            None => {
709                let n: usize = batch.iter().product();
710                let all = crate::ops::imp::arange(0.0, n as f64, 1.0, crate::DType::U32)?;
711                all.try_reshape(&batch.iter().map(|&d| d as isize).collect::<Vec<_>>())
712            }
713        }
714    };
715    let (l, r) = (indices(a, lhs)?, indices(b, rhs)?);
716    let batch = view::broadcast_shapes(op, l.dims(), r.dims())?;
717    Ok((broadcast_to(&l, &batch)?, broadcast_to(&r, &batch)?))
718}
719
720/// For rows sorted by weight batch (`a` is `[rows, 1, K]`, one row per output batch), where
721/// each batch's rows start (MLX's `gather_mm_offsets`, one dispatch; MLX's grouped kernels
722/// walk every weight batch once).
723fn sorted_offsets(a: &Array, b: &Array, rhs: &Array, sorted: bool) -> Result<Option<Array>> {
724    let m = a.dims()[a.ndim() - 2];
725    if !(sorted && m == 1 && a.ndim() == 3 && rhs.ndim() == 1) {
726        return Ok(None);
727    }
728    let groups: usize = b.dims()[..b.ndim() - 2].iter().product();
729    let primitive = crate::primitive::Primitive::SortedOffsets { groups };
730    Ok(Some(
731        Array::from_op(primitive, vec![rhs.clone()])?.swap_remove(0),
732    ))
733}
734
735/// Matrix products of gathered batches, as MLX's `gather_mm` (mixture-of-experts layers in
736/// full precision): output batch `i` is `a[lhs_indices[i]] · b[rhs_indices[i]]` over the
737/// flattened batch dimensions of `a` (`[..., M, K]`) and `b` (`[..., K, N]`), with the
738/// indices broadcast together and either defaulting to every batch. `sorted_indices`
739/// promises sorted `rhs_indices`, which lets one kernel walk each batch of `b` once.
740pub(crate) fn gather_mm(
741    a: &Array,
742    b: &Array,
743    lhs_indices: Option<&Array>,
744    rhs_indices: Option<&Array>,
745    sorted_indices: bool,
746) -> Result<Array> {
747    let dtype = promote_types(a.dtype(), b.dtype());
748    if dtype.category() != Category::Float {
749        return Err(Error::unsupported_dtype("gather_mm", dtype));
750    }
751    if a.ndim() < 2 || b.ndim() < 2 || a.dims()[a.ndim() - 1] != b.dims()[b.ndim() - 2] {
752        return Err(Error::InvalidArgument(format!(
753            "gather_mm: a must be [..., M, K] and b [..., K, N], got {:?} and {:?}",
754            a.dims(),
755            b.dims()
756        )));
757    }
758    let (a, b) = (astype(a, dtype)?, astype(b, dtype)?);
759    let (lhs, rhs) = gather_indices("gather_mm", &a, &b, lhs_indices, rhs_indices)?;
760    let mut inputs = vec![a.clone(), b.clone(), lhs, rhs.clone()];
761    let grouped = rhs.size() > ROW_TOKENS;
762    if let Some(offsets) = sorted_offsets(
763        &a,
764        &b,
765        &rhs,
766        lhs_indices.is_none() && sorted_indices && grouped,
767    )? {
768        inputs.push(offsets);
769    }
770    Ok(
771        crate::fast::imp::record_or_fallback(crate::fast::imp::FastOp::GatherMm, inputs)?
772            .swap_remove(0),
773    )
774}