Skip to main content

5 posts tagged with "metal"

View All Tags

The Sign Was the Whole Paper: Debugging a KV Cache Compressor on Real Models

· 18 min read
Rajveer Rathod
Author of VeloxQuant-MLX

How one arbitrary minus sign turned a KV cache compressor from useful into noise — and what it took to prove it on real models


I had a KV cache compression method in my library called Q-Filters. It had tests. It had docs. It had a benchmark harness with committed results. It shipped in version 0.31.0.

It also didn't work.

Not "worked slightly worse than the paper." Not "worked on some heads." On real trained weights, its scoring signal correlated with true attention at −0.032 — statistically indistinguishable from a coin flip, and pointing the wrong way about half the time.

Fixing it meant implementing the paper properly, writing two Metal kernels, finding a position-encoding bug that made end-to-end measurement impossible, and throwing away two benchmark harnesses that produced confident, meaningless numbers. Along the way the calibrated version went from ppl 598 to 16.3 on the same workload.

This is the whole run, including the parts where I was wrong.

A 5.65× Metal Kernel

· 25 min read
Rajveer Rathod
Author of VeloxQuant-MLX

How I fused KIVI's KV-cache quantization into two bit-exact Metal kernels, measured a 1.40×–5.65× op-level speedup, watched it vanish completely end-to-end — and the four separate times my own benchmarks gave me confidently wrong answers along the way.


There's a particular satisfaction in watching a GPU kernel you wrote beat the framework's version by 5×. There's a different feeling entirely when you plug it into the actual model and the tokens-per-second doesn't move at all.

This post is about both, and about the part in between: four occasions where my benchmarks confidently told me something false. One said the kernel was 11× slower than baseline. One said it was 28× faster. One said quantization consumed 97.83% of prefill time, when the real figure is about 1–2%. One showed a clean 127 MB memory saving that turned out to be nothing at all.

All four were my fault. All four produced a clean-looking number with a plausible story attached, which is exactly what made them dangerous.

The kernel is real, it's bit-exact, it ships, and I'd merge it again. But the honest headline is the one above, and the useful content is why both halves of it are true at the same time.

This is the long version, with the complete experimental record — every measurement generation, including the ones that were wrong.


What we're actually optimizing

If you run a language model locally, the thing that eventually stops you isn't compute. It's the KV cache.

Every token the model has seen leaves behind a key and a value vector in every attention layer. The model needs them to attend to the past, so they stay resident for the whole generation. The cache grows linearly with context — and unlike model weights, which you load once, it grows while you're using it.

On a 7B model at 32k context that's several gigabytes, comparable to the quantized weights themselves. On a Mac with unified memory, it's the difference between a long conversation working and your machine swapping itself to death.

KIVI (Liu et al., ICML 2024) is one answer, and it's the baseline every other algorithm in this library gets measured against. The insight is that keys and values want to be quantized along different axes:

  • Keys are quantized per-channel — each channel gets its own scale, computed across a group of tokens.
  • Values are quantized per-token — each token gets its own scale, computed across a group of channels.

Why asymmetric? Key tensors have a few channels with consistently huge magnitudes. Quantize per-token and those outliers blow up the scale for every other channel sharing the group. Value tensors lack that structure, and per-token suits them better.

A third piece matters for everything that follows: the most recent residual_length tokens stay in fp16. They're what attention weights most heavily, and they're also the tokens whose group isn't full yet. Once enough fresh tokens accumulate, they get quantized as a batch and folded into the compressed store. That batching event is a flush, and it is the operation this entire post is about.

The quantization itself is textbook asymmetric min/max:

zero = min(group)
scale = (max(group) - min(group)) / (2^bits - 1)
q = round((x - zero) / scale)
recon = q * scale + zero

In MLX this is roughly eight array operations: reshape to expose the group axis, min, max, subtract, divide, round, clip, multiply, add, reshape back.

Eight operations means eight kernel launches and — the expensive part — eight round trips to memory. Every intermediate is materialized. The quantized codes, which never needed to exist as a full-size tensor, get written to RAM and read straight back.

That's the target. One fused kernel, one pass, no intermediates.


The kernel

The layout problem that shapes everything

KV tensors are [batch, heads, seq, head_dim], row-contiguous. Flatten batch and heads and you get [BH, S, D], where element (bh, s, d) sits at bh*S*D + s*D + d.

Which means the two modes face opposite problems:

  • Values (per-token) — the group runs along D, the contiguous axis. Adjacent elements in a group are adjacent in memory.
  • Keys (per-channel) — the group runs along S, the strided axis. Adjacent elements are D floats apart. Typically 128.

My first version was one kernel handling both, which meant transposing the key tensor so the token axis became contiguous, then reusing the same code path.

That transpose was the whole problem. It's a full-size materializing copy — exactly the memory traffic the kernel exists to eliminate. I'd removed eight round trips and added one large one back. On the key path, the "optimized" kernel was a net loss.

So I threw it out and wrote two kernels, one per layout.

Kernel A — per-channel keys: one thread, one whole group

The trick is almost aggressively simple: give each thread an entire group, and don't reduce at all.

uint tid = thread_position_in_grid.x;

const uint BH = x_shape[0];
const uint S = x_shape[1];
const uint NG = (S + GROUP_SIZE - 1u) / GROUP_SIZE; // token groups

if (tid >= BH * NG * DHEAD) { return; }

const uint d = tid % DHEAD;
const uint r = tid / DHEAD;
const uint grp = r % NG;
const uint bh = r / NG;

const uint base = bh * S * DHEAD + d;
const uint s0 = grp * GROUP_SIZE;
const uint s1 = min(s0 + GROUP_SIZE, S);

float gmin = INFINITY;
float gmax = -INFINITY;
for (uint s = s0; s < s1; ++s) {
float v = float(x[base + s * DHEAD]);
gmin = min(gmin, v);
gmax = max(gmax, v);
}

Each thread strides by DHEAD — 128 floats between consecutive reads. In isolation that looks like the worst access pattern available.

But look at the indexing. d = tid % DHEAD means consecutive threads take consecutive channels. At any step of that loop, the 32 threads in a SIMD group read 32 adjacent addresses. The warp's access is fully coalesced. The stride is per-thread; the warp moves through memory as a solid block.

And because a thread owns its group outright, there is no cross-thread reduction. No threadgroup memory, no barriers, no butterfly, no transpose. The strided-looking layout turned out to need the least machinery.

Kernel B — per-token values: one SIMD group, one quantization group

The contiguous axis wants the mirror image. Lanes split a single group and cooperate:

uint lane = thread_position_in_threadgroup.x;
uint gid = threadgroup_position_in_grid.x;

// Whole threadgroups exit together, so every lane still reaches the
// butterfly below — a divergent return would deadlock the shuffle.
if (gid >= x_shape[0] * S * NGD) { return; }

float gmin = INFINITY;
float gmax = -INFINITY;
for (uint i = lane; i < GROUP_SIZE; i += 32u) {
uint d = d0 + i;
if (d < d1) {
float v = float(x[row_base + d]);
gmin = min(gmin, v);
gmax = max(gmax, v);
}
}

// Butterfly: after 5 XOR shuffles every lane holds the group-wide min/max.
for (uint off = 16u; off > 0u; off >>= 1u) {
gmin = min(gmin, simd_shuffle_xor(gmin, off));
gmax = max(gmax, simd_shuffle_xor(gmax, off));
}

The simd_shuffle_xor butterfly is the nice part. Five shuffles reduce 32 lanes to a min/max that every lane already holds — no broadcast step. And because lanes advance in lockstep, it needs no threadgroup memory and no barriers, unlike a tree reduction.

Note the comment on the bounds check. That return must be uniform across the threadgroup. If individual lanes bailed early, the survivors would shuffle against threads that no longer exist and the reduction would hang or return garbage. Whole threadgroups exit together, so every lane reaching the butterfly reaches it with all 32 partners intact.

At KIVI's default GROUP_SIZE=32 this is an exact fit: one lane per element, one butterfly, done.

Two optimizations I was sure would work, and didn't

I assumed caching each group in registers would help — read once, use twice, skip the second global load. Controlled same-process A/B said otherwise:

kernelregister cachingresult
per-channel (keys)group in registers0.76× at S=2048 — actively harmful
per-token (values)group in registers1.00×–1.02× — exactly neutral

In the channel kernel, a whole group is GROUP_SIZE floats per thread. The occupancy that costs outweighs the saved loads, which were hitting cache anyway. In the token kernel it's split across 32 lanes, so it's cheap — and buys nothing, for the same cache reason.

Both reverted, and the REG_SLOTS machinery deleted. I also swept threadgroup width and found the defaults (256 for channel, 32 for token) already optimal.

The pattern

Two hypotheses, both plausible, both wrong, both settled in about twenty minutes by a controlled A/B rather than by argument. The measurement was cheaper than the reasoning.


Three ways to be off by one bit

My acceptance criterion was bit-exactness against the MLX reference — not "close enough," but identical output. That turned out to be the most instructive constraint in the project, because it surfaced three failure modes a tolerance test sails straight past.

1. FMA contraction

My first parity run failed on 192 of 300 configurations — and every failure was off by exactly 1 ULP. That uniformity is a fingerprint: not an algorithm bug, a rounding difference.

The culprit was q * scale + gmin. Metal's compiler sees a multiply feeding an add and contracts it into a fused multiply-add: one instruction, one rounding. MLX does them separately, with two roundings. Same math, different result on ~0.02% of elements.

The fix is to break the pattern the optimizer looks for:

float prod = q * scale; // NOT an fma
out[base + s * DHEAD] = T(prod + gmin);

The irony is that the fused version is more accurate — it carries more intermediate precision. But the contract is parity with the reference, not maximum accuracy, so the less accurate version is the correct one. Uncomfortable sentence; right call. 300/300 after the fix.

2. Rounding mode

