Kipp Benchmarks

Kipp has a scalar CPU correctness oracle plus correctness-gated Metal and CUDA backends. Results must not mix unsupported checkpoints, weight schemes, or backend variants.

Required report fields

Benchmark policy

Report medians and a dispersion measure rather than only the best run. Keep prefill and token-generation measurements separate. Comparisons with other engines must use equivalent model weights, context, sampling settings, and hardware, and must link the exact commands used.

tools/bench.py is the canonical subprocess harness. It performs discarded warm-up runs, captures the CLI's separate prefill and decode timers, samples peak process RSS with /usr/bin/time -l, and reports the median, median absolute deviation, minimum, maximum, and each raw run as JSON. bench/spec_bench.py, bench/server_bench.py, bench/prefix_bench.py, bench/load_bench.py, and bench/ppl_bench.py cover speculation, server batching, cross-request prefix reuse, open-loop serving load, and quantization quality; all record the full engine/hardware/model provenance block via bench/_provenance.py.

All numbers are sustained steady-state measurements: Apple-silicon GPU clocks are demand-scaled, so benches run on an otherwise idle machine, back-to-back in one session after a multi-minute GPU warm-up, and a file is trusted only when its recorded dispersion is tight (see bench/README.md, "Measurement protocol").

Apple M5 Max (v0.0.3 measurement campaign, 2026-07-22)

