Skip to main content

rlx_cpu/thunk/ops/
quant.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4#![allow(unsafe_op_in_unsafe_fn)]
5use crate::thunk::*;
6
7/// Fused dequant + matmul (plan #5). Int8-blockwise weights: each
8/// `block_size` consecutive elements of a column share one f32
9/// scale (and optionally a zero-point). The dequant happens inside
10/// the inner accumulate so the f32 weight is never materialized.
11///
12/// `w_bytes` is the row-major i8 weight matrix `[k, n]`. `scales`
13/// and `zps` are `[k/block, n]`. When `asym=false`, `zps` may be
14/// empty.
15///
16/// Today this is the reference scalar implementation — the win is
17/// memory bandwidth, not flops, since LLM weights dominate the
18/// working set. A NEON SIMD path that loads 16 i8 → splat-scale →
19/// fused-multiply-add is the natural follow-on.
20#[allow(clippy::too_many_arguments)]
21pub fn dequant_matmul_int8(
22    x: &[f32],       // [m, k]
23    w_bytes: &[i8],  // [k, n]
24    scales: &[f32],  // [k/block, n]
25    zps: &[f32],     // [k/block, n] or empty
26    out: &mut [f32], // [m, n]
27    m: usize,
28    k: usize,
29    n: usize,
30    block_size: usize,
31    asym: bool,
32) {
33    let blocks_per_col = k.div_ceil(block_size);
34    // m>1 (GEMM: block-verify / prefill): the fused loop's inner access `w[p*n+j]`
35    // is stride-n (column of a [k,n] row-major matrix) — cache-hostile and
36    // unvectorizable. Dequant once to a contiguous f32 [k,n] and route through
37    // Accelerate `sgemm` → the AMX coprocessor (weight is reused across all m rows,
38    // so the dequant amortizes). m==1 (decode GEMV) keeps the fused path below —
39    // memory-lean (streams the packed int8 once, no k·n·4 f32 materialization).
40    if m > 1 {
41        let mut w_f = vec![0f32; k * n];
42        for p in 0..k {
43            let b = p / block_size;
44            for j in 0..n {
45                let z = if asym { zps[b * n + j] } else { 0.0 };
46                w_f[p * n + j] = (w_bytes[p * n + j] as f32 - z) * scales[b * n + j];
47            }
48        }
49        crate::blas::sgemm(x, &w_f, out, m, k, n);
50        return;
51    }
52    for i in 0..m {
53        let x_row = &x[i * k..i * k + k];
54        for j in 0..n {
55            // Accumulate one block at a time and fold in that block's scale
56            // ONCE (not per element): fewer multiplies, and a blocked sum that
57            // is better conditioned than a single k-length running f32 accum
58            // (the block scale no longer stretches the dynamic range mid-sum).
59            // Pure reassociation of the same terms — no extra precision loss,
60            // strictly less rounding.
61            let mut acc = 0f32;
62            for b in 0..blocks_per_col {
63                let s = scales[b * n + j];
64                let z = if asym { zps[b * n + j] } else { 0.0 };
65                let lo = b * block_size;
66                let hi = (lo + block_size).min(k);
67                let mut bacc = 0f32;
68                for p in lo..hi {
69                    let q = w_bytes[p * n + j] as f32;
70                    bacc += x_row[p] * (q - z);
71                }
72                acc += bacc * s;
73            }
74            out[i * n + j] = acc;
75        }
76    }
77}
78
79/// Codebook weight-synthesis matmul (single-level vector quantization).
80///
81/// The weight is stored transposed (`[n, k]`, GGUF "bt" layout) as codebook
82/// indices: `indices[j, kb]` selects centroid `codebook[indices[j,kb]] ∈
83/// ℝ^{entry_dim}`, which reconstructs the `entry_dim` weights at
84/// `W[j, kb·entry_dim .. (kb+1)·entry_dim]`. Output is `y = x · Wᵀ`, i.e.
85/// `out[i, j] = Σ_p x[i, p] · W[j, p]`. Like the dequant kernels, the win is
86/// weight-read bandwidth: the centroid is expanded inside the accumulate and
87/// the `k·n` f32 weight is never materialized (on the decode GEMV path).
88///
89/// Requires `k % entry_dim == 0` (guaranteed by the op's shape contract).
90#[allow(clippy::too_many_arguments)]
91pub fn synth_matmul_codebook(
92    x: &[f32],        // [m, k]
93    indices: &[u8],   // [n, k/entry_dim]
94    codebook: &[f32], // [num_entries, entry_dim]
95    out: &mut [f32],  // [m, n]
96    m: usize,
97    k: usize,
98    n: usize,
99    entry_dim: usize,
100) {
101    let kb_per_row = k / entry_dim.max(1); // codebook blocks per output column
102    // m>1 (GEMM/prefill): reconstruct the dense weight once and route through
103    // Accelerate `sgemm` — the codebook lookup amortizes across all m rows
104    // (same trade-off as `dequant_matmul_int8`'s m>1 branch).
105    if m > 1 {
106        // Reconstruct Wᵀ as [n, k] row-major — CONTIGUOUS writes (one
107        // `copy_from_slice` per codebook block), matching the [n, k/entry_dim]
108        // index layout. The earlier [k, n] layout scattered writes with stride
109        // n (cache-hostile, measured ~30× slower at real sizes). `sgemm_bt`
110        // then computes out = x · Wᵀ directly from the [n, k] weight.
111        let mut w_nk = vec![0f32; n * k];
112        for j in 0..n {
113            let wj = &mut w_nk[j * k..j * k + k];
114            let row = &indices[j * kb_per_row..j * kb_per_row + kb_per_row];
115            for (kb, &code) in row.iter().enumerate() {
116                let c = code as usize * entry_dim;
117                let base = kb * entry_dim;
118                wj[base..base + entry_dim].copy_from_slice(&codebook[c..c + entry_dim]);
119            }
120        }
121        crate::blas::sgemm_bt(x, &w_nk, out, m, k, n, 1.0);
122        return;
123    }
124    // m==1 (decode GEMV): stream the codes, reconstruct in-loop — the packed
125    // indices are read once, the k·n f32 weight is never built.
126    for i in 0..m {
127        let x_row = &x[i * k..i * k + k];
128        for j in 0..n {
129            let row = &indices[j * kb_per_row..j * kb_per_row + kb_per_row];
130            let mut acc = 0f32;
131            for (kb, &code) in row.iter().enumerate() {
132                let c = code as usize * entry_dim;
133                let cb = &codebook[c..c + entry_dim];
134                let base = kb * entry_dim;
135                for (t, &w) in cb.iter().enumerate() {
136                    acc += x_row[base + t] * w;
137                }
138            }
139            out[i * n + j] = acc;
140        }
141    }
142}
143
144/// Lower an `Op::SynthMatMul` node to a `Thunk::SynthMatMul` (resolve arena
145/// offsets + derive m/k/n from operand shapes).
146pub(crate) fn compile_synth_mat_mul(
147    node: &rlx_ir::Node,
148    graph: &Graph,
149    arena: &crate::arena::Arena,
150) -> Thunk {
151    let Op::SynthMatMul { kind } = &node.op else {
152        unreachable!()
153    };
154    let rlx_ir::SynthKind::Codebook {
155        entry_dim,
156        num_entries,
157    } = kind;
158    let n = node.shape.dim(node.shape.rank() - 1).unwrap_static();
159    let total = node.shape.num_elements().unwrap();
160    let m = total / n.max(1);
161    let x_total = graph.node(node.inputs[0]).shape.num_elements().unwrap();
162    let k = x_total / m.max(1);
163    Thunk::SynthMatMul {
164        x: node_offset(arena, node.inputs[0]),
165        indices: node_offset(arena, node.inputs[1]),
166        codebook: node_offset(arena, node.inputs[2]),
167        dst: node_offset(arena, node.id),
168        m: m as u32,
169        k: k as u32,
170        n: n as u32,
171        entry_dim: *entry_dim,
172        num_entries: *num_entries,
173    }
174}
175
176/// Interpreter-path execution of `Thunk::SynthMatMul`.
177#[inline(always)]
178pub(crate) fn exec_synth_mat_mul(t: &Thunk, base: *mut u8) {
179    let Thunk::SynthMatMul {
180        x,
181        indices,
182        codebook,
183        dst,
184        m,
185        k,
186        n,
187        entry_dim,
188        num_entries,
189    } = t
190    else {
191        unreachable!()
192    };
193    let (m, k, n, d) = (*m as usize, *k as usize, *n as usize, *entry_dim as usize);
194    let kb_per_row = k / d.max(1);
195    unsafe {
196        let xs = sl(*x, base, m * k);
197        let idx = std::slice::from_raw_parts(base.add(*indices) as *const u8, n * kb_per_row);
198        let cb = sl(*codebook, base, *num_entries as usize * d);
199        let out = sl_mut(*dst, base, m * n);
200        synth_matmul_codebook(xs, idx, cb, out, m, k, n, d);
201    }
202}
203
204#[allow(clippy::too_many_arguments)]
205pub(crate) fn dequant_matmul_int4(
206    x: &[f32],
207    w_bytes: &[u8],
208    scales: &[f32],
209    zps: &[f32],
210    out: &mut [f32],
211    m: usize,
212    k: usize,
213    n: usize,
214    block_size: usize,
215    asym: bool,
216) {
217    let blocks_per_col = k.div_ceil(block_size);
218    // m>1: dequant once to contiguous f32 [k,n] + AMX sgemm (see dequant_matmul_int8).
219    // m==1 keeps the memory-lean fused nibble path (streams packed int4, k·n/2 bytes).
220    if m > 1 {
221        let mut w_f = vec![0f32; k * n];
222        for p in 0..k {
223            let b = p / block_size;
224            for j in 0..n {
225                let elem = p * n + j;
226                let nibble = if elem & 1 == 0 {
227                    w_bytes[elem / 2] & 0x0F
228                } else {
229                    w_bytes[elem / 2] >> 4
230                };
231                let z = if asym { zps[b * n + j] } else { 0.0 };
232                w_f[elem] = (nibble as f32 - z) * scales[b * n + j];
233            }
234        }
235        crate::blas::sgemm(x, &w_f, out, m, k, n);
236        return;
237    }
238    for i in 0..m {
239        let x_row = &x[i * k..i * k + k];
240        for j in 0..n {
241            // Block-at-a-time accumulation with the scale folded in once per
242            // block (see dequant_matmul_int8). Reassociation only.
243            let mut acc = 0f32;
244            for b in 0..blocks_per_col {
245                let s = scales[b * n + j];
246                let z = if asym { zps[b * n + j] } else { 0.0 };
247                let lo = b * block_size;
248                let hi = (lo + block_size).min(k);
249                let mut bacc = 0f32;
250                for p in lo..hi {
251                    let elem = p * n + j;
252                    let byte_idx = elem / 2;
253                    let nibble = if elem & 1 == 0 {
254                        w_bytes[byte_idx] & 0x0F
255                    } else {
256                        w_bytes[byte_idx] >> 4
257                    };
258                    bacc += x_row[p] * (nibble as f32 - z);
259                }
260                acc += bacc * s;
261            }
262            out[i * n + j] = acc;
263        }
264    }
265}
266
267pub(crate) fn fp8_e4m3_to_f32(b: u8) -> f32 {
268    let sign = if b & 0x80 != 0 { -1.0 } else { 1.0 };
269    let exp = (b >> 3) & 0x0F;
270    let mant = b & 0x07;
271    if exp == 0 {
272        if mant == 0 {
273            return 0.0;
274        }
275        return sign * (mant as f32) * 2f32.powi(-9);
276    }
277    if exp == 0x0F {
278        return if mant == 0 {
279            sign * f32::INFINITY
280        } else {
281            f32::NAN
282        };
283    }
284    sign * (1.0 + mant as f32 / 8.0) * 2f32.powi(exp as i32 - 7)
285}
286
287pub(crate) fn fp8_e5m2_to_f32(b: u8) -> f32 {
288    let sign = if b & 0x80 != 0 { -1.0 } else { 1.0 };
289    let exp = (b >> 2) & 0x1F;
290    let mant = b & 0x03;
291    if exp == 0 {
292        if mant == 0 {
293            return 0.0;
294        }
295        return sign * (mant as f32) * 2f32.powi(-16);
296    }
297    if exp == 0x1F {
298        return if mant == 0 {
299            sign * f32::INFINITY
300        } else {
301            f32::NAN
302        };
303    }
304    sign * (1.0 + mant as f32 / 4.0) * 2f32.powi(exp as i32 - 15)
305}
306
307#[allow(clippy::too_many_arguments)]
308pub(crate) fn dequant_matmul_fp8(
309    x: &[f32],
310    w_bytes: &[u8],
311    scales: &[f32],
312    out: &mut [f32],
313    m: usize,
314    k: usize,
315    n: usize,
316    e5m2: bool,
317) {
318    let dequant = if e5m2 {
319        fp8_e5m2_to_f32
320    } else {
321        fp8_e4m3_to_f32
322    };
323    // m>1: dequant once to contiguous f32 [k,n] + AMX sgemm (per-column scale folded
324    // in). m==1 keeps the fused path below.
325    if m > 1 {
326        let mut w_f = vec![0f32; k * n];
327        for p in 0..k {
328            for j in 0..n {
329                let s = scales.get(j).copied().unwrap_or(1.0);
330                w_f[p * n + j] = dequant(w_bytes[p * n + j]) * s;
331            }
332        }
333        crate::blas::sgemm(x, &w_f, out, m, k, n);
334        return;
335    }
336    for i in 0..m {
337        let x_row = &x[i * k..i * k + k];
338        for j in 0..n {
339            // The per-column scale is loop-invariant: read it once and apply it
340            // to the finished dot instead of multiplying every term (the old
341            // code even re-fetched `scales.get(j)` on every k iteration). Fewer
342            // multiplies, one less rounding per element.
343            let s = scales.get(j).copied().unwrap_or(1.0);
344            let mut acc = 0f32;
345            for p in 0..k {
346                let w = dequant(w_bytes[p * n + j]);
347                acc += x_row[p] * w;
348            }
349            out[i * n + j] = acc * s;
350        }
351    }
352}
353
354#[allow(clippy::too_many_arguments)]
355pub fn dequant_matmul_nvfp4(
356    x: &[f32],
357    w_bytes: &[u8],
358    scale_bytes: &[u8],
359    global_scale: f32,
360    out: &mut [f32],
361    m: usize,
362    k: usize,
363    n: usize,
364) {
365    use rlx_ir::{NVFP4_GROUP_SIZE, fp4_e2m1_to_f32, fp8_e4m3_scale_to_f32};
366    let gs = NVFP4_GROUP_SIZE;
367    let n_blocks = k.div_ceil(gs);
368    // m>1: dequant once to contiguous f32 [k,n] + AMX sgemm (per-group E4M3 scale +
369    // global scale folded in). m==1 keeps the fused nibble path below.
370    if m > 1 {
371        let mut w_f = vec![0f32; k * n];
372        for p in 0..k {
373            let b = p / gs;
374            for j in 0..n {
375                let scale = fp8_e4m3_scale_to_f32(scale_bytes[b * n + j]) * global_scale;
376                let elem = p * n + j;
377                let nibble = if elem & 1 == 0 {
378                    w_bytes[elem / 2] & 0x0F
379                } else {
380                    w_bytes[elem / 2] >> 4
381                };
382                w_f[elem] = fp4_e2m1_to_f32(nibble) * scale;
383            }
384        }
385        crate::blas::sgemm(x, &w_f, out, m, k, n);
386        return;
387    }
388    for i in 0..m {
389        let x_row = &x[i * k..i * k + k];
390        for j in 0..n {
391            // Fold the per-group E4M3 scale in once per group, and the single
392            // global scale in once at the end, instead of multiplying both into
393            // every element. Reassociation only — strictly fewer roundings.
394            let mut acc = 0f32;
395            for b in 0..n_blocks {
396                let scale = fp8_e4m3_scale_to_f32(scale_bytes[b * n + j]);
397                let lo = b * gs;
398                let hi = (lo + gs).min(k);
399                let mut bacc = 0f32;
400                for p in lo..hi {
401                    let elem = p * n + j;
402                    let byte_idx = elem / 2;
403                    let nibble = if elem & 1 == 0 {
404                        w_bytes[byte_idx] & 0x0F
405                    } else {
406                        w_bytes[byte_idx] >> 4
407                    };
408                    bacc += x_row[p] * fp4_e2m1_to_f32(nibble);
409                }
410                acc += bacc * scale;
411            }
412            out[i * n + j] = acc * global_scale;
413        }
414    }
415}
416
417/// MxFp4x2 (two-level residual E2M1) DequantMatMul reference — the CPU decode
418/// path for `QuantScheme::MxFp4x2Block`. `w_bytes` = `[plane0 | plane1]` (each
419/// E2M1 nibbles packed 2/byte, `[k,n]` row-major); `scale_bytes` = `[s0 | s1]`
420/// f32 per `(block = k/group, n)`. `out = x · (s0·LUT[q0] + s1·LUT[q1])`.
421#[allow(clippy::too_many_arguments)]
422pub fn dequant_matmul_mxfp4x2(
423    x: &[f32],
424    w_bytes: &[u8],
425    scale_bytes: &[u8],
426    group: usize,
427    out: &mut [f32],
428    m: usize,
429    k: usize,
430    n: usize,
431) {
432    use rlx_ir::fp4_e2m1_to_f32;
433    let plane = (k * n).div_ceil(2); // bytes per nibble plane
434    let g = group.max(1);
435    let nblk = k.div_ceil(g);
436    let sbytes = nblk * n * 4; // bytes per f32 scale set
437    let rd_scale = |half: usize, idx: usize| -> f32 {
438        let o = half + idx * 4;
439        f32::from_le_bytes([
440            scale_bytes[o],
441            scale_bytes[o + 1],
442            scale_bytes[o + 2],
443            scale_bytes[o + 3],
444        ])
445    };
446    let nib = |plane_off: usize, elem: usize| -> u8 {
447        let b = w_bytes[plane_off + elem / 2];
448        if elem & 1 == 0 { b & 0x0F } else { b >> 4 }
449    };
450    for i in 0..m {
451        let x_row = &x[i * k..i * k + k];
452        for j in 0..n {
453            // Accumulate the two residual planes' dot products per block, then
454            // fold each plane's block scale in once (s0·Σq0 + s1·Σq1) rather
455            // than scaling every element. Reassociation only.
456            let mut acc = 0f32;
457            for b in 0..nblk {
458                let s0 = rd_scale(0, b * n + j);
459                let s1 = rd_scale(sbytes, b * n + j);
460                let lo = b * g;
461                let hi = (lo + g).min(k);
462                let mut acc0 = 0f32;
463                let mut acc1 = 0f32;
464                for p in lo..hi {
465                    let elem = p * n + j;
466                    let xv = x_row[p];
467                    acc0 += xv * fp4_e2m1_to_f32(nib(0, elem));
468                    acc1 += xv * fp4_e2m1_to_f32(nib(plane, elem));
469                }
470                acc += acc0 * s0 + acc1 * s1;
471            }
472            out[i * n + j] = acc;
473        }
474    }
475}
476
477// ── Native low-precision (FP8/FP6/FP4) scaled GEMM — CPU reference oracle ──
478//
479// Decode-and-accumulate *reference* for `Op::ScaledMatMul` + its quantize
480// producers. CPUs have no fp8 matrix units; correctness, not speed, is the
481// point — every GPU backend's native tensor-core path is checked against these.
482// Layout is TN: lhs [m,k], rhs [n,k] (K-last), out = lhs·rhsᵀ. Block scales
483// (when any) run along the last/contraction axis of each operand.
484
485/// Blocks along a `len`-element axis (1 for per-tensor).
486#[inline]
487pub(crate) fn lowp_nblk(len: usize, layout: rlx_ir::ScaleLayout) -> usize {
488    match layout {
489        rlx_ir::ScaleLayout::PerTensor => 1,
490        _ => len.div_ceil(layout.block() as usize),
491    }
492}
493
494/// Snap a raw f32 scale to the grid storable for `layout`, so quantizer and
495/// matmul agree bit-for-bit on the reconstructed value.
496#[inline]
497pub(crate) fn lowp_snap_scale(layout: rlx_ir::ScaleLayout, s: f32) -> f32 {
498    use rlx_ir::lowp_codec;
499    match layout {
500        rlx_ir::ScaleLayout::PerTensor => s,
501        rlx_ir::ScaleLayout::BlockMxE8M0 { .. } => {
502            lowp_codec::e8m0_to_f32(lowp_codec::f32_to_e8m0(s))
503        }
504        rlx_ir::ScaleLayout::Nvfp4 { .. } => lowp_codec::decode(
505            rlx_ir::ScaledFormat::F8E4M3,
506            lowp_codec::encode(rlx_ir::ScaledFormat::F8E4M3, s),
507        ),
508    }
509}
510
511/// Scale for element (`free`, `contract`) given decoded raw scales.
512#[inline]
513pub(crate) fn lowp_scale_at(
514    layout: rlx_ir::ScaleLayout,
515    scales: &[f32],
516    free: usize,
517    contract: usize,
518    nblk: usize,
519) -> f32 {
520    match layout {
521        rlx_ir::ScaleLayout::PerTensor => scales.first().copied().unwrap_or(1.0),
522        _ => scales[free * nblk + contract / layout.block() as usize],
523    }
524}
525
526/// Compute (snapped) raw f32 scales for `x` (`[rows, cols]`, blocks along
527/// `cols` = contraction). PerTensor → 1 value; block → `rows*nblk` row-major.
528pub(crate) fn lowp_compute_scales(
529    x: &[f32],
530    fmt: rlx_ir::ScaledFormat,
531    layout: rlx_ir::ScaleLayout,
532    rows: usize,
533    cols: usize,
534) -> Vec<f32> {
535    let maxf = fmt.max_finite();
536    let to_scale = |amax: f32| if amax > 0.0 { amax / maxf } else { 1.0 };
537    match layout {
538        rlx_ir::ScaleLayout::PerTensor => {
539            let amax = x.iter().fold(0.0f32, |a, &v| a.max(v.abs()));
540            vec![to_scale(amax)]
541        }
542        _ => {
543            let block = layout.block() as usize;
544            let nblk = cols.div_ceil(block);
545            let mut out = vec![1.0f32; rows * nblk];
546            for r in 0..rows {
547                for b in 0..nblk {
548                    let lo = b * block;
549                    let hi = (lo + block).min(cols);
550                    let mut amax = 0.0f32;
551                    for c in lo..hi {
552                        amax = amax.max(x[r * cols + c].abs());
553                    }
554                    out[r * nblk + b] = lowp_snap_scale(layout, to_scale(amax));
555                }
556            }
557            out
558        }
559    }
560}
561
562/// Quantize `x` (`[rows, cols]`, blocks along cols) to packed codes using the
563/// already-snapped, decoded raw `scales`.
564pub(crate) fn lowp_quantize(
565    x: &[f32],
566    scales: &[f32],
567    fmt: rlx_ir::ScaledFormat,
568    layout: rlx_ir::ScaleLayout,
569    rows: usize,
570    cols: usize,
571    out: &mut [u8],
572) {
573    let nblk = lowp_nblk(cols, layout);
574    for r in 0..rows {
575        for c in 0..cols {
576            let s = lowp_scale_at(layout, scales, r, c, nblk);
577            let v = if s != 0.0 { x[r * cols + c] / s } else { 0.0 };
578            out[r * cols + c] = rlx_ir::lowp_codec::encode(fmt, v);
579        }
580    }
581}
582
583/// TN scaled GEMM: lhs [m,k] codes, rhs [n,k] codes, out [m,n] f32.
584#[allow(clippy::too_many_arguments)]
585pub(crate) fn lowp_scaled_matmul(
586    lhs: &[u8],
587    rhs: &[u8],
588    lhs_scales: &[f32],
589    rhs_scales: &[f32],
590    bias: Option<&[f32]>,
591    out: &mut [f32],
592    m: usize,
593    n: usize,
594    k: usize,
595    layout: rlx_ir::ScaleLayout,
596    lhs_fmt: rlx_ir::ScaledFormat,
597    rhs_fmt: rlx_ir::ScaledFormat,
598) {
599    use rlx_ir::lowp_codec::decode;
600    let nblk = lowp_nblk(k, layout);
601    // Both operands' scales are constant across a contraction block, so their
602    // product factors out: acc += (a_scale·b_scale)·Σ decode(a)·decode(b).
603    // One block spans all of k for PerTensor. Reassociation only.
604    let bs = match layout {
605        rlx_ir::ScaleLayout::PerTensor => k.max(1),
606        _ => layout.block() as usize,
607    };
608    for i in 0..m {
609        for j in 0..n {
610            let mut acc = 0f32;
611            for b in 0..nblk {
612                let lo = b * bs;
613                let hi = (lo + bs).min(k);
614                let a_scale = lowp_scale_at(layout, lhs_scales, i, lo, nblk);
615                let b_scale = lowp_scale_at(layout, rhs_scales, j, lo, nblk);
616                let mut bacc = 0f32;
617                for p in lo..hi {
618                    bacc += decode(lhs_fmt, lhs[i * k + p]) * decode(rhs_fmt, rhs[j * k + p]);
619                }
620                acc += bacc * (a_scale * b_scale);
621            }
622            out[i * n + j] = acc + bias.map_or(0.0, |bb| bb[j]);
623        }
624    }
625}
626
627/// Grouped (MoE) TN scaled GEMM — the expert-indexed [`lowp_scaled_matmul`].
628/// `input [m,k]` codes, `weight [E,n,k]` codes (one TN slab per expert),
629/// `input_scales` (`[m, nblk]` row-major), `weight_scales` (`[E·n, nblk]`
630/// row-major), `expert_idx [m]` (f32-encoded), optional per-expert
631/// `bias [E,n]`. `out[i] = decode(input[i]) · decode(weight[eidx[i]])ᵀ`.
632/// Correctness reference — one row per token against its routed expert,
633/// reusing the same block reassociation as [`lowp_scaled_matmul`].
634#[allow(clippy::too_many_arguments)]
635pub(crate) fn lowp_scaled_grouped_matmul(
636    input: &[u8],
637    weight: &[u8],
638    input_scales: &[f32],
639    weight_scales: &[f32],
640    expert_idx: &[f32],
641    bias: Option<&[f32]>,
642    out: &mut [f32],
643    m: usize,
644    n: usize,
645    k: usize,
646    num_experts: usize,
647    layout: rlx_ir::ScaleLayout,
648    lhs_fmt: rlx_ir::ScaledFormat,
649    rhs_fmt: rlx_ir::ScaledFormat,
650) {
651    use rlx_ir::lowp_codec::decode;
652    let nblk = lowp_nblk(k, layout);
653    let bs = match layout {
654        rlx_ir::ScaleLayout::PerTensor => k.max(1),
655        _ => layout.block() as usize,
656    };
657    for i in 0..m {
658        let e = expert_idx[i] as usize;
659        debug_assert!(
660            e < num_experts,
661            "scaled grouped expert_idx out of range: {e} >= {num_experts}"
662        );
663        let a_codes = &input[i * k..(i + 1) * k];
664        let w_codes = &weight[e * n * k..(e + 1) * n * k];
665        for j in 0..n {
666            let mut acc = 0f32;
667            for b in 0..nblk {
668                let lo = b * bs;
669                let hi = (lo + bs).min(k);
670                let a_scale = lowp_scale_at(layout, input_scales, i, lo, nblk);
671                let w_scale = lowp_scale_at(layout, weight_scales, e * n + j, lo, nblk);
672                let mut bacc = 0f32;
673                for p in lo..hi {
674                    bacc += decode(lhs_fmt, a_codes[p]) * decode(rhs_fmt, w_codes[j * k + p]);
675                }
676                acc += bacc * (a_scale * w_scale);
677            }
678            out[i * n + j] = acc + bias.map_or(0.0, |bb| bb[e * n + j]);
679        }
680    }
681}
682
683/// Reconstruct f32 from packed codes: `out[i] = decode(code[i]) · scale(block)`.
684/// `[rows, cols]`, blocks along cols. Inverse of [`lowp_quantize`].
685pub(crate) fn lowp_dequantize(
686    codes: &[u8],
687    scales: &[f32],
688    fmt: rlx_ir::ScaledFormat,
689    layout: rlx_ir::ScaleLayout,
690    rows: usize,
691    cols: usize,
692    out: &mut [f32],
693) {
694    use rlx_ir::lowp_codec::decode;
695    let nblk = lowp_nblk(cols, layout);
696    for r in 0..rows {
697        for c in 0..cols {
698            let s = lowp_scale_at(layout, scales, r, c, nblk);
699            out[r * cols + c] = decode(fmt, codes[r * cols + c]) * s;
700        }
701    }
702}
703
704/// Decode a stored scale tensor (f32 for per-tensor, E8M0/E4M3 bytes for block
705/// layouts) into raw f32 scales. `n` is the scale-element count.
706pub(crate) unsafe fn lowp_read_scales(
707    layout: rlx_ir::ScaleLayout,
708    base: *mut u8,
709    offset: usize,
710    n: usize,
711) -> Vec<f32> {
712    use rlx_ir::lowp_codec;
713    match layout {
714        rlx_ir::ScaleLayout::PerTensor => {
715            unsafe { std::slice::from_raw_parts(base.add(offset) as *const f32, n) }.to_vec()
716        }
717        rlx_ir::ScaleLayout::BlockMxE8M0 { .. } => {
718            let bytes = unsafe { std::slice::from_raw_parts(base.add(offset), n) };
719            bytes.iter().map(|&b| lowp_codec::e8m0_to_f32(b)).collect()
720        }
721        rlx_ir::ScaleLayout::Nvfp4 { .. } => {
722            let bytes = unsafe { std::slice::from_raw_parts(base.add(offset), n) };
723            bytes
724                .iter()
725                .map(|&b| lowp_codec::decode(rlx_ir::ScaledFormat::F8E4M3, b))
726                .collect()
727        }
728    }
729}
730
731#[allow(unused_variables)]
732pub(crate) fn compile_quantize(
733    node: &rlx_ir::Node,
734    graph: &Graph,
735    arena: &crate::arena::Arena,
736    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
737    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
738    rng: rlx_ir::RngOptions,
739) -> Thunk {
740    let Op::Quantize {
741        axis,
742        scales,
743        zero_points,
744    } = &node.op
745    else {
746        unreachable!()
747    };
748    {
749        let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
750        Thunk::Quantize {
751            x: node_offset(arena, node.inputs[0]),
752            q: node_offset(arena, node.id),
753            len: node.shape.num_elements().unwrap() as u32,
754            chan_axis: chan_axis as u32,
755            chan_dim: chan_dim as u32,
756            inner: inner as u32,
757            scales: scales.clone(),
758            zero_points: zero_points.clone(),
759        }
760    }
761}
762
763#[allow(unused_variables)]
764pub(crate) fn compile_fake_quantize(
765    node: &rlx_ir::Node,
766    graph: &Graph,
767    arena: &crate::arena::Arena,
768    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
769    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
770    rng: rlx_ir::RngOptions,
771) -> Thunk {
772    let Op::FakeQuantize {
773        bits,
774        axis,
775        ste,
776        scale_mode,
777    } = &node.op
778    else {
779        unreachable!()
780    };
781    {
782        let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
783        let state_off = match scale_mode {
784            rlx_ir::op::ScaleMode::PerBatch => None,
785            rlx_ir::op::ScaleMode::EMA { .. } | rlx_ir::op::ScaleMode::Fixed => {
786                // Second input carries the [chan_dim] scale state.
787                debug_assert_eq!(
788                    node.inputs.len(),
789                    2,
790                    "EMA/Fixed FakeQuantize needs a state input"
791                );
792                Some(node_offset(arena, node.inputs[1]))
793            }
794        };
795        Thunk::FakeQuantize {
796            x: node_offset(arena, node.inputs[0]),
797            out: node_offset(arena, node.id),
798            len: node.shape.num_elements().unwrap() as u32,
799            chan_axis: chan_axis as u32,
800            chan_dim: chan_dim as u32,
801            inner: inner as u32,
802            bits: *bits,
803            ste: *ste,
804            scale_mode: *scale_mode,
805            state_off,
806        }
807    }
808}
809
810#[allow(unused_variables)]
811pub(crate) fn compile_fake_quantize_l_s_q(
812    node: &rlx_ir::Node,
813    graph: &Graph,
814    arena: &crate::arena::Arena,
815    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
816    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
817    rng: rlx_ir::RngOptions,
818) -> Thunk {
819    let Op::FakeQuantizeLSQ { bits, axis } = &node.op else {
820        unreachable!()
821    };
822    {
823        let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
824        Thunk::FakeQuantizeLSQ {
825            x: node_offset(arena, node.inputs[0]),
826            scale_off: node_offset(arena, node.inputs[1]),
827            out: node_offset(arena, node.id),
828            len: node.shape.num_elements().unwrap() as u32,
829            chan_axis: chan_axis as u32,
830            chan_dim: chan_dim as u32,
831            inner: inner as u32,
832            bits: *bits,
833        }
834    }
835}
836
837#[allow(unused_variables)]
838pub(crate) fn compile_fake_quantize_l_s_q_backward_x(
839    node: &rlx_ir::Node,
840    graph: &Graph,
841    arena: &crate::arena::Arena,
842    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
843    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
844    rng: rlx_ir::RngOptions,
845) -> Thunk {
846    let Op::FakeQuantizeLSQBackwardX { bits, axis } = &node.op else {
847        unreachable!()
848    };
849    {
850        let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
851        Thunk::FakeQuantizeLSQBackwardX {
852            x: node_offset(arena, node.inputs[0]),
853            scale_off: node_offset(arena, node.inputs[1]),
854            dy: node_offset(arena, node.inputs[2]),
855            dx: node_offset(arena, node.id),
856            len: node.shape.num_elements().unwrap() as u32,
857            chan_axis: chan_axis as u32,
858            chan_dim: chan_dim as u32,
859            inner: inner as u32,
860            bits: *bits,
861        }
862    }
863}
864
865#[allow(unused_variables)]
866pub(crate) fn compile_fake_quantize_l_s_q_backward_scale(
867    node: &rlx_ir::Node,
868    graph: &Graph,
869    arena: &crate::arena::Arena,
870    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
871    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
872    rng: rlx_ir::RngOptions,
873) -> Thunk {
874    let Op::FakeQuantizeLSQBackwardScale { bits, axis } = &node.op else {
875        unreachable!()
876    };
877    {
878        // Output shape is [chan_dim] — node.shape doesn't
879        // describe the input data layout, but inputs[0] does.
880        let in_shape = &graph.node(node.inputs[0]).shape;
881        let (chan_axis, chan_dim, inner) = quant_layout(in_shape, *axis);
882        Thunk::FakeQuantizeLSQBackwardScale {
883            x: node_offset(arena, node.inputs[0]),
884            scale_off: node_offset(arena, node.inputs[1]),
885            dy: node_offset(arena, node.inputs[2]),
886            dscale: node_offset(arena, node.id),
887            len: in_shape.num_elements().unwrap() as u32,
888            chan_axis: chan_axis as u32,
889            chan_dim: chan_dim as u32,
890            inner: inner as u32,
891            bits: *bits,
892        }
893    }
894}
895
896#[allow(unused_variables)]
897pub(crate) fn compile_fake_quantize_backward(
898    node: &rlx_ir::Node,
899    graph: &Graph,
900    arena: &crate::arena::Arena,
901    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
902    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
903    rng: rlx_ir::RngOptions,
904) -> Thunk {
905    let Op::FakeQuantizeBackward { bits, axis, ste } = &node.op else {
906        unreachable!()
907    };
908    {
909        let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
910        Thunk::FakeQuantizeBackward {
911            x: node_offset(arena, node.inputs[0]),
912            dy: node_offset(arena, node.inputs[1]),
913            dx: node_offset(arena, node.id),
914            len: node.shape.num_elements().unwrap() as u32,
915            chan_axis: chan_axis as u32,
916            chan_dim: chan_dim as u32,
917            inner: inner as u32,
918            bits: *bits,
919            ste: *ste,
920        }
921    }
922}
923
924#[allow(unused_variables)]
925pub(crate) fn compile_dequantize(
926    node: &rlx_ir::Node,
927    graph: &Graph,
928    arena: &crate::arena::Arena,
929    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
930    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
931    rng: rlx_ir::RngOptions,
932) -> Thunk {
933    let Op::Dequantize {
934        axis,
935        scales,
936        zero_points,
937    } = &node.op
938    else {
939        unreachable!()
940    };
941    {
942        let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
943        Thunk::Dequantize {
944            q: node_offset(arena, node.inputs[0]),
945            x: node_offset(arena, node.id),
946            len: node.shape.num_elements().unwrap() as u32,
947            chan_axis: chan_axis as u32,
948            chan_dim: chan_dim as u32,
949            inner: inner as u32,
950            scales: scales.clone(),
951            zero_points: zero_points.clone(),
952        }
953    }
954}
955
956#[allow(unused_variables)]
957pub(crate) fn compile_q_mat_mul(
958    node: &rlx_ir::Node,
959    graph: &Graph,
960    arena: &crate::arena::Arena,
961    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
962    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
963    rng: rlx_ir::RngOptions,
964) -> Thunk {
965    let Op::QMatMul {
966        x_zp,
967        w_zp,
968        out_zp,
969        mult,
970    } = &node.op
971    else {
972        unreachable!()
973    };
974    {
975        let x_shape = &graph.node(node.inputs[0]).shape;
976        let w_shape = &graph.node(node.inputs[1]).shape;
977        let m = x_shape.dim(0).unwrap_static();
978        let k = x_shape.dim(1).unwrap_static();
979        let n = w_shape.dim(1).unwrap_static();
980        Thunk::QMatMul {
981            x: node_offset(arena, node.inputs[0]),
982            w: node_offset(arena, node.inputs[1]),
983            bias: node_offset(arena, node.inputs[2]),
984            out: node_offset(arena, node.id),
985            m: m as u32,
986            k: k as u32,
987            n: n as u32,
988            x_zp: *x_zp,
989            w_zp: *w_zp,
990            out_zp: *out_zp,
991            mult: *mult,
992        }
993    }
994}
995
996#[allow(unused_variables)]
997pub(crate) fn compile_q_conv2d(
998    node: &rlx_ir::Node,
999    graph: &Graph,
1000    arena: &crate::arena::Arena,
1001    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1002    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1003    rng: rlx_ir::RngOptions,
1004) -> Thunk {
1005    let Op::QConv2d {
1006        kernel_size,
1007        stride,
1008        padding,
1009        dilation,
1010        groups,
1011        x_zp,
1012        w_zp,
1013        out_zp,
1014        mult,
1015    } = &node.op
1016    else {
1017        unreachable!()
1018    };
1019    {
1020        let in_shape = &graph.node(node.inputs[0]).shape;
1021        let w_shape = &graph.node(node.inputs[1]).shape;
1022        let out_shape = &node.shape;
1023        if kernel_size.len() == 2
1024            && in_shape.rank() == 4
1025            && w_shape.rank() == 4
1026            && out_shape.rank() == 4
1027        {
1028            Thunk::QConv2d {
1029                x: node_offset(arena, node.inputs[0]),
1030                w: node_offset(arena, node.inputs[1]),
1031                bias: node_offset(arena, node.inputs[2]),
1032                out: node_offset(arena, node.id),
1033                n: in_shape.dim(0).unwrap_static() as u32,
1034                c_in: in_shape.dim(1).unwrap_static() as u32,
1035                h: in_shape.dim(2).unwrap_static() as u32,
1036                w_in: in_shape.dim(3).unwrap_static() as u32,
1037                c_out: out_shape.dim(1).unwrap_static() as u32,
1038                h_out: out_shape.dim(2).unwrap_static() as u32,
1039                w_out: out_shape.dim(3).unwrap_static() as u32,
1040                kh: kernel_size[0] as u32,
1041                kw: kernel_size[1] as u32,
1042                sh: stride.first().copied().unwrap_or(1) as u32,
1043                sw: stride.get(1).copied().unwrap_or(1) as u32,
1044                ph: padding.first().copied().unwrap_or(0) as u32,
1045                pw: padding.get(1).copied().unwrap_or(0) as u32,
1046                dh: dilation.first().copied().unwrap_or(1) as u32,
1047                dw: dilation.get(1).copied().unwrap_or(1) as u32,
1048                groups: *groups as u32,
1049                x_zp: *x_zp,
1050                w_zp: *w_zp,
1051                out_zp: *out_zp,
1052                mult: *mult,
1053            }
1054        } else {
1055            Thunk::Nop
1056        }
1057    }
1058}
1059
1060#[allow(unused_variables)]
1061pub(crate) fn compile_dequant_mat_mul(
1062    node: &rlx_ir::Node,
1063    graph: &Graph,
1064    arena: &crate::arena::Arena,
1065    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1066    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1067    rng: rlx_ir::RngOptions,
1068) -> Thunk {
1069    let Op::DequantMatMul { scheme } = &node.op else {
1070        unreachable!()
1071    };
1072    {
1073        use rlx_ir::quant::QuantScheme;
1074        let n = node.shape.dim(node.shape.rank() - 1).unwrap_static();
1075        let total = node.shape.num_elements().unwrap();
1076        let m = total / n.max(1);
1077        let x_total = graph.node(node.inputs[0]).shape.num_elements().unwrap();
1078        let k = x_total / m.max(1);
1079        if scheme.is_gguf() {
1080            Thunk::DequantMatMulGguf {
1081                x: node_offset(arena, node.inputs[0]),
1082                w_q: node_offset(arena, node.inputs[1]),
1083                dst: node_offset(arena, node.id),
1084                m: m as u32,
1085                k: k as u32,
1086                n: n as u32,
1087                scheme: *scheme,
1088            }
1089        } else {
1090            match scheme {
1091                QuantScheme::Nvfp4Block => Thunk::DequantMatMulNvfp4 {
1092                    x: node_offset(arena, node.inputs[0]),
1093                    w_q: node_offset(arena, node.inputs[1]),
1094                    scale: node_offset(arena, node.inputs[2]),
1095                    global_scale: node_offset(arena, node.inputs[3]),
1096                    dst: node_offset(arena, node.id),
1097                    m: m as u32,
1098                    k: k as u32,
1099                    n: n as u32,
1100                },
1101                QuantScheme::MxFp4x2Block { group_size } => Thunk::DequantMatMulMxFp4x2 {
1102                    x: node_offset(arena, node.inputs[0]),
1103                    w_q: node_offset(arena, node.inputs[1]),
1104                    scale: node_offset(arena, node.inputs[2]),
1105                    dst: node_offset(arena, node.id),
1106                    m: m as u32,
1107                    k: k as u32,
1108                    n: n as u32,
1109                    group: *group_size,
1110                },
1111                QuantScheme::Int4Block { block_size } => Thunk::DequantMatMulInt4 {
1112                    x: node_offset(arena, node.inputs[0]),
1113                    w_q: node_offset(arena, node.inputs[1]),
1114                    scale: node_offset(arena, node.inputs[2]),
1115                    zp: node_offset(arena, node.inputs[3]),
1116                    dst: node_offset(arena, node.id),
1117                    m: m as u32,
1118                    k: k as u32,
1119                    n: n as u32,
1120                    block_size: *block_size,
1121                    is_asymmetric: false,
1122                },
1123                QuantScheme::Fp8E4m3 => Thunk::DequantMatMulFp8 {
1124                    x: node_offset(arena, node.inputs[0]),
1125                    w_q: node_offset(arena, node.inputs[1]),
1126                    scale: node_offset(arena, node.inputs[2]),
1127                    dst: node_offset(arena, node.id),
1128                    m: m as u32,
1129                    k: k as u32,
1130                    n: n as u32,
1131                    e5m2: false,
1132                },
1133                QuantScheme::Fp8E5m2 => Thunk::DequantMatMulFp8 {
1134                    x: node_offset(arena, node.inputs[0]),
1135                    w_q: node_offset(arena, node.inputs[1]),
1136                    scale: node_offset(arena, node.inputs[2]),
1137                    dst: node_offset(arena, node.id),
1138                    m: m as u32,
1139                    k: k as u32,
1140                    n: n as u32,
1141                    e5m2: true,
1142                },
1143                QuantScheme::Int8Block { block_size } => Thunk::DequantMatMul {
1144                    x: node_offset(arena, node.inputs[0]),
1145                    w_q: node_offset(arena, node.inputs[1]),
1146                    scale: node_offset(arena, node.inputs[2]),
1147                    zp: node_offset(arena, node.inputs[3]),
1148                    dst: node_offset(arena, node.id),
1149                    m: m as u32,
1150                    k: k as u32,
1151                    n: n as u32,
1152                    block_size: *block_size,
1153                    is_asymmetric: false,
1154                },
1155                QuantScheme::Int8BlockAsym { block_size } => Thunk::DequantMatMul {
1156                    x: node_offset(arena, node.inputs[0]),
1157                    w_q: node_offset(arena, node.inputs[1]),
1158                    scale: node_offset(arena, node.inputs[2]),
1159                    zp: node_offset(arena, node.inputs[3]),
1160                    dst: node_offset(arena, node.id),
1161                    m: m as u32,
1162                    k: k as u32,
1163                    n: n as u32,
1164                    block_size: *block_size,
1165                    is_asymmetric: true,
1166                },
1167                QuantScheme::MlxAffine { .. }
1168                | QuantScheme::MlxMxfp4 { .. }
1169                | QuantScheme::MlxMxfp8 { .. } => Thunk::DequantMatMulMlx {
1170                    x: node_offset(arena, node.inputs[0]),
1171                    w_q: node_offset(arena, node.inputs[1]),
1172                    scale: node_offset(arena, node.inputs[2]),
1173                    zp: node_offset(arena, node.inputs[3]),
1174                    dst: node_offset(arena, node.id),
1175                    m: m as u32,
1176                    k: k as u32,
1177                    n: n as u32,
1178                    scheme: *scheme,
1179                },
1180                other => panic!(
1181                    "DequantMatMul on CPU supports Int8/Int4/FP8/NVFP4/MLX legacy or GGUF schemes; got {other}"
1182                ),
1183            }
1184        }
1185    }
1186}
1187
1188#[allow(unused_variables)]
1189pub(crate) fn compile_scaled_mat_mul(
1190    node: &rlx_ir::Node,
1191    graph: &Graph,
1192    arena: &crate::arena::Arena,
1193    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1194    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1195    rng: rlx_ir::RngOptions,
1196) -> Thunk {
1197    let Op::ScaledMatMul {
1198        lhs_format,
1199        rhs_format,
1200        scale_layout,
1201        has_bias,
1202    } = &node.op
1203    else {
1204        unreachable!()
1205    };
1206    {
1207        // TN: lhs [m,k], rhs [n,k], out [m,n].
1208        let n = node.shape.dim(node.shape.rank() - 1).unwrap_static();
1209        let total = node.shape.num_elements().unwrap();
1210        let m = total / n.max(1);
1211        let lhs_total = graph.node(node.inputs[0]).shape.num_elements().unwrap();
1212        let k = lhs_total / m.max(1);
1213        Thunk::ScaledMatMul {
1214            lhs: node_offset(arena, node.inputs[0]),
1215            rhs: node_offset(arena, node.inputs[1]),
1216            lhs_scale: node_offset(arena, node.inputs[2]),
1217            rhs_scale: node_offset(arena, node.inputs[3]),
1218            bias: if *has_bias {
1219                node_offset(arena, node.inputs[4])
1220            } else {
1221                0
1222            },
1223            dst: node_offset(arena, node.id),
1224            m: m as u32,
1225            k: k as u32,
1226            n: n as u32,
1227            lhs_fmt: *lhs_format,
1228            rhs_fmt: *rhs_format,
1229            layout: *scale_layout,
1230            has_bias: *has_bias,
1231        }
1232    }
1233}
1234
1235#[allow(unused_variables)]
1236pub(crate) fn compile_scaled_grouped_mat_mul(
1237    node: &rlx_ir::Node,
1238    graph: &Graph,
1239    arena: &crate::arena::Arena,
1240    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1241    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1242    rng: rlx_ir::RngOptions,
1243) -> Thunk {
1244    let Op::ScaledGroupedMatMul {
1245        lhs_format,
1246        rhs_format,
1247        scale_layout,
1248        has_bias,
1249    } = &node.op
1250    else {
1251        unreachable!()
1252    };
1253    {
1254        // input [m,k]; weight [E,n,k] (TN, K-last); out [m,n].
1255        let in_shape = &graph.node(node.inputs[0]).shape;
1256        let w_shape = &graph.node(node.inputs[1]).shape;
1257        let m = in_shape.dim(in_shape.rank() - 2).unwrap_static();
1258        let k = in_shape.dim(in_shape.rank() - 1).unwrap_static();
1259        let num_experts = w_shape.dim(0).unwrap_static();
1260        let n = w_shape.dim(w_shape.rank() - 2).unwrap_static();
1261        Thunk::ScaledGroupedMatMul {
1262            input: node_offset(arena, node.inputs[0]),
1263            weight: node_offset(arena, node.inputs[1]),
1264            input_scale: node_offset(arena, node.inputs[2]),
1265            weight_scale: node_offset(arena, node.inputs[3]),
1266            expert_idx: node_offset(arena, node.inputs[4]),
1267            bias: if *has_bias {
1268                node_offset(arena, node.inputs[5])
1269            } else {
1270                0
1271            },
1272            dst: node_offset(arena, node.id),
1273            m: m as u32,
1274            k: k as u32,
1275            n: n as u32,
1276            num_experts: num_experts as u32,
1277            lhs_fmt: *lhs_format,
1278            rhs_fmt: *rhs_format,
1279            layout: *scale_layout,
1280            has_bias: *has_bias,
1281        }
1282    }
1283}
1284
1285#[allow(unused_variables)]
1286pub(crate) fn compile_scaled_quantize(
1287    node: &rlx_ir::Node,
1288    graph: &Graph,
1289    arena: &crate::arena::Arena,
1290    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1291    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1292    rng: rlx_ir::RngOptions,
1293) -> Thunk {
1294    let Op::ScaledQuantize {
1295        format,
1296        scale_layout,
1297    } = &node.op
1298    else {
1299        unreachable!()
1300    };
1301    {
1302        let xs = &graph.node(node.inputs[0]).shape;
1303        let cols = xs.dim(xs.rank() - 1).unwrap_static();
1304        let rows = xs.num_elements().unwrap() / cols.max(1);
1305        Thunk::ScaledQuantize {
1306            x: node_offset(arena, node.inputs[0]),
1307            scale: node_offset(arena, node.inputs[1]),
1308            dst: node_offset(arena, node.id),
1309            rows: rows as u32,
1310            cols: cols as u32,
1311            fmt: *format,
1312            layout: *scale_layout,
1313        }
1314    }
1315}
1316
1317#[allow(unused_variables)]
1318pub(crate) fn compile_scaled_quant_scale(
1319    node: &rlx_ir::Node,
1320    graph: &Graph,
1321    arena: &crate::arena::Arena,
1322    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1323    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1324    rng: rlx_ir::RngOptions,
1325) -> Thunk {
1326    let Op::ScaledQuantScale {
1327        format,
1328        scale_layout,
1329    } = &node.op
1330    else {
1331        unreachable!()
1332    };
1333    {
1334        let xs = &graph.node(node.inputs[0]).shape;
1335        let cols = xs.dim(xs.rank() - 1).unwrap_static();
1336        let rows = xs.num_elements().unwrap() / cols.max(1);
1337        Thunk::ScaledQuantScale {
1338            x: node_offset(arena, node.inputs[0]),
1339            dst: node_offset(arena, node.id),
1340            rows: rows as u32,
1341            cols: cols as u32,
1342            fmt: *format,
1343            layout: *scale_layout,
1344        }
1345    }
1346}
1347
1348#[allow(unused_variables)]
1349pub(crate) fn compile_scaled_dequantize(
1350    node: &rlx_ir::Node,
1351    graph: &Graph,
1352    arena: &crate::arena::Arena,
1353    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1354    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1355    rng: rlx_ir::RngOptions,
1356) -> Thunk {
1357    let Op::ScaledDequantize {
1358        format,
1359        scale_layout,
1360    } = &node.op
1361    else {
1362        unreachable!()
1363    };
1364    {
1365        let xs = &graph.node(node.inputs[0]).shape;
1366        let cols = xs.dim(xs.rank() - 1).unwrap_static();
1367        let rows = xs.num_elements().unwrap() / cols.max(1);
1368        Thunk::ScaledDequantize {
1369            codes: node_offset(arena, node.inputs[0]),
1370            scale: node_offset(arena, node.inputs[1]),
1371            dst: node_offset(arena, node.id),
1372            rows: rows as u32,
1373            cols: cols as u32,
1374            fmt: *format,
1375            layout: *scale_layout,
1376        }
1377    }
1378}
1379
1380#[allow(unused_variables)]
1381pub(crate) fn compile_dequant_grouped_mat_mul(
1382    node: &rlx_ir::Node,
1383    graph: &Graph,
1384    arena: &crate::arena::Arena,
1385    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1386    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1387    rng: rlx_ir::RngOptions,
1388) -> Thunk {
1389    let Op::DequantGroupedMatMul { scheme } = &node.op else {
1390        unreachable!()
1391    };
1392    {
1393        let in_shape = &graph.node(node.inputs[0]).shape;
1394        let w_shape = &graph.node(node.inputs[1]).shape;
1395        let m = in_shape.dim(in_shape.rank() - 2).unwrap_static();
1396        let k_dim = in_shape.dim(in_shape.rank() - 1).unwrap_static();
1397        let out_shape = &node.shape;
1398        let n = out_shape.dim(out_shape.rank() - 1).unwrap_static();
1399        let block_elems = scheme.gguf_block_size() as usize;
1400        let block_bytes = scheme.gguf_block_bytes() as usize;
1401        let slab_bytes = (k_dim * n) / block_elems * block_bytes;
1402        let total_bytes = w_shape.num_elements().unwrap();
1403        let num_experts = total_bytes / slab_bytes.max(1);
1404        Thunk::DequantGroupedMatMulGguf {
1405            input: node_offset(arena, node.inputs[0]),
1406            w_q: node_offset(arena, node.inputs[1]),
1407            expert_idx: node_offset(arena, node.inputs[2]),
1408            dst: node_offset(arena, node.id),
1409            m: m as u32,
1410            k_dim: k_dim as u32,
1411            n: n as u32,
1412            num_experts: num_experts as u32,
1413            scheme: *scheme,
1414        }
1415    }
1416}
1417
1418#[allow(unused_variables)]
1419pub(crate) fn compile_dequant_grouped_mat_mul_mlx(
1420    node: &rlx_ir::Node,
1421    graph: &Graph,
1422    arena: &crate::arena::Arena,
1423    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1424    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1425    rng: rlx_ir::RngOptions,
1426) -> Thunk {
1427    let Op::DequantGroupedMatMulMlx { scheme } = &node.op else {
1428        unreachable!()
1429    };
1430    let in_shape = &graph.node(node.inputs[0]).shape;
1431    let m = in_shape.dim(in_shape.rank() - 2).unwrap_static();
1432    let k_dim = in_shape.dim(in_shape.rank() - 1).unwrap_static();
1433    let n = node.shape.dim(node.shape.rank() - 1).unwrap_static();
1434    // scales are `[E, n, n_groups]` — expert count is the leading dim.
1435    let scales_shape = &graph.node(node.inputs[2]).shape;
1436    let num_experts = scales_shape.dim(0).unwrap_static();
1437    let w_bytes = graph.node(node.inputs[1]).shape.num_elements().unwrap();
1438    let slab_bytes = w_bytes / num_experts.max(1);
1439    let scale_bf16 = graph.node(node.inputs[2]).shape.dtype() == rlx_ir::DType::BF16;
1440    Thunk::DequantGroupedMatMulMlx {
1441        input: node_offset(arena, node.inputs[0]),
1442        w_q: node_offset(arena, node.inputs[1]),
1443        scale: node_offset(arena, node.inputs[2]),
1444        zp: node_offset(arena, node.inputs[3]),
1445        expert_idx: node_offset(arena, node.inputs[4]),
1446        dst: node_offset(arena, node.id),
1447        m: m as u32,
1448        k_dim: k_dim as u32,
1449        n: n as u32,
1450        num_experts: num_experts as u32,
1451        slab_bytes: slab_bytes as u32,
1452        scheme: *scheme,
1453        scale_bf16,
1454    }
1455}
1456
1457/// Per row, dequant expert `idx[row]`'s MLX-affine slab and matmul the row.
1458pub(crate) fn exec_dequant_grouped_mat_mul_mlx(t: &Thunk, base: *mut u8) {
1459    let Thunk::DequantGroupedMatMulMlx {
1460        input,
1461        w_q,
1462        scale,
1463        zp,
1464        expert_idx,
1465        dst,
1466        m,
1467        k_dim,
1468        n,
1469        num_experts,
1470        slab_bytes,
1471        scheme,
1472        scale_bf16,
1473    } = t
1474    else {
1475        unreachable!()
1476    };
1477    unsafe {
1478        exec_dequant_grouped_mat_mul_mlx_inner(
1479            base,
1480            *input,
1481            *w_q,
1482            *scale,
1483            *zp,
1484            *expert_idx,
1485            *dst,
1486            *m as usize,
1487            *k_dim as usize,
1488            *n as usize,
1489            *num_experts as usize,
1490            *slab_bytes as usize,
1491            *scheme,
1492            *scale_bf16,
1493        );
1494    }
1495}
1496
1497/// Offset-based core shared by the CPU thunk and the GPU backends'
1498/// host-delegate path ([`execute_dequant_grouped_matmul_mlx_f32`]).
1499#[allow(clippy::too_many_arguments)]
1500unsafe fn exec_dequant_grouped_mat_mul_mlx_inner(
1501    base: *mut u8,
1502    input: usize,
1503    w_q: usize,
1504    scale: usize,
1505    zp: usize,
1506    expert_idx: usize,
1507    dst: usize,
1508    m: usize,
1509    k: usize,
1510    n: usize,
1511    ne: usize,
1512    slab: usize,
1513    scheme: rlx_ir::quant::QuantScheme,
1514    scale_bf16: bool,
1515) {
1516    // Group size is scheme-carried; MXFP4 experts share the same 5-input op as
1517    // affine — the loader pre-decodes MXFP4 E8M0 scales to f32 and zeroes the
1518    // bias slab, so the arena layout (f32 scales/biases per group) is uniform;
1519    // only the per-row code decode differs (FP4 LUT vs `code·scale+bias`).
1520    let gs = match scheme {
1521        rlx_ir::quant::QuantScheme::MlxAffine { group_size, .. }
1522        | rlx_ir::quant::QuantScheme::MlxMxfp4 { group_size } => group_size as usize,
1523        other => panic!("DequantGroupedMatMulMlx: expected MlxAffine/MlxMxfp4, got {other:?}"),
1524    };
1525    let n_groups = k / gs;
1526    let sb_per_expert = n * n_groups; // scales/biases f32 per expert
1527    unsafe {
1528        let inp = sl(input, base, m * k);
1529        let ids = sl(expert_idx, base, m);
1530        let out = sl_mut(dst, base, m * n);
1531        let wt = std::slice::from_raw_parts(base.add(w_q) as *const u8, ne * slab);
1532        // BF16 scales/biases (half the arena of f32): decode expert `e`'s slab on
1533        // the fly. Byte length is 2× the f32 element count. Otherwise read as f32.
1534        let (scl, zpb, scl_b16, zpb_b16) = if scale_bf16 {
1535            let sb =
1536                std::slice::from_raw_parts(base.add(scale) as *const u8, ne * sb_per_expert * 2);
1537            let zb = std::slice::from_raw_parts(base.add(zp) as *const u8, ne * sb_per_expert * 2);
1538            (&[][..], &[][..], Some(sb), Some(zb))
1539        } else {
1540            (
1541                sl(scale, base, ne * sb_per_expert),
1542                sl(zp, base, ne * sb_per_expert),
1543                None,
1544                None,
1545            )
1546        };
1547        let bf16_slab = |bytes: &[u8], e: usize| -> Vec<f32> {
1548            bytes[e * sb_per_expert * 2..(e + 1) * sb_per_expert * 2]
1549                .chunks_exact(2)
1550                .map(|c| half::bf16::from_le_bytes([c[0], c[1]]).to_f32())
1551                .collect()
1552        };
1553        for r in 0..m {
1554            let e = (ids[r] as usize).min(ne.saturating_sub(1));
1555            let w_slab = &wt[e * slab..(e + 1) * slab];
1556            let (s_owned, b_owned);
1557            let (s_slab, b_slab): (&[f32], &[f32]) =
1558                if let (Some(sb), Some(zb)) = (scl_b16, zpb_b16) {
1559                    s_owned = bf16_slab(sb, e);
1560                    b_owned = bf16_slab(zb, e);
1561                    (&s_owned, &b_owned)
1562                } else {
1563                    (
1564                        &scl[e * sb_per_expert..(e + 1) * sb_per_expert],
1565                        &zpb[e * sb_per_expert..(e + 1) * sb_per_expert],
1566                    )
1567                };
1568            let row = &inp[r * k..(r + 1) * k];
1569            let res = match scheme {
1570                rlx_ir::quant::QuantScheme::MlxAffine { bits, group_size } => {
1571                    // Fused matvec: reads the packed 2-bit codes ONCE and
1572                    // accumulates in k-order (bit-exact with the materialize
1573                    // path) — parallel over the n outputs. The old
1574                    // `dequant_matmul_affine` materialized a whole f32 expert
1575                    // weight per token (16× the traffic, single-threaded); with
1576                    // one expert dequantized per token across a 256-expert MoE,
1577                    // that was the prefill bottleneck.
1578                    rlx_mlx_io::dequant_matvec_affine(
1579                        row,
1580                        w_slab,
1581                        s_slab,
1582                        b_slab,
1583                        bits as u32,
1584                        group_size,
1585                        k,
1586                        n,
1587                    )
1588                }
1589                rlx_ir::quant::QuantScheme::MlxMxfp4 { group_size } => {
1590                    // FUSED: decode e2m1 inline, parallel over the n outputs — no
1591                    // `[n,k]` f32 weight materialized per token (was `dequant_matmul_mxfp4`
1592                    // with m=1, which allocated + decoded the whole f32 expert weight
1593                    // for every routed row). Bit-exact accumulation order.
1594                    rlx_mlx_io::dequant_matvec_mxfp4(row, w_slab, s_slab, group_size, k, n)
1595                }
1596                _ => unreachable!(),
1597            };
1598            match res {
1599                Ok(o) => out[r * n..(r + 1) * n].copy_from_slice(&o),
1600                Err(_) => out[r * n..(r + 1) * n].fill(0.0),
1601            }
1602        }
1603    }
1604}
1605
1606/// Slice-based grouped MLX-affine matmul for backends that host-delegate by
1607/// value (rlx-mlx / rlx-wgpu / rlx-cuda copy Arrays out as `Vec`s rather than
1608/// sharing the CPU arena). `x`=[m,k], `w_bytes`=`num_experts` packed slabs,
1609/// `scales`/`biases`=[num_experts, n, n_groups] f32, `idx`=[m] f32-encoded
1610/// expert ids; writes `out`=[m,n]. `x @ dequant(W_e)^T` per row.
1611#[allow(clippy::too_many_arguments)]
1612pub fn dequant_grouped_matmul_affine_bt(
1613    x: &[f32],
1614    w_bytes: &[u8],
1615    scales: &[f32],
1616    biases: &[f32],
1617    idx: &[f32],
1618    out: &mut [f32],
1619    m: usize,
1620    k: usize,
1621    n: usize,
1622    num_experts: usize,
1623    bits: u32,
1624    group_size: usize,
1625) {
1626    use rayon::prelude::*;
1627    debug_assert_eq!(out.len(), m * n, "output buffer must be m×n");
1628    let slab = w_bytes.len() / num_experts.max(1);
1629    let n_groups = k / group_size.max(1);
1630    let sb = n * n_groups; // scales/biases f32 per expert
1631    // Parallelize over routed rows. This is the GPU host-delegate MoE path (amd
1632    // Vulkan / cuda / wgpu copy the routed inputs back and run the packed grouped
1633    // matmul on the CPU) — it was a SERIAL loop, so a Vulkan stage's MoE ran ~16×
1634    // slower than the same matmul on a native CPU stage (which is rayon-parallel).
1635    // Each row dequant-matmuls its expert's slab independently → embarrassingly
1636    // parallel; write to disjoint `out` chunks.
1637    out.par_chunks_mut(n).enumerate().for_each(|(r, out_row)| {
1638        let e = (idx[r] as usize).min(num_experts.saturating_sub(1));
1639        let w_slab = &w_bytes[e * slab..(e + 1) * slab];
1640        let s_slab = &scales[e * sb..(e + 1) * sb];
1641        let b_slab = &biases[e * sb..(e + 1) * sb];
1642        let row = &x[r * k..(r + 1) * k];
1643        // FUSED matvec: accumulate straight from the packed codes, no n×k f32
1644        // materialization (that memory traffic dominated the old path). Bit-exact.
1645        match rlx_mlx_io::dequant_matvec_affine(
1646            row,
1647            w_slab,
1648            s_slab,
1649            b_slab,
1650            bits,
1651            group_size as u32,
1652            k,
1653            n,
1654        ) {
1655            Ok(o) => out_row.copy_from_slice(&o),
1656            Err(_) => out_row.fill(0.0),
1657        }
1658    });
1659}
1660
1661/// Grouped MLX **MXFP4** matmul (`x @ dequant(W_e)^T` per row) — the MXFP4 analog
1662/// of [`dequant_grouped_matmul_affine_bt`], for backends that host-delegate by value
1663/// (rlx-mlx). `w_bytes` = `num_experts` packed e2m1 slabs (`n·k/2` bytes each),
1664/// `scales` = `[num_experts, n, n_groups]` **decoded f32** (e8m0→f32; MXFP4 has no
1665/// biases), `idx` = `[m]` f32-encoded expert ids; writes `out` = `[m,n]`.
1666#[allow(clippy::too_many_arguments)]
1667pub fn dequant_grouped_matmul_mxfp4_bt(
1668    x: &[f32],
1669    w_bytes: &[u8],
1670    scales: &[f32],
1671    idx: &[f32],
1672    out: &mut [f32],
1673    m: usize,
1674    k: usize,
1675    n: usize,
1676    num_experts: usize,
1677    group_size: usize,
1678) {
1679    // FUSED: decode e2m1 codes inline into the accumulate (no per-row `[n,k]` f32
1680    // weight materialization) + parallel over ALL m·n outputs. Was: per-row
1681    // `dequant_matmul_mxfp4(...,1,k,n)`, which allocated + decoded the whole ~n·k·4 f32
1682    // expert weight FOR EVERY token and only parallelized over the m (≈3) routed rows —
1683    // the packed MoE compute cliff on the GPU workers' host-delegate path (CUDA/ROCm).
1684    rlx_mlx_io::grouped_matmul_mxfp4_bt(
1685        x,
1686        w_bytes,
1687        scales,
1688        idx,
1689        out,
1690        m,
1691        k,
1692        n,
1693        num_experts,
1694        group_size,
1695    );
1696}
1697
1698/// Host-delegate entry for GPU backends (Metal/wgpu/…) that copy the routed
1699/// inputs back and run the packed grouped MLX matmul on the CPU. Mirrors
1700/// [`execute_dequant_matmul_mlx_f32`].
1701#[allow(clippy::too_many_arguments)]
1702pub unsafe fn execute_dequant_grouped_matmul_mlx_f32(
1703    input: usize,
1704    w_q: usize,
1705    scale: usize,
1706    zp: usize,
1707    expert_idx: usize,
1708    dst: usize,
1709    m: usize,
1710    k: usize,
1711    n: usize,
1712    num_experts: usize,
1713    slab_bytes: usize,
1714    scheme: rlx_ir::quant::QuantScheme,
1715    scale_bf16: bool,
1716    base: *mut u8,
1717) {
1718    unsafe {
1719        exec_dequant_grouped_mat_mul_mlx_inner(
1720            base,
1721            input,
1722            w_q,
1723            scale,
1724            zp,
1725            expert_idx,
1726            dst,
1727            m,
1728            k,
1729            n,
1730            num_experts,
1731            slab_bytes,
1732            scheme,
1733            scale_bf16,
1734        );
1735    }
1736}
1737
1738#[allow(unused_variables)]
1739pub(crate) fn compile_dequant_mo_e_weights(
1740    node: &rlx_ir::Node,
1741    graph: &Graph,
1742    arena: &crate::arena::Arena,
1743    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1744    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1745    rng: rlx_ir::RngOptions,
1746) -> Thunk {
1747    let Op::DequantMoEWeights { scheme } = &node.op else {
1748        unreachable!()
1749    };
1750    {
1751        let w_shape = &graph.node(node.inputs[0]).shape;
1752        let out_shape = &node.shape;
1753        let num_experts = out_shape.dim(0).unwrap_static();
1754        let k_dim = out_shape.dim(1).unwrap_static();
1755        let n = out_shape.dim(2).unwrap_static();
1756        let block_elems = scheme.gguf_block_size() as usize;
1757        let block_bytes = scheme.gguf_block_bytes() as usize;
1758        let slab_bytes = (k_dim * n) / block_elems * block_bytes;
1759        let total_bytes = w_shape.num_elements().unwrap();
1760        assert_eq!(
1761            total_bytes,
1762            num_experts * slab_bytes,
1763            "DequantMoEWeights packed bytes mismatch"
1764        );
1765        Thunk::DequantMoEWeightsGguf {
1766            w_q: node_offset(arena, node.inputs[0]),
1767            dst: node_offset(arena, node.id),
1768            k_dim: k_dim as u32,
1769            n: n as u32,
1770            num_experts: num_experts as u32,
1771            scheme: *scheme,
1772        }
1773    }
1774}
1775
1776#[inline(always)]
1777pub(crate) fn exec_dequant_mat_mul(t: &Thunk, base: *mut u8) {
1778    let Thunk::DequantMatMul {
1779        x,
1780        w_q,
1781        scale,
1782        zp,
1783        dst,
1784        m,
1785        k,
1786        n,
1787        block_size,
1788        is_asymmetric,
1789    } = t
1790    else {
1791        unreachable!()
1792    };
1793    {
1794        let (m, k, n, bs) = (*m as usize, *k as usize, *n as usize, *block_size as usize);
1795        let n_blocks = k.div_ceil(bs);
1796        unsafe {
1797            let xs = sl(*x, base, m * k);
1798            let w_bytes = std::slice::from_raw_parts(base.add(*w_q) as *const i8, k * n);
1799            let scales = sl(*scale, base, n_blocks * n);
1800            let zps = if *is_asymmetric {
1801                sl(*zp, base, n_blocks * n)
1802            } else {
1803                &[][..]
1804            };
1805            let out = sl_mut(*dst, base, m * n);
1806            // Opt-in int8 W8A8 SME path (Apple M4+): quantizes activations to
1807            // int8 and runs the SMOPA GEMM on the coprocessor. Restricted to
1808            // symmetric, single-block (per-output-channel) weight quant — SMOPA
1809            // does one int32 reduction so it can't fold per-K-block scales or
1810            // asymmetric zero-points. Lossier than the f32-activation oracle, so
1811            // strictly `RLX_CPU_SME_W8A8=1`. Falls through otherwise.
1812            #[cfg(rlx_cpu_amx_sme)]
1813            if n_blocks == 1
1814                && !*is_asymmetric
1815                && crate::intrinsics::apple_amx::sme::w8a8_dispatch_enabled()
1816            {
1817                use crate::intrinsics::apple_amx::sme;
1818                if m == 1 {
1819                    // Decode/GEMV: bandwidth-bound row-wise int8 kernel (MOPA is
1820                    // catastrophic at m=1).
1821                    let (xq, sx) = sme::quantize_i8_symmetric(xs);
1822                    sme::qgemv_i8(&xq, sx, w_bytes, scales, out, k, n);
1823                    return;
1824                } else if sme::worth_sme(m, k, n) {
1825                    let (xq, sx) = sme::quantize_i8_symmetric(xs);
1826                    sme::sme_qmatmul_i8_percol(&xq, sx, w_bytes, scales, out, m, k, n);
1827                    return;
1828                }
1829            }
1830            dequant_matmul_int8(xs, w_bytes, scales, zps, out, m, k, n, bs, *is_asymmetric);
1831        }
1832    }
1833}
1834
1835#[inline(always)]
1836pub(crate) fn exec_dequant_mat_mul_gguf(t: &Thunk, base: *mut u8) {
1837    let Thunk::DequantMatMulGguf {
1838        x,
1839        w_q,
1840        dst,
1841        m,
1842        k,
1843        n,
1844        scheme,
1845    } = t
1846    else {
1847        unreachable!()
1848    };
1849    {
1850        let (m, k, n) = (*m as usize, *k as usize, *n as usize);
1851        let block_bytes = scheme.gguf_block_bytes() as usize;
1852        let block_elems = scheme.gguf_block_size() as usize;
1853        debug_assert!(
1854            block_bytes > 0 && block_elems > 0,
1855            "non-GGUF scheme in GGUF arm"
1856        );
1857        debug_assert!(
1858            (k * n).is_multiple_of(block_elems),
1859            "k*n={} not aligned to GGUF block size {}",
1860            k * n,
1861            block_elems
1862        );
1863        let total_bytes = (k * n) / block_elems * block_bytes;
1864        unsafe {
1865            let xs = sl(*x, base, m * k);
1866            let w_bytes_ptr = base.add(*w_q) as *const u8;
1867            let w_bytes = std::slice::from_raw_parts(w_bytes_ptr, total_bytes);
1868            let out = sl_mut(*dst, base, m * n);
1869            crate::gguf_matmul::gguf_matmul_bt_dispatch_at(
1870                xs, w_bytes, out, m, k, n, *scheme, *w_q,
1871            );
1872        }
1873    }
1874}
1875
1876#[inline(always)]
1877pub(crate) fn exec_dequant_mat_mul_int4(t: &Thunk, base: *mut u8) {
1878    let Thunk::DequantMatMulInt4 {
1879        x,
1880        w_q,
1881        scale,
1882        zp,
1883        dst,
1884        m,
1885        k,
1886        n,
1887        block_size,
1888        is_asymmetric,
1889    } = t
1890    else {
1891        unreachable!()
1892    };
1893    {
1894        let (m, k, n, bs) = (*m as usize, *k as usize, *n as usize, *block_size as usize);
1895        let n_blocks = k.div_ceil(bs);
1896        unsafe {
1897            let xs = sl(*x, base, m * k);
1898            let w_bytes =
1899                std::slice::from_raw_parts(base.add(*w_q) as *const u8, (k * n).div_ceil(2));
1900            let scales = sl(*scale, base, n_blocks * n);
1901            let zps = if *is_asymmetric {
1902                sl(*zp, base, n_blocks * n)
1903            } else {
1904                &[][..]
1905            };
1906            let out = sl_mut(*dst, base, m * n);
1907            dequant_matmul_int4(xs, w_bytes, scales, zps, out, m, k, n, bs, *is_asymmetric);
1908        }
1909    }
1910}
1911
1912#[inline(always)]
1913pub(crate) fn exec_dequant_mat_mul_fp8(t: &Thunk, base: *mut u8) {
1914    let Thunk::DequantMatMulFp8 {
1915        x,
1916        w_q,
1917        scale,
1918        dst,
1919        m,
1920        k,
1921        n,
1922        e5m2,
1923    } = t
1924    else {
1925        unreachable!()
1926    };
1927    {
1928        let (m, k, n) = (*m as usize, *k as usize, *n as usize);
1929        unsafe {
1930            let xs = sl(*x, base, m * k);
1931            let w_bytes = std::slice::from_raw_parts(base.add(*w_q) as *const u8, k * n);
1932            let scales = sl(*scale, base, n);
1933            let out = sl_mut(*dst, base, m * n);
1934            dequant_matmul_fp8(xs, w_bytes, scales, out, m, k, n, *e5m2);
1935        }
1936    }
1937}
1938
1939#[inline(always)]
1940pub(crate) fn exec_dequant_mat_mul_nvfp4(t: &Thunk, base: *mut u8) {
1941    let Thunk::DequantMatMulNvfp4 {
1942        x,
1943        w_q,
1944        scale,
1945        global_scale,
1946        dst,
1947        m,
1948        k,
1949        n,
1950    } = t
1951    else {
1952        unreachable!()
1953    };
1954    {
1955        let (m, k, n) = (*m as usize, *k as usize, *n as usize);
1956        let n_scale = k.div_ceil(rlx_ir::NVFP4_GROUP_SIZE) * n;
1957        unsafe {
1958            let xs = sl(*x, base, m * k);
1959            let w_bytes =
1960                std::slice::from_raw_parts(base.add(*w_q) as *const u8, (k * n).div_ceil(2));
1961            let scale_bytes = std::slice::from_raw_parts(base.add(*scale) as *const u8, n_scale);
1962            let gs = sl(*global_scale, base, 1)[0];
1963            let out = sl_mut(*dst, base, m * n);
1964            dequant_matmul_nvfp4(xs, w_bytes, scale_bytes, gs, out, m, k, n);
1965        }
1966    }
1967}
1968
1969pub(crate) fn exec_dequant_mat_mul_mxfp4x2(t: &Thunk, base: *mut u8) {
1970    let Thunk::DequantMatMulMxFp4x2 {
1971        x,
1972        w_q,
1973        scale,
1974        dst,
1975        m,
1976        k,
1977        n,
1978        group,
1979    } = t
1980    else {
1981        unreachable!()
1982    };
1983    let (m, k, n, group) = (*m as usize, *k as usize, *n as usize, *group as usize);
1984    let plane = (k * n).div_ceil(2);
1985    let nblk = k.div_ceil(group.max(1));
1986    unsafe {
1987        let xs = sl(*x, base, m * k);
1988        let w_bytes = std::slice::from_raw_parts(base.add(*w_q) as *const u8, 2 * plane);
1989        let scale_bytes =
1990            std::slice::from_raw_parts(base.add(*scale) as *const u8, 2 * nblk * n * 4);
1991        let out = sl_mut(*dst, base, m * n);
1992        dequant_matmul_mxfp4x2(xs, w_bytes, scale_bytes, group, out, m, k, n);
1993    }
1994}
1995
1996#[inline(always)]
1997pub(crate) fn exec_dequant_mat_mul_mlx(t: &Thunk, base: *mut u8) {
1998    let Thunk::DequantMatMulMlx {
1999        x,
2000        w_q,
2001        scale,
2002        zp,
2003        dst,
2004        m,
2005        k,
2006        n,
2007        scheme,
2008    } = t
2009    else {
2010        unreachable!()
2011    };
2012    unsafe {
2013        exec_dequant_mat_mul_mlx_inner(
2014            base,
2015            *x,
2016            *w_q,
2017            *scale,
2018            *zp,
2019            *dst,
2020            *m as usize,
2021            *k as usize,
2022            *n as usize,
2023            *scheme,
2024        );
2025    }
2026}
2027
2028/// Shared body for MLX DequantMatMul (compile closure + exec_dispatch).
2029pub(crate) unsafe fn exec_dequant_mat_mul_mlx_inner(
2030    base: *mut u8,
2031    x: usize,
2032    w_q: usize,
2033    scale: usize,
2034    zp: usize,
2035    dst: usize,
2036    m: usize,
2037    k: usize,
2038    n: usize,
2039    scheme: rlx_ir::quant::QuantScheme,
2040) {
2041    use rlx_ir::quant::QuantScheme;
2042    use rlx_mlx_io::{
2043        dequant_matmul_affine, dequant_mxfp4_f32, dequant_mxfp8_f32, pack_factor,
2044        validate_dequant_matmul_dims,
2045    };
2046    if let Err(e) = validate_dequant_matmul_dims(scheme, k, n, None) {
2047        panic!("DequantMatMulMlx: {e}");
2048    }
2049    let xs = unsafe { sl(x, base, m * k) };
2050    let out = unsafe { sl_mut(dst, base, m * n) };
2051    match scheme {
2052        QuantScheme::MlxAffine { bits, group_size } => {
2053            let gs = group_size as usize;
2054            let n_groups = k / gs;
2055            let pf = match pack_factor(bits as u32) {
2056                Ok(p) => p as usize,
2057                Err(e) => panic!("DequantMatMulMlx: {e}"),
2058            };
2059            let packs = gs / pf;
2060            let bpp = match bits {
2061                3 | 6 => 3,
2062                5 => 5,
2063                _ => 1,
2064            };
2065            let w_need = n * n_groups * packs * bpp;
2066            let w_bytes = unsafe { std::slice::from_raw_parts(base.add(w_q) as *const u8, w_need) };
2067            let scales = unsafe { sl(scale, base, n * n_groups) };
2068            let biases = unsafe { sl(zp, base, n * n_groups) };
2069            match dequant_matmul_affine(
2070                xs,
2071                w_bytes,
2072                scales,
2073                biases,
2074                bits as u32,
2075                group_size,
2076                m,
2077                k,
2078                n,
2079            ) {
2080                Ok(y) => out.copy_from_slice(&y),
2081                Err(e) => panic!("DequantMatMulMlx affine: {e}"),
2082            }
2083        }
2084        QuantScheme::MlxMxfp4 { group_size } => {
2085            let gs = group_size as usize;
2086            let n_groups = k / gs;
2087            let w_bytes =
2088                unsafe { std::slice::from_raw_parts(base.add(w_q) as *const u8, n * k / 2) };
2089            let scales_u8 =
2090                unsafe { std::slice::from_raw_parts(base.add(scale) as *const u8, n * n_groups) };
2091            let w_f = match dequant_mxfp4_f32(w_bytes, scales_u8, group_size, n, n_groups) {
2092                Ok(v) => v,
2093                Err(e) => panic!("DequantMatMulMlx mxfp4: {e}"),
2094            };
2095            matmul_x_wt(xs, &w_f, out, m, k, n);
2096        }
2097        QuantScheme::MlxMxfp8 { group_size } => {
2098            let gs = group_size as usize;
2099            let n_groups = k / gs;
2100            let w_bytes = unsafe { std::slice::from_raw_parts(base.add(w_q) as *const u8, n * k) };
2101            let scales_u8 =
2102                unsafe { std::slice::from_raw_parts(base.add(scale) as *const u8, n * n_groups) };
2103            let w_f = match dequant_mxfp8_f32(w_bytes, scales_u8, group_size, n, n_groups) {
2104                Ok(v) => v,
2105                Err(e) => panic!("DequantMatMulMlx mxfp8: {e}"),
2106            };
2107            matmul_x_wt(xs, &w_f, out, m, k, n);
2108        }
2109        other => panic!("DequantMatMulMlx: unexpected scheme {other}"),
2110    }
2111}
2112
2113/// `out[m,n] = x[m,k] @ w_nkᵀ` (`w_nk` is `[n,k]` row-major) for MLX dequant-matmul.
2114///
2115/// Routes through BLAS so Apple Silicon hits the **AMX matrix coprocessor**
2116/// (Accelerate `sgemm`/`sgemv`) — the fastest CPU matmul path — instead of the
2117/// scalar triple-loop this used to be. The dense f32 GGUF path (`gguf_matmul_bt_cached`)
2118/// already did dequant→`sgemm_bt`; the MLX MXFP4/MXFP8 path did not, so this was the
2119/// dominant CPU cost for MLX-quantized inference (e.g. DeepSeek-V4 MXFP4). Off-Apple
2120/// / `--no-default-features` falls back to the portable SIMD gemm in `blas.rs` with
2121/// the same calling convention. Numerically equivalent to the scalar loop up to f32
2122/// accumulation order (blocked BLAS accumulation is if anything more accurate).
2123#[inline]
2124fn matmul_x_wt(x: &[f32], w_nk: &[f32], out: &mut [f32], m: usize, k: usize, n: usize) {
2125    if m == 1 {
2126        // GEMV: out[n] = w_nk[n,k] @ x[k].
2127        crate::blas::sgemv_nn(w_nk, x, out, n, k, 1.0, 0.0);
2128    } else {
2129        // GEMM: out[m,n] = x[m,k] @ w_nk[n,k]ᵀ.
2130        crate::blas::sgemm_bt(x, w_nk, out, m, k, n, 1.0);
2131    }
2132}
2133
2134/// Host-fallback entry for MLX affine/mxfp `Op::DequantMatMul` (Metal unified memory).
2135pub unsafe fn execute_dequant_matmul_mlx_f32(
2136    x: usize,
2137    w_q: usize,
2138    scale: usize,
2139    zp: usize,
2140    dst: usize,
2141    m: usize,
2142    k: usize,
2143    n: usize,
2144    scheme: rlx_ir::quant::QuantScheme,
2145    base: *mut u8,
2146) {
2147    unsafe {
2148        exec_dequant_mat_mul_mlx_inner(base, x, w_q, scale, zp, dst, m, k, n, scheme);
2149    }
2150}
2151
2152#[inline(always)]
2153pub(crate) fn exec_scaled_mat_mul(t: &Thunk, base: *mut u8) {
2154    let Thunk::ScaledMatMul {
2155        lhs,
2156        rhs,
2157        lhs_scale,
2158        rhs_scale,
2159        bias,
2160        dst,
2161        m,
2162        k,
2163        n,
2164        lhs_fmt,
2165        rhs_fmt,
2166        layout,
2167        has_bias,
2168    } = t
2169    else {
2170        unreachable!()
2171    };
2172    {
2173        let (m, k, n) = (*m as usize, *k as usize, *n as usize);
2174        let layout = *layout;
2175        let nblk = lowp_nblk(k, layout);
2176        let per_tensor = matches!(layout, rlx_ir::ScaleLayout::PerTensor);
2177        let n_lscale = if per_tensor { 1 } else { m * nblk };
2178        let n_rscale = if per_tensor { 1 } else { n * nblk };
2179        unsafe {
2180            let lhs_b = std::slice::from_raw_parts(base.add(*lhs) as *const u8, m * k);
2181            let rhs_b = std::slice::from_raw_parts(base.add(*rhs) as *const u8, n * k);
2182            let ls = lowp_read_scales(layout, base, *lhs_scale, n_lscale);
2183            let rs = lowp_read_scales(layout, base, *rhs_scale, n_rscale);
2184            let bias_s = if *has_bias {
2185                Some(sl(*bias, base, n))
2186            } else {
2187                None
2188            };
2189            let out = sl_mut(*dst, base, m * n);
2190            lowp_scaled_matmul(
2191                lhs_b, rhs_b, &ls, &rs, bias_s, out, m, n, k, layout, *lhs_fmt, *rhs_fmt,
2192            );
2193        }
2194    }
2195}
2196
2197pub(crate) fn exec_scaled_grouped_mat_mul(t: &Thunk, base: *mut u8) {
2198    let Thunk::ScaledGroupedMatMul {
2199        input,
2200        weight,
2201        input_scale,
2202        weight_scale,
2203        expert_idx,
2204        bias,
2205        dst,
2206        m,
2207        k,
2208        n,
2209        num_experts,
2210        lhs_fmt,
2211        rhs_fmt,
2212        layout,
2213        has_bias,
2214    } = t
2215    else {
2216        unreachable!()
2217    };
2218    unsafe {
2219        execute_scaled_grouped_matmul_f32(
2220            *input,
2221            *weight,
2222            *input_scale,
2223            *weight_scale,
2224            *expert_idx,
2225            *bias,
2226            *dst,
2227            *m as usize,
2228            *k as usize,
2229            *n as usize,
2230            *num_experts as usize,
2231            *has_bias,
2232            *lhs_fmt,
2233            *rhs_fmt,
2234            *layout,
2235            base,
2236        );
2237    }
2238}
2239
2240#[inline(always)]
2241pub(crate) fn exec_scaled_quantize(t: &Thunk, base: *mut u8) {
2242    let Thunk::ScaledQuantize {
2243        x,
2244        scale,
2245        dst,
2246        rows,
2247        cols,
2248        fmt,
2249        layout,
2250    } = t
2251    else {
2252        unreachable!()
2253    };
2254    {
2255        let (rows, cols) = (*rows as usize, *cols as usize);
2256        let layout = *layout;
2257        let nblk = lowp_nblk(cols, layout);
2258        let n_scale = if matches!(layout, rlx_ir::ScaleLayout::PerTensor) {
2259            1
2260        } else {
2261            rows * nblk
2262        };
2263        unsafe {
2264            let xs = sl(*x, base, rows * cols);
2265            let scales = lowp_read_scales(layout, base, *scale, n_scale);
2266            let out = std::slice::from_raw_parts_mut(base.add(*dst), rows * cols);
2267            lowp_quantize(xs, &scales, *fmt, layout, rows, cols, out);
2268        }
2269    }
2270}
2271
2272#[inline(always)]
2273pub(crate) fn exec_scaled_quant_scale(t: &Thunk, base: *mut u8) {
2274    let Thunk::ScaledQuantScale {
2275        x,
2276        dst,
2277        rows,
2278        cols,
2279        fmt,
2280        layout,
2281    } = t
2282    else {
2283        unreachable!()
2284    };
2285    {
2286        let (rows, cols) = (*rows as usize, *cols as usize);
2287        let layout = *layout;
2288        let nblk = lowp_nblk(cols, layout);
2289        unsafe {
2290            let xs = sl(*x, base, rows * cols);
2291            let scales = lowp_compute_scales(xs, *fmt, layout, rows, cols);
2292            match layout {
2293                rlx_ir::ScaleLayout::PerTensor => {
2294                    sl_mut(*dst, base, 1)[0] = scales[0];
2295                }
2296                rlx_ir::ScaleLayout::BlockMxE8M0 { .. } => {
2297                    let out = std::slice::from_raw_parts_mut(base.add(*dst), rows * nblk);
2298                    for (o, &s) in out.iter_mut().zip(&scales) {
2299                        *o = rlx_ir::lowp_codec::f32_to_e8m0(s);
2300                    }
2301                }
2302                rlx_ir::ScaleLayout::Nvfp4 { .. } => {
2303                    let out = std::slice::from_raw_parts_mut(base.add(*dst), rows * nblk);
2304                    for (o, &s) in out.iter_mut().zip(&scales) {
2305                        *o = rlx_ir::lowp_codec::encode(rlx_ir::ScaledFormat::F8E4M3, s);
2306                    }
2307                }
2308            }
2309        }
2310    }
2311}
2312
2313#[inline(always)]
2314pub(crate) fn exec_scaled_dequantize(t: &Thunk, base: *mut u8) {
2315    let Thunk::ScaledDequantize {
2316        codes,
2317        scale,
2318        dst,
2319        rows,
2320        cols,
2321        fmt,
2322        layout,
2323    } = t
2324    else {
2325        unreachable!()
2326    };
2327    unsafe {
2328        execute_scaled_dequantize_f32(
2329            *codes,
2330            *scale,
2331            *dst,
2332            *rows as usize,
2333            *cols as usize,
2334            *fmt,
2335            *layout,
2336            base,
2337        );
2338    }
2339}
2340
2341#[inline(always)]
2342pub(crate) fn exec_dequant_grouped_mat_mul_gguf(t: &Thunk, base: *mut u8) {
2343    let Thunk::DequantGroupedMatMulGguf {
2344        input,
2345        w_q,
2346        expert_idx,
2347        dst,
2348        m,
2349        k_dim,
2350        n,
2351        num_experts,
2352        scheme,
2353    } = t
2354    else {
2355        unreachable!()
2356    };
2357    {
2358        let m = *m as usize;
2359        let k_dim = *k_dim as usize;
2360        let n = *n as usize;
2361        let num_experts = *num_experts as usize;
2362        let block_elems = scheme.gguf_block_size() as usize;
2363        let block_bytes = scheme.gguf_block_bytes() as usize;
2364        let slab_bytes = (k_dim * n) / block_elems * block_bytes;
2365        unsafe {
2366            let inp = sl(*input, base, m * k_dim);
2367            let wt =
2368                std::slice::from_raw_parts(base.add(*w_q) as *const u8, num_experts * slab_bytes);
2369            let ids = sl(*expert_idx, base, m);
2370            let out = sl_mut(*dst, base, m * n);
2371            crate::gguf_matmul::gguf_grouped_matmul_bt(
2372                inp,
2373                wt,
2374                ids,
2375                out,
2376                m,
2377                k_dim,
2378                n,
2379                num_experts,
2380                *scheme,
2381            );
2382        }
2383    }
2384}
2385
2386#[inline(always)]
2387pub(crate) fn exec_dequant_mo_e_weights_gguf(t: &Thunk, base: *mut u8) {
2388    let Thunk::DequantMoEWeightsGguf {
2389        w_q,
2390        dst,
2391        k_dim,
2392        n,
2393        num_experts,
2394        scheme,
2395    } = t
2396    else {
2397        unreachable!()
2398    };
2399    {
2400        let k_dim = *k_dim as usize;
2401        let n = *n as usize;
2402        let num_experts = *num_experts as usize;
2403        let block_elems = scheme.gguf_block_size() as usize;
2404        let block_bytes = scheme.gguf_block_bytes() as usize;
2405        let slab_bytes = (k_dim * n) / block_elems * block_bytes;
2406        unsafe {
2407            let wt =
2408                std::slice::from_raw_parts(base.add(*w_q) as *const u8, num_experts * slab_bytes);
2409            let out = sl_mut(*dst, base, num_experts * k_dim * n);
2410            crate::gguf_matmul::dequant_moe_weights_to_grouped_f32(
2411                wt,
2412                out,
2413                num_experts,
2414                k_dim,
2415                n,
2416                *scheme,
2417            );
2418        }
2419    }
2420}
2421
2422#[inline(always)]
2423pub(crate) fn exec_q_mat_mul(t: &Thunk, base: *mut u8) {
2424    let Thunk::QMatMul {
2425        x,
2426        w,
2427        bias,
2428        out,
2429        m,
2430        k,
2431        n,
2432        x_zp,
2433        w_zp,
2434        out_zp,
2435        mult,
2436    } = t
2437    else {
2438        unreachable!()
2439    };
2440    {
2441        let m = *m as usize;
2442        let k = *k as usize;
2443        let n = *n as usize;
2444        unsafe {
2445            let x_ptr = base.add(*x) as *const i8;
2446            let w_ptr = base.add(*w) as *const i8;
2447            let bias_ptr = base.add(*bias) as *const i32;
2448            let out_ptr = base.add(*out) as *mut i8;
2449            for mi in 0..m {
2450                for ni in 0..n {
2451                    let mut acc: i32 = *bias_ptr.add(ni);
2452                    for ki in 0..k {
2453                        let xv = *x_ptr.add(mi * k + ki) as i32 - *x_zp;
2454                        let wv = *w_ptr.add(ki * n + ni) as i32 - *w_zp;
2455                        acc += xv * wv;
2456                    }
2457                    // Requantize: round(acc · mult) + out_zp,
2458                    // clamped to i8.
2459                    let r = (acc as f32 * *mult).round() as i32 + *out_zp;
2460                    let r = r.clamp(-128, 127) as i8;
2461                    *out_ptr.add(mi * n + ni) = r;
2462                }
2463            }
2464        }
2465    }
2466}
2467
2468#[inline(always)]
2469pub(crate) fn exec_quantize(t: &Thunk, base: *mut u8) {
2470    let Thunk::Quantize {
2471        x,
2472        q,
2473        len,
2474        chan_axis: _,
2475        chan_dim,
2476        inner,
2477        scales,
2478        zero_points,
2479    } = t
2480    else {
2481        unreachable!()
2482    };
2483    {
2484        let len = *len as usize;
2485        let chan_dim = *chan_dim as usize;
2486        let inner = *inner as usize;
2487        unsafe {
2488            let xs = sl(*x, base, len);
2489            let q_ptr = base.add(*q) as *mut i8;
2490            for i in 0..len {
2491                let c = if chan_dim == 1 {
2492                    0
2493                } else {
2494                    (i / inner) % chan_dim
2495                };
2496                let inv_scale = 1.0 / scales[c];
2497                let zp = zero_points[c];
2498                let v = (xs[i] * inv_scale).round() as i32 + zp;
2499                *q_ptr.add(i) = v.clamp(-128, 127) as i8;
2500            }
2501        }
2502    }
2503}
2504
2505#[inline(always)]
2506pub(crate) fn exec_dequantize(t: &Thunk, base: *mut u8) {
2507    let Thunk::Dequantize {
2508        q,
2509        x,
2510        len,
2511        chan_axis: _,
2512        chan_dim,
2513        inner,
2514        scales,
2515        zero_points,
2516    } = t
2517    else {
2518        unreachable!()
2519    };
2520    {
2521        let len = *len as usize;
2522        let chan_dim = *chan_dim as usize;
2523        let inner = *inner as usize;
2524        unsafe {
2525            let q_ptr = base.add(*q) as *const i8;
2526            let out = sl_mut(*x, base, len);
2527            for i in 0..len {
2528                let c = if chan_dim == 1 {
2529                    0
2530                } else {
2531                    (i / inner) % chan_dim
2532                };
2533                let scale = scales[c];
2534                let zp = zero_points[c];
2535                let qv = *q_ptr.add(i) as i32;
2536                out[i] = (qv - zp) as f32 * scale;
2537            }
2538        }
2539    }
2540}
2541
2542#[inline(always)]
2543pub(crate) fn exec_fake_quantize(t: &Thunk, base: *mut u8) {
2544    let Thunk::FakeQuantize {
2545        x,
2546        out,
2547        len,
2548        chan_axis: _,
2549        chan_dim,
2550        inner,
2551        bits,
2552        ste: _,
2553        scale_mode,
2554        state_off,
2555    } = t
2556    else {
2557        unreachable!()
2558    };
2559    {
2560        use rlx_ir::op::ScaleMode;
2561        let len = *len as usize;
2562        let chan_dim = *chan_dim as usize;
2563        let inner = *inner as usize;
2564        let q_max: f32 = match *bits {
2565            8 => 127.0,
2566            4 => 7.0,
2567            2 => 1.0,
2568            n => panic!("FakeQuantize: unsupported bits {n}"),
2569        };
2570        unsafe {
2571            let xs = sl(*x, base, len);
2572            let outs = sl_mut(*out, base, len);
2573
2574            let mut scale = vec![0f32; chan_dim];
2575            match scale_mode {
2576                ScaleMode::PerBatch => {
2577                    let mut max_abs = vec![0f32; chan_dim];
2578                    for i in 0..len {
2579                        let c = if chan_dim == 1 {
2580                            0
2581                        } else {
2582                            (i / inner) % chan_dim
2583                        };
2584                        let a = xs[i].abs();
2585                        if a > max_abs[c] {
2586                            max_abs[c] = a;
2587                        }
2588                    }
2589                    for c in 0..chan_dim {
2590                        scale[c] = (max_abs[c] / q_max).max(1e-12);
2591                    }
2592                }
2593                ScaleMode::EMA { decay } => {
2594                    // Per-channel current max-abs, then blend
2595                    // into the running state in place.
2596                    let mut max_abs = vec![0f32; chan_dim];
2597                    for i in 0..len {
2598                        let c = if chan_dim == 1 {
2599                            0
2600                        } else {
2601                            (i / inner) % chan_dim
2602                        };
2603                        let a = xs[i].abs();
2604                        if a > max_abs[c] {
2605                            max_abs[c] = a;
2606                        }
2607                    }
2608                    let state = sl_mut(state_off.expect("EMA needs state_off"), base, chan_dim);
2609                    for c in 0..chan_dim {
2610                        let cur = (max_abs[c] / q_max).max(1e-12);
2611                        // Cold-start: state==0 → seed directly.
2612                        let blended = if state[c] <= 0.0 {
2613                            cur
2614                        } else {
2615                            *decay * state[c] + (1.0 - *decay) * cur
2616                        };
2617                        state[c] = blended;
2618                        scale[c] = blended;
2619                    }
2620                }
2621                ScaleMode::Fixed => {
2622                    let state = sl(state_off.expect("Fixed needs state_off"), base, chan_dim);
2623                    for c in 0..chan_dim {
2624                        scale[c] = state[c].max(1e-12);
2625                    }
2626                }
2627            }
2628
2629            for i in 0..len {
2630                let c = if chan_dim == 1 {
2631                    0
2632                } else {
2633                    (i / inner) % chan_dim
2634                };
2635                let s = scale[c];
2636                let qv = (xs[i] / s).round().clamp(-q_max, q_max);
2637                outs[i] = qv * s;
2638            }
2639        }
2640    }
2641}
2642
2643#[inline(always)]
2644pub(crate) fn exec_fake_quantize_l_s_q(t: &Thunk, base: *mut u8) {
2645    let Thunk::FakeQuantizeLSQ {
2646        x,
2647        scale_off,
2648        out,
2649        len,
2650        chan_axis: _,
2651        chan_dim,
2652        inner,
2653        bits,
2654    } = t
2655    else {
2656        unreachable!()
2657    };
2658    {
2659        let len = *len as usize;
2660        let chan_dim = *chan_dim as usize;
2661        let inner = *inner as usize;
2662        let q_max: f32 = match *bits {
2663            8 => 127.0,
2664            4 => 7.0,
2665            2 => 1.0,
2666            n => panic!("FakeQuantizeLSQ: bad bits {n}"),
2667        };
2668        unsafe {
2669            let xs = sl(*x, base, len);
2670            let scale = sl(*scale_off, base, chan_dim);
2671            let outs = sl_mut(*out, base, len);
2672            for i in 0..len {
2673                let c = if chan_dim == 1 {
2674                    0
2675                } else {
2676                    (i / inner) % chan_dim
2677                };
2678                let s = scale[c].max(1e-12);
2679                let qv = (xs[i] / s).round().clamp(-q_max, q_max);
2680                outs[i] = qv * s;
2681            }
2682        }
2683    }
2684}
2685
2686#[inline(always)]
2687pub(crate) fn exec_fake_quantize_l_s_q_backward_x(t: &Thunk, base: *mut u8) {
2688    let Thunk::FakeQuantizeLSQBackwardX {
2689        x,
2690        scale_off,
2691        dy,
2692        dx,
2693        len,
2694        chan_axis: _,
2695        chan_dim,
2696        inner,
2697        bits,
2698    } = t
2699    else {
2700        unreachable!()
2701    };
2702    {
2703        let len = *len as usize;
2704        let chan_dim = *chan_dim as usize;
2705        let inner = *inner as usize;
2706        let q_max: f32 = match *bits {
2707            8 => 127.0,
2708            4 => 7.0,
2709            2 => 1.0,
2710            n => panic!("FakeQuantizeLSQBackwardX: bad bits {n}"),
2711        };
2712        unsafe {
2713            let xs = sl(*x, base, len);
2714            let scale = sl(*scale_off, base, chan_dim);
2715            let dys = sl(*dy, base, len);
2716            let outs = sl_mut(*dx, base, len);
2717            // STE-clipped: dx = dy when |x/s| ≤ q_max, else 0.
2718            for i in 0..len {
2719                let c = if chan_dim == 1 {
2720                    0
2721                } else {
2722                    (i / inner) % chan_dim
2723                };
2724                let z = xs[i] / scale[c].max(1e-12);
2725                outs[i] = if z.abs() <= q_max { dys[i] } else { 0.0 };
2726            }
2727        }
2728    }
2729}
2730
2731#[inline(always)]
2732pub(crate) fn exec_fake_quantize_l_s_q_backward_scale(t: &Thunk, base: *mut u8) {
2733    let Thunk::FakeQuantizeLSQBackwardScale {
2734        x,
2735        scale_off,
2736        dy,
2737        dscale,
2738        len,
2739        chan_axis: _,
2740        chan_dim,
2741        inner,
2742        bits,
2743    } = t
2744    else {
2745        unreachable!()
2746    };
2747    {
2748        let len = *len as usize;
2749        let chan_dim = *chan_dim as usize;
2750        let inner = *inner as usize;
2751        let q_max: f32 = match *bits {
2752            8 => 127.0,
2753            4 => 7.0,
2754            2 => 1.0,
2755            n => panic!("FakeQuantizeLSQBackwardScale: bad bits {n}"),
2756        };
2757        unsafe {
2758            let xs = sl(*x, base, len);
2759            let scale = sl(*scale_off, base, chan_dim);
2760            let dys = sl(*dy, base, len);
2761            let outs = sl_mut(*dscale, base, chan_dim);
2762            for v in outs.iter_mut() {
2763                *v = 0.0;
2764            }
2765            // ψ(z) = -z + round(z) inside range, sign(z)·q_max outside.
2766            // dscale[c] = sum_i ψ(x_i/s[c]) * upstream[i].
2767            for i in 0..len {
2768                let c = if chan_dim == 1 {
2769                    0
2770                } else {
2771                    (i / inner) % chan_dim
2772                };
2773                let s = scale[c].max(1e-12);
2774                let z = xs[i] / s;
2775                let psi = if z.abs() <= q_max {
2776                    -z + z.round()
2777                } else if z > 0.0 {
2778                    q_max
2779                } else {
2780                    -q_max
2781                };
2782                outs[c] += psi * dys[i];
2783            }
2784        }
2785    }
2786}
2787
2788#[inline(always)]
2789pub(crate) fn exec_fake_quantize_backward(t: &Thunk, base: *mut u8) {
2790    let Thunk::FakeQuantizeBackward {
2791        x,
2792        dy,
2793        dx,
2794        len,
2795        chan_axis: _,
2796        chan_dim,
2797        inner,
2798        bits,
2799        ste,
2800    } = t
2801    else {
2802        unreachable!()
2803    };
2804    {
2805        use rlx_ir::op::SteKind;
2806        let len = *len as usize;
2807        let chan_dim = *chan_dim as usize;
2808        let inner = *inner as usize;
2809        let q_max: f32 = match *bits {
2810            8 => 127.0,
2811            4 => 7.0,
2812            2 => 1.0,
2813            n => panic!("FakeQuantizeBackward: bad bits {n}"),
2814        };
2815        unsafe {
2816            let xs = sl(*x, base, len);
2817            let dys = sl(*dy, base, len);
2818            let outs = sl_mut(*dx, base, len);
2819
2820            // Per-channel max-abs → scale, same as forward.
2821            let mut max_abs = vec![0f32; chan_dim];
2822            for i in 0..len {
2823                let c = if chan_dim == 1 {
2824                    0
2825                } else {
2826                    (i / inner) % chan_dim
2827                };
2828                let a = xs[i].abs();
2829                if a > max_abs[c] {
2830                    max_abs[c] = a;
2831                }
2832            }
2833            let mut scale = vec![0f32; chan_dim];
2834            for c in 0..chan_dim {
2835                scale[c] = (max_abs[c] / q_max).max(1e-12);
2836            }
2837
2838            match *ste {
2839                SteKind::Identity => {
2840                    // dx = dy unchanged.
2841                    outs.copy_from_slice(dys);
2842                }
2843                SteKind::ClippedIdentity => {
2844                    // dx = dy * (|x| <= q_max·s); zero if the
2845                    // forward saturated.
2846                    for i in 0..len {
2847                        let c = if chan_dim == 1 {
2848                            0
2849                        } else {
2850                            (i / inner) % chan_dim
2851                        };
2852                        let bound = q_max * scale[c];
2853                        outs[i] = if xs[i].abs() <= bound { dys[i] } else { 0.0 };
2854                    }
2855                }
2856                SteKind::Tanh => {
2857                    // dx = dy * (1 - tanh²(x/s)).
2858                    for i in 0..len {
2859                        let c = if chan_dim == 1 {
2860                            0
2861                        } else {
2862                            (i / inner) % chan_dim
2863                        };
2864                        let t = (xs[i] / scale[c]).tanh();
2865                        outs[i] = dys[i] * (1.0 - t * t);
2866                    }
2867                }
2868                SteKind::HardTanh => {
2869                    // dx = dy * max(0, 1 - |x/(q_max·s)|).
2870                    for i in 0..len {
2871                        let c = if chan_dim == 1 {
2872                            0
2873                        } else {
2874                            (i / inner) % chan_dim
2875                        };
2876                        let bound = q_max * scale[c];
2877                        let attenuation = (1.0 - (xs[i] / bound).abs()).max(0.0);
2878                        outs[i] = dys[i] * attenuation;
2879                    }
2880                }
2881            }
2882        }
2883    }
2884}
2885
2886/// Host-fallback entry for GGUF `Op::DequantMatMul` (Metal unified memory).
2887pub unsafe fn execute_dequant_matmul_gguf_f32(
2888    x: usize,
2889    w_q: usize,
2890    dst: usize,
2891    m: usize,
2892    k: usize,
2893    n: usize,
2894    scheme: rlx_ir::quant::QuantScheme,
2895    base: *mut u8,
2896) {
2897    unsafe {
2898        let block_bytes = scheme.gguf_block_bytes() as usize;
2899        let block_elems = scheme.gguf_block_size() as usize;
2900        let total_bytes = (k * n) / block_elems * block_bytes;
2901        let xs = sl(x, base, m * k);
2902        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const u8, total_bytes);
2903        let out = sl_mut(dst, base, m * n);
2904        crate::gguf_matmul::gguf_matmul_bt_dispatch_at(xs, w_bytes, out, m, k, n, scheme, w_q);
2905    }
2906}
2907
2908/// Host-fallback entry for GGUF `Op::DequantGroupedMatMul` (MoE expert stack).
2909pub unsafe fn execute_dequant_grouped_matmul_gguf_f32(
2910    input: usize,
2911    w_q: usize,
2912    expert_idx: usize,
2913    dst: usize,
2914    m: usize,
2915    k: usize,
2916    n: usize,
2917    num_experts: usize,
2918    scheme: rlx_ir::quant::QuantScheme,
2919    base: *mut u8,
2920) {
2921    unsafe {
2922        let block_bytes = scheme.gguf_block_bytes() as usize;
2923        let block_elems = scheme.gguf_block_size() as usize;
2924        let slab_bytes = (k * n) / block_elems * block_bytes;
2925        let xs = sl(input, base, m * k);
2926        let w_bytes =
2927            std::slice::from_raw_parts(base.add(w_q) as *const u8, num_experts * slab_bytes);
2928        let ids = sl(expert_idx, base, m);
2929        let out = sl_mut(dst, base, m * n);
2930        crate::gguf_matmul::gguf_grouped_matmul_bt(
2931            xs,
2932            w_bytes,
2933            ids,
2934            out,
2935            m,
2936            k,
2937            n,
2938            num_experts,
2939            scheme,
2940        );
2941    }
2942}
2943
2944/// Host-fallback entry for Int8 `Op::DequantMatMul` (Metal unified memory).
2945pub unsafe fn execute_dequant_matmul_int8_f32(
2946    x: usize,
2947    w_q: usize,
2948    scale: usize,
2949    zp: usize,
2950    dst: usize,
2951    m: usize,
2952    k: usize,
2953    n: usize,
2954    block_size: u32,
2955    is_asymmetric: bool,
2956    base: *mut u8,
2957) {
2958    let bs = block_size as usize;
2959    let n_blocks = k.div_ceil(bs);
2960    unsafe {
2961        let xs = sl(x, base, m * k);
2962        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const i8, k * n);
2963        let scales = sl(scale, base, n_blocks * n);
2964        let zps = if is_asymmetric {
2965            sl(zp, base, n_blocks * n)
2966        } else {
2967            &[][..]
2968        };
2969        let out = sl_mut(dst, base, m * n);
2970        dequant_matmul_int8(xs, w_bytes, scales, zps, out, m, k, n, bs, is_asymmetric);
2971    }
2972}
2973
2974/// Host-fallback entry for Int4 `Op::DequantMatMul` (Metal unified memory).
2975pub unsafe fn execute_dequant_matmul_int4_f32(
2976    x: usize,
2977    w_q: usize,
2978    scale: usize,
2979    zp: usize,
2980    dst: usize,
2981    m: usize,
2982    k: usize,
2983    n: usize,
2984    block_size: u32,
2985    is_asymmetric: bool,
2986    base: *mut u8,
2987) {
2988    let bs = block_size as usize;
2989    let n_blocks = k.div_ceil(bs);
2990    unsafe {
2991        let xs = sl(x, base, m * k);
2992        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const u8, (k * n).div_ceil(2));
2993        let scales = sl(scale, base, n_blocks * n);
2994        let zps = if is_asymmetric {
2995            sl(zp, base, n_blocks * n)
2996        } else {
2997            &[][..]
2998        };
2999        let out = sl_mut(dst, base, m * n);
3000        dequant_matmul_int4(xs, w_bytes, scales, zps, out, m, k, n, bs, is_asymmetric);
3001    }
3002}
3003
3004/// Host-fallback entry for FP8 `Op::DequantMatMul` (Metal unified memory).
3005pub unsafe fn execute_dequant_matmul_fp8_f32(
3006    x: usize,
3007    w_q: usize,
3008    scale: usize,
3009    dst: usize,
3010    m: usize,
3011    k: usize,
3012    n: usize,
3013    e5m2: bool,
3014    base: *mut u8,
3015) {
3016    unsafe {
3017        let xs = sl(x, base, m * k);
3018        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const u8, k * n);
3019        let scales = sl(scale, base, n);
3020        let out = sl_mut(dst, base, m * n);
3021        dequant_matmul_fp8(xs, w_bytes, scales, out, m, k, n, e5m2);
3022    }
3023}
3024
3025/// Host-fallback entry for MxFp4x2 `Op::DequantMatMul` (Metal unified memory).
3026/// `w_q`=[plane0|plane1] nibbles, `scale`=[s0|s1] f32; byte offsets into `base`.
3027#[allow(clippy::too_many_arguments)]
3028pub unsafe fn execute_dequant_matmul_mxfp4x2_f32(
3029    x: usize,
3030    w_q: usize,
3031    scale: usize,
3032    dst: usize,
3033    m: usize,
3034    k: usize,
3035    n: usize,
3036    group: usize,
3037    base: *mut u8,
3038) {
3039    let plane = (k * n).div_ceil(2);
3040    let nblk = k.div_ceil(group.max(1));
3041    unsafe {
3042        let xs = sl(x, base, m * k);
3043        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const u8, 2 * plane);
3044        let scale_bytes =
3045            std::slice::from_raw_parts(base.add(scale) as *const u8, 2 * nblk * n * 4);
3046        let out = sl_mut(dst, base, m * n);
3047        dequant_matmul_mxfp4x2(xs, w_bytes, scale_bytes, group, out, m, k, n);
3048    }
3049}
3050
3051/// Host-fallback entry for NVFP4 `Op::DequantMatMul` (Metal unified memory).
3052pub unsafe fn execute_dequant_matmul_nvfp4_f32(
3053    x: usize,
3054    w_q: usize,
3055    scale: usize,
3056    global_scale: usize,
3057    dst: usize,
3058    m: usize,
3059    k: usize,
3060    n: usize,
3061    base: *mut u8,
3062) {
3063    let n_scale = k.div_ceil(rlx_ir::NVFP4_GROUP_SIZE) * n;
3064    unsafe {
3065        let xs = sl(x, base, m * k);
3066        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const u8, (k * n).div_ceil(2));
3067        let scale_bytes = std::slice::from_raw_parts(base.add(scale) as *const u8, n_scale);
3068        let gs = sl(global_scale, base, 1)[0];
3069        let out = sl_mut(dst, base, m * n);
3070        dequant_matmul_nvfp4(xs, w_bytes, scale_bytes, gs, out, m, k, n);
3071    }
3072}
3073
3074// ── Native low-precision ScaledMatMul host fallbacks (unified-memory) ──
3075// Reuse the CPU oracle kernels so Metal (no FP8 matrix HW) runs the exact same
3076// decode-and-accumulate reference. TN layout: lhs [m,k], rhs [n,k].
3077
3078/// Host fallback for `Op::ScaledQuantScale`. Byte offsets into `base`.
3079pub unsafe fn execute_scaled_quant_scale_f32(
3080    x: usize,
3081    dst: usize,
3082    rows: usize,
3083    cols: usize,
3084    fmt: rlx_ir::ScaledFormat,
3085    layout: rlx_ir::ScaleLayout,
3086    base: *mut u8,
3087) {
3088    unsafe {
3089        let xs = sl(x, base, rows * cols);
3090        let scales = lowp_compute_scales(xs, fmt, layout, rows, cols);
3091        let nblk = lowp_nblk(cols, layout);
3092        match layout {
3093            rlx_ir::ScaleLayout::PerTensor => {
3094                sl_mut(dst, base, 1)[0] = scales[0];
3095            }
3096            rlx_ir::ScaleLayout::BlockMxE8M0 { .. } => {
3097                let out = std::slice::from_raw_parts_mut(base.add(dst), rows * nblk);
3098                for (o, &s) in out.iter_mut().zip(&scales) {
3099                    *o = rlx_ir::lowp_codec::f32_to_e8m0(s);
3100                }
3101            }
3102            rlx_ir::ScaleLayout::Nvfp4 { .. } => {
3103                let out = std::slice::from_raw_parts_mut(base.add(dst), rows * nblk);
3104                for (o, &s) in out.iter_mut().zip(&scales) {
3105                    *o = rlx_ir::lowp_codec::encode(rlx_ir::ScaledFormat::F8E4M3, s);
3106                }
3107            }
3108        }
3109    }
3110}
3111
3112/// Host fallback for `Op::ScaledQuantize`. Byte offsets into `base`.
3113#[allow(clippy::too_many_arguments)]
3114pub unsafe fn execute_scaled_quantize_f32(
3115    x: usize,
3116    scale: usize,
3117    dst: usize,
3118    rows: usize,
3119    cols: usize,
3120    fmt: rlx_ir::ScaledFormat,
3121    layout: rlx_ir::ScaleLayout,
3122    base: *mut u8,
3123) {
3124    unsafe {
3125        let xs = sl(x, base, rows * cols);
3126        let nblk = lowp_nblk(cols, layout);
3127        let n_scale = if matches!(layout, rlx_ir::ScaleLayout::PerTensor) {
3128            1
3129        } else {
3130            rows * nblk
3131        };
3132        let scales = lowp_read_scales(layout, base, scale, n_scale);
3133        let out = std::slice::from_raw_parts_mut(base.add(dst), rows * cols);
3134        lowp_quantize(xs, &scales, fmt, layout, rows, cols, out);
3135    }
3136}
3137
3138/// Host fallback for `Op::ScaledDequantize` — packed codes (`U8`) → f32, the
3139/// inverse of [`execute_scaled_quantize_f32`]. Byte offsets into `base`.
3140#[allow(clippy::too_many_arguments)]
3141pub unsafe fn execute_scaled_dequantize_f32(
3142    codes: usize,
3143    scale: usize,
3144    dst: usize,
3145    rows: usize,
3146    cols: usize,
3147    fmt: rlx_ir::ScaledFormat,
3148    layout: rlx_ir::ScaleLayout,
3149    base: *mut u8,
3150) {
3151    unsafe {
3152        let nblk = lowp_nblk(cols, layout);
3153        let n_scale = if matches!(layout, rlx_ir::ScaleLayout::PerTensor) {
3154            1
3155        } else {
3156            rows * nblk
3157        };
3158        let cs = std::slice::from_raw_parts(base.add(codes), rows * cols);
3159        let scales = lowp_read_scales(layout, base, scale, n_scale);
3160        let out = std::slice::from_raw_parts_mut(base.add(dst) as *mut f32, rows * cols);
3161        lowp_dequantize(cs, &scales, fmt, layout, rows, cols, out);
3162    }
3163}
3164
3165/// Host fallback for `Op::ScaledMatMul` (TN). Byte offsets into `base`.
3166#[allow(clippy::too_many_arguments)]
3167pub unsafe fn execute_scaled_matmul_f32(
3168    lhs: usize,
3169    rhs: usize,
3170    lhs_scale: usize,
3171    rhs_scale: usize,
3172    bias: usize,
3173    dst: usize,
3174    m: usize,
3175    k: usize,
3176    n: usize,
3177    has_bias: bool,
3178    lhs_fmt: rlx_ir::ScaledFormat,
3179    rhs_fmt: rlx_ir::ScaledFormat,
3180    layout: rlx_ir::ScaleLayout,
3181    base: *mut u8,
3182) {
3183    unsafe {
3184        let lhs_b = std::slice::from_raw_parts(base.add(lhs), m * k);
3185        let rhs_b = std::slice::from_raw_parts(base.add(rhs), n * k);
3186        let nblk = lowp_nblk(k, layout);
3187        let per_tensor = matches!(layout, rlx_ir::ScaleLayout::PerTensor);
3188        let n_l = if per_tensor { 1 } else { m * nblk };
3189        let n_r = if per_tensor { 1 } else { n * nblk };
3190        let ls = lowp_read_scales(layout, base, lhs_scale, n_l);
3191        let rs = lowp_read_scales(layout, base, rhs_scale, n_r);
3192        let bias_s = if has_bias {
3193            Some(sl(bias, base, n))
3194        } else {
3195            None
3196        };
3197        let out = sl_mut(dst, base, m * n);
3198        lowp_scaled_matmul(
3199            lhs_b, rhs_b, &ls, &rs, bias_s, out, m, n, k, layout, lhs_fmt, rhs_fmt,
3200        );
3201    }
3202}
3203
3204/// Host fallback for `Op::ScaledGroupedMatMul` (expert-indexed TN). Byte
3205/// offsets into `base`. `input [m,k]` codes, `weight [E,n,k]` codes,
3206/// `input_scale [m,nblk]`, `weight_scale [E·n,nblk]`, `expert_idx [m]` f32,
3207/// optional per-expert `bias [E,n]`. Used by GPU backends over mapped memory.
3208#[allow(clippy::too_many_arguments)]
3209pub unsafe fn execute_scaled_grouped_matmul_f32(
3210    input: usize,
3211    weight: usize,
3212    input_scale: usize,
3213    weight_scale: usize,
3214    expert_idx: usize,
3215    bias: usize,
3216    dst: usize,
3217    m: usize,
3218    k: usize,
3219    n: usize,
3220    num_experts: usize,
3221    has_bias: bool,
3222    lhs_fmt: rlx_ir::ScaledFormat,
3223    rhs_fmt: rlx_ir::ScaledFormat,
3224    layout: rlx_ir::ScaleLayout,
3225    base: *mut u8,
3226) {
3227    unsafe {
3228        let input_b = std::slice::from_raw_parts(base.add(input), m * k);
3229        let weight_b = std::slice::from_raw_parts(base.add(weight), num_experts * n * k);
3230        let nblk = lowp_nblk(k, layout);
3231        let per_tensor = matches!(layout, rlx_ir::ScaleLayout::PerTensor);
3232        let n_i = if per_tensor { 1 } else { m * nblk };
3233        let n_w = if per_tensor {
3234            1
3235        } else {
3236            num_experts * n * nblk
3237        };
3238        let is = lowp_read_scales(layout, base, input_scale, n_i);
3239        let ws = lowp_read_scales(layout, base, weight_scale, n_w);
3240        let ids = sl(expert_idx, base, m);
3241        let bias_s = if has_bias {
3242            Some(sl(bias, base, num_experts * n))
3243        } else {
3244            None
3245        };
3246        let out = sl_mut(dst, base, m * n);
3247        lowp_scaled_grouped_matmul(
3248            input_b,
3249            weight_b,
3250            &is,
3251            &ws,
3252            ids,
3253            bias_s,
3254            out,
3255            m,
3256            n,
3257            k,
3258            num_experts,
3259            layout,
3260            lhs_fmt,
3261            rhs_fmt,
3262        );
3263    }
3264}
3265
3266/// Element-wise backward for `Op::Activation`. `xs` is the original
3267/// input to the forward activation; `dys` is the upstream gradient.
3268/// Writes `out[i] = (d/dx act(xs[i])) * dys[i]`.
3269/// Decompose a per-channel quantization shape into the
3270/// `(chan_axis, chan_dim, inner)` triplet the kernel needs to map a
3271/// flat output index to a channel index. Per-tensor (`axis = None`)
3272/// degenerates to `chan_dim = 1, inner = len`, which makes the
3273/// kernel's `(i / inner) % chan_dim` always 0 — same fast path the
3274/// scalar version used.
3275pub(crate) fn quant_layout(shape: &rlx_ir::Shape, axis: Option<usize>) -> (usize, usize, usize) {
3276    match axis {
3277        None => (0, 1, shape.num_elements().unwrap_or(0).max(1)),
3278        Some(d) => {
3279            let chan_dim = shape.dim(d).unwrap_static();
3280            let inner: usize = (d + 1..shape.rank())
3281                .map(|i| shape.dim(i).unwrap_static())
3282                .product::<usize>()
3283                .max(1);
3284            (d, chan_dim, inner)
3285        }
3286    }
3287}
3288
3289#[cfg(test)]
3290mod mxfp4x2_matmul_tests {
3291    use super::dequant_matmul_mxfp4x2;
3292    use rlx_ir::ScaledFormat;
3293    use rlx_ir::residual::{residual_dequantize, residual_quantize};
3294
3295    // The op decodes a two-level residual E2M1 weight (s0·LUT[q0] + s1·LUT[q1])
3296    // and matmuls with x. Verify the packed-nibble kernel reproduces a plain f32
3297    // matmul against the same residual-decoded weight. group = k → one MX block
3298    // per column (nblk = 1).
3299    #[test]
3300    fn dequant_matmul_mxfp4x2_matches_residual_decode() {
3301        let (m, k, n) = (3usize, 32usize, 4usize);
3302        let x: Vec<f32> = (0..m * k).map(|i| ((i % 7) as f32 - 3.0) * 0.25).collect();
3303        let w: Vec<f32> = (0..k * n).map(|i| ((i % 11) as f32 - 5.0) * 0.3).collect(); // [k,n]
3304
3305        let plane = (k * n).div_ceil(2);
3306        let mut w_bytes = vec![0u8; 2 * plane];
3307        let (mut s0, mut s1) = (vec![0f32; n], vec![0f32; n]);
3308        let mut w_dq = vec![0f32; k * n]; // reference decoded weight
3309
3310        for j in 0..n {
3311            let col: Vec<f32> = (0..k).map(|p| w[p * n + j]).collect();
3312            let rb = residual_quantize(&col, ScaledFormat::F4E2M1, 2);
3313            s0[j] = rb.scales[0];
3314            s1[j] = rb.scales[1];
3315            let dq = residual_dequantize(&rb);
3316            for p in 0..k {
3317                let elem = p * n + j;
3318                let byte = elem / 2;
3319                let shift: u32 = if elem & 1 == 0 { 0 } else { 4 };
3320                let mask: u8 = 0x0Fu8 << shift;
3321                w_bytes[byte] = (w_bytes[byte] & !mask) | ((rb.codes[0][p] & 0x0F) << shift);
3322                w_bytes[plane + byte] =
3323                    (w_bytes[plane + byte] & !mask) | ((rb.codes[1][p] & 0x0F) << shift);
3324                w_dq[elem] = dq[p];
3325            }
3326        }
3327
3328        let mut scale_bytes = Vec::with_capacity(2 * n * 4);
3329        for &s in &s0 {
3330            scale_bytes.extend_from_slice(&s.to_le_bytes());
3331        }
3332        for &s in &s1 {
3333            scale_bytes.extend_from_slice(&s.to_le_bytes());
3334        }
3335
3336        let mut out = vec![0f32; m * n];
3337        dequant_matmul_mxfp4x2(&x, &w_bytes, &scale_bytes, k, &mut out, m, k, n);
3338
3339        let mut want = vec![0f32; m * n];
3340        for i in 0..m {
3341            for j in 0..n {
3342                let mut acc = 0f32;
3343                for p in 0..k {
3344                    acc += x[i * k + p] * w_dq[p * n + j];
3345                }
3346                want[i * n + j] = acc;
3347            }
3348        }
3349
3350        for (a, b) in out.iter().zip(&want) {
3351            assert!((a - b).abs() < 1e-4, "kernel {a} vs reference {b}");
3352        }
3353    }
3354}
3355
3356#[cfg(test)]
3357mod amx_matmul_bench {
3358    use super::matmul_x_wt;
3359    use std::time::Instant;
3360
3361    // `matmul_x_wt` is the MLX MXFP4/MXFP8 dequant-matmul's inner product. Measures
3362    // the AMX win (Accelerate sgemm/sgemv) vs the old scalar triple-loop at
3363    // DeepSeek-V4-real dims (k=4096), for both decode (m=1 GEMV) and block/prefill
3364    // (m>1 GEMM). `--nocapture` to see the table. Correctness-gated.
3365    fn naive(x: &[f32], w: &[f32], out: &mut [f32], m: usize, k: usize, n: usize) {
3366        for i in 0..m {
3367            for j in 0..n {
3368                let mut a = 0f32;
3369                for p in 0..k {
3370                    a += x[i * k + p] * w[j * k + p];
3371                }
3372                out[i * n + j] = a;
3373            }
3374        }
3375    }
3376
3377    #[test]
3378    fn amx_vs_scalar_dequant_matmul() {
3379        let k = 4096usize;
3380        println!("\n══ AMX (Accelerate) vs scalar matmul — MLX dequant-matmul inner, k={k} ══");
3381        println!(
3382            "{:>4} {:>6} {:>10} {:>10} {:>9}   {:>10}",
3383            "m", "n", "scalar", "amx", "speedup", "rel|Δ|"
3384        );
3385        for (m, n) in [
3386            (1usize, 2048usize),
3387            (1, 4096),
3388            (5, 4096),
3389            (8, 2048),
3390            (16, 4096),
3391        ] {
3392            let x: Vec<f32> = (0..m * k).map(|i| (i as f32 * 0.7).sin() * 0.1).collect();
3393            let w: Vec<f32> = (0..n * k).map(|i| (i as f32 * 0.31).cos() * 0.1).collect();
3394            let mut o_naive = vec![0f32; m * n];
3395            let mut o_amx = vec![0f32; m * n];
3396            naive(&x, &w, &mut o_naive, m, k, n);
3397            matmul_x_wt(&x, &w, &mut o_amx, m, k, n);
3398            let reps = if m == 1 { 40 } else { 20 };
3399            let t = Instant::now();
3400            for _ in 0..reps {
3401                naive(&x, &w, &mut o_naive, m, k, n);
3402            }
3403            let tn = t.elapsed().as_secs_f64() * 1e3 / reps as f64;
3404            let t = Instant::now();
3405            for _ in 0..reps {
3406                matmul_x_wt(&x, &w, &mut o_amx, m, k, n);
3407            }
3408            let ta = t.elapsed().as_secs_f64() * 1e3 / reps as f64;
3409            let err = o_naive
3410                .iter()
3411                .zip(&o_amx)
3412                .map(|(a, b)| (a - b).abs())
3413                .fold(0f32, f32::max);
3414            let mag = o_naive.iter().map(|v| v.abs()).fold(1e-6, f32::max);
3415            println!(
3416                "{m:>4} {n:>6} {:>8.3}ms {:>8.3}ms {:>8.1}× {:>10.1e}",
3417                tn,
3418                ta,
3419                tn / ta,
3420                err / mag
3421            );
3422            assert!(
3423                err / mag < 1e-3,
3424                "AMX matmul must match scalar (rel {:.1e})",
3425                err / mag
3426            );
3427        }
3428        println!();
3429    }
3430
3431    // For the int8/int4/fp8/nvfp4 quant matmuls, the m>1 AMX path must equal the
3432    // m==1 scalar path applied row-by-row (same packed bytes) — and be much faster.
3433    // Uses random valid bytes: we compare two code paths, not vs a "true" dequant.
3434    #[test]
3435    fn amx_quant_matmul_schemes_m_gt_1() {
3436        use super::{
3437            dequant_matmul_fp8, dequant_matmul_int4, dequant_matmul_int8, dequant_matmul_nvfp4,
3438        };
3439        use rlx_ir::NVFP4_GROUP_SIZE;
3440        let (k, n, bs, mm) = (4096usize, 2048usize, 32usize, 8usize);
3441        let x: Vec<f32> = (0..mm * k)
3442            .map(|i| ((i * 7 % 23) as f32 - 11.0) * 0.03)
3443            .collect();
3444        let nb = k.div_ceil(bs);
3445        // Time a full m=mm AMX call vs mm separate m=1 scalar (fused) calls; assert
3446        // the batched result equals the row-by-row result.
3447        let run = |name: &str,
3448                   amx: &dyn Fn(&[f32], usize, &mut [f32]),
3449                   scalar_row: &dyn Fn(&[f32], &mut [f32])| {
3450            let mut got = vec![0f32; mm * n];
3451            amx(&x, mm, &mut got);
3452            let mut refr = vec![0f32; mm * n];
3453            for r in 0..mm {
3454                let mut row = vec![0f32; n];
3455                scalar_row(&x[r * k..r * k + k], &mut row);
3456                refr[r * n..r * n + n].copy_from_slice(&row);
3457            }
3458            let err = got
3459                .iter()
3460                .zip(&refr)
3461                .map(|(a, b)| (a - b).abs())
3462                .fold(0f32, f32::max);
3463            let mag = refr.iter().map(|v| v.abs()).fold(1e-6, f32::max);
3464            let reps = 10usize;
3465            let t = Instant::now();
3466            for _ in 0..reps {
3467                amx(&x, mm, &mut got);
3468            }
3469            let ta = t.elapsed().as_secs_f64() * 1e3 / reps as f64;
3470            let t = Instant::now();
3471            let mut tmp = vec![0f32; n];
3472            for _ in 0..reps {
3473                for r in 0..mm {
3474                    scalar_row(&x[r * k..r * k + k], &mut tmp);
3475                }
3476            }
3477            let ts = t.elapsed().as_secs_f64() * 1e3 / reps as f64;
3478            println!(
3479                "{name:<8} m={mm} k={k} n={n}: scalar(row×m) {ts:>8.3}ms  amx {ta:>7.3}ms  {:>6.1}×  rel|Δ| {:.1e}",
3480                ts / ta,
3481                err / mag
3482            );
3483            assert!(
3484                err / mag < 1e-3,
3485                "{name}: m>1 AMX must match row-by-row scalar (rel {:.1e})",
3486                err / mag
3487            );
3488        };
3489        println!("\n══ AMX vs scalar for int8/int4/fp8/nvfp4 dequant-matmul (m>1 GEMM) ══");
3490        // int8
3491        let w8: Vec<i8> = (0..k * n)
3492            .map(|i| ((i * 31 % 251) as i32 - 125) as i8)
3493            .collect();
3494        let sc8: Vec<f32> = (0..nb * n)
3495            .map(|i| 0.008 + (i % 7) as f32 * 0.002)
3496            .collect();
3497        run(
3498            "int8",
3499            &|x, m, o| dequant_matmul_int8(x, &w8, &sc8, &[], o, m, k, n, bs, false),
3500            &|x, o| dequant_matmul_int8(x, &w8, &sc8, &[], o, 1, k, n, bs, false),
3501        );
3502        // int4 (packed nibbles)
3503        let w4: Vec<u8> = (0..k * n / 2).map(|i| (i * 37 % 256) as u8).collect();
3504        run(
3505            "int4",
3506            &|x, m, o| dequant_matmul_int4(x, &w4, &sc8, &[], o, m, k, n, bs, false),
3507            &|x, o| dequant_matmul_int4(x, &w4, &sc8, &[], o, 1, k, n, bs, false),
3508        );
3509        // fp8 (per-column scale)
3510        let wf8: Vec<u8> = (0..k * n).map(|i| (i * 53 % 256) as u8).collect();
3511        let scc: Vec<f32> = (0..n).map(|j| 0.01 + (j % 5) as f32 * 0.004).collect();
3512        run(
3513            "fp8",
3514            &|x, m, o| dequant_matmul_fp8(x, &wf8, &scc, o, m, k, n, false),
3515            &|x, o| dequant_matmul_fp8(x, &wf8, &scc, o, 1, k, n, false),
3516        );
3517        // nvfp4 (group scale + global)
3518        let gs = NVFP4_GROUP_SIZE;
3519        let nbg = k.div_ceil(gs);
3520        let wn4: Vec<u8> = (0..k * n / 2).map(|i| (i * 41 % 256) as u8).collect();
3521        let scn: Vec<u8> = (0..nbg * n).map(|i| (0x30 + (i % 40)) as u8).collect();
3522        run(
3523            "nvfp4",
3524            &|x, m, o| dequant_matmul_nvfp4(x, &wn4, &scn, 0.5, o, m, k, n),
3525            &|x, o| dequant_matmul_nvfp4(x, &wn4, &scn, 0.5, o, 1, k, n),
3526        );
3527        println!();
3528    }
3529}