Metal's round() is half-away-from-zero. mx.round is half-to-even. They agree on everything except exact .5 codes — rare in random data, and systematically common in real quantization, because uniform grids produce exact midpoints. The fix is rint(). There's a test pinning it with inputs hand-built to land on .5.

3. Padding semantics

When a group doesn't divide evenly, MLX pads the tail by replicating the edge value, x[..., -1:], not with zeros. Pad with zeros and you've silently dragged gmin to 0 for every ragged group, corrupting the scale. Since every pad slot holds that same value, folding it in once is equivalent to looping:

if (s1 < s0 + GROUP_SIZE) {
float pad_val = float(x[base + (S - 1u) * DHEAD]);
gmin = min(gmin, pad_val);
gmax = max(gmax, pad_val);
}

All three have dedicated regression tests now. They're the kind of bug that produces plausible output — slightly different, never obviously broken. Without bit-exactness as the bar, all three would have shipped.


The bug that made generation hang forever

My favourite failure of the project, because the fix made the code simpler and the symptom was so much worse than the cause.

mx.fast.metal_kernel JIT-compiles from source. I was passing shape constants through the header as #defines, which lets the compiler turn tid % DHEAD into a shift-and-mask instead of integer division. Good idea for DHEAD — it's the model's head dimension, fixed for the life of a cache.

I did the same for the sequence length.

The sequence length grows by one every decode step. So every token triggered a fresh shader compilation. Generation didn't crash and didn't error — it just stopped. I killed the process after several minutes with no output.

The fix was to pass shape as a runtime buffer. MLX provides x_shape for free on every input, so this meant deleting code, not adding it.

Before → after

Before: hung indefinitely (killed after minutes) After: 1.0 second

Guarded now by a test that runs 55 sequence lengths through both kernels and asserts the dispatch cache holds exactly 2 entries — not 110. That test isn't checking performance. It's checking that one specific catastrophic bug can't come back.


Four benchmarks that lied

Here the post stops being about GPU programming and starts being about measurement, which is the part I'd actually want to read.

Lie #1 — "Quantization is 97.83% of prefill"

I wanted to know how much runtime quantization accounted for, so I instrumented the call: timer before, timer after, mx.eval() in between to force the computation. Here's the raw output:

# mlx-community/Llama-3.2-3B-Instruct-4bit layers=28

PREFILL-dominated (8k prompt, 4 new tokens):
prefill metal=False wall= 20.754s quant= 20303.3ms (97.83% of wall) calls=224 [keys 19483.2ms / values 820.0ms]
prefill metal=True wall= 20.940s quant= 20489.1ms (97.85% of wall) calls=224 [keys 19805.5ms / values 683.6ms]

DECODE-dominated (2k prompt, 240 new tokens):
decode metal=False wall= 10.114s quant= 4829.6ms (47.75% of wall) calls=504 [keys 4550.0ms / values 279.6ms]
decode metal=True wall= 9.952s quant= 4682.5ms (47.05% of wall) calls=504 [keys 4457.8ms / values 224.7ms]

Quantization was apparently the entire bottleneck. It was nonsense, and the mistake is in the description above: mx.eval() inside the measured region.

MLX is lazily evaluated. Operations build a graph; nothing computes until something forces it. By calling eval() inside my timer I wasn't measuring quantization — I was measuring every pending operation in the graph at that moment: attention, the MLP, the whole layer stack, all attributed to the one function that happened to trigger the flush.

Claimed vs actual

Claimed: 97.83% of prefill Actual: ~1–2% of prefill

Off by roughly fifty times, in the flattering direction, and it looked entirely plausible.

Notice too that the numbers are self-refuting if you read them properly: metal=False and metal=True report the same 97.8% share. A measurement that can't distinguish the two arms is measuring something other than the thing you changed.

The one genuinely useful output was incidental: calls=224 across 28 layers on an 8k prompt means 8 flushes per layer — which revealed that mlx_lm chunks prefill at 2048 tokens. That number matters later.

Lesson

In a lazy framework, a synchronization point inside your timer measures everything the framework was putting off. Force the graph to a known state before you start the clock.

Lie #2 — "0.09× and 28.08×"

Fresh microbenchmark, both flush sizes:

per-flush cost (keys+values, H=8 D=128, 28 layers)

S off ms on ms speedup | x28 layers off on saved
32 1.1334 12.4505 0.09x | 31.7ms 348.6ms -316.9ms
2048 19.8718 0.7076 28.08x | 556.4ms 19.8ms 536.6ms

Eleven times slower at small sizes, twenty-eight times faster at large ones. I nearly wrote a whole section theorizing about launch-overhead crossovers — there's a tidy story available where fixed dispatch cost dominates at S=32 and bandwidth savings dominate at S=2048.

The story was fiction. I had left an LLM benchmark running in the background on the same GPU.

Same benchmark, idle GPU:

S off ms on ms speedup | x28 layers off on saved
32 0.5050 0.3601 1.40x | 14.1ms 10.1ms 4.1ms
2048 3.8337 0.6785 5.65x | 107.3ms 19.0ms 88.3ms

Both processes were fighting for the same hardware, and contention landed unevenly across runs. These weren't noisy-around-the-truth — off by 15× in one direction and 5× in the other, and they looked like a coherent narrative. That's the dangerous part. Random noise looks random. Contention produces confident, structured, wrong answers.

The downstream damage is worth showing, because the wrong numbers propagated into a wrong prediction:

--- predicted end-to-end (from the CONTENDED numbers) ---
PREFILL 8k prompt: 4 chunks x 537ms saved = 2146ms of ~21000ms -> 10.2% faster
DECODE 240 tokens: 7 flushes x -316.9ms = -2218ms of ~10000ms -> -22.18% "faster"

--- predicted end-to-end (from the IDLE numbers) ---
PREFILL 8k prompt: 4 chunks x 88ms saved = 353ms of ~21000ms -> 1.7% faster
DECODE 240 tokens: 7 flushes x 4.1ms = 28ms of ~10000ms -> 0.28% faster

A predicted 10.2% prefill win and a 22% decode regression, versus the truth of +1.7% and +0.28%. Had I stopped there, I'd have gone hunting for a decode regression that never existed.

Lesson

A GPU is one resource. Check what else is running — and be most suspicious when a surprising result arrives with a satisfying explanation already attached.

Lie #3 — the benchmark that gave four different answers

Subtler, and I think the most broadly applicable. For the same kernel and the same configuration, my attempts produced 0.31×, 1.0×, 2.16×, and 3.4×. Not scatter around a value — four different conclusions, each internally consistent.

The root cause: I was calling the function repeatedly on the same input tensor.

MLX can recognize it has already computed something and reuse the result. The reference path — eight standard, individually cacheable array ops — benefits enormously. My custom kernel benefits far less. So the "baseline" was quietly handed a shortcut the kernel couldn't take, and every extra repetition widened the gap. Layering best-of-N on top amplified it further, because best-of-N systematically selects the run where caching helped most.

I got this badly wrong. I concluded the kernel was slower, set the feature flag off, and wrote that conclusion into the code and the tests. It was only when two independently-designed methods disagreed with me that I went back:

  1. Interleaved A/B — alternate on/off inside one process, so drift and thermal state hit both arms equally.
  2. Rotating input pool — cycle 20 distinct tensors so nothing can be reused.

Both landed at 1.36×–2.14×, agreeing with each other and disagreeing with me. I reverted the flag and corrected the tests.

The benchmark that ships in test_kivi_quant.py now does both, and its docstring explains both traps so the next person doesn't re-derive them.

Lesson

If your benchmark reuses inputs, you're partly measuring your framework's cache. And when two well-designed methods agree against your conclusion, the conclusion is what's wrong.

Lie #4 — the 127 MB memory saving that wasn't

This one I caught only because I ran a third model.

Llama's peak memory, from a clean interleaved run:

peak memory (GB): fp16=2.604 off=2.726 on=2.599

A 127 MB reduction with the kernel on. And there's a beautiful explanation sitting right there: the fused kernel eliminates MLX's intermediate tensors, so of course the high-water mark drops. Mechanistically plausible, exactly the result I wanted, and it would have made this post better.

Then the other two models came back:

modelkernel offkernel ondelta
Llama-3.2-3B2.726 GB2.599 GB−127 MB
Qwen2.5-7B5.080 GB5.130 GB+50 MB
Mistral-7B4.921 GB4.941 GB+20 MB

Two of three moved the opposite direction. It's allocator noise — MLX's memory pool responds to allocation ordering in ways unrelated to which kernel ran, and 127 MB out of 2.7 GB sits well inside that.

The near-miss

If I'd only run the model named in the original issue, I'd have shipped a false claim with a compelling mechanism attached. The third model is what turns a result into a finding — and the strongest argument for running it is precisely when the first one already told you what you hoped to hear.

There's a deeper reason this had to be noise, which I'll come back to at the end: quantize-then-dequantize cannot reduce peak memory, by construction.


The full end-to-end record

Four generations of end-to-end measurement, in the order I ran them. I'm including the early ones because their disagreement is the point.

Generation 1 — single-shot, three prompt lengths

First real run. Single-shot timings, no repeats, Llama-3.2-3B-Instruct-4bit, 28 layers, 8 KV heads, head_dim=128:

## PREFILL (prompt tokens/sec)
prompt tok fp16 kernel off kernel on on vs off flush/layer
559 488.2 481.6 486.0 1.01x 512
2059 463.4 451.7 465.7 1.03x 2016
8209 386.4 355.9 351.6 0.99x 8160

## DECODE (generation tokens/sec, 120 tokens)
config tok/s vs fp16 peak MB KV comp
fp16 46.06 100% 0.0 -
off 46.00 100% 0.0 4.99x
on 44.24 96% 0.0 4.99x

decode kernel on vs off: 0.962x

