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.

Panel-flash attention: prefill GQA on the tensor units, and the ~6× headroom it converts (2026-08-08)

v0.0.7 moves batched-prefill grouped-query attention off the simdgroup flash-attention kernel and onto the M5 mpp::tensor_ops matmul path the previous campaign built for projections, attacking the long-context cost that section named. The win grows with context, tracking attention's share of the wall:

Qwen3-4B BF16, M5 Max, same-session A/B, cool-start matched simdgroup (KIPP_METAL_TENSOR_DISABLE=1) panel-flash (default on M5-class) ratio
Prefill @2,048 4,069 4,530 1.11×
Prefill @6,400 2,740 3,451 1.26×
Prefill @12,800 1,754 2,410 1.37×
Decode 60.6 60.7 1.00×
Q8_0-KV control equal equal streaming, unchanged

Per 1,024-KV-position panel: a gather copies the round-visible KV range through the block table into contiguous K + transposed-V panels; Q·Kᵀ runs as one matmul2d per head into a float score panel; a panel-granular online softmax reuses the streaming kernel's exact merge expressions (bf16 probabilities, float denominators); P·V runs as a second matmul2d; an accumulate kernel folds each panel into the running output with the cross-panel rescale. It is tensor-state + BF16-KV only — the simdgroup flash-attention kernel stays the path for every other device, for KIPP_METAL_TENSOR_DISABLE, and for Q8_0 KV, so its fingerprints stay frozen. The committed records (4b-bf16-prefill2k.json, 4b-bf16-prefill12k8.json) read 4,443 tok/s at 2,048 tokens (sustained) and 2,410 at 12,800 (cool-start); same-session, llama.cpp reads 4,100 and 1,561, so Kipp leads it 1.54× at 12,800 — llama.cpp's flash-attention is simdgroup-only even on M5. CUDA was not revalidated this campaign.

A measurement note that shapes everything below: the M5 laptop chassis cannot hold steady state under sustained long-context prefill. bf16 @2,048 tensor holds ~4,450 tok/s across a dozen back-to-back runs (MAD 0.66%), but @12,800 the same kernel decays run-over-run as the chassis heat-soaks (2,304 → 1,098 within one session). This is thermal, not power (battery 100%, adapter steady). So the long-context A/B is measured cool-start matched — an idle-GPU cooldown before every run, both arms interleaved — which samples both kernels at the same thermal state and gives a fair, reproducible cold-prompt ratio. Absolute long-context numbers are cold-prompt medians, labelled as such.

Why attention was worth its own kernel-class swap: at a 12,800-token prefill the attention FLOPs are 2·2·(N²/2)·32 heads·128 dim·36 layers ≈ 48.3 TFLOP — about half the projection FLOPs and growing linearly with context. A two-point quadratic fit T(N)=aN+bN² across both kernel classes agrees within 11% and attributes ~85% of the 12.8k wall to attention. The simdgroup FA kernel ran ~3 TFLOP/s effective (--fa-bench-metal) while matmul2d at attention panel shapes sustains 20–25 TFLOP/s (probe E4) — the ~6× headroom panel-flash exists to convert.

Design findings, several of them kill records:

Per-kernel-class tripwires (M5 Max): tensor-state --prefill-metal bf16 fingerprint 721ea327c1facaed / NMSE 7.46e-07, pooled NMSE 6.05480037e-07; quant tensor-state q8_0 0c251632a7a38a3d, affine4 52161133c69d0d76; the frozen simdgroup values (49e2ada96bce2804 / 2.08738076e-06) remain recoverable with KIPP_METAL_TENSOR_DISABLE=1, and both classes must be checked on their own path. Every tensor-state value is tighter than the class it replaced, and paged placement invariance is bitwise through the panel gather.

Quantized projections on the tensor units: Q8_0/affine4 prefill 2.3× (2026-08-09)

v0.0.6 put BF16 projections on the Metal 4 mpp::tensor_ops path; v0.0.8 does the same for the quantized schemes, the last prefill path still on the simdgroup kernels.

Qwen3-4B, M5 Max, same-session A/B (both tensor-state) simdgroup (KIPP_METAL_TENSOR_DISABLE=1) quant-tensor (default) ratio
Q8_0 prefill @2,048 1,247 2,875 2.31×
affine4 prefill @2,048 1,250 2,857 2.29×
decode (both schemes) — — unchanged (matvec)

Committed steady-state records (tensor path): Q8_0 2,882 / 1,940 at 2,048 / 348 tokens, affine4 2,868 / 1,960 (MAD ≤0.3% at 2,048).

Unlike the BF16 tensor kernel — which feeds matmul2d bf16 weights straight from device memory and stages nothing — a quant weight must be dequantized first. kipp_matmul_q8_0_tensor and kipp_matmul_affine4_tensor reuse the simdgroup kernels' cooperative dequant prologue (kipp_fp16_bytes; Q8_0 d*q, affine4 scale*q+bias) to expand each K-block into a threadgroup bf16 tile (64 rows × 32), then run one matmul2d per block. The output tile geometry (64 × 128, K-chunk 32), transposed grid, and geometry probe are shared verbatim with the BF16 tensor kernel — nothing else moved.

Design notes:

Per-kernel-class tripwires (M5 Max): tensor-state --prefill-metal q8_0 fingerprint 33b605f4f5a05243 (nmse vs CPU oracle 9.2e-07), affine4 0953f2975a2692d5 (2.2e-06); the frozen simdgroup values (7421c99f0af71365 q8_0, 0709c0eb78978e61 affine4) remain recoverable with KIPP_METAL_TENSOR_DISABLE=1, and both classes must be checked on their own path. bf16 (721ea327c1facaed) and pooled (6.05480037e-07) reprint unchanged. CUDA was not revalidated this campaign.

