Skip to content

MoE look-ahead PR-P1: producer (NOT LANDABLE, see body) - #127

Open
ddvnguyen wants to merge 32 commits into
feat/763-reconcile-qwen4exp-mtpfrom
feat/moe-lookahead-p1
Open

ddvnguyen wants to merge 32 commits into
feat/763-reconcile-qwen4exp-mtpfrom
feat/moe-lookahead-p1

Conversation

@ddvnguyen

@ddvnguyen ddvnguyen commented Sep 13, 2026

Copy link
Copy Markdown
Owner

PR-P1: look-ahead prefetch producer (NOT LANDABLE)

Base is feat/763-reconcile-qwen4exp-mtp (PR #120 head), the same deviation PR #126
took for the llama.cpp-side consumer, as accepted by the reviewer.

This branch contains the complete producer implementation plus the measured blockers.
It must not merge in this state. The three blockers below are all reproducible,
deterministic and measured on the rig; the first one is a correctness blocker.

What is implemented

  • GGML_OP_MOE_PREFETCH end to end: enum, constructor, op name, symbol, CPU dispatch
    as a documented no-op, ggml_get_n_tasks, RPC op list, backend-ops skip list.
  • The graph-level producer: build_moe_lookahead() picks layer il+1's experts from
    layer il's post-attention state (build_lora_mm on ffn_gate_inp, then
    ggml_argsort_top_k) and ends in ggml_moe_prefetch(), gated on --moe-lookahead > 0,
    ctx_type == DEFAULT and decode rows only.
  • Call sites in src/models/qwen4exp.cpp and src/models/qwen35moe.cpp.
  • Reserve-time pool preinstall (preinstall_legacy_pools()), the -1 "snapshot not
    published" contract, and a bounded self-healing retry in the consumer.
  • A debug-only recall instrument behind GGML_CUDA_MOE_LOOKAHEAD_DEBUG.
  • The producer must use ffn_up_exps, not ffn_gate_up_exps: ffn_gate_up_exps is
    null for every layer of this model. The consumer resolves the layer by name, so any
    of the layer's cached expert tensors works.

Blocker 1: the producer changes the model output

Logits are not preserved. Same prompt, same flags, only --moe-lookahead differs:

configuration PPL all-logits md5
look-ahead 0 (baseline) 3.0613 fc1da5b9e41d249e7da51791a934d561
look-ahead 8, full producer 2.9434 8e0a9d10e9095ce7806f645e7edbba0e
look-ahead 0, CUDA graphs disabled 3.0613 fc1da5b9e41d249e7da51791a934d561
look-ahead 8, prefetch op removed, prediction only 2.9434 8e0a9d10e9095ce7806f645e7edbba0e
look-ahead 8, prediction reads a trunk tensor, no extra chain 3.1849 4ba9f8e7a60ca9830b0dcbed073ffc3e
look-ahead 8, gate MUL_MAT only, no argsort, no op 3.1377 9bbf44eb3b98b5503ed2d1f9b028dda3
look-ahead 8, neutral COPY node only 3.0613 fc1da5b9e41d249e7da51791a934d561

Readings, all deterministic across reruns:

  • The extra MUL_MAT that reads ffn_gate_inp is the trigger. Removing the argsort and
    the prefetch op does not remove the divergence.
  • A neutral extra node (a COPY of an existing tensor marked as an output) leaves the
    logits byte-identical, so the scheduler is not merely shape-sensitive; the platform is
    shape-neutral for a node that does not touch the router.
  • Disabling CUDA graphs on the baseline reproduces the baseline exactly, so losing graph
    capture is not the cause of the numeric change.
  • A count-based gate is the likely mechanism: the same use_counts proof that rejects
    the graph for the prefetch node (graph=unproven(14)) also covers the router tensors,
    and an extra reader of ffn_gate_inp changes those counts. This is unconfirmed, and it
    is the one open question left.

An earlier handoff recorded these logits as byte-identical. That reading came from a
build in which the producer never ran. It is refuted here.

Blocker 2: every prediction is dropped, so the feature is inert

acquire_legacy_cache() installs a new record only while the target group's authority is
GGML_CUDA_MOE_GROUP_AUTHORITY_LEGACY with admission open. On this rig the groups are
not under legacy authority, so the pool install fails for every target:

E moe-cache: look-ahead could not install 144 of 144 MoE expert cache pools;
             the target layers are not under legacy cache authority, so look-ahead prefetch is inert
W moe-cache: look-ahead prefetch found no installed pool for the target layer, prediction dropped

The producer runs and the consumer resolves the right layer, but no copy is ever
enqueued. This answers the design's open question Q1 negatively: the cross-layer pool
install is not reachable in this configuration.

Blocker 3: -13% decode throughput

arm decode t/s
look-ahead 0 43.23
look-ahead 8 37.71 (also 37.57, 37.61, 37.68 across runs)

The ids readback synchronizes the stream, which forces use_cuda_graph = false for every
graph containing the op. The precedent is ggml-cuda.cu ggml_cuda_mul_mat_id_needs_sync
at the same decision point, and the demand path's own ids readback has the same cost class.
The feature stays default-off.

Correctness work that is independent of the blockers

  • ggml.c: the per-node src loop must not count GGML_OP_MOE_PREFETCH sources. The op
    reads no tensor values, and counting them made the MoE grouped-decode certificate reject
    the graph:
    grouped decode certificate failed: ... graph=unproven(14) followed by a failed graph
    compute. With the exclusion, zero certificate failures.
  • preinstall_legacy_pools() returned 0 when the candidate snapshot was not published,
    which is indistinguishable from success, so the caller latched "pools ready" forever.
    It now returns -1 and the caller only latches on a real result.
  • The consumer retries the install behind a cooldown, bounded at 8 attempts.

Verification performed

  • test-moe-cache on the final code: OK, exit 0, including
    test_lookahead_prefetch_eviction_guard OK.
  • All builds are ninja -C build llama-server test-moe-cache llama-perplexity, CUDA arch
    86;120.

Recommended decision

  1. Do not merge.
  2. Decide whether the look-ahead producer is worth pursuing at all on this rig. Even a
    perfect producer has nothing to page into while the target layers are not under legacy
    cache authority (blocker 2), and the readback cost is structural (blocker 3).
  3. If it is pursued, blocker 1 has to be understood first: an extra reader of
    ffn_gate_inp must not be able to change the model's output. That is a property of the
    fork's router identification or of its count-based proofs, not of the prediction math,
    and it needs to be fixed where it lives.

Diagnostics kept on the branch (env-gated, inert when unset)

Added after the reopen-transport ruling commit, at the owner's request that the
instruments stay in the tree for troubleshooting rather than be reverted:

  • 9d24345de - phase attribution probe (GGML_CUDA_MOE_PHASE_PROBE=1) and off-lease
    recall scoring (GGML_CUDA_MOE_LOOKAHEAD_DEBUG=1), plus the two cache populations that
    were counted but never printed (prefetch_dropped, evicted_prefetched_unused).
  • 570370183 - measured results in docs/moe-lookahead-design.md.

Both probes are inert with the env vars unset (one cached getenv behind a static bool)
and both turn themselves off with a single stderr note if an event or a memcpy cannot be
issued, which is what happens under CUDA graph capture, so they cannot break a capture
run. An attribution needs GGML_CUDA_DISABLE_GRAPHS=1, because replay steps never visit
the host dispatch loop where the events are recorded. This does not change the three
blockers below: the producer is still not landable.

Measured on the RTX 3060, ctx 81920, cache 42, single 13,946-token prompt, 256 decode
steps at 96.90 ms/step (10.43 t/s). Phase rows sum to 95.7 ms, so the split covers the
step:

bucket ops ms/step share
MOE MUL_MAT_ID, MOE_PREFETCH, ARGSORT, TOP_K 67.18 69.3%
DENSE every other matmul and elementwise op 23.80 24.6%
ATTN flash-attn, indexer, SSM/GDN, rope, softmax 1.41 1.5%
OTHER copy/cont/reshape/view/permute/transpose 3.30 3.4%
PLE GET_ROWS on per_layer_token_embd 0.00 0.0%

Dispatch state mode_legacy=3 mode_direct=253; prefill (28 ubatches, 4555 ms each) is
87.9% MoE. Recall scored in the dispatch loop instead of the legacy blk.47 lease: 64.5%
of the used experts predicted, 80.6% of the predictions used; the other two populations
are structural zeros because nothing is offered to the installer (decode phase line
ops=0, legacy cache authority).

The bytes/token this implies (GGUF tensor inventory, 10 of 512 experts per layer):
1031 MiB of expert weights, 3.34 GiB of dense/attention/head/shared, 4.35 GiB total, and
the MoE phase streams its 1.01 GiB at 16 GB/s, i.e. 5.4% of the card's bandwidth, while
the dense phase reaches 126 GB/s. The decode step is MoE-kernel-bound, not transport- or
capacity-bound: prefetching ahead layers cannot make the kernels faster, and the expert
H2D (337.6 MiB/token, 3.71 GB/s of the gen4 x4 link's 7.88 GB/s) is already fully hidden
(calls=ready=12240, ready_min=255).

ddvnguyen and others added 5 commits September 13, 2026 23:03
…kahead

Wire the consumer half of docs/moe-lookahead-design.md: a new
--moe-lookahead N gate (default 0, env LLAMA_ARG_MOE_LOOKAHEAD) that asks the
CUDA MoE cache to prefetch the next layer's expert weights during the current
layer's compute.

The prefetch path mirrors acquire with is_prefetch=true, wait_for_compute=false
and pin=false: no stream synchronize, no compute-stream wait, pinned slots are
skipped, expert ids outside [0, n_experts) are ignored, and prefetch telemetry
is accounted separately from demand. Host-to-device copies are batched through
cudaMemcpyBatchAsync when CUDART >= 12.80, with a per-copy fallback otherwise.
Targets whose buffer is not MoE-cached are skipped with a one-shot warning.

--moe-lookahead 0 leaves the decode step unchanged. A non-zero width without
--moe-expert-cache-size > 0 fails loudly at model load.

Adds test-moe-cache --lookahead-prefetch-only and the lookahead prefetch legacy
layer case.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
The speculative prefetch path picked its victim with plain LRU, so a
prediction could displace an expert that demand had already proven hot. The
eviction guard the design requires (docs/moe-lookahead-design.md, "Invariants
to preserve" #2 and the colibri safety invariant at c:1462-1478) was missing:
only the pin guard - never evict a slot a running GEMM is reading - was
implemented, on both the demand and speculative paths.

Port colibri's PILOT_EVICT_GUARD. A speculative fill may displace a resident
only when that resident is not genuinely warm; a resident is protected when it
has at least 2 demand accesses AND is clearly hotter than the prediction, by
the 25% + 4-frequency hysteresis in LFRU score units (score ported from
colibri c/tier.h:40-43: frequency in the high bits, recency saturating in the
low byte). A blocked prediction is dropped rather than forced in, so it can
never thrash a demand-loaded expert.

Heat is recorded on demand accesses only. Counting predictions would let a
speculation inflate the very score that decides whether it may displace a
resident, which is the failure colibri hit in ggml-org#490.

Refactor the eviction core so the demand and speculative paths cannot drift:
ggml_cuda_moe_cache_select_victim_locked picks the LRU unpinned slot and
applies the guard for speculative fills, and
ggml_cuda_moe_cache_install_fill_locked records the eviction telemetry and
publishes the new entry. The copy-batching split in the prefetch path is
unchanged, and the free-slot, pin and unknown-eid behaviour is identical.

Add phase_prefetch_dropped so a guard that drops everything (colibri ggml-org#490)
is visible rather than silent, and extend the test prefetch accessor with it.
Add the lookahead prefetch eviction guard case: a 3-slot pool, a warm resident
survives a prediction for a never-seen expert (dropped, no copy, no eviction,
still a demand hit), while a once-demanded resident is evicted for it.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
…kahead

Predict layer il+1's top-k experts from layer il's post-attention state and page
them while layer il still computes, so the H2D copy overlaps compute instead of
stalling the next layer. Adds GGML_OP_MOE_PREFETCH (an explicit no-op outside
CUDA), the graph-level producer, the per-context gate and a reserve-time pool
preinstall, plus a debug-only recall instrument.

Measured on the rig (Qwen3.8-Flash-Next-APEX-I-Mini, -b 1 -ub 1 so every ubatch
is a decode row, --moe-expert-cache-size 84, look-ahead width 8):

- decode 37.6 t/s against 43.2 t/s with the feature off, about -13%, because the
  ids readback synchronizes the stream and forces use_cuda_graph = false.
- the MoE grouped-decode certificate rejects the graph unless GGML_OP_MOE_PREFETCH
  is excluded from the backend use counts, otherwise the run dies with
  "graph=unproven(14)" and a failed graph compute.
- logits are not preserved: PPL 2.9434 against 3.0613, and byte-different dumped
  logits. Isolation shows the extra MUL_MAT that reads ffn_gate_inp is the
  trigger; a neutral extra node does not reproduce it, disabling CUDA graphs does
  not reproduce it, and the prefetch op itself contributes nothing.
- every prediction is dropped in this configuration. A pool can only be installed
  while its group is under legacy cache authority with admission open, and the
  groups here are not, so preinstall_legacy_pools() fails for all 144 targets and
  the look-ahead has nothing to page into.

Not landable as is. The review PR carries the full isolation matrix.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
…roducer

Adds a measured-outcome section to the design note: the three blockers with their
numbers, the isolation matrix that pins the trigger to the extra MUL_MAT reading
ffn_gate_inp, Q1 answered negatively (the authority regime refuses the cross-layer
pool install), the throughput cost of the ids readback, the already-recorded
byte-identical claim refuted, and the future direction (early-router copy worker
plus device-to-host mailbox) if the track is ever re-approached. Also corrects
ffn_gate_up_exps being null, the preinstall point, Q3 and Q4.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
Recall was the one unmeasured number in the park decision. Measured it properly:
the horizon is one MoE layer (never a token), and the demand path cannot score it
because during decode only blk.47 reaches the host-visible ids read - the rest run
in the certified grouped path. Ground truth was therefore built in-graph from each
layer's own router matmul on its real FFN input.

Width 2/4/6/8/10 gives 97/95/91/86/79% recall of the predicted set against
19/38/55/69/79% coverage of the 10 experts the model actually uses; width 8 is
about 22x chance. RTX 3060 reproduces width 8 within noise (85.85% / 68.68%).

Also records the two traps that had kept this unmeasured: the committed
instrument's GGML_LOG_INFO never reaches the server log at default verbosity, and
cached-path scoring only ever sees the one layer holding a legacy lease.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
…rence

The design cites colibri's 71.6% PILOT recall on GLM-5.2 as the expectation for this
mechanism. Records that the producer measures 91.50% at width 6 and 86.21% at width 8,
with the explicit caveat that the models and expert counts differ so the comparison is
not like-for-like.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
…design invariant

preinstall_legacy_pools returned -1 for all 144 targets and every prediction was
dropped. Recording the ruling so it is not re-litigated: the acquire_legacy_cache
new-record latch (authority == LEGACY && !admission_closed) binds pool creation to a
certified execution, and an unauthenticated install would let a caller claim VRAM
pools certification never proved - the overcommit class README.md:25 warns about.
L+1 is never the authoritative group while L runs, so cross-layer install is
unreachable by design, not broken. If the track reopens the seam is authority
publication at graph reserve/certification time, which is a certification-contract
change needing its own review.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
…evice-side)

Reviewer ruling on how blocker 3 would be fixed if the track reopens. Mailbox
approved as the transport (it reuses the shipped early-router copy worker plus the
async paging half); the FreeToken-style device-side gather is rejected because it
needs device-resident slot management and would be a second cache implementation,
reintroducing the demand/speculative drift that select_victim_locked and
install_fill_locked eliminated. Deleting the use_cuda_graph=false rule alone does
not suffice: the op is capture-incompatible as written (pageable D2H memcpy,
stream sync, host-side slot booking), and a captured cuStreamWaitValue32 bakes an
expected value, so it must be refreshed per step or replaced by a polling kernel.
Minimal change set, required guards, and the unchanged sequencing (census
exclusion #128 -> authority publication -> transport) are recorded.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
Two env-gated diagnostics, off by default, kept for troubleshooting the
look-ahead track:

- GGML_CUDA_MOE_PHASE_PROBE=1 attributes each step's GPU time to op classes
  (attention ops, the MoE expert path, the per-layer embedding read, dense
  compute, plumbing), separates decode from prefill by inter-step wall time,
  reports the dispatch mode that produced each step, and prints a per-layer
  table. One CUDA event pair per maximal run of same-(phase, layer) nodes,
  read back once per step after a single stream sync (~2.6% of decode
  throughput on the RTX 3060).
- GGML_CUDA_MOE_LOOKAHEAD_DEBUG=1 scores the recorded prediction against the
  ids the router actually selected, for every MUL_MAT_ID node (the ids are in
  src[2]), i.e. off the legacy blk.47 lease, and reports recall and precision
  plus the used-not-predicted population. The other two populations are cache
  counters that were counted but never printed; they now appear on the
  moe-cache-phase line as prefetch_dropped and evicted_prefetched_unused.

Both probes turn themselves off with a single stderr note if an event or a
memcpy cannot be issued, which is what happens under CUDA graph capture, so a
diagnostic cannot break a capture run; during replay the host never visits the
dispatch loop either, so an attribution needs GGML_CUDA_DISABLE_GRAPHS=1. With
the env vars unset the only added work is a cached getenv lookup behind a
static bool.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
…sults

Instrument A on the RTX 3060 at ctx 81920 / cache 42: a decode step is 69% MoE
execution, 25% dense compute, 1.5% attention ops and 0% per-layer embedding,
with the phase rows summing to the step time; prefill is 88% MoE. In the same
window SM sits at 95-99% and the DRAM controller at 19-26%, the PCIe link is
gen4 x4 carrying 337.6 MiB of expert traffic per token at ~47% of its ceiling,
and the grouped telemetry reports every staged copy already complete, so the
limiter is MoE kernel execution rather than PCIe or VRAM bandwidth.

Instrument B, scoring predictions in the dispatch loop instead of the legacy
blk.47 lease: 64.5% of the used experts were predicted and 80.6% of the
predictions were used, the same order as the earlier in-graph measurement. The
other two populations are structurally zero in this configuration because the
consumer is inert (decode phase line ops=0, legacy cache authority printed once),
so every prediction is simply unused. Also records the independent finding that
the rig's -ot per_layer_token_embd=CPU is redundant and inert.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
@ddvnguyen

Copy link
Copy Markdown
Owner Author

Measured addendum from the same 3060 run, because it changes what the consumer is worth:

moe-cache-phase: phase=prefill ops=4512 l1_hits=127479 l1_misses=128285 l1_hit_rate=49.84%
  h2d_copies=128285 h2d_mib=75928.77 prefetch_hits=122059 prefetch_misses=122327
  prefetch_used=4720 prefetch_h2d_mib=73356.04 evicted_prefetched_unused=114496
  prefetch_dropped=0 ids_d2h_mib=3833.06 ids_d2h_ms=464.692 op_cpu_ms=1398.584
  copy_wait_events=423 copy_wait_event_ms=0.292

During prefill the legacy lease path installs 122,327 speculative slabs, uses 4,720 of
them (3.9%) and evicts 114,496 without a single hit - 73.4 GiB of prefetch H2D plus
3.8 GiB of ids D2H per request for a 50% L1 hit rate, with no queue wait at all
(copy_wait 0.29 ms over 423 events). The copies are fully hidden; the install decisions
are ~96% wasted. Decode does not use this path at all (ops=9, the blk.47 lease) and runs
on the grouped path instead.

Two consequences:

  • The counters added in 9d24345 (prefetch_dropped, evicted_prefetched_unused) are
    what make this visible; the 114,496 wasted installs were counted but never printed.
  • The LFRU victim ranking behaves, but the install side over-installs by roughly 25x in
    prefill. Any reopening of this track should fix prediction/install precision before
    adding a wider look-ahead, otherwise extra prediction depth lands in
    evicted_prefetched_unused.

Blocker status is unchanged: the producer is still not landable.

ddvnguyen and others added 9 commits September 15, 2026 09:12
Adds the staged look-ahead fill (PR-B) end to end. The producer's predicted
ids are filtered on the device to predicted-and-not-resident, published from a
device kernel into a cudaHostAllocMapped mailbox, and DMA'd into the target
group's staging lane by the existing early-router copy worker. The gather
consumes staging bytes when the expert's position is ready and falls back to
the host slab otherwise, so a late or absent copy degrades the speculation
instead of failing it.

The completion handshake writes the flag with a 4-byte H2D copy of exactly 1.
The previous cudaMemset(..., 1, 4) writes 0x01010101, which never matches the
EQ 1 wait, so the first publish stalled the stream forever: GPU idle, two host
threads spinning, /health alive, SIGTERM ignored. The set now fails fast when
the worker is gone, so a dead worker cannot wedge the compute stream again.

Also:
- device-side lane counters (staged bytes and count, consumed, publishes,
  dropped, resident-skipped), drained and emitted every 16 dispatches;
- mmid branch counters and per-node phase attribution in ggml-cuda.cu, env
  gated and inert when unset;
- deterministic expert-id and stride modes for the mul_mat_id sweep in
  test-backend-ops, so the stride/ids matrix is reproducible rather than
  dependent on the random shuffle;
- design-document updates and a new residency-model note.

Gate-off cost on the staging path is one atomic load; no D2H copy and no host
synchronization are added to the decode graph.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
The look-ahead predicted layer il+1's experts immediately after layer il's
attention and issued the staging DMA there, on the stated intent that the H2D
would overlap "this layer's MoE compute". The MoE bucket is not compute-bound:
of its 81.04 ms, roughly 77 ms is the demand gather reading expert host memory
over the same PCIe link the staging transfer needs. A transfer issued alongside
the gather therefore does not overlap compute - it competes with the gather and
displaces it byte for byte. Displacement coefficient measured at 1.00 over three
runs (gather pattern 5.93 GB/s solo, 5.08 GB/s with copy-engine traffic
concurrent).

Issue the look-ahead after this layer's FFN instead, so the transfer rides the
following layer's attention, where nothing is reading host memory.

Measured on the real-prompt harness (767-token prompt, 200 output tokens, same
binary, RTX 3060, --moe-expert-cache-size 28, --moe-lookahead 1):

  control                     9.643 t/s   per-token p50 103.73 ms
  lookahead 1, paced          9.357 t/s   per-token p50 107.76 ms   (-3.0%)
  lookahead 1, unpaced        9.040 t/s   per-token p50 112.40 ms   (-6.4%)

Pacing recovers about half the deficit, so the look-ahead remains net-negative
and the residual cost is not yet attributed. Control is unchanged by this commit
(9.66 -> 9.64, within run-to-run spread), confirming the reorder is inert when
--moe-lookahead is off.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
…consumption ledger

Three changes, all in service of one question: why does the look-ahead stage and
consume, yet not pay?

1. Rolling readiness (moe-cache.cu). Readiness for the look-ahead staging was
   decided once per layer by moe_early_router_ready_positions, which sampled the
   copy worker's landing counter a single time and demoted EVERY not-yet-landed
   position to -1. Readiness was therefore all-or-nothing: a transfer that was
   95% landed and one that was absent got the same outcome. Readiness is now
   evaluated per expert inside the gather against the live counter, re-sampled as
   the sweep advances, so a copy lands and is consumed progressively. A caller
   that passes no counter keeps the legacy meaning of position >= 0, so the early
   router path is untouched.

2. GGML_MOE_LOOKAHEAD_ISSUE_AFTER_FFN (qwen4exp.cpp, qwen35moe.cpp). Selects
   whether the look-ahead for layer il+1 is issued after this layer's FFN (default,
   as in 168a907) or before it (legacy). The two positions couple the prediction
   to different parts of the layer, so this A/Bs them in one binary without a
   rebuild.

3. Consumption ledger (moe-cache.cu). The look-ahead's own counters printed only
   every 16 dispatches and never showed the consumed side, so staged-vs-consumed
   could not be read; the available number was the plan's miss count, which the
   look-ahead does not change when it merely replaces a demand read. Now drained
   every dispatch and printing consumed_mib_total / consumed_total /
   consume_pct_total (lane-lifetime). Existing fields keep their names, order and
   meaning; the new ones are appended.

Measured, real prompt, 200 output tokens, same binary, RTX 3060, probe off:
  control                    9.699 t/s
  lookahead 1, after FFN     9.398 t/s   (-3.1%)
  lookahead 1, before FFN    9.030 t/s   (-6.9%)
All three arms produce 202/202 identical completion tokens at temperature 0, so
neither the reorder nor the rolling predicate changes model output.

The consumption ledger is the finding: consume_pct_total is ~95% in BOTH
placements. The staged bytes are consumed. An earlier reading of the plan-level
miss counter as "the reuse collapses to zero under pacing" was wrong and is
retracted; that counter does not measure consumption.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
…-in plan-side admission)

`copy_mib` was n_misses * resident_bytes_per_miss - a derived figure that charges every miss
to the demand side of the ledger whether or not the gather actually fetched it. Once the
look-ahead stages an expert, the gather serves that expert from staging and no host read
happens, but the ledger still billed it. The look-ahead therefore appeared to ADD demand
traffic (+979.6 MiB over 199 steps) when the instrument could not see the saving at all.

GGML_MOE_LOOKAHEAD_ADMIT_IN_PLAN=1 makes the plan classify a would-be miss whose slab has
already landed as an admission served from staging, and subtracts exactly those from the
demand-side counters. It is classification only: the miss entry, its slot and its committed
mapping are unchanged, the gather lands the staged payload in that same slot, and no slot
table is written - the plan stays the single writer of slot_for_expert/expert_for_slot/
last_used/expert_frequency, and no new synchronization is introduced. The predicate is the
gather's staged_ready test verbatim, sampled one launch earlier; the landing counter only
grows, so every expert classified here is staged-served by the gather below.

Default OFF is behaviourally today's: probe arm reproduces need=94570 resident=43125
copy_mib=92239.6 exactly. Gate ON changes the ledger and not the clock (9.3915 vs 9.3984 t/s).

Measured, real prompt, 200 output tokens, probe on, steps=199:

  control              need=94570 resident=43125 copy_mib=92239.6
  lookahead 1, gate 0  need=95520 resident=43529 copy_mib=93219.2
  lookahead 1, gate 1  need=95520 resident=43529 copy_mib=86625.0

93219.2 - 86625.0 = 6594.2 MiB, which equals the look-ahead's independently counted
consumed_mib_total (6594.29 MiB) to four significant figures. Two instruments agreeing is
what makes this a measurement rather than a relabel. Against the control the look-ahead
removes 5614.6 MiB over 199 steps = 28.2 MiB/step = -6.1% of demand traffic.

So the earlier reading of copy_mib as evidence that the look-ahead does not reduce demand
traffic was wrong, and is retracted: the counter could not express the saving. The feature
does remove demand traffic; what it costs is that it stages more than it saves.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
…activation path

Adds GGML_CUDA_MOE_PHASE_PROBE_PER_STEP: one `moe-step: step= need= resident= misses=
miss_mib=` line per decode step, independent of the cumulative summary gate.

Non-perturbing by construction: each dispatch snapshots the counter table asynchronously
(event record on the step's streams, wait on a private non-blocking stream, async D2H into
pinned memory, event record) and the host reads it at the NEXT drain with cudaEventQuery.
No cudaStreamSynchronize and no cudaDeviceSynchronize anywhere in the decode loop, so the
pipeline is never drained and the clock being measured is not disturbed. Measured cost:
9.6593 t/s instrumented vs 9.6375 uninstrumented on the same binary, i.e. free.

Root cause of a defect found while validating it: a replayed CUDA graph never calls
prepare_decode, so per-token notes taken there existed only for the first few CAPTURE
tokens. The ledger emitted 3 lines for a 199-step run. The notes are now also taken in
activate_graph_resources, which runs for CAPTURE and for REPLAY, so every token is
accounted. Emitted lines now equal the decode step count and their sums partition the
cumulative summary exactly.

Real prompt, 200 output tokens, --moe-lookahead 0, 199 steps:
  need=95520 resident=43529 misses=51991 miss_mib=93219.28 (480 demands/step = 48x10)
  misses/token: min 107 mean 261.3 p50 262 p95 345 max 480 (step 0, cold cache)

Per-token latency model, fitted on the 196 non-CAPTURE steps:
  observed_ms = 25.7 + 0.3003 * misses,  r2 = 0.990
  0.3003 ms/miss at 1.881 MB/miss = 6.27 GB/s, which is the measured achievable link
  bandwidth. Decode is bandwidth-bound on expert misses, and miss count explains 99% of
  the per-token latency variance.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
…16, unchanged)