Two problems. First, peak MB reads 0.0 — a unit bug on my side: mlx_lm.stream_generate reports peak_memory in GB, and I was dividing by 1024**2 as though it were bytes. The raw JSON shows the real values hiding at 2.48e-06.

Second, and more importantly: decode at 0.962× looks like a 4% regression. Single-shot numbers on one prompt, with no repeat structure — nowhere near enough to distinguish a real regression from thermal drift. Generation 3 is what settles it.

Generation 2 — the sync-instrumented run

Lie #1 above. Produced the 97.83% figure, which was wrong, and the 2048-token prefill chunking discovery, which was right and load-bearing.

Generation 3 — repeated runs with medians

Same model, but now multiple repeats per configuration reporting median/min/max, so spread is visible:

## PREFILL (prompt tok/s, max_tokens=4)
prompt=2065 tok (~2 chunks of 2048)
fp16 median= 468.49 min= 448.16 max= 473.22 vs fp16 100.0%
off median= 451.76 min= 420.91 max= 464.51 vs fp16 96.4%
on median= 460.48 min= 424.83 max= 470.01 vs fp16 98.3%
-> kernel on vs off: 1.019x

prompt=8213 tok (~5 chunks of 2048)
fp16 median= 300.09 min= 280.98 max= 352.22 vs fp16 100.0%
off median= 297.13 min= 291.43 max= 320.70 vs fp16 99.0%
on median= 296.32 min= 293.69 max= 309.83 vs fp16 98.7%
-> kernel on vs off: 0.997x

## DECODE (generation tok/s, 240 tokens, 2k prompt)
240 new tokens
fp16 median= 38.48 min= 38.01 max= 40.82 vs fp16 100.0%
off median= 40.21 min= 39.72 max= 41.10 vs fp16 104.5%
on median= 39.78 min= 37.22 max= 41.05 vs fp16 103.4%
-> kernel on vs off: 0.989x

peak memory (GB): fp16=2.604 off=2.726 on=2.599
KV compression: off=5.02x on=5.02x

This table is the most useful thing I measured, and not because of the ratios. Look at the min/max columns.

At 8k prompt, the unchanged fp16 baseline — same code, same model, nothing swapped — ranges from 280.98 to 352.22 tok/s. That's a ±25% spread from thermal state and system scheduling alone, on a configuration where nothing about the code changed between runs.

Now recall the prediction: +1.7% on prefill. Looking for a 1.7% effect through ±25% run-to-run variance is like weighing a signature on a bathroom scale. No amount of care in the on/off comparison fixes that; the instrument simply doesn't resolve the quantity.

Note also that KIVI itself (off, 96.4%) is slightly slower than fp16 at 2k — the quantization work is real, it's just small. And the decode ordering (off at 104.5% of fp16, i.e. faster than no quantization at all) is a tell that we're deep inside noise, since compressing the cache cannot make decode faster than not compressing it.

Generation 1's apparent 0.962× decode regression shows up here as 0.989×, with overlapping min/max ranges. It was drift.

Generation 4 — three models, interleaved, single process

The final protocol: one process per model, configurations interleaved rather than run in blocks, output text compared byte-for-byte between arms.

### mlx-community/Llama-3.2-3B-Instruct-4bit
layers=28 kv_heads=8 prompt=2065 tok
-> kernel on vs off: prefill 1.019x (2k) / 0.997x (8k) decode 0.989x
-> identical text on/off: True

### mlx-community/Qwen2.5-7B-Instruct-4bit
layers=28 kv_heads=4 prompt=2064 tok
config prefill tok/s decode tok/s peak GB KV comp
fp16 206.0 23.61 5.105 -
off 203.7 23.40 5.080 4.94x
on 208.3 23.26 5.130 4.94x
-> kernel on vs off: prefill 1.023x decode 0.994x
-> identical text on/off: True

### mlx-community/Mistral-7B-Instruct-v0.3-4bit
layers=32 kv_heads=8 prompt=2054 tok
config prefill tok/s decode tok/s peak GB KV comp
fp16 143.4 20.86 4.891 -
off 140.0 20.34 4.921 4.75x
on 140.2 20.26 4.941 4.75x
-> kernel on vs off: prefill 1.001x decode 0.996x
-> identical text on/off: True

Consolidated:

modellayersKV headsprefilldecodeidentical textKV compression
Llama-3.2-3B-4bit2881.019× / 0.997×0.989×5.02×
Qwen2.5-7B-4bit2841.023×0.994×4.94×
Mistral-7B-v0.3-4bit3281.001×0.996×4.75×

Everything within ±2%, which given ±25% baseline variance is indistinguishable from nothing.

Qwen is the most valuable row. Four KV heads instead of eight means a completely different flush geometry, exercising different bounds-check and ragged-tail paths. It still produces byte-identical output — which is the strongest evidence that the bit-exactness work held up outside the unit tests.


Why it's invisible, and why that was predictable

The true per-flush picture, idle GPU, Apple M4, 8 KV heads × 128 head dim, keys and values together:

flush sizekernel offkernel onspeedup
32 (decode)0.5050 ms0.3601 ms1.40×
2048 (prefill chunk)3.8337 ms0.6785 ms5.65×

Recall that mlx_lm chunks prefill at 2048 tokens. That's a happy accident: prefill flushes land exactly on the kernel's strongest case, where there's enough work to amortize dispatch and memory-traffic savings dominate. Decode flushes are always small — residual_length tokens, 32 here — the weak case.

Scale it to 28 layers, an 8k prompt (4 prefill chunks), 240 decode tokens (7 flushes):

phasesavedof wallpredictedmeasured
prefill (8k)353 ms~21,000 ms+1.7%0.997×
decode (240 tok)28 ms~10,000 ms+0.28%0.989×

There's the whole story, available before running a single model.

A 5.65× speedup on 1.7% of the work is a 1.7% speedup. Amdahl's law doesn't care how good the kernel is.

And 1.7% is four times smaller than the noise floor of the measurement. The end-to-end result wasn't a disappointment — it was arithmetic, and I could have computed it in ten minutes before writing any Metal at all.


So why does the kernel ship?

Given that it's invisible end-to-end, why merge it?

  • It's free. Bit-exact output, no regression on any model, 84 dedicated tests, byte-identical generations across three architectures. It never makes anything slower.
  • It removes a floor. Quantization is ~1–2% of runtime now. If the surrounding work gets faster — better attention kernels, better matmuls — that share grows. Fixed costs matter more as everything else shrinks.
  • Op-level wins are real even when invisible. 1.40× and 5.65× are honest measurements of the operation. That the operation is a small slice of the whole is a separate fact, and both belong in the report.

But the real reason to stay clear-eyed: the kernel was never where the memory win lived.

KIVI as implemented does quantize-then-dequantize — it computes the compressed representation and immediately expands it back to fp16 for attention. The 4.75×–5.02× compression is real arithmetic, but it is an accounting result, not a storage result. The tensor sitting in memory is still fp16.

This is also the structural reason Lie #4 had to be noise: if nothing is stored in compressed form, no kernel that computes the compression faster can reduce the high-water mark. I should have known the 127 MB was suspect on those grounds alone, before the other two models contradicted it.

To actually reduce memory you need two more things:

  1. Packed storage — keep quantized codes as uint8, never materialize the fp16 reconstruction.
  2. Dequant-in-SDPA — teach attention to read packed codes directly, so expansion happens in registers and never in RAM.

That's where both the memory win and the real speedup live, because it doesn't fuse 1–2% of the work — it shrinks the tensors every other operation has to move.

The kernel was step one. It was worth doing. It just isn't the point.


What I'd take away

If you're writing GPU kernels against a framework like MLX or PyTorch:

Match the thread mapping to the memory layout, not to intuition. The strided access pattern needed less machinery than the contiguous one — no reduction, no barriers, no transpose. My first instinct, transposing to make the layout "nice," was the version that lost.

Bit-exactness is a debugging tool, not just a correctness bar. FMA contraction, rounding mode, and padding semantics all produce plausible output. A tolerance test passes all three. Demanding identical output turned three silent behavioral differences into three failing tests with obvious causes.

Never specialize on a value that grows. Baking sequence length into a JIT header compiles one shader per token. The symptom was an indefinite hang; the fix deleted code.

Estimate the ceiling before you optimize. Ten minutes with Amdahl's law would have predicted the end-to-end result up front. It wouldn't have changed the decision to build it — but it would have set the expectation correctly, and I'd have spent my time on the parts that were load-bearing.

Measure your noise floor before your effect. The single most useful number in this entire project was ±25% — the run-to-run spread of an unchanged baseline. Without it, every ratio in every table is unfalsifiable.

Be most suspicious when the number is good. All four bad measurements came with satisfying stories. 97.83% "proved" the work mattered. 28.08× had a tidy overhead-crossover explanation. The 127 MB saving had a clean mechanism. Every one was wrong, and the plausible explanation is what let each survive as long as it did.

And: run the third model.


Kernels live in veloxquant_mlx/metal/src/, tests in veloxquant_mlx/tests/metal/test_kivi_quant.py. See the KIVI algorithm reference for the method itself, and Metal kernels for how kernels are dispatched library-wide. KIVI: Liu et al., ICML 2024. All measurements on an Apple M4 with MLX.

TensorOps Research: What We Learned Optimizing KV Caches

· 11 min read
Rajveer Rathod
Author of VeloxQuant-MLX

A deep-dive into Apple's Metal Shading Language specification, what TensorOps promised, why it didn't work through MLX, and the two real improvements we shipped from three sessions of research.


Where This Story Starts

A few weeks ago I shipped a FlashAttention-style Metal kernel for VeloxQuant-MLX that was correct, fast in isolation, and completely useless end-to-end. The blog post about that mistake is here. The short version: I built a fused dequant+attention kernel that beat mx.fast.scaled_dot_product_attention by 1.3× in benchmarks — then discovered mlx_lm had already eliminated the dequant cost via a persistent fp16 K_hat buffer, making my kernel 3-4× slower than the baseline it was supposed to beat.

