Exp 2026 06 26 MoE Decode Partition and Submit Floor - oRKLLM/ork-driver GitHub Wiki
MoE Decode Partition (M1b) + the "21 ms submit floor" that wasn't
Date: 2026-06-26 Β· SoC: RK3588 (10.3.0.236), governors pinned (DDR 2112 MHz / A76 2.4 GHz), -t 4, orkllm up (idle, non-contending)
Commits: ggml-ork edf5e855b (M1b hot-expert partition) Β· ggml-ork a72692188 (cold-path vec_dot reuse) Β· ork-driver dd6e7e6 (v0.3.5; chain/stream/load primitives) Β· probe tools/moe_m1_probe.c
Knobs: ORK_MOE_NPU=1, ORK_MOE_HOT=<K> (hot experts/tensor), ORK_MOE_HOT_GIB (byte budget), ORK_MOE_COLD_THREADS, ORK_PROFILE=1, ORK_OFF=1 (force all-CPU ref)
Continues M1a persist/load + the 4 GiB IOVA wall and M3 routing-skew sizing. This is the decode partition (M1b) wired into ggml-ork, plus an adversarial verification that overturns a wrong "MoE-on-NPU decode is dead" reading.
1. M1b β the decode partition, and fixing the broken live MoE path
The live ggml_backend_ork_mul_mat_id_i8 path (default round-robin pool ORK_MOE_CACHE=384) wedged LFM2.5-8B-A1B before generating: CREATE: Bad address (EFAULT on DRM_IOCTL_RKNPU_MEM_CREATE) β graph_compute -1 β llama_decode -3.
Diagnosis β over-residency at the 4 GiB IOVA cap, NOT a bad regcmd. The pool is keyed per-shape; all 22 MoE layers Γ 32 experts churn one 384-slot pool Γ 3 shapes β far more than 4 GiB of fresh DMA buffers β the IOMMU rejects MEM_CREATE. Proof: ORK_MOE_CACHE=8 generates coherent text, 0 CREATE failures, exit 0. The expert shapes themselves run fine.
Fix (edf5e855b) β a static hot-expert partition in the MUL_MAT_ID int8 handler:
- Backbone (attn/router/norms/dense FFN) stays resident in the wcache.
- Hot cache: top-K experts per layer-tensor pinned NPU-resident (LRU within the cap, IOVA freed on evict), run via
ork_mm_run_chain_i8with a per-taskrun_i8fallback. - Cold experts β CPU.
- Global byte budget (
ORK_MOE_HOT_GIB, default 2.5 GiB) gates admission so backbone + hot experts never exceed the 4 GiB IOVA window; over-budget experts degrade to CPU instead of EFAULTing.
Gate (LFM2.5-8B-A1B, deterministic): output matches the ORK_OFF=1 CPU reference for ~15 tokens then diverges by one token = expected int8-activation drift β
. Peak resident 1.805 GiB (K=8) / 2.000 GiB (K=12), both < 4 GiB β
. Zero CREATE failures, exit 0, NPU idle/responsive after every run β
.
2. Cold-path optimization (a72692188) β reuse ggml's kernel, don't hand-roll
ork is a buffer-less BLAS-style backend: when offload_op returns true the scheduler routes the entire MUL_MAT_ID op to ork, so cold experts can't simply "stay on the CPU backend" without touching scheduler internals. Instead the handler reuses ggml's own ggml_get_type_traits_cpu(type)->vec_dot (the asimddp/SDOT kernel) against the raw quantized weight rows + the activation pre-quantized to vec_dot_type β eliminating the old path's full Q4_Kβf32 plane dequant and scalar triple-loop, fanned across a threadpool. Cold expert ~590 Β΅s β effectively free relative to the rest (β1/35th of the apparent NPU hot cost). Symbol resolves at runtime against libggml-cpu.so; no CMake change.
3. β The "21 ms/expert submit floor" β VERIFIED AS AN ARTIFACT, not hardware
A profiling pass reported the partition was bottlenecked by a ~21 ms/expert "NPU chain-submit floor" at M=1 decode (NPU ~35Γ slower than CPU) and concluded MoE-on-NPU decode can't win. That contradicted M0 (NPU expert 3β10Γ faster) and contradicted itself (a "submit floor" with "repack ~0%" while the same run reported the hot pool thrashing and repacking every rep). It was adversarially verified.
Test 1 β isolated resident submit, M=1 (tools/moe_m1_probe.c, warm median Γ40)
Real Qwen3.6-35B-A3B expert dims are gate/up 2048Γ512, down 512Γ2048 (smaller than assumed); both measured, weights packed once and resident:
| dims | run_i8 | chain (S=8) | run_stream | CPU (NEON) | best NPU vs CPU |
|---|---|---|---|---|---|
| Qwen gate/up 2048Γ512 | 281 Β΅s | 165 Β΅s | 67 Β΅s | 73 Β΅s | NPU 1.10Γ |
| Qwen down 512Γ2048 | 212 Β΅s | 172 Β΅s | 54 Β΅s | 76 Β΅s | NPU 1.41Γ |
| LFM gate/up 2048Γ1792 | 554 Β΅s | 404 Β΅s | 152 Β΅s | 263 Β΅s | NPU 1.73Γ |
| LFM down 1792Γ2048 | 555 Β΅s | rc=-3 | rc=-3 | 263 Β΅s | CPU 0.47Γ |
The resident submit is 54β281 Β΅s β 80β400Γ faster than the claimed 21 ms, and run_stream (cross-core) beats CPU even at M=1 decode. (LFM down-proj K=1792 is not %512 β chain/stream reject it rc=-3 β run_i8-only, the one shape CPU wins; the real Qwen down-proj K=512 is chainable, so this doesn't bite the target.)
Test 2 β pack-vs-submit split inside the partition (instrumented, M=1 decode, real model)
The existing moe_pack/moe_deq profile accumulators were dead code (never written in the hot handler) β which is why a prior read concluded "repack ~0%". With live-pack timing wired in:
| hot_K | EFAULT | resident | hit-rate | ms / "chain-submit" | fallback calls |
|---|---|---|---|---|---|
| 8 | 0 | 0.94 GiB | 8.9% | 13.3 ms | 0 |
| 16 | 0 | 1.88 GiB | 29.9% | 26.2 ms | 0 |
| 24β128 | 31kβ43k | 1.98 GiB | ~32% | ~28 ms | ~0 |
Pack is 3.7β4.7 ms/pack (separately timed); the per-task fallback fires 0 times (the chain path succeeds). So the 13β28 ms is not pack and not fallback. It rises with resident footprint/hit-rate independent of EFAULT (the EFAULT-free hot_K=16 is still 26 ms), so the IOVA overcommit at hot_Kβ₯24 roughly doubles it but is not the root cause.
What the 21 ms actually is
A free+repack-each-rep microbench isolates the churn the partition suffers (8-slot pool over 128 experts β ~75β90% cold, constant LRU evict + live re-pack): repack alone = 3.1 ms (Qwen) / 11 ms (LFM), and run_i8 itself only moves 281 β 418 Β΅s under fresh IOVA (cold IOVA is cheap). The inflation to 13β28 ms is the eviction churn (MEM_CREATE/MEM_DESTROY ioctls at ~25β30 % hit-rate) serializing on the single-stream NPU against the chain submit β residency thrash, not a per-submit hardware cost.
Verdict
β
MoE-on-NPU decode is back in play. The resident per-expert GEMM (54β67 Β΅s via run_stream) beats CPU even at M=1 β the long "dead end" history was always repack churn + residency thrash + single-core chain, never the GEMM. The committed cold-path vec_dot reuse stands regardless.
Path (micro-validated; end-to-end re-bench is the open M2 step):
- Persist experts to
.orkpack(M2) β kills the 3β5 ms live-pack. - Switch the partition
run_chain_i8βrun_stream_i8β 2.5Γ even resident (165 β 67 Β΅s). - Size the no-EFAULT cache to the IOVA budget (
hot_K=16already holds 1.88 GiB EFAULT-free) β kills the thrash; the cap is per-tensor count, not bytes. - Re-benchmark decode only after experts are resident β today's 0.15β0.70 t/s is thrash, not the mechanism, so it is not a valid measurement.
β Update 2026-06-27 β M2 end-to-end decode: the micro-win does NOT survive M=1 (it loses), and why
Built the full M2 path (persist .orkpack 7.77 GB int8 + run_stream hot experts + conformance gate routing the K=1792 ffn_down to CPU + cold-CPU vec_dot) and benched LFM2.5-8B-A1B decode vs the CPU reference, -t 4, governors at max:
| metric | M2 (best: HOT=4) | CPU (ORK_OFF=1) |
|---|---|---|
| prefill pp64 | ~10.6 t/s | 36.2 |
| decode tg64 | 6.56 | 19.09 β M2 ~2.9Γ slower |
| peak resident | 0.602 GiB | β |
| hit-rate | 16.8 % | β |
| EFAULT | 0 | β |
Verdict: M2 LOSES at M=1 decode (~2.9Γ). NOT a default β kept behind ORK_MOE_NPU (off by default), NOT recommended on RK3588-class hardware. The resident GEMM is fine; three RK3588-specific walls sink the end-to-end case:
- Usable IOVA is only ~1.2 GiB, not ~3.5. The GGUF backbone weights already consume ~2.8 GiB of the 4 GiB cap, so the hot cache maxes out ~HOT=4 (16.8 % hit); HOT=16 EFAULTs. (Corrects this page's earlier "hot_K=16 holds 1.88 GiB" β that ignored the resident backbone.)
- The cold-expert path is memory-BANDWIDTH-bound, not thread-bound. Each cold expert re-reads its full ~3.67 MB weight from DRAM per token; a row-parallel rewrite was neutral (
COLD_THREADS=1770 Β΅s vs threaded 688 Β΅s). A per-expert GEMV structurally loses to ggml's fused, cache-blocked batchedMUL_MAT_ID. With ~83 % of experts cold, this dominates. ork_mm_load_i8is 5.9 ms/load (rebuilds the full-KBf: un-tileβre-tile + per-tile bsync + a 2nd DMA buffer) β a frozen-submodule cost paid per first-touch. (Persist was working β the earlier "176 live-packs" was a mislabeled counter; they're disk loads, 0 packs.)
Structural floors no knob fixes: ffn_down (K=1792, not chain-conforming) is always CPU-cold (~88 cold GEMVs/token); 4-of-32 experts/token thrashes a small resident cap.
The lever is M>1, not residency. At M=1 every cost is per-token and un-amortized and the cold majority is memory-bound. At M>1 (batched verify / prefill) the 5.9 ms load + the NPU submit amortize across tokens, and the cold GEMV becomes a real M-row GEMM (compute-bound β competitive). Closing M=1 specifically would need frozen-layer work (a cheaper ork_mm_load_i8 that skips the Bf rebuild for decode shapes; NPU-enabling K=1792; or routing cold experts to ggml's native batched kernel).
Disposition (per the "keep ruled-out approaches runnable behind env flags" rule): M2 is preserved as an experimental, default-OFF setting (ORK_MOE_NPU), wired into oRKLLM with an RK3588 "not recommended" disclaimer. Revisit when: a wider-IOVA device (no 32-bit IOMMU), DDR5 / higher bandwidth, or the M>1 batched-verify path.
Honesty / caveats
- CPU reference: my NEON probe 73β76 Β΅s; the integration's threaded ggml
vec_dot194β214 Β΅s; an earlier note quoted 590 Β΅s. The verdict (resident NPU 54β67 Β΅s) holds against all three. - The probe was built against submodule
15cb782(v0.3.4+15); the integration pinsdd6e7e6(v0.3.5) β adjacent versions, identical chain/stream/envelope semantics. - Verification instrumentation in the board
ggml-ork.cppwas left uncommitted (measurement only; backup at/tmp/ggml-ork.cpp.bak); orkllm never stopped; board left as found.