BigMoeOnEdge/docs/hardware-planning.md
Raffaele 79196c6dc9
feat(build): compute backends as modules beside the engine, on every OS (#263)
* feat(android): the release APK carries the Vulkan and OpenCL backends beside Hexagon

One APK for every phone: the planner enumerates whichever GPU the phone's
driver registers, probes it in a process of its own and arms it as the
prefill device by the rule that arms Metal and the NPU; no engine change.

The OpenCL backend imports libOpenCL.so, and the APK ships the Khronos ICD
loader under that name so the import resolves on a phone without OpenCL,
where the loader reports no platform. Linked against a vendor library
instead, the process could not start on such a phone, nor on one whose
library lacks a symbol the backend imports, which a 2020 phone did. Where
the phone has a vendor library the app puts it first on the linker path
(a directory holding one symlink to it) so the backend links it directly,
as llama.cpp does on Snapdragon: opened through the loader, the vendor
library binds its own cl* calls to the loader's exports and the two
deadlock at the first platform query, measured on a Snapdragon phone. A
vendor library that refuses to link ends the process before main; the app
notices and starts the engine again with the loader. Vulkan links the
system libvulkan.so every phone has.

scripts/android-gpu-deps.sh fetches what the NDK does not carry (Vulkan and
SPIRV headers, OpenCL headers, the loader built with the NDK) at pinned
Khronos tags and writes the cmake flags; the release recipe and the CI
Android job both use it, so every PR cross-compiles both backends. The
release verification requires the three new libraries in the APK, and
release-apk gains a dry run from any ref that keeps the APKs as an
artifact, the one way to see the recipe run before a release.

Docs: backends, platforms, roadmap, npu-prefill, hardware-planning, the
app README; backends.md corrects OpenCL's buffer_from_host_ptr cell (a
function that declines, not an implementation) and config.h points at it
instead of a bench-data folder the repo does not have.

* fix(arena): refuse a device that pads a single expert past its share of the slot

A backend that adds a fixed slack to every tensor's allocation (OpenCL
reserves carve room on each quantized tensor) makes one expert's
allocation larger than its share of the whole tensor, so the last expert's
placement reaches past the slot and ggml aborts inside
ggml_backend_tensor_alloc instead of returning. Measured on a Snapdragon
phone with the OpenCL device named by hand. The arena now checks a single
expert's allocation against its stride when it sizes the slots and refuses
with the reason, where the load can still fall back to the host. Gates
pass.

* docs: what the phone's GPU measured through the planner, and the NPU run it owes (#264)

* feat(build): every release carries its compute backends as modules, on every OS

The engine knows no backend at compile time. Each is a library ggml loads
from the executable's own directory at start-up, the CPU one once per
instruction-set tier with the best picked on the machine, and a module
whose driver library is missing is not loaded instead of keeping the
binary from starting (BMOE_BACKEND_MODULES, on by default; iOS stays
linked, as the platform demands). The APK carries Vulkan and OpenCL
beside Hexagon, the Linux and Windows archives Vulkan, the macOS one
Metal; a module built with another toolchain and dropped beside the
binary loads like the shipped ones. The planner enumerates what
registered and arms a prefill device by the rule that arms Metal and the
NPU; no rule changes.

--overlap survives the move: the fork's expert-ready hook is reached
through the CPU backend's registry (get_proc_address) rather than linked,
so the fork stays one commit, a build on stock upstream refuses --overlap
with the reason instead of failing to link, and the server's default and
its overlap_available follow the backend that loaded. The gates for the
overlap path pass through that route. Backends are loaded from beside
the executable only, once, and counted before llama.cpp's own loader
runs. Windows gains the device probe in a process of its own.

The app's launcher names no backend: the linker path is the engine's
directory plus a directory holding one symlink to the phone's OpenCL
library under its standard name, so the OpenCL module loads where the
phone has one and not otherwise; the vendor directory on the path and
the backend tuning variables are gone, following what upstream measured.
The Khronos ICD loader is a link-time stand-in only and does not ship.

scripts/build-portable.sh is gone: the default build is that build.

* build: one CPU module on Apple, and the Linux archives from 24.04 for the Vulkan shader compiler

Apple silicon is one family and the compilers on the CI images refuse the
tier flags (svmmla needs sve,i8mm), so macOS gets the one baseline CPU
module; Ubuntu 22.04 has no glslc package, 24.04 does, and the archives'
README says the glibc that follows.

* build: pin the fork branch that exposes the expert-ready hook through the CPU registry

Same single commit on the same upstream base, with the setter in the CPU
backend's get_proc_address table, so a CPU backend loaded as a module can
hand the engine the hook (docs/seam.md).

* ci(release-host): the macOS modules are .so beside the .dylib, and Linux builds with gcc-14

The archive step copied only .dylib on macOS and required a tiered CPU module
name; both wrong since Apple has one CPU module and the modules are .so. On
24.04 arm the compiler the image ships does not build the newest ARM CPU tier;
gcc-14 does, as in llama.cpp's own release.

* fix(android): the engine reaches the vendor libraries again, and the NPU keeps its two settings

A process the app starts links in the linker's default namespace, which opens
a vendor library only from a directory named on LD_LIBRARY_PATH; a symlink to
one from anywhere else is refused. With the linker path reduced to the app's
own directory plus a symlink to libOpenCL.so, the OpenCL module failed with
"not accessible for the namespace" and the Hexagon one with "libcdsprpc.so not
found", both silently, since ggml loads modules quietly in a release build.
Measured in the app on a Snapdragon phone: its engine had mapped the CPU and
Vulkan modules and nothing else.

The path is back to the app's directory, /system/lib64, /vendor/lib64, in that
order: vendor last, where it shadows no system library, which was the concern
behind dropping it. With it, all four modules load (CPU, Vulkan, OpenCL, HTP).

GGML_HEXAGON_HOSTBUF and GGML_HEXAGON_OPPOLL are set again. Without the first
the NPU offers no host buffer type, the prefill cannot move the KV cache into
it and a device graph's attention runs on the CPU; without the second each of
the arena's crossings costs about twice as much. Only the Hexagon backend
reads them, so they cost nothing elsewhere. docs/npu-prefill.md and the
changelog describe the path as it is.

* ci(release-host): the Windows archives carry their C++ runtime, and Linux x86_64 runs on 22.04 again

The modules share the engine's C runtime, which is right (one heap), but the
archives did not carry it: every exe and DLL imports msvcp140, vcruntime140
and vcruntime140_1, and a machine that never installed the Visual C++
Redistributable could not start them, the failure v0.23 had fixed. Packaging
now copies those DLLs from the image's redistributable folder beside the
binaries, a local deployment the redistributable allows, and fails if one is
missing.

The Linux archives had moved to 24.04 for its glslc package, which raised the
floor from glibc 2.34 / GLIBCXX_3.4.30 to 2.38 / 3.4.32 and left Ubuntu
22.04, Debian 12 and RHEL 9 unable to run them. x86_64 builds on 22.04 again,
with LunarG's Vulkan SDK as llama.cpp's own release does there. aarch64 stays
on 24.04 with gcc-14, which the newest ARM tier needs and 22.04 on arm cannot
provide together with glslc; its README and docs/community-benchmarks.md say
so, and the latter no longer describes the single-tier CPU builds.

* feat(prefill): a device is confirmed on the model before the session keeps it, and the matmul check is llama.cpp's

The planner arms a prefill device on one synthetic matmul, and that test is
wrong in both directions. Its private bound (1e-4 NMSE) was five times
stricter than the 5e-4 llama.cpp's test-backend-ops allows a matmul, and
refused a laptop iGPU whose half-precision Vulkan prefill differs by 4.4e-4
and leaves the model's perplexity unchanged (32.58 against 32.46 over a
1070-token text). And no matmul bound can see a fault only the model's own
activations trigger: a phone GPU's half-precision Vulkan prefill took the
same text's perplexity to 711 445, no next token right.

The check is now llama.cpp's measure and bound. And after the load the
session confirms the device on the model itself (--prefill-verify, on by
default, a RunConfig field and one params row): a fixed 64-token text is
prefilled on the host and on the device, and the device is kept only if its
top next token matches the host's in at least 90% of the positions. A device
that fails is handed back exactly as one that failed to open; a plan's
device (best effort) leaves the prefill on the CPU. A one-token host graph
separates the passes, since two wide graphs of one shape on different
placements would let llama.cpp reuse the first. The laptop iGPU agrees in
98% (logit NMSE 1.3e-3). Both passes are timed and printed but not judged:
at 64 tokens cold expert reads dominate, and that iGPU, faster there, ran a
real 1015-token prompt 8% slower than the host.

* fix(plan): apply the fitter's placement only where every device it used has its own memory

The trust check asked whether any device on the machine had memory of its own. On a phone the
NPU, whose buffers come from a kernel allocator and which states no size, passed it, and the
fitter's 41 layers (2106 MiB) on the GPU beside it were applied although that GPU's memory is the
host's and the plan's ledger held none of it. The devices the fitter gave bytes to are now the
ones asked, each with a stated size; where one shares the host's memory the placement is declined
as it already was where every device does.

* fix(plan): a prefill device is charged its own compute buffer, not the fitter's sum

The fitter projects compute buffers for the devices it places layers on, and the planner charged
their sum to whichever device would prefill. On a phone a GPU's 467 MiB went to the NPU beside
it, which the fitter never places on, and came out of the expert cache (904 MiB instead of about
1370, one expert slot instead of two). The placement now keeps each device's part, and the
prefill device pays its own; one the profile does not know still pays the whole sum.

* fix(io): every buffer that holds tensor data keeps a page readable past its end

One of ggml's CPU kernels (Q6_K, on cores with SVE and int8 matmul) loads 14 bytes past a
block's end, which llama.cpp's own buffers absorb. A dense weight in a buffer sized to it exactly,
with a guard page after it, faulted on a phone at random once the release carried that CPU
variant. The dense copies, the expert slots and cache regions, the row-gathered tables and the
bandwidth probe's host copy now end with the OS's page of slack, committed where the platform
faults on a page that was only reserved. The confirmation text is tokenized at the size the
tokenizer asks for instead of a fixed buffer.

* fix: the overlap gates cannot skip unseen, a confirmation that cannot run is not a verdict, and a boundary page goes with its last neighbour

Four findings of the review of this branch, each closed where it was found:

- The gates that prove --overlap (G4, G5b, S2) skipped themselves, exit 0, wherever the CPU
  backend loaded had no expert-ready hook. Whether it has one is a run-time fact now, decided by
  which CPU module ggml picked, so a bump or a stripped module could retire the three gates and
  the suite would stay green. The gates take --require-hook, under which the missing hook is a
  failure, tests/CMakeLists passes it from BMOE_GATES_REQUIRE_HOOK, and the CI job whose purpose is
  to prove --overlap turns it on. The gates load the backends the way the engine does
  (register_backends) before judging, since an empty registry would answer "absent" everywhere.
- --prefill-verify refused the device when the check itself could not run: a text that did not
  tokenize, or --prefill-min-tokens above the 64-token probe. Not being able to confirm is no
  evidence against a device, so the probe is now at least as wide as the device's narrowest graph
  and a check that still cannot run says so and leaves the device as set.
- A slice is committed through the slack page after it, which is the head page of the next slice,
  and eviction released only the pages between the two boundaries: once an expert had been
  resident, its right neighbour's head page stayed committed until the buffer was freed, outside
  the cache ledger - one page per expert and projection, up to 72 MiB on a 48-layer, 128-expert
  model. A boundary page is now released with the last of its two neighbours, the liveness of a
  neighbour read under io_mtx_ since the workers finish speculative reads there.
- The top-level CMake comment still named BMOE_HAVE_EXPERT_READY_HOOK, which this branch removed,
  and release-host checked for the CPU module but not for the GPU module an archive is named for.

docs/backends.md takes the two decisions from the roadmap PR this branch supersedes (CUDA and HIP
out of the default archives; the load time with an idle GPU module owed a measurement), and
docs/hardware-planning.md, docs/seam.md and the changelog follow the code.

Gates: 20/20 on a Windows modules build with BMOE_GATES_REQUIRE_HOOK=ON, the overlap gates run.
2026-10-18 12:15:28 +02:00

72 KiB
Raw Permalink Blame History

Hardware planning (--auto)

The engine exposes a lot of knobs, and almost every one of them has a right answer that depends on the machine rather than on taste. --auto derives them instead of asking, and prints the fact behind each choice so a run can still be explained afterwards.

bmoe-cli -m model.gguf --auto                   # measures, plans, runs. Nothing else to do
bmoe-cli -m model.gguf --auto --plan-explain    # ...and print why it chose what it chose
bmoe-cli -m model.gguf --plan-only              # print the plan and exit, without loading the model
bmoe-cli -m model.gguf --auto --no-probe-io     # plan from free facts only, touching no storage
bmoe-cli -m model.gguf --auto --probe-mem      # ...and measure what this machine will let us KEEP

It is opt-in. Without it nothing changes.

What runs where, so the rest of this document is not misread

A machine with an accelerator uses it. The first stage is llama.cpp's own capacity fitter, and whatever it places on a device is placed, computed there, and none of the rules below touch it: the plan carries its n_gpu_layers and its override patterns straight into the load. On a box with 24 GB of VRAM and a 150 GB model, the attention, the dense set and the experts of every layer that fits are on the GPU.

What this planner owns is the residue - the expert tensors the fitter left on the host because there was no room - and those are the ones it streams from flash. Those cannot be computed on a discrete GPU, and the extra-offload line in every plan is about them and only them, never about the fitter's placement. The reason is in "Why the obvious route is closed" below, and it is not a choice: a streamed expert exists by having its data pointer rebound onto memory this engine reserved, and a discrete device cannot read that memory through anything the public API offers today. Upstream is stuck on the same step - its own expert-streaming PR has been open since July, and the neighbouring issue says the fix needs a change inside ggml_backend_sched_compute_splits, which is a fork.

Two stages, one composer

Placing tensors across compute devices is a capacity problem, exactly solvable from tensor sizes, and llama.cpp already solves it: common_fit_params loads the model with no_alloc, measures the projected memory per device, and if it does not fit first shrinks the context, then moves weights from device memory into system memory — per class inside each layer, attention first and the sparse expert tensors last. It sees every backend through ggml. The comment above it reads "assumes system memory is unlimited", and that line is where this planner begins.

So --auto runs in two stages. The first is the fitter, called behind one adapter (core/src/plan/placement_probe.cpp, the only file that touches llama.cpp's common/), which answers: which layers keep their experts on the host, and what the placed model reserves there for context and compute. The second is this planner, sizing the flash tier on what is left. The session then loads with the fitter's n_gpu_layers and override patterns rather than a hard-coded zero.

Three of the fitter's answers are read rather than trusted. Left at 0 it picks the model's full training context, so our context is always the pin. On a machine with no GPU it leaves n_gpu_layers at its default, which means "all", so devices are counted instead: the placement is applied only when every device the fitter gave bytes to was measured to have memory of its own and states its size. Where one reads the host's memory, the fitter has counted one pool twice, and the placement is quoted under placement-declined and not applied. Asking the machine instead of those devices let an NPU on a phone vouch for the GPU beside it, and 2106 MiB went "on devices" that the plan's ledger never held. And its host model term is neither the file nor the dense set, so dense bytes come from the gguf profile and only the context and compute reservations come from the fitter — the one thing it knows that the profile does not, and 605 MiB the earlier budget had overcommitted on the desktop.

Two outcomes of the first stage end the plan early, and both are stated: if the host residual fits in RAM the experts stay resident and nothing streams (the classic -ot exps=CPU offload); if every layer's experts land on a device, nothing is left for the streamer.

The unit everything is priced in

Most knobs in a plan are decided by one fact: the lane count by the storage curve, o-direct by whether uncached reads return correct bytes, the thread count by a sweep. For those, a rule that reads the fact is the right shape and nothing below applies.

Four decisions are not like that, because they are trades. RAM given to the dense set is RAM taken from the expert cache. A group moved to a device frees host memory and adds graph crossings. A narrower prefill hands its reservation to the weights. These compete, so they have to be priced against each other in one unit, and that unit is seconds per byte of weights.

It is available because batch-1 decode is bandwidth-bound: every weight is read once and multiplied once, so a GEMV's cost tracks the bytes it sweeps exactly as a read's does. Flash, RAM and device memory become directly comparable — which is what lets the flash tier enter the objective function at all, and is the one thing no other placement solver does. --fit upstream does static memory accounting and disables itself under -ot; ATSInfer solves the same knapsack with a 15k-line fork, CUDA only, RAM↔VRAM only.

core/src/plan/allocate.cpp prices every legal lane for every group, ranks the candidates by seconds saved per byte of RAM, and spends the budget down that list. The expert cache is one more candidate on the same list — a fractional one, above its own model-derived floor — rather than a special case that receives whatever is left over. That matters in two directions: the old residual handed the cache more than a hit was worth on a machine with room, and handed the dense set memory that would have bought more hits on a pressured one.

The model is decomposed by access shape, not by role, because that is what decides residency: a 30B MoE reads every byte of its attention weights per token and one row of its embedding table, and pricing both as "dense weights" prices things three orders of magnitude apart identically.

Three rules keep the cost model from becoming an oracle, and they are load-bearing:

  1. An unmeasured number may never justify a move. A missing fact makes a candidate ineligible, never cheap. Every decline names the measurement it was owed.
  2. A device must win by a margin, not merely win. Half the plausible levers this engine has tried lost on measurement; a planner built to believe its own model would rediscover every one of them with confidence.
  3. The plan that streams nothing stays reachable, because residency beats streaming whenever residency is available.

The term a bandwidth ratio cannot see

The device decision is where a cost model earns its keep or discredits itself. Bandwidth alone would have recommended an offload measured at 0.53x: the probe read that device at 20 GiB/s against the host's 12. What it could not see is the graph crossings. Dense and expert halves alternate per layer, so a split placement crosses the host/device boundary twice per layer; the contiguous-block shape that should have put the boundary in one place was swept 0/2/4/8 and went 2.398 to 0.587 tok/s, with N=4 and N=8 faulting — while the I/O half of the trade did exactly what this cost model predicts (cache hits 48% → 73%, re-reads 84.5 → 5.2 per token). Merely registering a device cost 61 crossings per token with nothing placed on it.

So crossings are a priced term (PlannerPolicy::device_split_seconds), and while their price is unmeasured no group is placed on a device and the plan says which measurement it was owed. Two further costs a single-GEMV probe structurally cannot see were also paid on that machine: the offload disabled Flash Attention, and quantized kernel quality is not uniform — the fast path was name-gated on the expert tensors, so dense weights at 2–4 bit fell to generic kernels while the CPU had hand-written dotprod.

Correctness gates speed: a device that does not reproduce the CPU's answer on the probe graph is excluded before its rate is looked at, and a device that advertises host pointers must honour one when handed it.

The plan is a value

--probe measures, --plan says what it would do and exits, --plan-run does it (--auto is an alias). --plan prints the exact bmoe-cli line that reproduces the plan, because a plan that can only be executed is an oracle and an oracle cannot be falsified — everything this repository knows about performance was learned by A/B-ing one lever at a time. Any flag typed by hand is a pin the planner may not touch.

At the end of a run the plan's prediction is compared against what happened, with the credited cache hit rate beside the measured one. That comparison is what makes the coefficients correctable rather than believed: hits are credited linearly in the fraction of the expert set held, while routing has strong temporal locality — one measurement on record is a cache holding 13% of the set serving 61.9% of the reads — so the prediction is pessimistic by roughly that factor. Fitting a curve to a single point would be inventing the shape, so the shape stays linear and the coefficient is what the loop corrects.

Backends this build can even see

The planner enumerates what ggml registers. A backend the build did not compile in, and did not find beside the executable, is a device no rule can consider — so the plan reports "no accelerator" on any hardware, forever, and that is a property of the build printed as a property of the room.

Every release is built with the backends as modules (BMOE_BACKEND_MODULES, the top-level CMakeLists): register_backends() loads them from the executable's own directory before enumeration, and from there only - a lone binary copied without its libraries reports a machine with no accelerator, and a module left in the working directory is not picked up. Which modules a release carries is decided by the build host's toolchain, not by a list per OS: Vulkan, OpenCL and Hexagon in the APK, Vulkan in the Linux and Windows archives, Metal in the macOS one; a module a user builds with another toolchain and drops beside the binary is loaded like the shipped ones. --overlap survives the move because the fork's hook is reached through the CPU backend's registry rather than linked (seam.md).

The profile records how many backends came linked, how many were loaded at run time, and whether anyone looked at all, so the plan keeps three answers apart: this machine has no accelerator, this build carries none, and nobody went to look.

None of this writes a backend or forks one. Every backend comes from ggml; the planner asks devices about properties — host_memory, host_buffer, host_ptr_buffers, needs_repack, runs_expert_op, identity_ok — and never matches a name. That is how an NPU with two native quant formats excludes itself without a line written for it.

The two invariants

Decline instead of guessing. Every probed fact is a tri-state, and Unknown is an input rather than a defect: a knob whose deciding fact is missing keeps its default and is reported as [unprobed]. A plan full of unprobed lines is a list of the measurements the project still owes itself, which is worth more than a plan that invented values for them.

Nothing lossy arms itself. Expert dropping, substitution and route-ahead change the output and make a run irreproducible. They are never turned on by a plan, at any quality budget, on any machine. A caller that arms one keeps it, attributed to [operator].

A third rule falls out of the first two: a knob you passed by hand is never overwritten. Flags and BMOE_* env overrides both count as the caller speaking.

The flow

  1. Probe the model from its gguf metadata (nothing loaded, no tensor data read) and the machine from what it reports for free.
  2. Classify the regime — a ratio of the two, never a property of either. The whole model inside the residency budget is fits; only the dense set is experts-stream; not even that is dense-oversized.
  3. fits declines streaming and says so: on that machine reading experts from flash only adds reads that resident weights do not need.
  4. Dense policy, from what a reclaim costs here.
  5. The memory ledger: one account for everything the run holds — context and compute buffers, an armed device, the dense set, the expert cache — against two ceilings, what can be held and what can be held reclaim-exempt. The cache is what is left under both, capped at the experts' own size and floored at the model's token cycle. See The memory ledger.
  6. I/O policy from the storage facts.
  7. Emit the plan with its rationale. A decline is a plan too: the config it carries still runs. A budget under the token cycle is not one of them: it turns the cache off and the experts stream uncached (see The token cycle), unless the drive's read rate was never measured and the stream has no price. A plan that does decline for want of room streams nothing and holds nothing, so its cost is restated as the file left mapped with nothing resident - the bound, which is then the prediction - and its ledger keeps only the context and compute buffers. Before, it printed the cost of the allocation it had just refused: 0.23 s/token predicted where the run took 1.46 and the bound said 1.69.

One sequence, every front-end

Steps 1 to 7 are one call, probe_and_plan (core/include/bmoe/auto_plan.h), and every front-end that plans makes it: bmoe-cli --auto and the server's /api/plan alike. Its order is a measurement, not a preference - backends registered before anything looks at a device, each device asked in its own process before this one uses any, the bandwidth and device-cost probes before the storage probe reads gigabytes, the headroom probe last because its active mode leaves the kernel busy - and an order written out once per front-end is an order that can drift until one of them measures the other's recovery. What a front-end decides is in its options: whether the storage is read (--no-probe-io), whether memory is measured by holding it (--probe-mem, never from the server), and how it starts itself again to probe a device. The composition lives with the adapters, because it starts llama.cpp's backends; it decides nothing, and the rules it ends in stay pure.

Inside, plan_run is a list of named stages over one planning state - the widest graph, the regime, the placement, the accelerator axis, the dense policy, the ledger and the prefill device, the cache floor, the I/O knobs, the devices the run lists - and allocate a list of named steps, in the order this page describes them. A reason that tells a person what to type spells the flag through the parameter table, so a renamed flag cannot leave the rationale advising one that no longer parses.

The facts, and which knob each one decides

fact decides
residency budget: how much this process may hold the regime, and the cache budget
headroom: how much of it can be KEPT the same two, when it has been measured rather than reported
core classes, fastest first --threads: a barrier waits for its slowest participant
whether a device executes over a host buffer whether streamed experts could be computed on it
host memory bandwidth, and a device's whether moving that compute is worth anything
what happens to anonymous memory under pressure the dense policy, and the margin
whether a reclaim-exempt allocation exists whether the dense set can be pinned
how much may be held reclaim-exempt in total, net of what already is whether the dense set fits there whole, and what is left for the cache
how much may be locked where it already sits whether the cache is sized to what can be locked or to what can be held
whether a device's buffers are held reclaim-exempt whether arming it is charged to that total too
the same matmul many tokens wide, on the host and on every device, with the result compared --prefill-device: which device, if any, runs the prefill
model: the largest layer's experts, and its other weights what a prefill device costs in memory
compute buffers at the widest graph, and what a token of width adds --ubatch: how wide a prompt is computed
whether mapped file pages count against the fatal limit whether leaving the dense set mapped is free
whether uncached reads work on this path --no-odirect
whether a live mapping serialises concurrent reads --release-mmap
read rate by request size and lane count --io-threads
model: expert slice size which point of that rate curve applies
model: token cycle the cache floor, below which the cache is off and the experts stream uncached

There are no platform names in the rules — Windows, Android, iOS, CUDA and Metal appear only in core/src/plan/hardware_probe.cpp, the adapter that fills the profile. That is what lets the rules be unit-tested against machines nobody here owns (tests/planner_test.cpp), and it is why the same rule that picks anonymous buffers on a desktop picks a pinned allocation on a phone and leaves the weights mapped on a platform that kills a process for holding too much: one rule, three facts, three answers, no branch.

The token cycle

The cache floor is the model's own arithmetic: for every MoE layer, one expert's bytes across that layer's expert tensors, times the routing width. Under it the cache evicts what the same token still needs, so it costs its memory and returns no hits at all — the cliff docs/cache-sizing.md documents.

It is summed over the tensors the file carries rather than multiplied out, because the factors are not uniform: a fused gate_up projection is twice the stride of a split one, an architecture with leading dense blocks has layers that demand nothing, and a trailing multi-token-prediction block names expert tensors that llama.cpp never loads. On Qwen3.6-35B-A3B-Q4_K_M this derivation returns 581 MiB against the 582 MiB the engine computes at load from the tensors it actually bound — an independent check that the metadata-only path sees the same model.

Because the floor is per-model, it also overrides the generic cache_min_mb guard: a budget above this model's cycle is not pathological however small it looks, and the plan says so when it forces past it.

The floor takes the cache away, not the stream. A budget under the cycle used to decline streaming altogether and leave the file mapped, which is the machine where a mapped file is at its worst: the memory a cache lacks is the memory the page cache lacks too. The plan now sets --cache-mb 0 and keeps --moe-stream, on the ledger's own price for the uncached stream against the mapping; where the drive's read rate was never measured there is no such price, and it declines as before. Measured on a 16 GB Apple-silicon laptop with Qwen3.6-35B-A3B Q4_K_M at a 16384 context and about 3 GB reported available, which leaves the cache under its 581 MiB cycle: declined and mapped, 0.26 tok/s over 182 tokens; streamed with no cache, 7.4 tok/s on the same request, and 7.3 tok/s over 160 tokens from --auto alone (95 ms/token predicted, 138 measured). The floor itself holds: with more memory free, the same model with a 536 MiB cache forced on returned 0.0% hits at 6.3 tok/s against 8.6 with none. What it costs to be under the floor is the cache: 17.8 tok/s at 81% hits with the 5.9 GB cache the plan takes when 8.9 GB is reported available. One machine, one model.

The headroom probe

MemAvailable and its equivalents answer "how much could be allocated right now". Every sizing rule here is asking something else: how much can be held and kept. On a machine whose reclaim compresses, the two differ in both directions at once. The reported figure is a floor, because the kernel will compress other processes' idle pages to make room — the test phone held 3.8 GB of pinned dense set plus cache while reporting 3.6 GB available, and reported 5.6 GB afterwards. And it is an over-promise, because that same cheap reclaim takes our pages back just as readily, which is why a model that merely fits is not a model that survives.

Two answers, and the caller chooses what to spend. The estimate is free: what the machine's own compressor is currently achieving, applied to the set the kernel would compress first. Both numbers are read from this machine's accounting; neither is a constant, and where the accounting is not readable — an unprivileged process may not read the compressor's statistics on some systems — the fact stays unknown and every rule falls back to the reported budget and says which one it used. The measurement is --probe-mem: hold memory in steps and watch for the moment the kernel takes the first of it back. It is the real answer and it is intrusive by nature, so it never runs unasked; it grows in steps and stops at the first sign of loss, so it usually never reaches its ceiling, and it releases everything on every path out.

The estimate is one rule over two facts, and each adapter that can read them supplies them: the compressor's ratio, and the anonymous memory nobody has touched lately. One machine publishes them as a compressed block device's statistics and an inactive-anonymous line; another as its host page accounting, which does not split the inactive list by what backs it, so the idle anonymous share is taken from below - the inactive pages left over if every file-backed page were among them - and is zero while the file cache is larger than the inactive list. It applies only where a reclaim compresses: where pressure ends the process, what its neighbours could be squeezed down to is not room it may take. Measured on a 16 GB Apple-silicon laptop with Qwen3.6-35B-A3B Q4_K_M at a 16384 context, while another process held 9.5 GB of idle anonymous memory: 2.7 GB reported available and 4.8 GB estimated holdable, which is the difference between no cache and a 2 GB one - 8.1 tok/s streamed uncached, 12.0 tok/s at 68% hits with the plan sized from the estimate. The idle process paid for it: swap in use went from 1.2 to 2.8 GB across the runs. On the same machine with nothing else running the two plans are the same within noise (19.3 and 19.5 tok/s over 300 tokens, twice each). A synthetic neighbour on one machine; real applications are owed.

The memory ledger

Everything the run holds for its whole length is a row in one account, and the account has two ceilings. The first is what the process may hold: the headroom above, less the margin. The second is what it may hold reclaim-exempt — memory the kernel may neither swap nor compress — and it is the smaller wherever it exists. They are different totals, and sizing against the first alone is how a plan comes to ask for locks the machine stops granting part way.

row from charged to
context and compute buffers llama.cpp's own projection for this model and context holdable
a device armed for prefill two of the model's largest expert layer, plus the compute buffer llama.cpp projects for that device (none for one it never places layers on, such as an NPU) holdable; lockable too where its buffers are
dense set the gguf holdable; lockable if it is pinned
expert cache what is left holdable; lockable for the part that can be locked in place

The order is the order of what a byte is worth. The fixed rows come off the top because nothing chooses them. The dense set is next because it is read whole on every token, and it is pinned whole or not at all: a set pinned part way is reclaimed from the part that was refused, on every token. Where it does not fit the lockable total it is held as ordinary memory and the plan says how far short it fell. The cache takes the rest, and where it can be locked in place it is sized to what can be locked rather than to what can be held — a byte past that is one the machine is free to take back.

The lockable total is two facts, not one, because the two mechanisms are not the same store everywhere. One machine offers a reclaim-exempt allocation with no published total and will lock almost nothing in place: its dense set can be protected and its cache cannot, so the cache there is ordinary memory bounded by what can be held, exactly as before. Another grants any process a lock up to a system-wide total, the same store either way. No rule names either machine; each adapter answers the two questions, and a machine that grants no lock answers zero to both.

That total is net of what is already locked, by anyone it counts. On the laptop this was measured on it is system-wide and counts the kernel's own wired memory: locking in 32 MiB steps until the kernel refused granted 10336 MiB where the limit less the system's wired count stood at 10368, against a limit of 12451. So it is read when the plan is made rather than derived from the limit, and it carries a margin of its own (lock_margin), because another process — or a device that wires its buffers — can take from it between the plan and the load.

A plan can still be overtaken, so a refusal is data, not an error. If the platform stops locking the cache part way, the cache budget steps down to what is granted and the pinning goes on, never under one token cycle. That is --cache-pin-fit, which the plan arms exactly when it sized the cache to the lock and which is off otherwise: the engine cannot tell what a reclaim costs, so a budget set by hand is never shrunk unasked, and without the flag a part-way refusal ends pinning and leaves the budget alone. Likewise if the pinned store runs out during load, the remaining dense tensors are ordinary buffers and the run continues. Both are recorded in the streamer's statistics (cache_pin_refused_bytes, dense_pin_refused_bytes), which nothing reads yet: they are the signal the next plan is to be corrected from. A platform that refuses the very first lock is not in this case, and neither is one whose total is smaller than a token cycle - a per-process limit of a few MiB grants the first slices and then nothing: both grant no lock worth having, and the cache there is ordinary memory at its full budget.

Giving up at the first part-way refusal is what the ledger was written to stop, and the mechanism is worth stating because it is not the obvious one. The refused slices are a small part of the cache; what does the damage is that every slice committed afterwards is ordinary memory too, so a cache that turns over replaces its locked slices with unlocked ones until none are left. On a 16 GB laptop, a 91 GB model at 2 bits per weight with its 6.7 GB dense set pinned and a 4.1 GB cache asked for: 0.27 tok/s. The same request with the budget stepping down instead settled at 3.2 GB and ran at 2.48, which is also what --auto now reaches by asking for 3.2 GB in the first place. A model whose cache barely turns over - 92% hits - took the same refusal without loss, which is how this stayed hidden.

The margin on the lockable total is not there to absorb a misreading, since a refusal is survivable. It is there because the platform's limit is not a place to run: what is locked is taken from everything that is not, this process's own context and compute buffers included. Same laptop, a 35B model at Q4_K_M, same cache hit rate: 23.1, 23.2 and 23.5 tok/s asking for 95% of the total, 7.4 and 14.6 asking for 98%.

What the ledger does not do yet: where not even one token cycle of cache can be locked after the dense set, it does not ask whether un-pinning the dense set to protect the cache would have been the better trade — it reports the cache as ordinary memory and stops there. And a device's cost is the model's arithmetic plus llama.cpp's projection of its compute buffer, not a measurement of what the device held.

The prefill plan

Prefill and decode want opposite hardware, so they are two placements and the plan decides them separately. A decode graph is one token wide: every weight is read once and multiplied once, and what bounds it is how fast weights can be read. A prefill graph is hundreds of tokens wide: every weight is multiplied by a whole batch, and what bounds it is how fast the engine can compute. The two rank devices in opposite orders, and a decision about one read off the other's figure is made on the wrong axis.

So the bandwidth probe runs its matmul twice, in this model's own quantized type: one token wide, which is the figure everything about decode reads, and 128 wide, which nothing but this rule reads. On the laptop this was measured on the cores read 109 GiB/s on the first against the GPU's 64 to 71, and compute 198 on the second against the GPU's 1512 to 1558: a device that loses the decode by a third wins the prefill 7.6 times over. That ratio is the one a real prefill had already shown on the same machine and model - a 1473-token prompt in 3.4 s against 25.4 on the cores (docs/npu-prefill.md) - which is what entitles a 9 MB probe to speak for it.

A device is armed when four things hold, each a fact and none a name:

  1. It was measured at prefill width. An unmeasured device is not armed; a device that will not run the wide graph keeps its zero, and zero is "unmeasured", never "slow". The plan names it with the reason, so a device that cannot run this model's format (an NPU and a 2-bit file) does not read as a device that is not there.
  2. It gave the host's answer at that width. Asked separately from the one-token check, because a backend's batched kernel is not the kernel its one-token path runs.
  3. It wins by the margin every other device decision uses (min_backend_win).
  4. It fits the ledger. Under streaming a prefill device carries a layer's experts and a layer's other weights through slots - the layer being computed and the one being loaded behind it - plus its compute buffer. That is charged as a row; the run must still stream, with a cache of at least one token cycle, and the dense set and the cache must keep the protection they had. Those are paid on every decoded token, and a prefill is paid once. A device with memory of its own costs this pool nothing and is checked against its own free memory instead.

One expert slot or two. The experts are the large part of that row and the part with a choice in it. Two slots read a layer while the one before it computes; one slot reads each layer at its own routing node, with the device waiting, and holds a layer less. The result is the same - the gates score both bit for bit - so it is a trade of prefill time for memory, and the ledger makes it: two where they fit, one where only one does, and the plan says which (--prefill-slots, which a caller can also fix). Measured on the laptop above, the same 1461-token prompt: 3.43 s with two slots, 3.94 and 4.19 s with one, the same generated text, and 498 MiB less held on a model whose layer of experts is that size. On the phone's NPU, the same prompt and the model at Q4_0: 10.2 s with two slots, 11.7 s with one, the same generated text, 448 MiB less. One slot also reads less in routed mode, since every read is made with the routing already known and none from a prediction.

Anything else in the run that cannot share a session with a device prefill - row streaming, speculative decoding, a kept prefix state - declines it with the reason, instead of producing a configuration that does not start. A device the caller named, or turned off, is left alone.

Every refusal says which of these it was and quotes the figures, and --plan prints one line per device with both measurements so the decision can be checked against them. A device the plan armed is also marked optional (--prefill-best-effort): if it cannot be set up at load, the prefill runs on the host and the model loads. A device the caller named keeps the old behaviour and fails the load, because someone who asked for a device wants to know it is not being used.

Measured end to end on that laptop, Qwen3.6-35B-A3B Q4_K_M, --auto and no other flag: the plan arms the GPU, a 1461-token prompt prefills in 3.37 and 3.48 s (433 and 420 tok/s), and the same configuration with the prefill left on the cores takes 21.2 s, so time to first token goes from 28.8 s to 13.4. It is not free. The device's row took 1.55 GB from the expert cache, and decode on a short prompt - 26 tokens, under the narrowest graph a device is handed, so the device did nothing for it - ran at 20.9 tok/s against 23.1 to 23.5 without the device. The plan cannot know how long the prompts will be; it arms the device because a prefill gain of that size outweighs a tenth of the decode rate on any prompt long enough to reach it, and says how to turn it off. On the 91 GB model the same rule refuses in under four seconds: two layers and the compute buffer are 6.1 GB there, and the cache would fall under one token cycle.

Memory in turns. The expert slots and the expert cache are never busy at once. A device prefill reads from flash into the slots and does not go through the cache; a decode goes through the cache and does not touch the slots. Held side by side for the whole session, the slots charge decode, on every token, for memory only a prefill uses - which is what the 20.9 against 23.3 tok/s above is - and they raise how much a machine must have free before the device can be armed at all.

So the slots a plan arms are held only while a prefill runs (--prefill-slots-on-demand). Before the first device graph of a prompt the cache gives up as much as the slots take, its coldest entries first, and the slots are built; after the last one they are given back and the cache has its budget again. In the ledger they are a row in turn with the cache, not in the total: they fit if the cache can yield that much, or if what is left over still fits both ceilings. What stays for the session is the rest of the device's row, the layer's other weights and the compute buffer.

It costs the re-read of what was evicted and one setup of the slots per prompt. Same laptop, same model, --auto alone: the 1461-token prefill takes 3.52 and 3.64 s against 3.43 with the slots held, the generated text is the same, and decode on the short prompt is back to 21.9 tok/s with a 7.6 GB cache where holding the slots left it 6.4 GB and 20.9. What it still gives up against no device at all is the part that stays, 556 MiB there. If the device has no room when its turn comes, that prefill runs on the host and the next one tries again.

The same rule on a phone, with nothing written for it: a 12 GB phone with a Hexagon v81 NPU, the same model at Q4_0 and at Q4_K_M. The NPU is found, measured 128 tokens wide at 40x the cores (1480 to 1520 GiB/s against 27 to 38) with the same result, and reads no faster than they do one token wide (28 against 26 to 33) - the two axes again, further apart than on the laptop. Whether it is then armed is the ledger's answer and depends on the day. With the phone in ordinary use, 2.9 GB reported available, it is refused: the dense set takes 1.6 to 1.8 GB of the 2.6 the plan may hold, the cache needs its token cycle, and one expert slot with the compute buffer is another 1.0. That is the reported figure being a floor on a machine that compresses (see the headroom probe): the same phone runs that prefill when asked by hand, 1461 tokens in 10.2 s, because the system makes the room. The plan does not count on room it has not measured.

With the slots in turn the same phone, awake and reporting 4.6 GB available, armed the NPU from --auto alone: two expert slots in turn with a 1.8 GB cache, the 1461-token prompt prefilled in 10.9 s (134 tok/s) on the NPU and decoded at 3.13 tok/s on the cores, one run. Slots built per prefill were also checked there by hand against slots held for the session: the same generated text, 10.6 s against 10.2.

A device can also be wrong about whose memory it uses, and the ledger does not take its word. The NPU measures as not drawing on the host when it allocates, and reports no memory of its own: its buffers come from a kernel allocator this process cannot see being charged. Read as "its own memory", that armed it at a cost of 0 MiB. A pool with no stated size cannot be budgeted against, so a device that claims one and gives no size is charged to the host.

The output is not the host's to the last bit. No weight is dropped or approximated - this is not one of the lossy levers, which never arm themselves - but a device's arithmetic is its own, and greedy decoding amplifies a difference at a near tie. In the run above the text after the device prefill matched the text after the host prefill for the first 369 of 541 generated characters and then chose a different word; two device runs matched each other exactly. A caller who needs the host's answer byte for byte passes an empty --prefill-device, which the plan leaves alone.

On agreement. The check is not bit equality, and it is not ours: it is the normalized mean squared error llama.cpp's test-backend-ops applies to a matmul, sum((a-b)^2) / sum(a^2) under 5e-4. What precision a backend's kernels should have is upstream's call, made for every backend it ships; a stricter private bound only refuses devices its tests accept. Ours was stricter, and did: a laptop iGPU's Vulkan prefill accumulates in half precision and differs from the host by 4.4e-4 (2.1% of the answer), the bound was then 1e-4 in this unit, and the device was reported as wrong - while the model's perplexity over a 1070-token text with its prefill on that device was 32.58 against the host's 32.46. Correct kernels on full-precision paths sit far below, at 1.6e-9 to 1.6e-7; a wrong kernel is off by the size of the answer, an NMSE near 1.

Passing the check means a device computes what llama.cpp considers the same matmul, not the same bytes, and a greedy decode can choose a different word after a near tie, as the run above shows. It does not mean the device computes the model: a synthetic matmul cannot see a fault that only the model's own activations trigger. A phone GPU's Vulkan prefill, half precision too, took the same text's perplexity from 32.4 to 711 445 - no next token right in 1070.

So the session confirms the device on the model before keeping it (--prefill-verify, on by default). After the load it prefills a fixed text on the host and on the device - 64 tokens, or the device's narrowest graph (--prefill-min-tokens) where that is wider - and keeps the device only if the top next token agrees in at least 90% of the positions; otherwise the device is handed back exactly as one that failed to open, and with --prefill-best-effort, which a plan that armed the device sets, the prefill stays on the CPU. A check that cannot run - a text the vocabulary does not tokenize, no graph as wide as the device's narrowest within what one graph may return - leaves the device as set and says so: not confirming is no evidence against it. Measured: the laptop iGPU agrees in 98% (logit NMSE 1.3e-3). The check costs three short prefills at load. It judges correctness only: it times both passes and prints the times, but at 64 tokens cold expert reads dominate, and that iGPU, faster there (6.5 s against 7.5), prefilled a real 1015-token prompt 8% slower than the host. Whether a device is faster stays the plan's estimate, which does not yet charge the expert arena's reads: it put the same iGPU at 1.5x.

The widest graph

A prompt is computed in graphs --ubatch tokens wide, and llama.cpp reserves its compute buffers for the widest of them. Left alone the width follows the context: one graph per context, which is the fastest prefill there is. The buffers grow with it, a row of activations per token, and they are memory the expert cache does not get - for a pass that happens once per prompt, against a cache that is read on every token.

Two things were wrong at once. The ledger read the buffers from the capacity fitter, which projects them at llama.cpp's own default of 512 tokens whatever the session is about to open with; and the fitter projects a vocabulary-wide logit row for every position, where a prompt reads one. So at a 2048 context the two errors roughly cancelled, and at a 16384 context the ledger charged 1.2 GB for buffers that were 8 GB: on a 16 GB laptop the cache lock was refused part way, the cache settled at half its budget, and decode ran at 14.6 and 16.7 tok/s where the same model does 23.

The projection is now made at the width the session opens with, with the row cap the session sets when a device computes its prefill (what is held is what is touched, and a prompt touches one row), and at half that width as well. The two points give what a token of width costs on this model, context and set of backends, so any narrower graph can be priced without asking again (Placement::at_width). Checked against what a session then reserved, same laptop, 16384 context: 486 MiB projected on the GPU for a 2048-wide graph against 474 reserved, 972 against 980 at 4096, 3888 against 3920 at 16384. The host's share is projected high, by up to a factor of two, which is the side that cannot overcommit.

The rule is the widest graph whose buffers stay inside a share of what the process may hold (PlannerPolicy::compute_share_max, a tenth), found by halving from the context and never below ubatch_min. Inside the share nothing changes and the knob keeps its default. A device's buffers count where they come out of this pool and not where the device has memory of its own. A width set by hand is left alone and charged in full; a slope that was not measured decides nothing.

The share is policy, and the plan prints it as such. What is measured is the trade it stands in for, on that laptop at a 16384 context, a 10850-token prompt and then a short one:

width buffers reserved prefill decode
512 0.4 GB 61.7 s 22.5 and 23.4 tok/s
1024 (what the rule picks) 0.6 GB 31.8 s 22.4 and 22.3 tok/s
2048 1.0 GB 19.8 s 23.2 and 22.6 tok/s
4096 2.0 GB 17.3 s 23.4 and 23.5 tok/s
16384 (the context) 8.0 GB 20.4 s 14.6 and 16.7 tok/s

Narrower is not free here the way it is for an engine whose weights are resident: every piece of a prompt routes its experts again and reads from flash the ones the piece before did not leave in the slots, so a prefill is a pass over the experts per piece. That is why the fixed 512 other engines use costs three times the prefill on this one, and why the width is a decision rather than a constant. The rule lands one step narrower than the table's best row, 1024 rather than 2048, because the host's projection is high; that costs twelve seconds of prefill on that prompt and is the price of a figure that cannot overcommit.

What it does not do: the capacity fitter still places weights on a device with memory of its own at the full width, so on such a machine a narrower graph leaves device memory the fitter thought was spoken for. And a session without a prefill device still reserves the logit rows the projection leaves out - untouched, so not held, but committed on a system that counts reservations.

A device is probed in a process of its own

Every probe that touches a device does so by using it, and a device is only as sound as its driver. The planner is meant to be handed a binary that carries every backend and to find out for itself what the machine has - which only works if finding out cannot end the program.

It could. On a phone from 2020, a build carrying the Vulkan backend loaded it, listed the GPU, and died at the first allocation: the backend calls a function that driver does not have, without checking, and there is no error to catch. The OpenCL backend could not be linked for the same phone at all, for two functions its driver lacks - which is why the APK ships the Khronos ICD loader as the library the backend imports, so the binary starts on any phone, while the app links the vendor's own where there is one (npu-prefill.md). Neither is a property the profile could have carried in advance; both are the driver's.

So each device beyond the host is asked in a second process first: this same executable, started again with the device's name, which runs everything that touches that device and prints its facts as one line. If the line comes back the facts are taken; if the child dies, or does not answer in its time, the device is marked unusable with the reason. Either way the probes in this process leave it alone. An unusable device is weighed by nothing and kept out of the run - the fitter is not asked about it, no layer is placed on it, and the host carries the run - because what ended the probe would end the load the same way.

The same phone, the same build: the plan now completes in 23 s, prints device Vulkan0: UNUSABLE - its probe ended the process that ran it (signal 11), keeps it out, and the run decodes at 1.87 and 2.02 tok/s on the cores with the backend's library still beside the executable. On the laptop, where the device works, the plan is the same as before and the second process costs about 3.5 s, most of it the child measuring the host again as its own reference.

Starting a second process is the front-end's business, so the probe takes a runner, built from the arguments each front-end hands probe_and_plan to start itself again (AutoPlanOptions); bmoe-cli and bmoe-server each give them (--probe-device is the child's mode in both, and the child's whole side is one call, probe_device_child; --no-isolated-probes turns it off). Where a platform has no way to start one the runner is empty and the probes run here, as they always did: that is the case on iOS. Windows has its own implementation of the child, built and not yet run on a machine with a GPU. The time limit is two minutes, and it has not been seen to fire.

What this does not do: it measures a matmul, not a prefill. The loaders that fill the device's slots from flash, and the storage under them, are not in the probe, so on a machine whose storage is the bottleneck the real gain is smaller than the ratio. And it does not arm a device where the whole model fits and nothing streams, because there the device would need a resident copy of every layer, which is the capacity fitter's question and not this one.

The storage probe

Nothing to run beforehand. The probe is part of --auto, happens once inside the load, and asks the caller nothing. It costs about a second of a load already measured in seconds - 6.3 to 6.9 on the desktop it was validated on - against a lane count worth 12% of every token afterwards. --no-probe-io opts out for a caller that must not touch the drive at all.

It reads the file the experts are in (ModelProfile::expert_file), which on a model split into shards is not the file the caller names: the first shard can be metadata alone. Read there, an 85 GB model in three files answered 101906 MiB/s from a 5 MB file the drive had in its own cache, and the plan priced a token of expert reads at 38 ms. Read where the weights are, the same drive answers 6843 MiB/s.

It measures the rate curve around the model's own expert slice, at one, two and four lanes, taking the median of three samples at the size that decides anything, and picks the largest lane count that reaches within 5% of the best rate. It also measures the price of a refault — page-sized scattered reads, one lane — because that is what a group left mapped costs once pressure has dropped its pages, and it is not a fraction of the sequential rate but a different order of magnitude: 24 MiB/s against 2800 on this desktop, 116x. Without that term, leaving a group mapped looks nearly free and the plan under-spends on residency by two orders of magnitude.

Both are read the way the engine reads, at granules the machine states rather than at 4096. The reader asks the file it opened for its direct-read alignment (pio::direct_alignment: the kernel's statx answer on Linux and Android where there is one, the filesystem's block size otherwise, the volume's sector sizes on Windows) and cuts its windows to that; the probe reports it as storage.align, or 0 where the platform did not say and the reader fell back to 4096. A refault is read one VM page at a time, because that is what the kernel faults in: 16 KiB on Apple silicon, 4 KiB on most Linux and Android kernels.

The decisive sample is sized in bytes, not in requests

The sample at the point that decides something used to be a fixed request count, which makes its duration shrink as the storage gets faster: 160 requests of 664 KiB is 106 MiB, about 40 ms on this NVMe, and a 40 ms measurement on a general-purpose OS reports scheduling noise. Three consecutive runs on the same machine answered 1116, 1526 and 2583 MiB/s, and the plan's predicted ms/token moved with them by more than 2x — which would have poisoned the prediction-versus-measurement loop before it could correct anything.

Sized by bytes instead (512 MiB per sample), the same three runs read 2832, 2845 and 2861 MiB/s and predicted 202, 206 and 197 ms/token. The probe now also records its own spread across repeats, and the plan quotes it when it is wide: a rate that is not repeatable makes every number downstream of it unrepeatable too, and a reader comparing two runs deserves to know that before concluding anything from them.

Largest, not smallest, and that is a correction the machine forced. The rule used to take the cheapest count inside the tolerance, on the reasoning that where two lane counts deliver the same throughput the cheaper one is strictly better: fewer threads, less queueing, less contention with the compute the reads are meant to overlap. Sound, and wrong. On the desktop SSD here the probe cannot separate two lanes from four - repeated runs pick either, medians and all - while the engine is not ambiguous at all: three interleaved 64-token runs give 4.648 tok/s at two lanes against 5.261 at four, +13%, with no overlap between the groups.

So a lane buys something this probe does not measure. What it reads is aggregate throughput; what a streamed decode also spends is latency, waiting for the slice the next expert needs, and a queue that drains sooner ends the stall sooner even when the bytes per second come out the same. Until that is measured directly, the tie-break follows the evidence rather than the principle: inside the probe's own resolution, more lanes. With that, the pick is stable across runs.

What it will not tell you

The same probe tries to answer whether a live mapping of the model serialises concurrent uncached reads, by reading with a mapping alive and again after releasing it. It reports Yes or nothing, never No, and that is a deliberate limitation rather than an oversight.

On the desktop where the engine gains 24% of decode from --release-mmap — its own read rate goes from 871 to 1680 MiB/s — this probe sees the two arms within 2% of each other. Whatever it is failing to reproduce (most likely the access pattern: it reads uniformly at random where the engine walks expert slices layer by layer against a warm cache), its fidelity is established in the positive direction only. A negative from an instrument that missed a known positive is not evidence of absence, and printing one as measured would be exactly the confident wrong answer this design exists to avoid. So the plan prints the two rates, says the probe saw nothing, and tells you to measure --release-mmap yourself.

A rule that a machine took back

Threads from core classes: proposed, shipped, refuted, withdrawn — in a day. The rule set the count to the fast class, because every thread meets the same barrier and one on a slower core sets the pace rather than adding to it. The first heterogeneous machine it met disagreed: a phone reporting two prime cores and six others got two threads instead of four, decode compute went 0.127 → 0.195 s/token and throughput 4.24 → 2.68 tok/s. Half the threads cost more than the imbalance saved, and nothing in this planner knows where that trade turns over.

What survives is the fact. The classes are measured, they are printed, and the knob keeps its default — because a count derived from them would need a thread sweep on the machine itself, which is a probe this does not have. The same reasoning retired the prefill count at the same time: it is plausibly helped by every core there is, and plausibly is precisely what does not ship here.

It is worth writing down that this is the second principle in two days to be overturned by a measurement rather than by an argument — the lane tie-break was the first. Both were sound. Neither was true.

Where a rule is still cruder than the fact it stands in for

The "fits" exit asks for air, and the ratio is still policy. The case is the phone, and it is the common one rather than the exotic one: an 8B-class MoE that fits in 12 GB just barely. It fits on paper. In practice the system, the app and the kernel's own page cache sit on top of it, and what the last few hundred MiB go to is decided by whoever touched memory last — so a resident model is reclaimed from underneath every few tokens and refaults its dense set from flash a page at a time. Streamed, the same model holds a pinned dense set and a cache that chooses what to keep, and decodes faster than the "fully resident" version that keeps losing itself. The measured case is stark: the 35B fully resident through mmap decoded at 0.1 tok/s where streaming it decoded at 5.0. Fitting is not the same as being left alone.

So the exit now asks a second question. Where a reclaim is cheap for the kernel — it compresses the page, or drops a clean one — residency must leave room beyond the model itself, fits_air_ratio of what it would hold, or the experts stream and the plan says why. Where a reclaim has to write to a disk the kernel is far more reluctant and the classic host offload stands; where the allocation is exempt nothing can take it; where the limit is a hard per-process cap nothing is taken at all, and the margin for that case is already the largest of the three. An unprofiled machine keeps the old behaviour, like every other missing fact: the evidence is specific to cheap reclaim and does not entitle a rule to generalise past it.

That ratio is policy, not measurement, and it is deliberately crude because the error it guards is asymmetric by two orders of magnitude — streaming a model that would have fitted costs some reads, residency on one that does not costs fifty times the throughput. What narrows it is the headroom probe above, which the air test already reads through the budget: where the headroom is measured, the air being counted is real spare capacity rather than an accounting figure, and only the multiplier is still policy. The same measurement is what stops the budget being read as a floor on a compressing machine, which is why the two were one piece of work rather than two.

What is not probed yet

  • A machine with a device to try it on. The bandwidth probe runs on every backend that registers, and the routing that acts on its answer is in the session. Neither has met an accelerator: this machine registers only the CPU, so the probe measures the host's figure, the rule finds nothing to compare it against, and the plan says so. The mechanism is complete and unverified, which is a different thing from missing, and the plan distinguishes them.
  • The width rule on any machine but one. The widest graph is now chosen (see "The widest graph" above), and every figure behind the rule comes from one 16 GB laptop with a GPU that shares its memory. Owed, each on its own hardware: a phone with an NPU, where each prefill piece re-reads its experts through a slower store and the holdable figure is a quarter of the laptop's, so the rule will choose a narrower graph than the one a prefill was measured at there; a machine with no accelerator at all, where the prefill is the cores' and its cost per piece is not the device's; a discrete GPU, whose buffers are not this pool's and are left out of the share; and a system where reserved memory is committed whether or not it is touched, where the logit rows this projection leaves out are charged all the same.

The residency budget is MemAvailable on every platform that reports it, and on a machine whose reclaim compresses that is a floor rather than a cap: the phone held 3.8 GB of pinned dense set plus cache with 3.6 GB "available", because the kernel compressed other processes' idle memory to make room, and reported 5.6 GB available afterwards. The honest budget there is the compressible headroom, which is estimated where the machine publishes the two facts behind it (see The headroom probe). Where it does not - the compressor's statistics are not readable to an unprivileged process on some phones - the reported figure stands: an unpinned plan under pressure there streams with the cache off and says why, and a pinned --cache-mb wins over the floor.

One caveat on the budget itself: it is a one-shot reading of a quantity that moves. The same machine and model planned twice minutes apart produced 4.3 GB and 8.2 GB of available memory, and therefore two different cache budgets. The plan quotes the number it used, which is what makes the difference visible rather than mysterious.

Compute devices are enumerated through ggml_backend_dev_* and measured, and one rule reads them: the choice of the prefill device. No rule moves decode onto one. A device whose memory is host memory frees nothing when a tensor moves off it, which is why the capacity tier is nearly inert on an integrated GPU and only the bandwidth tier is left.

Architecture

The logical flow, as implemented. Diamonds are decisions; the two bracketed boxes in the session are designed and not yet active.

 INPUT
   model.gguf              machine                  caller
   (never loaded)          (as it is right now)     (flags typed by hand = PINS)
        |                      |                         |
        v                      v                         |
 +--------------+   +--------------------+               |
 | MODEL PROBE  |   | MACHINE PROBE      |               |
 | experts/layer|   | available memory   |               |
 | slice, token |   | overflow: compress |               |
 | cycle, dense,|   |  / swap / kill     |               |
 | MTP, tied    |   | reclaim-exempt     |               |
 | head         |   | core classes       |               |
 |              |   | host bandwidth     |               |
 |              |   | devices: host buf? |               |
 |              |   |  runs our layout?  |               |
 +------+-------+   +---------+----------+               |
        |                     |                          |
        |           +---------v----------+               |
        |           | HEADROOM PROBE     |               |
        |           | free: compressor   |               |
        |           |  ratio x reclaimable                |
        |           | --probe-mem: hold  |               |
        |           |  until pages go    |               |
        |           +---------+----------+               |
        |                     |                          |
        |           +---------v----------+               |
        |           | BANDWIDTH PROBE    |               |
        |           | one GEMV, every     |               |
        |           |  backend: host vs   |               |
        |           |  device, same units |               |
        |           +---------+----------+               |
        |                     |                          |
        |           +---------v----------+               |
        |           | STORAGE PROBE ~0.6s|               |
        |           | rate(size, lanes)  |               |
        |           | O_DIRECT ok?       |               |
        |           | mapping serialises?|  <- Yes or Unknown, never No
        |           +---------+----------+               |
        v                     v                          v
 ==================================================================
 | STAGE 1 . llama.cpp CAPACITY FITTER        (VRAM -> RAM)       |
 | per layer: attention -> up -> gate -> sparse experts last      |
 | on every backend ggml sees                                     |
 | in:  context pinned by us (never 0 -> "model max")             |
 | out: n_gpu_layers . split . overrides . KV+compute reservation |
 ================================+=================================
                                | Placement:
                                |  which layers keep experts on the HOST
                                |  what stays resident on the host
                                v
        <> everything on devices?  -- yes --> done: nothing to stream
        | no
        v
        <> host residual FITS in RAM? -- yes --> experts resident,
        | no                                      no streaming
        v                                         (= -ot exps=CPU)
 ==================================================================
 | STAGE 2 . PURE PLANNER                     (RAM -> flash)      |
 | 0 llama.cpp symbols . 0 platform names                         |
 |                                                                |
 |  DENSE policy  <-- overflow + reclaim-exempt store + who bills |
 |      compress & dma-buf         -> pinned (ahwb)               |
 |      kill & file pages uncounted -> mmap                       |
 |      otherwise                  -> anon                        |
 |                                                                |
 |  CACHE budget = usable - host dense - fitter reservations      |
 |      cap: only the expert share left on the host               |
 |      <> >= token cycle?  no -> cache OFF, stream uncached      |
 |                            (no read rate measured -> DECLINE)   |
 |                            (caller pin -> proceed + warning)    |
 |                                                                |
 |  I/O   <-- curve: smallest lane count within 5% of peak        |
 |        <-- O_DIRECT verified on the path                       |
 |        <-- mapping release: only if measured Yes AND the shape |
 |            is safe (head not tied, MTP not in use)             |
 |                                                                |
 |  LOSSY = off. Always. Unless the caller armed it.              |
 ================================+=================================
                                | Plan = RunConfig + rationale
                                | every line: [measured|derived|policy|operator|unprobed]
                                v
 +----------------------------------------------------------------+
 | SESSION                                                        |
 | loads with the fitter's n_gpu_layers + overrides               |
 | dense -> anon | dma-buf | mmap                                 |
 | host experts -> streamer: LRU cache, lanes, mul_mat_id overlap |
 | experts -> planned device's HOST buffer type at LOAD          |
 | (but the streamer then rebinds onto its own reservation)      |
 | [NOT ACTIVE] prefill on the device over streamed experts       |
 +--------------------------------+-------------------------------+
                                  v
 +----------------------------------------------------------------+
 | llama.cpp + ggml (stock submodule)                             |
 | CPU . CUDA . Metal . Vulkan . ROCm . SYCL . OpenCL             |
 +----------------------------------------------------------------+

 OUTPUT: tok/s + the printed plan (--plan-explain) + the plan in the CSV header

Three things to read off it. One knowledge boundary: above the probes there is no platform name; below them, public API plus common/fit.h in one file. Two stages in sequence, not merged: the fitter answers how much fits where (exact, computed), the planner answers how to read what does not fit (measured, or declared unknown). Four early exits, and every one of them is a plan: a dense model, everything on devices, a host residual that fits, a budget under the token cycle on a drive whose read rate was never measured. None is an error; each says why.

Levers llama.cpp already has that this planner does not use yet

Each row is a gap here paired with the public facility that closes it. None needs a fork.

gap facility what it would allow
streamed experts computed on a device rather than on the CPU cores not a buffer-type override — that route is closed, see below. The open one is ggml_backend_dev_buffer_from_host_ptr on a backend that can wrap memory the caller already owns, the streamer's reservations could be handed over and the device could execute out of them. Who implements it is not what caps.buffer_from_host_ptr says — see "A capability that lies by omission" below
prefill on the device over streamed experts (built as --prefill-device, see npu-prefill.md; the planner does not arm it yet) cparams.op_offload, offload_kqv, the scheduler's batch threshold (ggml_backend_dev_offload_op) per-op copy of host weights to the device above a batch size: the "batch amortises bytes over link bandwidth" rule
no thread rule — in, from core classes rather than a core count n_threads_batch distinct from n_threads, llama_numa_init still open: different counts for decode and prefill, and NUMA on workstations
KV and flash attention undecided flash_attn_type (auto), type_k / type_v, kv_unified, swa_full KV memory as a budget line instead of an ignored one
device selection mparams.devices[], split_mode, main_gpu tell the fitter which devices to use, e.g. exclude one that demands a repack
a unified-memory device looks discrete — no longer decides anything the bandwidth probe, not the integrated flag what the flag would have told us cheaply is now measured directly, and measured beats reported: the rule compares the device's figure to the host's on the same graph
the budget under a compressing reclaim — in (--probe-mem, and a free estimate always) nothing in llama.cpp: a kernel fact the headroom probe stays ours, and is the one measurement no other engine takes

DGX Spark, as a worked case

128 GB of unified LPDDR5x at roughly 273 GB/s, twenty ARM cores, a Blackwell GPU, models streamed from NVMe: exactly the experts-stream regime, and the machine upstream's own expert streaming PR benchmarked on. This planner used to misread it twice over: ggml reports the GB10 as a GPU with memory of its own, because the CUDA backend's integrated flag is disabled, and the profile called a weight rebindable only where the memory was the host's — so every expert matmul went to the ARM cores with the GPU idle. The reading is fixed: rebindability is asked of the device, and the integrated flag is not needed for anything any more, because what it would have hinted at is now measured directly — the same GEMV on every backend, and the rule compares the rates.

But the route everyone reaches for first is closed, and the evidence is in the pinned submodule rather than inferred. Binding the expert overrides to a device's host buffer type:

  • src/llama-model-loader.cpp carries the comment "avoid using a host buffer when using mmap" and substitutes the CPU's buffer type for any device host buffer whenever the model is mapped. This engine always maps it — load-bearing, not a setting — so such an override is undone at load and nothing downstream ever sees it.
  • ggml/src/ggml-cuda/ggml-cuda.cu's supports_buft accepts the CUDA host buffer only on an integrated device. On a discrete one no device claims the buffer and the op lands on the CPU. With integrated disabled upstream, a unified-memory GB10 answers as discrete: the machine where this would pay is the machine that refuses it.

And a third reason that is ours alone: the streamer rebinds data onto memory it reserved itself, so the bytes a device would read were never allocated by it or registered with it.

The route that is actually open is ggml_backend_dev_buffer_from_host_ptr, which wraps memory the caller already owns in a buffer of the device's own; CPU, BLAS and Metal advertise it, and llama.cpp uses it itself to hand Metal the mapped model region. Who else implements it is a longer story — see the next section. For us it would mean the streamer's per-layer reservations being wrapped that way, which trades against the lazy commit they exist for: a wrapped or page-locked range needs its pages present, and a full-size reservation with pages committed on a miss and released on eviction is exactly what lets a 150 GB model have valid addresses everywhere while holding two. CUDA's nearest equivalent, cudaHostRegister via ggml_backend_cuda_register_host_buffer, is behind an environment variable upstream and accelerates transfers rather than moving where an op runs.

So the axis, stated honestly: on Apple-style unified memory a path exists and is unbuilt; on discrete CUDA there is no path through today's public API; on a unified CUDA device the path exists in principle and upstream reports the device in a way that refuses it. The planner says as much, names the device that would qualify, and arms nothing.

A capability that lies by omission

caps.buffer_from_host_ptr is the field this planner used to read as the answer to "can this device wrap memory we own". It is not that answer, and reading it as one produced a wrong fact about the backends that matter most here.

In the pinned submodule, Vulkan and SYCL each report the capability false while wiring a complete implementation into their device interface, and OpenCL wires one that declines. Vulkan's is not a stub: it imports host memory through VK_EXT_external_memory_host, refusing only when the extension is absent or when the pointer and size are not multiples of the device's minImportedHostPointerAlignment. That is precisely why the capability is false — it is a blanket promise, and a backend whose support is conditional per device and per call has nothing blanket to promise, so it promises nothing. CUDA, by contrast, leaves the interface pointer NULL: there the false is a genuine absence.

Two consequences, and the second is why this is not simply fixed.

A false is not evidence of absence, so the probe no longer records one. true maps to Yes and everything else to Unknown, which is the same asymmetry the mapping-serialisation probe already carries and for the same reason: an instrument that returns a negative on a case known to be positive has fidelity in one direction only. The plan prints the unknown and says the advertisement decided nothing.

And it cannot be settled by trying. ggml_backend_dev_buffer_from_host_ptr calls straight through to the interface pointer, unlike its neighbour ggml_backend_dev_host_buffer_type, which null-checks and returns nothing. So an attempt on a device that truly lacks it dereferences null rather than declining, and there is no way from outside to tell the two cases apart first. The instrument that would close this is a three-line null guard upstream, matching the function directly above it — a contribution, not a fork, and the one thing that would turn this Unknown into a measurement.

This matters beyond tidiness: Vulkan and OpenCL are the GPU APIs on the phones this project targets. The route we described as open only on Apple may be open on an Adreno or a Mali too, and the old reading of this field would have reported it closed without ever asking.

The measurement to beat is public: on the same GB10, llama.cpp's own expert-streaming PR reports 0.87 tok/s of decode with the experts on the CPU against 2.20 with them on the GPU, and 1.06 against 5.69 in prefill. The counter-argument — that shared memory means both are bound by the same DRAM, so the move buys nothing — has no measurement behind it anywhere, and the Apple evidence runs the other way: token generation there peaks at about six CPU threads and gets slower with more, which is what saturating below the fabric's limit looks like.