Exp 2026 07 26 CPU NPU FFN Overlap - oRKLLM/ork-driver GitHub Wiki
Exp-2026-07-26 β CPUβ₯NPU FFN SiLU overlap: apipe (+6.5%, documented+removed) vs the doorbell production path
Stack: ggml-ork fork feat/ork-async-stream Β· ggml/src/ggml-ork/ggml-ork.cpp (ggml_backend_ork_ffn_swiglu_chain) Β· env-gated experiments. Board: RK3588 (10.3.0.236), DDR 2112 / A76 2.4 GHz, governors=performance. Model: qwen3-1.7b-q8_0 (ORK_DIRECT=1 ORK_PERSIST=qwen3-1.7b-q8_0.orkpack ORK_FFN_CHAIN=1), prefill P=256 / ubatch=256, one clean run per boot (cross-run IOVA is not freed; a 2nd run collapses to ~4 t/s).
The int8 FFN SwiGLU chain runs the exact CPU fp32 SiLU (accurate, and the fused-fp16/int8 SiLU is quality-dead β see the SmoothQuant notes). That SiLU is serial. Hide it under independent NPU matmul work (the up projection depends only on x, not on the gate), and parallelize it across cores, to close the prefill gap.
| config | prefill t/s | Ξ vs baseline | note |
|---|---|---|---|
baseline (ORK_FFN_CHAIN=1, serial exact CPU SiLU) |
141.70 | β | naive chain |
| apipe + pool(0β6) β async-worker overlap | 150.97 | +6.5% | documented + removed (this experiment) |
doorbell β ORK_FFN_SCHED+driver, tuned |
150.93 | +6.5% | production path (ties apipe) |
| doorbell nc=0 greedy (mis-placed) | 112 | β21% | the core-placement trap |
generalized ork_spine_run (dynamic DAG) |
55β90 | β36..β61% | poll-tick + per-op dispatch tax |
Correctness: PPL bit-identical across baseline / apipe / doorbell β NLL 2.9118, PPL 18.3891 (ork_ppl β¦ wiki_tiny.txt 256 256). The overlap changes scheduling only, never numerics.
Per FFN layer, in ggml_backend_ork_ffn_swiglu_chain, gated ORK_FFN_APIPE (requires the default fc.silu_cpu int8-gate + CPU-SiLU path):
-
gate =
ork_mm_run_i8(owg, M, xr_i8, gate32)β blocking, full-M (owg = per-channel int8 gate, resolved like the GU_CHAIN path). - Launch up =
ork_mm_run_i8_async(ctx->npu, owu.w, M, xr_i8, up32)β the async launcher runs the blockingork_mm_run_i8on a worker thread; the RKNPU submit blocks that worker in the kernel completion-wait (IRQ-driven, the worker SLEEPS), freeing its core. - On the calling thread, meanwhile: exact CPU SiLU
silu_f[i] = g/(1+exp(-g))overgate32(dequant per-rowas_xΒ·per-channelbsg). Parallelized via a work-stealing CPU pool (OrkFfnPool:std::deque<std::function<void()>>, atomicfetch_addtile pull, CV-woken persistent workers pinned toORK_FFN_POOL_CORES, single final join β no per-tile barrier, so little A55 cores contribute their ~β share without dragging). -
ork_async_wait(up)β rendezvous β dequantup32βup_f. Then normal Phase-3 GLU + down.
Overlap window = silu(gate) β₯ up; up fully hidden (drain=0). Env for the winner: ORK_FFN_APIPE=1 ORK_FFN_POOL_CORES=0,1,2,3,4,5,6.
Why it wins: the async up is kernel-IRQ-driven (worker sleeps), so it does not hold any big core during the NPU compute β all cores are free for the parallel SiLU, and the full-MT (128-row) up matmul incurs no doorbell tile-tax. drain=0 confirms the whole up fit inside the SiLU window.
Prefill throughput swung 112 β 151 purely on core layout β this is the reusable result:
- It is NOT DRAM bandwidth. apipe+pool with the MOST silu cores (0β6) was the fastest (150.97); a bandwidth wall would make it slowest. Refuted.
-
It is NOT a doorbell-fundamental limit, and NOT starvation (for the pooled case): a dedicated spinner thread (
OrkNpuDriver) driving the doorbell changed nothing (112β112). -
It IS core placement / timing. The doorbell's colsplit worker threads pin to cores
0..nc-1(npu.cCPU_SET(i)), and its spin-driver occupies a core. Silu on those cores collides. Fix: explicitnc(the greedync=0/all-core mode was the 112 trap) + a poll-only spinner on a little core + silu on the freed big cores β 150.93 (nc=2, driver core=0, silu=4,5,6,7). Monotonic in nc (1β130, 2β142, 3β143 at silu=0β6). Theapipepath tolerates silu-on-big because its worker sleeps; the doorbell must keep silu off its active cores because its worker spins.
Wiring the whole FFN as an ork_spine_run op-DAG (units + tagged placement + deps) is correct (PPL-identical) but 55β90 t/s: ork_spine_run's 20Β΅s poll-tick + per-op cross-thread dispatch (mutex/cond) + per-tile op decomposition. Profile: ~1.7 s of the 2.8 s wall was orchestration outside ork's matmul counters. Lesson: the adaptivity must not live in a per-op runtime loop; use the spine units (ork_spine_unit_dispatch/_wait) with a prescribed schedule, not the dynamic scheduler.
-
Production = doorbell (
ORK_FFN_SCHED+OrkNpuDriverspinner +OrkFfnPool): ties apipe (150.93), fits the spinner architecture, and is what the pack-time planner targets. -
apipe is documented here and REMOVED from the code β it added a second overlap mechanism (async-worker) that duplicates the doorbell's role at equal performance. This entry is its record (mechanism above is sufficient to re-implement; it lived on
feat/ork-async-streampre-strip). -
Dead siblings also removed:
ORK_FFN_SILU_OVERLAP(fp16-gate async overlap, superseded),ORK_FFN_GATE_M48(fused-fp16-SiLU gate β structurally dead:ork_mm_pack_f16_fused_actrejects the two-signed gate band; single-signed fp16 LUT index). -
Next: pack-time prescribed tuning β a one-time calibrator bakes the winning per-(chain,shape) schedule (mechanism + core-map + tile) into the orkpack; a prescribed executor replays it on
ork_spine_unit. SeePRESCRIBED_TUNING_PLAN.md. Adaptive across model+chipset, zero per-op runtime cost.
- One clean run per boot (
sudo rebootbetween configs; IOVA not freed β 2nd run ~4 t/s). -
ORK_VERBOSE=1prints per-layer lane timing (gate/cpu-silu/drain) for apipe/sched. - Correctness gate:
ork_ppl <gguf> <text> 256 256must match baseline exactly.