I kept the kernel in the library as an opt-in API. It's correct, it's tested, and it loses.

After writing that post, a reader suggested I look at the Metal Shading Language specification — specifically Metal 4, which Apple released with macOS Sequoia. The argument was: Metal 4 adds hardware tensor operations that could replace the slow part of the kernel. Maybe there was a path to winning that I hadn't found yet.

So I read the spec. All 346 pages of it.

This is what I found.


The Kernel's Hot Path

To understand why the spec research mattered, I need to explain the bottleneck.

The fused SDPA kernel computes attention directly from VecInfer's compressed codebook indices without materializing the fp16 key matrix. For each query, it needs to compute a Look-Up Table first:

LUT[sub, centroid] = q_sub_vector · codebook_row[centroid]

For VecInfer's default config (n_sub=16, sub_dim=8, n_centroids=256), this is a [16, 8] @ [8, 256] matrix multiply — 4,096 dot products. In the current kernel, 32 GPU lanes stripe these across the SIMD group: each lane computes 128 scalar dot products independently.

This LUT precompute is Phase 0. Everything else — the online softmax, the V accumulation — comes after. If the LUT is slow, everything is slow.

The Metal 4 spec describes two potential hardware paths to speed this up:

  1. simdgroup_float8x8 (Metal 2.3+, Section 2.4 / 6.7): 8×8 hardware matmul tiles via simdgroup_multiply_accumulate. Available today.
  2. TensorOps matmul2d (Metal 4+, Section 7.2): A full hardware matrix multiply API with a cooperative_tensor destination. Potentially much faster.

I tested both.


Attempt 1: simdgroup_float8x8

The Metal spec (Section 6.7, Table 6.9) shows simdgroup_float8x8 as a cooperative 8×8 float matrix multiply tile. The <metal_simdgroup_matrix> header is accessible via MLX's header= parameter:

k = mx.fast.metal_kernel(
name="my_kernel",
source=src,
header="#include <metal_simdgroup_matrix>\nusing namespace metal;\n",
...
)

The tiling plan for our LUT: n_sub=16 rows / 8 = 2 row-tiles, n_centroids=256 cols / 8 = 32 col-tiles, sub_dim=8 = 1 K-tile. Total: 64 hardware matmul operations.

I implemented it. Correctness test: zero diff vs reference.

Then I benchmarked it against the current scalar loop:

scalar loop: 212 µs per LUT precompute
simdgroup 8×8: 255 µs per LUT precompute

The hardware matrix multiply was slower.

The reason is protocol overhead. simdgroup_float8x8 is a cooperative operation — all 32 lanes must execute each tile in lock-step. For our 64 tile iterations, that's 64 synchronization points. The scalar loop has zero synchronization: each lane independently computes 128 dot products in parallel. For a small matrix like [16,8]@[8,256], the cooperation overhead dominates the compute savings.

simdgroup_matrix wins at large, batched matmuls (MLX uses it for GEMM with 128×128 tiles). For our 16×256 LUT, it's the wrong tool. Reverted.


Attempt 2: Metal 4 TensorOps

Section 7.2 of the spec describes tensor_ops::matmul2d — a hardware-accelerated matrix multiply that operates on tensor<> types and writes to a cooperative_tensor destination held in thread registers. The pitch is exactly right: no threadgroup memory round-trip, hardware tensor units, single API call.

The example from the spec:

#include <metal_tensor>
#include <MetalPerformancePrimitives/MetalPerformancePrimitives.h>
using namespace metal;
using namespace mpp;

[[ kernel ]] void matrixMultiply(
tensor<device half, dextents<int, 2>> a [[ buffer(0) ]],
tensor<device half, dextents<int, 2>> b [[ buffer(1) ]],
tensor<device half, dextents<int, 2>> c [[ buffer(2) ]]) {

constexpr auto desc = tensor_ops::matmul2d_descriptor(64, 32, 0);
tensor_ops::matmul2d<desc, execution_simdgroups<4>> op;
matmulOp.run(a, b, c);
}

Clean. Exactly what we need.

I confirmed the header is accessible:

k = mx.fast.metal_kernel(
name="test",
source=src,
header="""
#include <metal_tensor>
#include <MetalPerformancePrimitives/MetalPerformancePrimitives.h>
using namespace metal;
using namespace mpp;
""",
)
# Compiles. Header is reachable.

And Metal 4 is available:

Metal version: 400.0 (M4, macOS Sequoia)

Then I tried to actually use matmul2d. Three blockers, in order of discovery:

Blocker 1: Type support.

Table 7.3 of the spec lists supported type combinations. float/float/float is listed — but when I tried it:

static_assert failed: "Unsupported type"

Table 7.4 clarifies: bfloat/bfloat/bfloat and several mixed-precision combinations require OS 26.1 and later. That's iOS/macOS naming — it maps to macOS 26.1 (not released yet as of this writing). The float/float/float path in Table 7.3 is supported, but only with certain execution_scope + K-dimension combinations that are hardware-dependent.

Blocker 2: tensor_handle vs tensor_inline.

The spec's matmul2d example uses tensors declared as kernel parameters with [[buffer(N)]] attributes — these are tensor_handle type. MLX's metal_kernel generates the function signature automatically: it only creates raw pointer buffers (const device float* a [[buffer(0)]]), not tensor<device half, ..., tensor_handle> parameters.

The only tensor type you can construct at runtime from a pointer is tensor_inline. But cooperative_tensor.store() only accepts tensor_handle targets for device memory writes. The round-trip cooperative_tensor → tensor_inline → device output is blocked:

error: candidate template ignored: could not match 'tensor_handle' against 'tensor_inline'

Blocker 3: Dynamic K hangs the GPU compiler.

When I tried K=0 (dynamic length, matching the spec example exactly), the MLX JIT compilation hung. The TensorOps template instantiation with dynamic_length_v<int> appears to trigger a very long (possibly infinite) compile path under MLX's inline Metal JIT. The process never returned.

Summary: TensorOps is architecturally incompatible with MLX's mx.fast.metal_kernel API. The API generates raw pointer buffers; TensorOps requires tensor-typed formal parameters. The mismatch is fundamental, not a workaround.


What Actually Worked

Two improvements from the spec research did ship.

1. metal::precise::exp — a correctness fix hiding as a performance question

Section 8.2 of the spec describes rounding mode. Section 8.3 covers floating-point exceptions. Table 8.2 documents accuracy under fast math.

The relevant line: exp() in fast math mode (-fmetal-math-mode=fast) does not guarantee exp(-INFINITY) = 0.0. The spec's ULP table for fast math lists relaxed accuracy bounds for transcendentals.

Our kernel uses exp(score - running_max) for the online softmax. When a lane is masked (causal or sliding-window), we set score = -INFINITY. In fast math mode, exp(-INFINITY) may not be exactly 0.0 — which would corrupt the softmax denominator.

The fix: use the metal::precise:: namespace to force IEEE-compliant exp regardless of compiler math mode:

// Before (math-mode dependent):
float w = exp(score - m_new);

// After (always correct):
float w = metal::precise::exp(score - m_new);

MLX's metal_kernel API has no parameter for compiler flags, so -fmetal-math-mode=relaxed isn't accessible. The namespace workaround is better anyway — it's surgical, affects only these two exp calls, and documents intent in the code.

2. simd_broadcast_first — eliminating two threadgroup barriers per tile

Section 6.9.2 of the spec (Table 6.14) lists the full SIMD-group permute function set. One entry:

simd_broadcast_first(x) → broadcasts lane 0's value to all lanes
without a threadgroup barrier

The original kernel used threadgroup memory to share the running max and rescale factor:

// Before: two threadgroup variables, two barriers per tile
if (lane == 0) {
tg_m_shared = m_new;
tg_factor = factor;
}
threadgroup_barrier(mem_flags::mem_threadgroup);
m_new = tg_m_shared;
factor = tg_factor;

With simd_broadcast_first, both threadgroup variables disappear entirely — running_m becomes a lane-local float that all 32 lanes keep synchronized:

// After: no threadgroup variables, no barriers for scalar sharing
float m_new = simd_broadcast_first(max(running_m, tile_max));
float factor = simd_broadcast_first(
isfinite(running_m) ? metal::precise::exp(running_m - m_new) : 0.0f);
running_m = m_new;

With S_kv=4096 and 128 tiles, this removes 256 threadgroup barriers from the hot loop. Threadgroup barriers are expensive — they serialize the entire threadgroup and flush threadgroup memory. Removing them reduces both latency and the register pressure from storing shared state.

Both of these are in the current kernel. 9 parity tests pass. The improvements are real even if the end-to-end situation hasn't changed.


The Actual Answer to "What Is Section 7.2 Useful For?"

TensorOps would be transformative if MLX supported tensor-typed kernel parameters. The current mx.fast.metal_kernel API exposes only raw device pointers — the [[buffer(N)]] binding that TensorOps needs is auto-generated as const device float*, not tensor<device half, ..., tensor_handle>.

To use TensorOps for our LUT precompute, MLX would need one of:

  1. Support tensor<> as a formal parameter type in metal_kernel's auto-generated signature. Something like input_tensor_types=[("a", mx.float16, 2)] that generates tensor<device half, dextents<int,2>> a [[buffer(0)]].

  2. A new mx.fast.metal_tensor_kernel variant that accepts tensor operands natively and dispatches via TensorOps internally.

This is exactly the GitHub issue we filed at ml-explore/mlx. The issue covers three requests — compiler options, integer template parameters, and Metal 4 tensor type access — all confirmed by direct testing.


The Broader Pattern

Three sessions, three attempts at the LUT precompute, three different techniques:

