The question I kept getting

Every few weeks someone at work asks me a version of the same thing. "We have this laptop, or this one box in the office. Which quant should we run? How many threads? Is the GPU worth it for a small model? Why does the number on the llama.cpp README not match what we see?" I had opinions, and most of them came from reading other people's benchmarks on hardware nothing like ours. That bothered me. So I took one small model, one laptop I use every day, and went through every knob llama.cpp exposes with a stopwatch in one hand and a perplexity tool in the other.

The idea I actually wanted to test came from a hunch. A transformer isn't one blob of weights. It's an embedding table, a stack of layers, and an output head, and each of those can be stored at a different precision. If the output head is a quarter of the model and it gets read on every single token, then quantizing just that tensor should buy speed without touching the layers that do the reasoning. Or the opposite: maybe the head is exactly the thing you should protect and the layers are where the cheap bytes are. I didn't know. Now I do, with error bars.

This is a long post. It's structured so you can stop at any section and still leave with something usable. Every number here was produced by a script in the companion repository, every chart is drawn from a results file you can open, and the whole thing runs on a prebuilt llama.cpp release. No compiler needed, unless you want one.

Section What you get
The machine and the modelWhy a single 16 GB DIMM sets the ceiling, and why the LM head is 26% of every token
Threads on a hybrid CPUThe default thread count is wrong on 12th to 14th gen Intel, and by how much
Which CPU DLL you're runningHow llama.cpp picks one of fourteen ggml-cpu libraries, and what forcing a different one costs
Quantization, speed and accuracyTwenty-one variants scored against BF16 with KL divergence, not vibes
The tied LM headWhat happens when you quantize only the head, and when you protect only the head
GPUWhy a 0.6B model can't use a 384 GB/s card, and what to do about it
When the model doesn't fitQwen3-8B on an 8 GB card: layer splits versus tensor placement
Speculative decoding without a draft modelSelf-drafting from n-grams, with the same greedy output
A driver in a few hundred linesTalking to llama.dll from Python with cffi, and where the overhead really is
Choosing for your hardwareA decision guide by CPU family, RAM, VRAM and workload

The machine and the model

The laptop is an ASUS TUF F16 with an Intel Core i7-14650HX, an RTX 5060 Laptop GPU with 8 GB of GDDR7, one 16 GB DDR5-5600 SO-DIMM and an NVMe drive. That "one" matters more than anything else on the list. The board has two memory slots and only one is populated, so the CPU has a single 64-bit channel to memory. Peak theoretical bandwidth is 5600 MT/s times 8 bytes, which is 44.8 GB/s. A second DIMM would double it. Keep that number in mind; the whole CPU half of this post is a story about it.

Component What it is What matters for inference
CPUIntel i7-14650HX, 8 P-cores + 8 E-cores, 24 threads, Raptor Lake HXAVX2, FMA, F16C and AVX-VNNI. No AVX-512. Hybrid scheduling is the trap.
Memory1 x 16 GB DDR5-5600, single channel44.8 GB/s theoretical. I measured 34.0 GB/s achievable.
GPUNVIDIA RTX 5060 Laptop, 8 GB GDDR7, 128-bit bus, compute capability 12.0384 GB/s. About 7 GB free with the desktop and other things resident.
Storage1 TB NVMe (WD SN5000S)Only matters for the first load; every model here fits in RAM.
OS and buildWindows 11, llama.cpp b10941 (0.4.0-dev), prebuilt win-cpu-x64 and win-cuda-13.3-x64 zipsBuilt with Clang 20. The CUDA zip carries native sm_120a code, so no PTX JIT on Blackwell.

The model is Qwen3-0.6B, the official Qwen/Qwen3-0.6B-GGUF Q8_0 file, 639 MB on disk. I picked it because it's small enough to run every experiment on both the CPU and the GPU dozens of times, and because it has a property that makes the head question sharp: its output projection is tied to its input embedding. There is no output.weight tensor in the file. The 151,936 x 1024 token_embd.weight matrix does both jobs.

Tensor breakdown of Qwen3-0.6B-Q8_0.gguf (scripts/inspect_gguf.py)
role                              params(M)        MB  %params  %bytes  types
token_embd.weight                    155.58    165.31    26.10   26.09  Q8_0
blk.*.attn_q.weight                   58.72     62.39     9.85    9.85  Q8_0
blk.*.attn_k.weight                   29.36     31.20     4.93    4.92  Q8_0
blk.*.attn_v.weight                   29.36     31.20     4.93    4.92  Q8_0
blk.*.attn_output.weight              58.72     62.39     9.85    9.85  Q8_0
blk.*.ffn_gate.weight                 88.08     93.59    14.78   14.77  Q8_0
blk.*.ffn_up.weight                   88.08     93.59    14.78   14.77  Q8_0
blk.*.ffn_down.weight                 88.08     93.59    14.78   14.77  Q8_0
norms (F32)                            0.06      0.24     0.01    0.04  F32

TOTAL params 596.05 M, bytes 633.50 MB, mean bpw 8.503

Twenty-six percent of the bytes are the embedding matrix. Here's the part that took me a while to internalize. When a token goes in, the model looks up one row of that matrix: a get_rows op that touches 1 KB. When a token comes out, the model multiplies the final hidden state by the whole matrix to get a logit for every one of the 151,936 vocabulary entries. That's a mul_mat that reads all 165 MB. So the input side is free and the output side costs a quarter of the model, on every decode step, no matter what you do to the layers.

One more detail that turned out to matter for reproducing other people's numbers. Not every Qwen3-0.6B GGUF on Hugging Face is tied. The HF safetensors checkpoint ships a redundant lm_head.weight even though the config says tie_word_embeddings: true, and the converter doesn't drop it. So the ggml-org and bartowski files have 311 tensors and an extra 165 MB output.weight, while the official Qwen file and the unsloth files have 310. Bytes read per token are the same either way, since the head gets read once regardless of which tensor holds it, but file size, RAM footprint and the default quantization mix all differ. If someone's numbers don't match yours, check the tensor count first.

Bytes per token, the only model you need

Generating one token with a dense model means reading every weight once. Prompt processing is different, because a batch of 512 tokens shares one read of the weights, but decode is one token at a time and the arithmetic per byte is tiny. On this CPU the decode step does roughly 1.9 floating point operations per byte it reads; the chip can do about 23 per byte before it would run out of memory bandwidth. So decode is bandwidth-bound by more than an order of magnitude, and the fastest it can possibly go is

The ceiling
tokens/s  <=  bandwidth / bytes_read_per_token

CPU, theoretical:  44.8 GB/s / 0.6335 GB  =  70.7 tok/s   (Qwen3-0.6B Q8_0, weights only)
CPU, measured:     34.0 GB/s / 0.6335 GB  =  53.7 tok/s
GPU, theoretical:  384 GB/s  / 0.6335 GB  =  606 tok/s

Nobody achieves the spec sheet, so I measured what a plain streaming read gets: eight processes each summing a private 512 MB buffer, started on a barrier so they overlap, aggregate bytes over wall time. Best pass was 34.0 GB/s, 76% of theoretical. llama.cpp's own kernels turned out to do slightly better than my naive test, reaching about 36 GB/s at their best, so the honest ceiling is somewhere between 53.7 and 70.7 tok/s for this file on this CPU. Every CPU result below should be read against that range. If a change moves you closer to it, it was a real improvement. If you're already near it, no kernel on earth is going to double your speed; only fewer bytes per token will, or a way to get more than one token per read.

The KV cache adds to the bytes as context grows. For this model that's 112 KB per position at f16, which is small next to 633 MB of weights until you're thousands of tokens deep. I measured that too, further down.

Baseline: what you get out of the box

Before touching anything, I ran llama-bench exactly the way most people would: the model, the prebuilt binary, no flags. Prompt processing at 512 tokens, generation at 128, five repetitions, the process at high priority so the OS doesn't preempt it for the browser.

Baseline commands
bin\cpu\llama-bench.exe  -m Qwen3-0.6B-Q8_0.gguf -p 512 -n 128 -r 5 --prio 2 -ngl 0
bin\cuda\llama-bench.exe -m Qwen3-0.6B-Q8_0.gguf -p 512 -n 128 -r 5 --prio 2
Backend Threads pp512 (tok/s) tg128 (tok/s) Share of the bandwidth ceiling Condition
CPU, defaults16 (auto)217.4 ± 0.219.5 ± 1.028% of 70.7working load
CUDA, defaultsall layers on GPU19654 ± 1179303.1 ± 22.050% of 606working load

Two things jump out. The CPU is at 28% of its bandwidth ceiling, which is a lot of headroom for a workload that is supposed to be limited by bandwidth. And the GPU, with eleven times the measured bandwidth, is 16 times faster, which sounds like a win for the GPU until you notice it's sitting at half of its own ceiling. Both have specific causes, and both are fixable without recompiling anything.

One word about that last column, because it matters for every CPU number in this post. This baseline was taken under my normal working load: two dev servers, a VM, an IDE and a few agents in the background. Some of the measurements below were taken in a quiet stretch with all of that idle, and the two conditions differ by more than most of the flags I'm about to test. Every table says which it is.