The grouped replacement rule is LFU-with-decay: plan->frequency_epoch = step >> 4, and the
effective frequency shifts right once per elapsed epoch, so the stored demand history
halves every 16 grouped planning steps and is zeroed after 32 epochs (512 steps). A decode
token is 48 planning steps (48 MoE layers, one launch each), so the policy's memory was
about one to two tokens. GGML_CUDA_MOE_FREQUENCY_HALFLIFE retunes the epoch length without
changing the decay law; unset, empty, non-numeric or non-positive falls back to 16, and the
value is read once at grouped-context creation next to the existing FREQUENCY gate.

This exists to settle a question with a measurement rather than an argument. Capacity is
VRAM-capped at N~53, so if the remaining headroom were in WHICH experts are kept, a longer
frequency memory would show it. It does not. All arms at N=53, look-ahead off, real prompt,
200 output tokens, per-step ledger on:

  half-life 16, LFU (default)   r = 58.0%   11.702 t/s
  half-life 256, LFU           r = 54.4%   11.026 t/s   (-6.1%)
  half-life 2048, LFU          r = 54.4%   11.033 t/s   (-6.0%)
  pure LRU (GGML_CUDA_MOE_FREQUENCY=0)     11.236 t/s   (-4.3%)

Longer frequency memory makes it worse, and so does dropping frequency entirely: the
recent set IS the recurrence set for this workload, and a long-horizon accumulator protects
experts that are no longer needed and evicts the ones that are. The default is the best of
the four, so the replacement policy is not a lever and was not one before this change.

