Skip to content

[MLX] Experimental int8 x int4 MPP GEMM + Muse Glimmer benchmark - #24467

Draft
metascroy wants to merge 4 commits into
mainfrom
int8-dynamic-exp
Draft

metascroy wants to merge 4 commits into
mainfrom
int8-dynamic-exp

Conversation

@metascroy

@metascroy metascroy commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Draft / experimental. Not for landing yet. This PR is mainly here so the benchmark can be run on other Apple GPUs, especially M5.

What this adds

mlx::int8_int4_gemm (backends/mlx/custom_kernel_ops/int8_int4_gemm.py): an MLX-backend custom op that multiplies 8-bit dynamically quantized activations by 4-bit per-channel weights, with exact INT32 accumulation.

  • Activations: quantized on the fly. Symmetric, per token (row), int8.
  • Weights: symmetric, per output channel, int4, two values packed per byte (low nibble first). This is the same layout as Metal's int4b_format.
  • Kernels:
    • Quantize: one threadgroup of 256 threads per token row writes int8 activations and fp32 scales. It can zero-pad the rows to a whole number of output tiles.
    • Full-K GEMM: one Metal Performance Primitives (Metal 4) int8 x int4b_format matmul over the full K, reading packed weights straight from device memory. BM and BN are template parameters.
    • Split-K GEMM: S threadgroups per output tile each cover K / S and write INT32 partial sums; a reduce kernel adds them and applies the scales. It multiplies the number of threadgroups by S, which helps GEMMs with few output tiles (small N or small M).
    • Matvec: for M <= 2. A SIMD kernel that streams packed weights like MLX's qmv, using exact integer arithmetic instead of MPP.
    • Staged: one fused kernel that quantizes and unpacks into threadgroup memory one K tile at a time (full_k=False). Not swept by default; it never won.
  • All lowerings give bit-identical results. The integer dot product is exact, and every kernel applies the scales in the same order.
  • Handler: the export handler currently emits only the quantize + full-K path. Split-K and matvec are used by the benchmark only, for now.

bench_int8_int4_gemm.py (backends/mlx/custom_kernel_ops/test/) is a standalone MLX benchmark covering every Muse Glimmer GEMM shape:

  • Shapes: qkv, o, gate_up, down (52 occurrences each) and lm_head (1).
  • Baseline: MLX quantized_matmul, 4-bit affine, group size 32.
  • Defaults: M = 4, 8, 16, 256 (speculative decoding and the 256-token prefill chunk), with fp16 and bf16 activations. Add --m 1 2 for decode.
  • Estimate: total Muse Glimmer GEMM time per forward, computed as official time x number of occurrences, summed over shapes.

How the benchmark measures

  • Serialized timing. Each timing builds a chain of calls where every call consumes the previous call's output (x = x0 + 0 * y[:, :1]), then evaluates the chain once. Calls cannot overlap on the GPU, just as consecutive layers cannot in a model. The same chain without the GEMM is timed and subtracted. Timing independent calls instead lets the GPU overlap them, which hides per-call latency and badly overstates small-M results.
  • Time-based iteration counts. Each timing targets --time-budget-ms (50 ms) of GPU time, estimated from one call, between --min-iters (3) and a per-round cap. Short kernels (all of M <= 16) run the full cap; large-M timings stay short, which limits heat.
  • Configs swept. Full-K and split-K (--splits 1 2 4 8) tiles with BM <= max(8, M), plus matvec at M <= 2.
  • Two-round tile selection.
    1. Sweep: every config is checked against a reference and timed (cap --iters, 10).
    2. Refine: the --refine-top (3) fastest configs of each lowering (full-K, split-K, matvec) are re-timed (cap --refine-iters, 20), and the fastest is kept.
  • Official numbers. The refined winner and MLX are timed alternately --final-repeats (5) times (cap --final-iters, 100); the medians are reported. MLX is only timed in this round, so both sides are measured under the same GPU conditions.
  • Cache. Calls rotate across enough weight copies to exceed --rotate-mb (512), so weights are not served from cache. int8_int4 times include activation quantization.

How to run

Requirements:

  • An Apple Silicon Mac with a Metal 4 capable GPU.
  • An ExecuTorch Python dev install from this branch (so executorch.backends.mlx imports).
  • pip install mlx. The M4 run below used MLX 0.32.4.
git fetch origin int8-dynamic-exp && git checkout int8-dynamic-exp
# from the directory that contains the executorch checkout
python -m executorch.backends.mlx.custom_kernel_ops.test.bench_int8_int4_gemm \
  --m 1 2 4 8 16 256 --dtypes fp16 --pad-m 2>&1 | tee int8_int4_bench.log