Threads on a hybrid CPU

llama.cpp's default thread count on Windows is the number of physical cores. On this chip that is 16: eight performance cores and eight efficiency cores. The Linux build excludes E-cores when it can read the topology; the Windows build doesn't, and llama-bench --help confirms (default: 16). Here's what that costs, one sweep from 1 to 24 threads, run in a quiet stretch with nothing else on the machine, decode and prompt processing as two separate passes.

Decode and prompt-processing speed of Qwen3-0.6B Q8_0 on the i7-14650HX against thread count from 1 to 24. Decode rises to 57 tok/s at 6 to 8 threads and falls steadily after that, to 26 at 24 threads. Prompt processing also peaks at 8 threads, at 518 tok/s, and drops once threads spill onto E-cores.
Both curves peak at the P-core count. Past eight threads the E-cores are on the critical path and everything waits for them.
Threads pp256 (tok/s) tg128 (tok/s)
197.9 ± 1.024.6 ± 0.3
2187.0 ± 1.442.4 ± 0.5
4306.7 ± 3.253.2 ± 0.4
6403.6 ± 13.156.6 ± 0.3
8517.9 ± 18.056.6 ± 0.1
12341.2 ± 2.649.4 ± 4.8
16419.4 ± 3.143.6 ± 0.5
24458.6 ± 46.426.2 ± 1.6

Decode is flat from six to eight threads, 56.6 and 56.6 tok/s, and then gets worse with every thread you add: 23% slower at the default of 16, 54% slower at 24. How much worse depends on the day. Two more paired runs of 16 against 8 threads in the same quiet window gave 53.8 and 52.3 against 55.4 and 56.4, a 5% loss, with 24 threads at 44.5; the sweep's 16-thread point was a worse placement than those. And under my working load, the baseline above, the default was 48% slower than -t 8 (19.5 ± 1.0 against 37.3 ± 3.4, measured back to back). So the honest range for the cost of the default on this chip is "somewhere between a few percent and half, and it gets worse the busier the machine is". The reason is structural. Every matrix multiply in the decode graph is split across the threads, and the graph can't move on until the slowest thread finishes its slice. Thread nine lands on an E-core with a lower clock and a smaller share of the memory system, so it finishes last, and the seven P-cores that were done early sit at the barrier waiting for it. Sixteen threads means eight of them are slow, and the fast ones spend most of every step idle. The wide error bar at 12 threads, 4.8 tok/s across five runs, is the scheduler sometimes getting the placement right and sometimes not.

I expected prompt processing to behave differently, because it's arithmetic-bound and an E-core does contribute real FLOPs. It didn't. In the same sweep pp256 peaked at eight threads too, 518 tok/s against 419 at sixteen and 459 at twenty-four. The batch is split into slices the same way the decode matmuls are, and a slice on an E-core still takes longer than the rest of the step. To make sure I wasn't reading noise, I ran 8 versus 16 threads back to back on pp512 three more times: 483 against 415, 434 against 228 and 172 against 231 tok/s. The one pair where 16 won is the one where something woke up mid-run; I looked and couldn't see what. Prompt processing, being compute-bound, is the measurement that suffers most from a busy machine. Sixteen threads pinned to the eight P-cores with their SMT siblings did 461, the same as eight pinned (461), so hyperthreading doesn't add anything either. On this chip and this build the answer is one number, not two: -t 8, and leave -tb alone unless you've measured it on your own machine. Linux builds that exclude E-cores automatically get this right by default; the Windows build doesn't.

What about pinning the eight threads to the eight P-cores explicitly, one per physical core, with -C 0x5555 --cpu-strict 1? I expected this to be the best CPU result in the post, and for a while I thought it was: an early pinned run gave 52.3 tok/s when an unpinned -t 8 run from my first, noisier thread sweep an hour earlier had given 34.7. But this laptop isn't a benchmark rig. It runs two Vite dev servers, a VM, an IDE and a pile of agents while I work, and the load comes and goes. When I finally measured the two settings back to back in a quiet stretch, unpinned -t 8 did 56.9 tok/s and strict pinning did 56.0. The same. The earlier gap was the machine, not the flag. And in a paired run with the background load present, strict pinning gave 40.9 ± 14.4 tok/s against 37.3 ± 3.4 for plain -t 8, with individual samples of 54, 51, 49, 27 and 24: whenever a pinned core was busy, the whole barrier waited on it, and a strictly pinned thread can't migrate. So the recommendation is the boring one. Set the thread count to the number of P-cores and let the scheduler place them. Pinning bought nothing when it was safe and cost a third when it wasn't.

One more result from the same run that I didn't expect: eight threads confined to just four P-cores with their SMT siblings (-C 0xFF --cpu-strict 1, effectively) decoded at 56.4 tok/s, the same as eight threads on eight cores. Decode needs enough outstanding memory requests to keep the controller busy, and four cores with hyperthreads generate as many as eight cores without. Prompt processing, which needs the arithmetic, dropped from 286 to 222 tok/s. If you're sizing a CPU for decode, bandwidth per core matters more than core count.

I also tried E-cores alone, eight threads strictly pinned to logical CPUs 16 to 23. Prompt processing was fine at 130 tok/s. Decode was 1.72 tok/s. Not a typo. That's what a barrier-bound workload looks like on cores that share a small ring stop and run at a fraction of the clock; every step is a full round of the slowest core's memory latency. If you have ever wondered why a background LLM task on a laptop crawls while the fan barely spins, this is probably why: Windows put it on the E-cores.

One thing that did nothing: --poll. The Windows release is built with OpenMP, and the poll setting is only read by llama.cpp's own threadpool. If you want to tune spin-wait behaviour on Windows, the knobs are the OpenMP ones, KMP_BLOCKTIME and OMP_WAIT_POLICY, not --poll.

Which CPU DLL you're running

Unzip the Windows CPU release and you'll find fourteen files named ggml-cpu-*.dll: x64, sse42, sandybridge, ivybridge, haswell, skylakex, cannonlake, icelake, cascadelake, cooperlake, sapphirerapids, alderlake, zen4 and piledriver. Each one is the same CPU backend compiled with a different set of instruction-set flags. Only one gets loaded. The question I had was how it decides, whether it decides well, and what happens when it doesn't.

The mechanism lives in ggml-backend-reg.cpp. At start-up, ggml scans the executable's folder for backend libraries, and for each CPU variant it calls an exported ggml_backend_score() function. The score is computed in cpu-feats-x86.cpp from CPUID: every feature the variant was built with is a required bit, and if the running CPU lacks any of them the score is zero. Among the survivors, more features means a higher score, and the highest score wins. On the i7-14650HX that's alderlake, the only variant that pairs AVX2 and FMA with AVX-VNNI; haswell is the runner-up and the seven AVX-512 variants score zero because the chip doesn't have it. You can see the decision in the first line of any tool's output:

What loads on this CPU
load_backend: loaded CPU backend from ...\bin\cpu\ggml-cpu-alderlake.dll

There is no flag or environment variable to force a variant. GGML_BACKEND_PATH exists, but it registers an additional backend after auto-selection rather than replacing it. What does work is the crude thing: a folder containing the executables, ggml.dll, ggml-base.dll, llama.dll and exactly one ggml-cpu-*.dll. With one candidate, the highest score is that one. So I made fourteen folders and ran the same benchmark in each, with Q8_0 and Q4_K_M, because the two families use different code paths.

Horizontal bar chart of decode speed for Qwen3-0.6B at Q8_0 and Q4_K_M under each ggml-cpu DLL variant that loads on the i7-14650HX. alderlake and haswell lead; sandybridge, ivybridge and sse42 trail; x64 is slowest. The seven AVX-512 variants refuse to load.
Every variant that can load on this CPU, forced one at a time. Anything with AVX-512 in its name simply does not run here.
DLL variant Instruction set it assumes Q8_0 pp256 Q8_0 tg128 Q4_K_M pp256 Q4_K_M tg128
alderlakeAVX2, FMA, F16C, BMI2, AVX-VNNI154 ± 3639.9 ± 1.0307 ± 1657.9 ± 1.3
haswellAVX2, FMA, F16C, BMI2151 ± 1539.3 ± 1.3297 ± 1857.0 ± 1.3
ivybridgeAVX, F16C126 ± 738.1 ± 1.0163 ± 751.1 ± 4.1
sandybridgeAVX136 ± 1138.2 ± 1.0175 ± 350.7 ± 1.4
piledriverAVX, FMA, F16C (AMD)120 ± 340.0 ± 0.7162 ± 1055.8 ± 2.1
sse42SSE4.2112 ± 237.0 ± 1.346 ± 023.5 ± 0.2
x64SSE2 baseline84 ± 134.4 ± 1.341 ± 122.8 ± 0.9
skylakexAVX-512 familydoes not load on this CPU
cannonlakeAVX-512 familydoes not load on this CPU
icelakeAVX-512 familydoes not load on this CPU
cascadelakeAVX-512 familydoes not load on this CPU
cooperlakeAVX-512 familydoes not load on this CPU
sapphirerapidsAVX-512 familydoes not load on this CPU
zen4AVX-512 familydoes not load on this CPU

