Last updated: 2026-06-25
ZINC MMQ v2 — Fused Q4_K×Q8_1 Tensor-Core GEMM (beat llama.cpp)#
Goal: a custom CUDA kernel for Q4_K (and Q6_K/Q5_K/Q8_0) prefill GEMMs that beats llama.cpp's MMQ by ≥20% on RTX 4090 (sm_89) and RTX 5090 (sm_120).
Why this exists#
ZINC's current dense prefill path does dequant Q4_K→fp16 + cuBLAS fp16 TC GEMM.
llama.cpp's MMQ fuses the dequant INTO the TC inner loop + uses Q8_1 activation
(half the activation bytes). Result: ZINC dense prefill is 6-16× behind llama.cpp.
The gap doc (docs/prefill_gap_closure_plan.md) identifies this as lever T1 —
the highest-impact remaining work. This doc designs the replacement.
llama.cpp's MMQ — what they do right + what they leave on the table#
What they do right (we must match)#
- Q8_1 activation: quantize fp32 → Q8_1 (1 byte/elem) before the GEMM → halves activation DRAM traffic vs fp16
- Fused dequant: Q4_K block unpacked in registers per
mma.synccall — no separate dequant pass - 128×128 tile (mmq_x=128, mmq_y=128), 8 warps (256 threads/CTA)
- mma.sync m16n8k16 (not wmma — finer-grained, lower register pressure)
- Stream-K work decomposition for full SM utilization at small T
- Always preferred over cuBLAS on Turing+ (
turing_mma_available → true)
What they leave on the table (our edge)#
| Gap | llama.cpp | ZINC v2 improvement | Expected gain |
|---|---|---|---|
| Pipeline depth | Manual 2-stage with __syncthreads |
cp.async 3-stage (overlap load+dequant+compute, no sync between stages) | +10-15% (hides ~33% more latency) |
| Activation quant | Separate quantize_mmq_q8_1_cuda kernel (extra DRAM round-trip: read fp32, write Q8_1, read Q8_1) |
Fuse fp32→Q8_1 into the GEMM pipeline (quantize in shared mem during cp.async load) | +5-10% (eliminates one full activation DRAM pass) |
| Adaptive activation format | Always Q8_1 | fp16 at large T (compute-bound, skip quant overhead); Q8_1 at small T (bandwidth-bound, halve traffic) | +5-10% at T≥1024 |
| Weight in shared mem | Q4_K blocks → dequant in registers per mma | Dequant Q4_K→fp16 in shared mem once (during cp.async stage), then ldmatrix from fp16 — cleaner pipeline separation |
+5% (fewer per-mma register ops) |
| MoE expert routing | Same MMQ kernel with ids (gather into Q8_1) |
Persistent kernel processes expert buckets sequentially — expert weight stays in shared mem across all its tokens (zero re-read) | +15-20% on MoE models |
| Stream-K fixup | Extra fixup kernel + atomic buffer | Persistent CTAs with atomic work queue — no fixup, simpler dispatch | +2-3% (no fixup overhead) |
Expected combined: +42-73% over llama.cpp's MMQ kernel throughput. Even taking the conservative half: +20-35% over llama.cpp end-to-end prefill (non-GEMM overhead caps the kernel speedup translation).
Hardware targets#
| GPU | SMs | sm_ | TC TFLOPS (fp16) | DRAM GB/s | L2 (MB) | Shared/SM | cp.async | TMA |
|---|---|---|---|---|---|---|---|---|
| RTX 4090 | 128 | 8.9 | 82.6 (f32 acc) / 165.2 (f16 acc) | 1008 | 72 | 100 KB | ✅ | ❌ |
| RTX 5090 | ~170 | 12.0 | ~210 (est, fp16) | ~1792 | ~96 | 228 KB | ✅ | ✅ |
Compute/bandwidth ratio (roofline):
- 4090: 82.6T / 1008G = 82 ops/byte → compute-bound at AI > 82 (i.e., T > ~23 for Q4_K at 0.5625 bytes/elem)
- 5090: ~210T / 1792G = 117 ops/byte → compute-bound at AI > 117 (i.e., T > ~33)
Implication: at T ≥ 64 prefill is solidly compute-bound. The kernel's TC utilization (roofline efficiency) is the primary driver. At T < 64 (decode, small prefill), it's bandwidth-bound and Q8_1 activation matters most.
Kernel architecture#
Tile layout#
Block tile: [BM=128, BN=128, BK=32] (M=weight rows, N=tokens, K=inner)
Warps: 8 warps × 32 threads = 256 threads/CTA
Warp tile: [WM=64, WN=64] (each warp owns a 64×64 sub-tile = 4×4 mma tiles)
MMA inst: mma.sync.aligned.m16n8k16.f32.f16.f16.f32 (Ada/Turing)
wgmma.mma_async.sync.aligned.m64n128k16 (Blackwell, 5090)
K-loop: process BK=32 per inner iteration, pipeline 3 stages
3-stage cp.async pipeline#
Stage 0: cp.async load Q4_K weight [128, 32] + fp32 activation [128, 32] → buf[0]
Stage 1: cp.async load → buf[1], while: dequant buf[0] Q4_K→fp16 in shared mem,
quantize buf[0] fp32→Q8_1→fp16 in shared mem (fused quant)
Stage 2: cp.async load → buf[2], while: dequant/quant buf[1], mma.sync on buf[0]
Steady: compute on buf[k], dequant/quant buf[k-1], load buf[k-2]
(all overlapped via cp.async.commit_group + wait_group)
Shared memory budget per CTA:
- 3 × weight fp16 [128, 32] = 3 × 8 KB = 24 KB
- 3 × activation fp16 [128, 32] = 3 × 8 KB = 24 KB (or Q8_1: 12 KB)
- Total: 48 KB (fp16 act) or 36 KB (Q8_1 act) → 3 CTAs/SM on 4090 (100 KB)
Adaptive activation format#
if T >= COMPUTE_BOUND_T (64): // compute-bound → skip quant overhead
activation_format = FP16 // read fp32 from DRAM, f32→f16 in shared, mma(fp16,fp16)
else: // bandwidth-bound → halve activation traffic
activation_format = Q8_1 // read fp32 from DRAM, quantize to Q8_1 in shared, mma(fp16,fp16)
The fp16 weight × fp16 activation path is the same either way — only the shared memory staging format differs (fp16 direct vs Q8_1→fp16 dequant). The Q8_1 path trades a per-block scale multiply for halved DRAM reads.
Persistent kernel + atomic work queue#
__global__ void mmq_v2_kernel(...) {
// Each CTA persists, claims (M_tile, N_tile) from atomic counter
__shared__ int work_idx;
while (true) {
if (threadIdx.x == 0) work_idx = atomicAdd(&global_work_idx, 1);
__syncthreads();
if (work_idx >= total_tiles) return;
int m_tile = work_idx / n_tiles_n;
int n_tile = work_idx % n_tiles_n;
process_tile(m_tile, n_tile); // full K-loop over [BM, BK] × [BK, BN]
}
}
No stream-K fixup — each tile is computed atomically by one CTA. For MoE, the
work queue entries are (expert_id, m_tile, n_tile) so an expert's weight stays
L1/L2-resident as consecutive tiles are processed.
MoE routing (fused gather)#
For mul_mat_id (MoE), the activation is already gathered by expert in the Q8_1
quantization pass (same as llama.cpp's ids). The persistent kernel processes
entries (expert, m_tile, n_tile) — the expert's weight slice [BM, K] is loaded
once and reused for all its n_tiles (tokens routed to that expert). This is the
key MoE win: each expert weight read ~once instead of once-per-token.
Implementation milestones#
M0 — Microbench harness ✅#
Isolated GEMM benchmark: allocate Q4_K [M, K] + fp32 [K, T], run kernel, measure TFLOPS + GB/s. Compare vs cuBLAS + dequant round-trip.
M1 — Baseline wmma kernel ✅ (14.6 TFLOPS, 16% of cuBLAS)#
64×64 tile, BK=32, 4 warps, no bank-conflict padding. Naive per-element dequant. Bottleneck: 8% occupancy (misdiagnosed as ALU-bound; actually shared mem exceeded 48KB limit in later BK=256 attempt).
M2 — Parity with dequant+cuBLAS ✅ (92→136 TFLOPS)#
Fixes: WSTRIDE=36 (bank-conflict-free), 9.2 KB shared (4 CTAs/SM, 33% occupancy). Key result: matches dequant+cuBLAS exactly at all T values. The fused kernel does the same DRAM traffic, so parity is expected without traffic reduction.
M3 — Warp specialization ✅ (experiment, no gain over M2)#
Tried producer-consumer split (4 warps dequant + 4 warps mma, double-buffered). Finding: no improvement. The GPU's warp scheduler already overlaps ALU and TC across warps naturally — explicit warp specialization adds thread/sync overhead without achieving additional overlap. The bottleneck is total ALU instruction count for the Q4_K dequant (~6 instr/element × 2048 elements/K-iter × 128 K-iters = 1.6M instructions/CTA), not pipeline stalls.
Tried __launch_bounds__(128, 8) (64 regs, 67% occupancy): also no gain. The
grid provides only 4 CTAs/SM regardless, so extra register headroom is unused.
Conclusion: to BEAT dequant+cuBLAS, must either:
- Reduce DRAM traffic — Q8_1 activation (halve activation reads, the llama.cpp approach)
- Reduce dequant ALU — vectorized byte access (uint32 loads), lookup table, or process both nibble halves per byte read
- Use mma.sync PTX instead of wmma — finer-grained register control, potentially fewer instructions per TC operation
M4 — Q8_1 activation quantization (next)#
Quantize fp32 activation to Q8_1 (1 byte/elem) before the GEMM. At bandwidth-bound sizes (T<256), this halves the activation DRAM traffic. Requires a fast quantization pre-pass + Q8_1→fp16 dequant in the GEMM inner loop. This is how llama.cpp achieves its edge over cuBLAS+dequant." Gate: must match cuBLAS+dequant throughput before adding complexity.
M2 — cp.async 3-stage pipeline#
Add the 3-stage pipeline. Gate: must beat M1 by ≥10%.
M3 — Fused fp32→Q8_1 activation quant#
Quantize activation in the pipeline. Gate: must beat M2 at T<256 by ≥5%.
M4 — Persistent kernel + MoE fusion#
Replace grid dispatch with persistent CTAs. Fuse MoE expert routing.
M5 — Integrate into forward_cuda.zig#
Wire into gemmDispatchPrefill, gated by env (ZINC_MMQ_V2=1), default-on after
validation.
Revised priorities (post-M3 findings)#
The warp specialization experiment (M3) proved that ALU/TC overlap is already maximized by the GPU's warp scheduler. The remaining levers are:
- Q8_1 activation (M4) — the proven llama.cpp approach. Halves activation DRAM reads. Expected: +20-30% at T≤512 (bandwidth-bound regime).
- Vectorized dequant — read uint32 (4 bytes = 8 nibbles) instead of individual bytes. Reduces load instruction count by 4×. Expected: +10-15%.
- mma.sync PTX — replace wmma with inline PTX
mma.sync.m16n8k16. Gives finer register control, avoids wmma overhead. Expected: +5-10%. - Larger 128×128 tile — amortizes sync/launch overhead over 4× more work. Now viable with the bank-conflict-free layout. Expected: +5%.
Correctness gate#
validate_catalog.sh5/5 (fp16 TC is token-tolerance, not bit-identical — that's OK)- Reference: compare kernel output vs CPU dequant + fp32 matmul (max abs diff < 0.1)
- GEN_IDS: bit-identical ON vs OFF for fp16 path (reduction-order tolerance for tokens)