Decode is weight-bandwidth-bound: the dequant-vectorization kill (2026-08-12)

With every prefill path on the tensor units (v0.0.6–v0.0.8), decode is the last axis where Kipp trails llama.cpp (~3–6% BF16/Q8_0, ~17% 4-bit). Decode generates one token at a time and streams every model weight per token, so it is memory-bandwidth-bound. Two independent signals confirm the floor:

The one lever with apparent headroom — measured, and killed. The Q8_0 and affine4 decode matvec inner loops were scalar (float(qs[j])*in[j] per weight, per-byte nibble unpack) while BF16 was vectorized, and Q8_0 decoding only ~1.6× faster than BF16 despite moving ~half the bytes suggested the quant kernels were partly ALU-bound on dequant. We vectorized both inner loops (float4 activation loads + FMA, mirroring the BF16 kernel; correctness-gated: operator suite 38/0, oracle NMSE 9.2e-07/2.2e-06, tensor fingerprints frozen) and ran a same-session A/B (vectorized vs scalar). A naive sequential A/B read Q8_0 +2.3%, but the BF16 control — an untouched, identical kernel — read +2.8% between the two binaries, i.e. the +2.3% was measurement drift, not a win. An interleaved, drift-cancelling A/B settled it: drift-corrected Q8_0 −2.6% (vectorized is if anything slightly slower — extra register pressure, no fewer bytes) and affine4 neutral. The change was reverted. Quant decode is byte-bound, not ALU-bound; the dequant loop is not the bottleneck.

Conclusion: the BF16/Q8_0 decode gap vs llama.cpp is a weight-bandwidth floor, not an implementation defect — both engines stream the same bytes and land within a few percent. The larger 4-bit gap is partly the format: Kipp's affine4 carries a per-group scale and bias (higher quality), which Q4_0's scale-only zero-point (−8) avoids, costing an extra term per block; closing it would mean a new scale-only 4-bit scheme, not a kernel tweak.

Measurement note: decode is immune to GPU-clock DVFS and other-GPU contention, but not to Low Power Mode, which throttles unified-memory bandwidth SoC-wide — under LPM a BF16 decode control read 12.5 vs 59.7 (5× low). Every decode A/B here carries an untouched BF16 control as the validity gate.

Scale-only 4-bit (scale4_gs32): +4.5% decode over affine4, prefill parity (2026-08-14)

The decode-kill section above named the remaining 4-bit lever: not a kernel tweak but a new scale-only format. scale4_gs32 is it — 32-weight groups of 16 packed nibbles plus one fp16 scale (18 bytes, 4.5 bpw), symmetric via the implicit −8 zero-point: w = scale·(q − 8). It drops affine4's per-group bias (20 → 18 bytes), so it is Q4_0-class where affine4 is Q4_1-class. The converter mirrors llama.cpp's Q4_0 (scale = signed-max-magnitude / −8, quantized against the fp16-rounded scale), and the three Metal kernels (matvec decode, simdgroup prefill, tensor prefill) reuse the affine4 shapes with the bias term removed and the −8 folded per value.

Decode: +4.5% over affine4 — a same-session interleaved A/B (2 rounds × 10 runs each, drift-cancelled): scale4 131.9 vs affine4 126.3 tok/s (MAD ≤1.1%), with the untouched BF16 control at 59.41 (≈ the known-good 59.7 — session valid, not throttled). Why +4.5% and not the naive 18/20 = 10%: the byte saving is only on the seven 4-bit projections; decode also streams the unchanged BF16 lm_head, embeddings, and norms plus the KV cache, and pays fixed per-step overhead. The whole artifact is 7.4% smaller (3.05 → 2.83 GiB), and the realized decode win tracks a bit under that — exactly the weight-bandwidth law the kill section established. Prefill is parity with affine4 (same-session A/B: scale4-tensor 3,752 vs affine4-tensor 3,811 tok/s, −1.6%; scale4's tensor kernel is 4.15× its own simdgroup path), as expected: prefill is compute-bound on matmul2d, so dropping one dequant term per block is marginal.

Quality is a small, honest cost of the symmetric format: the full-vocab logit NMSE is 4.4e-3 against the BF16 reference with argmax exact (bound 3e-2, shared with affine4) — well inside the Q4-class gate. Correctness is frozen by fingerprint in both env states: tensor prefill 18bed8f354933b6a (matrix-vs-vector NMSE 3.0e-06, vs CPU oracle 1.6e-06), simdgroup (KIPP_METAL_TENSOR_DISABLE=1) 4bb690d2528803fe; the operator suite (38/0) exercises both scale4 kernels against an integer-exact synthetic reference at tolerance 0. Records: 4b-scale4_gs32-decode 128.9, -prefill 2,460, -prefill2k 3,842 tok/s.

Note on comparability: the +4.5% and the prefill-parity figures are same-session interleaved A/Bs, not subtractions of committed records — cross-session record subtraction is unreliable here (affine4's committed prefill2k of 2,868 is a cooler v0.0.8 session; this session affine4 prefill measured ~3,811). Against llama.cpp, scale4 is now format-matched to Q4_0 (both scale-only 4-bit, same 18-byte block), so the residual decode gap (Kipp ~132, llama.cpp Q4_0 149.6, ≈1.13× — down from affine4's 1.17×) is engine efficiency, not format. Perplexity was not re-run for this release (the full wikitext-2 sweep is ~2.4 h/scheme); the NMSE above is the quality gate, and a scale4 ppl entry can be added to ppl-4b.json later.

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.