Repository navigation
CPU Big Core Contention
A co-resident, idle process can crater ork-driver decode by ~40× — and it is not NPU contention. This page documents the mechanism, the proof, and the fix, because the symptom is easy to misread as "the NPU is single-stream / the submit tax dominates."
On RK3588, with the oRKLLM service merely running (dashboard up, no model loaded, NPU load 0%), a llama-bench decode (tg) on the open stack crawls to <0.1 tok/s and often never completes a chunk. Prefill (pp) is unaffected (~83 tok/s either way). Stopping oRKLLM → decode jumps to ~4 tok/s.
It is tempting to blame the NPU ("single-stream", "submission tax"). That is wrong: oRKLLM holds no NPU handle when idle (/dev/dri not open, /sys/kernel/debug/rknpu/load = 0% on all cores).
- ork-driver pins its latency-sensitive NPU-submit / worker threads to the big cores (A76, cpus 4–7) via
pin_big_core()(disable withORK_NO_AFFINITY=1). - A co-resident server's always-on threads (e.g. oRKLLM's Node event loop + its dashboard
/ws/metricspolling) also land on a big core. - Decode is ~200 tiny, latency-bound submits per token. Each one: quantize activation → wake the pinned submit thread (which is asleep, blocked on the NPU) → NPU → dequantize. When another thread shares that big core, the submit thread waits behind it for a scheduler slice on every wake.
- It's driven by wake frequency, not average CPU: a "3.4%-idle" metrics poller that wakes often injects a slice of delay per submit × thousands of submits/decode → ~40×. Prefill is a handful of big throughput-bound submits, so it shrugs the contention off — which is exactly the prefill-fine / decode-dead asymmetry observed.
| co-resident oRKLLM state | decode tok/s |
|---|---|
| running, on big cores (default) | <0.1 💀 |
| stopped | 3.99 |
running, pinned to little cores taskset -acp 0-3
|
4.01 ✅ |
Three points isolate the cause to oRKLLM's big-core presence, not the NPU. ork-driver's own run_i8 is healthy in isolation (mc_prof M=1: 612 µs 1-core / 219 µs 3-core).
-
Quick (any co-resident process): keep it off the big cores —
sudo taskset -acp 0-3 $(pgrep -f <proc>). Decode goes <0.1 → ~4 with the process still running. -
Permanent (oRKLLM): shipped in oRKLLM
alpha(perf_governor.js) — inference runs in forked workers, so the orchestration process self-pins to little cores at startup (pinOrchestrationToLittle) and each forked inference worker pins to big cores (pinWorkerToBig); big.LITTLE is detected fromcpu_capacity(no-op on uniform-core SoCs). So oRKLLM stays resident for the dashboard and its own inference still gets the big cores. -
Possible ork-driver hardening (not done): raise the submit thread's scheduling priority (
nice/SCHED_RR) so it preempts idle pollers even when co-located — more robust than affinity, but needs privileges.
The big-core thread pin above is about where the submit threads run. A separate, often-conflated lever is where the hardware-IRQ handlers run. They want opposite placement:
-
Submit threads → big cores (A76): low submit latency (the existing
pin_big_core()/ORK_NO_AFFINITYmechanism above). -
HW-IRQ handlers → little cores (A55): steer the
rknpu(NPU) andgalcore(Mali) hardware IRQs to the A55 little cores so they don't preempt the A76 cores running the NEON int4→int8 prefetch/expand pipeline. An IRQ handler firing on a big core context-switches it mid-compute — exactly what you don't want on the cores doing the inflate/expand work.
So the rule is: submit threads on big, IRQ handlers on little — not a contradiction, two different schedulable entities with opposite needs. Steer IRQs via /proc/irq/<n>/smp_affinity for the rknpu and galcore interrupt lines (mask to cpus 0–3).
This becomes critical once the tile-streamed prefetch pipeline (M2 — see Optimization Roadmap / Heterogeneous Serving) is live, because that pipeline keeps the A76 cores busy expanding/tiling the next slices on the NPU's critical path — an IRQ preemption there directly stalls the matmul feed. (For the current decode-only path the big-core thread contention above dominates; IRQ steering is a saturation-pipeline lever.)
Already-covered (no separate work needed): NPU-IOVA anti-fragmentation does NOT need a separate paged allocator — the IOMMU already maps scattered physical pages → contiguous IOVA, and the M2 tile-streaming uses uniform fixed-size tile buffers (a de-facto slab allocator), so fragmentation doesn't arise.
- Before declaring an NPU/decode regression, check what else is on the big cores. The fix is scheduling, not the NPU.
- Submit threads want big cores; HW-IRQ handlers want little cores — opposite placement, becomes load-bearing under the M2 prefetch pipeline.
- This is distinct from the
make testsingle-command-queue caveat (a wedged submit blocks the next) — that is a real NPU-queue property; this page is about CPU scheduling between live processes. - See also Performance, the
decode-bottleneckfinding, and oRKLLM'sperf_governor.js.