mirror of
https://github.com/Helldez/BigMoeOnEdge.git
synced 2026-10-03 03:25:42 +00:00
feat(moe): cache-aware expert substitution (--expert-substitute, experimental) and --ppl (#171)
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:
parent
9153dcc27a
commit
4334c89616
39 changed files with 4181 additions and 27 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue