Repository navigation
NPU Weight Residency Bypass
Note (2026-06-24): The residual per-submit decode cost discussed here is the NPU submission round-trip, which is real. But a much larger co-resident-process decode stall has since been isolated as CPU big-core scheduling contention (an idle co-resident server's threads on the big cores preempting ork-driver's pinned submit thread), NOT an NPU single-stream limit. Rule out core affinity before attributing slow decode to the NPU — see CPU Big-Core Contention.
During multi-turn conversational chat, a catastrophic performance degradation was observed on Turn 2 and subsequent turns, where token-generation (decode) speeds plummeted to
This document details the root cause of this performance discrepancy and the architectural fix implemented in the ggml-ork backend to restore optimal throughput.
To optimize tensor operations, the ork-driver backend implements a weight caching system (wcache). Weights are loaded, packed, and marked as resident in the NPU's memory:
- Turn 1 (Cold Weights): On the first turn, weights are not yet resident in the NPU's caching memory.
-
Turn 2+ (Warm/Resident Weights): Once loaded, weights persist in the NPU's weight cache, and subsequent operations recognize them as "NPU resident" via
is_npu_resident(src0).
In ggml-ork.cpp, the backend determines whether to route a matrix multiplication (ggml_backend_ork_device_supports_op) to the NPU using the following gating logic:
if (is_npu_resident(src0)) {
pass_m_threshold = (M >= 1); // <-- Routing failure trigger
} else {
pass_m_threshold = (M >= min_m || op->ne[2] > 1 || op->ne[3] > 1);
}-
On Turn 1:
is_npu_resident(src0)returnsfalse. The gating falls to theelsebranch, checking if the batch size$M \ge \text{min_m}$ . Because single-token decode has$M=1$ (which is less thanmin_m), decode is correctly and efficiently routed to the CPU NEON cores (fast). -
On Turn 2+: The weights are resident, so
is_npu_resident(src0)returnstrue. This causessupports_opto usepass_m_threshold = (M >= 1). Since$M=1$ for decode, the single-token matmuls are routed to the NPU.
Although the NPU's hardware compute cores are highly efficient for large batch sizes (
The weight-residency override was removed from the routing logic. Regardless of whether the weights are resident in the NPU cache, single-token decode operations (
The gating logic in ggml-ork.cpp was updated to strictly enforce the batch size threshold for all operations:
-if (is_npu_resident(src0)) {
- pass_m_threshold = (M >= 1);
-} else {
- pass_m_threshold = (M >= min_m || op->ne[2] > 1 || op->ne[3] > 1);
-}
+pass_m_threshold = (M >= min_m || op->ne[2] > 1 || op->ne[3] > 1);By removing the conditional bypass, all decode operations (
Findings from the saturation-endgame work; hardware-validated on board 10.3.0.236. These reframe what "weight residency" can and cannot buy on this SoC.
The NPU's dma_buf is carved from the same 32 GB of LPDDR4X as the CPU and GPU — it is not a separate pool. Two consequences for the mixed-precision pack (see Mixed int8/int4-NF4 .orkpack):
- The int4 storage win does not shrink the NPU resident footprint. The NPU compute path is int8, so a resident w4a8 weight occupies the buffer as int8 regardless of its on-disk int4 form. The int4 savings are in the source/disk footprint and in GPU/CPU consumption (those engines read int4 natively), not in the NPU's resident bytes.
- The 4 GB IOVA cap is carved from the same 32 GB. The RK3588 NPU IOMMU is 32-bit, so only ~4 GB of weights are DMA-mappable at once — a hard ceiling on int8 NPU residency, taken out of the shared 32 GB (not additive to it).
A ~35B mixed model (~20 GB packed) can sit fully resident by partitioning across engines rather than streaming:
- NPU ≤ 4 GB — the int8 tier (hottest / most compute-heavy tensors), capped by the IOVA limit.
- CPU / GPU ~28 GB — the int4 bulk (consumed natively at int4) plus any int8 overflow.
- + KV cache — fits in the remaining headroom.
The scarce 4 GB of NPU residency is the constrained resource, so it should hold the hottest, most compute-heavy int8 tensors — exactly the tensors the tier map flags for the int8 tier. This is the residency side of the phase-aware scheduling design: prefill compute concentrates on the NPU-resident tensors, while int4 tensors live where their native-dtype engine (GPU/CPU) can stream them.
Streaming is the fallback, not the plan. Per-token decode streaming is bandwidth-walled (each token re-streams the layer), and the DMA fill only hides behind compute at large prefill M. Fit-by-partition avoids streaming entirely for models that fit the 32 GB.
The fit-by-partition picture is only practical because the DMA-fill path is now fast (see the DMA-buf fill optimization chain): the cacheable 0x403 + clean-before-submit fill (~10 GB/s, 8× over write-combine) and the direct int4→int8 tiled inflate (ORK_DIRECT_I4, 2.2–5×, bit-identical) are what keep both the initial residency load and any prefill-streaming refill cheap.