The development machine changed from a base M5 (10-core GPU, 24 GiB) to an M5 Max (40-core GPU, 128 GB); throughput is roughly 4× the sections below, which are retained for the base-M5 configuration. An earlier 2026-07-21 campaign was measured on a build whose Metal matrix kernels had silently failed to compile (a reserved MSL keyword; every correctness gate still passed) — those numbers are superseded, and the harness now refuses to record results from a fallback build. Current reference numbers (Qwen3-4B, Metal, greedy; every value traces to a committed bench/results/*.json):

Prefill round shape (2026-07-26)

Metal prefill was the one axis where Kipp lost to llama.cpp: 504 tok/s against 2,174 at a matched 2,048-token prompt, measured back to back on the same host, weights, and session. Two independent limits caused it, and the arithmetic matters because the intuitive reading is wrong.

Projection weight traffic is set by the in-kernel token tile, not by the round size:

weight bytes per prefill = ceil(total_tokens / matmul_token_tile) x projection_bytes

The dispatch grid is (row groups, token groups) and each threadgroup streams its own weight rows over the full shared dimension, so threadgroups that would share weight rows are never co-resident. Widening the round therefore moves no weight bytes; only the tile does. What the round width fixes is occupancy: the K and V projections have 1,024 rows, so at a 32-token round they dispatched 16 threadgroups onto a 40-core GPU, and the engine sustained only ~255 GB/s, roughly half of what the device can hold.

Both levers were measured separately, one session, idle machine, five runs each (Qwen3-4B BF16, 2,048-token prompt, M5 Max):

matmul tile round prefill tok/s decode tok/s
16 32 (previous) 504.4 (MAD 0.45) 55.75
32 32 356.6 (MAD 4.58) 54.16
16 512 1222.3 (MAD 8.22) 56.44
32 512 1310.0 (MAD 0.60) 56.10

Three results worth keeping:

The round size itself was swept rather than guessed (same session, 2,048-token prompt): 256 -> 1191.4, 512 -> 1273.8, 1024 -> 1340.3, 2048 -> 1292.0 tok/s. Prefill peaks near 1024, but decode falls from 56.25 to 54.78 there and to 53.57 at 2048, so 512 is the shipped default: it takes the bulk of the prefill win while leaving decode -- the larger competitive margin -- untouched.

A third limit turned up only when the quantized schemes were measured, and it nearly shipped a large regression. The quantized matmul kernels already stage dequantized weights in ~16.9 KiB of threadgroup memory, so widening their token tile costs far more in occupancy than it saves in weight traffic. Isolated on Q8_0 at a 2,048-token prompt:

Q8_0 16-token tile 32-token tile
32-token round 455.1 74.9
512-token round 1139.4 135.1

The wider round is worth 2.5x for Q8_0 as well, but the wider tile is -88% for it while being +7% for BF16. Tile width is therefore a property of each kernel rather than of the projection layer, and the two quantized kernels keep the narrower tile. Correctness alone would never have caught this: all three schemes passed every gate at either tile.

Back-to-back A/B of the shipped configuration against the previous one, all three weight schemes, same session, after a sustained warm-up (Qwen3-4B, 2,048-token prompt, five runs each):

scheme previous shipped ratio decode before -> after
BF16 504.1 (MAD 0.11) 1308.2 (MAD 0.64) 2.60x 55.99 -> 56.47
Q8_0 453.6 (MAD 1.00) 1141.7 (MAD 1.14) 2.52x 85.79 -> 85.68
affine4 gs32 482.1 (MAD 0.18) 1139.5 (MAD 0.31) 2.36x 110.04 -> 110.21

Decode is unchanged for every scheme. An earlier run of the same comparison measured BF16 prefill at 407 tok/s with a median absolute deviation of 10.5 and affine4 decode 20% low; those readings were thermally polluted and are not used. The rule this reinforces is in bench/README.md: only a back-to-back same-session A/B is meaningful here, because the narrow-round configuration spent proportionally more time in per-round fixed cost and therefore tracked GPU clock state more closely than the new one does.

Every step is bit-exact: the full-logit fingerprint printed by --prefill-metal is unchanged through the refactor, the wider round, and the larger tile, verified by pinning the round back to 32 to separate the refactor from the speedup.

Two further changes were implemented, measured, and reverted rather than shipped, because the repository's standard is that a change earns its place:

Remaining prefill cost is therefore neither weight traffic nor attention locality. (Superseded 2026-07-28: the conclusion that "closing the rest needs a threadgroup-staged, K-blocked matmul" was tested and falsified — see the next section. The 2,174 tok/s reference figure it reasoned from was itself stale.)

Provenance note: the shipped-configuration figures in this section are backed by the committed bench/results/4b-*.json records from the release re-run; the intermediate sweep values were measured on the working tree that carried the change under test, per the policy at the top of this file.

What the matmuls are actually bound by, and the llama.cpp comparison was stale (2026-07-28)

Two findings from one measurement day, both of which correct this file.

The llama.cpp comparison was stale. The 2026-07-22 A/B recorded llama.cpp at 2,174 tok/s prefill / 35.0 decode (BF16). Re-running the exact committed command, binary, model, and OS on 2026-07-28 gives ~3,800–4,100 tok/s prefill and ~60–63 decode — roughly 2× on both axes — while Kipp reproduces its own committed numbers to within noise in the same session. The old llama.cpp session was evidently measured in a degraded GPU-clock state (the uniform ~2× across unrelated workloads is the signature; the exact cause is not reconstructable). The head-to-head below replaces it, and the lesson is recorded here precisely because this repository's claims are only as good as their worst measurement: cross-engine numbers are now re-validated in the same session as the Kipp numbers they are compared against.

Where the missing prefill time actually goes. A new isolated instrument (build/kipp_test_metal --mm-bench-metal) runs the live projection-matmul pipelines on every Qwen3-4B shape at a 512-token round, cycling through eight distinct weight buffers (so the system-level cache cannot serve one resident copy) versus reusing one. The result is unambiguous: rotating equals reusing on every shape, effective weight fetch is 17–24 GB/s against ~455 GB/s of demonstrated sustained bandwidth, and the kernels run at 8.7–12.3 TFLOP/s. The matmuls are compute/issue-bound, not traffic-bound, and summing the per-shape times attributes ~82% of 2,048-token prefill wall clock to them. Weight-traffic reduction — the motivation this file previously assigned to a future "K-blocked matmul" — cannot help; that plan is retired.

Consistent with that, explicitly staging the activation tile through threadgroup memory (removing the transposed device-memory gathers from the hot loop) was implemented, measured at −7 to −10% on every shape, and reverted: the fourth time explicit staging has lost to this GPU's implicit cache hierarchy. The simdgroup-matrix BF16 kernel sits at ~80% of the achievable ALU-issue ceiling and stays as is.

What did work: one shared dequant block per quantized threadgroup. The Q8_0/affine4 kernels staged four private 32-row dequantized blocks (16.9 KiB of threadgroup memory) — one per simdgroup, each serving 16 tokens. They now share a single cooperatively-dequantized 32-row block (4.2 KiB) across a 32-row × 64-token threadgroup tile: weight bytes are dequantized once per 64 tokens instead of once per 16, and the 4× smaller staging keeps several threadgroups resident per core. Accumulators, dequant expressions, and FP32 accumulation order are unchanged, so the kernels are bit-exact — the --prefill-metal fingerprints and the --pooled-metal tripwire did not move a digit. Same-session interleaved A/Bs: Q8_0 1137.7 → 1206.0 tok/s, affine4 1139.4 → 1213.9 (two passes each, decode dead flat); against the committed v0.0.4 records the release re-run reads +10.2% (Q8_0) and +11.7% (affine4) at 2,048 tokens. Widening the quant token slice on top of it (16 → 32 tokens per simdgroup) collapses to ~1 TFLOP/s from register spilling — the third confirmation that the 2-fragment slice is that kernel's register ceiling — and was reverted.

The corrected head-to-head (same session, same host and weights, llama.cpp 178a6c449 with Metal, llama-bench -p 2048 -n 256 -r 5; every figure traces to llamacpp-qwen3-4b.json and 4b-*.json, 2026-07-28):

Qwen3-4B, M5 Max Kipp llama.cpp (default) llama.cpp (GGML_METAL_TENSOR_DISABLE=1)
Prefill BF16 @2048 1,312 3,788 1,261
Prefill Q8_0 @2048 1,213 2,729 1,022
Prefill Q4-class @2048 1,214 (affine4) 1,225 (Q4_0) 608 (Q4_0)
Decode BF16 61.2 63.1 47.0 (interleaved; see note)
Decode Q8_0 97.4 100.5 46.8 (interleaved; see note)
Decode Q4-class 128.8 (affine4) 149.6 (Q4_0) 51.2 (interleaved)

llama.cpp's decode figures are from isolated decode-only runs (-p 0 -n 256, cooled machine): its interleaved -p 2048 -n 256 run heats the GPU enough to depress its own tg readings to 47–69 tok/s, and quoting those would flatter Kipp. The honest decode summary is therefore: llama.cpp is ~3% ahead on the matched schemes (63.1/100.5 vs 61.2/97.4) and clearly ahead on 4-bit decode (scale-only Q4_0 dequantizes more cheaply than scale+bias affine4; the schemes also differ in quality — affine4's perplexity cost is documented above, Q4_0's on the llama.cpp stack was not measured here). The prior "decode 1.7× in Kipp's favor" claim derived entirely from the stale llama.cpp session and is withdrawn.

The decisive prefill variable is the instruction class. On M5, llama.cpp routes GEMM through the Metal 4 tensor API (mpp::tensor_ops, the neural accelerators) by default — it enables that path only on M5/M6/A19/A20-class devices, and its own source notes it is a wash or a loss on M4 and earlier. That path is worth ~2.3–3× on prefill GEMM and nothing on decode (matvec). Forced onto the same simdgroup-matrix instruction class Kipp uses (GGML_METAL_TENSOR_DISABLE=1), llama.cpp's prefill drops to 1,261 / 1,022 / 608 — Kipp leads its own instruction class on every scheme (1.04× / 1.19× / 2.0×).

What follows from this: matching llama.cpp's default BF16/Q8_0 prefill on M5 requires an mpp::tensor_ops matmul path, not further simdgroup tuning — that is the next roadmap item, behind the same runtime-probe-and-fall-back ladder and oracle gates as the simdgroup kernels. (Delivered in v0.0.6 — next section.)

The tensor-ops path: BF16 prefill 2.80×, and where llama.cpp's design was wrong for this engine (2026-07-30)

v0.0.6 ships the mpp::tensor_ops (M5 neural-accelerator) matmul path for BF16 layer projections, and it closes the instruction-class gap the previous section documented:

Qwen3-4B BF16, M5 Max, same-session interleaved A/B simdgroup (KIPP_METAL_TENSOR_DISABLE=1) tensor (default on M5-class) ratio
Prefill @348 1,031.4 2,489.3 2.41×
Prefill @2,048 1,312.5 (MAD 1.2) 3,682.1 (MAD 0.28) 2.80×
Prefill @12,800 691.8 968.7 1.40×
Decode 67.6 68.1 identical
Q8_0 control @2,048 equal equal

The committed records (4b-bf16-*.json, 2026-07-30 session) read 3,679 / 2,500 at 2,048 / 348 tokens. That is parity with llama.cpp's own tensor path (its cool-session 3,788 from the 2026-07-28 record; its quant rows are unchanged — Kipp's quantized schemes still run the simdgroup kernels, bit-frozen by fingerprint in both env states). The 12,800-token ratio falling to 1.40× says what the next campaign is: with projections ~5× faster, long-context prefill attention is now the dominant cost.

Three design findings, two of them against the llama.cpp blueprint this port started from:

Because CI runners cannot run (or even compile) this path, it carries more tripwires than any other kernel class: a runtime compile-and-probe ladder behind a device allow-list, KIPP_METAL_REQUIRE_TENSOR=1 turning silent fallback into a load failure (it caught a real kernel bug during development), a bench harness that refuses degraded-build numbers, an affirmative "tensor path disabled (reason)" line asserted by CI, tensor slots in the shader geometry probe, and an exact-integer operator test at tolerance 0.0 on a ragged 72×133 shape — integer FP32 accumulation is order-independent, so a wrong matmul2d descriptor cannot hide inside a tolerance. Per-kernel-class tripwires (M5 Max): tensor --prefill-metal fingerprint 794f1799c6a7326a / pooled NMSE 5.12341885e-07; the frozen simdgroup values (49e2ada96bce2804 / 2.08738076e-06) remain verifiable with KIPP_METAL_TENSOR_DISABLE=1, and both must be checked on their own path.

Optimized Metal kernels on Apple M5 (v0.0.1)

Measured on 2026-07-13 with Kipp v0.0.1's batched Metal path: one serial compute encoder per command buffer, up to 32 prefill tokens per batch, simdgroup-matrix BF16 projections for batched prefill (16-token tiles, FP32 accumulation, with a vector-kernel fallback on devices without bfloat simdgroup matrices), a simdgroup matvec for single-token decode, and split-K streaming online-softmax attention (eight partial softmaxes per head merged in threadgroup memory). Same hardware, model artifact, build flags, and harness as the baseline below; one discarded warm-up and five measured subprocesses per configuration.

Commands:

python3 tools/bench.py --warmup 1 --runs 5 --decode 8
python3 tools/bench.py --warmup 1 --runs 5 --decode 32 --prompt "<265-token prompt>"

Results (median, with median absolute deviation):

Short-prompt prefill is dominated by fixed per-evaluation cost; the 265-token configuration reflects the batched matrix-kernel prefill path. Decode remains memory-bandwidth-bound: at ~13.8 tokens/s the engine streams roughly 115 GB/s of BF16 weights through the M5. Prefill at this revision was limited by per-layer KV re-reads in attention rather than by the projection matmuls. That attribution is specific to the 32-token round this section measured and no longer describes the engine -- see "Prefill round shape" below for the current model and the measurement that corrected it.

Batched decoding through the server (48 sampled tokens per sequence, temperature 0.8, single measurements). Multi-choice requests (n) and independent concurrent clients batch the same way — each step reads the weights once for every active sequence:

Aggregate throughput approaches compute limits instead of scaling the bandwidth cost with the number of sequences.

Phase 3 Apple M5 baseline (superseded)

Measured on 2026-07-10 with the original one-token-per-command-buffer kernels, on:

Command:

python3 tools/bench.py --warmup 1 --runs 5 --decode 8

Results:

Peak process RSS is not total unified-memory residency. Metal accesses the 7.50 GiB model through a shared no-copy mmap, and macOS does not charge those GPU-touched mapped pages to the CLI's reported peak RSS in the same way as ordinary process allocations. The artifact size and cache layout therefore remain necessary context; the RSS number must not be presented as the total memory needed to run the model.

This was a readable fixed-shape baseline, not an optimized performance claim. At that revision the implementation intentionally excluded fusion, quantization, batching, and private weight copies; later results above supersede those constraints.