Repository navigation
Exp 2026 06 28 Multi Domain Residence
The headline: the RK3588 NPU's 32-bit IOVA limit is per iommu_domain_id, not per device. The rknpu driver exposes up to 8 domains, each with its own ~4 GiB IOVA space, so up to ~32 GiB of weights can be NPU-resident at once (ceiling = physical RAM, not IOVA). This is reachable from our open uABI (a field already in rknpu_ioctl.h), needs no kernel patch and no librknnrt, and is bit-exact. It overturns the prior "only ~4 GiB DMA-mappable → must stream big models" conclusion.
The finding came out of a three-step investigation (arbiter → dump → de-risk). Commits: ork-driver 8bc4004 (tools/domain_probe.c + ORK_IOMMU_DOMAIN knob), all local. Board 10.3.0.236, RK3588, rknpu v0.9.8, governors max.
Converted Qwen2.5-7B-Instruct (dense) → w8a8 .rkllm = 7.65 GiB (>4 GiB → must use the 4 GiB window) on an x86_64 Rosetta-Docker container, benched on the SBC: librkllmrt (vendor closed runtime) vs llama.cpp CPU, same model.
| streamed 7B w8a8, warm | prefill (tok/s) | decode (tok/s) |
|---|---|---|
| librkllmrt (NPU) | 73.6 | 4.05 |
| CPU (llama.cpp Q8_0, -t4) | 15.7 | 3.42 |
librkllmrt-on-NPU beats CPU on a streamed >4 GiB model — prefill 4.7×, decode 1.18×. This is the existence proof: the RK3588 NPU can run a big model and beat CPU, so our open stack's prior streamed-7B loss (prefill ~2.74) was an implementation gap, not a hardware ceiling. (Decode is only 1.18× — both are ~26–31 GB/s, the LPDDR bandwidth ceiling; that's the regime model: NPU crushes compute-bound M>1 prefill, marginal on bandwidth-bound M=1 decode.)
Captured librkllmrt's ioctl/regcmd stream during a steady-state run. The surprise: it doesn't stream at all — it resides the whole model across two IOMMU domains.
-
8
MEM_CREATE/ 8MEM_DESTROYtotal for the entire run — zero per-token buffer churn. (Our open stack did ~100–170MEM_CREATE/MEM_DESTROYper token — theMEM_DESTROY~0.5–2 ms churn was the whole gap.) - 8 buffers across 2 domains: domain 0 = 3.83 GiB (weights 3.806 + regcmd + I/O), domain 1 = 2.98 GiB (weights 2.936 + regcmd + I/O) → 6.585 GiB of int8 weights resident + mapped simultaneously (> 4 GiB).
- Weights uploaded once at load (a single
TO_DEVsync of each whole weight buffer, never repeated); regcmds are static (weight base-IOVAs baked in, buffers never move) → the NPU reads weights in place from resident DMA. Per-token syncs are KB-scale on the I/O/KV buffer only. -
6–9 matmuls chained per
SUBMIT(a fused layer block,regcfg_amount=108); 3 cores round-robincore_mask0x1/0x2/0x4; lower layers → domain-0 weights, upper layers → domain-1. - All buffers plain cacheable IOMMU-mapped NON_CONTIGUOUS (0x403); no SRAM, no write-combine, no DMA32.
| domains | total resident | per-domain |
|---|---|---|
| 1 (baseline) | 4.23 GB then EFAULT | matches dma_probe
|
| 2 | 8.46 GB | 4.23 + 4.23 |
| 8 | 32.28 GB (no EFAULT) | 8 × 4.03 GB, capped by the probe's 30 GB limit |
-
The mechanism was already in
rknpu_ioctl.h, unused:rknpu_mem_create.iommu_domain_id(places a buffer in a domain) +rknpu_submit.iommu_domain_id(selects the active domain for a submit). Setting the field is sufficient — no action needed (RKNPU_SET/GET_IOMMU_DOMAIN_ID= 25/24 exist and logswitch iommu domain 0→1, but field-only works). Each domain = an independent 32-bit IOVA space (first IOVA ~0xfc000000each). -
Domain-1 buffers are NPU-submittable and bit-exact: the entire
test_matmulsuite (chained / streamed / M-tiled int8, NEON pack, NF4, imatrix, dump/load roundtrip) routed through domain 1 → allmism=0, identical to the domain-0 control. - 8 domains confirmed → 2 ≈ 8 GiB, 8 ≈ 32 GiB residable; ceiling is now physical RAM.
-
Caveat: a single
SUBMITreads one domain, so a layer's weights must be co-located in one domain (partition layers by domain, like librkllmrt). A single matmul spanning two domains is not proven.
✅ The 4 GiB IOVA cap is per-domain. Models up to ~physical RAM (≈32 GiB int8, far more int4) can be NPU-resident with zero per-token churn — no streaming required. This is reachable from the open uABI (a field we'd never set), no kernel patch, no blob, bit-exact. dma_probe's EFAULT at 4.03 GiB was the per-domain cap, hit because it used one domain. The "must stream" era is over for any model that fits RAM.
Recipe (kill churn + escape the cap): per domain (≤~3.8 GiB), one resident weights buffer (0x403, packed with that domain's layers) + one static regcmd buffer (resident weight IOVAs baked in) + one I/O/KV buffer; one MEM_CREATE + one TO_DEV sync each, never freed. Split the model across 2+ iommu_domain_ids. Per token: zero MEM_CREATE/DESTROY — chained submits vs fixed regcmd + resident IOVAs, sync only the small activation/KV ranges. Chain 6–9 matmuls per submit (run_chain_i8).
Where the open stack can then BEAT the vendor: (a) int4/NF4 storage ≈ 2× decode — decode is weight-read-bandwidth-bound (~26.7 GB/s ≈ LPDDR ceiling); librkllmrt streams full int8, halving the bytes ~doubles the ceiling (needs a coherent int4 — the Hadamard track). (b) SRAM/NBUF tier — librkllmrt uses none; on-chip SRAM for hot scratch is untapped. (c) async cross-core dispatch out-pipelines its serial round-robin. (d) submit fusion (match 6–9/submit via run_chain_i8).
Provenance (clean-room, open): the multi-domain trick was learned by RE-ing librkllmrt's ioctl pattern; we call the open rknpu uABI field ourselves and synthesize our own regcmd — no proprietary code, no kernel module. Runs on the vendor BSP kernel + the (GPL, source-available) rknpu driver, same substrate the closed runtime uses.
Next (in flight): end-to-end 7B 2-domain residence in ggml-ork (target librkllmrt parity 73.6/4.05, zero churn), then the 27B scale test (RAM is now the binding constraint — int8 27B ≈ 27 GB is at the edge of 32 GB; int4 ≈ 13.5 GB fits but needs native-int4). Results to be appended.
The open stack's earlier streamed-7B numbers (prefill 2.74, decode churn) were a broken measurement, profiled here: on aarch64, llama.cpp loads Q8_0 into the CPU_REPACK buffer (is_host=0), which fails ggml's offload guard and ORK's supports_buft → the NPU got zero matmuls (the "CPU 15.7" baseline was the NPU sitting idle). GGML_NO_REPACK=1 is required to engage it. With the NPU actually running, the matmul is 0.6–6 % of the wall (~190 t/s ceiling); the loss was serial per-matmul submit + activation churn + cold-conversion tax — all implementation. Plus a cache-evict bug (erase vs unmap) was found and fixed.