Benchmark local LLMs with reproducible GPU diagnostics
CLI that benchmarks local LLM inference, splitting prefill, decode, and wallclock speed, and cross-checks the OS GPU meter to catch silent CPU fallback.
Why it matters
Verify that your local LLM deployment is actually using the GPU and performing at expected speeds by measuring prefill, decode, and wallclock times across multiple passes, then producing a reproducible verdict that explains whether the GPU did the work and why.
Outcomes
What it gets done
Detect silent CPU fallback by comparing engine logs against OS-level GPU telemetry
Measure quantization accuracy by reading actual bits-per-weight from GGUF files
Split prefill and decode performance to catch partial GPU offload issues
Monitor running LLM servers and flag performance degradation in real-time
Install
Add it to your toolbox
Run in your project directory:
curl -fsSL https://spark.entire.vc/get/logxio-picchio | bash Overview
Picchio
A single-file Python CLI that benchmarks local LLM inference on llama.cpp and ollama, splitting prefill, decode, and wallclock speed and cross-checking the engine's own log against the operating system's GPU meter to produce a verdict on whether the GPU actually did the work. Use it when a reported tok/s number for local LLM inference needs verifying, whether the GPU silently fell back to CPU, a GGUF's quant label matches its real bits per weight, or a monitored server or process is holding its GPU baseline.
What it does
picchio is a single Python file, downloaded as one zipapp with no dependencies beyond the standard library, that benchmarks local LLM inference on llama.cpp and ollama and prints a verdict on whether the GPU actually did the work. It splits three lanes that a single tok/s number hides: prefill (how fast the model reads the prompt), decode (how fast it writes the answer), and wallclock (generated tokens over everything, including load and warmup), then reads the OS's own GPU meter, ioreg and powermetrics on macOS, NVML on NVIDIA Linux, against the engine's log to catch cases where a build silently fell back to CPU or a full-offload claim contradicts a GPU the OS saw sit idle.
When to use - and when NOT to
Use it to verify a local model's real inference speed, to catch a GGUF whose quant label does not match its actual bits per weight (picchio id MODEL walks the tensor table and prices every tensor by its ggml type), to watch whole-GPU activity beside a running process without launching or signaling it (picchio watch), to monitor a running llama-server or ollama tag on a timer for a prefill/decode ratio collapse (picchio monitor), or to run and resume long benchmark queues and agent-trace jobs with checkpointed, per-step evidence (picchio run SUITE.json), including a parity mode that runs the same fixtures through a bare engine and a product-wrapped adapter and only issues a causal verdict when model hash, runtime build, context, KV, sampling, and the actual wire bytes all match, falling back to a DIRECTIONAL result otherwise. It needs python3 plus either llama.cpp or ollama installed; the full lane-table verdict is llama.cpp and ollama only, other engines like MLX or LM Studio get placement truth only through watch, not the complete diagnosis. Server mode forces a full prompt read on every pass, and on a remote URL wallclock includes the network round trip; warm numbers can drift 5-8% between sessions on an idle machine, which --passes 5 tightens.
Inputs and outputs
curl -fsSL https://raw.githubusercontent.com/logxio/picchio/main/public/picchio.pyz -o picchio
chmod +x picchio
./picchio
Run with no arguments and it finds local models across ollama tags, the current folder, and the HF and LM Studio caches, then runs the one selected; a .gguf path gets the full llama.cpp diagnosis, an ollama tag gets measurement mode. Three timed passes run by default, the first one cold, taking about a minute with the GPU engaged or a few minutes on CPU, writing one cache file under ~/.cache/picchio and nothing else. Output is a verdict block on stdout, or one JSON object with --json, human conclusions moved to stderr, reporting HEALTHY, PARTIAL OFFLOAD, SILENT CPU FALLBACK, or CONFLICTING EVIDENCE, each with a distinct scripting exit code.
Integrations
picchio guard -- <command> wraps an arbitrary command and warns the moment layers land off the GPU without killing it; picchio compare diffs two saved verdict blocks and names the first differing variable; picchio verify FILE flags a pasted block whose own numbers contradict each other; picchio capabilities --json prints a stable command, schema, and exit-code contract meant for Codex or Claude Code to consume directly. Agent-trace runs emit a generic picchio.agent-trace.v1 object with fixed, non-branded component names plus a neutral Markdown table, keeping exact wire bodies, which may contain private prompt content, in a local artifact directory rather than the shared table.
Who it's for
Developers and researchers benchmarking local LLM inference on their own hardware who need proof that a GPU is actually doing the work, not just a single throughput number to trust. The project is MIT licensed.
Source README
picchio
One executable Python file that measures local LLMs and leaves reproducible evidence.
Install · Commands · Agent traces · Quant · Lanes · Measured · Examples
Most GPU speed claims are one tok/s number. That number can be
correct and still tell you the wrong story. Three failure modes,
each one command:
- Four quantizations of the same Qwen3.5-9B, all labeled Q4_K_M,
measure 5.02, 5.02, 5.07 and 5.27 bits per weight
(the quant label). - Losing the GPU cost prefill 22x and decode under 2x on the same
model and file (three lanes). - The 36 tok/s I remembered from bare llama.cpp reproduced in no
cell of a 32 cell matrix (silent CPU fallback).
picchio splits prefill, decode and wallclock, reads the engine's
log against the OS's GPU meter, and prints a verdict that says
whether the GPU did the work, and why.
Install
curl -fsSL https://raw.githubusercontent.com/logxio/picchio/main/public/picchio.pyz -o picchio
chmod +x picchio
./picchio
With no arguments it finds your models (ollama tags, the current
folder, the HF and LM Studio caches) and runs the one you pick. A
.gguf path gets the full llama.cpp diagnosis; an ollama tag gets
measurement mode.
Needs python3 and either llama.cpp or ollama. Three passes with a
fixed prompt, the first one cold.
About a minute here with the GPU engaged, a few minutes on CPU. It
writes one cache file under ~/.cache/picchio and nothing else.
./picchio --selftest runs the bundled queue/parity recovery tests. A
repository checkout also replays the raw engine logs in
examples/raw/ and must reproduce every committed
verdict block line for line; the badge runs it on every push. The
downloaded zipapp is one file; its source stays modular under src/.
Commands
In the table, picchio stands for ./picchio.
| command | what it does | real output |
|---|---|---|
picchio diagnose TARGET --json |
explicit AI entry for one diagnosis; stdout is one JSON object, the human verdict stays on stderr | same evidence as the target rows below |
picchio model.gguf |
full llama.cpp diagnosis: three passes, placement, cold start breakdown, verdict | example |
picchio qwen3.5:9b |
same passes through your local ollama server, placement from the memory split it reports | example |
picchio http://127.0.0.1:8080 |
measures a llama-server already running, nothing launched, warm rows only | example |
picchio guard -- <command> |
wraps your own command, warns the moment layers land off the GPU, never kills it | example |
picchio compare A.txt B.txt |
diffs two saved blocks variable by variable, the first config difference takes the blame | example |
picchio verify FILE |
flags a pasted block whose own numbers contradict each other | block · output |
picchio watch [PID|ollama] --for 8 --json |
watches whole-GPU activity beside a process or loaded Ollama model without parsing or signaling it; stable JSON and raw JSONL are optional (macOS) | example |
picchio monitor TARGET |
probes a running llama-server url or ollama tag on a timer, flags any probe whose prefill/decode ratio collapses from that engine's own healthy baseline; --json for a pasteable session |
server · ollama+json |
picchio run SUITE.json |
runs or resumes a long queue, generic multi-round agent trace, or bare/product parity job; one final JSON plus a complete artifact directory | trace · manifest contract |
picchio capabilities --json |
prints the stable command, schema and exit-code contract for Codex or Claude Code | machine JSON |
picchio plan [MODEL] |
will it fit, priced from the gguf header; a decode estimate appears once one run is measured | example |
picchio id MODEL |
splits the quant label: per tensor type mix, effective bits per weight, KV dtype, experts | example |
picchio --explain 36 |
classifies a number you saw against the lanes measured here (cached rates, no rerun) | example |
picchio model.gguf --ctx-sweep |
re-measures the lanes at several context depths and reports the decay slope (9 full runs by default: 3 tiers x 3 passes, several minutes, not the ~1 min single run) | example |
watch runs next to real work, launching, unloading and signaling
nothing; --for is the sampling window in seconds, and positionalollama names the loaded model being judged (--engine ollama remains
an alias). With no --for, a bare watch samples 6 s, but watch PID
runs until that process exits (capped at an hour), so pass --for for
a daemon you do not want to wait on:
./picchio watch ollama --for 8 --json --keep-logs evidence/
With --json, the human conclusion stays on stderr and stdout is onepicchio.watch.v1 object, ready for json.loads. --keep-logs writeswatch.samples.jsonl and watch.summary.json; every sample carries a
monotonic timestamp, GPU utilization, power and memory, with unavailable
fields recorded as null and explained in warnings. These are whole-GPU
measurements, never per-PID attribution.
--passes N measurement passes, first one cold (default 3, min 2)
--keep-logs DIR save each pass's raw engine output into DIR, plus
the sampled GPU curve (telemetry.json) on macOS
and on NVIDIA Linux
--no-telemetry skip the OS-side GPU sampling; the os line then
says the verdict rests on engine+timing only
--json JSON only on stdout; human verdict stays on stderr
--bin PATH llama.cpp binary to use; prefer llama-completion, the
one-shot binary, not the interactive llama-cli
--selftest verify bundled logic/recovery; a clone also replays raw logs
--version print version and measurement protocol
Anything after a bare -- goes straight to the llama.cpp binary.
Color only on a terminal (NO_COLOR respected); piped output is
plain ASCII.
Exit codes, for scripting: 0 healthy or no evidence, 2 could not
run, 3 partial offload, 4 silent CPU fallback, 5 conflicting
evidence. guard passes the wrapped command's own exit code through
(128 plus the signal number if it died by one); compare exits 0
once both blocks parse; verify exits 0 when a block is
self-consistent, 5 when its sources fight; watch exits 0 when the
GPU is working, 4 when it sits idle; monitor exits 0 when every
probe held the GPU, 4 when any probe caught a fallback.
run uses the same numeric channel with job-specific meanings: 0 completed,
2 could not run or incomplete, 3 runtime failure, 4 quality failure, 5 causal
evidence conflict, 6 safety stop, 130 interrupted. Runtime success never
upgrades quality success.
Long runs
./picchio run suite.json
That command creates suite.picchio-run/ before work starts. It appends raw
evidence, atomically commits each result, and updates a checkpoint. Run the
same command after interruption: completed cases are skipped and unfinished
cases get a new attempt without overwriting the old one.
Queue artifacts separate runtime and quality, keep per-request input/output,
server log references, memory/swap snapshots, whole-GPU JSONL and hourly
throughput/thermal rollups. Parity runs the same fixtures through bare and
product adapters. It emits a causal verdict only when model hash, runtime build,
context, KV, sampling, task bytes, actual engine wire bytes, cache state and
received process evidence all match. Parity is interleaved by default; failed
causal evidence says DIRECTIONAL and exits 5.
Agent traces
A command adapter can return a generic picchio.agent-trace.v1. Picchio keeps
every round's real engine body, separates the last request's current context
from cumulative prompt work, and writes both JSON and a neutral Markdown table.
Component names are fixed (system instructions, document context, tool schemas, and the rest), never copied from an application's branding. If an app
reports that it compacted below the threshold, the result says FALSE POSITIVE
instead of leaving a cumulative token number open to interpretation. See the
rendered trace and complete adapter
contract. Exact wire bodies stay in the local artifact
directory and may contain private prompt or document content; redact them before
sharing. The Markdown table contains counts and neutral categories only.
The quant label
picchio id MODEL walks the gguf tensor table and prices every
tensor by its ggml type. Our own Q4_K_M measures 5.07 bits per
weight, 27% over the 4 in the name: a mix of five tensor types
from 4.50 to 32.00 bits, and the header's own byte offsets have to
audit to the same total before the card prints. The same Qwen3.5-9B
under the same Q4_K_M label measures 5.02, 5.02, 5.07 and 5.27 bits
per weight across four quantizers, on the 427 tensors all four
files share (examples/quantizers/). The
label does not even promise the same tensor set: one quantizer
ships a 243M-parameter MTP head inside the main file at q8_0,
another ships the same head as a separate repo. The KV cache dtype is
not in the file; the card cites only a concrete marker from the last
measured run for that model and engine (llama.cpp stderr or a readable
local Ollama runner log), and says not recorded when neither exposes
one. On a mixture of experts it reports how many experts wake per token
(examples/id-35b.txt reads 8 of 256, about
3.5B of 34.7B weights per token). Works on a .gguf path or
an ollama tag, read only, exit 0.
Three lanes
Prefill (elsewhere called prompt processing or pp) is
how fast the model reads your prompt; decode (tg or eval) is how
fast it writes the answer; wallclock is generated tokens divided by
everything, load and warmup included.
The lanes fail separately; the chart is two real runs from
examples/, 4 of 10 cpu threads on the CPU side.
Prefill sets the time to first token on a long prompt. A Mac
screenshot showing 500 tok/s is almost always prefill.
Silent CPU fallback
Same machine, same model, same file, forced to CPU
(examples/cpu-fallback.txt):
The WHY line names the first cause the run's own evidence can
prove, or says unknown.
While measuring local models for an app I am building, weeks of
it, bare llama.cpp gave me 36 tok/s and the same model through the
app gave 11.5: that gap is why this repo exists. A 32 cell matrix
across CPU and GPU, cold and warm, reproduced the 36 in no cell, a
rate from a different lane remembered as generation speed. What
the matrix did surface was this silent fallback.
The os line
While the passes run, a background thread reads the OS's own GPU
meter: on macOS, ioreg at 4 Hz plus the powermetrics energy
counters, minus the sudo; on NVIDIA Linux, the driver's NVML. That
is the os line. A full offload claim over a GPU the OS saw stay
flat is CONFLICTING EVIDENCE (exit 5). A build that prints no gpu
evidence while the meter watches the gpu stay idle is SILENT CPU
FALLBACK (exit 4), measured on a real mis-built binary. A missing
source abstains; the line says which evidence is left.
llama-bench
llama-bench answers a different question. Steady state pp and tg
for this machine and model, measured here, same model, same day:
| tool, config | prompt side | generation side | notes |
|---|---|---|---|
| llama-bench, default | pp256: 597.06 | tg64: 20.21 | backend column: BLAS,MTL |
| llama-bench, -ngl 0 (CPU) | pp256: 27.82 | tg64: 11.90 | backend column: BLAS,MTL |
The rented 4090 does the same. Its CUDA build keeps CUDA in that
column at -ngl 0. The 21x prompt side collapse is the CPU run's
only visible trace; there is no load time, no cold/warm split, no
verdict.
Measured
Apple M5, 32 GB, macOS 26.5.1, llama.cpp build 9430 and ollama
0.31.1, roughly 730 prompt tokens and 128 generated tokens per pass,
three passes, the first one cold. That protocol is named in every
block footer (mp1); if it ever changes the tag changes. The lane
columns hold warm medians; the raw engine output behind the first
three rows and the 4090 row is in examples/raw/,
written by --keep-logs.
| machine | model, engine | protocol | prefill | decode | wallclock | verdict |
|---|---|---|---|---|---|---|
| Apple M5, 32 GB | Qwen3.5-9B Q4_K_M, llama.cpp b9430 | mp1 | 588.0 | 21.1 | 15.5 | HEALTHY |
| Apple M5, 32 GB | same, forced CPU (0/33 layers) | mp1 | 26.8 | 12.2 | 3.0 | SILENT CPU FALLBACK |
| Apple M5, 32 GB | qwen3.5:9b, ollama 0.31.1 | mp1 | 833.8 | 21.3 | 18.1 | HEALTHY |
| Apple M5, 32 GB | Qwen3.6-35B-A3B UD-Q4, llama.cpp | mp1 | 787.3 | 34.4 | 19.1 | HEALTHY |
| Apple M5, 32 GB | qwen3.6:35b-a3b, ollama 0.31.1 | mp1 | 1191.8 | 33.4 | 27.6 | HEALTHY |
| RTX 4090, Linux | Qwen3.5-9B Q4_K_M, llama.cpp b9430 | mp1 | 6763.3 | 138.0 | 25.2 | HEALTHY |
| your machine |
Run picchio once and paste the verdict block into an issue; a
boring HEALTHY on hardware I do not have is still a data point. A
wrong verdict is the issue I want most.
Misdiagnosis reports
go to the top of the pile.
The 35B result is mostly a load-time problem. 13 of the first
pass's 19 seconds went to reading 20.6 GiB of weights. The
3B-active MoE still decodes 1.6x faster than the dense 9B.
Limits
- Tested: one Apple Silicon machine (llama.cpp and ollama) plus
one rented Linux RTX 4090 (CUDA). ollama on Linux and Vulkan
parsing have not touched real hardware; if you run those, I want
the verdict block either way. - The full verdict block is llama.cpp and ollama only. MLX, LM
Studio and other engines get placement truth throughwatch,
not the lane table. - Ollama does not expose per layer placement, device init logs, or
thread configuration. Placement comes from the memory split it
reports, unknown when there is none. - Server mode forces a full prompt read on every pass; on a remote
url, wallclock includes the network round trip. - Warm numbers drift between sessions: the 9B medians in this repo
moved 5 to 8% between two recording rounds on an idle machine.--passes 5tightens a single reading. - The os meter counts the whole GPU (index 0 on Linux), so it only
judges runs that started from an idle GPU.
FAQ
Common questions
Discussion
Questions & comments · 0
Sign In Sign in to leave a comment.