Commit graph

26 commits

Author SHA1 Message Date
Raffaele
a874b6f6c8
feat(prefill): the planner chooses the prefill device, and its slots take turns with the cache (#219)
The planner chooses where a prompt is computed, and what that costs the
decode in memory.

The prefill device. The planner measures the model's matmul at prefill
width on the host and on every device - the shape of a prefill, which
ranks devices in the opposite order to a one-token decode - and arms the
fastest one that reproduces the host's result, wins by a margin, and whose
slots still fit the memory ledger. Every refusal names its reason and its
figures. A device the plan armed is best-effort: if it cannot be set up at
load the prefill runs on the host.

One expert slot or two on the device, chosen by the ledger: two where a
layer can be read while the one before it computes, one where two do not
fit.

Slots in turn with the cache. The expert slots and the expert cache are
never busy at once, so the slots a plan arms are held only while a prefill
runs; the cache yields its coldest entries for them and gets its budget
back afterwards.

A device with memory of its own and no stated size is charged to the host.

Measured on a 16 GB Apple-silicon laptop, Qwen3.6-35B-A3B Q4_K_M, --auto
alone: a 1461-token prompt prefills in 3.4 s against 21.2 s on the cores.
2026-10-13 19:54:49 +02:00
Raffaele
3170385fad
feat(prefill): the NPU prefill reads only routed experts (+ --decide-probe); 0.28.0 (#208)
The NPU prefill's expert arena read every expert of every layer ahead of its
routing. It now reads, ahead of a layer's routing, the experts the previous
graph routed there, and at the routing node whatever the routing adds. The
matmul reads only routed experts, so the output is bit for bit the same. A layer
routing more than --prefill-routed-full (0.85) of its experts gets the next one
read whole; --no-prefill-routed restores whole layers everywhere.

Phone, Hexagon v81 NPU, top-4, same session, every answer identical:
Qwen3.6-35B-A3B Q4_0 7.68 -> 4.16 s, Q4_K_M 9.95 -> 5.37 s, Gemma 4 26B-A4B
Q4_K_M 6.69 -> 3.70 s, Nemotron 3.5 30B-A3B Q4_0 7.42 -> 6.81 s.

Also: --decide-probe (experimental per-decision expert usage and layer-exit
answers), BMOE_DECIDE prefill_dev_* counters, gates G17f/G17g, app 0.28.0.
2026-09-29 16:08:23 +02:00
Raffaele
1d37057fac
feat: --decide, choose from a list from one prefill, with no decode (#201)
Some checks failed
ci / changes (push) Has been cancelled
ci / host-windows (push) Has been cancelled
ci / host-macos (push) Has been cancelled
ci / android-apk (push) Has been cancelled
ci / format (push) Has been cancelled
ci / host-linux (push) Has been cancelled
A session opened with --decide answers which of a list of choices the model would pick, read from
the next-token distribution after one prefill, with no decode. The state after a shared prefix is
kept and restored when the next prefix extends it. Android app: a Choose from options switch.

With --prefill-device, a decision is prefilled by the chat turn's placement rule and keeps no
prefix state: llama.cpp saves a sequence through KV views that do not follow the moved model state
(gate G18g). Also fixes the engine version, stuck at 0.23.0 since 0.24.0. App 0.27.0 (42).
2026-09-28 12:01:25 +02:00
Raffaele
10af539a63
feat: prefill on the NPU, decode on the CPU (--prefill-device), in the release APK (#200)
Wide prefill graphs run on the Hexagon NPU through a two-layer arena streamed from flash; decode stays on the CPU. The app offers it in its own NPU section, off by default, Snapdragon only. A missing or unopenable device leaves the run on the CPU. release-apk builds the Hexagon backend and a skel per NPU generation in a separate, secret-free job. Bundles the llama.cpp bump to bmoe/expert-ready-hook-2609 (K-quants on the NPU). App 0.26.0 (41).
2026-09-28 09:52:46 +02:00
Raffaele
50d0a38338
feat(moe): Nemotron 3.5 (nemotron_h_moe) and Ornith 1.5 support (#203)
nemotron_h_moe is the third expert layout: gate-less. Each expert is
up, ReLU^2, down, so the registry row names ffn_up_exps and
ffn_down_exps and leaves the tail slot empty, as the fused gemma4 row
does. The Mamba2/attention blocks, the shared expert, the optional
latent projections and the MTP block all stay on the resident side of
the seam. No llama.cpp change and no submodule bump: the pinned tree
already builds nemotron_h_moe.

make-tiny-moe.py learns the whole shape in miniature (hybrid stack,
latent projections, biased sigmoid router, shared expert, a trailing
MTP block that is never loaded), and it runs as a third byte-identity
gate. Every identity gate passes on it.

The architecture never puts two MoE blocks next to each other, so the
forward predictors (predict-prefetch, route-ahead, the stale half of
predict-log) have no next layer to target. The gate reads that from the
file and reports those checks N/A instead of failing or passing them
vacuously; an unreadable file keeps them strict.

Ornith-1.5-35B-A3B is qwen35moe and needs no engine change. Both models
join the Android catalog at Q4_K_M; neither has device numbers yet.

Also releases 0.25.0: versionCode 40, versionName 0.25.0, dated changelog.
2026-09-28 09:34:42 +02:00
Raffaele
4334c89616
feat(moe): cache-aware expert substitution (--expert-substitute, experimental) and --ppl (#171)
Some checks are pending
ci / changes (push) Waiting to run
ci / format (push) Blocked by required conditions
ci / host-linux (push) Blocked by required conditions
ci / host-windows (push) Waiting to run
ci / android-apk (push) Waiting to run
Experimental, off by default. Before a decode routing is committed, every
expert already in the LRU cache gets its score raised by L times the
token's score range and the top-k is taken again, so a near-tie goes to
the expert already in RAM (Skliar et al., arXiv:2412.00099). The same
number of experts runs; fewer are read from flash. Scores are read from
the tensor the graph itself sorted, exact for any gating function.

Desktop, Qwen3.6-35B Q4_K_M at L=0.15: 258 to 119 MiB of flash per
token, 2.37 to 3.84 tok/s, perplexity +1 to 4 %, tinyMMLU 88 to 84/100,
HumanEval-50 42 = 42. The on-device A/B is still owed, hence experimental.

Also: --ppl / --ppl-step / --ppl-list / --ppl-choices (teacher-forced
perplexity, one token per decode so cache-dependent policies are priced
where they act), scripts/tinymmlu-bench.py, scripts/humaneval-bench.py,
gates G8d/G8e, app switch "Prefer cached experts" under Experimental,
docs/cache-aware-substitution.md.
2026-08-29 10:13:47 +02:00
Raffaele
9153dcc27a
feat(moe): serve row-gathered dense tables from flash (--row-stream) (#180)
Dense tables the graph only gathers rows from (the token embedding, on
most models) are bound to reserved address space and fetched in 16 KiB
slabs inside a bounded LRU window, instead of being read whole and kept
resident. Which tables qualify is decided from the captured graph, not
from a name list. Byte-identical to the resident reference; -497 MiB
pinned on Qwen3.8-Flash-Next and -515 MiB on Qwen3.6-35B on the 12 GB
test phone, throughput neutral, off by default. Gates G15a/G15b.
App 0.24.0 (unreleased), new page docs/row-gathered-tables.md.
2026-08-29 10:04:02 +02:00
Helldez
4d1508a9aa
test(gates): cover the speculative decode loop and route-ahead (#152)
Both features shipped in this release with no runtime coverage at all: validation tests only. That is the same shape of hole that let a zero-copy corruption reach the edge of a merge with five green checks behind it.

G13 gates the speculative loop through the n-gram source. Abstention is forced by requiring a match longer than the whole generation, so the plain path is taken by construction and the identity is structural rather than lucky; the first draft of this gate left the matcher at its default, expected a synthetic model to repeat nothing, and it drafted anyway. A third cell then turns drafting on and asserts the machinery survives, since a batched verify may legitimately order a near-tie differently and there is no reference output for that.

G14 gates route-ahead in two halves. A horizon past the last layer can override nothing, so the run must be byte-identical: that covers the plumbing without touching the lossy part. A horizon of one must commit real routings and still generate, with the override count asserted so an inert policy cannot pass vacuously.

G11 and G12 are reserved for the zero-copy branch and left unused here, which is the collision this file's index now exists to prevent.

README: the features section is one row per setting, giving the name the app uses, the flag and its accepted values. The benchmark tables all take the same shape, so their widths stop depending on how long a configuration description happened to be.
2026-08-02 02:11:28 +02:00
Helldez
ad13038ce0
feat(moe): --io-two-wave, publish a layer's read batch in two waves (#128)
A cold layer's batch became visible to the I/O lanes only after every
miss took its page commits - up to three vm_commit syscalls per cold
expert of bookkeeping sitting in front of the first byte of I/O, which
is the latency-to-first-slice the sidecar refutation (PR #90) identified
as the binding constraint. (#118)

With the flag on, only the first present projection - the one
mul_mat_id blocks on first - is committed up front; its jobs publish
and wake the lanes immediately, and the remaining projections are
committed and appended while the lanes already read.

The drain protocol grew the one thing this needs:

- io_drain copies each job out under the lock, so jobs_ growing (and
  possibly reallocating) mid-batch cannot leave a worker holding a
  dangling reference;
- the worker wait predicate admits next_idx_ < batch_njobs_, so a
  worker that drained wave one and left comes back for a batch that
  grew in the SAME generation - the gen comparison alone never would;
- a wave-two commit failure goes fatal and wakes the ready waiters,
  because wave one already published flags this batch will never flip.

Batch completion cannot fire between the waves: the only thread that
waits on done_cnt_ == batch_njobs_ is the eval thread, and it is the
one appending wave two.

Overlap + LRU cache only (validate() enforces both); recorded in the
CSV preamble as io_two_wave. Default off: the win is bounded by the
commit cost per cold layer, and the failure mode of a drain-protocol
bug is a hang the host cannot reproduce - so a new gate (G4d) holds
two-wave output byte-identical to serial streaming, and the flag stays
off until the on-device A/B (#120) says the win is real.
2026-07-28 15:46:52 +02:00
Helldez
9e04e04d53
feat(moe): measure how predictable expert routing is, then act on it — and refute it (#101)
* feat(moe): --predict-log, measure how predictable expert routing is

Temporal prefetch was built on a predictor nobody had priced. The
previous-token bet turned out to be right ~38% of the time on Qwen and
~18% on gpt-oss, which cannot pay for the reads it speculates. The
lesson was not that prefetch is impossible but that a predictor should
be measured before it is wired into anything. This adds the instrument,
not a policy.

--predict-log ranks each layer's experts a layer early, by running the
NEXT layer's router matrix on the CURRENT layer's gate input. The
residual stream barely moves between layers, so the stale input ranks
nearly as the real one will -- the mechanism FATE (arXiv 2502.12224)
reports 78.8% for. It is training-free and changes no model: the router
matrix is a dense weight already resident, and the prediction is one
GEMV per layer. The matrix is learned from the graph (the gate matmul's
first source) rather than looked up by tensor name, so no architecture
is named anywhere in the path; being a weight leaf, its pointer stays
valid into layers the current token has not reached.

Three predictors are scored against the routing the router actually
produced, so they are comparable on one run: the stale gate, the
previous-token bet --prefetch already places, and a zero-staleness
control. The control is the load-bearing part. It shares every line of
code with the prediction under test and differs only in using the
layer's own matrix, so it must reproduce the selection llama.cpp
computes from those same two tensors. A transposed matrix, a mis-strided
row or the wrong token of the batch collapses it toward chance while the
stale figure would stay superficially plausible; an architecture that
selects by something other than raw-logit ranking (an additive bias,
group-limited routing) puts it below 100% and by that much the stale
figure understates the method. The CLI says so rather than letting the
gap be blamed on staleness.

Reported per layer as well as in aggregate, because an aggregate
flatters a prefetch: what a prefetch costs is set by the layers it gets
wrong, and a MoE model's first layers route far less predictably than
its last. Denominators are printed per predictor -- the stale gate
structurally cannot rank layer 0 (nothing precedes it) or the first
token of a run, and those routings are counted as unscored rather than
folded in, since a routing that was not ranked is not a wrong guess. A
predictor with no routings at a layer prints "-", never 0.0.

Diagnostics only: nothing it computes reaches load_layer, the cache or
the graph, so a probed run reads exactly the bytes an unprobed one does.
G9a gates that byte identity and G9b gates the control, which reads
100.0% on the tiny model. It is not free -- one isolated node and two
GEMVs per layer on the eval thread -- so a probed run is not a benchmark
run, and it requires --moe-stream since routing does not depend on how
the weights reached memory.

docs/expert-prediction.md also records the caveat the number will need:
a high score would say the routing is knowable earlier, not that knowing
it earlier makes decode faster. On a flash already saturated, starting a
read sooner adds no bandwidth -- which is why prefetch, layer-LFU and
the expert sidecar all lost despite improving the metric each was
designed around.

* feat(moe): --predict-prefetch, speculate on the stale-gate prediction

The probe said the routing is knowable a layer early (~89% of routed
slots on a 128-expert model, vs ~43% for the previous-token bet the
temporal prefetch acts on). This wires that prediction into the existing
speculative read path -- same cache buffers, same accounting, same
settle, same moe-prefetch summary line (tagged [stale-gate]) -- so the
only thing that changes is which guess rides the idle lanes.

Two decisions carry the design:

Speculation is issued AFTER the current layer's load, not at prediction
time. Every load path begins by quiescing speculation, and a layer's own
load sits a few graph nodes after its gate matmul -- reads queued at
prediction time would be cancelled before a lane picked them up. Each of
the three load sites (plain topk, deferred drop, drop fallback) issues
the pending next-layer prediction right after its load_layer, restoring
the same read-ahead window the temporal prefetch gets. On the tiny-model
gates this is the difference between 0% and 27% of speculated experts
proving useful -- the latter matching the probe's measured accuracy on
that model, which is the accounting agreeing with itself.

It is drop-aware. With --drop-cold-experts armed, a predicted expert
whose predicted routing weight (softmax over the predicted top-k)
falls below the drop threshold is not speculated: if it misses, the
policy discards it unread, so reading it ahead would spend the exact
I/O the policy exists to save. The top prediction is always kept,
mirroring the policy's own pin of the top-weighted expert. The known
interplay is inherited from the temporal prefetch and deliberate: a
correct guess un-drops an expert, buying quality at the same threshold
rather than speed.

The routing width the prefetch predicts at is learned from the topk
node, not read from config, so an --n-expert-used override stays honest
with no extra plumbing. Mutually exclusive with --prefetch (two
predictors would double-speculate the same future); requires the LRU
cache; decode only. The control GEMV remains probe-only, so the
production path costs one gate GEMV per MoE layer per token.

Gates: G10a proves byte-identity through the speculative path
(prefetch-sync, forced small cache, hits and evictions both occur);
G10b proves the run actually speculated and that useful-hit accounting
tracks the probe's accuracy. Off by default, pending an on-device A/B.

* feat(moe): cap predictive speculation at the top 2 predicted misses per layer

Speculating the whole predicted routing was measured on device at -38%
against its own baseline despite every intermediate metric improving
(hit 77.6->88.9%, stall 32->9 ms, 84% of speculations useful): the +33%
flash bytes and the vm commits fighting a full cache (major faults x3)
cost several times the stall removed. The stall a prefetch can remove is
head-of-line only -- overlap already hides the tail behind the expert
matmul -- so the cap keeps the part of the bet that can pay and drops
the part that provably cannot. Residency-aware: a predicted expert
already in cache does not burn a slot of the cap.

* perf(moe): rebuild the predictive prefetch around its measured costs

The observer-tax run priced the naive implementation: ~35-45 ms per
GEMV pass (ggml_fp16_to_fp32 is a function call per weight element --
21M calls/token) and ~20 ms/token for the extra isolated node, against
a speculation machinery that costs ~15-25. The GEMV and the barrier
were the feature; this commit removes both.

- gate_scores converts F16 natively on aarch64 (one instruction,
  vectorizable) instead of a function call per element.
- The prefetch no longer isolates the gate matmul: the ask pass hands
  over its source pointers for free, and the gate-input row is read at
  the topk callback with no barrier of its own. A sampled watchdog
  (the zero-staleness control, every 512 routings) validates the
  barrier-less read and disarms the prefetch out loud if the memory
  planner ever reuses that buffer -- without it, a future llama.cpp
  bump could silently turn the predictor into a noise generator.
- The GEMV runs on a dedicated worker at a TWO-layer horizon: one
  layer ahead has no landing spot (the callbacks between a layer's
  gate and its own load are microseconds apart), while at l+2 the
  worker has a whole layer for a ~0.5M-MAC job and the result inherits
  the same post-load issue window as before. The probe now also scores
  stale-2, so the extra layer of staleness is priced per model rather
  than assumed.
- The prediction's residents are RETAINED (new IExpertSource::retain:
  move-to-MRU, deliberately not a cache hit so the hit-rate metric
  stays honest) -- protecting a predicted expert costs zero bytes,
  unlike prefetching it. Only predicted misses are speculated, still
  capped at 2.

The probe path keeps its barrier and both GEMVs: a probed run is
diagnostics, and its job is to be right, not fast.

* feat(moe): --predict-spec-max N — how much flash the prediction may spend (0 = retention only)

The cap was a constant; the retention-only point (0) is the config the
whole experiment now hinges on -- the prediction protecting predicted
residents from eviction while spending no flash at all -- and a
measured constant that cannot be varied is not a mechanism. Validated
[0, 8]; retention happens at every value because it is free.

* docs(predict): record the 2026-07-23 campaign — accuracy confirmed, throughput verdict open

Accuracy on device: stale-gate 88.6% (Qwen3-30B) / 80.7% (Qwen3.6),
control exactly 100.0% on every layer of both, prev-token 43/35% --
corroborating the offline route-trace estimates.

Throughput: the day's C-vs-B losses are recorded WITH their
invalidation. Re-running the reference on the by-then-hot device gave
3.93 tok/s against the cool morning's 6.54 with byte-identical I/O,
hit and drop counts -- the engine is deterministic, the -40% was
silent thermal capping, and every variant had been compared against
the cool number. The one thermally matched pair that was measured
(speculation on 8 io lanes, -28%, effective flash bandwidth 585->392
MiB/s) kills the more-lanes hypothesis specifically; spec-max 2 and
retention-only still owe a matched cool pair.

What did survive: the observer-tax decomposition (109 ms/token: the
per-element exported-function F16 conversion at 21M calls/token, plus
the barrier), and retention moving hit rate 0.1pp on a 3000 MiB cache
-- the offline replay bound confirmed from inside the engine.

* feat(app): expose the predictive prefetch as an experimental Streaming toggle

Off by default. Gated on streaming + a live cache like the temporal
prefetch, and the two settings disable each other in the UI -- the
engine refuses the pair, and a control the engine will reject is worse
than one that cannot be set. The spec-max rung selector (0/1/2/4)
surfaces the retention-only point, which is the configuration the open
throughput question most needs measured from the app. Session
signature includes both fields so flipping them reopens the process.

No versionCode bump: this is a PR-branch test build, not a release.

* docs(predict): record the 2026-07-24 matched pairs — read-ahead refuted, retention hit-neutral

A four-cell session (B, retention-only, spec-2, B sentinel) run at fixed 30 s
spacing re-proved the thermal-contamination mechanism (sentinel −17%, clusters
silently capped from cell 2) and yielded one genuinely matched pair: spec-2 vs
the B sentinel at the same caps and battery temperature, 3.14 vs 3.96 tok/s
(−21%) with hit rate up 4.3pp and 79% of speculations useful. Speculation
improves every metric it owns and still loses the wall clock — the flash has no
spare bandwidth to spend. Retention-only again moved the hit rate by nothing
(77.2% vs 77.6%), as the offline replay bound predicted.

* feat(app): contrast the two prefetch predictors in the UI, default spec-max to 0

The temporal and predictive toggles now say what actually differs — the bet
("repeats the previous token", ~40%) vs the question ("ask the next router a
layer early", ~85%) — instead of describing mechanisms side by side. Spec-max
defaults to 0 (retention only): the matched-pair A/B showed the read-ahead
losing −21% on a saturated flash, so 0 is the only rung the measurements did
not refute, and the helper text says so.

* chore(predict): file the changelog under Unreleased, drop a dead member, record the verdict

Three loose ends found reviewing the branch for merge:

- The changelog entries had been appended to the already-released 0.15.1 section;
  they belong under [Unreleased], where the release commit carves them out.
- nu_hint_ was written on every routing and read nowhere: a leftover of the
  synchronous first design, whose successor passes the routing width straight into
  the prediction job.
- docs/roadmap.md still closed the routing-prediction question on the 2026-07-12
  removal. It now records what reopening it with a training-free predictor found:
  the accuracy is real and the throughput is not, for the same reason more lanes
  and the sidecar lost.
2026-07-28 09:21:41 +02:00
Helldez
bea5a0b99e
feat(moe): --drop-cold-experts — spend quality only where it buys I/O (#95)
* feat(moe): --drop-cold-experts, spend quality only where it buys I/O

Turbo top-k drops the tail of a routing whether or not those experts were
already in RAM. A resident expert costs no flash read, so that trade pays
quality for nothing on the ~80% of decode routings that are cache hits.

This skips a routed expert only when it is a cache MISS and the router
weighted it below frac x (1/top-k). Replayed over the committed route
traces at frac 1.0, decode phase, that avoids 66% of flash reads for 9.5%
of the router's weight mass, where --n-expert-used 5 avoids 23% for a
comparable 10.6% -- about 3x the reads at the same quality cost.

Implementation. The decision needs the FINAL router weights, which arrive
several nodes after the topk where the streamer normally loads, so
load_layer() is deferred to the terminal node of the layer's weight chain.
Which node that is depends on the model's gating, so the hook learns it
from the graph rather than carrying an architecture table; if it fails to
arrive the hook forgets it and re-learns rather than re-betting. A dropped
slot has its weight zeroed and its expert id repointed at the routing's
top-weighted expert: an unread expert can sit in reserved-but-uncommitted
VM and mul_mat_id would touch it anyway, so the kernel is given memory
that is certainly resident and multiplies it by exactly zero.

Requires the LRU cache -- with --cache-mb 0 residency reads all-miss and
the policy would silently degenerate into an unconditional weight cut.
Prefill is excluded by default. The top expert is always pinned, so no
routing can be emptied at any threshold.

Gates: G8a/G8a' prove the deferral and the learned terminal node are
transparent (byte-identical output, zero drops, at a threshold below any
producible weight); G8b that full strength against a constantly-evicting
cache never reaches an unloaded slot; G8c that at top-k 1 dropping is a
no-op, pinning both the top-expert guarantee and the threshold tracking
the effective top-k.

Three existing metrics shift meaning under dropping and the docs now say
so: cache_hit_pct rises without the cache serving more (a dropped routing
is a miss that is never looked up), and token/layer_demand measure what
was staged rather than routed. prefetch.md's "cannot change output" is
scoped, limitations.md gains the non-reproducibility entry, and
benchmark-method.md warns that reversing the run order cannot distinguish
a moved drop rate from a contaminated cell.

Off by default in the CLI and in the app. The output is not reproducible
-- what gets dropped depends on what the cache held -- so it carries no
rows in the README tables, and switching it on by default waits on a
published on-device A/B rather than on the replay argument alone.

* feat(app): default cache-aware dropping to 75%, measured on device

Qwen3.6-35B-A3B (top-k 8 of 256), in-app, cache 3000, one variable
changed: 2.549 tok/s off, 3.938 at F=0.75 (+55%), 4.702 at F=1.0 (+84%),
with flash reads falling 248 -> 163 -> 48 GiB. Per-token bootstrap
intervals separate every pair except off vs 0.50, which overlaps -- at
half the uniform share the policy drops 2.7% of routings and buys
nothing, which doubles as a negative control that the machinery is free
when it does not fire.

Run order was 1.0, off, 0.5, 0.75, so the two fastest cells are the first
and the LAST; thermal drift would have made the last the worst. The
mechanism orders by threshold even though the run order does not.

The replay turned out conservative rather than optimistic. It is
documented as an upper bound because it cannot model the cache changing
in response to dropping: at F=0.75 it was accurate (37% predicted, 34%
measured), at F=1.0 it understated (66% predicted, 81% measured). Avoided
reads free cache capacity, which raises the hit rate, which leaves fewer
misses to drop.

75% rather than 100% is deliberate: it takes the larger part of the win
for half the discarded routings (14% against 28%). Quality is still
unquantified -- no perplexity number and no side-by-side exists -- so the
conservative end of a measured range is the defensible default. The CLI
stays off; the byte-identity gates need a deterministic default.

Also records cache_hit_pct rising 67.8 -> 90.7% as the documented
accounting artefact rather than the cache serving more, and majflt/token
as dominated by each run's starting memory state, not by the threshold.
2026-07-22 17:21:55 +02:00
Helldez
79611654cd Revert "feat(moe): --drop-cold-experts — spend quality only where it buys I/O (#94)"
This reverts 45a90a2.

The feature merged before the evidence for its shipping default did. The
replay numbers argue the shape of the trade is favourable, but no on-device
A/B is published in this repository, and the app default it landed with
(75%) changes model output for every user of the demo app -- and changes it
non-reproducibly, which no other setting in this engine does.

Nothing was found wrong with the code. This is a sequencing decision: the
work returns as a pull request, with the app default back to off, so the
measurement lands before the default does.

Reverted rather than force-pushed: main is public and this commit was
already pushed, so the history stays honest about what happened.
2026-07-22 15:47:14 +02:00
Helldez
45a90a2df5
feat(moe): --drop-cold-experts — spend quality only where it buys I/O (#94)
* feat(moe): --drop-cold-experts, spend quality only where it buys I/O

Turbo top-k drops the tail of a routing whether or not those experts were
already in RAM. A resident expert costs no flash read, so that trade pays
quality for nothing on the ~80% of decode routings that are cache hits.

This adds the cache-aware version: skip a routed expert only when it is a
cache MISS and the router weighted it below frac x (1/top-k). Replayed over
the committed route traces at frac 1.0, decode phase, that avoids 66% of
flash reads for 9.5% of the router's weight mass, against 59%/37% for
--n-expert-used 3 — about 3x the reads avoided at a comparable cost. The
threshold is a curve, not a switch: 0.75 trades 4.4% of the mass for 37% of
the reads, better than --n-expert-used 5 on both axes.

Implementation. The decision needs the FINAL router weights, which arrive
several nodes after the topk where the streamer normally loads, so with the
policy armed load_layer() is deferred to the terminal node of the layer's
weight chain. Which node that is depends on the model's gating, so the hook
learns it from the graph rather than carrying an architecture table; until
it is known a layer loads at its topk node undropped. A dropped slot has its
weight zeroed and its expert id repointed at the routing's top-weighted
expert: an expert we decline to read may sit in reserved-but-uncommitted VM
and mul_mat_id would still touch it, so the kernel is given memory that is
certainly resident and multiplies it by exactly zero. Survivors are rescaled
by default, since a systematically shrunk expert output perturbs the residual
stream more than the missing contribution does.

Prefill is excluded by default (cold cache, ~4x the weight mass discarded,
and compute-bound anyway). The largest weight in a routing is always at least
the uniform share, so frac <= 1 can never empty a layer; validate() enforces
the bound and the top expert is pinned regardless.

Gates: G8a proves the deferral and the learned terminal node are transparent
(a threshold below any producible weight leaves the output byte-identical),
G8b that full strength with the cache off never reaches an unloaded expert.

Unlike every other knob this one is state-dependent: what gets dropped
depends on what the cache held, so output is not reproducible across runs.
Off by default, not in the app's settings, and NOT yet measured on device —
the numbers above are a static replay and an upper bound. docs/expert-
dropping.md states what is owed before it is recommended anywhere.

* feat(app): expose cache-aware expert dropping in Settings

Speed / quality -> Drop cold experts, as a percentage of the uniform
share (off / 50 / 75 / 100). The engine takes a fraction; the app stores
integer rungs, so the setting divides by 100 on the way to the flag.

Disabled in mmap mode: the policy asks the expert source what is resident,
and there is no expert source without the streamer. Included in the session
signature, so changing it reopens the session rather than being ignored by
a process already loaded.

Off by default. This exists so the A/B can be run where the engine actually
ships -- through the app, not a pushed CLI binary.

* fix(moe): require the cache for dropping, and correct what it reports

Review of the first two commits found the policy could be armed in a
configuration where it is not cache-aware at all, and that two of the
numbers it reports were wrong.

- Require the LRU cache. With --cache-mb 0 query_residency answers
  all-miss, so the policy silently degenerated into an unconditional
  weight cut -- exactly what --n-expert-used already does, under a flag
  claiming to consult residency. validate() now rejects it, as it already
  did for --prefetch, and the app gates the setting on the same condition.

- Fix experts_routed. It was incremented inside apply_drop, so it counted
  what the policy examined rather than what the router selected: layers
  before the terminal weight node is learned, and every un-armed phase,
  were missing from the denominator. The reported drop rate was a fraction
  of the wrong thing.

- Re-learn instead of re-betting. If the node learned as terminal does not
  arrive, the deferral now also forgets it, so the next graph loads at the
  topk node while it re-learns. Deferring again on a stale guess would
  repeat the fault every token against a graph that had moved.

- Point the gates at a real cache. G8a/G8b ran with the cache off, where
  the shared-slot path has no reserved-but-uncommitted memory -- so the id
  repointing, which is the design's whole safety argument, was never
  exercised. They now run against a constantly-evicting budget. Adds G8a'
  (asserts routings were examined and none dropped, so an inert-threshold
  flake fails legibly) and G8c (at top-k 1 dropping is a proven no-op,
  pinning both the top-expert guarantee and the threshold being taken
  against the effective top-k).

Docs: three metrics change meaning under dropping and none of them said
so. A dropped routing is a miss that is never looked up, so cache_hit_pct
rises without the cache serving more, and token/layer_demand measure what
was staged rather than routed -- documented in telemetry.md, pressure.md
(size the cache with dropping off, then turn it on) and metrics.h.
prefetch.md's "cannot change output" is scoped: under dropping a correct
guess un-drops an expert. limitations.md gains the non-reproducibility
entry, benchmark-method.md the axis plus a warning that reversing the run
order cannot distinguish a moved drop rate from a contaminated cell, and
architecture.md/runtime.h no longer claim unconditional determinism.
Fixes two anchors the README rename broke, and a changelog sentence that
quoted the equal-I/O row while drawing the equal-quality conclusion.

App: Drop cold experts defaults to 75%. The default is a product decision
taken on the maintainer's device; no benchmark for it is published here,
and docs/expert-dropping.md says that plainly instead of implying a
measured figure. The CLI stays off by default -- the byte-identity gates
need a deterministic default.
2026-07-22 15:08:49 +02:00
Helldez
228e162465 chore: sweep the retired governor's residue from core and CLI
Retiring the adaptive cache governor left state that nothing reads and comments
that describe a control loop the engine no longer has. The budget is sized once
at init and then fixed for the run; make the code say only that.

Dead state, all write-only since the governor went:
  - cache_auto_, cache_target_ and slot_sz_ are gone; cache_floor_ and
    cache_hard_cap_ demote to locals in the auto-sizing block, which is the only
    place they ever meant anything.
  - DenseWeights::rewarm() had no caller: it was the "reclaim happened, warm the
    dense set again" hook the governor drove.
  - ProcessMemory::rss_peak_bytes (VmHWM) was populated and never read. The bench
    script samples VmHWM straight from /proc, so nothing is lost.

Comments that had started to lie: config.h still described a budget "re-checked
during generation", session.h "complements --cache-mb auto's automatic tracking",
expert_source.h a budget that "moves under --cache-mb auto". And the CLI printed
"(auto, resized N×)" where N is now structurally always 0.

cache_resizes stays: it still counts an app's explicit set_cache_budget_mb calls
(Android onTrimMemory), which the shrink gate exercises.

Gates pass (byte-identity, qwen3moe + gemma4).
2026-07-17 10:04:27 +02:00
Helldez
847676d715 refactor(io): FileReader + DenseWeights modules — per-consumer O_DIRECT
Extract the pooled positioned reader (O_DIRECT chosen per reader, not one global flag) and the dense-weights policy+sensor into their own modules; ExpertStreamSource composes them. So the dense and expert O_DIRECT choices are independent.
2026-07-17 08:55:17 +02:00
Helldez
e78fb78194 feat(moe): --dense-weights — dense (non-expert) weight residency policy
Read each dense weight once via O_DIRECT into an anon buffer and rebind (anon), page-cache them at load (warm), or leave them mmap'd (mmap). RouterHook captures the non-expert weight leaves; the streamer reads+rebinds and drops the mmap copy. Byte-identity gates G6/G7.
2026-07-17 08:55:17 +02:00
Helldez
9eea9743b6 refactor(moe): remove speculative gating to restore the modular seam
Speculative gating was the only feature that broke the ports-and-adapters
seam: it made router_hook reach into architecture-specific router math
(RouterPre), spawned a second thread inside the eval-callback bridge, and
inlined predictor logic into the streaming hot path. It was experimental and
default-off, and never paid its way in steady-state decode on device.

Removing it collapses MoeRecipe back to {arch, expert suffixes} — the header's
stated design intent — and router_hook back to capture -> gather -> load_layer
plus temporal prefetch. The shared speculative-prefetch queue in
expert_stream_source (used by --prefetch) is untouched.

- delete core/src/moe/spec_dot.{h,cpp} and docs/spec-gating.md
- strip RouterPre + router-node fields from recipe.h and every registry row
- drop spec_gate / spec_recall_* config, the run() wiring, and the
  moe_spec_recall_pct / moe_spec_auto_off summary fields (+ CLI flags,
  BMOE_SPEC_GATE env, BMOE_DONE + CSV columns, moe-spec-gate print)
- remove the Android "Speculative gating" toggle and specGate setting
- drop gates G6a-d; G1-G5 and S1-S3 still prove streamed == resident
- clean the reusable bench scripts of --spec-gate; keep docs/bench-data as an
  archive of the historical measurements

Host byte-identity gates pass for qwen3moe and gemma4.
2026-07-14 10:41:27 +02:00
Helldez
6a4208c1f6 style: clang-format the adaptive-cache and session-gate sources 2026-07-13 15:44:21 +02:00
Helldez
991a33b795 test: cache-budget shrink byte-identity gate (S3)
S3 opens a session with a warm cache, drops the budget to ~0 MiB between
generations to force a full eviction pass, then generates again: the output
must still match the cold resident reference, proving a runtime resize only
changes residency, never the produced bytes.
2026-07-13 08:20:41 +02:00
Helldez
7d6a4c68c9 feat(moe): auto-disable speculative gating when router recall stays low
Speculative gating only pays for its extra I/O and per-layer graph barrier
when the router it forecasts is actually predictable. On architectures where
cross-layer routing correlates weakly the predictor wastes reads for no
throughput gain, and previously nothing turned it off. Add a self-governor:
once at least spec_recall_warmup predictions have been scored (past prompt-
transition noise), if cumulative recall is below spec_recall_min_pct the
engine latches spec-gating off for the rest of the run — which also drops
the router-input barrier. Defaults 75/512; 0 disables the check. Exposed as
--spec-recall-min and surfaced in the moe-spec-gate summary line. New gate
G6d proves the mid-run on-to-off transition is byte-identical (the governor
fires at 25 percent recall on the synthetic model).
2026-07-13 08:00:50 +02:00
Helldez
f5e3c4c272 test: G6 speculative-gating byte-identity gates
G6a asserts spec-gating leaves output unchanged (isolating the router-input node and prefetching
its prediction only warms the cache). G6b forces synchronous prefetch so the full
predict->prefetch->integrate->hit path runs deterministically. G6c repeats under overlap. All pass
on qwen3moe and gemma4.
2026-07-12 20:18:42 +02:00
Helldez
4e02eb7584 test: G5 temporal-prefetch byte-identity gates
G5a (serial) and G5b (overlap) run streaming with a forced cache and 2 look-ahead layers and
assert the output matches plain streaming — prefetch warms the cache without changing bytes.
G5c forces synchronous completion of the speculative reads so the integrate-then-hit path (a
prefetched expert becoming resident, then a later routing hitting it) is exercised
deterministically, which the timing race in G5a/b seldom reaches on a fast host. All pass on
qwen3moe and gemma4.
2026-07-12 20:05:43 +02:00
Helldez
4593256d61 test: session byte-identity gates (S1/S2)
Two sequential Session generates of the same prompt, the second running against the LRU cache
the first left warm, must both match the cold one-shot resident reference. Guards the new
across-prompt cache persistence: warming the cache changes latency, never the produced bytes.
S2 repeats under --overlap. Both pass on qwen3moe and gemma4.
2026-07-12 19:36:56 +02:00
Helldez
98c6339039 test(moe): add G4 overlap byte-identity gates incl. single-lane stress
G4a/b/c prove the overlapped path is byte-identical to serial streaming for
cache-off, LRU, and the io_threads=1 stress case that maximises stalls. Guarded
by BMOE_HAVE_EXPERT_READY_HOOK so the suite still builds against stock upstream.
2026-07-12 08:54:19 +02:00
Helldez
d2d5dc9ee7 style: apply clang-format across sources 2026-07-10 18:34:36 +02:00
Helldez
4b5a5b55d2 test(moe): byte-identity gates and synthetic tiny-moe generator 2026-07-10 18:18:19 +02:00