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.
48 KiB
Telemetry contract
With --progress, bmoe-cli emits machine-readable lines the Android app (and any other
consumer) parses. The format is versioned by this document; keep it stable.
Per-token lines
Emitted once per generated token, in order:
BMOE_LOAD {"mb":<float>,"ms":<float>}
BMOE_PROGRESS {"step":<int>,"steps":<int>,"wall_ms":<float>,"io_ms":<float>,
"compute_ms":<float>,"mgmt_ms":<float>,"stall_ms":<float>,"read_mb":<float>,
"cache_hit_pct":<float>,"majflt":<int>,"cpu_ms":<float>,"dense_resident_frac":<float>,
["reset":1,]"delta_reasoning":"<string>","delta_text":"<string>"}
BMOE_LOADappears only when experts were read this token;mbis the flash bytes read,msthe read time.BMOE_PROGRESS.step/stepsare 1-based index and target token counts.read_mbis the flash bytes read this token;stall_msis the overlap-only wall time compute lost to reads (0 in serial mode).wall_ms= total token time;io_ms= flash read time.compute_msis a residual, not a measured quantity: no clock runs around llama.cpp's matmul kernels in a normal run, so compute is whatever wall time is left after the measured terms are subtracted —wall_ms − io_ms − mgmt_msin serial,wall_ms − stall_ms − mgmt_msunder overlap. When that residual is the number in question,--compute-tracemeasures it directly instead (see Decode traces) — at a cost that makes it a diagnostic, not telemetry.compute_msis clamped at 0 — a negative compute would be nonsense. That clamp means the wall-additive identity is not exact in the pathological case — a consumer that recovers the flash-wait term aswall_ms − compute_ms − mgmt_msgetswall_ms − mgmt_mswhen the clamp fires, over-attributing to flash. Read the wall-additive flash term straight fromio_ms(serial) /stall_ms(overlap) instead of inverting the residual.stall_msis the union of stalled intervals: the cumulative wall time during which at least one compute thread was blocked on a streamed expert. Overlapping waits count once, so it is the critical-path quantity — one blocked thread already means the graph is not progressing. (It was previously the summed per-thread block time divided byn_threads, a mean that equaled the wall stall only if every compute thread blocked together and understated it whenever a minority of threads did the waiting — with the difference silently landing incompute_ms.) The interval opens the moment a thread finds its expert unready — including the short pre-block spin — and a stats snapshot taken mid-stall includes the open interval up to now. Attribution between compute and flash is still approximate (see--compute-tracewhen the split itself is the question), but the flash term no longer depends on how the waiting was distributed across threads. In serial modeio_msis the wall time blocked on reads (a subset ofwall_ms). Under--overlapits meaning changes: it is the sum of per-lane busy time, so it can exceedwall_msbecause lanes read in parallel with compute. Usestall_msfor the wall time compute actually lost to reads under overlap.stall_mshas a structural floor above zero — overlap cannot hide all flash. Which experts a token needs is only known once the router gate runs, immediately before the FFN consumes them, so on a cache miss the first missed expert's read is issued after routing and the FFN waits for it (overlap still hides experts 2…k of that layer behind expert 1's compute). The residual stall therefore tracks the miss rate: it approaches zero only at ~100 % cache hit (the whole expert set resident, i.e. no streaming) or with perfect speculative--prefetch. Empirically it never reaches zero — measured per-token minimum ≈ 5–15 ms at 76–81 % hit (Qwen/Gemma), rising to hundreds of ms at 12–18 % hit (gpt-oss). A run that reads as "compute-bound" once warm is the expected success case: streaming has hidden the bulk of I/O, leaving compute as the bottleneck.
mgmt_ms(not emitted in the JSON, but folded into thecompute_msresidual above and written to the CSV) is the cache-management time this token: virtual-memory commit of newly cached pages, eviction of cold pages, and the LRU bookkeeping. On the first few tokens after prefill it can be a large share of the token; at steady state it is near zero. Surfacing it stops the "all compute" reading on warm-up tokens where the real cost is cache churn, not matmul.cache_hit_pctis the cumulative cache hit rate, or-1when no cache is used. Under--drop-cold-expertsread it with care: a dropped routing is a miss that is never looked up, so it leaves both sides of the ratio and the reported hit rate rises without the cache having served anything more. Compare runs at the same drop rate, or readexperts_droppednext to it. Under--expert-substitutethe hit rate rises for a real reason — the routing was steered toward what is resident — andexperts_substitutedsays how much of it was steered.majflt/cpu_msdecompose thecompute_msresidual — the whole point being that "compute" above is a catch-all that silently absorbs page faults and scheduler stalls, not just matmul.- The app panel never reads the residual as compute. Its four bars are attribution components,
not a partition of wall time: compute =
cpu_ms ÷ compute threads— an attribution proxy (process CPU time, which includes the I/O lanes' work, divided down to a per-compute-thread figure; it is not a direct measurement of matrix-kernel execution), and because process CPU also covers the I/O lanes and other process threads, it is an upper bound on compute-thread CPU-equivalent time rather than a disjoint share of the token. Under heavy streaming the lanes' CPU is large enough to matter: compute + flash wait + mgmt can exceedwall_ms, and the panel clamps the unattributed remainder at 0 in that case instead of displaying the overlap. Flash wait =io_ms(serial) /stall_ms(overlap) read as measured in both the live and the end-of-run summary views (io_s_tok/stall_s_tokinBMOE_DONE), cache mgmt =mgmt_ms, and unattributed = the non-negative wall-time remainder — the off-CPU time (zram swap-in, preemption, frequency caps) the residual used to paint as compute. The bars are deliberately not rescaled to total 100 %: measurement noise is preferred to a fabricated normalization. The CPU-busy diagnostic keeps its own denominator —cpu ÷ (wall × busy threads), busy threads including the I/O lanes under overlap — which is a different quantity from the compute bar's and must not be "simplified" into it. They are measured directly aroundllama_decode(no submodule patch needed):majfltis the major page faults served this token — a non-zero count means a mmap-resident (dense) weight was re-faulted from flash inside the decode, i.e. a >RAM residency stall masquerading as compute.cpu_msis CPU time summed across all threads; compare it towall_ms × threadsfor occupancy — near 100% is genuinely compute-bound, well below means the cores were throttled, preempted, or blocked (a low-clock frequency cap or a co-resident process), not doing more math. Both are0when the platform can't report them (the Windows host build); treat0as "unmeasured". dense_resident_fracis the sampled fraction of the DENSE (non-expert) weights still in RAM (bymincore, throttled). Under--dense-weights anonit samples our own buffers (is zram holding them?); under mmap/warm the model's mmap (is the kernel dropping it?). A diagnostic read alongsidemajflt— nothing acts on it.-1when unmeasured.delta_textis the newly generated answer text since the previousBMOE_PROGRESSline, JSON-escaped, with any reasoning span stripped out. The reader appends it to what this generation already delivered. (Carrying the cumulative answer on every line made a generation of n tokens O(n²) on the wire; the full final text still travels inBMOE_DONE.)delta_reasoningis the same delta for the thinking span, when a reasoning model's chat template separated it from the answer. Empty with chat off, on a non-reasoning model, or when thinking was disabled (think=false). Display-only, kept apart from the answer so a UI can render it as a distinct thinking block.reset(present only as"reset":1) means the deltas on THIS line are full snapshots that replace the accumulated state instead of extending it. It appears when the chat parser retroactively reclassifies — a closing think tag arrives and text reported as answer becomes reasoning — which an append-only delta cannot express.
End-of-run lines
=== answer ===
<full generated text>
=== perf ===
generation: <n> tokens, <s> s/token (<t> tok/s)
compute: <pct>% CPU occupancy (<c> cpu-s/token over <n> threads), <f> major faults/token
mode: expert streaming, cache <auto|<n> MiB|off>, dense <mmap|warm|anon|ahwb>[, overlap]
moe-stream: read <mib> MiB (<mib/tok> MiB/token), decode <s> s/token (compute <c> + cache mgmt <m> + flash I/O <i> s/token, <bw> MiB/s)
moe-cache: <pct>% hit, resident <mib> MiB
The mode: line is printed on every run, streaming or not, and is the first thing to read: without
--moe-stream the engine is plain llama.cpp on mmap and every moe-* line below is absent, so a
report missing them is a baseline and not a measurement of this engine. On a MoE architecture the
build has a recipe for, that case names the flag; on any other model it says the architecture is not
one this build streams.
The compute: line decomposes the residual: low CPU occupancy points at a throttled/preempted
core (a frequency cap, a co-resident process) rather than heavy math, and non-zero major
faults/token means dense weights were re-faulting from flash inside the decode. It is omitted on
platforms that can't measure it (the Windows host build).
compute in the moe-stream: line is the same residual described for compute_ms above; cache mgmt is the per-token mean of mgmt_ms.
With --prefetch K a moe-prefetch: line is added:
moe-prefetch: <mib> MiB speculative, <useful>/<prefetched> experts useful (<pct>%)
<mib> is the flash read done speculatively this generation (a subset of the total read),
<prefetched> the experts fully read ahead, and <useful> how many of those a later routing
actually hit. See prefetch.md. With --predict-prefetch the same line is emitted
with a [stale-gate] tag — same counters, different predictor (see
expert-prediction.md) — and with --route-ahead N with a [route-ahead]
tag, where the useful fraction sits at ~100% by construction: the "speculated" ids are the
committed routing itself (see route-ahead.md). The CSV preamble records which
was active (prefetch= / predict_prefetch= / route_ahead=).
With --row-stream a moe-rows: line is added, and only when a table actually qualified:
moe-rows: <mib> MiB of row-gathered table(s) off the resident set, <mib> MiB resident,
<n> rows gathered, <n> reads (<mib> MiB)
The first two numbers are the trade: what those tables would have occupied under the ordinary
dense policy, and what they occupy now. The last three are what buying it cost in flash - read
them against the moe-stream: byte count on the same run. A second line appears only on failure
(<n> FAILED reads - this run's output is not trustworthy); since the tables are bound to
reserved address space, a fetch that did not happen is memory that was never written, so that line
means the generated text is garbage rather than merely slow. See
row-gathered-tables.md.
With --route-ahead N a moe-route-ahead: line is added:
moe-route-ahead: <N> layers early — <committed> routings committed, <passed> passed through;
committed selection agreed with the router on <pct>% of slots
<committed> decode routings had their expert selection replaced by the N-layers-early
prediction; <passed> were eligible but had no prediction to commit (the first decode token, and
any layer the hook could not read) and kept the router's choice. The agreement percentage is the
measured routing perturbation: 100% minus it is the fraction of routed slots that went to an
expert the router did not choose. See route-ahead.md. The CSV preamble records
the flag (route_ahead=).
With --mtp or --ngram a summary line is added, prefixed with the source that drafted:
mtp: <accepted>/<drafted> drafts accepted (<pct>%), <t> tokens per verify decode (<d> decodes for <n> tokens)
ngram: <accepted>/<drafted> drafts accepted (<pct>%), <t> tokens per verify decode (<d> decodes for <n> tokens)
Acceptance is what decides whether speculation can pay at all — for --mtp it is a property of the
model's trained head, for --ngram a property of how repetitive the text is. Tokens per verify
decode is what it actually bought: it is the factor the decode count fell by. It is always below
1 + --draft.
A second line reports what that cost:
mtp: drafting costs <s> s/token on top of decode → <t> tok/s effective (vs <t> reported)
Read this one before believing a speculated run is faster. tok/s is computed from decode time
alone, and drafting happens between decodes, so the headline rate does not include it. The
effective rate does. On a run where speculation is not paying, the two can differ by tens of
percent. See mtp.md.
With --ngram a third line reports coverage:
ngram: drafted on <d> of <n> steps (<pct>%); the rest decoded plainly
The n-gram source drafts only when it finds a confident match, and a step that drafts nothing costs
exactly a plain decode. So this percentage is what a result means: the same small delta over
baseline says something quite different at 10% coverage than at 90%. Also in the CSV trailer as
drafted_steps=, against mtp_decodes=. See ngram.md.
With --drop-cold-experts F a moe-drop: line is added:
moe-drop: <dropped>/<routed> routed experts dropped (<pct>%), threshold <F> x uniform
The flag fixes a threshold, not a rate: how much is actually discarded depends on what the cache held, so this line — not the flag — is what a run traded. See expert-dropping.md.
With --predict-log two moe-predict: lines and a per-layer table are added:
moe-predict: stale-gate <pct>% of routed slots (<pct>% whole routings) | prev-token <pct>% (<pct>%) | fresh-gate control <pct>% (<pct>%)
moe-predict: scored — stale-gate <rows> routings/<slots> slots, prev-token <rows>/<slots>, control <rows>/<slots>; <n> routings the stale-gate could not rank
layer stale prev ctrl routings
Three predictors scored against the routing the router actually produced, so they are comparable on
one run: stale runs the next layer's router a layer early, prev is the previous-token bet
--prefetch makes, and ctrl is the same arithmetic with no staleness — a control that should read
100%, not a predictor. Read the first two against ctrl. Denominators differ per predictor and are
printed separately, because the stale gate structurally cannot rank layer 0 or the first token; a
predictor with no routings at a layer prints -, never 0.0. The probe changes nothing that is
read, but it costs a barrier and two GEMVs per layer, so a probed run is not a benchmark run. See
expert-prediction.md.
Under --overlap the moe-stream: line additionally reports stall_s/tok=<s> — the mean
wall time per token that compute threads waited for expert reads to complete. It is 0 in
serial mode (where the read wait is already folded into decode time).
moe-cache: is present only when a cache is active. The === answer === / === perf ===
banners appear only in --progress mode; without it the CLI streams the answer inline and
prints just the summary lines.
CSV sink
--csv PATH additionally writes a # preamble describing the run, then one row per token, then a
# summary ... trailer. Intended for the benchmark sweep.
Run-parameter preamble
# bmoe_metrics v2
# engine=<ver>
# model=<file> arch=<arch> n_layer=<n> n_expert=<n> n_expert_used=<k> threads=<n>
n_ctx=<n> n_ubatch=<n> chatml=<0|1>
# moe_stream=<0|1> cache_mb=<n> cache_auto=<0|1> cache_floor_mb=<n> cache_ceil_mb=<n>
cache_cycle_mb=<n> force_cache=<0|1> load_all=<0|1> io_threads=<n> o_direct=<0|1>
overlap=<0|1> io_two_wave=<0|1> prefetch=<n>
route_ahead=<n> predict_prefetch=<0|1> predict_log=<0|1> predict_spec_max=<n> prefetch_sync=<0|1>
dense_weights=<mmap|warm|anon|ahwb> drop_cold_frac=<f> drop_renorm=<0|1> drop_prefill=<0|1>
substitute_lambda=<f>
# temp=<f> top_k=<n> top_p=<f> seed=<u> compute_trace_layers=<n> spec=<off|mtp|ngram>
spec_draft_max=<n> mtp_p_min=<f> ngram_min_match=<n>
Rows without this are not evidence: two files answer "which is faster" only if something says what
differed between them, and by the time a CSV is read the argv that produced it is gone. Written once
per session (a second generate() does not repeat it). Keys are whitespace-separated key=value
and order-independent; new keys are appended freely and older parsers ignore what they do not
know.
Values are the resolved configuration, not what was typed — cache_mb is what the streamer
settled on, which under --cache-mb auto is a number no flag mentioned, and n_expert_used is the
effective top-k after any override. Fields to read carefully:
engineis the version that produced the rows (bmoe-cli --version), so a committed file still names its build after the checkout has moved on.unknownif the build did not define it. It sits on its own line so themodel=line keeps starting withmodel=, which is how the app's CSV reader finds a run's name.n_ubatchsets the compute-buffer reservation, so it moves the very memory columns below;0means it followsn_batch.cache_cycle_mbis one token's worst-case routed bytes for this model at thisn_expert_used, priced at load from tensor shapes. It is here so a budget can be judged without a second run: acache_mbunder it cannot hold a token cycle, so the hit rate is near zero however legal the number looks againstcache_min_mb. The engine warns once at load when that is the case. See cache-sizing.md.predict_log=1,prefetch_sync=1orcompute_trace_layers>0mean the run was instrumented. A probed or traced run is not a benchmark run — see the warning under Decode traces.temp>0means the run was stochastic: not comparable token-for-token with a greedy one, and not reproducible except throughseed.load_all=1reads the whole expert set, so itsread_bytesmeans something different from a selective run's.o_direct=<0|1>is what the shard opens achieved, not what the flag asked for: a platform can refuse the request, and the open-time verify can downgrade a shard that mis-serves it to buffered. On a Mac the request is served withF_NOCACHE— it turns data caching off for the descriptor without imposing any of LinuxO_DIRECT's alignment or DMA semantics — soo_direct=1there means "uncached descriptor", never "O_DIRECT".mtp=1means the run used the model's MTP head to draft and verified a whole group per decode. No weight is skipped and nothing is approximated, but the text is not guaranteed identical to an unspeculated greedy run: a verify decode is a wide batch, and batch width moves the last bits on this backend, so a near-tie can flip (see mtp.md). The per-token accounting differs too: seemtp_batchbelow. Never average rows from aspec=mtporspec=ngramfile together with rows from aspec=offone, nor two speculated files from different sources.
think is deliberately absent: it is a property of a request, not of the session, so one value in
a session-wide preamble would be wrong for every turn that asked for the other. Per-turn facts belong
next to the turn column.
Per-token rows
step,steps,wall_ms,io_ms,compute_ms,read_bytes,cache_hit_pct,stall_ms,mgmt_ms,majflt,cpu_ms,
dense_resident_frac,turn,majflt_mib,cache_budget_mib,rss_mib,rss_anon_mib,rss_file_mib,swap_mib,
mem_available_mib,mem_free_mib,swap_free_mib,loop_overhead_ms,mtp_batch,mtp_draft_ms,drain_ms,
adopt_ms,ra_issue_ms,ra_wd_ms
stall_ms, mgmt_ms, majflt, cpu_ms and dense_resident_frac are trailing columns appended
after the original seven. stall_ms is the wall time compute threads waited for expert reads that
token (0 in serial mode); mgmt_ms is the cache-management time described above (plus the throttled
dense-residency probe); majflt/cpu_ms are the fault + CPU-time decomposition of the compute
residual (see the BMOE_PROGRESS notes above), 0 when unmeasured; dense_resident_frac is the
sampled dense-weight residency, -1 when unmeasured. All are additive: older CSVs have fewer columns,
so consumers must read by column NAME (from the header row) and treat any as optional. The # summary
line likewise gains stall_s/tok=<s>, mgmt_s/tok=<s>, majflt/tok=<f>, cpu_s/tok=<s>,
token_demand_MiB=<f> (the expert bytes one token routes, measured — where cache hits start, NOT a
floor to defend; see pressure.md), experts_routed=<n> / experts_dropped=<n> (what
cache-aware dropping actually discarded during generation — the flag sets a
threshold, not a rate, so this is the only record of the trade a run made),
experts_reranked=<n> / experts_substituted=<n> (the same ledger for
cache-aware substitution: slots the re-ranking examined, and slots
it moved to a resident expert),
row_table_MiB=<f> / row_resident_MiB=<f> / row_rows=<n> / row_reads=<n> / row_read_MiB=<f> / row_evictions=<n> / row_io_errors=<n> (the row-gathered tables described above; all zero when --row-stream is off or nothing qualified) and layer_demand_MiB=<f> (the widest layer's routed
bytes: the mechanical floor the cache must be able to stage) and loop_overhead_s/tok=<s> (see
below) and, under --mtp or --ngram, mtp_drafted=<n> /
mtp_accepted=<n> / mtp_decodes=<n> (the acceptance rate and how many decodes the generation
actually cost — tokens / mtp_decodes is the amortisation achieved) and mtp_draft_s/tok=<s> (what
that amortisation cost outside the decode, which s/tok and tok/s both exclude) and
drafted_steps=<n> (how many steps drafted at all — below mtp_decodes only for --ngram, which
abstains); see the io_ms
note above for how the read-time columns are reinterpreted under overlap. The route-ahead keys ra_committed,
ra_passthrough, ra_agree_pct, ra_gemv_ms/tok, ra_issue_ms/tok and ra_wd_ms/tok mirror the
CLI's moe-route-ahead: report (route-ahead.md); drain_s/tok / adopt_s/tok
average the two wait columns below, and evictions / rereads are the cache-churn counters from
the moe-cache: line — a read of an entry the cache had already held once is the only way a
prefetch whose reads are all "useful" can still raise the byte count.
The prefill phase's own split rides the trailer too: prefill_cpu_s=<s>, prefill_read_mib=<f>,
prefill_io_s=<s>, prefill_stall_s=<s>, prefill_mgmt_s=<s> — the same raw values, names and
units as the BMOE_DONE keys of those names (see the protocol notes above), appended keys so
existing readers ignore them. A recorded run can now answer "what did the prompt cost, and in
what" without the protocol line.
With --prefill-device, four more: prefill_dev_tokens=<int> (prompt tokens whose graph ran on the
device), prefill_dev_nodes=<int> (graph nodes the device actually computed, i.e. whose output
landed in a device buffer: the proof it ran, where the token count only says the weights were moved),
prefill_dev_read_mib=<f> (experts the device arena read from flash) and prefill_dev_stall_s=<s>
(wall time the device graph waited at a layer for the arena). All zero when the device is off; the
last two also without streaming, where there is no arena. Same names in BMOE_DONE.
The trailing block is the memory picture, added so a run can be diagnosed from its own file:
| column | meaning |
|---|---|
turn |
which generate() this token belongs to (0 for a one-shot run). A session CSV spans every turn; without this the two-turn shape — a fast turn, an idle, then the turn that pays for it — is unreadable. |
majflt_mib |
what those faults moved: majflt x page size. The same fact as the count, in the unit the rest of the row uses — directly comparable to read_bytes, i.e. the reads we chose against the reads the kernel forced on us. |
cache_budget_mib |
the expert-cache budget in effect. Fixed for the run now (an explicit --cache-mb, or what auto sized to once at load) — the runtime governor that moved it is retired. |
rss_anon_mib |
resident anonymous memory — the expert cache lives here (and, under --dense-weights anon, the dense buffers). Falling while cache_budget_mib stays put means the kernel is taking it. |
rss_file_mib |
resident file-backed memory — the mmap'd model. Reclaimed by being dropped, not swapped, so it never shows in swap_mib. |
dense_resident_frac |
fraction of the DENSE (non-expert) weights still in RAM, by mincore (throttled). Under --dense-weights anon it samples our own buffers — is zram holding them? Under mmap/warm the model's VMAs (/proc/self/maps) — is the kernel dropping the model? Read with majflt. -1 when unmeasured. |
swap_mib |
anonymous memory already lost to zram (VmSwap). |
rss_mib |
total resident (VmRSS). |
mem_available_mib / mem_free_mib / swap_free_mib |
what the device claims about itself. MemAvailable counts this process's own mmap'd weights as reclaimable, so it over-states headroom — it is recorded next to what we measured ourselves because the gap between them is the story. |
loop_overhead_ms |
wall time between the previous token's decode and this one's: sampling, detokenization, rendering the answer for a UI, and the sink writes. On the first token, the gap from the end of prefill to the first decode. |
mtp_batch |
how many tokens the decode that produced this row confirmed. 1 without speculation. A verify decode confirms a whole group and its entire cost — wall_ms, io_ms, majflt, cpu_ms, read_bytes, loop_overhead_ms — is charged to the group's FIRST row; the rest carry zeros. Without this column those zeros read as free tokens. Per-token cost of a group is the first row's wall_ms / mtp_batch. |
mtp_draft_ms |
time this group spent drafting and catching the draft context up — everything speculation adds outside the decode. A slice of loop_overhead_ms, not an addition to it. 0 without speculation, and near-zero under --ngram, whose drafting is a scan of the token history rather than a decode. This is the column that makes the price of speculation measurable instead of inferable: wall_ms never contained it, and neither does tok/s. |
drain_ms |
overlap only: eval-thread wall waiting for the previous layer's read batch to finish before its job slots are reused, at the top of each async load. Part of compute_ms — named so a fat compute residual can be attributed instead of theorised about. 0 when it costs nothing. |
adopt_ms |
route-ahead only: the load waiting for its own committed speculative reads to complete before staging (adoption). Part of mgmt_ms. |
ra_issue_ms |
route-ahead only: eval-thread time issuing the committed selection's early reads (settle, residency split, retain, prefetch — page commits included). Part of compute_ms. |
ra_wd_ms |
route-ahead only: the sampled fresh-gate watchdog's exact float GEMV on the eval thread. Part of compute_ms. |
All are 0 where the platform cannot report them (the Windows host build reports device memory but not the per-process split).
What tok/s does not include
wall_ms brackets llama_decode and nothing else — that is what makes compute_ms a clean
residual. It also means everything between two decodes is outside wall_ms, outside gen_seconds
and therefore outside the reported tok/s. loop_overhead_ms is that region, measured, so a
change that moves only it is visible instead of free.
Read it next to wall_ms: the two together are what a caller actually waits through. The summary's
loop_overhead_s/tok is the same figure averaged, and includes the tail after the last token that
no row can carry.
Route trace
--route-trace PATH writes the per-step, per-layer MoE routing trace. Everything above answers
how long a token took; this answers what the router asked for — which experts each layer
routed, how strongly, and whether they were already resident. It needs --moe-stream (without
streaming there is no routing to observe) and it is a diagnostic, not telemetry: capturing it
asks the compute graph for extra nodes, which adds a barrier per MoE layer, and it writes a row
per routed expert. A traced run is not a benchmark run — the numbers in the --csv of a
traced run are slower than the real thing, and mgmt_ms in particular shifts, because settling
speculative prefetch moves outside the window that times it.
Columns are append-only within v1, like the metrics CSV: dropped was added after
expert_bytes, so consumers must read by column NAME and treat any column as optional rather than
indexing by position.
The file is long format: a # preamble carrying the run's static facts, then one row per routed
expert. Conceptually it is a matrix — rows are steps, columns are layers — and a cell is the
n_expert_used rows sharing (turn, phase, step, layer).
# route_trace v1
# model=<path> arch=<string> n_layer=<int> n_expert=<int> n_expert_used=<int>
# layer=<int> expert_bytes=<int> dense_bytes=<int> (one per layer)
turn,phase,step,layer,slot,expert,weight,residency,expert_bytes,dropped
| column | meaning |
|---|---|
turn |
session-mode turn; 0 for a one-shot run. One file per run, appended across turns. |
phase |
0 = prefill (one batched decode over many tokens), 1 = decode (one token per step). |
step |
absolute context position of the token being routed, so prefill and decode share one axis. |
layer |
MoE layer. Dense layers never appear. |
slot |
0..n_expert_used-1, the router's rank order — slot 0 is its top choice. |
expert |
the routed expert id: the cell's payload, and what every reuse question is asked of. |
weight |
the final applied routing weight, after whatever softmax/normalise/scale the architecture uses. nan when the graph exposed no weight node — "unknown", never 0. |
residency |
0 = miss (this routing reads from flash), 1 = hit, 2 = hit on a speculative prefetch's first touch. |
expert_bytes |
flash bytes this routing reads; 0 unless residency=0. |
dropped |
1 when cache-aware dropping discarded this routing — a miss weighted below the threshold, never read, weight zeroed. Always 0 with --drop-cold-experts off. |
(turn, phase, step, layer, slot) is unique. Two asymmetries are deliberate:
residencyis per routing,expert_bytesis per read. During prefill many tokens of one batch may route the same expert; the streamer reads it once, so only the first row carries the bytes while every row keepsresidency=0— each of those routings did face a cold cache. Summingresidency==0therefore over-counts misses versuscache_hit_pctin prefill; summingexpert_bytesis right. In decode (one token per step) the question does not arise.dense_bytesis static,expert_bytesis not. Dense weights are mmap-resident and never streamed, so there is nothing to measure per step:dense_bytesis what a cold layer costs to page in, stated once. Per-layer I/O time is absent for the same kind of reason — under--overlapreads complete asynchronously, so any per-layer timing would be fiction.weightandresidencydescribe the router;droppeddescribes the policy. When dropping is on, a discarded routing keeps the weight the router gave it and the residency it faced — the trace records the routing that was chosen — whileexpert_bytesfalls to0, because a dropped expert is never read. Summingexpert_bytestherefore still measures real flash traffic, anddroppedis what explains the gap againstresidency==0.
The last layer has only one prefill step, and that is real. Before the final layer's FFN,
llama.cpp gathers only the tokens whose logits were asked for (inp_out_ids; see `il == n_layer
- 1
inthird_party/llama.cpp/src/models/*.cpp`). The engine asks for the last token only, so during prefill the last MoE layer routes exactly one token while every other layer routes the whole prompt. The trace reports this faithfully — that layer's row carries the final prompt position, not position 0 — so do not read the gap as lost rows. It also means a long prompt warms every layer's experts except the last one's.
A step below zero would mean a row that could not be attributed to a position (more than one
output token in a batch). The CLI's greedy loops never produce one.
Join it to --csv on step (subtracting the prompt length from the trace's step for the
decode phase) to put per-token wall time next to what was routed.
scripts/route-analyze.py reads the file — stdlib only, nothing to install:
python scripts/route-analyze.py trace.csv # the default view set
python scripts/route-analyze.py trace.csv --view matrix --steps 0-15 --layers 0-11
python scripts/route-analyze.py trace.csv --view reuse # reuse distance -> cache policy
Size: roughly steps x moe_layers x n_expert_used rows — ~200k rows (~8 MiB) for a 500-token
decode on a 48-layer, top-8 model. Prefill adds a row per prompt token, so a long prompt
dominates the file; --phase decode is the usual lens.
Real traces from Qwen3-30B-A3B, Gemma-4-26B-A4B and gpt-oss-120b on device, with the analysis they support, are archived in bench-data/2026-07-15-route-trace/.
Session mode
With --session, bmoe-cli keeps the model loaded and the expert cache warm across prompts
instead of exiting after one generation (see session.md). Requests arrive as one
JSON object per line on stdin; responses interleave control lines with the same per-token
lines above on stdout. The control lines are also BMOE_<TAG> {json}, so a per-token parser
extends to them naturally.
Requests (stdin):
{"cmd":"generate","id":<int>,"prompt":"<string>","n_predict":<int>,"think":<bool>,"clear_kv":<bool>}
{"cmd":"decide","id":<int>,"prefix":"<string>","suffix":"<string>","choices":["<string>",...],
"reuse_prefix":<bool>} # needs --decide; always rendered with reasoning off; see decide.md
{"cmd":"cancel"} # interrupt the in-flight generation; the session stays loaded
{"cmd":"close"} # end the session (EOF on stdin does the same)
prompt is JSON-escaped (newlines as \n); n_predict/think/clear_kv are optional and
default to the process's flags / true. clear_kv:true starts a new chat (drops the KV and
the engine-held conversation); clear_kv:false continues the conversation — send only the new
user message, the engine re-renders the whole history and reuses the KV prefix (see
session.md). cancel may arrive at any time, including mid-generation.
Responses (stdout):
BMOE_READY {"load_s":<float>,"arch":"<string>","n_ctx":<int>,
"think_ctl":"template|prefill|none","n_expert_used":<int>} # once, after the model loads
BMOE_BEGIN {"id":<int>} # a generation started
BMOE_LOAD / BMOE_PROGRESS ... # per token, as above
BMOE_DONE {"id":<int>,"cancelled":<bool>,"tokens":<int>,"tok_s":<float>,
"prefill_s":<float>,"prefill_tps":<float>,"load_s":<float>,"cache_hit_pct":<float>,
"n_prompt":<int>,"n_past":<int>,"compute_s_tok":<float>,"io_s_tok":<float>,
"cache_resident_mib":<float>,"cache_budget_mib":<float>,"read_mib":<float>,
"stall_s_tok":<float>,"mgmt_s_tok":<float>,"majflt_tok":<float>,"cpu_s_tok":<float>,
"prefill_cpu_s":<float>,"prefill_read_mib":<float>,"prefill_io_s":<float>,
"prefill_stall_s":<float>,"prefill_mgmt_s":<float>,
"prefill_dev_tokens":<int>,"prefill_dev_nodes":<int>,"prefill_dev_read_mib":<float>,
"prefill_dev_stall_s":<float>,
"token_demand_mib":<float>,"mtp_drafted":<int>,"mtp_accepted":<int>,"mtp_decodes":<int>,
"mtp_draft_s_tok":<float>,"drafted_steps":<int>,"loop_overhead_s_tok":<float>,
"reasoning":"<string>","text":"<string>"}
BMOE_DECIDE {"id":<int>,"cancelled":<bool>,"best":<int>,"choice_logp":[<float|null>,...],
"n_tokens":<int>,"n_reused":<int>,"n_prefilled":<int>,"restore_s":<float>,
"store_s":<float>,"prefill_s":<float>,"prefill_cpu_s":<float>,"prefill_read_mib":<float>,
"prefill_io_s":<float>,"prefill_stall_s":<float>,"prefill_mgmt_s":<float>,
"prefill_dev_tokens":<int>,"prefill_dev_read_mib":<float>,"prefill_dev_stall_s":<float>,
"prefill_dev_routed":<int>,"prefill_dev_demand":<int>,"prefix_state_mib":<float>}
BMOE_ERROR {"id":<int>,"fatal":<bool>,"msg":"<string>"}
A decide request is answered by BMOE_BEGIN and then one BMOE_DECIDE line, with no per-token
lines in between: nothing is decoded (decide.md). choice_logp[i] is the log-probability
of the first token of choices[i] over the whole vocabulary, in request order, and null where it
is minus infinity. best is the index of the highest. n_reused tokens were restored from the kept
prefix state and n_prefilled were prefilled; the prefill_* keys read exactly as BMOE_DONE's.
prefill_dev_tokens is how many of the prefilled tokens ran on the prefill device (0 without
--prefill-device); prefill_dev_read_mib and prefill_dev_stall_s are its expert arena's reads and
the time the graph waited on them, as in BMOE_DONE and apart from the streamer's prefill_read_mib.
In routed mode (the default), prefill_dev_routed counts the (layer, expert) pairs the prefill routed to
and prefill_dev_demand those the prediction missed and the arena read at the routing node (both 0
with --no-prefill-routed). prefix_state_mib is the memory the kept state holds after the call. There is
no think key: a decision is always rendered with reasoning off. reuse_prefix defaults to true. Colliding choices (two sharing a first token), no choices, a prompt past
n_ctx, or a session opened without --decide answer BMOE_ERROR with fatal:false.
BMOE_DONE's mtp_* keys are the self-speculation counters (all 0 without speculation, and the
same keys whichever source drafted): mtp_accepted / mtp_drafted is the acceptance on that turn,
and tokens / mtp_decodes is the amortisation the turn actually achieved. mtp_draft_s_tok is what
the drafting cost, and loop_overhead_s_tok the whole between-decode gap it lives in — tok_s
excludes both, so 1 / (1/tok_s + loop_overhead_s_tok) is the rate a user actually experiences.
drafted_steps is how many passes drafted at all: it equals mtp_decodes for the head and is lower
for --ngram, which decodes plainly when it has no match. See mtp.md and
ngram.md.
The prefill_* keys are the prompt phase's own attribution (#173): prefill_read_mib / _io_s /
_stall_s / _mgmt_s are deltas of the same cumulative streamer counters the decode fields come
from, taken across this turn's prefill chunks, and prefill_cpu_s is process CPU over the same
window (an upper bound on compute-thread CPU-equivalent time, as everywhere). Read them with the
same per-phase rules: prefill_io_s is summed lane busy time under overlap and can exceed the
wall; prefill_stall_s is the union of stalled intervals. On a >RAM model the prompt is what the
user actually waits for before the first token, and until these keys it had exactly one number
(prefill_s) with no compute/flash split at all — the phase the decode-oriented counters could
not see. They are 0 with streaming off (prefill_cpu_s still reported).
BMOE_READY's n_expert_used is the effective routing width, after any --n-expert-used
override (0 on a non-MoE model). A UI needs it to say anything sensible about
--drop-cold-experts, whose threshold is a fraction of 1/top-k: the same
percentage trims a tail at 8 experts and takes half the routing at 2.
BMOE_READY's think_ctl states how a "think":false request can be honoured on the model that
was just loaded, so a UI need not offer a control that does nothing. It is decided by rendering the
model's own chat template, not from a list of model names:
| value | meaning | what the UI should do |
|---|---|---|
template |
passing the flag is all there is to do: either the template reads it (Qwen3, Gemma 4), or the model does not reason and there is nothing to suppress | offer the toggle |
prefill |
the template ignores it, but reasoning is a structural section the turn can start past (harmony/gpt-oss) | offer the toggle |
none |
the model reasons on every turn and cannot be asked not to (LFM2.5) | show the control disabled, and say why |
Which one a model gets is decided from data the model supplies, never from its name.
none requires positive evidence that the model reasons and owns the span it reasons in: it
declares a <think>-style pair and its template actually uses it. A span the model opens and
closes itself is its own, so handing it one already closed and empty is only a suggestion — LFM2.5
reasons straight past it and emits the reasoning untagged into the answer, worse than not asking
at all. Both halves of the test matter, because handlers publish the tag pair for a whole family:
the non-reasoning members (LFM2-8B-A1B, LFM2.5-Instruct) advertise a <think> they never emit, and
reporting those uncontrollable would tell the user "this model always reasons" about a model that
never does.
prefill is the opposite shape: no span of the model's own, but the format separates reasoning
structurally (a channel), and starting the turn past that section is not something it can decline.
BMOE_DONE carries the end-of-generation summary (the one-shot mode's generation: /
moe-stream: text lines are not emitted in session mode). n_prompt is the tokens actually
prefilled this turn (the suffix after any reused KV prefix), and n_past is the total context
length after the turn — so a multi-turn UI can show both per-turn prefill cost and how full the
context is. prefill_tps is the prompt prefill rate; compute_s_tok/io_s_tok are the per-token
AVERAGES over the run (so a UI can show an average compute-vs-I/O split, not just the last token).
cache_resident_mib/cache_budget_mib track the fixed cache, read_mib is the
total flash streamed this generation, and stall_s_tok/mgmt_s_tok the per-token overlap stall and
cache-management cost. text is the final answer and reasoning the final thinking span (empty
unless the model reasoned), same split as the per-token lines. BMOE_ERROR with fatal:false is a rejected
request (e.g. the prompt plus n_predict exceeds n_ctx) and leaves the session usable;
fatal:true means the process is ending.
Decode traces
--compute-trace PATH and --io-trace PATH decompose the two terms the per-token CSV can only
report as totals. The route trace answers what the router asked for; these answer where the
time went, and they exist because the headline number they decompose is not measured at all —
compute_ms is a residual, so every cost the engine does not itself clock (page faults, scheduler
stalls, the matmuls) is pooled into it.
Both are diagnostics, not telemetry, and both perturb what they measure. A traced run is not a benchmark run. Read the shares, not the absolutes.
Both work in --session mode too, appending across turns like the per-token CSV.
--compute-trace — one row per graph node
Returning true from the eval callback makes ggml compute exactly up to that node, synchronize,
and call back — so the wall delta between consecutive boundaries is that node's real compute time,
measured, not inferred. The same boundaries sample major faults, which is the point: on a >RAM
model most of "compute" is flash faults, and no residual can show that. The cost is a barrier per
node and no operator coalescing, so the total is inflated well above an untraced run.
Unlike the other traces this one does not need --moe-stream: it times the graph, which a
plain mmap run has too, so a dense baseline can be traced and compared against a streamed one.
# compute_trace v1
# model=... arch=qwen3moe n_layer=48 n_threads=4 io_threads=4 o_direct=1 overlap=0
turn,phase,step,seq,layer,op,name,wall_ns,majflt
0,1,29,0,-1,GET_ROWS,embd,428500,0
0,1,29,1,0,RMS_NORM,norm-0,19500,0
seq is the node's execution order in the decode; layer is parsed from the node name's -<il>
suffix (-1 = belongs to no layer: embeddings, the output head, masks). op and name are raw —
which node is attention vs dense FFN vs expert matmul is naming policy that varies by
architecture, so the engine reports what the graph said and the analysis script classifies.
--compute-trace-layers — one row per layer segment
The per-node trace's barrier count is also its distortion: ~3000 barriers per token serialize the
graph against the expert stream, so on a model that streams heavily the trace mostly measures its
own serialization. Layer granularity isolates only the first node of each layer — ~n_layer
barriers per token — which preserves operator coalescing and, crucially, the async expert
prefetch: the io lanes keep reading across a boundary, so the traced numbers sit close to an
untraced run and can be compared across models. The trade is per-op detail: a row aggregates
everything since the previous boundary.
Rows share the per-node schema with op fixed to LAYER. name says which segment: blk.<il>
is layer il's, pre is the embedding lookup before layer 0, and post — emitted when the batch
closes — is the last layer's tail plus the final norm and LM head. The routing nodes the streamer
isolates anyway also close a segment (a barrier that exists untraced too, so it costs nothing
extra); those rows carry the same blk.<il> name and simply sum into their layer.
scripts/decode-analyze.py compute detects the granularity and prints the per-segment table.
--io-trace — one row per flash read
Needs --moe-stream (no engine-issued reads without it). Records every pread the streamer
issues, tagged with the (layer, expert, projection) it serves.
# io_trace v1
turn,phase,step,layer,expert,proj,lane,spec,offset,req_bytes,read_bytes,latency_ns
0,1,29,0,87,1,0,0,1526304,65536,69632,416800
req_bytes is what the caller wanted; read_bytes is the aligned window actually pulled — the
gap is O_DIRECT alignment waste, and read_bytes is what effective bandwidth must be judged
against. spec=1 marks a speculative prefetch read. Rows are stamped with the decode they were
drained after, so a read straddling a token boundary is attributed to the decode that flushed it.
Reading them
scripts/decode-analyze.py (stdlib only) reports what each file is for:
scripts/decode-analyze.py compute ct.csv --layers # share by op, fault attribution, by layer
scripts/decode-analyze.py io io.csv --adjacent # latency percentiles, size/bandwidth, lanes,
# and the coalescing ceiling