Proposal: a self-sizing (dynamic) expert cache - #103
Open
Helldez wants to merge 1 commit into
Open
Conversation
Reframes the entry around the general intent: the budget is a static decision today and neither the model nor the device gets a say after load. Records what a dynamic one would size from, and why available RAM is not it. No behaviour change.
Helldez
force-pushed
the
docs/cache-sizing-in-token-cycles
branch
from
July 26, 2026 07:51
8647aca to
c74618f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Proposal only — no implementation. The diff is one roadmap entry; this description is the
actual content. Opening it to settle the intent before writing any code.
Intent: the expert cache should size itself
The cache budget is a static decision today.
--cache-mb autoreads available RAM once at loadand holds that number for the whole run; every other path is a constant somebody picked by hand.
After load, neither the model nor the device has any say. All three of those are wrong in ways that
are visible in data already committed here.
The same number means different things per model. The budget that matters is not MiB, it is how
many token cycles it holds — a cycle being what one token routes across every layer,
TD = k × Σ entry_bytes(il). At a fixed 3000 MiB:Three times the cache, same setting. TD is arithmetic over the gguf and is known at bind — predicted
1814 against a measured
token_demand_MiB=1815.4on gpt-oss — and nothing currently uses it.The same number means different things per device, and over time. A budget that is healthy on
one phone loses the dense weights on another, and the same run degrades as the device warms: at a
fixed 3000 MiB, one committed 1350-token run goes 4.52 → 2.17 tok/s while faults rise 20×. A value
chosen once at load cannot see any of that.
And it is sized from a signal that does not predict the cost. Across the 38 committed runs with
per-token telemetry, the rank correlation between
mem_available_mibandmajfltswings from−0.49 to +0.64 depending on the run. Two other signals do track it —
dense_resident_fracandswap_mib:Within a run, faults against tok/s on 100-token windows give −0.71 / −0.62 / −0.57 on the three
long runs.
What the routing traces contribute
Replaying the committed traces (
docs/bench-data/2026-07-15-route-trace/) throughscripts/route-replay.py, with budgets set to exact multiples of TD:Two things a dynamic policy can build on. The cliff below one cycle is exactly 0.0 % on all three
models, offline — it is LRU against the deterministic layer visit order, not a device artifact —
so it is a hard boundary, not a tunable. And there is no knee: marginal gain decays smoothly
(gpt-oss +17.6, +13.0, +11.5, +8.0, +4.0, …), so any "stop at N cycles" rule would be an invented
constant. An earlier draft of this proposal used 4 cycles; on Qwen that would have left 15 points
of hit rate on the table.
A potential algorithm
Sketch, not a specification. The point is that every term is either derived or measured, so no
constant is tuned per model or per device.
Constraints it is built to respect:
construction (gate S3).
kanddrop_cold_fracchange the output and stay exactly where the userput them for the whole session.
set_cache_budget()is a between-turns operation and theproposal keeps it that way. This is a real limitation: intra-run degradation is seen only at the
next turn.
phone it does not and growth stops. Same code.
Why this is not the retired governor
--cache-dynamic(gov2) was measured a net loss and removed (docs/pressure.md). Two differences:it chased
MemAvailable, the signal shown above to be noise; and it resized continuously, so ashrink could land inside the cliff, where you pay all the RAM and all the management for a 0 % hit
rate. Quantising to whole cycles — with the last step down going to zero rather than to TD−ε —
removes that failure mode.
Open questions
wins by 20–30× on the ms-saved / ms-lost balance; the only zeros are budgets below one cycle.
It is entirely possible that a correct floor captures nearly all the available win and the growth
loop is not worth its complexity. That would be the good outcome.
in the 1395-faults/token run, but that run does 1.877 tok/s against 3.066 for the same
configuration when clean — a 5–10× larger real penalty. Reclaim hurts beyond the faults it counts.
major_faults()returns 0 anddense_resident_fracis −1 on Windows by design (
core/src/io/platform_io.cpp:126-130). Worth notingautopickedbudgets between 5280 and 7420 MiB for the same model on the same machine in one afternoon, and
that spread confounded the lane comparison in the desktop campaign.
need a short
--route-tracerun each before their curves can be checked against the rest.If it goes ahead
Three separable pieces, increasing risk:
{0} ∪ [TD, ∞), replacingcache_min_mb. Closes the cliff, which ishow the current default fails silently. Useful on its own, and static.
scripts/route-replay.py).A/B before anyone believes it.