Please post int8_int4_bench.log. Close other GPU-heavy apps while it runs. Drop --dtypes fp16 to also run bf16.

Useful flags:

  • --m 1 2 4 8 16 256: token batch sizes.
  • --shapes qkv o gate_up down lm_head
  • --dtypes fp16 bf16
  • --pad-m: zero-pad activations to whole BM tiles in the quantize kernel, so partial tiles read zeros.
  • --bm 8 16 32 64 128, --bn 16 32 64 128: output tile rows and columns. MPP requires BM to be a multiple of 8.
  • --splits 1 2 4 8: split-K factors.
  • --matvec-max-m 2: largest M that also tries the matvec kernel (0 disables it).
  • --bk -1: K tile size; -1 means full K. Add e.g. 32 64 128 to also sweep the staged kernel.
  • --group-size 32: MLX baseline group size.
  • --time-budget-ms 50, --min-iters 3, --iters 10, --refine-top 3, --refine-iters 20, --final-iters 100, --final-repeats 5: see "How the benchmark measures".
  • --warmup 1: untimed runs before each timing, so kernels are compiled first.
  • --dtype-pause 60, --shape-pause 10: seconds to pause between dtypes and between shapes.
  • --verbose: print every config and the refine ranking.

How to read a progress line: [fp16] qkv M=8 split_k8 8x128 int8_int4 124.1 us | mlx 204.0 us | 1.64x (refine 123.0 us, 6 candidates).

  • full_k BMxBN is the full-K kernel with that output tile, split_kS BMxBN is split-K with S splits, and matvec mrR is the matvec kernel with R activation rows per threadgroup.
  • The two times are the official medians. Speedup is MLX time / int8_int4 time, so > 1 means int8_int4 is faster.
  • refine is the winner's time in the refine round, for reference.

M4 Max results (fp16 activations, MLX 0.32.4, --m 1 2 4 8 16 256 --dtypes fp16 --pad-m)

Estimated total Muse Glimmer GEMM time per forward:

M int8_int4 ms MLX gs=32 ms speedup winning lowering
1 29.6 34.8 1.18x matvec
2 33.3 36.1 1.09x matvec
4 49.6 47.0 0.95x split-K
8 50.6 90.0 1.78x split-K
16 76.9 143.7 1.87x split-K
256 989.0 1066.5 1.08x split-K / full-K

Summary:

  • M = 1 and 2 (decode): matvec wins at M = 1 on every shape (1.10-1.25x). int8_int4 reads 0.5 bytes per weight against MLX's 0.625 (gs = 32 stores an fp16 scale and bias per 32 weights). At M = 2, gate_up, down and lm_head still win (1.13-1.18x), while qkv and o lose (0.85-0.90x). Matvec does the dot product on the ALUs, so its cost grows with M; past M = 2, MPP split-K is faster.
  • M = 4: int8_int4 is just behind (0.87-1.03x per shape). Padded to an 8-row MPP tile, M = 4 costs the same as M = 8. MPP's int8 rate on M4 Max appears to be about the same as its fp16 rate, so the 8 padded rows cost an estimated ~30 ms of compute alone, while MLX's matrix-vector kernel does fp16 work for just 4 rows, hidden behind the weight reads.
  • M = 8 and 16: int8_int4 wins every shape, 1.78-1.87x overall (1.5-2.0x per shape). Split-K with 8 splits wins most of them. MLX switches to its tiled matmul kernel at M = 8, and its time roughly doubles from M = 4.
  • M = 256 (prefill chunk): 1.08x overall. gate_up, down and lm_head gain 1.08-1.09x; qkv and o are about even (1.00-1.03x). int8_int4 sustains 11.6-14.1 TOPS against MLX's 10.8-13.2. Split-K by 2 or 4 still wins 4 of 5 shapes.
  • Noise: refine and official times mostly agree within about 5% (lm_head at M = 256 is at 5%; the short o M = 2 timing is the outlier). The official round alternates int8_int4 and MLX, so the speedups stay comparable. Treat differences of a few percent as noise.
  • Patched MLX is faster than pip MLX at some small M. The MLX backend can be built with mlx_metal_small_batch_quantized_mlp.patch, which is tuned for these shapes on M4 Max at M = 4 and M = 16. This benchmark uses pip MLX.
  • Hypothesis: M4's int8 matrix rate is no faster than fp16, which limits the prefill gain to about 10% and costs the M = 4 case. M5 is the interesting test.