The default arm also reproduces the pre-change 11.738 t/s to 0.31% on the previous build,
which is the regression check for the added kernel argument.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
The startup warning for --moe-expert-cache-size said the flag was set but
never said to what. That makes a cache-size sweep unverifiable from logs:
the only way to tell which N a run used was to infer it from the miss
count, which is exactly the quantity the sweep is trying to change.

Emit the resolved slot count in the same one-line warning. The value is
params.moe_expert_cache_slots, the same field handed to the CUDA
set_slots entry point a few lines earlier, so the logged value cannot
drift from the installed one.

Verified: loading with --moe-expert-cache-size 53 and 28 prints
n_slots=53 and n_slots=28 respectively. Logging only, no behaviour change.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
The moe-lookahead-stage line could not answer the obvious question about
the feature: of the experts it fetches ahead of time, how many does the
next layer actually want? It printed staged BYTES and consumed BYTES, and
it printed counters[3] twice under two names - prefetch_used (interval
delta) and consumed_total (lifetime) - so there was no staged-expert count
and no ratio.

Add staged_experts_total (counters[2]) and used_pct_total
(counters[3]/counters[2], expert counts, not bytes). Guarded like the
existing consume_pct_total. All twelve existing fields keep their names and
order, so existing parsers are unaffected.

