How it works

The state engine

Inference engines treat a context as memory inside a process. That is the right model for a chatbot and the wrong one for an agent, because agents do not run forward in a line — they branch, backtrack and resume. Three operations fix it, and they build on each other.

Paging

Positions live in fixed-size pages rather than one flat allocation (kv.DefaultPageTokens = 64). A context is an ordered list of page pointers. This buys two things: a context grows without reallocating, and — far more importantly — two contexts can point at the same page. Everything below depends on that one fact.

CONTEXT — AN ORDERED LIST OF POINTERS [ →p0 ][ →p1 ][ →p2 ][ →p3 ] len 214 positions 64 pos 64 pos 64 pos 22 pos A page is the unit of sharing, of copying, and of eviction. INSIDE ONE PAGE [ layer ][ position ][ kvDim ] — layer-major A forward pass touches one layer at a time across all positions, so this ordering is what keeps the attention loop walking memory forwards instead of striding across it. POSITION-MAJOR ON DISK — SO A SNAPSHOT OUTLIVES THE PAGE SIZE Three precisions, chosen at run time f32 exact · vq8 quantises values only, 66% · q8 quantises keys too, 31%
Layout inside a page is layer-major because that is the order the forward pass reads it in. On disk it is position-major instead, so a cache built with 64-position pages can restore a snapshot written by one using 128.

The prefix tree

Agent traffic is overwhelmingly repetitive: the same system prompt, the same tool definitions, the same conversation re-sent with one more turn on the end. Cache.Match(tokens) walks a radix tree over token sequences and returns a context holding the longest prefix already computed, along with how many tokens matched. Only the new suffix is prefilled.

root "<system prompt> you are…" 2,140 TOKENS · CONTEXT HELD "…tools: [search, write]" split node — no context NOT AN EVICTION VICTIM "…tools: [search]" A query arrives matched: 2,140 of 2,187 tokens prefilled: 47 reported as llamay_cached_prompt_tokens Bounded, and LRU an entry holds page references, so the memory is the cache
Eviction has to walk the tree rather than a list, because an interior node created by an edge split holds no context of its own and must not be considered a victim.

Copy-on-write fork

Context.Fork() copies the page-pointer slice and increments refcounts. Nothing else. A page is cloned only when a branch writes to a page whose refcount is above one — in Context.writable, which is the only place in the package where copying happens.

a branch writes position p refcount of that page? = 1 — NOBODY ELSE HOLDS IT write in place, allocate nothing > 1 — SHARED WITH A PARENT OR SIBLING clone the page, decrement the old one, write to the clone Measured 32-page context, forked 8 ways, one token appended to each branch 0 pages by the fork · ≤16 by the appends A COPYING IMPLEMENTATION WOULD NEED 256
One function copies, and it is the only one. That is what makes the property testable rather than a claim about discipline across four call sites.

Two properties are asserted in tests rather than assumed:

The fork path, measured on a laptop:

Transcript — the whole session as text
~/llamay $ llamay ctx build -m qwen2.5:0.5b -o build/demo/work/base.llamayctx -p "You are re
viewing a database migration. The table is users, the column is email, and the index is uniq
ue."
prefilled 23 tokens in 175ms
saved build/demo/work/base.llamayctx (552.14 KiB) in 7ms
~/llamay $ ls -lh build/demo/work/base.llamayctx | awk '{print $5, $9}'
552K build/demo/work/base.llamayctx
~/llamay $ # eight branches off that one prefill
~/llamay $ llamay ctx fork -m qwen2.5:0.5b -i build/demo/work/base.llamayctx -n 8
source                    build/demo/work/base.llamayctx, 23 tokens, 1 pages
branches                  8 in 1µs (200ns each)
pages allocated           0
pages a copy would need   8
memory held               1.50 MiB (a copy would be 12.00 MiB)
~/llamay $ # pages allocated: 0. The branches share every page until one writes.
Forking a context eight ways. The thing llama.cpp has no name for. One prefill, eight branches, copy-on-write pages — the copy that was not made is the number to watch. Recorded 2026-09-18 · Apple M4, macOS 26.0.1 · llamay v0.1.58-1-gb14f550 · metal:Apple M4 · 0:15

Snapshots

A context serialises position-major rather than page-major, so a snapshot is independent of the page size that produced it. Two checks are non-negotiable and both are in the format itself.

RESTORE, IN ORDER — EACH REFUSAL HAS ITS OWN SENTINEL magic ErrNotSnapshot version ErrSnapshotVersion weight digest ErrSnapshotModel shape ErrSnapshotShape length bound 16M POSITIONS CRC ErrSnapshotCorrupt Why the digest is not advisory KV computed under different weights is not detectably wrong at generation time — it produces fluent, confident nonsense — so it has to fail at load or it never fails at all. Why the length bound exists /v1/contexts/restore hands an unauthenticated body straight in. Four bytes of declared length once bought 24 KB of allocation per page on a 24-layer model. Ids are read before anything is sized.
Files are written through a temporary and renamed, because a partly written snapshot that looks loadable is worse than no snapshot. The weight check is the layout first, then the SHA-256 of the weights file; see the same weights.
91×faster to restore a 512-token context than to re-prefill it — and the ratio grows with length
0 pagesallocated by forking a 32-page context eight ways
1,654 linesof pointer-sharing arithmetic, checked against a reference that shares nothing

At a prompt size worth keeping, with thirty-two branches off it:

Transcript — the whole session as text
~/llamay $ llamay bench -m qwen2.5:0.5b -gpu -forks 32 -prompt 2048 -gen 32 -reps 1
llamay v0.1.58-1-gb14f550 · Qwen2.5 0.5B Instruct · qwen2 · 7 threads · kernels neon+i8mm ·
matmul metal:Apple M4 · gemm own kernels · kv f32
weights 373.71 MiB · repack on · context 32768 · prompt 2048 · generate 32

phase     rate          best     note
prefill   650.4 tok/s   3.149s   one command buffer per 256-token tile
decode    41.7 tok/s    768ms    506 dispatches per token, one submission

cpu encoding 40ms · gpu execution 3.854s (1% encoding)

phase      rate    best   note
fork x32   65µs           0 pages allocated (a copy would be 1024)
snapshot   129ms          48.01 MiB for 2048 tokens, at f32
restore    74ms           vs 3.149s to re-prefill

gpu time: staging 149ms · kernels 9.77s · copy-back 191ms

Restoring a 2048-token context is 42.6x faster than recomputing it.
~/llamay $ # 0 pages allocated where a copy would have been 1024 — that is the fork
What the state engine is worth, at 2048 tokens. The same benchmark with a prompt worth keeping and thirty-two branches off it. The line to read is the last one: restoring that context against computing it again. Recorded 2026-09-18 · Apple M4, macOS 26.0.1 · llamay v0.1.58-1-gb14f550 · metal:Apple M4 · 0:10

How it is proved

The package is pointer arithmetic, and the way it is checked is a reference implementation run beside it in lockstep. The reference is deliberately stupid: every context holds its own full copy of every position, nothing is shared at all, and the prefix tree is a flat list scanned linearly for the longest key that is a prefix of the query. It is obviously correct because it does nothing clever, so a sequence of operations where the two disagree is a bug in the clever one.

TestModelledOperations drives random sequences of every operation the package offers — allocate, extend, overwrite, fork, truncate, close, retain, match, under an arena and without one, at each of the three precisions — and asserts the whole invariant set after every single step:

At 500 seeds of 400 operations against each of the six configurations, all of it holds; the committed run is smaller so the suite stays fast. Around that: forks at a page boundary and one position either side, twelve-deep forks of branches that have themselves been forked and written, every truncation point of a shared context, every single-byte corruption and every truncation of a snapshot, and the concurrency the scheduler actually creates — sixteen goroutines forking one parent, snapshot-and-restore racing a decode that is rewriting the shared prefix, eviction racing allocation — under -race.

