GPU benchmarks — Runner vs llama.cpp (CUDA)
First published 2026-07-29, runner 94ce01f; MoE rows updated the same day
on runner 26cf7e6 after the device-routing work landed. One machine, one
method, both engines on the same files — and the losing rows published
alongside the winning ones.
Setup
| GPU | NVIDIA RTX PRO 6000 Blackwell Max-Q, MIG 1g.24gb slice (not the full card) |
| CPU | AMD Ryzen Threadripper (128 threads available; runner used its defaults) |
| Runner | 94ce01f (2026-07-29), built -O3 -march=native, CUDA via sm_75 PTX, JIT to compute 12.0 |
| llama.cpp | build ea12b27 (CUDA), same GGUF files |
| Method | 512-token prefill / 128-token greedy decode (--temp 0), full GPU offload (verified full=1 per run), median of 3 runs; llama.cpp via llama-bench -p 512 -n 128 -ngl 99 -r 2 |
MIG caveat: a 1g.24gb slice has roughly ~200 GB/s of memory bandwidth and a fraction of the card’s SMs. Absolute tok/s will differ on other hardware; the ratios between the two engines are the meaningful result, and even those shift with the compute/bandwidth balance (the same kernels measured different ratios on an RTX 3070).
Results — 2026-09-02, three hosts, runner 6d705e9
Same method as the 2026-08-13 table on the MIG (--bench-json -n 128 -b 64,
mean of 2; llama-bench -p 512 -n 128 -ngl 99 -r 2; full offload verified
full=1 on every row), plus two consumer hosts. Absolute numbers on the MIG
box are lower than in August on BOTH sides (llama.cpp’s Llama-3.2-3B decode
reads 101.8 against 169.0 then) because the box carried a load average of 18
from other work during the run and the driver moved to 610.43; the ratios are
the result, as the setup section says.
MIG 1g.24gb, llama.cpp b10353 CUDA (same build as August)
| model | quant | decode tok/s: runner / llama.cpp | prefill tok/s: runner / llama.cpp |
|---|---|---|---|
| Llama-3.2-3B | Q4_K_M | 87.9 / 101.8 (86%) | 525.7 / 6387.6 (8.2%) |
| Phi-4-mini | Q8_0 | 53.8 / 57.7 (93%) | 355.6 / 5960.6 (6.0%) |
| granite-3.3-8b | Q4_K_M | 39.7 / 45.4 (87%) | 220.5 / 2288.9 (9.6%) |
| granite-4.1-8b | Q4_0 | 41.7 / 46.5 (90%) | 151.1 / 2359.2 (6.4%) |
| Phi-3.5-mini | Q4_K_M | 72.0 / 83.2 (87%) | 284.9 / 5120.3 (5.6%) |
| gemma-4-12B | Q4_K_M | 23.2 / 28.8 (81%) | 123.7 / 1439.2 (8.6%) |
| Qwen3-30B-A3B (MoE) | Q4_K_M | 62.2 / 86.6 (72%) | 128.5 / 2326.3 (5.5%) |
| gemma-4-26B-A4B (MoE) | Q4_0 | 23.3 / 56.2 (41%) | 34.3 / 2429.7 (1.4%) |
| Qwen2.5-32B | Q3_K_S | 1.8 / 14.3 (13%) | 15.3 / 469.0 (3.3%) |
Dense decode moved from 77-87% to 81-93% and the MoE rows from 67% and 22% to 72% and 41% (the KV-ring accounting fix and the August decode work); prefill is unchanged at 5-10% and stays the column that decides the next piece of work. The Q3_K row is still the naive token-identical kernel.
RTX 3070 8 GB (Windows 11, driver 596.36), llama.cpp b10754 CUDA 13.3 prebuilt
| model | quant | decode tok/s: runner / llama.cpp | prefill tok/s: runner / llama.cpp |
|---|---|---|---|
| Qwen2.5-7B | Q4_K_M | 65.6 / 81.6 (80%) | 112.3 / 2224.5 (5.0%) |
| granite-4.1-3b | Q8_0 | 78.6 / 94.3 (83%) | 493.5 / 5614.1 (8.8%) |
| Qwen2.5-3B | Q4_K_M | 113.3 / 161.2 (70%) | 234.0 / 6069.9 (3.9%) |
A desktop card with a display attached, a newer llama.cpp than the MIG table’s, and the same shape of result: decode within 20-30%, prefill an order of magnitude behind.
Apple M1 8 GB, Metal, llama.cpp from Homebrew (ggml 0.22.0), 2026-09-02
| model | quant | decode tok/s: runner / llama.cpp | prefill tok/s: runner / llama.cpp |
|---|---|---|---|
| granite-4.1-3b | Q8_0 | 11.6 / 14.4 (80%) | 47.8 / 266.5 (17.9%) |
| gemma-3-4b | Q4_K_M | 8.4 / 20.3 (41%) | 62.9 / 250.3 (25.1%) |
Measured with 1.3 GB of RAM available: the runner logged its own warning (“weights are 3.6 GB but only 1.3 GB of RAM is available, expect the model to be evicted”) and paged, while llama.cpp wires the model into memory. That is the coexistence trade the README describes, read from the losing side: on a pressured machine the resident engine decodes faster and the evictable one gives the memory back. The gemma row is that effect, not a kernel result; re-measure on a quiet M1 before reading it as one.
Results — 2026-08-13, both sides re-measured on this box
The 2026-07-29 table below is kept as history. It was taken before a CUDA
prefill correctness bug was found (TC_GEMM_32B published uninitialised shared
memory for token columns 16..63 — its Q8_0 prefill rows were therefore
measuring a kernel that was not computing what it reported), and before Q4_0,
the granite arch and the Q4_0 decode GEMV landed. This table replaces it: both
engines re-run on the same slice, same files, same day, on the fixed kernel.
| model | quant | decode tok/s: runner / llama.cpp | prefill tok/s: runner / llama.cpp |
|---|---|---|---|
| Llama-3.2-3B | Q4_K_M | 130.3 / 169.0 (77%) | 735.1 / 8440.6 (8.7%) |
| Phi-4-mini | Q8_0 | 80.2 / 92.2 (87%) | 509.8 / 8397.0 (6.1%) |
| granite-3.3-8b | Q4_K_M | 61.0 / 73.9 (83%) | 326.4 / 3335.8 (9.8%) |
| granite-4.1-8b | Q4_0 | 64.4 / 75.9 (85%) | 230.4 / 3710.6 (6.2%) |
Runner via --bench-json -n 128 -b 64 (mean of 2, spread < 2.2%); llama.cpp
b10353 built with -DGGML_CUDA=ON from the same source tree on this box, via
llama-bench -p 512 -n 128 -ngl 99 -r 2.
The reference reproduces the published one. llama.cpp measures 8440.6 / 169.0 on Llama-3.2-3B against the 8373.6 / 169.0 recorded on 2026-07-29 with a different build — so the denominators in the old table were sound, and the movement in the ratios is runner’s.
Decode: 77-87%, up from 73-79%. The granite-4.1-8b Q4_0 row (85%) is new coverage rather than tuning: Q4_0 had no coalesced decode GEMV until 2026-08-13 and ran at 11.9 tok/s, which was slower than the same model on the CPU.
Prefill: 6.1-9.8%, up from 4.3-5.6%, and still the honest weak column. The gain came from admitting types and architectures to the tensor-core path (Q6_K 2026-08-08, Q4_0 + granite 2026-08-13), not from a faster kernel — the tile is still 64 columns wide where llama.cpp’s stack is deeper. See performance.md for what was measured and rejected.
Results — default configuration, 2026-07-29
“Default” means what each engine does out of the box on these files. For Runner that includes the tensor-core prefill GEMM on the seven gated dense (Q4_K, arch) combos (promoted 2026-07-29 behind a measured tolerance gate; see the TC spec) and the scalar path everywhere else.
| model | quant | decode tok/s: runner / llama.cpp | prefill tok/s: runner / llama.cpp |
|---|---|---|---|
| Llama-3.2-3B | Q4_K_M | 130.7 / 169.0 (77%) | 438.1 / 8373.6 (5.2%) |
| Phi-3.5-mini | Q4_K_M | 112.4 / 142.8 (79%) | 302.3 / 6965.6 (4.3%) |
| gemma-4-12B | Q4_K_M | 35.9 / 48.9 (73%) | 131.8 / 2349.0 (5.6%) |
| Qwen3-30B-A3B (MoE) | Q4_K_M | 102.2 / 151.7 (67%) | 194.0 / 3233.5 (6.0%) |
| gemma-4-26B-A4B (MoE) | Q4_0 | 24.7 / 114.2 (22%) | 23.6 / 3694.2 (0.6%) |
| Qwen2.5-32B | Q3_K_S | 3.0 / 25.1 (12%) | 1.4 / 794.4 (0.2%) |
Reading the numbers honestly
Dense decode is the story: 73–79% of llama.cpp. Single-stream decode is
memory-bandwidth-bound on this slice — both engines read the same quantized
weights per token — so parity is the physical target, not victory. Runner’s
decode GEMVs (aligned 8-byte quant loads, float4 activation loads, factored
per-group affine) close most of the remaining gap while keeping the engine
dependency-free.
Prefill is llama.cpp’s win, and we publish it as such. llama.cpp’s prefill throughput comes from a mature tensor-core GEMM stack across every quant. Runner’s TC path covered Q4_K and Q8_0 when this table was taken, and lifted promoted dense models from ~3% to ~4–6% of llama.cpp. It now also covers Q6_K (2026-08-08) and Q4_0 (2026-08-13, with the granite arch), and the 32-byte-block kernel it shares was carrying a 48-of-64-columns bug until 2026-08-13 — see the re-measured table above. Further coverage is tracked work, and the gap is reported, not hidden.
Known-slow rows are kept in the table. Q3_K decode (12%) uses a
token-identical but naive kernel — its rewrite is a tracked item, including the
measured root cause (accumulator spill to local memory at the widened tile).
MoE decode reached 67% via device-side routing and fused indirect expert
matvecs (2026-07-29); gemma’s dual-branch MoE (22%) and MoE prefill remain
the tracked remainders. The certified byte-identity property for MoE is
defined over the eager routing path (RUNNER_MOE_EAGER=1, pinned in the
certification harnesses); the fused default is verified
selection-identical with routing weights within ~2 ulp.
Correctness gates every speed number. The scalar path is certified
token-identical CPU vs GPU (and against a pinned llama.cpp revision where
recorded — see the compatibility program). The
tensor-core path is fp16-tile arithmetic and is instead held to a teacher-forced
tolerance gate (make test-tc-tol): every promoted row measured 0/64 top-1
flips and ≤0.012% mean logit deviation; in free-running checks to date its
greedy output has matched the scalar path exactly.
Trajectory (same box, same method, same llama.cpp build)
| model | decode, 2026-07-25 | decode, 2026-07-29 | prefill, 07-25 | prefill, 07-29 |
|---|---|---|---|---|
| Llama-3.2-3B | 52% | 77% | 3.1% | 5.2% |
| Phi-3.5-mini | 50% | 79% | 2.9% | 4.3% |
| gemma-4-12B | 48% | 73% | 3.1% | 5.6% |
| Qwen3-30B-A3B | 36% | 67% | 2.4% | 6.0% |
| gemma-4-26B-A4B | 21% | 22% | 0.8% | 0.6% |
| Qwen2.5-32B | 12% | 12% | 0.3% | 0.2% |
Six days of kernel work (decode GEMV bandwidth pass, MVB-16 tiles, MMQ-style TC prefill GEMM + its tolerance gate, MoE device-side routing + fused indirect expert matvecs) — plus one silent MoE GPU→CPU fallback found by this benchmark’s own control run, fixed and now guarded by a test.
Reproducing
# runner rows (median of 3; verify the gpu-split line reports full=1)
./runner -m model.gguf -f prompt-512tok.txt -n 128 --temp 0 -s 1 \
--ignore-eos --gpu-layers 99
# llama.cpp rows
llama-bench -m model.gguf -p 512 -n 128 -ngl 99 -r 2
# pin runner's scalar path (byte-identical CPU==GPU) if comparing outputs
RUNNER_CUDA_TC=0 ./runner ...
Source: docs/benchmarks.md in the repository, copied at build time.