Measured at --moe-expert-cache-size 42 --moe-lookahead 8: 35222 staged,
672 consumed, 1.91%. The same ratio is 95.2-95.5% at width 1, which is the
whole difference between the widths.

Also worth recording since the field names mislead: counters[4], printed as
"published", is a count of publish EVENTS, not experts.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
Adds GGML_CUDA_MOE_DEMAND_TRACE (+ ..._FILE) which drains, once per decode
step, the expert ids the grouped plan actually commits: one line per
(step, layer) as step=<u> layer=<u> ids=<comma separated>.

The record is taken from the plan's own unique_experts staging array at the
same statement that stages it, so it is the same data the plan acts on, and
the step index comes from a device-side per-layer counter so that CUDA graph
capture/replay advances it exactly like a direct launch.

This is the input an offline Belady oracle needs to bound how many of the
~225 misses/token any per-layer policy could avoid. At N=42 it captures
199 steps x 48 layers and reproduces need=480/step exactly.

Gate is presence-only and defaults OFF; nothing is allocated when it is off.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
Oh My Pi added 6 commits September 15, 2026 18:14
Stores the harness that produced every number in the MoE decode-throughput
work, so it travels with the code instead of living only on the measuring
rig.

benches/moe-cache/
  stream.sh            base driver: serve once, stream a real prompt, record
                       the arrival time of every token
  arm_*.sh             19 arms: cache sweep, order control, policy sweep,
                       VRAM budget, pacing, recall, correctness (greedy token
                       identity and perplexity), demand tracing, eviction and
                       admission A/B
  phase.sh etc.        older attribution drivers, kept for provenance.
                       phase.sh is the retired synthetic harness - its numbers
                       must never be mixed with stream.sh real-prompt numbers
  mixed_contention.cu  the PCIe gather-contention microbench
  expert-quant-*.txt   per-tensor quantization maps (1224 tensors; the 144
                       expert tensors retargeted)
  run_imatrix.sh       importance matrix pass - required because a sub-q2
  run_requant.sh         reduction is refused without one
  slotalloc/           the calibrated policy simulator. Reproduces the shipped
                       LFU-16 policy per-step on 8 of 8 recorded ledgers, which
                       is what makes its Belady and horizon results usable

docs/moe-lookahead-improvement-plan.md
  The design and measurement record, revision by revision.

Outputs (logs, per-token TSVs, demand traces) are gitignored: they are rig-local
evidence and are not source.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
The policy engine reproduces the shipped LFU-16 policy per-step exactly on
8 of 8 recorded ledgers, so its results are usable. It finds that
prediction-guided victim selection caps at +4.2% with a PERFECT same-layer
next-step predictor, and at exactly zero with the predictor in the tree,
whose horizon is one MoE layer rather than one step.

The +32% Belady gap needs 8-16 steps of same-layer horizon, and that cannot
exist for a non-speculative decoder: layer L's router at step T+1 consumes
layer L-1's output at step T+1, a function of token T+1, which is sampled
after the moment the prediction would be needed. The retention axis is
closed in principle, not merely unprofitable.