The two rows to read are fork is exact and snapshot resumes:

Transcript — the whole session as text
~/llamay $ llamay verify -m testdata/azmx-tiny.gguf
check                    result   detail
prefill == decode        pass     max logit difference 0.00e+00
vq8 kv cache             pass     moves 0.3% of the sampler's mass over 64 positions (worst
0.3% at position 55), greedy agrees at all 64, max logit shift 0.028 — values quantised, key
s exact
q8 kv cache              note     moves 0.3% of the sampler's mass over 64 positions (worst
0.4% at position 30), greedy differs at 1 of 64, max logit shift 0.034 — keys quantised too,
 use vq8 instead
vq8 bound has teeth      pass     a one-bit cache moves 37.2% of the sampler's mass, over th
e 5% bound — so the row above is not vacuous on this file
tokenizer round trip     pass     7 samples exact
fork is exact            pass     difference 0.00e+00 (must be zero)
snapshot resumes         pass     difference 0.00e+00
thread-count invariant   pass     difference 0.00e+00 across 1, 2 and 4 threads

All invariants hold.
~/llamay $ # fork is exact and snapshot resumes: 0.00e+00, which is the only passing value
The invariants, checked on your own file. Eight checks, including one that is expected to fail and fails: a deliberately destroyed quantiser, there so the bound above it is not vacuous. Recorded 2026-09-18 · Apple M4, macOS 26.0.1 · llamay v0.1.58-1-gb14f550 · metal:Apple M4 · 0:05

Four things that were wrong

Kept here because each one is the reason an invariant above is phrased the way it is.

The bugHow it presentedThe fix
The prefix tree leaked arena slots Nothing looked wrong: counters balanced, pages were collected, the entry bound held. Only the arena went on believing slots were occupied, so a server doing exactly what the tree is for drained its device arena over hours and silently stopped using the accelerated attention path. One release path instead of four hand-written copies. Forty retains against a four-entry bound had left eighty slots out for eight live pages.
Recycled arena slots were handed out dirty The heap path gets zeroed storage from make and the arena path got whatever the last page left, so the same program produced different bytes depending on whether a GPU arena happened to be attached — and an unzeroed slot wrote another conversation's KV into a snapshot. Slots are zeroed on issue. The snapshot is a file whose entire purpose is to be shipped elsewhere, which is what made this more than an aesthetic problem.
Eviction left the node standing Five hundred twelve-token inserts against a four-entry bound left five hundred and one nodes, each holding a full copy of its token edge — tens of megabytes retained for prefixes evicted precisely because nobody wanted them. Eviction prunes upward from the victim, and a node with one child and no context — a split that no longer splits anything — collapses into its child.
Restore believed the length it was told The position count is four bytes; the positions it promises are not. A one-megabyte body could claim several gigabytes before the lie ran into the end of the stream and the checksum that would have caught it. Token ids are read into a plain slice before anything is allocated for them, and a declared length beyond sixteen million positions is refused by name.

The same weights, by content

A snapshot used to name its model by layout: the architecture and the shape of every tensor. A fine-tune has the same layout as its base, so a context computed by one could be restored into the other and produce confident nonsense. Since 0.3.0 a snapshot (format version 3) also records the SHA-256 of the weights file — combined with the adapter's, for a LoRA model — and a restore checks it after the layout. A mismatch is 409 Conflict.

Older snapshots, which carry no digest, still restore by layout, with a warning in the log. Under -profile enterprise, or on a request that carries a classification marking, they are refused unless the server was started with -allow-legacy-snapshots.

Spill: memory pressure without losing a context

-ctx-mem-budget 8G gives each model a budget for resident KV. Past it, the least recently used idle context with pages of its own is written to -ctx-spill-dir and its pages freed. The next request that names it reads it back, exactly — the file is at the cache's own precision — and deletes the file. A context in use is never spilled.

What this composes into

What is not done