Seven of the fourteen refuse to load, with a clear CPU backend is not loaded error: every variant that assumes AVX-512. Of the seven that run, look at the Q8_0 decode column first. It barely moves. From the plain x64 build with no vector extensions beyond SSE2 to the alderlake build, decode goes from 34 to 40 tok/s, because Q8_0 decode is waiting on memory whichever kernel does the multiply. Now look at Q4_K_M. On alderlake and haswell it's 58 tok/s, the fastest decode in this post on the CPU. On sandybridge and ivybridge, which have AVX but not AVX2, it drops to 51. On sse42 and x64 it collapses to 23 tok/s, slower than Q8_0 on the same DLL. Unpacking K-quant blocks is real arithmetic, and without 256-bit integer instructions that arithmetic becomes the bottleneck instead of the memory bus. Prompt processing tells the same story more loudly: Q4_K_M goes from 307 tok/s on alderlake to 41 on x64, a factor of seven. The alderlake versus haswell difference, which is AVX-VNNI, was within noise on both quants.

The instruction that separates alderlake from haswell is vpdpbusd, the AVX-VNNI dot product of unsigned and signed bytes. In ggml's x86 kernels it's reached through the shared int8 helpers that the Q8_0, Q4_0, Q4_1, Q5_0, Q5_1 and IQ4_NL dot products call. The K-quant kernels, Q4_K through Q6_K, don't go through those helpers; they use the older maddubs and madd pair with per-sub-block scales. So on paper VNNI can only ever help the "0" family. In practice, on this model, it didn't help either family measurably: Q8_0 prompt processing was 154 tok/s on alderlake and 151 on haswell, well inside the run-to-run spread. The dot product isn't where a 0.6B model spends its prompt time. What the variant really buys you is the jump from 128-bit to 256-bit integer math, and that jump is worth a factor of two to seven depending on the quant.

What this means for choosing a build: the auto-selection is right, and you shouldn't fight it. The cases where you need to know about the variants are the two failure modes. If you copy the binaries to a server and only bring one DLL, you've silently chosen the variant, and if it's the wrong one you'll get either a slow build or no CPU backend at all (a lone unsupported DLL doesn't fall back to anything). And if you're on a CPU family the release doesn't have a variant for, you get the nearest lower one, which is fine on decode and costs you something on prompt processing. The table at the end of this post maps common CPUs to the variant they'll get.

Measuring accuracy properly

Speed without an accuracy number is a trap. Anyone can make a model fast by throwing bits away. So before the quantization experiments, a word on how I scored them, because the method matters as much as the results.

Perplexity alone isn't enough. It tells you how surprised the model is by real text, and a quantized model can have almost the same perplexity as the original while disagreeing with it on which token is most likely a good fraction of the time. The measure I trust is the one llama.cpp's own contributors use to judge quantization: run the reference model over a text corpus, save its full logit distribution at every position, then run the quantized model over the same text and compute the Kullback-Leibler divergence between the two distributions at each position. KLD is zero when the quantized model produces the same distribution as the reference. It captures every kind of drift, not just the one token that happened to be correct.

Reference logits, then every variant against them
# 1. BF16 reference over 200 chunks of 512 tokens of wikitext-2 test (51,000 scored positions), on the GPU
llama-perplexity -m Qwen3-0.6B-BF16.gguf -f wiki.test.raw -c 512 --chunks 200 -ngl 99 \
    --kl-divergence-base base-bf16-c512-200.kld        # 15.5 GB file, 82 seconds

# 2. Each variant, same text, same chunking
llama-perplexity -m variant.gguf -f wiki.test.raw -c 512 --chunks 200 -ngl 99 \
    --kl-divergence-base base-bf16-c512-200.kld --kl-divergence

A few things I learned about the tool that the README doesn't spell out. The stored logits are not fp16: each position holds a scale, a minimum, and one uint16 per vocabulary entry, which for a 152K vocabulary is 304 KB per scored token. That's why the full wikitext-2 test set would be a 42 GB file, and why I capped it at 200 chunks. Two hundred chunks at 512 tokens scores about 51,000 positions, enough that the error bars on mean KLD are in the fifth decimal place. Running BF16 against its own stored logits gives a KLD of 0.00000 and a top-1 agreement of 99.99%, which is the encoding noise floor; everything below is well above it.

Two caveats on interpretation. Wikitext perplexity on an instruction-tuned, thinking-mode model is a relative measure of degradation, not a task score; the text is scored raw, with no chat template. And the popular thresholds you'll see quoted, "KLD under 0.01 is indistinguishable, over 0.1 is noticeable", are folklore. They aren't in any llama.cpp discussion I could find. What I use instead is the in-tree reference table for Llama-3-8B: q8_0 scored 0.0014, q6_K 0.0055, q4_K_M 0.031, q3_K_M 0.10, q2_K 0.44. Keep those in mind when you read the 0.6B numbers, because a 600M-parameter model is far more fragile than an 8B one.

Twenty-one variants, one chart

I built every variant from the unsloth BF16 GGUF with llama-quantize from the same b10941 release, using unsloth's importance matrix for every type below Q8_0. Three families: the standard mixes you'd download from any quant provider; variants where only the tied head changes; and variants where a specific block changes, attention versus FFN versus a range of layers. The plot below has bytes-per-token on the x axis and KLD on a log y axis. Down and to the left is good.

Scatter plot of 21 quantized variants of Qwen3-0.6B: weight bytes read per token on the x axis from 290 MB to 640 MB, mean KL divergence to BF16 on a log y axis from 0.002 to 1. The standard llama-quantize mixes form the lower-left frontier; head-only and block-only variants sit above it except the Q6_K head on a Q8_0 body.
The standard mixes already trace the frontier. Hand-mixing only wins at the very top, where the head is the cheapest thing left to shrink.
Variant MB / token Head PPL Mean KLD 99th pct KLD RMS Δp Top-1 agree
BF16 (reference vs its own stored logits)1192BF1621.90-0.0000 ± 0.00000.0000.0%100.0%
Q8_0 (requantized from BF16)633Q8_021.890.0027 ± 0.00000.0171.3%97.0%
Q8_0 (official Qwen file)633Q8_021.950.0040 ± 0.00000.0271.6%96.4%
Q8_0 body, Q6_K head596Q6_K21.940.0038 ± 0.00000.0211.5%96.3%
Q8_0 body, Q5_K head575Q5_K21.960.0077 ± 0.00000.0382.1%94.7%
Q6_K489Q6_K21.940.0104 ± 0.00010.0652.5%94.3%
Q8_0 body, Q4_K head556Q4_K22.380.0221 ± 0.00010.1003.5%91.3%
Q8_0 body, IQ4_XS head551IQ4_XS22.170.0251 ± 0.00010.1133.7%90.4%
Q5_K_M438Q6_K22.430.0273 ± 0.00020.1844.0%91.3%
Q8_0, attention at Q4_K545Q8_022.430.0411 ± 0.00030.2734.9%89.4%
Q8_0, FFN at Q4_K501Q8_022.540.0521 ± 0.00040.3935.8%88.0%
Q8_0 edges, layers 4-23 at Q4_K476Q8_022.680.0589 ± 0.00040.3836.0%87.2%
Q4_K_M with ffn_down and head at Q8_0461Q8_022.710.0624 ± 0.00050.4406.2%87.2%
Q4_K_M body, BF16 head574BF1622.900.0727 ± 0.00060.5086.7%86.1%
Q4_K_M body, Q8_0 head428Q8_022.890.0731 ± 0.00060.5256.7%86.3%
Q4_K_M391Q6_K22.970.0741 ± 0.00060.5176.7%86.1%
Q4_K_S377Q6_K23.030.0865 ± 0.00060.5977.2%85.0%
Q4_K_M body, Q4_K head351Q4_K23.430.0914 ± 0.00060.6107.4%84.4%
Q8_0 body, Q3_K head535Q3_K23.480.0915 ± 0.00050.3897.1%82.8%
IQ4_XS362Q6_K23.590.1025 ± 0.00070.7127.9%83.6%
Q4_0376Q6_K23.960.1500 ± 0.00090.9619.3%80.7%
Q3_K_M341Q6_K25.830.2497 ± 0.00151.59512.0%76.0%
Q2_K290Q6_K44.660.8401 ± 0.00424.61522.4%57.8%

Reading down the standard column first. Q8_0 is the safe default everyone says it is: KLD in the third decimal place, 97% top-1 agreement, and 47% fewer bytes than BF16 at essentially no cost. Q6_K is the next real step, 23% fewer bytes for 2.6x the official Q8_0's KLD (3.8x my own Q8_0's, which came out a little cleaner), but still under 0.011. Q5_K_M is where the curve bends; from there each 4-bit step roughly doubles the divergence, and Q4_0, which some people still pick because it's "the standard 4-bit", is a bad deal against Q4_K_M at nearly the same size. Q3_K_M loses a quarter of top-1 agreement. Q2_K doubles the perplexity and agrees with the reference on the top token barely more than half the time; on a model this small it isn't a quantization, it's a different model.

Now compare with the 8B reference numbers I quoted. Q4_K_M on Llama-3-8B scores 0.031; on Qwen3-0.6B it scores 0.0741. Same recipe, more than double the damage. Small models have less redundancy to hide quantization noise in. If you are running a sub-1B model, the bits you save between Q8_0 and Q4_K_M are the wrong bits to save, and the rest of this post will show that they don't even buy much speed on a GPU.

The tied LM head

Back to the hunch. In a tied file, llama-quantize treats token_embd as the output tensor: --output-tensor-type is the flag that changes it, and in every K-quant mix the default is to hold it at Q6_K while the layers go lower. That's why the stock Q4_K_M reads 391 MB per token and 33% of it is the head. So I asked the question in both directions. Body at Q8_0, head going down: how much speed can the head give me for free? Body at Q4_K_M, head going up: is the default Q6_K head protecting anything?

Two horizontal bar charts for a Q8_0 body with the head at Q8_0, Q6_K, Q5_K, Q4_K, IQ4_XS and Q3_K. Left: mean KLD rises from 0.0027 to 0.0915. Right: top-1 agreement falls from 97.0% to 82.8%.
Body fixed at Q8_0, only the head changes. Q6_K is free. Q5_K is cheap. Q4_K and below are where the head starts to matter.
Body Q8_0, head at MB / token Bytes saved Mean KLD Top-1 agree CPU tg128, -t 8 (quiet sweep) CUDA tg128
Q8_06330 MB (0%)0.002797.0%50.6 ± 0.6289 ± 9
Q6_K59638 MB (6%)0.003896.3%51.7 ± 0.5294 ± 7
Q5_K57558 MB (9%)0.007794.7%53.0 ± 1.7320 ± 8
Q4_K55678 MB (12%)0.022191.3%54.7 ± 0.5327 ± 10
IQ4_XS55183 MB (13%)0.025190.4%55.1 ± 4.9330 ± 13
Q3_K53598 MB (16%)0.091582.8%55.3 ± 0.3317 ± 13

Q6_K for the head is a genuine free lunch on the accuracy side: 38 MB fewer bytes on every token, and a KLD of 0.0038 against 0.0040 for the official Q8_0 file, top-1 agreement within a tenth of a point. The imatrix-weighted Q6_K quantization of the embedding rows is simply very good. Q5_K costs twice the KLD of Q8_0 but is still under 0.008, well inside what anyone would call transparent. Then the cliff. Q4_K on the head alone pushes KLD to 0.022 and drops top-1 agreement by nearly six points. That is more damage than quantizing the entire body to Q5_K_M, for a third of the byte savings. IQ4_XS is no better, and Q3_K on the head alone does as much harm as taking the whole model to Q4_K_S.

The speed side needs a more careful measurement than the big sweep gives, because a 6% change in bytes is smaller than this machine's run-to-run noise. So I measured the head variants back to back, alternating files, three rounds of five repetitions each. In the quiet round, plain Q8_0 decoded at 56.9 tok/s, the Q6_K head at 59.6, the Q5_K head at 61.3, and full Q6_K at 68.5: gains of 4.6%, 7.7% and 20.4% for byte reductions of 6%, 9% and 23%. That's the bandwidth model again, to within a point. On the GPU none of the head variants moved the needle, for the launch-bound reason explained further down. So: the Q6_K head is a few percent on the CPU and a smaller file everywhere, at zero measurable accuracy cost. Not a headline, but free.

So the hunch was half right. The head is a quarter of the traffic and you can shave it, but only by about 20%, and the reason is the thing that makes it a head: every one of its 151,936 rows gets multiplied against the hidden state on every token, and the logits are compared against each other in a softmax. Quantization noise in the layers gets averaged across thousands of hidden dimensions before it reaches the output; noise in the head lands directly on the ranking of tokens.

Horizontal bar chart for a Q4_K_M body with the head at BF16, Q8_0, Q6_K and Q4_K. Mean KLD is 0.0727, 0.0731, 0.0741 and 0.0914 respectively.
Body fixed at Q4_K_M. Raising the head above the default Q6_K changes nothing; lowering it to Q4_K does.

The other direction is the more useful negative result. With the body at Q4_K_M, moving the head from the default Q6_K up to Q8_0 changes KLD from 0.0741 to 0.0731, and all the way to BF16 gives 0.0727. Those three numbers are the same number. The layers are the bottleneck at that point, and the 47% larger file you get from a BF16 head buys nothing. The --leave-output-tensor advice that circulates for re-quantization is a good idea when you're re-quantizing an already quantized file; it isn't a quality lever on a fresh quant. Meanwhile dropping the head to Q4_K to match the body costs a quarter more divergence for 10% fewer bytes. The default mix has this right.

Attention, FFN, or the edges

The same tool lets you target any tensor by regex, so I asked where the bytes are cheapest. Three variants, body otherwise Q8_0: attention projections to Q4_K, FFN projections to Q4_K, and the middle twenty layers to Q4_K with the first four and last four kept at Q8_0. Plus one "reverse" mix: Q4_K_M with ffn_down and the head raised to Q8_0, which is the shape of several popular "XL" quants.

Horizontal bar chart of mean KLD for block-level variants: all Q8_0 0.0027, attention Q4_K 0.0412, FFN Q4_K 0.0521, layers 4 to 23 Q4_K 0.0589, ffn_down and head at Q8_0 with the rest Q4_K 0.0624, stock Q4_K_M 0.0741.
Per byte saved, the FFN is slightly cheaper to quantize than attention on this model, and keeping the outer layers at full precision doesn't earn back its bytes.
Variant MB / token Bytes saved vs Q8_0 Mean KLD KLD per 100 MB saved
Q8_0 (requantized from BF16)6330 MB0.00270.0000
Q8_0, attention at Q4_K54588 MB0.04110.0436
Q8_0, FFN at Q4_K501132 MB0.05210.0374
Q8_0 edges, layers 4-23 at Q4_K476157 MB0.05890.0357
Q4_K_M with ffn_down and head at Q8_0461172 MB0.06240.0346
Q4_K_M391243 MB0.07410.0294

Attention at Q4_K does less absolute damage than FFN at Q4_K, but it also saves fewer bytes, and per hundred megabytes the FFN comes out slightly ahead. Neither is anywhere near the head in sensitivity per byte. The layer-range mix, which is the intuition behind "keep the first and last layers in high precision", didn't earn its keep here: the middle twenty layers at Q4_K plus eight full-precision layers scores worse than putting the whole FFN stack at Q4_K for about the same size. And the popular "raise ffn_down" recipe does help relative to stock Q4_K_M, but it costs 70 MB per token to get there, at which point plain Q5_K_M is smaller and better.

The honest summary of this whole section is that llama-quantize's default mixes are the Pareto frontier for this model. Every hand-built variant I made either matched a standard mix at the same size or sat above the curve. The one exception is the top end: if you want Q8_0 quality, take the Q8_0 body with a Q6_K head, and you get 6% fewer bytes with nothing measurable lost. That, and not the general idea of per-layer mixing, is what I'd actually ship.

A small side finding. My own Q8_0, built from the unsloth BF16 with b10941, scored a KLD of 0.0027 against 0.0040 for the official file, and 97.0% top-1 against 96.4%. Both files have identical head and norm tensors; all 196 per-layer weight tensors differ byte-wise. Q8_0 has no importance matrix and its rounding is deterministic, so the official file was made through a different conversion path, most likely from a different intermediate precision or an older converter. I can't say which. What I can say is that re-quantizing from the BF16 yourself, with a current build, is cheap and gave a slightly better file than the one I downloaded.

The GPU, and why a small model can't use it

The RTX 5060 Laptop has 384 GB/s of memory bandwidth, eleven times the CPU's measured 34. If decode were purely bandwidth-bound the 0.6B Q8_0 would run at 600 tok/s. It runs at about 289. Where did the other half go?

Into launches. A decode step for this model is roughly 350 to 450 CUDA kernel launches: for each of 28 layers there's a norm, four attention projections, RoPE, the attention itself, another norm, three FFN projections and an activation, plus the head. Each launch costs a few microseconds of fixed overhead before any bytes move, and at 633 MB per step the bytes only take 1.6 ms at full bandwidth. The fixed costs add up to something of the same order. llama.cpp already mitigates this with CUDA graphs, which replay a recorded launch sequence instead of issuing each kernel from the host, and the release builds have that on by default. It still isn't free. This is the general rule for small models on big GPUs: below a couple of billion parameters, decode speed is set by how many kernels are in the graph, not by how many bytes they read.

The quantization sweep proves it. On the CPU, going from Q8_0 to Q4_K_M cut bytes by 38% and sped decode up by 1.37x, almost exactly the bandwidth model's prediction. On the GPU the same change went from 289 to 339 tok/s, and Q6_K was actually slower than Q8_0 at 294, because its dequantization kernel does more work per block and the bytes it saves weren't the bottleneck. Q2_K, at less than half the bytes of Q8_0, ran at 339. The one place bytes start to bite on the GPU is BF16: 1.2 GB per token is 3 ms of transfer at full bandwidth, and it ran at 176. If you're running a sub-1B model on a discrete GPU, quantizing below Q8_0 buys you almost nothing in speed and costs you real accuracy. Keep it at Q8_0, or Q8_0 with the Q6_K head.

Two scatter plots of decode speed against weight bytes per token for all variants. Left, CPU: speed rises steadily as bytes fall, from 28 tok/s at BF16 to 82 at Q2_K. Right, CUDA: speed stays between 290 and 390 tok/s regardless of bytes.
On the CPU, decode speed is a function of bytes. On this GPU, for this model, it isn't.
Variant MB / token CPU tg128, -t 8 (quiet sweep) CUDA tg128 CUDA pp512 Mean KLD
BF16119228.0 ± 0.6176 ± 515301 ± 4710 (reference)
Q8_0 (requantized from BF16)63350.6 ± 0.6289 ± 918996 ± 9640.0027
Q8_0 body, Q6_K head59651.7 ± 0.5294 ± 719027 ± 10250.0038
Q6_K48959.0 ± 1.8294 ± 1316288 ± 7300.0104
Q5_K_M43863.5 ± 1.0317 ± 1318071 ± 9100.0273
Q4_K_M39169.5 ± 1.1339 ± 1818327 ± 10860.0741
Q4_K_S37770.2 ± 1.2367 ± 2519225 ± 9460.0865
IQ4_XS36271.1 ± 1.8348 ± 1419417 ± 10390.1025
Q4_037677.2 ± 2.9368 ± 1419962 ± 10930.1500
Q3_K_M34176.0 ± 1.3316 ± 1517278 ± 8630.2497
Q2_K29082.5 ± 1.3339 ± 1915616 ± 8320.8401

Offload, one layer at a time

-ngl is the number of transformer layers whose weights live on the GPU; the rest stay in system RAM and run on the CPU. The 0.6B has 28 layers, and a value above that also moves the output head. Here's the whole sweep, with the eight CPU threads handling whatever isn't offloaded.

Line chart of decode speed against number of layers offloaded to the GPU for Qwen3-0.6B Q8_0, from 35 tok/s at zero layers to 293 tok/s with all 28 layers plus the output head. The curve bends sharply upward only in the last few layers.
Partial offload is a poor deal. The speed lives in the last few layers, because the step is only as fast as the slowest device on the critical path.
-ngl Where the weights live pp256 (tok/s) tg128 (tok/s)
0all on CPU2298 ± 38537.3 ± 3.5
44 of 28 layers on GPU1984 ± 68652.5 ± 8.6
88 of 28 layers on GPU2906 ± 61357.1 ± 9.7
1212 of 28 layers on GPU3243 ± 43866.5 ± 15.5
1616 of 28 layers on GPU4672 ± 71290.4 ± 14.0
2020 of 28 layers on GPU4824 ± 542130.5 ± 4.2
2424 of 28 layers on GPU7687 ± 1082169.4 ± 3.5
2626 of 28 layers on GPU8537 ± 780222.6 ± 3.4
2727 of 28 layers on GPU12319 ± 1480249.1 ± 5.8
2828 of 28 layers on GPU14344 ± 1912265.5 ± 9.5
alleverything, head included17033 ± 2101294.6 ± 5.5

The shape of that curve is the important thing. Twelve of twenty-eight layers on the GPU is not 43% of the way to GPU speed; it's 11% of the way. A decode step is sequential through the layers, so the CPU layers set the pace regardless of how fast the GPU finishes its share, and every step also pays to copy the hidden state across PCIe in each direction. Until the CPU's share is small, you're paying GPU overhead for CPU speed. The other thing to notice is the last row: 28 layers gives 265, but "everything" gives 295. The difference is the tied head. With -ngl 28 the 165 MB output matmul runs on the CPU every token, and it's worth about 8% of the total.

The lesson generalizes to any model that doesn't fit: offload as many layers as will fit and then some, because the first layers you leave on the CPU are the expensive ones, not the cheap ones. I'll come back to this with a model that genuinely doesn't fit.

Flash attention, KV cache types, context depth

Three settings that people fiddle with on GPUs, measured at full offload.

Setting pp512 (tok/s) tg128 (tok/s)
Flash attention on, f16 KV (default)19387 ± 1191300.4 ± 16.0
Flash attention off12789 ± 675276.5 ± 21.1
FA on, q8_0 K and V cache18095 ± 1174293.5 ± 16.9
FA on, q4_0 K and V cache18404 ± 1201290.9 ± 16.3

Flash attention is on by default on this build (-fa auto resolves to on for CUDA) and turning it off costs 34% of prompt throughput and 8% of decode. That's the one setting here worth checking on older builds or other backends. The quantized KV cache, on the other hand, does nothing useful at short context: q8_0 keys and values are a few percent slower than f16 at 128 tokens of context, because the model's KV traffic is tiny next to its weights and the quantization adds work. Where it pays is depth.

Line chart of decode speed against context depth of 0, 2048, 8192 and 16384 tokens, for the GPU with f16 KV cache, the GPU with q8_0 KV cache, and the CPU with flash attention on and off. All lines fall with depth; the q8_0 KV line overtakes f16 past 8K tokens on the GPU; on the CPU the flash-attention-off line is faster past 2K.
Every device slows down as the KV cache grows. Past 8K tokens a q8_0 cache is faster on the GPU because it halves the bytes attention has to read.
Configuration tg64 at 0 at 2,048 at 8,192 at 16,384
GPU, f16 KV316.0 ± 17.5258.6 ± 24.0168.4 ± 5.5107.7 ± 9.1
GPU, q8_0 KV296.4 ± 16.1243.5 ± 23.2164.2 ± 5.4118.8 ± 3.6
CPU -t 8, FA on40.1 ± 3.822.7 ± 0.88.8 ± 0.2
CPU -t 8, FA off40.0 ± 3.326.7 ± 2.814.2 ± 0.7

At 16K tokens of context the f16 cache holds 1.8 GB for this model, and reading the relevant part of it every step has become comparable to reading the weights. The GPU's decode speed drops to a third of its short-context value; with a q8_0 cache it drops to 40%, so past 8K the quantized cache wins outright. On a small model the cache is a bigger fraction of the traffic than on a large one, so this crossover comes earlier than you'd see with an 8B.

The CPU result surprised me. With flash attention on, the CPU is slower at depth than with it off: 8.8 versus 14.2 tok/s at 8K tokens. The fused CPU attention kernel in this build is tuned for the GPU-shaped problem, and on eight cores the plain path, which does a big matmul then a softmax, keeps the memory system busier. It's a reminder that -fa auto is a heuristic, and on CPU-only long-context work it's worth measuring both.

Batch size for prompt processing

-ub sets the physical batch, the number of tokens that go through one matrix multiply during prompt processing, and it's the one knob that turns a prompt from bandwidth-bound into compute-bound. On the GPU, 2048 tokens of prompt at -ub 64 run at 8,451 tok/s; at the default 512 they run at 16,301, and going higher does nothing. On the CPU the curve is the same shape with smaller numbers: 124 at 32 and 201 at 512. Leave it at the default unless you're memory-constrained, and if you are, know that halving it costs real prompt throughput.

When the model doesn't fit: Qwen3-8B on an 8 GB card

Everything above used a model that fits anywhere. The more common situation on a laptop is the one where it almost fits. So I took Qwen3-8B at Q6_K, 6.7 GB of weights, on a card with 8 GB of which about 7 GB were free. At the tiny context llama-bench uses by default it squeezes in whole, and I checked the working case too: at 4K context and all 37 layers on the card, the logs show 5,922 MB of weights, 576 MB of f16 KV cache and 100 MB of compute buffer, 6.6 GB, and it loads. At 8K the cache doubles and the total passes what the card has free. So the question of what to leave behind is real, and the first thing to know is what "doesn't fit" looks like on Windows.

Line chart of Qwen3-8B Q6_K decode speed against layers offloaded: 5.3 tok/s on the CPU alone, 6.8 at 16 layers, 15 at 28, 23.7 at 32, 29.7 at 34, 34.8 at 36, 37.5 with the head on the GPU too. A second series shows FFN-only placement on the CPU for 4, 8 and 12 layers at 28.5, 20.6 and 14.5 tok/s.
An 8B is bandwidth-bound on this GPU, so every layer you leave on the CPU costs its full share. Moving only the FFN weights of a layer is the cheaper way to give up VRAM.
Configuration Weights on CPU (approx.) pp256 (tok/s) tg64 (tok/s)
CPU only100%12 ± 25.3 ± 0.2
-ngl 1656% (+ head)442 ± 226.7 ± 0.9
-ngl 2433% (+ head)609 ± 3210.6 ± 1.0
-ngl 2822% (+ head)802 ± 3615.0 ± 1.2
-ngl 3017% (+ head)895 ± 4117.3 ± 1.2
-ngl 3211% (+ head)1051 ± 5623.7 ± 0.7
-ngl 338% (+ head)1098 ± 6125.5 ± 1.9
-ngl 346% (+ head)1206 ± 6229.7 ± 0.6
-ngl 353% (+ head)1325 ± 6831.9 ± 1.4
-ngl 360% (+ head)1497 ± 4334.8 ± 1.0
-ngl 99 (everything)0%1654 ± 1237.5 ± 3.4
-ngl 99, FFN of layers 0-3 on CPU~8%1222 ± 4328.5 ± 1.6
-ngl 99, FFN of layers 0-7 on CPU~16%962 ± 4420.6 ± 1.2
-ngl 99, FFN of layers 0-11 on CPU~24%789 ± 4014.5 ± 1.2
-ngl 99, q8_0 KV cache0%1611 ± 2236.4 ± 2.4
-ngl 34, q8_0 KV cache6%1216 ± 5228.3 ± 1.9