Full fp16 output
device: Apple M4 Max  mlx: 0.32.4  M: [1, 2, 4, 8, 16, 256]  dtypes: ['fp16']  mlx group_size: 32  warmup: 1  time budget: 50 ms (min iters 3)  sweep iters: 10  refine: top 3/lowering x 20 iters  final iters: 100  final repeats: 5  pad_m: True  quant threads: 256  splits: [1, 2, 4, 8]  matvec max M: 2
  [fp16] qkv      M=1    matvec mr1           int8_int4      70.3 us | mlx      79.5 us | 1.13x  (refine 66.6 us, 7 candidates)
  [fp16] qkv      M=2    matvec mr2           int8_int4      86.9 us | mlx      78.6 us | 0.90x  (refine 85.2 us, 7 candidates)
  [fp16] qkv      M=4    split_k8 8x128       int8_int4     119.4 us | mlx     106.3 us | 0.89x  (refine 118.0 us, 6 candidates)
  [fp16] qkv      M=8    split_k8 8x128       int8_int4     124.1 us | mlx     204.0 us | 1.64x  (refine 123.0 us, 6 candidates)
  [fp16] qkv      M=16   split_k8 16x128      int8_int4     187.9 us | mlx     373.3 us | 1.99x  (refine 183.7 us, 6 candidates)
  [fp16] qkv      M=256  split_k4 64x64       int8_int4    2195.6 us | mlx    2272.0 us | 1.03x  (refine 2195.6 us, 6 candidates)
pausing 10s before o...
  [fp16] o        M=1    matvec mr1           int8_int4      34.8 us | mlx      38.4 us | 1.10x  (refine 34.4 us, 7 candidates)
  [fp16] o        M=2    matvec mr2           int8_int4      49.8 us | mlx      42.5 us | 0.85x  (refine 60.0 us, 7 candidates)
  [fp16] o        M=4    split_k8 8x64        int8_int4      62.4 us | mlx      54.3 us | 0.87x  (refine 61.2 us, 6 candidates)
  [fp16] o        M=8    split_k8 8x64        int8_int4      64.8 us | mlx      98.5 us | 1.52x  (refine 60.0 us, 6 candidates)
  [fp16] o        M=16   split_k8 16x64       int8_int4      96.6 us | mlx     163.8 us | 1.70x  (refine 94.8 us, 6 candidates)
  [fp16] o        M=256  split_k2 128x32      int8_int4    1061.4 us | mlx    1061.7 us | 1.00x  (refine 1057.3 us, 6 candidates)
pausing 10s before gate_up...
  [fp16] gate_up  M=1    matvec mr1           int8_int4     284.3 us | mlx     344.9 us | 1.21x  (refine 281.6 us, 7 candidates)
  [fp16] gate_up  M=2    matvec mr2           int8_int4     301.8 us | mlx     342.1 us | 1.13x  (refine 298.1 us, 7 candidates)
  [fp16] gate_up  M=4    split_k8 8x128       int8_int4     469.4 us | mlx     454.1 us | 0.97x  (refine 461.6 us, 6 candidates)
  [fp16] gate_up  M=8    split_k4 8x128       int8_int4     472.0 us | mlx     881.1 us | 1.87x  (refine 469.5 us, 6 candidates)
  [fp16] gate_up  M=16   split_k8 16x128      int8_int4     720.8 us | mlx    1346.5 us | 1.87x  (refine 718.5 us, 6 candidates)
  [fp16] gate_up  M=256  full_k 32x128        int8_int4    9627.8 us | mlx   10542.2 us | 1.09x  (refine 9631.0 us, 6 candidates)
pausing 10s before down...
  [fp16] down     M=1    matvec mr1           int8_int4     153.8 us | mlx     173.6 us | 1.13x  (refine 153.0 us, 7 candidates)
  [fp16] down     M=2    matvec mr2           int8_int4     174.6 us | mlx     199.4 us | 1.14x  (refine 166.4 us, 7 candidates)
  [fp16] down     M=4    split_k8 8x128       int8_int4     259.9 us | mlx     246.3 us | 0.95x  (refine 256.4 us, 6 candidates)
  [fp16] down     M=8    split_k8 8x64        int8_int4     269.9 us | mlx     460.6 us | 1.71x  (refine 262.6 us, 6 candidates)
  [fp16] down     M=16   split_k8 16x64       int8_int4     404.2 us | mlx     749.0 us | 1.85x  (refine 401.7 us, 6 candidates)
  [fp16] down     M=256  split_k4 32x128      int8_int4    4991.8 us | mlx    5404.4 us | 1.08x  (refine 4929.5 us, 6 candidates)
