Exp 2026 06 27 Prefetch Overlap and Stream Pool - oRKLLM/ork-driver GitHub Wiki
Prefetch-Overlap Measurement + RAM-Resident 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.
TL;DR
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_*.
The measurement (stream_prefetch_probe.c)
| 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.
Why this confirms the cache design
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.
What was built (v0.5.0, all bit-exact, make test green)
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).
Verdict
β
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). β οΈ Wins are M>1 prefill/verify (fill hides β₯ M256) and warm cache hits (cheap map, no re-inflate); it does not help M=1 decode (fill un-hidden + bandwidth-bound) or any unmap-per-swap pattern, and it does not escape the 4 GiB cap. NEXT: Step 2 β retarget ggml-ork's hot-expert/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.
Update β end-to-end in ggml-ork (Step 2 + 2b)
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.