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: affine only.
201    pub biases: Option<Array>,
202    /// How the weights were quantized.
203    pub quant: Quant,
204}
205
206impl Quantized {
207    /// Weights quantized elsewhere (a checkpoint's tensors).
208    pub fn new(
209        weight: Array,
210        scales: Array,
211        biases: Option<Array>,
212        quant: impl Into<Quant>,
213    ) -> Self {
214        Self {
215            weight,
216            scales,
217            biases,
218            quant: quant.into(),
219        }
220    }
221
222    /// The weights restored (MLX's `dequantize`), in `dtype` (default: the scales' dtype for
223    /// affine, bfloat16 for the floating-point modes).
224    #[track_caller]
225    pub fn dequantize(&self, dtype: impl Into<Option<DType>>) -> Array {
226        crate::ops::dequantize(self, dtype)
227    }
228
229    /// The first error any of the arrays carries.
230    pub fn check(&self) -> Result<()> {
231        for a in self.arrays() {
232            let _ = a.clone().check()?;
233        }
234        Ok(())
235    }
236
237    /// The arrays, in MLX's operand order: weight, scales and (affine) biases.
238    pub(crate) fn arrays(&self) -> impl Iterator<Item = &Array> {
239        [&self.weight, &self.scales].into_iter().chain(&self.biases)
240    }
241}
242
243impl crate::tree::Static for QuantMode {
244    fn hash_static(&self, mut h: &mut dyn std::hash::Hasher) {
245        std::hash::Hash::hash(self, &mut h);
246    }
247}
248
249impl crate::tree::Static for Quant {
250    fn hash_static(&self, mut h: &mut dyn std::hash::Hasher) {
251        std::hash::Hash::hash(self, &mut h);
252    }
253}
254
255/// A tree of buffers (never trainable, as quantized layers' tensors) with MLX's names
256/// `weight`, `scales` and `biases`, and `quant` as a static field: `eval(&q)`, the
257/// transforms and `save_safetensors` take quantized weights directly.
258impl crate::tree::Leaves for Quantized {
259    fn visit(&self, v: &mut crate::tree::Visit<'_>) {
260        v.buffer("weight", &self.weight);
261        v.buffer("scales", &self.scales);
262        v.buffer("biases", &self.biases);
263        v.static_field("quant", &self.quant);
264    }
265}
266
267impl crate::tree::Tree for Quantized {
268    fn visit_mut(&mut self, v: &mut crate::tree::VisitMut<'_>) {
269        v.buffer("weight", &mut self.weight);
270        v.buffer("scales", &mut self.scales);
271        v.buffer("biases", &mut self.biases);
272    }
273}
274
275/// Options of [`gather_qmm`](crate::ops::gather_qmm), built with setters.
276/// `GatherQmm::default()` multiplies by the transposed weights (MLX's default).
277#[derive(Clone, Copy, Debug)]
278#[non_exhaustive]
279pub struct GatherQmm<'a> {
280    /// `x · wᵀ` (MLX's default) rather than `x · w`.
281    pub transpose: bool,
282    /// The right indices are sorted (rows grouped by expert), so one kernel can walk each
283    /// expert's weights once.
284    pub sorted_indices: bool,
285    /// nvfp4 only: one float32 scale per expert (`w.weight`'s batch shape), multiplying the
286    /// expert's decoded weights by `global_scale / (448 · 6)`. Needs the right indices.
287    pub global_scale: Option<&'a Array>,
288}
289
290impl Default for GatherQmm<'_> {
291    fn default() -> Self {
292        Self {
293            transpose: true,
294            sorted_indices: false,
295            global_scale: None,
296        }
297    }
298}
299
300impl<'a> GatherQmm<'a> {
301    /// Whether a gather of `rows` sorted index rows over `experts` weight batches takes the
302    /// grouped kernel (each expert's rows in tiles), rather than one product per row. Two
303    /// gathers that take the same kernel give a row the same bits, whatever other rows are
304    /// gathered with it: a caller batching gathers keeps each one's bits when its rows and
305    /// the batch's agree here.
306    pub fn groups(rows: usize, experts: usize) -> bool {
307        grouped(rows, experts)
308    }
309
310    /// Whether the weights are used transposed.
311    #[must_use]
312    pub fn transpose(self, transpose: bool) -> Self {
313        Self { transpose, ..self }
314    }
315
316    /// Whether the right indices are sorted.
317    #[must_use]
318    pub fn sorted_indices(self, sorted_indices: bool) -> Self {
319        Self {
320            sorted_indices,
321            ..self
322        }
323    }
324
325    /// The per-expert nvfp4 global scale.
326    #[must_use]
327    pub fn global_scale(self, global_scale: &'a Array) -> Self {
328        Self {
329            global_scale: Some(global_scale),
330            ..self
331        }
332    }
333}
334
335/// Checks the scales (and biases) against the mode, as MLX's `validate_mode_with_type`:
336/// affine needs float biases, the floating-point modes uint8 scales and no biases.
337fn check_groups(op: &str, mode: QuantMode, scales: &Array, biases: Option<&Array>) -> Result<()> {
338    let invalid = |msg: String| Err(Error::InvalidArgument(format!("{op}: {msg}")));
339    match (mode, biases) {
340        (QuantMode::Affine, None) => {
341            invalid("biases must be provided for affine quantization".into())
342        }
343        (QuantMode::Affine, Some(_)) => Ok(()),
344        (_, Some(_)) => invalid(format!(
345            "biases must be null for quantization mode '{mode}'"
346        )),
347        (_, None) if scales.dtype() != DType::U8 => invalid(format!(
348            "scale type must be uint8 but received type {}",
349            scales.dtype()
350        )),
351        (_, None) => Ok(()),
352    }
353}
354
355/// Quantizes the last axis of `w` (float16, bfloat16 or float32, at least 2-D, the last
356/// dimension divisible by the group size). Returns the packed codes (uint32), the scales and,
357/// for affine, the biases, equal to MLX's bit for bit (the floating-point modes: MLX's Metal
358/// quantizer's).
359pub(crate) fn quantize(w: &Array, quant: Quant) -> Result<(Array, Array, Option<Array>)> {
360    quant.checked("quantize")?;
361    let mut out = Array::from_op(Primitive::Quantize { quant }, vec![w.clone()])?;
362    let biases = (quant.mode == QuantMode::Affine).then(|| out.pop().expect("three outputs"));
363    let scales = out.pop().expect("scales");
364    Ok((out.pop().expect("codes"), scales, biases))
365}
366
367/// Restores the values packed by [`quantize`], in `dtype` (default: the scales' dtype for
368/// affine, bfloat16 for the floating-point modes).
369pub(crate) fn dequantize(
370    w: &Array,
371    scales: &Array,
372    biases: Option<&Array>,
373    quant: Quant,
374    dtype: Option<DType>,
375) -> Result<Array> {
376    quant.checked("dequantize")?;
377    check_groups("dequantize", quant.mode, scales, biases)?;
378    if let Some(d) = dtype
379        && d.category() != Category::Float
380    {
381        return Err(Error::InvalidArgument(format!(
382            "dequantize: only real floating types are supported but the output dtype is {d}"
383        )));
384    }
385    match biases {
386        Some(b) => {
387            let out = Array::from_op(
388                Primitive::Dequantize {
389                    quant,
390                    dtype: scales.dtype(),
391                },
392                vec![w.clone(), scales.clone(), b.clone()],
393            )?
394            .swap_remove(0);
395            match dtype {
396                Some(d) if d != out.dtype() => astype(&out, d),
397                _ => Ok(out),
398            }
399        }
400        None => {
401            let dtype = dtype.unwrap_or(DType::BF16);
402            if !matches!(dtype, DType::F16 | DType::BF16 | DType::F32) {
403                return Err(Error::unsupported_dtype("dequantize", dtype));
404            }
405            let primitive = Primitive::Dequantize { quant, dtype };
406            Ok(Array::from_op(primitive, vec![w.clone(), scales.clone()])?.swap_remove(0))
407        }
408    }
409}
410
411/// The matrix product of `x` with quantized weights, `x · dequantize(w)ᵀ` if `transpose`
412/// (MLX's default, for weights stored as `[out, in]`) or `x · dequantize(w)`, without
413/// materialising the dequantized matrix.
414///
415/// As in MLX, affine promotes `x`, `scales` and `biases` to one floating-point type; the
416/// floating-point modes keep `x`'s dtype. Batch dimensions broadcast when `x` and `w` both
417/// have more than two. A one-dimensional `x` is a row vector.
418pub(crate) fn quantized_matmul(
419    x: &Array,
420    w: &Array,
421    scales: &Array,
422    biases: Option<&Array>,
423    transpose: bool,
424    quant: Quant,
425) -> Result<Array> {
426    quant.checked("quantized_matmul")?;
427    check_groups("quantized_matmul", quant.mode, scales, biases)?;
428    let dtype = match biases {
429        Some(b) => promote_types(promote_types(x.dtype(), scales.dtype()), b.dtype()),
430        None => x.dtype(),
431    };
432    if dtype.category() != Category::Float || dtype.size() > 4 {
433        return Err(Error::unsupported_dtype("quantized_matmul", dtype));
434    }
435    let x = astype(x, dtype)?;
436    let mut groups = vec![w.clone()];
437    match biases {
438        Some(b) => groups.extend([astype(scales, dtype)?, astype(b, dtype)?]),
439        None => groups.push(scales.clone()),
440    }
441    let vector = x.ndim() == 1;
442    let mut x = if vector { expand_dims(&x, 0)? } else { x };
443    let (xn, wn) = (x.ndim(), w.ndim());
444    if wn > 2 && xn >= 2 {
445        // Broadcast the batch dimensions of every operand together (MLX broadcasts them only
446        // when both have batch dimensions; nuvec also lifts a 2-D `x` to `w`'s batch).
447        let batch =
448            view::broadcast_shapes("quantized_matmul", &x.dims()[..xn - 2], &w.dims()[..wn - 2])?;
449        let full = |a: &Array| -> Result<Array> {
450            let mut shape = batch.to_vec();
451            shape.extend_from_slice(&a.dims()[a.ndim() - 2..]);
452            broadcast_to(a, &shape)
453        };
454        x = full(&x)?;
455        for g in &mut groups {
456            *g = full(g)?;
457        }
458    }
459    let mut inputs = vec![x];
460    inputs.extend(groups);
461    let primitive = Primitive::QuantizedMatmul { quant, transpose };
462    let out = Array::from_op(primitive, inputs)?.swap_remove(0);
463    if vector {
464        squeeze(&out, &[-2])
465    } else {
466        Ok(out)
467    }
468}
469
470/// The operands of a recorded [`FastOp::GatherQmm`](crate::fast::imp::FastOp::GatherQmm):
471/// `(x, w, scales[, biases][, global_scale], lhs_indices, rhs_indices[, offsets])`.
472pub(crate) struct GatherOperands<'a> {
473    pub x: &'a Array,
474    pub w: &'a Array,
475    pub scales: &'a Array,
476    pub biases: Option<&'a Array>,
477    pub global_scale: Option<&'a Array>,
478    pub lhs: &'a Array,
479    pub rhs: &'a Array,
480    pub offsets: Option<&'a Array>,
481}
482
483impl<'a> GatherOperands<'a> {
484    /// The operand count without the optional offsets.
485    pub(crate) fn arity(mode: QuantMode, global_scale: bool) -> usize {
486        5 + usize::from(mode == QuantMode::Affine) + usize::from(global_scale)
487    }
488
489    /// Splits `inputs` (`arity` or `arity + 1` arrays, checked by the caller).
490    pub(crate) fn split(inputs: &'a [Array], mode: QuantMode, global_scale: bool) -> Self {
491        let mut i = 3;
492        let mut next = |present: bool| {
493            present.then(|| {
494                i += 1;
495                &inputs[i - 1]
496            })
497        };
498        let biases = next(mode == QuantMode::Affine);
499        let global_scale = next(global_scale);
500        let (lhs, rhs) = (&inputs[i], &inputs[i + 1]);
501        Self {
502            x: &inputs[0],
503            w: &inputs[1],
504            scales: &inputs[2],
505            biases,
506            global_scale,
507            lhs,
508            rhs,
509            offsets: inputs.get(i + 2),
510        }
511    }
512}
513
514/// Quantized matrix products of gathered batches, as MLX's `gather_qmm` (mixture-of-experts
515/// layers): output batch `i` is `x[lhs_indices[i]] · dequantize(w)[rhs_indices[i]]ᵀ` (or
516/// without the transpose), indexing the flattened batch dimensions of `x` (`[..., M, K]`)
517/// and of the weights. The indices (integers) broadcast together; either defaults to every
518/// batch of its operand in order. Without indices this is [`quantized_matmul`].
519#[allow(clippy::too_many_arguments)]
520pub(crate) fn gather_qmm(
521    x: &Array,
522    w: &Array,
523    scales: &Array,
524    biases: Option<&Array>,
525    lhs_indices: Option<&Array>,
526    rhs_indices: Option<&Array>,
527    quant: Quant,
528    options: GatherQmm<'_>,
529) -> Result<Array> {
530    let invalid = |msg: String| Err(Error::InvalidArgument(format!("gather_qmm: {msg}")));
531    quant.checked("gather_qmm")?;
532    if lhs_indices.is_none() && rhs_indices.is_none() {
533        if options.global_scale.is_some() {
534            return invalid("global scale is not supported without indices".into());
535        }
536        return quantized_matmul(x, w, scales, biases, options.transpose, quant);
537    }
538    check_groups("gather_qmm", quant.mode, scales, biases)?;
539    let dtype = match biases {
540        Some(b) => promote_types(promote_types(x.dtype(), scales.dtype()), b.dtype()),
541        None => x.dtype(),
542    };
543    if dtype.category() != Category::Float || dtype.size() > 4 {
544        return Err(Error::unsupported_dtype("gather_qmm", dtype));
545    }
546    if x.ndim() < 2 || w.ndim() < 2 || biases.is_some_and(|b| scales.dims() != b.dims()) {
547        return invalid(format!(
548            "x must be [..., M, K] and w [..., N, K'], got {:?} and {:?}",
549            x.dims(),
550            w.dims()
551        ));
552    }
553    if let Some(gs) = options.global_scale {
554        if quant.mode != QuantMode::Nvfp4 {
555            return invalid("global scale is only supported for 'nvfp4' quantization mode".into());
556        }
557        if gs.dtype() != DType::F32 {
558            return invalid(format!(
559                "global scale must have dtype float32 but got {}",
560                gs.dtype()
561            ));
562        }
563        let experts = &w.dims()[..w.ndim() - 2];
564        if gs.dims() != experts {
565            return invalid(format!(
566                "global scale must have one entry per expert with shape {experts:?} but got {:?}",
567                gs.dims()
568            ));
569        }
570    }
571    let x = astype(x, dtype)?;
572    let (lhs, rhs) = gather_indices("gather_qmm", &x, w, lhs_indices, rhs_indices)?;
573    let mut inputs = vec![x.clone(), w.clone()];
574    match biases {
575        Some(b) => inputs.extend([astype(scales, dtype)?, astype(b, dtype)?]),
576        None => inputs.push(scales.clone()),
577    }
578    inputs.extend(options.global_scale.cloned());
579    inputs.extend([lhs, rhs.clone()]);
580    // MLX's rule (`GatherQMM::eval_gpu`): the sorted kernel tiles each expert's rows, so it
581    // pays only with at least 16 rows and 4 per expert on average; fewer take one product
582    // per row. nuvec also keeps one product per row up to `ROW_INDEPENDENT_ROWS` tokens of
583    // top-8 routing, so rows stay independent (the row-independence contract).
584    let experts: usize = w.dims()[..w.ndim() - 2].iter().product();
585    let sorted = lhs_indices.is_none() && options.sorted_indices && grouped(rhs.size(), experts);
586    if let Some(offsets) = sorted_offsets(&x, w, &rhs, sorted)? {
587        inputs.push(offsets);
588    }
589    let op = crate::fast::imp::FastOp::GatherQmm {
590        quant,
591        transpose: options.transpose,
592        global_scale: options.global_scale.is_some(),
593    };
594    // Affine gathers have kernels for transposed weights only.
595    let kernel = (options.transpose || quant.mode != QuantMode::Affine)
596        && crate::fast::imp::on_gpu(dtype, x.size());
597    // With a global scale the primitive runs on the CPU too (its fallback): it refuses the
598    // global scale's derivative, as MLX's `GatherQMM`, where the graph would differentiate
599    // through it.
600    let out = if kernel || options.global_scale.is_some() {
601        Array::from_op(Primitive::Fast(op), inputs)?.swap_remove(0)
602    } else {
603        op.fallback(&inputs)?.swap_remove(0)
604    };
605    Ok(out)
606}
607
608/// The gathered product of [`FastOp::GatherQmm`](crate::fast::imp::FastOp::GatherQmm) as
609/// a graph: each operand's gathered batches, then one batched product. A global scale
610/// multiplies each expert's decoded weights by `global_scale / (448 · 6)` (MLX's point, per
611/// group before the dot product).
612pub(crate) fn gather_qmm_graph(
613    inputs: &[Array],
614    quant: Quant,
615    transpose: bool,
616    global_scale: bool,
617) -> Result<Array> {
618    let ops = GatherOperands::split(inputs, quant.mode, global_scale);
619    let rows = |a: &Array, indices: &Array| -> Result<Array> {
620        let nd = a.ndim();
621        let mut shape = vec![-1];
622        shape.extend(a.dims()[nd.saturating_sub(2)..].iter().map(|&d| d as isize));
623        crate::indexing::take(&reshape(a, &shape)?, indices, Some(0))
624    };
625    let (x, w, s) = (
626        rows(ops.x, ops.lhs)?,
627        rows(ops.w, ops.rhs)?,
628        rows(ops.scales, ops.rhs)?,
629    );
630    let Some(gs) = ops.global_scale else {
631        let b = ops.biases.map(|b| rows(b, ops.rhs)).transpose()?;
632        return quantized_matmul(&x, &w, &s, b.as_ref(), transpose, quant);
633    };
634    let factor = crate::ops::imp::divide(
635        &rows(&expand_dims(&expand_dims(gs, -1)?, -1)?, ops.rhs)?,
636        crate::fp8::E4M3_MAX * crate::fp8::E2M1_MAX,
637    )?;
638    let dense = multiply(&dequantize(&w, &s, None, quant, Some(DType::F32))?, &factor)?;
639    let dense = if transpose {
640        let n = dense.ndim();
641        let mut axes: Vec<usize> = (0..n).collect();
642        axes.swap(n - 2, n - 1);
643        crate::ops::imp::transpose(&dense, &axes)?
644    } else {
645        dense
646    };
647    let product = crate::ops::imp::matmul(&astype(&x, DType::F32)?, &dense)?;
648    astype(&product, x.dtype())
649}
650
651/// Whether a gather with `rows` index rows over `experts` weight batches takes the grouped
652/// (sorted) kernel: MLX's rule, at least 16 rows and 4 per expert; row-exact
653/// ([`crate::with_row_exact`]), more rows than the row-independent region (`ROW_TOKENS`).
654pub(crate) fn grouped(rows: usize, experts: usize) -> bool {
655    let least = if crate::row_exact() {
656        ROW_TOKENS + 1
657    } else {
658        16
659    };
660    rows >= least && rows / experts.max(1) >= 4
661}
662
663/// Gathered rows up to which every row runs as its own product: `ROW_INDEPENDENT_ROWS`
664/// tokens with up to 8 experts each.
665pub(crate) const ROW_TOKENS: usize = crate::ROW_INDEPENDENT_ROWS * 8;
666
667/// The batch indices of a gathered product, broadcast together; either defaults to every
668/// batch of its operand, in order.
669fn gather_indices(
670    op: &'static str,
671    a: &Array,
672    b: &Array,
673    lhs: Option<&Array>,
674    rhs: Option<&Array>,
675) -> Result<(Array, Array)> {
676    let indices = |x: &Array, given: Option<&Array>| -> Result<Array> {
677        let batch = &x.dims()[..x.ndim() - 2];
678        match given {
679            Some(i)
680                if i.dtype().category() == Category::Float || i.dtype() == crate::DType::Bool =>
681            {
682                Err(Error::InvalidArgument(format!(
683                    "{op}: indices must be integers"
684                )))
685            }
686            Some(i) => astype(i, crate::DType::U32),
687            None => {
688                let n: usize = batch.iter().product();
689                let all = crate::ops::imp::arange(0.0, n as f64, 1.0, crate::DType::U32)?;
690                all.try_reshape(&batch.iter().map(|&d| d as isize).collect::<Vec<_>>())
691            }
692        }
693    };
694    let (l, r) = (indices(a, lhs)?, indices(b, rhs)?);
695    let batch = view::broadcast_shapes(op, l.dims(), r.dims())?;
696    Ok((broadcast_to(&l, &batch)?, broadcast_to(&r, &batch)?))
697}
698
699/// For rows sorted by weight batch (`a` is `[rows, 1, K]`, one row per output batch), where
700/// each batch's rows start (MLX's `gather_mm_offsets`, one dispatch; MLX's grouped kernels
701/// walk every weight batch once).
702fn sorted_offsets(a: &Array, b: &Array, rhs: &Array, sorted: bool) -> Result<Option<Array>> {
703    let m = a.dims()[a.ndim() - 2];
704    if !(sorted && m == 1 && a.ndim() == 3 && rhs.ndim() == 1) {
705        return Ok(None);
706    }
707    let groups: usize = b.dims()[..b.ndim() - 2].iter().product();
708    let primitive = crate::primitive::Primitive::SortedOffsets { groups };
709    Ok(Some(
710        Array::from_op(primitive, vec![rhs.clone()])?.swap_remove(0),
711    ))
712}
713
714/// Matrix products of gathered batches, as MLX's `gather_mm` (mixture-of-experts layers in
715/// full precision): output batch `i` is `a[lhs_indices[i]] · b[rhs_indices[i]]` over the
716/// flattened batch dimensions of `a` (`[..., M, K]`) and `b` (`[..., K, N]`), with the
717/// indices broadcast together and either defaulting to every batch. `sorted_indices`
718/// promises sorted `rhs_indices`, which lets one kernel walk each batch of `b` once.
719pub(crate) fn gather_mm(
720    a: &Array,
721    b: &Array,
722    lhs_indices: Option<&Array>,
723    rhs_indices: Option<&Array>,
724    sorted_indices: bool,
725) -> Result<Array> {
726    let dtype = promote_types(a.dtype(), b.dtype());
727    if dtype.category() != Category::Float {
728        return Err(Error::unsupported_dtype("gather_mm", dtype));
729    }
730    if a.ndim() < 2 || b.ndim() < 2 || a.dims()[a.ndim() - 1] != b.dims()[b.ndim() - 2] {
731        return Err(Error::InvalidArgument(format!(
732            "gather_mm: a must be [..., M, K] and b [..., K, N], got {:?} and {:?}",
733            a.dims(),
734            b.dims()
735        )));
736    }
737    let (a, b) = (astype(a, dtype)?, astype(b, dtype)?);
738    let (lhs, rhs) = gather_indices("gather_mm", &a, &b, lhs_indices, rhs_indices)?;
739    let mut inputs = vec![a.clone(), b.clone(), lhs, rhs.clone()];
740    let grouped = rhs.size() > ROW_TOKENS;
741    if let Some(offsets) = sorted_offsets(
742        &a,
743        &b,
744        &rhs,
745        lhs_indices.is_none() && sorted_indices && grouped,
746    )? {
747        inputs.push(offsets);
748    }
749    Ok(
750        crate::fast::imp::record_or_fallback(crate::fast::imp::FastOp::GatherMm, inputs)?
751            .swap_remove(0),
752    )
753}