Repository navigation
Exp 2026 06 27 Prefetch Overlap and Stream Pool
Commit: ork-driver a443b64 (v0.5.0) · probe tools/stream_prefetch_probe.c · API ork_stage_* / ork_stream_pool_* / ork_mm_load_i4a8_import in src/npu.c + include/ork_npu.h. RK3588 10.3.0.236, perf governors, taskset 4-7, median 15 reps, all bit-exact vs CPU int8 reference.
To stream int4-stored weights, each swap is FILL (int4→int8 inflate + tile into a non-mapped staging dma-buf + cache-clean) → MAP (bare MEM_CREATE import → IOVA) → SUBMIT, and a prefetch thread can run FILL(N+1) behind SUBMIT(N). Measured the pieces. MAP is cheap (48–169 µs); MEM_DESTROY UNMAP is expensive (~0.5–2 ms) — the irreducible swap killer. So a transient unmap-every-swap ring loses (overlap never reaches t_submit; fill only hides at M ≳ 256). But the measurement validates the RAM-resident-cache design: since unmap is the dear op, cache the inflated int8 in RAM, keep hot entries mapped, and unmap only on eviction — a hit then pays just the cheap map and never re-inflates. Built that as ork_stream_pool_*.
| op | cost | note |
|---|---|---|
map (MEM_CREATE import of a filled staging buf) |
48–169 µs | cheap; isolated from fill |
unmap (MEM_DESTROY) |
461–1827 µs (~0.5–2 ms) | expensive — irreducible per-swap if you unmap every swap |
| fill (int4→int8 inflate + tile) | 415 µs (1 MB expert) / 2.7 ms (4 MB backbone) | M-independent |
| int8 fill-once bare-map-only | 170 µs @4 MB / 654 µs @16 MB | confirms map ≪ fill |
-
Overlap vs serial (prefetch FILL behind SUBMIT): only ~1.0–1.17× at multi-core; the overlapped wall/swap never reaches
t_submitbecause unmap sits on top of every swap. -
Fill-hide crossover ≈ M 256 (single-core backbone): the inflate fully hides once
t_submit ≥ ~2.5× t_fill(M=256/512 → "FILL HIDDEN"); at decode M it does not.
The expensive op is unmap, not map and not (on a hit) inflate. So the correct structure is a memory hierarchy, not a transient ring:
int4 on disk/RAM (compact)
→ inflated int8 in RAM (LRU, big budget — pays inflate ONCE, avoids re-inflate)
→ IOVA-mapped hot tier (≤4 GiB; evict RARELY because unmap is ~0.5–2 ms)
A hit pays only the cheap map (or nothing, if the entry is still mapped) and zero re-inflate. Unmap is amortized over many hits at eviction time. This is the routing-skew hit-rate lever realized in RAM budget (8–15 GiB) instead of the 1.2 GiB usable IOVA that capped the earlier MoE cache — without paying re-inflate on hits.
-
ork_stream_pool_*— RAM-resident inflated-int8 cache.add_i8(copy pre-tiled bytes) /add_i4a8(inflate int4→int8 once); cheapmap/run/unmap; entries persist in RAM across unmap;remove= caller-driven evict;entry_bytesfor the RAM budget. Lifecycle only — the LRU/eviction policy stays with the caller (ggml-ork). -
ork_mm_load_i4a8_import— int4a8 import sibling ofork_mm_load_i4a8(the i8 one shipped in a443b64's predecessor). -
ork_stage_*— the staging-ring primitives the probe exercises (fill/map/run/unmap/free).
✅ The RAM-resident inflated-int8 cache with cheap map-on-hit and evict-time unmap is the right streaming structure — and the measurement is what ruled out the simpler unmap-every-swap ring (MEM_DESTROY ~0.5–2 ms). wcache LRU to budget RAM-int8 and map-on-hit via the pool, keeping hot entries mapped (don't unmap per swap), then measure end-to-end on a streamed M>1 path.
Wired the pool into ggml-ork's streaming LRU (board commit d2e8526e8, behind ORK_STREAM_POOL=1, off by default; submodule → ork-driver a443b64). Bit-exact perplexity, zero regression at the default (resident) budget. But the end-to-end decode verdict is a clean negative: forced-stream 1.7B decode (-n 8, IOVA 1536 MB, RAM 22 GB) is identical pool-on vs pool-off (0.03 == 0.03 t/s; resident ceiling 3.85). ORK_PROFILE root cause: run_i8 (NPU compute) is ~0.6% of the decode wall; ~99% is per-token IOVA map/unmap churn (~100–170 weights cycled through the window every token, each MEM_CREATE+MEM_DESTROY). The inflate+copy the pool removes is dwarfed by that churn → it changes nothing at M=1. This is the 4 GiB cap as churn: a decode working set larger than the window must unmap-to-map every weight every token. (A wiring gap also left remaps=0 — the grouped QKV/gate-up handler bypasses the pool's cheap-remap branch — but even fixed, unmap churn dominates.)
Conclusion (banked): NPU-streamed decode is churn-bound — big-model decode runs on CPU (native Q4_K, fused, no churn); the NPU's lane is resident-hot weights + M>1 prefill/verify. The pool's one open upside is M>1 prefill streaming (import removes the copy, prefetch hides the inflate at M ≳ 256 — both proven at the microbench level), still to be demonstrated end-to-end on a real >4 GiB dense transformer (the board has none yet — only Q4 MoE 27B + hybrid-SSM 9B). Env gotchas: pool needs ulimit -n raised (each entry holds Sk×Sn+Sn dma-buf fds); check sudo-proc liveness with ps -p, not kill -0.