AttemptTechniqueResult
OriginalScalar loop, 32 lanes stripe independentlyBaseline
Attempt 1simdgroup_float8x8, cooperative 8×8 tiles20 µs slower — protocol overhead wins
Attempt 2TensorOps matmul2d, hardware tensor unitsAPI incompatible with MLX's kernel wrapper

The pattern: each attempt was technically sound, correctly implemented, and blocked by something orthogonal to the GPU math.

  • Simdgroup matrix: the hardware works, the tile size is wrong.
  • TensorOps: the hardware works, the API binding doesn't exist.

In both cases, the blocker wasn't that the hardware was slow. The blocker was that the interface between our code and the hardware had a constraint we couldn't see until we hit it.

The right mental model for GPU kernel work on Apple Silicon: there are three layers — the math you want to do, the hardware that can do it, and the API that connects them. Breakthroughs happen at the API layer, not the math layer. The math for attention has been solved. The hardware for matrix multiply has been built. The gap is the binding.

That gap is the GitHub issue. If MLX adds tensor<> support to metal_kernel, this whole investigation becomes a one-afternoon project. Until then, the scalar LUT is the fastest thing we can write.


What Is in the Library Now

veloxquant_mlx/metal/fused_sdpa.py has:

  • metal::precise::exp for both softmax exp calls — correctness guarantee regardless of MLX math mode
  • simd_broadcast_first replacing threadgroup barriers for running_m — 256 fewer barriers at S_kv=4096
  • tg_m_shared and tg_factor threadgroup variables removed — smaller threadgroup memory footprint
  • All 9 parity tests passing: causal, non-causal, sliding-window, GQA, short-sequence, long-sequence, dispatcher patch

The end-to-end situation is unchanged from the previous post — the kernel only helps if mlx_lm exposes a way to skip K_hat materialization, which requires an upstream change.

But the kernel is now more correct and slightly better engineered. That's what reading 346 pages of a GPU spec gets you when the hardware feature you wanted is one API version away.


The One Practical Takeaway

Before spending time implementing a GPU optimization, answer this question:

Which layer is blocking you — the math, the hardware, or the API?

If the math is solved and the hardware exists, the answer is almost always the API. Find the API gap first. File the issue or write the binding. Don't write the kernel until the API exists to call it from.

I wrote the kernel first. I found the API gap last. Three sessions later.


I Wrote a Metal Kernel to Stop My Mac From OOMing

· 13 min read
Rajveer Rathod
Author of VeloxQuant-MLX

How a 30-line Metal compute shader replaced the worst hot path in VeloxQuant-MLX 0.5.1, what I learned about Apple Silicon kernel launch overhead, and why this matters if you run LLMs locally on Mac.


The Bug That Wouldn't Die

A few weeks back I shipped VeloxQuant-MLX 0.5.0 — a Python library that compresses the KV cache for any model you load through mlx_lm. The headline algorithm is VecInfer, which uses product vector quantization to squeeze keys down to 1 bit per element. That is 16× compression. Sounds great.

It worked beautifully on Llama-3.1-8B, Mistral-7B, Qwen2.5-7B, Phi-4 — every model with head_dim=128. And then I tested Falcon3-7B.

[VecInfer-2bit] generating...
Out of memory: requested 712 MB, available 0

Falcon3-7B has head_dim=256. The chunked nearest-centroid search at the heart of quantize_vq allocates a tensor of shape [chunk_size, n_centroids, sub_dim] on every chunk. For Falcon's geometry that's a multi-hundred-megabyte intermediate — at every single token, on every layer, on every step. The GPU runs out of memory before generating a single token.

I shipped 0.5.0 with the OOM marked as a known limitation. It bothered me. I knew the fix conceptually — accumulate the squared distance in registers, never materialize the diff matrix — but doing that meant writing a Metal compute shader, and I had never written one.

This post is what happened when I did.


What Even Is a KV Cache And Why Should You Care

Quick recap. Every transformer layer needs to remember the keys and values it computed for every token it's already seen. For a 7B model with 32 layers, 8 KV heads, and head_dim=128, generating an 8,000-token response means storing:

32 layers × 8 heads × 8000 tokens × 128 dims × 2 (K + V) × 2 bytes (fp16)
≈ 1 GB

On a 16 GB MacBook running the model weights (~5 GB at 4-bit) plus the OS and your app, that 1 GB is the difference between a fluent response and a hard crash. The KV cache is the silent killer of long-context inference on Mac.

KV-cache quantization — storing those keys and values at fewer bits — is the answer. There are several flavors. The aggressive one I shipped, VecInfer, uses product vector quantization:

  1. Split each [head_dim] key vector into small sub-vectors of length sub_dim (typically 4 or 8).
  2. Pre-train a codebook of K-means centroids on calibration data.
  3. At inference, encode each sub-vector as the index of its nearest centroid.

A 128-dim fp16 key (256 bytes) becomes 16 indices at 8 bits each (16 bytes). That's the 16× compression.

The hot operation is step 3: finding the nearest centroid. On every layer, on every token, you do a vectorized argmin against the codebook. That's quantize_vq.


What quantize_vq Was Doing Wrong

Here's what the pure-MLX implementation looks like (paraphrased):

def quantize_vq(x, codebook, sub_dim):
# x: [N, sub_dim] -- the sub-vectors to encode
# codebook: [n_centroids, sub_dim]
diff = x[:, None, :] - codebook[None, :, :] # [N, n_centroids, sub_dim]
d2 = mx.sum(diff * diff, axis=-1) # [N, n_centroids]
return mx.argmin(d2, axis=-1) # [N]

That diff tensor is the killer. Its shape is [N, n_centroids, sub_dim]. For Falcon3-7B-shape inputs:

  • N = 4096 tokens × 4 KV heads × 64 sub-vectors per head = 1,048,576
  • n_centroids = 256
  • sub_dim = 4
  • Total: 1,048,576 × 256 × 4 × 2 bytes (fp16) = 2.1 GB intermediate

The implementation tries to mitigate this by chunking N — processing 4,096 sub-vectors at a time — but even one chunk is still ~32 MB, and a 7B model's GPU memory pressure means even that gets fragmented and OOMs in practice.

What you actually want is for each thread to compute the argmin in registers, only writing out a single uint32 index. No intermediate tensor. Total intermediate memory: zero.

That's exactly what a Metal compute kernel can do.


What Is MLX mx.fast.metal_kernel?