pausing 10s before lm_head...
  [fp16] lm_head  M=1    matvec mr1           int8_int4    1360.7 us | mlx    1696.0 us | 1.25x  (refine 1361.0 us, 7 candidates)
  [fp16] lm_head  M=2    matvec mr2           int8_int4    1409.3 us | mlx    1666.6 us | 1.18x  (refine 1413.8 us, 7 candidates)
  [fp16] lm_head  M=4    split_k4 8x128       int8_int4    2166.8 us | mlx    2233.9 us | 1.03x  (refine 2162.4 us, 6 candidates)
  [fp16] lm_head  M=8    split_k2 8x128       int8_int4    2186.6 us | mlx    4459.6 us | 2.04x  (refine 2183.9 us, 6 candidates)
  [fp16] lm_head  M=16   split_k2 16x128      int8_int4    3568.0 us | mlx    6836.2 us | 1.92x  (refine 3488.7 us, 6 candidates)
  [fp16] lm_head  M=256  split_k2 32x128      int8_int4   59423.3 us | mlx   63920.0 us | 1.08x  (refine 56546.4 us, 6 candidates)
=== fp16: best int8_int4 config vs MLX 4-bit affine gs=32 ===
(official: refined winner and MLX re-timed alternately, medians)
shape          N      K    M | int8_int4 us   TOPS config               |     mlx us   TOPS | speedup
-----------------------------------------------------------------------------------------------------
qkv         8704   6656    1 |         70.3   1.65 matvec mr1           |       79.5   1.46 |   1.13x
qkv         8704   6656    2 |         86.9   2.67 matvec mr2           |       78.6   2.95 |   0.90x
qkv         8704   6656    4 |        119.4   3.88 split_k8 8x128       |      106.3   4.36 |   0.89x
qkv         8704   6656    8 |        124.1   7.47 split_k8 8x128       |      204.0   4.54 |   1.64x
qkv         8704   6656   16 |        187.9   9.87 split_k8 16x128      |      373.3   4.97 |   1.99x
qkv         8704   6656  256 |       2195.6  13.51 split_k4 64x64       |     2272.0  13.06 |   1.03x
o           6656   4096    1 |         34.8   1.57 matvec mr1           |       38.4   1.42 |   1.10x
o           6656   4096    2 |         49.8   2.19 matvec mr2           |       42.5   2.57 |   0.85x
o           6656   4096    4 |         62.4   3.49 split_k8 8x64        |       54.3   4.02 |   0.87x
o           6656   4096    8 |         64.8   6.73 split_k8 8x64        |       98.5   4.43 |   1.52x
o           6656   4096   16 |         96.6   9.03 split_k8 16x64       |      163.8   5.33 |   1.70x
o           6656   4096  256 |       1061.4  13.15 split_k2 128x32      |     1061.7  13.15 |   1.00x
gate_up    39936   6656    1 |        284.3   1.87 matvec mr1           |      344.9   1.54 |   1.21x
gate_up    39936   6656    2 |        301.8   3.52 matvec mr2           |      342.1   3.11 |   1.13x
gate_up    39936   6656    4 |        469.4   4.53 split_k8 8x128       |      454.1   4.68 |   0.97x
gate_up    39936   6656    8 |        472.0   9.01 split_k4 8x128       |      881.1   4.83 |   1.87x
gate_up    39936   6656   16 |        720.8  11.80 split_k8 16x128      |     1346.5   6.32 |   1.87x
gate_up    39936   6656  256 |       9627.8  14.14 full_k 32x128        |    10542.2  12.91 |   1.09x
down        6656  19968    1 |        153.8   1.73 matvec mr1           |      173.6   1.53 |   1.13x
down        6656  19968    2 |        174.6   3.05 matvec mr2           |      199.4   2.67 |   1.14x
down        6656  19968    4 |        259.9   4.09 split_k8 8x128       |      246.3   4.32 |   0.95x
down        6656  19968    8 |        269.9   7.88 split_k8 8x64        |      460.6   4.62 |   1.71x
down        6656  19968   16 |        404.2  10.52 split_k8 16x64       |      749.0   5.68 |   1.85x
down        6656  19968  256 |       4991.8  13.63 split_k4 32x128      |     5404.4  12.59 |   1.08x
lm_head   202048   6656    1 |       1360.7   1.98 matvec mr1           |     1696.0   1.59 |   1.25x
lm_head   202048   6656    2 |       1409.3   3.82 matvec mr2           |     1666.6   3.23 |   1.18x
lm_head   202048   6656    4 |       2166.8   4.97 split_k4 8x128       |     2233.9   4.82 |   1.03x
lm_head   202048   6656    8 |       2186.6   9.84 split_k2 8x128       |     4459.6   4.82 |   2.04x
lm_head   202048   6656   16 |       3568.0  12.06 split_k2 16x128      |     6836.2   6.30 |   1.92x
lm_head   202048   6656  256 |      59423.3  11.59 split_k2 32x128      |    63920.0  10.77 |   1.08x
=== fp16: estimated Muse Glimmer GEMM time per forward (qkv x52, o x52, gate_up x52, down x52, lm_head x1) ===
   M | int8_int4 ms |     mlx ms | speedup