Three things to take from this. First, the full-offload number: 37.5 tok/s for 6.72 GB per token is 252 GB/s of effective bandwidth, 66% of the card's 384. That's what a properly bandwidth-bound model looks like on this GPU, and it's the number the 0.6B could never reach because it's too small to keep the card busy. Second, the CPU-only speed of 5.3 tok/s is right where the memory model says it should be: 34 GB/s divided by 6.72 GB. Third, and this is the practical part, the price of each layer left behind. Going from 36 layers to 34 costs 14%. To 32, 32%. To 28, more than half. The curve is the same shape as the 0.6B's, and for the same reason: the step is only as fast as the CPU's share, and the CPU is six times slower per byte.

The better move, when you need to give back VRAM, is -ot with a pattern that sends only the FFN weights of the first few layers to the CPU and keeps their attention on the GPU. Four layers' FFNs are about 70% of four layers' bytes, and that configuration decoded at 28.5 tok/s against 25.5 for the nearest whole-layer split. Attention is the part that grows with context, and its KV cache stays on the GPU where the attention kernels are; the FFN is a fixed-size matmul the CPU can handle. llama-server and llama-cli expose this directly as --n-cpu-ffn N; llama-bench only has the pattern form.

Giving back VRAM the cheap way
# whole layers on the CPU: simple, costs ~8% per layer on this card
llama-server -m Qwen3-8B-Q6_K.gguf -ngl 32 -t 8 -fa on

# same VRAM back, attention stays on the GPU: keeps the KV cache and attention kernels fast
llama-server -m Qwen3-8B-Q6_K.gguf -ngl 99 --n-cpu-ffn 6 -t 8 -fa on

# the llama-bench spelling of the same thing
llama-bench -m Qwen3-8B-Q6_K.gguf -ngl 99 -ot "blk\.[0-5]\.ffn_.*=CPU" -t 8 -fa on

The KV cache at q8_0 is the other lever. It halves the cache's VRAM, and at this context length it costs nothing measurable: 36.4 tok/s against 37.5 at full offload. On a card that is one layer short, quantizing the cache is the first thing to try, before any weights move.

What "doesn't fit" looks like on Windows

It doesn't look like an error. The Windows display driver lets a CUDA allocation overflow into system RAM, so -ngl 99 at 8K context loads fine and reports every layer offloaded. With the server sitting there loaded, Windows' GPU process-memory counter showed 7,118 MB of it in dedicated VRAM and 274 MB in shared memory, which is system RAM the GPU reaches over PCIe. And the same configuration in llama-bench decodes at 8.7 tok/s at 8K depth, where 4K gives 34.0 and zero depth 39.1. The attention bytes at 8K account for a few percent of that drop, not a factor of four; the rest is whatever the driver does to keep the resident set moving, and I can't see inside it. What I can say is that nothing warns you. If your speed falls off a cliff when you raise the context, this is the first thing to suspect.

The fixes, at the same 8K depth: -ngl 34 gives back two layers and the head and decodes at 19.5 tok/s, -ngl 32 at 12.2, and keeping every layer on the card with a q8_0 KV cache, which halves the cache to 576 MB, decodes at 29.8. The quantized cache is the right answer by a wide margin, for the same reason as before: it removes bytes without putting the CPU on the critical path.

One thing I deliberately didn't do is turn on --fit, which is on by default in llama-server and llama-cli and will adjust -ngl and the context size to whatever fits. It's a good default, and on Windows it's the thing standing between you and the silent spill above. It's also a way to not know why your speed changed between two runs. When you're measuring, set the numbers yourself.

Speculative decoding without a draft model

Decode is bandwidth-bound, which means the arithmetic units sit idle most of the time. If you could guess the next several tokens and verify all of them in one forward pass, you'd read the weights once for several tokens instead of once per token. That's speculative decoding, and the usual way to get the guesses is a second, smaller model. But for a lot of real work, the best predictor of the model's next tokens is the prompt itself. Editing, summarizing, rewriting, extracting fields, fixing code: the output quotes the input. Prompt-lookup decoding drafts by finding the last few tokens somewhere earlier in the sequence and proposing whatever followed them last time. No second model, no extra weights read, and with greedy sampling the output is guaranteed identical to normal decoding, because every drafted token is verified by the real model before it's accepted.