What remains reachable is the existing one-layer look-ahead, whose blocker is
named and measured: a ~2.05 MiB staging window against 15.4 MiB needed gives
1.91% usefulness at width 8 versus 95.5% at width 1.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
ev0p isolates the one retention policy with a measured positive sign in the
offline engine (223.57 vs 225.00 misses/step). The existing protect arm ran it
composed with look-ahead, which confounds the measurement.

Records what each arm is expected to show, so a null result is evidence rather
than an unexplained reading: ev8p should be exactly null because the tree's
predictor targets a different layer, and ev8a's win is timing, not bytes.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
Measured on the rig, one build, real prompt, 200 tokens, N=42:

  width 0  225.00 misses/step  10.8320 t/s   (default path, ledger byte-identical
                                              to the recorded baseline via cmp)
  width 1  225.00 misses/step  10.3841 t/s  -4.14%   94.78% of staged data used
  width 2  225.00 misses/step  10.4677 t/s  -3.36%
  width 4  225.00 misses/step  10.0130 t/s  -7.6%
  width 8  225.00 misses/step   8.0052 t/s  -26.08%  2.33% of staged data used

Misses are identical at every width, so the look-ahead never changes the hit rate,
and it loses even at width 1 where the staging mechanism works as designed. With a
displacement coefficient of 1.00 the fabric is serial, so a staged fetch displaces
a demand fetch that still has to happen: staged bytes are additive, not
substitutive. The earlier 1.91%-at-width-8 reading was a lateness symptom, not the
cause.

Consequence: N=53 with the look-ahead off is 11.79 t/s re-derived from
perftok-n53.tsv (+8.9% over 10.832, both with lookahead=0 per arm_cache_sweep.sh:25),
so N=53 now dominates every look-ahead configuration and the recorded mutual
exclusion costs nothing.

Also: ev0p measured 223.57 misses/step against the offline engine's predicted
223.57, validating the simulator end to end; ev8p - ev8r = -0.08 confirms the
predicted-mask pin is a no-op.

Adds arm_width.sh. Documents the admission-path crash (abort on step 2, before any
admission was committed) as an open blocker.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
…closed

Admission (the owner's request) works and is coherent but loses. With the
plan-residency trap fixed, ev8a completes 199 steps: 44.92 admissions/step,
real traffic falling 225.0 -> 223.9, so predicted experts do become hits - but
at ~1.1 fewer misses bought with 45 extra fetches, a 41:1 loss ratio, and 98.3%
of admissions evicting a resident rather than filling an empty slot. Net -9.1%
on top of the look-ahead's own -26.1%.

Mechanism resolved: staged_mib is a per-dispatch delta and *_total fields are
lifetime sums; conflating them produced a phantom 150x mismatch. Per-step
staging is 29.2/62.9/139.4/319.5 MiB at widths 1/2/4/8 and the waste column fits
~1.3 ms fixed plus ~0.10 ms per MiB/step wasted, so width 8 is a traffic problem
and the small widths are a fixed per-feature cost.

Retention closed at the operating point: recent1 buys -1.43 misses/step at N=42
but only -0.21 (-0.10%) at N=53 - the headroom shrinks as capacity grows.

Harness: anchored all kill patterns to bin/<binary>; a wrapper containing
"--target llama-server" was killing its own parent shell via the hygiene pkill.
Adds TAG_PREFIX and an arm selector.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
…nged

Implements GGML_MOE_EVICT_POLICY (lfu default, lru, protect, recent1),
GGML_MOE_ADMIT_PREDICTED and GGML_MOE_ADMIT_MAX, plus the counters
admitted_experts / admit_evictions / admit_skipped and staged_mib_total.
Committed because the measurement record now cites the build that produced
it; the feature it adds measures as a loss and is kept as the tested path.

Default path verified byte-identical, not merely argued: with the env unset
the plan kernel writes no counter, pin mode is NONE, admit_cap is 0, both new
branches are dead, and the ledger is cmp-clean against the recorded baseline
(199 steps, 225.00 misses/step).

Measured, N=42, real prompt, 200 tokens, look-ahead off unless stated:
- shipped LFU-16   225.00 misses/step  10.8320 t/s
- recent1          223.57              10.8316 t/s   (deterministic miss gain,
                                                     invisible in time)
- protect, width 8 223.49               8.0368 t/s   (mask pin = -0.08 vs
                                                     recent1: a no-op)
- admit=1, width 8 268.80 misses/step, 44.92 admissions/step, real traffic
                   223.9, 98.3% of admissions evicting a resident, 7.2730 t/s.
                   Coherent - predicted experts do become hits - but ~1.1 fewer
                   misses bought with 45 extra fetches, a 41:1 loss ratio.

Fixes a plan-residency trap that aborted the server on step 2 whenever
admission was enabled: the candidate list is built from slot tables as of
publish time, so it compensated for the previous plan's misses but not for its
admissions, letting two slots claim one expert.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
Oh My Pi and others added 6 commits September 15, 2026 21:55
Owner decision: stay at N=42 or N=36 rather than N=53, deliberately keeping VRAM
for MTP, and drive the look-ahead from the MTP head instead of the current
cross-layer heuristic.

N=36 measured, same session and binary, look-ahead off: 239.11 misses/step at
10.3626 t/s, i.e. -4.37% from N=42, for ~618 MiB freed. Marginal -2.35
misses/slot, consistent with -2.25 measured from 42 to 48 and Belady's -2.0.

Records that section 7.28's impossibility argument is scoped to a
NON-SPECULATIVE decoder, which is now load-bearing: MTP is speculation, so
running the router on draft hidden states yields same-layer demands for
T+1..T+H before the real tokens exist. That is the 8-16 step horizon the
retention axis needs, so the axis is closed for the pre-MTP fork rather than
permanently.

With the caveat that MTP fixes the prediction quality completely but the
staging lane only partly: retention removes fetches while staging can only move
them in time, and the lane lost 3.7% at width 1 where 94.85% of staged bytes
were useful. Spend MTP predictions on victim choice, not on staging.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
…e fork

Sizing (arithmetic on measured totals, no head run - none exists):
  one appended block = 913.3 MiB experts + 78.3 MiB non-expert = 991.6 MiB of
  weights; under --n-cpu-moe only the 78.3 MiB is VRAM, plus the head's own MoE
  cache at 3 banks x N slots x 1.793 MiB = 226 MiB at N=42 / 194 MiB at N=36.
  Total ~310-330 MiB (N=42) and ~280-300 MiB (N=36) against 1.55 / 2.2 GiB of
  headroom. Either operating point covers it. The traffic term is the risk:
  a full MoE block per draft step is +17.8% at H=4 unless cache-resident.

Source findings:
  - src/models/qwen4exp.cpp has a working DECODER_MTP graph for this arch
    (:52 nextn_predict_layers, :186 draft-only export, :313 graph_mtp).
  - The NextN tensors are created inside the trunk layer loop, after the
    `il < n_layer` continue, so a block is a full layer plus a head.
  - embed_tokens / shared_head_head are TENSOR_NOT_REQUIRED and absent for
    qwen4exp - the head reuses the trunk's, so no vocab-sized tensor.
  - build_moe_lookahead() already takes nextn_state as its input, so the plan
    is a change of input producer, not a new mechanism. Its only consumer today
    is GGML_OP_MOE_PREFETCH - the staging lane measured as a net loss in 7.30.
    Retargeting the consumer to victim choice is the actual work item.
  - The trained block is absent: 0 of 1224 tensors match nextn|mtp|draft.

Also records the erratum on the per-token latency/miss regression, which was a
misreading of perftok-*.tsv rows (sub-token deltas, not one row per token).

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
…e-dispatch

7.31 mapped the offline horizon curve onto MTP draft length H. Checked against
the driver this cycle: the premise does not hold.

What is true - verification does batch every draft position:
  server-context.cpp:580  sampled token and draft tokens go in the same batch
  server-context.cpp:619  batch asserted large enough for both
  server-context.cpp:1273 n_max + 1 > n_ubatch is rejected: one ubatch
  server-context.cpp:4559 acceptance samples over all n_draft + 1 positions
  moe-cache.cu            grouped cache, unique_experts per dispatch = union

Why that is not a look-ahead: layer L's router output for T+1..T+H is computed
in the same grouped dispatch that consumes it. The union is fetched once, in the
operation that revealed it. Retention changes which residents survive into LATER
dispatches, and those demands are unknown for the original reason - layer L's
demand for a future token needs layers 0..L-1's output for that token, and MTP
supplies draft TOKENS, not their trunk hidden states. 7.28's impossibility
argument is restated, not escaped.

Consequences recorded: the horizon curve is an offline bound in the same sense
Belady is; the +8%/+13% acceptance-discounted figures are withdrawn; MTP's only
ahead-of-time signal is for its own layer, whose consumer is the staging lane
measured dead. One loophole left as a measurement, not an argument:
--decode-overlap could split draft positions into separate dispatches, which the
moe-step ledger and the demand trace would show directly.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
…t condition

server-context.cpp:1273 refuses to start unless n_max + 1 tokens fit in a
single ubatch ("capped MTP replay requires an ubatch of at least %d tokens").
The whole draft is replayed in one forward, so overlap cannot be splitting it -
that check would fail on every start. 7.31's H-curve mapping is permanently
withdrawn, not conditionally withheld, and the ledger run proposed to confirm it
would only restate the start condition.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
#124

7.32.5 item 1 said the trained block does not exist. That was wrong in the way
that matters. It ships separately and is on the rig:

  /mnt/SSD/MTP/mtp-Qwen3.8-Flash-Next-shared-Q8_0.gguf     2657.48 MiB
  /mnt/SSD/MTP/mtp-Qwen3.8-Flash-Next-shared-Q4_K_M.gguf   1818.80 MiB

Enumerating its tensors shows a complete block 48 - attn q/k/v/output/norms,
indexer, all 8 hc_attn_*/hc_ffn_*, the full MoE (ffn_gate_inp, ffn_*_exps,
ffn_*_shexp) and the 6 nextn.* tensors - declaring nextn_predict_layers,
nextn_shared_target_tensors and arch qwen4exp. So 7.32.2's "a full layer plus a
head", derived from the loader's control flow, is confirmed against the artifact,
and the head's cost is now measurable directly rather than by arithmetic. Only
the trunk-file claim survives: 0 of 1224 tensors match nextn|mtp|draft.

The more useful find is issue #124: the expert cache and MTP are mutually
exclusive on this model today. With --moe-expert-cache-size > 0 plus
--spec-type draft-mtp the grouped plan is unavailable (required_unsupported
63-95), the graph fails closed, draft-mtp never produces or verifies drafts, and
the server falls back to legacy. Any MTP number taken with the cache on is
measuring the fallback. That is the next item.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
The operating rule for this track is "the doc wins". Section 8's CURRENT OPERATING
POINT block still presented N=53 as the live best configuration, and 7.29's
"Recommended operating point: N=53" was unmarked, so the doc would have
resurrected the configuration the owner declined.

Both now state the owner's decision (N=42 or N=36, look-ahead off, VRAM kept free
for MTP) and mark the N=53 text as a record of why the trade was believed to
exist rather than a recommendation. Section 8 also gets the intra-session table
(N=36 10.3626 / N=42 10.8362 / N=48 11.3891 / N=53 11.7824) so the older
different-session N=53 figures are not mistaken for the comparable pair, and the
stale HEAD pointer is refreshed.

No new measurement; this is corrections to a document that is load-bearing for
the next agent.

Assisted-by: Oh My Pi (deepseek-v4.1-flash)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant