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.
This commit is contained in:
Raffaele 2026-08-29 10:13:47 +02:00 • committed by GitHub
parent 9153dcc27a
commit 4334c89616
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 4181 additions and 27 deletions

View file

@ -159,3 +159,7 @@ Two worth knowing before you turn them on:
- **"Stream row-gathered tables"** (`--row-stream`) serves the token embedding table out of flash
instead of RAM. Lossless, and which tables it applies to is read off the model's own graph, so
on a model where none qualify it does nothing. See `../../docs/row-gathered-tables.md`.
- **"Prefer cached experts"** (`--expert-substitute`) steers each routing toward experts already
in RAM, so the same number of experts runs but fewer are read from flash. It changes the reply,
and past 20% the reply keeps reading well while the model behind it is much worse: judge it on
answers you can check. See `../../docs/cache-aware-substitution.md`.

View file

@ -65,6 +65,18 @@ data class AppSettings(
// so the only question it raises is whether the reads cost more than the RAM is worth - which
// is why it is off until the on-device A/B says otherwise.
val rowStream: Boolean = false,
// Cache-aware substitution, as a PERCENTAGE of the router's score range (0 = off). Before a
// routing is committed, every expert already resident gets its score raised by this fraction of
// the range and the top-k is taken again, so a resident expert wins a slot only when it was
// within that margin of the one it displaces. It runs the SAME number of experts — it just
// needs fewer of them read from flash.
//
// Measured on the host at 15% (docs/cache-aware-substitution.md): half the flash bytes per
// token, +62% decode, perplexity up 1-4%. At 30% perplexity is up 25%; at 60% the model is
// destroyed (perplexity 31 against 4.2) while the text still reads well, which is why the rungs
// stop at 30 and the screen warns from 20. LOSSY and cache-dependent, like dropping. 0 until
// the on-device A/B earns it a default.
val substitutePct: Int = 0,
// Which source drafts for self-speculation: "off", "mtp" or "ngram". Both verify the same way —
// one wider decode, greedy acceptance — and differ only in what a draft costs.
//
@ -175,6 +187,9 @@ data class AppSettings(
// discovered by the streamer's capture pass; independent of the cache and of the
// dense-weight mode, since what it changes is which tensors that mode applies to.
if (rowStream) a += "--row-stream"
// Same cacheOn guard and for the same reason: with no cache there is nothing resident
// to substitute toward, so the policy would re-rank against an all-miss mask.
if (substitutePct > 0 && cacheOn) a += listOf("--expert-substitute", (substitutePct / 100.0).toString())
}
// Outside the streaming block on purpose: speculation is a decode-loop change, not a
// residency policy, so it applies to the mmap baseline too — which is what makes an A/B of
@ -221,6 +236,7 @@ data class AppSettings(
.putInt("routeAhead", routeAhead)
.putInt("dropColdPct", dropColdPct)
.putBoolean("rowStream", rowStream)
.putInt("substitutePct", substitutePct)
.putInt("sessionCtx", sessionCtx)
.putString("spec", spec).putInt("mtpDraft", mtpDraft).putInt("mtpPMinPct", mtpPMinPct)
.putBoolean("thinking", thinking)
@ -327,6 +343,9 @@ data class AppSettings(
// above it the threshold could exceed every weight in a routing. The rungs below it are the
// conservative half of the curve, where the replay already beats a top-k cut on both axes.
val DROP_COLD_CHOICES = intArrayOf(0, 50, 75, 100)
// Stops at 30 deliberately: 60 was measured to destroy the model while still reading well,
// and 30 already costs a quarter in perplexity. Below 10 the saving is not worth a rung.
val SUBSTITUTE_CHOICES = intArrayOf(0, 10, 15, 20, 30)
val THREAD_CHOICES = intArrayOf(2, 4, 6, 8)
val NPREDICT_CHOICES = intArrayOf(16, 32, 48, 64, 128, 256, 512, 1024, 2048)
@ -360,6 +379,7 @@ data class AppSettings(
routeAhead = p.getInt("routeAhead", d.routeAhead),
dropColdPct = p.getInt("dropColdPct", d.dropColdPct),
rowStream = p.getBoolean("rowStream", d.rowStream),
substitutePct = p.getInt("substitutePct", d.substitutePct),
sessionCtx = p.getInt("sessionCtx", d.sessionCtx),
spec = run {
val saved = p.getString("spec", null)

View file

@ -482,6 +482,7 @@ private fun configSummary(s: AppSettings): String {
if (s.prefetchLayers > 0) parts += "prefetch ${s.prefetchLayers}"
else if (s.predictPrefetch) parts += "predict" + if (s.predictSpecMax > 0) " ${s.predictSpecMax}" else ""
if (s.dropColdPct > 0) parts += "drop ${s.dropColdPct}%"
if (s.substitutePct > 0) parts += "prefer-cached ${s.substitutePct}%"
}
}
parts += "${s.threads} threads"

View file

@ -420,6 +420,7 @@ private val CONFIG_ORDER = listOf(
"drop_cold_frac" to "Drop cold experts",
"drop_renorm" to "Drop renormalise",
"drop_prefill" to "Drop in prefill",
"substitute_lambda" to "Prefer cached experts",
"temp" to "Temperature",
"top_k" to "top-k",
"top_p" to "top-p",
@ -463,7 +464,7 @@ private fun prettyConfigValue(key: String, v: String, info: Map<String, String>)
// dropping happened" is exactly the misreading this display exists to prevent. The drop
// fraction is shown as the engine took it (a fraction of the uniform share 1/top-k, not of the
// routing) so it matches --drop-cold-experts and the settings screen.
(key == "prefetch" || key == "route_ahead" || key == "drop_cold_frac") && v.toFloatOrNull() == 0f -> "off"
(key == "prefetch" || key == "route_ahead" || key == "drop_cold_frac" || key == "substitute_lambda") && v.toFloatOrNull() == 0f -> "off"
key == "predict_spec_max" && v == "0" -> "0 (retention only)"
else -> v
}

View file

@ -202,6 +202,28 @@ fun SettingsScreen(current: AppSettings, onChange: (AppSettings) -> Unit, onBack
)
ExperimentalGroup {
// Measured on the desktop only; the phone A/B is what decides whether it earns a
// default, so it sits with the other levers still owed one.
IntSetting(
"Prefer cached experts (% of score range)", AppSettings.SUBSTITUTE_CHOICES,
current.substitutePct,
format = { if (it == 0) "off" else "$it%" },
// Needs the streamer and a live cache, for the same reason dropping does: with
// nothing resident there is nothing to prefer.
enabled = stream && cacheOn,
) { onChange(current.copy(substitutePct = it)) }
Hint(
"When two experts score close, picks the one already in RAM. Same number of " +
"experts, fewer flash reads, faster decode. Changes the reply; 15% is the " +
"measured sweet spot."
)
if (current.substitutePct >= 20) {
Text(
"Past 15% the model degrades faster than its replies show. Judge it on " +
"answers you can check, not on how fluent it sounds.",
fontSize = 12.sp, color = MaterialTheme.colorScheme.error,
)
}
LabeledDropdown(
"Guess ahead",
listOf("Off", "Model's own head (MTP)", "Repeated text (n-gram)"),