llama.cpp has this built into llama-server and llama-cli as --spec-type ngram-simple, ngram-mod and ngram-cache (llama-completion doesn't accept the flag, and llama-bench has no speculative mode at all). I also wrote my own version in the Python driver described in the next section, mostly to understand it, and because I wanted control over the two parameters that matter: how many tokens to match on, and how many to draft.

Three workloads, same for every configuration, temperature zero, 256 tokens out. "Free" asks for an explanation of memory-bound decoding with no source text. "Edit" gives a paragraph with three typos and asks for the corrected paragraph in full. "Quote" gives the same paragraph and asks the model to quote two sentences verbatim and explain them.

Grouped bar chart of end-to-end decode speed for three workloads, free, edit and quote, under plain decoding, llama-server's ngram modes, and the driver's n-gram loop, on CPU and on CUDA. The edit workload roughly doubles with drafting; the free workload does not change or gets slightly slower.
Drafting from the prompt pays exactly when the output copies the prompt. On open-ended writing it finds nothing to draft.
Configuration Free (tok/s) Edit (tok/s) Quote (tok/s) Edit: accepted / drafted
CPU, -t 8
llama-server, no speculation47.1 ± 3.832.0 ± 0.527.0 ± 3.4none
llama-server, ngram-simple (defaults: n=12, m=48)37.0 ± 0.724.0 ± 0.726.7 ± 4.961 / 384 (16%)
llama-server, ngram-mod (defaults)36.6 ± 1.139.4 ± 6.526.0 ± 1.91 / 64 (2%)
llama-server, ngram-cache38.4 ± 0.638.0 ± 0.639.2 ± 1.7135 / 209 (65%)
llama-server, ngram-simple, n=3, m=847.5 ± 0.380.5 ± 1.474.3 ± 4.0134 / 176 (76%)
driver, greedy loop36.9 ± 1.936.5 ± 0.936.2 ± 1.1none
driver, n-gram loop, n=3, draft 836.4 ± 1.660.5 ± 2.451.9 ± 2.1134 / 176 (76%)
driver, n-gram loop, n=2, draft 841.4 ± 1.761.0 ± 0.358.9 ± 1.8138 / 237 (58%)
driver, n-gram loop, n=3, draft 1630.9 ± 1.951.2 ± 1.248.7 ± 0.9140 / 240 (58%)
CUDA, all layers on the GPU
llama-server, no speculation235.9 ± 1.5243.0 ± 2.8237.3 ± 5.6none
llama-server, ngram-simple (defaults)251.9 ± 0.7273.8 ± 6.5242.0 ± 23.861 / 384 (16%)
llama-server, ngram-cache219.0 ± 6.3355.9 ± 7.7310.5 ± 23.7137 / 217 (63%)
llama-server, ngram-simple, n=3, m=16237.4 ± 5.1407.9 ± 3.8318.8 ± 73.3140 / 240 (58%)
driver, greedy loop271.8 ± 17.7276.0 ± 3.8268.1 ± 5.5none
driver, n-gram loop, n=3, draft 8280.2 ± 6.0486.1 ± 6.5504.4 ± 8.4134 / 176 (76%)
driver, n-gram loop, n=3, draft 16283.8 ± 3.7569.8 ± 16.3567.6 ± 22.2140 / 240 (58%)
Qwen3-8B Q6_K, -ngl 28, 4K context
llama-server, no speculation14.0 ± 0.113.7 ± 0.113.7 ± 0.0none
llama-server, ngram-mod (defaults)14.4 ± 0.424.6 ± 11.214.4 ± 0.82 / 41 (5%)
llama-server, draft model = Qwen3-0.6B Q8_0, up to 6 tokens12.5 ± 1.026.0 ± 0.116.1 ± 0.8107 / 117 (91%)

The defaults are the first lesson. llama-server's ngram-simple looks for a 12-token match and drafts 48 tokens at a time, sizes chosen for long documents on big GPUs. On a 240-token editing prompt it found a match one time in six and paid for the misses: 24.0 ± 0.7 tok/s against 32.0 ± 0.5 with no speculation at all. ngram-mod drafted once in the whole run. ngram-cache, which keeps a statistics table rather than a fixed match length, did well out of the box. And the same ngram-simple mode with a 3-token match and 8-token drafts, the parameters my driver uses, went to 80.5 ± 1.4 tok/s on editing and 74.3 ± 4.0 on quoting, 2.5x and 2.7x, with no loss on free writing. Eighty tokens per second on a CPU whose single-token ceiling is 70 is the point of the exercise: speculation is the only technique in this post that gets past the bandwidth wall, because it reads the weights once for several tokens. On the GPU the same setting gave 407.9 ± 3.8 against 243.0 ± 2.8, and my driver's loop landed in the same place, 486.1 ± 6.5 with 8-token drafts and 569.8 ± 16.3 with 16, because verifying sixteen tokens on a launch-bound GPU costs about the same as verifying one. On the CPU the longer draft was worse, since sixteen extra tokens of real arithmetic per step is no longer free there. Draft length should follow the device.

The mechanism in my driver is about forty lines and worth spelling out, because it's the same thing the server does. Keep a dictionary from every n-gram in the sequence to the position after it. After each accepted token, look up the last n tokens; if they occurred before, take the tokens that followed as the draft. Decode [token, draft...] in one batch with logits requested for every row. Row i's argmax is the model's real prediction given the draft up to i; accept while it agrees with the draft. Then roll the KV cache back to the accepted length with llama_memory_seq_rm and continue. The cost of a wrong guess is one forward pass over a handful of tokens, which on a bandwidth-bound model is nearly the same as a pass over one token. The gain from a right guess is a full step's worth of weight reads. That asymmetry is the whole idea.

The verify step (scripts/driver.py, abridged)
batch = [tok] + draft
self._decode(batch, n_past, 'all')          # one llama_decode, logits for every row
n_acc = 0
for i in range(len(batch)):
    pred = int(np.argmax(self.logits_view(i)))  # zero-copy view, no memcpy
    if i < len(draft) and pred == draft[i]:
        n_acc += 1                            # the model agrees, keep going
    else:
        nxt = pred; break                     # first disagreement: this is the real next token
out += draft[:n_acc]
keep = n_past + 1 + n_acc
self.lib.llama_memory_seq_rm(self.mem, 0, keep, -1)   # drop the rejected tail from the KV cache
n_past = keep; tok = nxt

The draft-model version, for when you have a big target and a small sibling, is the --spec-type draft-simple mode with -md. On the 8B with 28 layers on the card, a split I picked before I'd checked that the whole model fits at 4K (it does, see the previous chapter), though it has the side benefit of leaving room for the draft model's own 633 MB, the 0.6B as a draft gave 26.0 ± 0.1 tok/s against 13.7 ± 0.1 without it on the edit workload, and 12.5 ± 1.0 against 14.0 ± 0.1 on free writing, where n-gram lookup had nothing to offer. A small model from the same family agrees with the big one often enough to draft for it on anything.

A driver in a few hundred lines of Python

I wanted to know two things about the layer between llama.cpp and the code that calls it. How much does a Python loop cost per token, compared with the C++ tools? And what does it take to talk to llama.dll directly, without llama-cpp-python, so that the binding tracks the release you actually downloaded instead of whichever commit the wheel was built against?

The answer to the second question is: about 160 lines of cffi declarations. cffi in ABI mode needs no compiler; you paste the struct definitions and function prototypes from llama.h into a cdef string, dlopen the DLL, and call. The only trap I hit was that llama_backend_init() doesn't load the dynamic backend libraries; the CLI tools do that through ggml_backend_load_all() in common, so the binding has to call ggml_backend_load_all_from_path() on the release folder itself, or the model load fails with "no backends are loaded". After that, the same file works for the CPU zip and the CUDA zip; it's just a different folder.

src/llamabind.py, the part that matters
from cffi import FFI
ffi = FFI()
ffi.cdef(r"""
struct llama_model_params { ... };        // copied field-for-field from include/llama.h @ b10941
struct llama_context_params { ... };
typedef struct llama_batch { int32_t n_tokens; llama_token * token; float * embd; llama_pos * pos;
                             int32_t * n_seq_id; llama_seq_id ** seq_id; int8_t * logits; } llama_batch;
struct llama_model * llama_model_load_from_file(const char * path, struct llama_model_params p);
int32_t llama_decode(struct llama_context * ctx, llama_batch batch);
float * llama_get_logits_ith(struct llama_context * ctx, int32_t i);
bool llama_memory_seq_rm(llama_memory_t mem, llama_seq_id seq, llama_pos p0, llama_pos p1);
""")
ggml = ggml_ffi.dlopen('ggml.dll')
ggml.ggml_backend_load_all_from_path(bin_dir)        # the step llama_backend_init() does not do
lib = ffi.dlopen('llama.dll')

def logits_view(self, i):
    p = lib.llama_get_logits_ith(self.ctx, i)
    return np.frombuffer(ffi.buffer(p, self.n_vocab * 4), dtype=np.float32)   # no copy

The struct layout is the risk with ABI-mode bindings: get one field wrong and you corrupt memory silently. My check was to call llama_context_default_params() and print the fields; if n_batch reads 2048, n_ubatch 512 and flash_attn_type minus one, the layout is right. Pin the binding to the release tag and diff llama.h when you upgrade.

With that in place, three loops on the same context. A plain greedy loop: one llama_decode per token, argmax with numpy over a zero-copy view of the 151,936 logits. The same loop with llama.cpp's own sampler chain doing the argmax in C, as a control for the Python side. And the n-gram loop from the previous section.

Loop CPU, free CPU, edit CPU, quote CUDA, free CUDA, edit CUDA, quote
Greedy, numpy argmax36.9 ± 1.936.5 ± 0.936.2 ± 1.1271.8 ± 17.7276.0 ± 3.8268.1 ± 5.5
Greedy, llama.cpp sampler in C37.1 ± 2.633.5 ± 0.738.0 ± 1.2291.6 ± 4.3269.3 ± 1.5283.5 ± 1.9
n-gram draft (n=3, 8 tokens)36.4 ± 1.660.5 ± 2.451.9 ± 2.1280.2 ± 6.0486.1 ± 6.5504.4 ± 8.4

The first two rows are the answer to the overhead question. Doing the argmax in Python over 151,936 floats versus doing it in C changes nothing measurable: 36.9 ± 1.9 against 37.1 ± 2.6 tok/s on the CPU, 271.8 ± 17.7 against 291.6 ± 4.3 on the GPU, both inside the run-to-run noise. A numpy argmax over that many floats takes about twenty microseconds on this machine (I timed it); a decode step takes 27 milliseconds on the CPU and 3.5 on the GPU. The driver against llama-server is a less tidy comparison, because the two were measured in different sessions on a machine whose load changes: on the CPU the server was ahead on free writing, 47.1 ± 3.8 to 36.9 ± 1.9, and behind on editing, 32.0 ± 0.5 to 36.5 ± 0.9, which is what noise looks like, not a gap. On the GPU the driver was consistently a little ahead, 271.8 ± 17.7 to 235.9 ± 1.5, and my guess is the server's per-token work, sampling, streaming and bookkeeping, is a visible share of a 3.5 ms step where it isn't of a 27 ms one. Either way the C++ tools aren't leaving anything on the table that a custom loop can pick up by being leaner. What the driver buys isn't speed; it's control. The n-gram loop with parameters tuned to the workload is what gets the 1.7x on the CPU and 2.1x on the GPU in the third row, and it took forty lines to write.

On Cython: I planned to write the loop in it and didn't, and I'll say why rather than pretend it was a choice of taste. This laptop has no MSVC toolchain and CPython on Windows is built with MSVC, so a Cython extension would have meant installing Visual Studio Build Tools or fighting the MinGW ABI. More to the point, the measurements above show there's nothing for Cython to recover. The per-token cost of the Python loop is tens of microseconds against a 27 ms decode step on the CPU and a 3.5 ms step on the GPU. The loop isn't the bottleneck; the weights are. Where a compiled extension would matter is a sampler with real work in it, or a batched server handling many sequences, and neither is what a single-user driver does.

The other honest caveat is the one the identity check in bench_driver.py carries. It compares every loop's output tokens against the greedy loop's on the same prompt, and with greedy sampling, speculative decoding is supposed to produce exactly the sequence plain decoding would. It did, in all three workloads on the CPU and in the edit and quote workloads on the GPU. (The extra draft-length runs, n=2 and 16 tokens, ran the n-gram loop alone, so their check only proves the repetitions agreed with each other.) One free-writing run on the GPU diverged. The reason is that a batch of eight tokens goes through different CUDA kernels than a batch of one, with slightly different floating-point rounding, and 256 tokens of open-ended text contain a near-tie or two where that rounding flips the argmax. From there the sequences differ. It's not an error, the verified output is still a valid greedy continuation of the model's own logits, but "bit-identical" is a property of exact arithmetic, not of GPUs.

Loading: mmap, mlock, and time to first token

The last thing people ask about is the pause before the first token. On this machine, with the file in the page cache, the 0.6B loads in 0.42 s with the default memory-mapped mode and 0.52 s with --load-mode none, which reads the file into a private buffer instead of mapping it; mlock adds the cost of pinning the pages up front and buys you protection against the OS paging them out later. None of those changes decode speed once the weights are resident. The cold-cache case, the first run after a reboot, is bounded by the drive: 640 MB from this NVMe is well under a second. The 6.7 GB model is about three seconds cold.

Two newer options are worth knowing about even though they didn't apply here. --load-mode dio asks for direct I/O to bypass the page cache; it's Linux-only in this build and silently behaves like none on Windows. And --lazy-mode reads certain tensors from disk on demand instead of keeping them resident, which sounds like exactly what a memory-constrained laptop wants, but it applies only to tensors an architecture has marked as lazy-readable, which at b10941 means the per-layer embedding tables of Gemma 4 and the Qwen3.8 Flash Next family. For an ordinary dense model it's a no-op.

What I'd actually run

Collapsing all of that into a configuration for this laptop, and then into rules that transfer to other machines.

Step Setting Decode, tok/s What changed
CPU 1-t 16 (default) vs -t 8, same run, working load19.5 ± 1.0 vs 37.3 ± 3.4threads = P-cores; 5 to 23% on an idle machine
CPU 2Q8_0 vs Q8_0 body with Q6_K head, -t 8, quiet paired run56.9 vs 59.66% fewer bytes, same accuracy
CPU 3llama-server plain vs --spec-type ngram-simple n=3 m=8, editing task32.0 ± 0.5 vs 80.5 ± 1.4past the single-token bandwidth wall
CPU 3bsame, free writing47.1 ± 3.8 vs 47.5 ± 0.3nothing to draft, nothing lost
GPU 1llama-bench defaults vs -ngl 28 (head on CPU)294.6 ± 5.5 vs 265.5 ± 9.5keep the head on the card
GPU 2-fa on vs -fa off300.4 ± 16.0 vs 276.5 ± 21.1flash attention on
GPU 3llama-server plain vs ngram-simple n=3 m=16, editing task243.0 ± 2.8 vs 407.9 ± 3.8drafts are nearly free when launch-bound

Each row is measured against its own baseline in the same run, because on a shared laptop the absolute numbers drift by 30% between a quiet hour and a busy one and only paired comparisons mean anything. Reading it as multipliers: fixing the thread count is worth about 1.9x on this CPU, the Q6_K head another 1.05x, and self-speculative decoding 2.5x on editing tasks and nothing on open-ended writing. On the GPU the model was already close to its launch-bound ceiling out of the box; the wins there are keeping the head on the card, keeping flash attention on, not quantizing below Q8_0 for a model this small, and drafting when the task allows it.

Choosing for your hardware

These are the rules I'd hand to someone setting up a new machine, in the order they matter.

1. Work out your bytes-per-token budget first

Measure or look up your memory bandwidth, divide by the model file size, and that is your decode ceiling on the CPU. Do the same with the GPU's bandwidth. If the CPU ceiling is under 10 tok/s for the model you want, the answer is a smaller model or a GPU, not a flag. If the GPU ceiling is over 500 tok/s, the model is too small to use the GPU efficiently and you should expect a third to a half of that number. Laptop memory is usually the binding constraint; check whether you have one DIMM or two before anything else, because the second slot is the cheapest 2x you will ever buy.

2. Threads: P-cores only, one number

Set -t to the number of performance cores and stop there. On Intel 12th gen and later, and on any laptop chip with efficiency cores, the Windows default is wrong for decode: it cost 5 to 23% on this chip with the machine idle and about half with my usual work running alongside. It was wrong for prompt processing too, so -tb isn't the escape hatch it looks like. On AMD and on older Intel parts without E-cores, the default is fine. Don't bother pinning with -C and --cpu-strict 1; it matched the unpinned result on a quiet machine and lost a third on a busy one. Four P-cores with SMT gave the same decode speed as eight without, so on a smaller chip you're not as far behind as the core count suggests.

3. Let the DLL pick itself, but know which one you got

Look for the loaded CPU backend from line. The mapping from CPU to variant, from reading the build scripts and the scoring code:

CPU Variant loaded Notes
Intel 12th to 14th gen Core, Core Ultra (Meteor, Arrow, Lunar Lake)alderlakeAVX2 + AVX-VNNI. AVX-512 is fused off on these parts. Use P-core thread counts.
Intel 4th to 11th gen desktop and mobile without AVX-512 (Haswell through Comet Lake)haswellSame kernels as alderlake minus VNNI; measured within noise here.
Intel Ice Lake, Tiger Lake, Rocket Lake (10th and 11th gen with AVX-512)icelake512-bit paths plus VNNI. Watch clocks; some of these parts downclock hard under AVX-512.
Intel Xeon Skylake-SP / Cascade Lake / Cooper Lake / Ice Lake-SPskylakex / cascadelake / cooperlake / icelakeOne variant per Xeon generation, each adding VNNI or BF16.
Intel Xeon Sapphire Rapids, Emerald Rapids, Xeon W-2400/3400sapphirerapidsThe only variant with AMX tiles. Big prompt-processing gains on the right quants.
AMD Zen 1 through Zen 3 (Ryzen 1000 to 5000, EPYC Naples to Milan)haswellAVX2 without AVX-512. Full-speed K-quants.
AMD Zen 4 and Zen 5 (Ryzen 7000 to 9000, EPYC Genoa, Turin)zen4AVX-512 with VNNI and BF16, no AMX. Beats sapphirerapids on the score only because that one requires AMX.
Intel 2nd and 3rd gen, AMD FXsandybridge / ivybridge / piledriverAVX without AVX2. Q8_0 decode fine, K-quants noticeably slower, prompts 2x slower.
Anything older, or a VM with a masked CPUIDsse42 or x64Avoid K-quants entirely; Q8_0 or Q4_0 only. Check the VM's CPU flags.
Windows on ARM, Linux aarch64one ggml-cpu library, or the armv8.x familyNo x86 scoring; ARM feature detection is separate.
Apple Silicon (macOS release)no variantsA single natively compiled CPU backend; Metal is the path that matters anyway.

4. Pick the quant by where the model runs, not by file size

5. Flash attention on, KV cache quantized past a few thousand tokens

Keep -fa on for GPU work; it's worth a third of prompt throughput. On CPU-only long-context work, measure it both ways. Switch the KV cache to q8_0 when contexts run past about 4K, or whenever you're short of VRAM; it costs nothing at short context and wins at long.

6. Speculate when the output repeats the input

Turn on --spec-type ngram-mod in llama-server for editing, rewriting, extraction, code modification, anything where the answer copies from the prompt. Leave it off for open-ended generation, where it adds a little overhead and finds nothing to draft. If you have a small model from the same family, draft-model speculation is the stronger tool for a big target, and the 0.6B is exactly the right draft for an 8B.

Reproduce it

Everything in this post lives in one folder with no build step. The companion repository has the scripts, the raw bench.jsonl, the KLD outputs and the chart code. To rerun the core of it on your own machine:

Minimal reproduction
# 1. binaries: llama-b10941-bin-win-cpu-x64.zip and -win-cuda-13.3-x64.zip (+ cudart) from the llama.cpp releases page
# 2. model + reference + imatrix
Qwen/Qwen3-0.6B-GGUF          Qwen3-0.6B-Q8_0.gguf
unsloth/Qwen3-0.6B-GGUF       Qwen3-0.6B-BF16.gguf  imatrix_unsloth.dat
ggml-org/ci                   wikitext-2-raw-v1.zip

# 3. the ceiling
python scripts/membw.py

# 4. threads
llama-bench -m Qwen3-0.6B-Q8_0.gguf -p 256 -n 128 -r 5 --prio 2 -ngl 0 -t 1,2,4,6,8,12,16,24

# 5. a variant, then its accuracy
llama-quantize --imatrix imatrix_unsloth.dat --output-tensor-type q6_k Qwen3-0.6B-BF16.gguf head-Q6_K.gguf Q8_0 8
llama-perplexity -m Qwen3-0.6B-BF16.gguf -f wiki.test.raw -c 512 --chunks 200 -ngl 99 --kl-divergence-base base.kld
llama-perplexity -m head-Q6_K.gguf       -f wiki.test.raw -c 512 --chunks 200 -ngl 99 --kl-divergence-base base.kld --kl-divergence

# 6. the driver
conda create -n infer-lab python=3.11 numpy cffi
python scripts/bench_driver.py --model Qwen3-0.6B-Q8_0.gguf --bin bin/cpu --threads 8

A note on the machine state, because it affected the numbers and I'd rather say so than hide it in the error bars. This laptop was doing my day job while the benchmarks ran: two Vite dev servers, a VM, an editor and a pile of agent processes. Every result reports mean and standard deviation over three to five repetitions, and I re-ran the paired comparisons back-to-back so the noise hits both sides equally, but the absolute numbers are a floor. Where a comparison mattered I measured it back to back, and I've said in each section which numbers came from a quiet stretch and which didn't. The accuracy numbers are unaffected; they don't depend on time.

What this doesn't tell you

Closing note

I went into this expecting the head to be the trick. It half is: a Q6_K head on a Q8_0 body is the one hand-mixed quant that beat the defaults, and it's free. But the bigger finding was less glamorous. The single most expensive mistake on this laptop was a thread count, and the second was a missing memory stick. Everything about kernels and instruction sets was worth less than either. Measure the ceiling, then measure your distance from it, and the flags mostly pick themselves.

Resources & Links

Scripts and raw results llama.cpp b10941 Qwen3-0.6B-GGUF

Tags:

LLM & RAG llama.cpp Quantization CUDA Python Inference Engineering