Skip to content

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

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

metascroy wants to merge 2 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. It uses Metal Performance Primitives (Metal 4) for INT8 x INT4 -> 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.
  • Two lowerings:
    • full_k=False ("staged"): one fused kernel that quantizes activations and unpacks weights into threadgroup memory one K tile at a time, then calls MPP matmul2d.
    • full_k=True: a per-token quantize kernel, then one full-K MPP int8 x int4b_format GEMM that reads packed weights straight from device memory.
  • Tiles: BM, BN and BK are Metal template parameters.

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).
  • Sweep: BM x BN x BK tile configs, where BK=-1 means the full-K lowering. Every config is checked against a reference, and the fastest one that passes is reported.
  • Baseline: MLX quantized_matmul, 4-bit affine, group size 32.
  • Defaults: M = 4, 8, 16, 256, 512 (speculative decoding and prefill), with fp16 and bf16 activations.
  • Estimate: total Muse Glimmer GEMM time per forward, computed as best time x number of occurrences, summed over shapes.

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 2>&1 | tee int8_int4_bench.log

This takes about 11-16 minutes on M4 Max, including a 60 s pause between fp16 and bf16. Please post int8_int4_bench.log.

Useful flags:

  • --m 4 8 16 256 512: token batch sizes.
  • --shapes qkv o gate_up down lm_head
  • --dtypes fp16 bf16
  • --bm 8 16 32 64 128: output tile rows. MPP requires a multiple of 8.
  • --bn 32 64 128: output tile columns.
  • --bk 32 64 128 -1: K tile size; -1 means full K.
  • --group-size 32: MLX baseline group size.
  • --warmup 1: untimed runs per config/shape/M before timing, so kernels are compiled first.
  • --iters 10: timed runs per config/shape/M.
  • --dtype-pause 60: seconds to pause between fp16 and bf16.
  • --verbose: print every tile config.

How to read a progress line: [bf16] qkv M=256 mlx 2355.2 us | best full_k 32x128 2166.4 us, 1.09x vs mlx.

  • full_k BMxBN means the full-K kernel with that output tile.
  • staged BMxBNxBK means the K-tiled kernel.
  • Speedup is MLX time / int8_int4 time, so > 1 means int8_int4 is faster.

M4 Max results (fp16 activations, MLX 0.32.4, default flags)

Estimated total Muse Glimmer GEMM time per forward, fp16:

M int8_int4 ms MLX gs=32 ms speedup
4 117.2 154.5 1.32x
8 84.3 199.8 2.37x
16 81.3 180.3 2.22x
256 965.6 1060.0 1.10x
512 1943.4 2161.7 1.11x

Summary:

  • M = 256 and 512 (prefill): int8_int4 is 1.10-1.11x faster than MLX gs=32. It sustains about 14 TOPS, against MLX's 12.2-13.4. Most of these winners are full-K tiles with BM >= 32 (32x128 or 128x32). These multi-millisecond points are the most reliable.
  • M = 8 and 16: BM = 8/16 tiles win. Because MPP requires BM % 8 == 0, BM = 8 is the smallest tile, so M = 4 still computes 8 rows per tile.
  • Caveats on the small-M speedups (1.3-5x):
    • MLX small-M timings vary a lot between runs. For example, MLX bf16 o at M=4 has measured anywhere from 64 to 231 us on the same M4 Max. Treat the small-M MLX baselines, and the speedups computed from them, as approximate.
    • MLX has an M=8 cliff. lm_head at M=8 takes 11.3 ms, against 4.1 ms at M=4.
    • The patched MLX may differ. The MLX backend's patched build (mlx_metal_small_batch_quantized.patch) may behave differently from pip MLX at these sizes.
    • Noise. Each point is one warmup call plus one 10-call timed batch, so small-M numbers are noisy. Some M=4 points are slower than the M=8 point for the same shape, which is noise.
  • Hypothesis: M4 has no faster int8 matrix rate, which would explain why the prefill gain is only about 10%. M5 is the interesting test.
Full fp16 output
device: Apple M4 Max  mlx: 0.32.4  M: [4, 8, 16, 256, 512]  dtypes: ['fp16', 'bf16']  mlx group_size: 32  warmup: 1  iters: 10
  [fp16] qkv      M=4    mlx     456.0 us | best full_k 32x128 372.5 us, 1.22x vs mlx
  [fp16] qkv      M=8    mlx     450.2 us | best staged 8x64x128 204.2 us, 2.20x vs mlx
  [fp16] qkv      M=16   mlx     301.8 us | best full_k 16x128 204.6 us, 1.48x vs mlx
  [fp16] qkv      M=256  mlx    2377.1 us | best full_k 64x64 2126.9 us, 1.12x vs mlx
  [fp16] qkv      M=512  mlx    4597.4 us | best full_k 32x128 4194.0 us, 1.10x vs mlx
  [fp16] o        M=4    mlx     219.9 us | best full_k 32x32 186.0 us, 1.18x vs mlx
  [fp16] o        M=8    mlx     261.9 us | best full_k 8x128 75.3 us, 3.48x vs mlx
  [fp16] o        M=16   mlx     201.6 us | best full_k 16x64 100.9 us, 2.00x vs mlx
  [fp16] o        M=256  mlx    1173.1 us | best full_k 128x32 1026.7 us, 1.14x vs mlx
  [fp16] o        M=512  mlx    2181.1 us | best full_k 32x128 1995.1 us, 1.09x vs mlx
  [fp16] gate_up  M=4    mlx    1280.5 us | best staged 8x32x128 1085.2 us, 1.18x vs mlx
  [fp16] gate_up  M=8    mlx    1998.2 us | best staged 8x128x64 768.7 us, 2.60x vs mlx
  [fp16] gate_up  M=16   mlx    1851.7 us | best full_k 16x128 701.2 us, 2.64x vs mlx
  [fp16] gate_up  M=256  mlx   10650.4 us | best full_k 32x128 9489.0 us, 1.12x vs mlx
  [fp16] gate_up  M=512  mlx   22275.8 us | best full_k 128x32 19743.5 us, 1.13x vs mlx
  [fp16] down     M=4    mlx     935.0 us | best full_k 8x64 562.0 us, 1.66x vs mlx
  [fp16] down     M=8    mlx     913.6 us | best staged 8x32x128 531.4 us, 1.72x vs mlx
  [fp16] down     M=16   mlx     987.9 us | best full_k 16x32 493.6 us, 2.00x vs mlx
  [fp16] down     M=256  mlx    5188.3 us | best full_k 64x32 4999.5 us, 1.04x vs mlx
  [fp16] down     M=512  mlx   10540.9 us | best full_k 128x32 9609.9 us, 1.10x vs mlx
  [fp16] lm_head  M=4    mlx    4137.7 us | best full_k 8x128 2513.4 us, 1.65x vs mlx
  [fp16] lm_head  M=8    mlx   11340.8 us | best full_k 8x128 2155.2 us, 5.26x vs mlx
  [fp16] lm_head  M=16   mlx    6424.2 us | best full_k 16x128 3328.6 us, 1.93x vs mlx
  [fp16] lm_head  M=256  mlx   51801.3 us | best full_k 32x128 48196.5 us, 1.07x vs mlx
  [fp16] lm_head  M=512  mlx  102750.0 us | best full_k 32x128 95198.9 us, 1.08x vs mlx