MLX (Apple's array library for Apple Silicon) has a feature most people don't know about: mx.fast.metal_kernel. It lets you write a Metal Shading Language function inline as a Python string and have MLX JIT-compile it, manage the buffer bindings, and dispatch it on the GPU.

The whole thing takes a few lines of Python:

kernel = mx.fast.metal_kernel(
name="vecinfer_quantize",
input_names=["x", "codebook"],
output_names=["out"],
source=METAL_SOURCE, # a string of MSL
)

result = kernel(
inputs=[x, codebook],
output_shapes=[(N,)],
output_dtypes=[mx.uint32],
grid=(N, 1, 1),
threadgroup=(256, 1, 1),
)

MLX handles all the boilerplate: function signature generation, dtype binding, threadgroup memory, dispatch encoding. You write the kernel body. It's the easiest GPU programming experience I've ever had — closer to writing a Python function than to traditional CUDA.


The Kernel: 18 Lines of MSL

Here's the entire fused-argmin kernel that replaces that 2 GB intermediate tensor:

uint vec_idx = thread_position_in_grid.x;
uint N_total = x_shape[0];
if (vec_idx >= N_total) {
return;
}

uint n_centroids = codebook_shape[0];
uint sub_dim = codebook_shape[1];
uint x_base = vec_idx * sub_dim;

// Track running argmin in registers — never materialize the diff matrix.
float best_dist = INFINITY;
uint best_idx = 0;

for (uint c = 0; c < n_centroids; ++c) {
uint cb_base = c * sub_dim;
float dist = 0.0f;
for (uint i = 0; i < sub_dim; ++i) {
float d = float(x[x_base + i]) - float(codebook[cb_base + i]);
dist += d * d;
}
if (dist < best_dist) {
best_dist = dist;
best_idx = c;
}
}

out[vec_idx] = best_idx;

That's it. Each GPU thread handles one sub-vector. It loops over all centroids, accumulates squared distance in a single float register, tracks the running minimum, and writes one uint32 index. The intermediate "diff matrix" never exists anywhere except in those two register-resident floats per thread.

Memory complexity: O(N) total output, vs O(N × n_centroids × sub_dim) for the Python path.


The Numbers

I wrote a benchmark script — scripts/plot_metal_benchmarks.py in the repo — that runs both paths across realistic shapes and saves figures. Here are the results.

Throughput: 6.9–14.7× Speedup

Shapepure-MLXMetalSpeedup
S=128, D=1283.64 ms0.53 ms6.9×
S=512, D=12813.5 ms1.26 ms10.7×
S=2048, D=12855.1 ms4.18 ms13.2×
S=8192, D=128228.6 ms15.6 ms14.7×
S=1024, D=25627.0 ms2.23 ms12.1×
S=4096, D=256108.8 ms7.98 ms13.6×

The speedup scales with sequence length — longer contexts (where the Python path is bandwidth-bound on those huge diff tensors) get bigger wins. At S=8192, D=128 we go from 228 ms per call to 16 ms per call. Per call. Multiply by 32 layers × 1 quantize per step × hundreds of tokens and you're talking minutes saved per long generation.

Memory: 729 MB → 12 MB

At the Falcon3-7B OOM trigger shape (head_dim=256, n_centroids=256, sub_dim=4, S=4096):

PathPeak memory
Pure-MLX quantize_vq729.3 MB
Metal vecinfer_quantize_metal12.0 MB
Reduction98.4% (saved 717 MB)

This is the result that matters. The kernel doesn't just make existing models faster — it makes models that previously OOMed actually run.

Correctness: Bit-Exact on fp32, MSE-Identical on fp16

This is where I had to be careful. The Metal kernel and the pure-MLX path don't produce identical indices on fp16 inputs — about 0.1% of indices differ.

Why? When two centroids are nearly equidistant from a point, the choice of "nearest" depends on the order of floating-point operations. The pure-MLX path does the subtract in fp16 (because the inputs are fp16); the Metal kernel promotes to fp32 inside the inner loop. When the tiebreaker happens at the 5th decimal place, the two paths pick different winners.

But here's the thing: the reconstruction quality is identical. I validated this by reconstructing keys from both index sets and measuring MSE against the original input:

B=1 H=8 S=2048 D=128 sub_dim=8 n_c=256 dtype=float16
idx_diff = 0.104%
mse_ref = 3.7211e-01 mse_metal = 3.7211e-01
rel_err = 5.61e-07

Reconstruction MSE matches to 7 decimal places. The two paths produce functionally identical compressed representations — they just disagree on which arbitrary tie-breaker to pick.

The parity tests in veloxquant_mlx/tests/cache/test_vecinfer_metal_parity.py validate this directly: assert that reconstruction MSE is within 1% relative error, not that indices match.


What I Got Wrong on the First Try

I want to be honest about the missteps, because they're the actually interesting part.

Mistake 1: I Wrote the Dequant Kernel First

My first instinct was to write a Metal kernel for dequantize_vq — the operation that takes codebook indices and reconstructs the float vectors. It's conceptually simpler (just a gather), so I started there.

After getting bit-exact correctness, I benchmarked it:

shape pure-mlx metal speedup
B=1 H=8 S=128 n_sub=16 sub_dim=8 223.3 µs 185.6 µs 1.20x
B=1 H=8 S=512 183.6 µs 209.3 µs 0.88x
B=1 H=8 S=2048 258.3 µs 275.9 µs 0.94x
B=1 H=8 S=8192 467.8 µs 577.6 µs 0.81x

My kernel was slower than MLX's mx.take. That stung. After staring at the numbers for an hour, the reason became obvious: MLX's mx.take is already a highly tuned Metal gather kernel under the hood. There is no "Python overhead" to eliminate. The pure-MLX path is a Metal kernel. My kernel was duplicating it badly.

The lesson: before writing a custom kernel, profile to find the operation that has actual Python/intermediate-tensor overhead. mx.take does not. quantize_vq does, because of the [N, n_centroids, sub_dim] materialization. The 30-line MSL shader had to fuse an algorithm — argmin over distances — not just replace a builtin.

I kept the dequant kernel as a building block for Phase 2 (fused dequant+SDPA), but the headline result is the quantize kernel.

Mistake 2: Wrong Threadgroup Layout

My first quantize kernel dispatched one thread per (input_vector, sub_dim_component) pair. That made each thread tiny — one subtract, one square, one accumulate — and meant launching N × sub_dim threads. For typical shapes, that's millions of threads.

Apple Silicon GPUs have 32-wide SIMD groups and an internal cost per thread launch. Launching 8× more threads than you need is pure overhead.

The fix was to dispatch one thread per input vector and let each thread loop over all sub_dim components in registers. Same total work, 8× fewer thread launches, much better register reuse. That's the layout in the kernel above.

Mistake 3: I Assumed End-to-End Would Always Be Faster

After validating the kernel was 13× faster on synthetic shapes, I ran the full benchmark on SmolLM2-135M (a 135-million-parameter tiny model) expecting to see a speedup in end-to-end token generation.

I got the opposite. The Metal path was slower end-to-end — 75 tok/s vs 178 tok/s for the pure-MLX path.

The reason: Metal kernel dispatch has a fixed per-launch overhead of roughly 50–200 µs on Apple Silicon. SmolLM2 has 30 layers, each doing 2 quantize calls per token, so that's ~60 kernel launches per generated token. The per-launch overhead exceeded the work each kernel did.

The kernel is designed for the regime where it matters: 7B+ models with realistic context lengths, where each quantize_vq call is doing milliseconds of work. On those, the launch overhead is negligible relative to the kernel runtime, and you get the full 10–14× speedup.

This is a limitation of MLX's kernel launch path — MLX doesn't yet expose a way to amortize launch overhead across multiple layers in a single dispatch. That's a Phase 3 problem and probably out of scope for a Python-level library.


How to Use This Today

VeloxQuant-MLX 0.5.1 is on PyPI. Install:

pip install --upgrade VeloxQuant-MLX

The Metal kernels are on by default when available. No code changes needed. Your existing VecInferKVCache calls auto-detect Metal and use the fast path:

import mlx_lm
from veloxquant_mlx import KVCacheConfig, KVCacheFactory

model, tokenizer = mlx_lm.load("mlx-community/Falcon3-7B-Instruct-4bit")

# Metal auto-detected. To force off for debugging: use_metal_kernels=False
config = KVCacheConfig(
method="vecinfer",
head_dim=256,
key_sub_dim=4,
value_sub_dim=4,
key_codebook_bits=8,
value_codebook_bits=8,
smooth_factors=calibrated_smooth_factors,
key_codebook=calibrated_key_codebook,
use_metal_kernels=None, # None = auto, True = require, False = forbid
)

The new use_metal_kernels flag is three-state:

  • None (default) — auto-detect; use Metal if available, silently fall back if not
  • True — require Metal; raise at construction time if unavailable
  • False — forbid Metal; use the pure-MLX path (for parity testing and debugging)

To verify the speedup on your own machine:

git clone https://github.com/rajveer43/VeloxQuant-MLX
cd VeloxQuant-MLX
PYTHONPATH=. python scripts/plot_metal_benchmarks.py
# Produces figures/metal/summary.png with your hardware's numbers

What's Next: Phase 2

The quantize kernel is the biggest single win, but it's not the end. Phase 2 is fusing dequantize + scaled-dot-product-attention into a single kernel.

Right now, even with Phase 1, the cache still materializes the full fp16 key tensor on every attention call. The dequant happens — efficiently, since mx.take is already fast — but we hold the result in GPU memory long enough to pass it to MLX's SDPA. For very long contexts, that materialized key tensor is still significant memory pressure.

The Phase 2 kernel would:

  1. Take codebook indices, the per-query LUT (q_tilde @ codebook.T), and value indices
  2. Compute attention scores directly via LUT lookup, never materializing fp16 keys
  3. Compute the softmax-weighted value sum in-kernel
  4. Output the final attention result in one fused pass

This is what the VecInfer paper's CUDA kernel does. Porting it to Metal is the goal. If you've written Metal compute shaders before and want to collaborate, the GitHub issue is open.


The Meta-Lesson: Custom Kernels Are More Accessible Than You Think

I had never written a Metal shader before this project. The mental model is straightforward once you get past the syntax:

  1. Identify the operation with materialization overhead (not just a slow Python loop — those are usually wrapped in optimized C++ already; look for operations that create big intermediate tensors)
  2. Write the algorithm with the intermediate as register-state instead of memory-state (running min, running sum, running argmin)
  3. Dispatch one thread per output element, not per input or per output-component
  4. Validate with reconstruction error, not bit-exact equality, when fp16 is involved
  5. Benchmark at realistic shapes, not toy shapes — kernel launch overhead can dominate for small workloads

Total time investment for this Phase 1: about 6 hours of focused work, including the two failed approaches above. The resulting kernel unblocks head_dim=256 models that previously OOMed, gives a 10–14× speedup on the hot path, and is 30 lines of MSL.

If you've been hesitant to write custom GPU kernels because it sounds intimidating — mx.fast.metal_kernel makes the bar way lower than it used to be on CUDA. Try it.


TL;DR

  • VeloxQuant-MLX 0.5.1 adds a Metal compute kernel for quantize_vq, the hot path in VecInfer KV-cache compression
  • 13× faster on realistic shapes (S=2048+)
  • 98% less peak memory at the Falcon3-7B OOM trigger configuration
  • Drop-in, zero API change — auto-detected when Metal is available
  • Free, MIT-licensed, on PyPI: pip install VeloxQuant-MLX
  • The kernel is 30 lines of Metal Shading Language inside Python
  • Phase 2 (fused dequant+SDPA attention kernel) is next

GitHub: github.com/rajveer43/VeloxQuant-MLX PyPI: pypi.org/project/VeloxQuant-MLX Benchmark figures: figures/metal/summary.png in the repo

If this saves your Mac from OOMing tonight, leave a star — or open an issue if it doesn't.

TurboQuant + Metal Kernels: The Combined Writeup

· 16 min read
Rajveer Rathod
Author of VeloxQuant-MLX

How I wrote five hand-tuned Metal compute kernels in MLX for TurboQuant — and what every bug taught me about Apple GPU programming.


The Problem

My Mac was choking on long-context LLM inference.

Not because the model was too large — I had already quantized the weights. The bottleneck was the KV cache. At 8k context, a single layer's key cache is [1, 32, 8192, 128] in fp16 — over 67 MB per layer, 2 GB across 32 layers. On Apple Silicon, where the GPU and CPU share the same physical memory, that pressure is immediate and painful.

VeloxQuant-MLX already had several compression algorithms: TurboQuantRVQ (7.5× via two-stage scalar RVQ), QJL (16× via 1-bit Johnson-Lindenstrauss sketching), and VecInfer (16× via product VQ). But they were all running through pure MLX graph operations — no custom GPU kernels. The hot paths were either slow or allocating huge intermediate tensors.

The fix: write the hot paths in Metal Shading Language and JIT-compile them via mx.fast.metal_kernel.

This is the story of how I did it, what broke, and what I learned.


The Stack

Before diving into the kernels, here's the relevant context:

  • MLX — Apple's NumPy-style ML framework with lazy evaluation and Metal GPU backend
  • mx.fast.metal_kernel — Python API to write raw Metal Shading Language compute shaders that plug into MLX's lazy graph
  • TurboQuant — a family of KV cache quantization algorithms (MSE, Prod, RVQ) implemented in VeloxQuant-MLX
  • QJL — Quantized Johnson-Lindenstrauss: compress keys to 1-bit sign sketches + a scalar norm

The goal was to replace the slowest pure-MLX operations with Metal kernels that live in five focused submodules:

SubmoduleWhat it does
_bit_packing.pyPack/unpack b-bit indices into uint8 bytes
_scalar_quant.pyNearest-centroid quantize, dequantize, fused Hadamard+quant
_qjl.pyQJL sign encode and inner product scoring
_rvq_attend.pyFused RVQ key decode + FlashAttention-style online softmax

How mx.fast.metal_kernel Works

Before showing any kernel code, there's one thing you need to understand about the API — because getting it wrong produces silent, subtle bugs.

The API in 30 seconds

import mlx.core as mx

kernel = mx.fast.metal_kernel(
name="my_kernel",
input_names=["x", "y"],
output_names=["out"],
source="""
uint i = thread_position_in_grid.x;
out[i] = x[i] + y[i];
""",
)

result = kernel(
inputs=[a, b],
grid=(N, 1, 1),
threadgroup=(256, 1, 1),
output_shapes=[(N,)],
output_dtypes=[mx.float32],
)

The source string is raw Metal Shading Language — no kernel keyword, no function signature. MLX wraps it. Shape information is injected automatically: inside the kernel, x_shape[0] gives you the first dimension of x.

The #1 Gotcha: Grid = Total Threads

This is the single most important thing to get right, and the MLX documentation is easy to misread on this point.

In standard Metal (Obj-C / Swift), you call dispatchThreadgroups(n_groups, threadsPerThreadgroup: tg_size) — so the grid is in threadgroup units.

MLX uses dispatchThreads — the grid is in total thread units.

That means if you want B threadgroups of T threads each:

# WRONG — only dispatches 1 thread per threadgroup
grid=(B, 1, 1), threadgroup=(T, 1, 1)

# CORRECT — dispatches B threadgroups of T threads each
grid=(B * T, 1, 1), threadgroup=(T, 1, 1)

I made this mistake on four out of five kernels. The symptom was identical every time: only the first batch element had correct output; everything else was zero. It looked like a memory layout bug or an indexing error, not a dispatch error. I spent hours debugging before I found it.

The Lazy Graph Contract

mx.fast.metal_kernel returns a lazy node — nothing runs until mx.eval() is called. mx.eval() internally:

  1. Encodes the compute command into a MTLCommandBuffer
  2. Calls commandBuffer.commit() to submit to the GPU
  3. Calls commandBuffer.waitUntilCompleted() to synchronize

You never write any of this yourself. MLX owns the entire Metal command buffer lifecycle.


Kernel 1: Bit-Packing — 30× Over NumPy

The problem

TurboQuantRVQ stores KV cache keys as b-bit indices (b ∈ {1, 2, 4}). The pure-Python path used a loop to pack these into uint8 bytes. At 65k elements it was ~8 ms — unacceptable.

The kernel

constexpr int ELEMS_PER_BYTE = 8 / B_BITS;
constexpr uint MASK = (1u << B_BITS) - 1u;

uint byte_idx = thread_position_in_grid.x;
uint base = byte_idx * ELEMS_PER_BYTE;

uint packed_byte = 0u;
for (int i = 0; i < ELEMS_PER_BYTE; ++i) {
uint val = uint(indices[base + i]) & MASK;
packed_byte |= (val << (i * B_BITS));
}
packed[byte_idx] = uint8_t(packed_byte);

One thread per output byte. B_BITS is a template parameter — a compile-time integer constant. This lets the compiler statically unroll the inner loop (2 iterations for b=4, 4 for b=2, 8 for b=1) and inline the constants.

The dispatch:

grid = ((n_bytes, 1, 1),)
threadgroup = ((min(256, n_bytes), 1, 1),)

Results

NNumPyMetalSpeedup
4,0960.52 ms0.18 ms2.9×
16,3842.1 ms0.17 ms12.5×
65,5368.4 ms0.28 ms29.5×

The kernel dispatch overhead is ~0.17 ms regardless of N. Below ~2k elements NumPy wins because there's nothing to hide the launch cost behind. Above 16k elements, Metal dominates by an order of magnitude.


Kernel 2: Scalar Quantize / Dequantize — 11× Over NumPy

The problem

TurboQuantMSE quantizes each key dimension independently against a Lloyd-Max codebook. The pure-MLX path computed |x - centroids|² as a full [N, 2^b] matrix, then took argmin — allocating a tensor that was 2^b times the input size.

The quantize kernel

constexpr int N_CENTS = 1 << B_BITS;

uint elem = thread_position_in_grid.x;
float val = float(x[elem]);
int best = 0;
float best_dist = INFINITY;

for (int j = 0; j < N_CENTS; ++j) {
float d = val - centroids[j];
float dist = d * d;
if (dist < best_dist) { best_dist = dist; best = j; }
}
indices[elem] = uint8_t(best);

One thread per element. The centroid scan lives entirely in registers — no intermediate allocation. With B_BITS as a template, the loop body is known at compile time: the compiler generates 2, 4, 8, or 16 iterations of straight-line code.

The dequantize kernel

Even simpler — a pure gather:

uint elem = thread_position_in_grid.x;
x_hat[elem] = half(centroids[uint(indices[elem])]);

Results

NNumPy argminMetalSpeedup
16,3840.21 ms0.17 ms1.2×
65,5360.86 ms0.19 ms4.5×
262,1443.5 ms0.31 ms11.3×

Kernel 3: Fused Hadamard + Quantize — The Hardest One

The problem

TurboQuantMSE (with Hadamard preconditioner) runs:

y = diag * H * x / sqrt(D) [randomized Hadamard rotation]
idx = argmin_k |y - c_k|² [nearest-centroid quantize]

Two separate dispatches, with a [B, D] fp16 intermediate between them. Fusing them into one kernel eliminates that allocation and the round-trip to GPU memory.

The kernel design

Walsh-Hadamard Transform (WHT) is an in-place butterfly — each pass halves the stride. On GPU, D threads share a threadgroup, and each butterfly step needs a barrier.

threadgroup float buf[MAX_D]; // static threadgroup memory; MAX_D injected at compile time

// 1. Load + diagonal sign flip
float v = float(x[tg * D + lane]);
v *= float(diag[lane]);
buf[lane] = v;
threadgroup_barrier(mem_flags::mem_threadgroup);

// 2. In-place WHT — range-based parallel butterfly
for (uint stride = 1; stride < D; stride <<= 1) {
uint local = lane % (stride << 1u);
bool is_upper = local >= stride;
uint partner = is_upper ? (lane - stride) : (lane + stride);
float a = buf[lane];
float b = buf[partner];
threadgroup_barrier(mem_flags::mem_threadgroup);
buf[lane] = is_upper ? (b - a) : (a + b);
threadgroup_barrier(mem_flags::mem_threadgroup);
}

// 3. Scale
float y = buf[lane] * metal::rsqrt(float(D));

// 4. Nearest-centroid argmin (register-local)
int best = 0;
float best_dist = INFINITY;
for (int j = 0; j < N_CENTS; ++j) {
float d = y - centroids[j];
float dist = d * d;
if (dist < best_dist) { best_dist = dist; best = j; }
}
indices[tg * D + lane] = uint8_t(best);

The threadgroup array buf[MAX_D] requires MAX_D to be a compile-time constant — which is why it's injected as a #define in the kernel header:

_hadamard_quantize_kernel = mx.fast.metal_kernel(
...
header=f"#define MAX_D {D}\n",
source=_HADAMARD_QUANTIZE_SRC,
)

The butterfly bug

My first implementation used:

uint partner = lane ^ stride; // XOR butterfly

This looked right — it's the standard Cooley-Tukey bit-reversal trick. But on GPU, it produced ~90% index mismatch vs the sequential reference.

The problem: lane ^ stride traverses the WHT in bit-reversal order, which is fine for sequential execution (because you can reorder the output at the end), but on GPU where lanes run simultaneously, XOR pairing creates data races within a butterfly pass — some lanes read values that other lanes in the same pass are simultaneously writing.

The fix is a range-based butterfly that unambiguously partitions each pass into non-overlapping upper/lower pairs:

uint local = lane % (stride << 1u);
bool is_upper = local >= stride;
uint partner = is_upper ? (lane - stride) : (lane + stride);
float a = buf[lane];
float b = buf[partner]; // read BEFORE the barrier write below
threadgroup_barrier(mem_flags::mem_threadgroup);
buf[lane] = is_upper ? (b - a) : (a + b);

Reading a and b before the barrier guarantees both values come from the previous pass. After this fix, 100% of indices matched the reference.

Grid

The grid uses B threadgroups of D threads — not B × D total:

# Wrong:
grid=(B, 1, 1), threadgroup=(D, 1, 1) # only 1 thread per threadgroup!

# Correct:
grid=(B * D, 1, 1), threadgroup=(D, 1, 1) # B threadgroups of D threads

Kernel 4: QJL Encode — Simdgroup Sign Packing

The problem

QJL encoding requires:

  1. For each key vector x[b], compute sign(S @ x[b]) for all m sketch dimensions — giving m bits
  2. Pack those m bits into m/8 uint8 bytes (LSB-first)
  3. Compute ‖x[b]‖ (one scalar per key)

The pure-MLX path materialized the full [B, m] float matrix S @ x.T before sign-taking — m * d * B * 4 bytes, growing linearly with batch and sketch size.

Simdgroup design

Each simdgroup (32 lanes) handles 32 consecutive sketch dimensions. Lane j computes dot(S[simd_blk*32 + j, :], x[b, :]) via a scalar loop:

uint b_idx = flat_tg / n_simd_per_batch;
uint simd_blk = flat_tg % n_simd_per_batch;
uint sketch_j = simd_blk * 32u + lane;

float dot_val = 0.0f;
if (sketch_j < m) {
uint S_row = sketch_j * d;
uint x_row = b_idx * d;
for (uint i = 0; i < d; ++i) {
dot_val += float(S[S_row + i]) * float(x[x_row + i]);
}
}

After the dot product, all 32 lanes cooperate to pack 32 sign bits into 4 bytes using simd_shuffle:

uint sign_bit = (dot_val >= 0.0f) ? 1u : 0u;
uint byte_in_blk = lane / 8u;
uint bit_in_byte = lane % 8u;

uint packed_byte = 0u;
for (uint bit = 0; bit < 8u; ++bit) {
uint src = byte_in_blk * 8u + bit;
packed_byte |= (simd_shuffle(sign_bit, src) << bit);
}

if (bit_in_byte == 0 && sketch_j < m) {
packed_signs[out_byte] = uint8_t(packed_byte);
}

simd_shuffle(val, lane_id) broadcasts sign_bit from lane src to the current lane — no shared memory needed. Lane 0 (of each byte group) does the final write.

The norm is computed cooperatively by simd_blk 0:

if (simd_blk == 0) {
float x_sq = 0.0f;
for (uint i = lane; i < d; i += 32u) {
float v = float(x[x_row + i]);
x_sq += v * v;
}
float norm_sq = simd_sum(x_sq);
if (lane == 0) norms[b_idx] = half(metal::sqrt(norm_sq));
}

Grid (the bug, again)

n_simd_per_batch = (m + 31) // 32
n_total_threads = B * n_simd_per_batch * 32 # ← must multiply by 32
grid=(n_total_threads, 1, 1), threadgroup=(32, 1, 1)

Without the * 32, only B * n_simd_per_batch total threads dispatched — meaning only the first simdgroup ran, and only the first key had any output.


Kernel 5: Fused RVQ Decode + Attend — Online Softmax Without Materializing K

The problem

Attention with a quantized KV cache normally requires two dispatches:

  1. Decode all compressed keys → K_hat tensor [B, H, S_kv, D] (fp16, potentially GBs)
  2. Run softmax(q @ K_hat.T / sqrt(D)) @ V

The K_hat tensor is allocated, filled, used once, and thrown away. For RVQ keys this is unavoidable in the two-dispatch design — but we can fuse everything into a single FlashAttention-style pass that decodes keys on the fly without ever materializing K_hat.

Design

Each threadgroup handles one query position (b, h, sq). Lanes stripe across the D-dimensional vectors in steps of TG = min(D, 32):

float running_m = -INFINITY; // online softmax running max
float running_d = 0.0f; // online softmax running denominator
float my_out[8]; // per-lane output accumulator
for (int i = 0; i < 8; ++i) my_out[i] = 0.0f;

for (uint sk = 0; sk < S_kv; ++sk) {
// 1. Decode key on-the-fly: k[i] = cents1[idx1[i]] + cents2[idx2[i]]
float partial_dot = 0.0f;
for (uint i = tg_lane; i < D; i += TG) {
float ki = centroids1[uint(k_indices1[k_off])]
+ centroids2[uint(k_indices2[k_off])];
partial_dot += float(q[q_base + i]) * ki;
}
float score = simd_sum(partial_dot) * inv_sqrt_d;

// 2. Online softmax update (Dao et al. FlashAttention)
float m_new = metal::max(running_m, score);
float factor = metal::exp(running_m - m_new);
float w = metal::exp(score - m_new);
running_d = running_d * factor + w;
running_m = m_new;

// 3. Rescale and accumulate value
for (uint i = 0; i < n_owned; ++i) my_out[i] *= factor;
for (uint i = tg_lane; i < D; i += TG) {
float vi = float(v_codebook[cb_off]);
uint out_i = (i - tg_lane) / TG;
my_out[out_i] += w * vi;
}
}

// 4. Normalize and write
for (uint i = tg_lane; i < D; i += TG) {
uint out_i = (i - tg_lane) / TG;
out[out_off] = half(my_out[out_i] / running_d);
}

simd_sum(partial_dot) broadcasts the full dot product to all lanes in the simdgroup — this is the SIMD-level reduction that gives the correct score without any threadgroup memory.

The local accumulator index out_i = (i - tg_lane) / TG is the critical piece: lane 0 owns dims {0, TG, 2×TG, ...}, lane 1 owns {1, TG+1, ...}, and out_i is the position within that lane's private array.


The Benchmarks

After fixing all the dispatch bugs, here are the results on Apple M-series (figures saved to figures/metal/turboquant_kernels/):

KernelPeak speedup vs NumPyNotes
turboquant_bit_pack (b=4, N=65k)29.5×NumPy loop vs Metal one-thread-per-byte
turboquant_scalar_quantize (N=256k)11.3×Eliminates [N, 2^b] diff tensor
turboquant_hadamard_quantize (D=1024)1.1×Fused saves 1 allocation; WHT itself is fast
qjl_encode (B=256)0.2× (small B); ~1× (large B)np.packbits is BLAS-level; Metal overhead dominates at B<64
turboquant_fused_rvq_decode_attendNo NumPy baseline (different algorithm)

Memory savings are the bigger story for the RVQ attend kernel — it eliminates the [B, H, S_kv, D] fp16 K_hat tensor entirely. At S_kv=4096, H=32, D=128 that's 33 MB per layer, ~1 GB across a 32-layer model, allocated and freed every forward pass.

1-bit bit-packing alone gives 16× memory compression on the key cache (1 bit per dimension vs fp16). Combined with the Metal kernel's 30× throughput advantage, the packing/unpacking step goes from a bottleneck to essentially free.


What I Learned

1. Grid = total threads is the most common MLX Metal mistake

Every tutorial and reference for Metal uses dispatchThreadgroups. MLX uses dispatchThreads. These are different. If your output is correct for the first batch element and zero elsewhere, check your grid first.

2. XOR butterflies are wrong for parallel WHT

The standard sequential WHT uses pair = i ^ stride. On GPU this causes data races within a butterfly pass because multiple threads simultaneously read from and write to overlapping pairs. Use range-based pairing (local = lane % (stride*2); is_upper = local >= stride) and read both values before the barrier.

3. simd_sum and simd_shuffle are your first tools, not shared memory

For reductions and broadcasts within a simdgroup (32 lanes), simd_sum and simd_shuffle are zero-cost compared to threadgroup_barrier + shared memory. Design around simdgroups first; only escalate to threadgroup memory when you need communication beyond 32 lanes.

4. Template parameters unlock static unrolling

template <int B_BITS> turns runtime constants into compile-time constants. The inner loop over centroids becomes 2, 4, 8, or 16 unrolled iterations — no branch, no loop counter. This is how Metal kernels beat NumPy at large N despite higher launch overhead: the arithmetic is genuinely faster.

5. You don't manage commandBuffer

MLX handles commandBuffer.commit() and commandBuffer.waitUntilCompleted() inside mx.eval(). You never touch Metal command buffers when using mx.fast.metal_kernel. This is by design — MLX's lazy graph batches multiple kernel dispatches into one command buffer where possible.

6. The launch overhead is real and ~0.17 ms

Every Metal kernel dispatch costs ~0.17 ms regardless of work size. For small N (< ~2k elements), NumPy is faster. For large N (> ~16k), Metal wins by 10–30×. Design your batching strategy accordingly — combine small operations into a single larger kernel rather than dispatching many small ones.


Code Organization

The five kernels are organized into focused submodules under veloxquant_mlx/metal/:

metal/
├── __init__.py # lazy re-exports
├── kernels.py # thin facade — imports from all submodules
├── _bit_packing.py # turboquant_bit_pack, turboquant_bit_unpack
├── _scalar_quant.py # turboquant_scalar_quantize, _dequantize, _hadamard_quantize
├── _qjl.py # qjl_encode, qjl_inner_product
├── _rvq_attend.py # turboquant_fused_rvq_decode_attend
└── _vecinfer.py # vecinfer_dequant_metal, vecinfer_quantize_metal, ...

Each submodule has its own _cache: dict = {} for the kernel singleton pattern — build the MTLComputePipelineState once on first call, reuse forever:

def _pack_kernel(b: int):
key = ("bit_pack", b)
if key not in _cache:
_cache[key] = mx.fast.metal_kernel(
name=f"turboquant_bit_pack_b{b}",
input_names=["indices"],
output_names=["packed"],
source=_PACK_SRC,
)
return _cache[key]

kernels.py is now a 47-line re-export facade:

from veloxquant_mlx.metal._bit_packing import turboquant_bit_pack, turboquant_bit_unpack
from veloxquant_mlx.metal._scalar_quant import turboquant_scalar_quantize, ...
from veloxquant_mlx.metal._qjl import qjl_encode, qjl_inner_product
from veloxquant_mlx.metal._rvq_attend import turboquant_fused_rvq_decode_attend

All 40 tests pass after the restructuring — the facade is transparent to callers.


The Broader Point

Apple Silicon is a genuinely good target for this kind of work. Unified memory means you don't pay PCIe bandwidth to move data between CPU and GPU — the Metal kernel reads the same bytes your Python code just wrote. The simdgroup primitives (simd_sum, simd_shuffle) are clean and well-documented. And mx.fast.metal_kernel makes the iteration loop fast: write Metal source in Python, evaluate, fix, repeat.

The hard part isn't the Metal itself — it's understanding how MLX dispatches kernels. Once you internalize "grid = total threads, not threadgroups" and "lazy graph, so nothing runs until mx.eval()", the rest is straightforward shader programming.

The full source is in VeloxQuant-MLX under veloxquant_mlx/metal/. The benchmark script is at veloxquant_mlx/benchmarks/metal_kernel_benchmark.py and produces all the figures discussed here.


References


Code: github.com/rajveer43/VeloxQuant-MLX · Previous post: I Wrote a Metal Kernel to Stop My Mac From OOMing on LLM Inference