llama-roofline
Why is your llama.cpp generation that fast and not faster? One command answers it.
When a local LLM generates text one token at a time, it has to read every weight in the model, from memory, for every single token. Nothing is reused. That makes token generation a memory-bandwidth problem, not a compute problem, and it means your tokens per second are governed by a single equation:
decode tok/s = BW_eff / bytes_per_token
llama-roofline measures both sides of that equation on your machine and tells you where
you sit, in words rather than in a table of numbers.
pip install llama-roofline
llama-roofline diagnose
No flags, no config. It looks for your GGUF files, finds llama-bench, measures what your
memory can actually sustain, benchmarks across a thread sweep, and prints this:
========================================================================
llama-roofline v0.2.2 -- diagnosis
========================================================================
Machine : 12th Gen Intel(R) Core(TM) i7-12700H
20 logical / 14 physical cores, 34.0 GB RAM, Windows AMD64
Memory : 54.3 GB/s sustained read (measured: max kernel, 20 threads)
llama.cpp : build 10154 (0e4a03622), backends: CPU
THE ANSWER
------------------------------------------------------------------------
Yes. Decode is memory-bandwidth-bound, at 78% of what this machine
sustains.
Every token reads the whole model out of RAM, and at this utilisation
the cores are mostly waiting for it. A faster CPU changes nothing
here. Fewer bytes does.
For qwen3b-q4km the ceiling on this machine is about 28 tok/s. You
measured 21.9 at 8 threads.
WHAT TO CHANGE
------------------------------------------------------------------------
* Threads: use 8. Running 20 cost 25% of decode throughput, because
decode is waiting on memory and more waiters do not make memory
faster.
* Size is the only dial that moves this. Halving the bytes read per
token roughly doubles tok/s, which is why a smaller model or a lower
quantization buys speed almost exactly in proportion to the bytes it
removes.
* Not every format of the same size costs the same, though. Run
`llama-roofline advise` to see what has been measured.
* Measured here: qwen0.5b-q4km is 4.91x smaller than qwen3b-q4km and
decodes 4.28x faster.
* Prompt processing is the opposite case: it scaled 4.9x with threads.
If your workload is long prompts and short answers, you do want the
cores.
WHAT WAS MEASURED
------------------------------------------------------------------------
model quant size decode prefill thr GB/s %ceil
------------------------------------------------------------------------------------
qwen0.5b-q4km Q4_K_M 392 MB 93.7t 277t 8 36.7 68%
qwen1.5b-q4km Q4_K_M 980 MB 39.7t 133t 8 38.9 72%
qwen3b-q4km Q4_K_M 1.92 GB 21.9t 76t 8 42.1 78%
THE ROOFLINE
------------------------------------------------------------------------
decode tok/s = 37.18 GB/s / bytes per token
fitted across 3 models, R^2 = 0.9960,
taking each model at its own best thread count
That is a real run on a real laptop, trimmed of its caveats section, which is printed and is not optional.
The line worth staring at is the thread one. Setting -t 20 on a 20-thread machine looks
free. It cost a quarter of the decode throughput.
diagnose also writes diagnosis.md, which is the same thing shaped for pasting into an
issue or a forum thread when you want someone to look at your numbers.
Use Ollama or LM Studio and have no llama.cpp build?
You can still look inside the models you already have. inspect reads only the file
header, needs no llama-bench, and takes about a second:
pip install llama-roofline
llama-roofline inspect ollama:qwen2.5:7b # any model you have pulled
llama-roofline inspect ~/path/to/model.gguf # LM Studio and every other GGUF
It prints what the file actually contains under its format label, how many bytes decode reads per token, and how much of that is the output head. Divide your memory bandwidth by that per-token figure and you have the ceiling on your generation speed before you run anything.
Which quantization should you run?
advise answers "which quantization format should I run on this CPU?" from measurements,
not from file names. On silicon it has data for, it prints a per-format table with the
measurement each row came from. On anything else it says so and measures your own files
instead of guessing:
llama-roofline advise
Read any 0.5B comparison knowing what is in the files. At that size most formats are
mostly not what they are named after, so part of what separates them is which types
llama-quantize substituted rather than which format was asked for. The report says so
where it prints such a table, and the next section
is how to check any file you have.
The data file ships with the package and records, for every row, the run it came from in
the source column. On silicon nobody measured, the tool says so and shows you how to measure
it rather than guessing:
llama-roofline advise --measure --models ~/models/qwen0.5b-*.gguf
That benchmarks the files you already have and prints the same table from your own numbers.
Most people have one file per model rather than several formats of one model, which compares sizes instead of formats. If that is you:
llama-roofline advise --plan ~/models/qwen0.5b-f16.gguf
prints the llama-quantize commands that build a comparable set, and refuses if you point
it at a file that is already quantized, so every file in the set comes from the same
source.
What is actually in that GGUF file?
A format label names a recipe, not a type, and at small model sizes the recipe substitutes so heavily that the name stops describing the file.
llama-roofline inspect ~/models/qwen0.5b-Q4_K_M.gguf
label : Q4_K_M architecture: qwen2
actually : 12% Q4_K in the repeating layers, 5.52 bits per weight
file : 491 MB, 291 tensors
per token : 392 MB read (81% of the file)
output head: 145 MB, 37% of what is read per token
TENSOR TYPES
------------------------------------------------------------------------
type tensors MB share
Q5_0 133 267 54%
Q8_0 13 146 30%
Q6_K 12 43 9%
Q4_K 12 29 6%
F32 121 0 0%
WHAT THE LABEL MEANS HERE
------------------------------------------------------------------------
Only 12% of this file's repeating-layer bytes are Q4_K. Most of it
is Q5_0 70%, Q6_K 17%, Q8_0 1%. The output head is Q8_0. It stores
5.52 bits per weight. A GGUF label names a recipe rather than a
type. llama-quantize substitutes per tensor when a shape does not
divide by the block size. None of that is visible from the filename,
and at small model sizes the substitution can take over the file.
Eight formats of Qwen2.5-0.5B built from one FP16 source, measured over their repeating layers:
| format | on-label | bits/weight | what most of it is |
|---|---|---|---|
| Q4_0 | 100% | 4.50 | |
| Q8_0 | 100% | 8.50 | |
| IQ4_NL | 95% | 4.55 | |
| IQ4_XS | 24% | 4.48 | IQ4_NL 70% |
| Q6_K | 24% | 7.93 | Q8_0 76% |
| Q4_K_M | 12% | 5.52 | Q5_0 70% |
| Q3_K_M | 0% | 4.57 | Q4_0 64%, Q4_K 28% |
| Q2_K | 0% | 4.20 | Q4_0 75%, Q3_K 24% |
Three of eight are what they say. K-quants want blocks of 256 and this model's embedding
dimension is 896, so llama-quantize substitutes per tensor, warns per tensor, and prints
no summary. At 1.5B the same recipes land at 58 to 100% on-label, so this is a small-model
effect, and small models are where quantization comparisons usually get run.
inspect is also where the bytes-per-token figure comes from. The token embedding is a row
lookup rather than a stream, so file size overstates what decode actually reads, by 18 to
22% on these files.
The longer path
If you want the full sweep, the figure and the fitted roofline across every model you
own, run is still there and unchanged.
llama-roofline run --models ~/models/*.gguf
What it tells you that a benchmark does not
llama-bench already tells you your tokens per second. This tells you why that number
is what it is, and which knob actually moves it:
- Are you at the wall? If decode is at 85% of your memory ceiling, a faster CPU will do nothing for you. If it is at 30%, something else is wrong and the report says what to check.
- How much will a smaller quant buy? Not a guess. The fit predicts it, and the report shows the ratio measured on your own models.
- How many threads should you actually use? Decode saturates early and then goes backwards. Prefill keeps scaling. The report gives you the knee for both.
- Where does the model stop applying? MoE models are detected and excluded from the fit. Measurements that exceed the ceiling are reported as lower bounds rather than as an impossible ">100% of peak".
Commands
# the zero-configuration answer: find models, measure, explain
llama-roofline diagnose
# one model, or a directory, or a glob
llama-roofline diagnose ~/models/llama3-8b-q4km.gguf
llama-roofline diagnose ~/models --quick # fewer settings, one repetition
# which quantization format to run on this machine's cores
llama-roofline advise
llama-roofline advise --core cortex-a76 --threads 4
llama-roofline advise --measure --models ~/models/qwen-*.gguf # from your own files
llama-roofline advise --plan ~/models/qwen-f16.gguf # build a set worth comparing
# what a GGUF file actually contains
llama-roofline inspect ~/models/model.gguf
llama-roofline inspect ~/models/model.gguf --tensors
# the full sweep: every model, the fit, the figure
llama-roofline run --models ~/models/*.gguf
llama-roofline run --models a.gguf b.gguf --threads 1,2,4,8,16 --reps 5
# just this machine's memory-bandwidth ceiling
llama-roofline membw
# already have a STREAM number? skip the microbenchmark and tighten the percentages
llama-roofline run --models ~/models --peak-bw 204.8
# re-render a report or figure from saved results
llama-roofline report out/roofline.json --markdown report.md
Useful flags: --llama-bench PATH if it is not found automatically (or set $LLAMA_BENCH),
--n-gen / --n-prompt to change the generation and prompt lengths, --depth N to
generate with N tokens already in the KV cache, --gpu-layers (default 0, see
limitations), --out DIR, --no-plot, --quiet. --help on any subcommand lists
everything.
--depth is the one to reach for if you run long contexts, because the weights-only
roofline is a short-context result. See Long context below.
diagnose and run count bytes differently, on purpose
diagnose parses the GGUF tensor table and counts only what decode reads per token: the
repeating layers and the output head, with the token embedding excluded because generation
looks up one row of it rather than streaming it. On a 0.5B model that is 18 to 22% fewer
bytes than the file, and the difference is format-dependent, so it changes which format
looks efficient.
run keeps the older convention, the model's resident size as llama-bench reports it,
because that is what the published fits this tool reproduces were computed with, and a
tool that quietly stops reproducing its own reference result is not worth much. Pass
--file-bytes to diagnose if you want the two to agree.
Output
diagnose writes two files to --out (default ./llama-roofline-out):
| file | what it is |
|---|---|
diagnosis.md |
the shareable one: paste it into an issue or a forum thread as is |
diagnosis.json |
everything, versioned schema, for your own analysis |
run writes four: report.txt, report.md, roofline.png and roofline.json.
advise prints to the terminal and takes --markdown and --json if you want files.
Install
pip install llama-roofline
Or as an isolated command-line tool, without touching your environment:
pipx install llama-roofline # or: uv tool install llama-roofline
uvx llama-roofline inspect model.gguf # one-off run, nothing installed
Python 3.9 or newer, on Linux, macOS or Windows. That pulls in numpy and matplotlib so
the tool works end to end on first run.
The analysis core is pure standard library. numpy is used only to measure the
bandwidth ceiling (skip it with --peak-bw) and matplotlib only to draw the figure
(skip it with --no-plot). advise and inspect need neither, and CI has a job that
proves all of it still runs with both absent. So on a constrained box,
pip install llama-roofline --no-deps gets you a working tool as long as you supply the
ceiling yourself with --peak-bw.
Do not have llama.cpp yet? You almost certainly do not need to build it. llama-bench
ships with every packaged copy:
brew install llama.cpp # macOS, Linux
winget install llama.cpp # Windows
conda install -c conda-forge llama.cpp # anywhere
Prebuilt archives for macOS, Linux, Windows and Android are attached to every build at github.com/ggml-org/llama.cpp/releases. From source, if you would rather:
git clone https://github.com/ggml-org/llama.cpp && cd llama.cpp
cmake -B build && cmake --build build --target llama-bench -j
Ollama and LM Studio both bundle llama.cpp and neither exposes llama-bench, so having
one of them installed does not count.
Results gallery
See examples/ for full output from an Intel i7-12700H (DDR5) and a
Raspberry Pi 5 (LPDDR4X). Those two machines differ by 3.5x in fitted bandwidth and by
roughly 20x in price, and both land in the same place: decode between 65% and 97% of the
memory ceiling, throughput tracking 1/model_bytes with an R^2 above 0.98. Both fits take
each model at its own best thread count, which is the upper envelope of the sweep rather
than any one setting, and every report the tool prints now says so next to the number.
Please add yours. Open an issue with the "Results gallery" template and paste your
report.md. Hardware I do not own is the most useful contribution anyone can make, and a
result that contradicts the model is more interesting than one that confirms it.
How it works
docs/METHOD.md has the full methodology: why decode is bandwidth-bound
and prefill is not, which bandwidth kernels are used and why copy is excluded from the
ceiling, how bytes-per-token is determined, and how the fit is computed.
The short version: three read-only numpy kernels over a thread pool establish the ceiling
as a measured lower bound; llama-bench supplies throughput; tok/s = BW * (1/bytes)
is fitted through the origin by least squares; R^2 against the mean of y tells you whether
the model actually holds on your machine.
Long context
The fitted roofline assumes bytes-per-token is the weights alone, which is true with an almost-empty KV cache and progressively less true as context grows. That is not a hand-wave, it is measured. Decode on this laptop, with the cache pre-filled to each depth:
Decode throughput as a percentage of the same model with an empty cache:
| model | 0 ctx | 512 ctx | 2,048 ctx | 8,192 ctx |
|---|---|---|---|---|
| Qwen2.5-0.5B Q4_K_M | 100% | 96% | 74% | 39% |
| Qwen2.5-1.5B Q4_K_M | 100% | 97% | 80% | 45% |
The falloff is far steeper than the extra KV bytes alone would cause, so it is not just more streaming; it is attention work over the cache, which grows with context length.
So: treat the headline fit and the utilisation percentages as short-context numbers, and if you run long contexts, measure at your own context length:
llama-roofline run --models ~/models/*.gguf --depth 8192
Full analysis in docs/METHOD.md.
Limitations
Read these before quoting a number.
- CPU inference is the target. GPU offload is detected and warned about, but the
ceiling measured is system RAM bandwidth, not VRAM, so the percentages will not apply.
--gpu-layers 0is the default for that reason. - Bytes-per-token is a short-context figure.
diagnosecounts the tensors decode actually reads andruncounts the model's resident size, and both are exact for a dense transformer with a nearly empty KV cache and an overestimate once the cache grows. Treat every percentage here as a short-context result. - Mixture-of-experts models are flagged, not solved. Only the routed experts are read per token, so they sit off the dense roofline and are excluded from the fit.
- The ceiling is a lower bound. llama.cpp's hand-written SIMD kernels can stream faster
than numpy. If your decode exceeds the measured ceiling the report tells you so instead
of printing nonsense. Pass
--peak-bwwith a real STREAM number to tighten it. - Run it on an idle machine. Background load depresses the ceiling and inflates every percentage derived from it. The tool checks its own repetitions for disagreement and flags the measurement as unstable when it finds it, but the cheapest fix is to close things first.
- Throughput only, except where the advisor says otherwise. Nothing this tool measures
on your machine touches output quality. The perplexity column in
advisecomes from the shipped study, on one model at one scale, and is not a measurement of your files. - The advisor knows one core. The Raspberry Pi 5's Cortex-A76, at 0.5B and 1.5B, on one model family. Close relatives are labelled as extrapolation and everything else gets told to measure for itself. That is a narrow base for a broad question, and widening it is what the results gallery is for.
- Single-stream decode only. Batching amortises the weight read across sequences, which is precisely the escape hatch from this roofline. This measures the worst case, which is the case most local users are actually in.
Citing
If this tool is useful in something you publish or post, please cite it. See
CITATION.cff, or:
M. N. Jacob, llama-roofline: a portable memory-bandwidth roofline for llama.cpp, v0.2.2, 2026. doi:10.5281/zenodo.21842493
@software{jacob_llama_roofline,
author = {Jacob, Manu Nicholas},
title = {llama-roofline: a portable memory-bandwidth roofline for llama.cpp},
year = {2026},
publisher = {Zenodo},
doi = {10.5281/zenodo.21842493},
url = {https://github.com/manunicholasjacob/llama-roofline}
}
10.5281/zenodo.21842493 is the concept DOI and always resolves to the newest version.
To cite this exact release, use 10.5281/zenodo.23127710.
The methodology comes from a study of LLM inference on a 2 GB Raspberry Pi 5 and an x86 laptop, currently under review; the reproducibility artifact for that work is at edge-llm-memory-wall. The underlying DRAM-ceiling method follows the earlier memory-wall characterisation in the same line of work.
Credits
All throughput numbers come from llama.cpp (MIT), without which none of this exists. This tool drives it and does arithmetic on the output; the hard part was already done by its contributors.
MIT licensed. Contributions welcome, see CONTRIBUTING.md.
Metadata
Release files for llama-roofline 0.2.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| llama_roofline-0.2.2.tar.gz | 95.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| llama_roofline-0.2.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 166.4 kB
Release files / llama_roofline-0.2.2.tar.gz
| Download URL | llama_roofline-0.2.2.tar.gz |
|---|---|
| Size | 95.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9e224104fbeff88e22cfda5c08e962f3ef1e81dd62d379aefc27116667c3a0b3
|
|
BLAKE2b-256 checksum How to use checksums |
20c075ce4f1b9477edbec4358a8c4a9687db651e0fd29ec8d3d3c36607e85f8e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency logRelease files / llama_roofline-0.2.2-py3-none-any.whl
| Download URL | llama_roofline-0.2.2-py3-none-any.whl |
|---|---|
| Size | 70.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
707bea3f85c928a3c798f16f25ce030ae3cc7d02835d1b037f3b97f8917a33c6
|
|
BLAKE2b-256 checksum How to use checksums |
f4cee2938e82cf0d5b8bbe19f208a14d35e86fa2f111cc2ee1494dd33b70440c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency log