CPU inference¶
CPU is the default target. An operator with no GPU budget still has to run agents, and
llama.cpp with GGUF quantization is the mature path for that. It serves OpenAI's wire
format through llama-server, so it arrives through the same adapter as vLLM: an agent
does not become a different agent because the weights moved onto a CPU.
What the kit owns is the part around the server — the flags it is started with, the prompt cache each request asks for, and whether the model fits in the machine at all.
from tesserix_adk.models.gguf import GgufModel
from tesserix_adk.models.providers import LlamaCppProvider, LlamaCppTuning
provider = LlamaCppProvider(
"llama-3.1-8b-instruct",
base_url="http://127.0.0.1:8080",
capabilities=serves,
tuning=LlamaCppTuning(threads=8, batch_size=512, context_tokens=8_192),
weights=GgufModel(name="llama-3.1-8b-instruct", parameters_b=8.03),
available_bytes=16 * 1024**3,
)
weights and available_bytes together are what turn an OOM kill into a typed refusal.
Given neither, nothing is checked — the kit does not guess a machine's memory.
Fitting a model in a machine¶
Three things occupy memory, and they move for different reasons.
| Part | Size | Moves with |
|---|---|---|
| Weights | parameters × bits per weight ÷ 8 | The quantization |
| KV cache | kv_bytes_per_token × context_tokens |
The context the server was started with, used or not |
| Buffers | ~512 MiB | Roughly constant across model sizes on the CPU backend |
GgufModel.footprint(context_tokens=…) returns those three as a MemoryEstimate.
refuse_if_it_will_not_fit compares the total against the memory there is and raises
ModelTooLargeError — a ConfigurationError, so anything already handling assembly
failures catches it — naming the shortfall and, where there is one, a lighter quantization
that would have fitted:
llama-3.1-8b-instruct at q5_k_m with a 32768-token context needs 9.80 GiB (5.30 GiB of
weights, 4.00 GiB of KV cache, 0.50 GiB of buffers) and this machine has 8.00 GiB:
q2_k would fit
The KV cost per token is a field rather than a constant because grouped-query attention
changes it by an order of magnitude between two models of the same size. It is
2 × layers × kv_heads × head_dim × 2 bytes at f16 — 131,072 for an 8B Llama, 36,864 for
Qwen2.5 3B. Leaving the default on a model without grouped-query attention under-counts
badly, which is exactly the case where the machine then OOMs.
Quantization¶
Fewer bits per weight means a smaller file and less memory bandwidth per token, which on
CPU is the entire performance budget — and worse output. Q4_K_M is where the published
trade-off sits, so it is DEFAULT_QUANTIZATION, and quantization_for(...) never returns
anything heavier: past that point more bits buys little quality and costs the one resource
there is none of.
| Format | Bits per weight | Where it is the right answer |
|---|---|---|
q2_k |
3.35 | Nothing else fits, and the quality cost is visible |
q3_k_m |
3.91 | A tight machine, one step down from the default |
q4_0 |
4.55 | Legacy; prefer q4_k_m at nearly the same size |
q4_k_m |
4.83 | The default. Where quality holds |
q5_k_m |
5.67 | Quality-sensitive work with memory to spare |
q6_k |
6.56 | Near-lossless, at a bandwidth cost |
q8_0 |
8.5 | Reference comparisons |
f16 |
16.0 | Measuring what quantization cost |
quantization_for(8.03, context_tokens=4_096, available_bytes=5 * 1024**3)
# Quantization.Q3_K_M — the heaviest that fits, never heavier than the default
A CPU-viable model set¶
Computed with GgufModel.footprint at q4_k_m, using each architecture's own KV cost.
These are the numbers the kit will check against, not a benchmark — see below for
measuring throughput on the hardware you actually have.
| Model | Params (B) | KV bytes/token | Weights | Total @ 4k | Total @ 8k |
|---|---|---|---|---|---|
qwen2.5-3b-instruct |
3.09 | 36,864 | 1.74 GiB | 2.38 GiB | 2.52 GiB |
llama-3.2-3b-instruct |
3.21 | 114,688 | 1.80 GiB | 2.74 GiB | 3.18 GiB |
mistral-7b-instruct-v0.3 |
7.25 | 131,072 | 4.08 GiB | 5.08 GiB | 5.58 GiB |
llama-3.1-8b-instruct |
8.03 | 131,072 | 4.52 GiB | 5.52 GiB | 6.02 GiB |
qwen2.5-14b-instruct |
14.77 | 196,608 | 8.30 GiB | 9.55 GiB | 10.30 GiB |
An 8B at q4_k_m is the size that fits a 16 GiB machine with room for the rest of the
container, which is why it is the reference model everywhere in these docs.
Measuring throughput on your own hardware¶
Tokens per second on CPU is a property of the machine, not of the model: it tracks memory bandwidth almost linearly, so a number measured on one box says nothing useful about another. The kit therefore ships no throughput table. Measure yours:
-p is prompt ingestion (the first seconds of a run), -n is generation (everything
after), -t is threads, -r is repetitions. Report both: they scale differently, and a
box that ingests quickly can still generate too slowly to be worth using.
Then sweep -t across physical core counts and take the best, rather than assuming more
is better — see below.
Tuning¶
LlamaCppTuning is how the server was started, in the form both the operator and the kit
read. It exists so the fit check and the launch command cannot disagree about the context
length. server_arguments() renders the flags:
LlamaCppTuning(threads=8, batch_size=512, context_tokens=8_192).server_arguments()
# ('--threads', '8', '--batch-size', '512', '--ctx-size', '8192')
| Field | Flag | What it is for |
|---|---|---|
threads |
--threads |
Generation threads. Physical cores, not hyperthreads |
batch_size |
--batch-size |
Prompt tokens per pass; fills memory bandwidth on ingestion |
micro_batch_size |
--ubatch-size |
The physical batch inside a logical one |
context_tokens |
--ctx-size |
What the KV cache is sized from, and what the fit is checked against |
prompt_cache |
— | Per-request, not a flag: see below |
A field left None renders no flag, leaving llama.cpp's own default — rendering a guess at
a thread count is worse than rendering nothing. A batch_size below its micro_batch_size
is refused at construction, because llama.cpp silently reads it as a batch of the larger.
Oversubscribing threads makes a CPU backend slower, reliably and counter-intuitively: the threads contend for the same memory bandwidth they are all waiting on. Start at physical cores, then measure downwards.
The prompt cache¶
llama.cpp does not keep the evaluated prefix between turns unless each request asks it to,
and without it every turn re-evaluates the whole prompt — on CPU that is the run. The
provider sends cache_prompt: true by default; LlamaCppTuning(prompt_cache=False) turns
it off for a server sharing one slot between unrelated callers, where the cache thrashes.
The cache hits on an exact byte prefix. Anything that reorders a system message, restamps a timestamp into it, or re-serialises a tool schema differently between turns is a cache that never hits, which is why prompt assembly is byte-stable and has a fingerprint test guarding it. If you add to the prompt, add at the end.
Running the same agent elsewhere¶
The CPU path is worth having only if it is not a fork. An agent written against vLLM runs
here unchanged — same Agent, same AgentRunner, same structured output, same usage
accounting — with only the provider swapped. examples/cpu_inference.py runs one agent
against both and asserts the answers match, over a stub transport, with no network.
Known limitations¶
- No throughput numbers. As above: they are a property of the box.
llama-benchis the answer, not a table in a repository. - The footprint is an estimate. Buffers are approximated as a constant, and a build with different BLAS backends or a flash-attention kernel will differ. It is deliberately not optimistic, but leave headroom.
- No GPU offload tuning.
--n-gpu-layersand partial offload are a separate story. - Weights are not shipped or verified here. The kit describes a model; obtaining and checksumming the GGUF file is the operator's, and the supply chain's, business.
- Capabilities are declared, not probed. As for any self-hosted endpoint, what the build and the model can do is stated by whoever deployed it.