Exp 2026 09 05 SRAM Aggregate Bandwidth - oRKLLM/ork-driver GitHub Wiki
Exp-2026-09-05 — Is on-chip SRAM a separate memory port? (aggregate-bandwidth investigation)
Status: CLOSED, split verdict. SRAM is a separate port for CPU access (confirmed 2026-08-11, 104% retention under DRAM saturation). It is not one for NPU weight reads (closed 2026-09-05, ≤0.5 pp across six comparisons). The practical consequence: placing NPU weights in SRAM buys no contention relief, so the CPU∥NPU partition cannot be funded from SRAM on this SoC.
Kernel: 6.1.115-vendor-rk35xx-fence. Board 10.3.0.236. Results from other kernels are not comparable.
Why the question matters
The decode CPU/NPU partition stance holds that decode should not be dogmatically CPU-only — that
independent ops should be partitioned across CPU and NPU for true parallelism. But a partition where both
engines read the same DRAM is bandwidth-neutral at best: they simply divide one ~28 GB/s budget. It only
pays if the NPU's share reads a different memory. RK3588 has one candidate: the 956 KB syssram block
(DTB Change 1 + CONFIG_ROCKCHIP_RKNPU_SRAM=y).
So the load-bearing question was never "is SRAM fast?" — it is "does SRAM traffic spend DRAM's budget?" Additivity, not latency.
The five probes, and what each actually settled
| # | date | probe | what it measured | verdict |
|---|---|---|---|---|
| 1 | 2026-08-11 | test_sram_bw |
CPU↔buffer bandwidth, SRAM vs DRAM | uncached SRAM == DRAM (1.19 GB/s both) — the mapping is the wall, not the memory |
| 2 | 2026-08-11 | test_sram_npubw |
NPU weight read, SRAM vs DRAM | inconclusive — ~95% submit overhead (~331 µs for ~18 µs of streaming) |
| 3 | 2026-08-11 | test_sram_pipe |
CPU→SRAM under 3-core DRAM saturation | SEPARATE PORT CONFIRMED — 104% (2.89 → 2.99 GB/s, unaffected) |
| 4 | 2026-09-05 | sram_bw_probe |
NPU weight in SRAM under a CPU hog | VOID — see below |
| 5 | 2026-09-05 | sram_port_probe |
same, done properly | NULL, airtight — ≤0.5 pp at three weight sizes |
Why probe 4 was void (the instructive one)
It defaulted to K=512 N=256 — a 128 KiB weight, one submit per iteration. 128 KiB at ~11 GB/s is
~12 µs of streaming against a ~167 µs per-submit floor: ~93% of every iteration was host and fixed submit
cost. Moving 12 µs of traffic between two memories cannot register. It reported "flat" and that flatness
was then explained by inventing a mechanism — "syssram is behind the same LPDDR controller, a storage
class not a second port" — which contradicted probe 3, already on disk, in another project's directory.
Two lessons, both cheap to apply:
- Never explain a null before proving the experiment could have shown a positive. A flat A/B where the manipulated variable governs 7% of the runtime is not evidence about the variable.
- The record was in the llama.cpp project's transcripts and memory, not ork-driver's — which is why this ground was re-covered. Cross-project findings need an anchor in both indexes.
The airtight closure — tools/sram_port_probe.c
Three design requirements, each fixing a specific defect in the earlier attempts:
(a) Force the NPU into a weight-bandwidth-bound regime. Weight sized to ~640 KiB (of ~700 KiB usable SRAM) so each stream is ~31 µs, and T=16 tasks HW-chained in one submit, all re-reading that same weight. 640 KiB exceeds the 384 KB CBUF, so it cannot stay resident — every task re-streams. The fixed per-submit cost amortizes over 16 tasks.
(b) A hard validity gate that can VOID the run. Sweep the weight size and fit time = a + b·N:
N=160 weight=160KiB 14.40 us/task 11.38 GB/s
N=320 weight=320KiB 20.29 us/task 16.15 GB/s
N=640 weight=640KiB 31.93 us/task 20.52 GB/s
fit: time = 8.55 us + 0.0365 us/N -> streaming fraction 73.2% (midpoint linearity +0.2%)
73.2% of each task is weight streaming, so placement is observable. Below ORK_MIN_FRAC (0.70) the probe
refuses to print a verdict rather than report a null it has not earned. This gate is exactly what
probes 2 and 4 lacked.
(c) A SPIN control that divides out scheduling. The NPU submit path is itself host-CPU-bound (~95 µs of host work per submit), so a contender that merely occupies big cores slows the NPU with no memory involvement at all. Measuring only against a memcpy hog cannot tell that apart from bus contention. So: main thread pinned to core 4, three contenders on cores 5–7, in two modes — LOAD (memcpy, ~23.5 GB/s of DRAM traffic) and SPIN (pure ALU, zero traffic). Memory-attributable slowdown = LOAD / SPIN.
The control earned its place immediately: SPIN retention is 99.8–100.1%, so with the affinity split there is no scheduling term, and every bit of the LOAD drop is memory.
Result — 3 trials, A-B-A interleaved, N=640
| place | solo | under SPIN | under LOAD | memory-only | aggregate |
|---|---|---|---|---|---|
| DRAM weight | 20.54 GB/s | 100.0% | 13.85 GB/s | 67.5% | 37.32 GB/s |
| SRAM weight | 20.64 GB/s | 100.0% | 13.97 GB/s | 67.7% | 37.40 GB/s |
Δ = +0.2 pp. Replicated across three weight sizes, with the sign flipping — a null, not a small win:
| N (weight) | streaming fraction | DRAM | SRAM | Δ |
|---|---|---|---|---|
| 160 (160 KiB) | 41% | 55.7% | 56.0% | +0.3 |
| 320 (320 KiB) | 58% | 61.3% | 60.8% | −0.5 |
| 640 (640 KiB) | 73% | 67.7% | 68.0% | +0.3 |
An SRAM-resident NPU weight contends with CPU DRAM traffic exactly as much as a DRAM-resident one.
Where the contention actually lands
Retention rises with N (55.7 → 61.3 → 67.7), which is itself informative. Each task is
time = a + b·N: a fixed ~8.55 µs part (A reads, C writes, regcmd/descriptor fetches — DRAM-resident in
both arms) plus the b·N weight stream. Solving retention(N) = (a+bN)/(a·f + b·N·g) on the endpoints:
- g = 1.22 — the weight stream slows only 22% under a 23.5 GB/s CPU load
- f = 2.19 — the fixed DRAM-resident remainder slows 2.19×
The two-parameter fit then predicts the unseen midpoint at 61.4% against 61.3% measured — a 0.1 pp out-of-sample check, so the decomposition is not curve-fitting.
So most of the NPU's loss under CPU load is not the weight stream at all — it is the small,
unavoidable, DRAM-resident per-task traffic. That part cannot be moved to SRAM (task descriptors in SRAM is
a hard wedge: 0x40b carries KERNEL_MAPPING and the kernel reads them), and even the 22% that is
the stream gets no relief from SRAM placement.
Reconciling probe 3 and probe 5
Both are solid; they measure different access paths:
- CPU → SRAM: immune to DRAM saturation (104%).
- NPU → SRAM: not immune — identical to DRAM (67.7% vs 67.5%).
"Separate port" is therefore directional: true of the path the CPU takes to syssram, false of the path
the NPU takes to it. Any future claim in either direction must say which engine is doing the accessing —
the unqualified sentence "SRAM is/isn't a separate port" has now caused an error in each direction.
Consequences
- The CPU∥NPU partition cannot be funded from SRAM. The decode-cpu-npu-partition-stance premise "wins when the NPU share reads SEPARATE memory (SRAM)" is not available on RK3588 for weights.
- ~~Running both engines is worth ~1.5× aggregate (37.4 GB/s)~~ — **that figure was wrong and is retracted.** It added two different currencies (NPU *useful weight bytes* + CPU *read+write* traffic) and exceeded the board's theoretical LPDDR peak, which is proof it over-counted. Re-measured in one currency against the DDR controller's own load counter: the dual-engine gain is **1.41×** in the decode-shaped regime and only **1.10×** when the CPU already streams well. See Exp-2026-09-05 Heterogeneous Dispatch Ceiling.
- SRAM's remaining honest uses: memory that costs no DRAM/IOVA, and a CPU-side activation pipe (separate port, but ~3 GB/s feed and ~700 KiB).
Bugs found while building the probe (all real, all worth remembering)
-O3deleted the saturator. The memcpy destination is never read, so the compiler removed the copy; the hog "reached" 7,056,858 GB/s (spinning onclock_gettime) and generated no traffic — and both arms dutifully reported 99.9% retention of nothing. Fixed with anasm volatile ... "memory"barrier. A contention probe must verify its own contender;sram_port_probenow calibrates the saturator and aborts on an implausible rate.ork_w_freedoes not return SRAM to the pool (first flagged 2026-08-11, still true). A held 640 KiB weight left only ~124 KiB, so later SRAM packs silently failed over to DRAM.- A 3/4 placement threshold accepted partial placement as success — 124 KiB of a 160 KiB request passed as "IN SRAM", fabricating an SRAM arm that was mostly DRAM. Now requires the whole tile.
Open
The— followed up the same day: Exp-2026-09-05 Fixed Per-Task Cost. It is host work, not NPU work (kernel hardware time for the fixed part is flat under load), and the precompiled regcmd cache removes 72–80% of it. That also explains retrospectively why SRAM placement could not help: the NPU-side fixed cost is only ~1.27 µs and never inflates.f = 2.19fixed-remainder slowdown is unexploredc->mrcTRY_ALLOC_SRAMstill fails over to DRAM for reasons unknown (separate from #2 above).