=== fp16: best int8_int4 config vs MLX 4-bit affine gs=32 ===
shape          N      K    M | int8_int4 us   TOPS config               |     mlx us   TOPS | speedup
-----------------------------------------------------------------------------------------------------
qkv         8704   6656    4 |        372.5   1.24 full_k 32x128        |      456.0   1.02 |   1.22x
qkv         8704   6656    8 |        204.2   4.54 staged 8x64x128      |      450.2   2.06 |   2.20x
qkv         8704   6656   16 |        204.6   9.06 full_k 16x128        |      301.8   6.14 |   1.48x
qkv         8704   6656  256 |       2126.9  13.95 full_k 64x64         |     2377.1  12.48 |   1.12x
qkv         8704   6656  512 |       4194.0  14.15 full_k 32x128        |     4597.4  12.90 |   1.10x
o           6656   4096    4 |        186.0   1.17 full_k 32x32         |      219.9   0.99 |   1.18x
o           6656   4096    8 |         75.3   5.79 full_k 8x128         |      261.9   1.67 |   3.48x
o           6656   4096   16 |        100.9   8.65 full_k 16x64         |      201.6   4.33 |   2.00x
o           6656   4096  256 |       1026.7  13.60 full_k 128x32        |     1173.1  11.90 |   1.14x
o           6656   4096  512 |       1995.1  13.99 full_k 32x128        |     2181.1  12.80 |   1.09x
gate_up    39936   6656    4 |       1085.2   1.96 staged 8x32x128      |     1280.5   1.66 |   1.18x
gate_up    39936   6656    8 |        768.7   5.53 staged 8x128x64      |     1998.2   2.13 |   2.60x
gate_up    39936   6656   16 |        701.2  12.13 full_k 16x128        |     1851.7   4.59 |   2.64x
gate_up    39936   6656  256 |       9489.0  14.34 full_k 32x128        |    10650.4  12.78 |   1.12x
gate_up    39936   6656  512 |      19743.5  13.79 full_k 128x32        |    22275.8  12.22 |   1.13x
down        6656  19968    4 |        562.0   1.89 full_k 8x64          |      935.0   1.14 |   1.66x
down        6656  19968    8 |        531.4   4.00 staged 8x32x128      |      913.6   2.33 |   1.72x
down        6656  19968   16 |        493.6   8.62 full_k 16x32         |      987.9   4.31 |   2.00x
down        6656  19968  256 |       4999.5  13.61 full_k 64x32         |     5188.3  13.12 |   1.04x
down        6656  19968  512 |       9609.9  14.16 full_k 128x32        |    10540.9  12.91 |   1.10x
lm_head   202048   6656    4 |       2513.4   4.28 full_k 8x128         |     4137.7   2.60 |   1.65x
lm_head   202048   6656    8 |       2155.2   9.98 full_k 8x128         |    11340.8   1.90 |   5.26x
lm_head   202048   6656   16 |       3328.6  12.93 full_k 16x128        |     6424.2   6.70 |   1.93x
lm_head   202048   6656  256 |      48196.5  14.29 full_k 32x128        |    51801.3  13.29 |   1.07x
lm_head   202048   6656  512 |      95198.9  14.47 full_k 32x128        |   102750.0  13.40 |   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
------------------------------------------
   4 |       117.21 |     154.49 |   1.32x
   8 |        84.29 |     199.78 |   2.37x
  16 |        81.34 |     180.26 |   2.22x
 256 |       965.59 |    1060.03 |   1.10x
 512 |      1943.41 |    2161.70 |   1.11x

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.

❌ 1 New Failure, 1 Unclassified Failure

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

NEW FAILURE - The following job has failed:

UNCLASSIFIED FAILURE - DrCI could not classify the following job because the workflow did not run on the merge base. The failure may be pre-existing on trunk or introduced by this PR:

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.

This branch was previously deployed

1 inactive deployment
cadence — c0c33222 Deployed Nov 2, 2026 by metascroy via hifi1s-op-test / hifi1s #36107
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