------------------------------------------
   1 |        29.61 |      34.79 |   1.18x
   2 |        33.29 |      36.12 |   1.09x
   4 |        49.55 |      47.01 |   0.95x
   8 |        50.59 |      89.96 |   1.78x
  16 |        76.86 |     143.73 |   1.87x
 256 |       989.00 |    1066.50 |   1.08x

Adds `mlx::int8_int4_gemm`: dynamic symmetric per-token int8 activations x
symmetric per-channel int4 weights, INT32 accumulation via Metal
Performance Primitives (Metal 4). Two lowerings:

* full_k=False: one fused kernel that quantizes activations and unpacks int4
  weights into threadgroup memory per BK tile, then runs MPP matmul2d.
* full_k=True: per-token quantize kernel, then a full-K MPP
  `int8 x int4b_format` GEMM reading packed weights straight from device
  memory.

Tile sizes (BM, BN, BK) are Metal template parameters.

Adds bench_int8_int4_gemm.py, which sweeps tile configs for every Muse Glimmer
GEMM shape, compares the best config against MLX 4-bit affine
quantized_matmul (gs=32) for fp16 and bf16, and estimates total Muse Glimmer
GEMM time per forward.
@pytorch-bot

pytorch-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24467

Note: Links to docs will display an error until the docs builds have been completed.

❌ 7 New Failures, 1 Cancelled Job, 1 Pending

As of commit d92fee3 with merge base 822cb60 (image):

NEW FAILURES - The following jobs have failed:

CANCELLED JOB - The following job was cancelled. Please retry:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

- Default --bm now 8 16 32 64 128; BMs that are not a multiple of 8 are
  skipped (MPP matmul2d requires M % 8 == 0).
- --warmup (default 1): untimed runs per config / shape / M before the
  timed --iters runs, so kernels are compiled first.
- --dtype-pause (default 60s) between activation dtypes.
…fficial re-run

- Time each config as a dependent chain (each call consumes the previous
  output) and subtract the chain-only cost, so calls cannot overlap on the
  GPU as they cannot between model layers. Independent calls hid per-call
  latency and overstated small-M speedups.
- Pick the tile in two rounds: every config with --iters (10), then the
  --refine-top (3) fastest per lowering (staged, full-K) with
  --refine-iters (20).
- Re-time the refined winner against MLX alternately --final-repeats (5)
  times with --final-iters (100) and report the medians; MLX is no longer
  timed after the sweep.
…; prune bench sweep

Kernels (int8_int4_gemm.py):
- Quantize kernel: one threadgroup of TPR (256) threads per row instead of
  one SIMD group; can zero-pad xq to whole BM tiles. Full-K GEMM reads M from
  x_scale so padded xq works.
- Split-K full-K GEMM: SPLITS threadgroups per output tile each cover K/SPLITS
  and write INT32 partials; a reduce kernel sums them and applies the scales.
- Matvec for small M: streams packed weights like MLX's qmv with exact
  integer accumulation.
- Both new paths are bit-identical to the full-K kernel. The handler does not
  emit them yet.

Benchmark (bench_int8_int4_gemm.py):
- Sweeps split-K (--splits 1 2 4 8) and matvec (--matvec-max-m 2); refine
  keeps the top configs of each lowering.
- --pad-m and --quant-threads.
- Smaller default sweep: staged (BK > 0) is off (never won a serialized run),
  BM <= max(8, M), M = 4 8 16 256.
- Time-based iteration counts: each timing targets --time-budget-ms (50) of
  GPU time, clamped to [--min-iters, round cap], so large-M timings stay short
  and the GPU stays cooler.

This branch was previously deployed

1 inactive deployment
cadence — d92fee35 Deployed Nov 4, 2026 by metascroy via hifi-op-test / hifi4 #36619
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant