Roadmap Edge Asymmetric SLM - oRKLLM/ork-driver GitHub Wiki

Roadmap β€” Edge-Asymmetric Sparse MoE SLM (RK3588-class edge SBCs)

Status: DRAFT specification, not yet started. Target: the RK3588 CLASS of edge SBCs β€” 32-bit NPU IOVA, A76-class NEON, Vulkan iGPU, shared LPDDR (validated on RK3588 board .236; RK3576 inherits the code path with untested caps). Training host: Apple Silicon M5 Max / 128 GB UMA.

Provenance note. Some [M] figures below are measured on the training host (Apple Silicon M5 Max / 128 GB) by the sibling qwen35-lora-moe project rather than on the RK3588 board β€” every such figure says so inline. They bear on Phase 1 and Phase 2, never on board behaviour.

How to read this page. Every number is tagged. [M] = measured on our board, with the experiment linked. [D] = derived arithmetic from [M] figures. [P] = proposed, not yet validated. [?] = external claim we have not verified and which gates part of the plan. The design below is coherent under [M] and [D]; the [?] items are the real schedule risk and are collected in Β§7.


1. Executive summary & hardware rationale

The thesis: prefill and decode are different machines' problems, so stop running them on the same engine.

1.1 Why pure-NPU decode fails

Three independent measured reasons, any one of which is fatal:

cause measurement
Per-submit floor dominates at M=1 167 Β΅s submit floor; only ~16 Β΅s irreducible per task [M] (Submit floor decomposed)
Per-task chaining cost is regime-dependent ~1.3 Β΅s when a task's DMA hides under a neighbour's compute; ~9–13 Β΅s at M=1 where nothing hides it [M] (Experiment Log 2026-09-06)
W8A8 is the only dense NPU format RK3588 is W8A8 or W4A4 only β€” W4A8 is not a hardware mode [M]. Int4 weights force int4 activations and the coherence collapse that follows

Measured end-to-end this is not close: NPU M=1 decode β‰ˆ 7.2 t/s against CPU 12.8–13.67 t/s on the same model [M]. Decode has been the CPU's job in this project since that measurement, and this design makes that split architectural rather than a fallback.

One correction to the premise in the brief. M=1 decode is described as "strictly memory-bandwidth bound". On this board it is latency-bound first: a single A76 achieves ~8 GB/s of the ~21.9 GB/s it can stream, which is why a one-cache-line PRFM prefetch is worth 9–11% [M] (test_nf4_decode). Bandwidth only becomes the binding constraint once several cores are streaming concurrently. This matters for the plan: quantization alone will not deliver the projected token velocity β€” thread count, prefetch distance and expert-gather locality carry comparable weight. Budget engineering time for all four.

1.2 Why homogeneous MoE-on-NPU fails

MoE expert offload to the NPU is closed in all regimes measured β€” the binding constraints are the fused-CPU-MoE gap, fusion lost on partition, and the 4 GB IOVA budget; even a free NPU loses [M]. The measured-optimal profile for a routed MoE on this board is already asymmetric, and this roadmap generalises it rather than inventing it:

  • prefill (M>1): experts on CPU (batched 4Γ—4 NF4 NEON GEMM), attention/dense int8 on NPU [M]
  • decode (M=1): all-CPU fused gemv [M]

Reference point on qwen3.6-35B-A3B: prefill 36.9 t/s (1.59Γ— native) but decode 6.3–6.6 (0.85Γ—), PPL +5.5% [M].

Read that decode figure correctly β€” it is easy to misuse. Both rows are the same routed-MoE model: it compares our runtime against ggml's, both sparse, both on CPU. It is not evidence about sparse vs dense. Two known contributors: NF4 re-quantization instead of Q4_K (which also produces the +5.5% PPL), and it is an accepted trade for the 1.59Γ— prefill β€” ORK_MOE_AUTO=0 exists to decline it. Treat it as a ~15% implementation gap in our expert path, not as a verdict on the architecture.

The sparse premise itself is arithmetic, not conjecture: decode reads active parameters while dense reads all of them, so at equal decode bandwidth sparsity buys total capacity β€” precisely the currency a bandwidth-bound device lacks. This blueprint's 2.1 B / 636 M active costs the same per token as a dense 636 M model while carrying 3.3Γ— the parameters.

1.3 How the split solves both walls

The IOVA wall and the bandwidth wall have different solutions, and disaggregation lets us apply each where it works:

  • IOVA wall β€” only the prefill slice needs to be DMA-mappable. The NPU IOMMU (rk_iommu v2) is 32-bit; we measured EFAULT at 4.03 GiB and the driver reports a 3900 MiB ceiling [M]. Keeping the NPU-resident set at 2.1–3.0 GB leaves real headroom rather than living on the edge.
  • Bandwidth wall β€” decode never enters IOVA at all. It runs INT4 in 64-bit host memory where the format is unconstrained by the NPU datapath.

The cost this brief does not price: weight duplication. The same tensors exist in two precisions β€” W8A8 for prefill, INT4 for decode. Under config (B) (Β§1.6) only the dense backbone is duplicated into IOVA; the routed experts are held once as INT4 for decode plus a W8A8 copy in host RAM for CPU-side prefill. For the Β§2 model: 0.51 GB (IOVA) + 1.59 GB (host W8A8 experts) + 1.05 GB (host INT4) β‰ˆ 3.15 GB of weights [D]. Affordable on an 8 GB class member and comfortable on 16–32 GB, but it must appear in the RAM budget from day one rather than be discovered in Phase 3. If RAM is tight on an 8 GB board, the W8A8 expert copy is the one to drop β€” accept NF4 experts in prefill too and take the quality/throughput hit, which Phase 1.4 should quantify.

1.4 One thing that must not be assumed: concurrency

Heterogeneous engines are additive only on different resources β€” measured directly: a memory-bound third engine adds nothing (Mali streaming cut CPU+NPU aggregate 29.3 β†’ 16.9 GB/s), while a compute-bound third engine is fully additive (Mali memory-free kernel held 99.9% while CPU+NPU held 99.3%) [M]. The DRAM ceiling is ~30 GB/s at DMC 2112 MHz, not the 20–25 GB/s in the brief [M].

Consequence: sequential prefill→decode is safe. Pipelined prefill(chunk N+1) ‖ decode(chunk N) puts two memory-bound engines on one bus and will be zero-sum. If pipelining is wanted later it must be measured, not assumed, and /sys/class/devfreq/dmc/load is the ground truth.

The one measurement that directly supports sparse decode on A76

test_nf4_decode was built for this question: 64 distinct expert weights (32 MB ≫ 3 MB L3) walked once, so every K-step is a cold DRAM read like real MoE decode. It measured the M=1 loop latency-bound β€” one A76 reaching ~8 GB/s of the ~21.9 it can stream β€” with a one-cache-line PRFM worth 9–11% [M]. That is a tuning surface with headroom, not a wall, and it is the best evidence we hold that sparse decode on these cores is viable. Use this probe (not test_i4_gemm, whose single 0.5 MB weight stays L3-resident) for any expert-path work.

Thermal envelope: actively cooled

The target board is actively cooled, which has two consequences worth stating because they are usually assumed away on SBCs:

  • Every figure in this document is a sustained-clock measurement, not a burst one β€” the ~30 GB/s bus ceiling, the 21.8 GB/s achieved decode, and the three-engine additivity results all hold under continuous load [M].
  • Sustained multi-engine operation is a supported design point, which is what makes the Phase 4 Vulkan path (fully additive for compute-bound, memory-light kernels [M]) worth evaluating at all.

It does not unlock a persistent GPU spin-wait: Vulkan gives no forward-progress guarantee between workgroups, and the KMD watchdog kills long-running jobs. Those are correctness and driver constraints, not thermal ones. A bounded polling kernel behind timeline semaphores is the version worth measuring.

1.5 What the board actually delivers β€” projections grounded in measured rates

Neither engine runs at its datasheet number, so both projections below are anchored to a rate this board has actually achieved rather than to a theoretical ceiling.

Decode. The reference point: Qwen3-1.7B-w8a8 decodes at 12.8 t/s [M]. That is 1.7 GB/token Γ— 12.8 = 21.8 GB/s achieved on 4Γ—A76 β€” which also settles Β§1.1's nuance: per core the loop is latency-bound (~8 GB/s of 21.9 [M]), but in aggregate at -t4 it does reach the bus. Applying that achieved rate to this model:

value
bus traffic @ INT4 318 MB/token [D]
at 21.8 GB/s achieved ~68 t/s ceiling [D]
realistic after router, expert gather, GDN state update ~35–50 t/s [P]

Prefill. Measured 178 t/s on 1.7B dense = 3.4 GFLOP/token β†’ ~578 GFLOP/s [D], which is ~10% of the 6 TOPS headline. That is not a compute limit: prefill is CPU-bound on this board β€” activation quant ~45%, resolve ~43%, NPU only ~11% [M]. Applying the same achieved rate to 636 M active params (1.27 GFLOP/token):

value
at 578 GFLOP/s achieved ~455 t/s [D]
realistic, quant/resolve overhead scaling sub-linearly ~300–450 t/s [P]

The actionable consequence: the NPU is not the prefill bottleneck and adding NPU TOPS would not help. The lever is the host side β€” the precompiled regcmd cache and quant/NPU overlap β€” which is another reason Β§6 matters more than it looks.

1.6 Designing for the CLASS, not for our board

The target is the RK3588-class edge SBC β€” 32-bit NPU IOVA, A76-class NEON cores, Vulkan iGPU, shared LPDDR4x/5 β€” not one board. That changes which constraint binds, and the answer is not the one the brief assumes.

The IOVA ceiling is only binding if experts are NPU-resident

Two configurations, and the choice determines everything downstream:

(A) experts in IOVA (B) experts on CPU
prefill fully on NPU dense/attention on NPU, experts batched NF4 NEON
NPU-resident set entire model at W8A8 dense backbone only
total-param cap from IOVA β‰ˆ 3.5 B at W8A8, no matter how sparse none β€” RAM-bound instead
measured precedent β€” 1.59Γ— prefill on 35B-A3B [M]

(A) is the trap sparsity is supposed to avoid. If every expert must be DMA-mappable, the 4 GB window caps total parameters at ~3.5 B β€” so sparsity buys decode speed but not capacity, which defeats the point of a sparse MoE on a memory-constrained device.

(B) is the measured-optimal profile already and it inverts the constraint: the NPU only holds the dense backbone. For the Β§2 blueprint that is mixing 0.239 B + shared experts 0.198 B + embeddings 0.076 B = ~0.51 GB at W8A8 β€” seven times under the window [D]. IOVA stops being the binding constraint entirely.

Adopt (B). The consequences are worth stating plainly, because they reverse Β§4's emphasis:

  • The real ceilings become RAM (total params) and bus bandwidth (active params), not IOVA.
  • There is spare IOVA to spend: a wider dense backbone, more attention layers, or resident activation scratch for larger prefill chunks are all affordable.
  • The design ports downward: a class member with a smaller window still holds a 0.5 GB backbone.

Sizing formulas β€” re-derive, don't copy the numbers

active_params   ≀  (achieved_bus_GB/s Γ· target_tok/s) Γ· bytes_per_param      # decode velocity
npu_resident    ≀  iova_budget βˆ’ activation_scratch βˆ’ egress_ring            # prefill residency
total_params    ≀  (ram_budget βˆ’ kv βˆ’ state βˆ’ activations)
                   Γ· (bytes_W8A8_slice + bytes_INT4_slice)                   # dual residency

Worked for this class at 21.8 GB/s achieved [M] and INT4 decode:

target max active params note
20 t/s ~2.2 B comfortable; quality-bound, not bandwidth-bound
40 t/s ~1.1 B the sensible design point
68 t/s 0.64 B the Β§2 blueprint

The Β§2 blueprint at 636 M active is conservative β€” there is room to roughly double active parameters and still clear 40 t/s. Given (B) frees the IOVA budget too, the natural rebalance is a larger dense backbone (better quality per active parameter, and it runs on the NPU where prefill is cheap) rather than more routed experts. Settle it in Phase 1.3/1.4 against measured expert-gather locality, not by intuition.

The Vulkan iGPU: measured, and mostly not useful here

We have run this, so it does not need re-litigating from first principles. Mali-G610 exposes VK_KHR_shader_integer_dot_product with integerDotProduct4x8BitPackedSignedAccelerated = true, and on a real int8 GEMM, verified bit-exact, it measured ~32Γ— behind the NPU [M].

Worse for a three-engine plan, the additivity rule is sharp [M]:

Mali workload effect on CPU+NPU aggregate
memory-streaming kernel 29.3 β†’ 16.9 GB/s β€” actively harmful, DMC 88β†’85%
memory-free compute kernel Mali 99.9% β€– CPU+NPU 99.3% β€” fully additive

The iGPU shares the same ~30 GB/s bus, so for anything memory-bound β€” which is all of decode and most of prefill β€” it cannot help and will take bandwidth from the engines that can. The architecture must not depend on it.

The one regime that survives is compute-bound and memory-light. Plausible candidates, none yet measured [P]: the GDN chunked-form UT-solve (38% of scan time on CPU [M], small matrices, arithmetically dense), sampling/logit post-processing, and expert index permutation. Treat Vulkan as an opportunistic Phase 4, gated on a DMC-load measurement showing the candidate kernel is genuinely memory-light.

(Test-harness note: any Vulkan probe must reject llvmpipe explicitly β€” a software rasterizer will happily produce plausible-looking numbers that mean nothing [M].)

Portability mechanism

ork-driver already detects the SoC at runtime and carries one caps file per chip (src/soc/<chip>.c: core count, CBUF budget, output-width cap, K-slice) β€” RK3588 is hardware-validated, RK3576 shares the code path with inherited, untested params [M]. The model must consume these as parameters, never constants: mcap, nmax and core count all move across the class. A blueprint that hardcodes mcap = 192 will silently miscompute on the next chip.


2. Layer-by-layer architectural blueprint [P]

Sized so that active parameters Γ— 0.5 B/param β‰ˆ 300 MB/token, which is the brief's decode target.

d_model            1536
n_layers           28          21 GDN  (75%)  +  7 attention (25%), attention at layers 3,7,11,15,19,23,27
vocab              49152       tied embeddings
block config params/layer
GDN sequence mixer 12 heads Γ— head_dim 128, expand 1 (q,k,v,gate,out) 9.4 M
Attention (GQA) 12 query / 3 KV heads, head_dim 128 5.9 M
Shared expert (always on) ffn 1536, SwiGLU (gate/up/down) 7.08 M
Routed experts 16 experts Γ— ffn 768, top-2 56.6 M

Totals [D]

params
routed experts (28 Γ— 56.6 M) 1.586 B
shared experts (28 Γ— 7.08 M) 0.198 B
sequence mixing (21Γ—9.4 + 7Γ—5.9) 0.239 B
embeddings (tied) 0.076 B
total β‰ˆ 2.10 B
active/token (mixing + shared + top-2 routed) β‰ˆ 636 M
decode bus traffic @ INT4 β‰ˆ 318 MB/token

2.0 Conformance to the board's hard limits [M]

The dimensions above are not free choices β€” they are picked to satisfy every limit this project has measured. Any future edit to the blueprint must re-check this table.

limit value [M] blueprint ok
K % 32 == 0 API constraint K ∈ {1536, 768, 128} βœ…
N % 16 == 0 API constraint N ∈ {1536, 4608, 768, 384, 16} βœ…
Output-width cap nmax 8192 max N = 4608 (fused QKV) β†’ single slice, no N-tiling βœ…
int8 M-tile cap mg_max*64 192 @ K=1536 (704@512, 320@1024, 128@2048, 64@β‰₯3072) prefill chunk 512 β†’ 3 M-tiles of 192 βœ…
Wide-K K-split threshold K > 4096 K = 1536 β†’ full-K path, no K-split accumulate βœ…
CBUF budget cbuf_elems 57344 β€” βœ…
Quantization symmetry symmetric only, no zero-point W8A8 export is symmetric per-channel βœ…
Datapath modes W8A8 or W4A4 only; W4A8 is not a HW mode INT4 is CPU-exclusive by hardware βœ…
NPU concurrency single-stream β€” concurrent submits wedge the IOMMU sequential schedule, one submit queue βœ…
Board RAM 32 GB 3.2 GB weights + ~0.4 GB state βœ…

Two notes that fall out of the table:

  • mcap = 192 at K=1536 sets the prefill tile geometry. A 512-token prefill chunk is 3 M-tiles per program; a 2048-token chunk is 11. Weight DMA amortizes over rows within a tile, so larger prefill chunks are strictly better for the NPU β€” bounded by IOVA scratch, not by correctness.
  • The weight-reuse gate we just shipped (K*segw ≀ WEIGHT_BANK*32KB and mc == mcap, eligibility N ≲ 192 [M]) engages on exactly one tensor in this model: the MoE router (N = 16). Correct and free, but negligible β€” do not design around it.

2.1 Why 75% GDN, and the measured caveat

GDN/LRU gives O(1) state update, which removes the KV cache from 21 of 28 layers [D]:

per token @ 32k ctx
KV, 7 attention layers, GQA, fp16 10.75 KB 344 MB
KV if all 28 layers were attention 43 KB 1.4 GB
GDN recurrent state (21 Γ— 12 heads Γ— 128Γ—128 Γ— fp32) constant 16.5 MB 16.5 MB

Caveat, measured and load-bearing: the on-NPU GDN scan loses to ggml's solve-free CPU recurrence β€” ~1.9Γ— at 0.8B and ~4.8Γ— at 9B, and the gap widens with scale [M]. Root cause: the chunked form adds a CPU UT-solve (38% of scan time) that the plain recurrence skips, plus submit overhead, and there is no crossover because the GDN state fits in cache. So: GDN belongs on the CPU decode engine and must not be scheduled onto the NPU. That is consistent with this architecture, but it forecloses a "just move the scan to the NPU" optimisation someone will inevitably propose.

If sequence-mixing offload to the NPU is ever genuinely wanted, Mamba-2 / SSD is the variant with evidence: it loses 0.7Γ— at 130M but wins ~2Γ— byte-coherent at 2.7B (9.72 vs 4.37 P128) [M]. That crossover is real and is the reason this document does not simply say "linear attention is CPU work".

2.1a Speculative decoding: implied by Β§1.1, priced by Β§2.1

Β§1.1 corrects the brief to say decode is latency-bound first (~8 GB/s of a possible 21.9 per core [M]). That is exactly the regime where speculative decoding pays: it converts latency into compute the board already has idle. It is not mentioned anywhere else in this document and should be, because the 75% GDN choice prices it.

What GDN costs a speculator [M]. A recurrent state cannot be rolled back by truncating cache slots the way a KV cache can β€” once history is folded into the state, a rejected draft token cannot be subtracted out. The standard remedy is snapshot-and-recommit, ~2 target forwards per accepted step, which puts break-even at acceptance length Ο„ > 2 rather than Ο„ > 1. On a model that is 75% GDN this is the dominant term, not a detail.

What it is worth if paid [M]. Measured on Qwen3.5-4B, which shares this document's ~75% GDN pattern, on Apple Silicon:

acceptance length speedup
released reference drafter, authors' harness 7.50 2.54Γ—
same drafter, independent harness, correct anchor geometry 7.01 2.00Γ—
an undertrained in-house drafter (68M params, 5.7k samples) 1.83 1.02Γ—

The third row is the warning: a drafter 9Γ— too small and trained on ~140Γ— too little data returns nothing, while sitting in the serving path costing latency. Speculative decoding is not a switch to flip; it is a second model with its own data requirement.

Two cheaper mitigations than snapshot-and-recommit, both measured elsewhere: cache the SSM inputs rather than the state so rollback becomes a pointer move (vLLM carries an RFC for the same idea), and note that a fully-accepted block needs no rollback at all β€” the "2 forwards always" model is pessimistic and the gap between it and measurement is itself worth reporting.

Recommendation: treat this as a Phase 4-class option, gated on a measurement, and do NOT size the decode velocity projection in Β§1.5 as if speculation is available. If it is wanted, the drafter's data budget belongs in Phase 1 alongside the base model's, not discovered later.

2.2 Attention layers β€” do not quantize their KV

KV quantization measured as a 4Γ— attention tax, not a bandwidth win [M] (Experiment Log 2026-09-03). With only 7 attention layers the fp16 KV cache is 344 MB at 32k, which is affordable. Keep it fp16.


3. Training & quantization pipeline (Apple Silicon) [P]/[?]

3.1 Memory model

The brief's ~2.5 GB static state per 1B params decomposes as:

component per 1B mechanism
FP8 base weights ~1.0 GB native FP8 storage
8-bit optimizer state ~1.0–1.2 GB GaLore low-rank projection, or Muon (no FP32 second moment)
gradients O(single layer) layer-wise backward + fused optimizer step, freed per layer
total static ~2.5 GB

For the Β§2 model (2.1 B): ~5.3 GB static [D], leaving ~120 GB of a 128 GB UMA for activations, batch and sequence length. The binding constraint on this host is Metal compute throughput, not memory β€” the memory plan is comfortable and should not drive architecture decisions.

3.1a The ceiling that is neither compute nor memory [M]

Β§3.1 concludes that Metal compute throughput binds this host rather than memory. Both are true and neither is what stops a long run. Metal limits a device to 499,000 buffers per PROCESS, and the count climbs with iterations β€” it is a COUNT, unrelated to how much RAM is free.

Measured on the same M5 Max / 128 GB training host, by the sibling qwen35-lora-moe project:

job died at free RAM at the time
0.8B on-policy generation ~57k mx.async_eval calls ample
4B drafter training step 3,840 of 6,000 ample
4B on-policy generation ~92k decode steps, having written nothing ample
32-expert LoRA training iter 500 of 1,888 ~63 GB free

The failure text is RuntimeError: [metal::malloc] Resource limit (499000) exceeded, and a sibling form is [Event::Event] Failed to create Metal shared event β€” the latter raised with 101 GB free. mx.synchronize() plus mx.clear_cache() between iterations slows the approach to the ceiling but does not prevent it; only process exit releases the resources.

Consequence for Phase 1.5. A ~2.1 B pre-training run is many thousands of steps. In one process it will hit this, and a crashed run that only writes weights at completion loses everything it did. The mitigation is cheap and must be in the training loop from the start rather than retrofitted:

  • chunk the run so each process performs at most a few hundred optimizer steps, and
  • persist after every chunk, off-host where possible, so a dead process costs one chunk.

Cost is one model load per chunk (~7 s measured). The sibling project's auto_resume.sh and train_experts_chunked.sh implement exactly this pattern and are the reference implementation.

3.2 The risk that gates this phase

MLX native FP8 training is unverified [?]. MLX's mature quantization surface is 4/8-bit for inference; FP8 training with Metal-side scaling is a materially different claim. Do not schedule around it until proven. Phase 1 exit criterion: a 2-layer toy model trains stably in the intended precision for 1k steps, loss curve matching a bf16 control. If it fails, the fallback is bf16 master weights + FP8 storage, which costs ~2 GB/1B more and still fits comfortably.

Similarly [?]: Muon's Newton–Schulz orthogonalization and GaLore's projections both need Metal kernels that may not exist in MLX at production quality. Prefer Muon if either works β€” dropping Adam's FP32 second moment is the larger and more reliable saving, and it has fewer moving parts than GaLore's periodic subspace re-estimation.

3.3 Dual export β€” the same weights, two formats

                 FP8 / bf16 checkpoint (MLX)
                            β”‚
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β–Ό                                   β–Ό
  W8A8 symmetric per-channel            INT4 / NF4 per-channel
  β†’ NPU prefill slice                   β†’ CPU decode slice
  (int8 A Β· int8 B β†’ int32 C)           (grouped, NEON gemv/GEMM)

Hard constraints from measurement:

  • Symmetric only. RK3588 matmul is symmetric-quantization only [M] β€” no zero-point on the NPU path.
  • W4A8 does not exist [M]. Int4 on the NPU means W4A4 and incoherent activations. The INT4 slice is therefore CPU-exclusive by hardware, not by preference.
  • Both slices must agree numerically. Prefill produces the state that decode continues from; a quantization mismatch shows up as a quality cliff at the prefillβ†’decode seam, which is invisible to per-op tests. Gate: perplexity measured across the seam, not per-engine.
  • Attention precision is a file property and the single biggest decode lever measured: q8_0 attention is the balanced optimum (36.9 prefill / 6.59 decode) vs f16 (38.4 / 5.26) and Q4_K (26.8 / 7.42, below the sbits<5.0 floor at which the NPU declines the tensor entirely) [M].

4. Zero-copy memory map & IOVA layout

╔═══════════════════════════════════════════════════════════════════════════╗
β•‘  32-BIT IOVA WINDOW  β€”  NPU DMA-mappable, hard ceiling 4.03 GiB [M]       β•‘
β•‘  driver-reported budget 3900 MiB [M]                                      β•‘
β•Ÿβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β•’
β•‘  W8A8 prefill weights β€” DENSE BACKBONE ONLY (config B)   ~0.51 GB         β•‘
β•‘    mixing 0.239 B Β· shared experts 0.198 B Β· embed 0.076 B                β•‘
β•‘    routed experts are NOT here β€” they live in host RAM (Β§1.6)             β•‘
β•‘  regcmd program cache (ork_pc_*, precompiled)            ~16–64 MB        β•‘
β•‘  activation / A-stage scratch (M-tiled)                  ~128 MB          β•‘
β•‘  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                  β•‘
β•‘  β”‚ EGRESS RING  32 MB (double-buffered 2 Γ— 16 MB)      β”‚                  β•‘
β•‘  β”‚ computed states / recurrent tokens β†’ host memory    β”‚                  β•‘
β•‘  β”‚ NOT the KV cache, NOT the context                   β”‚                  β•‘
β•‘  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β•‘
β•‘                                                                           β•‘
β•‘  TOTAL β‰ˆ 0.67 GB   β†’  ~3.2 GB headroom under the 3900 MiB budget          β•‘
β•‘  IOVA IS NOT THE BINDING CONSTRAINT under config (B). Spare window can    β•‘
β•‘  fund a wider dense backbone or larger prefill chunks β€” see Β§1.6.         β•‘
β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
              β”‚  DMA out (doorbell-signalled, see Β§4.1)
              β–Ό
╔═══════════════════════════════════════════════════════════════════════════╗
β•‘  64-BIT HOST RAM  β€”  CPU-addressable, no IOVA constraint                  β•‘
β•Ÿβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β•’
β•‘  INT4/NF4 decode weights (full model, incl. all experts) ~1.05 GB         β•‘
β•‘  W8A8 routed experts for CPU-side PREFILL (batched NEON)  ~1.59 GB        β•‘
β•‘  MoE router weights (fp16, latency-critical)             ~2 MB            β•‘
β•‘  GDN recurrent state, 21 layers                          16.5 MB (const)  β•‘
β•‘  KV cache, 7 attention layers, fp16                      344 MB @ 32k     β•‘
β•‘  expert gather staging / NEON worker scratch             ~64 MB           β•‘
β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•

4.0 Sizing the egress ring: 32 MB, not 64–128 MB [D]

The brief specifies 64–128 MB. The arithmetic does not support it. Per 512-token prefill chunk, everything that must cross the boundary is:

payload size
hidden states, 512 Γ— 1536 Γ— fp32 3.1 MB
KV for the 7 attention layers, 512 tokens Γ— 10.75 KB 5.5 MB
GDN recurrent state, 21 layers (handed over once, not per chunk) 16.5 MB
peak in flight ~25 MB

32 MB double-buffered (2 Γ— 16 MB) covers a 512-token chunk with margin, and IOVA is the scarcest resource in the system β€” spending 96 MB of it on ring headroom we can prove is unused is the wrong trade. Size the ring from the chunk, and if chunk size grows past ~2048 tokens, recompute rather than round up.

On-chip SRAM is not an alternative here. This board has 956 KB of NPU SRAM enabled (custom kernel, CONFIG_ROCKCHIP_RKNPU_SRAM=y) [M], which is ~30Γ— too small for this ring. It is also the wrong port: SRAM is a confirmed separate port for CPU access but measured null for NPU weight reads (67.7 vs 67.5%, sign flips across 3 sizes) [M]. Do not plan around it.

4.1 "DMA fences" β€” use the doorbell instead [M]

The brief specifies streaming egress "via DMA fences". The RKNPU uABI exposes fence_fd, but the mechanism this project has actually built and measured is different and better:

  • NONBLOCK submit (flag 0x2) + DRAM doorbell busy-poll on stock driver, no reflash: dispatch 130 Β΅s β†’ 5 Β΅s [M].
  • Precompiled regcmd cache (ork_pc_*): 6.03Γ— lower per-token program-build cost [M]. The two stack β€” one is the dispatch half, the other the build half.
  • Zero-copy input (ork_dma_alloc) is validated and default-on, βˆ’17% on the full-K prefill matmul [M]. Zero-copy output is correct but ~0 gain and unsafe under concurrent multi-core (per-core bsyncs do not serialize against NPU writes) β€” leave ORK_ZC_OUT off for the egress ring [M].

Design the ring as a single-producer/single-consumer buffer with a last-word sentinel poll, which is the pattern already proven in the colsplit path, rather than a fence-based handshake.

4.2 Single IOMMU domain, deliberately

Everything above fits one domain under 4 GB, and that is a design goal, not an accident. Multi-domain operation on this silicon has a history: two IOVA leaks (abrupt-kill teardown; a kernel bug filing every dma-buf import under domain 0 so destroy unmapped the wrong page table), a broken domain-switch path (default_domain lie + force-reset), and non-deterministic layout costing 46 vs 4 t/s. All are fixed by kernel patches carried on our board [M], but a design that never needs a second domain avoids the entire class. If the model ever exceeds the window, stream layers through it (LRU weight cache) rather than adding a domain.


5. Phase-by-phase execution roadmap

Phase 1 β€” Architecture definition & MLX pre-training loop

# deliverable exit criterion
1.1 Precision spike 2-layer toy trains 1k steps in target precision, loss matches bf16 control [?] gate. NOTE: 1k steps sits BELOW the 499k-buffer ceiling (Β§3.1a), so this gate passes while the full run still dies. Add a separate long-run gate: one domain trained to completion through the chunk/resume path, proving continuation is bit-faithful.
1.2 Optimizer spike Muon (preferred) or GaLore step implemented + numerically checked vs AdamW on a small dense model
1.3 GDN/attention interleave ablation 75/25 vs 50/50 at ~300M scale; pick on quality-per-active-param
1.4 MoE granularity ablation 16Γ—768 top-2 vs 32Γ—384 top-4; must also report expert-gather locality on A76, not just quality
1.4b Dense control at equal active params a dense ~636 M model vs the sparse 2.1 B/636 M, decode t/s and quality. The sparse bandwidth argument is arithmetic, but the gather overhead is not β€” this control prices it directly and fails the premise cheaply if it dominates
1.5 Full pre-training run ~2.1 B, target loss; checkpoint in a format both exporters read

Decision deferred to 1.4, deliberately: whether routed experts belong in the NPU prefill slice at all. Our measured MoE profile puts experts on CPU even for prefill [M], but that was measured on 35B-A3B with large experts; at 768-wide experts the balance may invert. The memory map in Β§4 assumes experts are NPU-resident for prefill; if 1.4 says otherwise, the IOVA slice drops from 2.1 GB to ~0.5 GB and the design gets easier, not harder.

Phase 2 β€” Dual-export graph toolchain

# deliverable notes
2.1 W8A8 exporter β†’ NPU prefill slice symmetric per-channel; emit the .orkpack tile layout directly
2.2 INT4/NF4 exporter β†’ CPU decode slice per-channel, group size chosen by NEON kernel, not by convention
2.3 Seam-coherence harness PPL measured across prefill→decode, plus bit-exact state handoff at the boundary
2.4 Golden-checksum regression fixed-seed inputs, O(MΒ·N) checksum vs embedded golden β€” the project's existing pattern; a full CPU reference at these K values takes minutes and will not be run per-commit

Do not target ONNX/RKNN. See Β§6.

Phase 3 β€” Heterogeneous C++ runtime

# deliverable notes
3.1 Prefill engine on ork-driver ork_i8_mm_pack / run, multi-core colsplit, precompiled regcmd cache
3.2 NEON decode workers INT4/NF4 fused M=1 gemv, PRFM one cache line ahead (+9–11% [M]), persistent OpenMP pool
3.3 Egress ring + handoff SPSC, sentinel poll, NONBLOCK submit
3.4 Scheduler sequential prefill→decode first. Pipelining is a §1.4 measurement, not a milestone
3.5 Core affinity NPU driver threads and decode workers pinned to A76 (cpu4..7); little cores drag the threadpool barrier β€” measured as the difference between losing to and beating the closed runtime [M]
3.6 Submit serialization The NPU is single-stream: concurrent submits wedge the IOMMU and cost a power cycle [M]. One owner of the render node β€” either a single submit thread or the orkd daemon, which exists for exactly this and reclaims the domain on client EOF
3.7 Governor + measurement discipline DDR performance @ 2112 MHz and A76 performance @ 2.4 GHz verified before any timing β€” a parked DDR governor roughly halves decode [M]. Report prefill and decode separately, β‰₯2 reps, warm

Board-safety constraints that bind Phase 3 work [M]: never kill -9 an in-flight submit (wedges the IOMMU β†’ power cycle, and a hard cut mid-submit can corrupt SPI and require physical reflash); run anything touching the NPU under sudo tools/util/npu_guard.sh -- <cmd> on a shared board; use timeout so board-side jobs self-terminate cleanly.

Phase 4 β€” Opportunistic Vulkan offload (optional, gated)

Only after Phase 3 is measured end-to-end. Entry gate: a candidate kernel must be shown memory-light (/sys/class/devfreq/dmc/load near-flat while it runs), because a memory-bound Mali kernel measured negative [M]. Candidates, in order of plausibility [P]: the GDN chunked-form UT-solve (38% of CPU scan time [M], small dense matrices), logit/sampling post-processing, expert index permutation. Non-goal: int8 GEMM β€” the NPU is ~32Γ— faster [M] and shares the same bus.


6. Correction to the brief: do not build Phase 3 on librknnrt

The brief specifies a runtime "using librknnrt". This project exists specifically to replace it, and that replacement is measured at parity: open stack 178 prefill / 12.8 decode vs librkllmrt 184 / 11.71 on Qwen3-1.7B-w8a8 β€” 97% of prefill and 109% of decode [M].

Building on librknnrt would forfeit, concretely:

  • the precompiled regcmd cache (6.03Γ— lower per-token build [M]) and the NONBLOCK doorbell (130 Β΅s β†’ 5 Β΅s dispatch [M]) β€” neither is reachable through the closed API;
  • hardware task chaining (task_number > 1), which is how the per-task cost drops toward the ~1.3 Β΅s floor [M];
  • direct control of 0x1040 bank splits and the M-tile cap mg_max*64, worth ~2.1Γ— single-core / ~1.6Γ— three-core at K=2048 [M];
  • the ability to fix driver-level defects ourselves β€” the multi-domain IOVA bugs in Β§4.2 were diagnosed and patched by this project, not by the vendor.

It would also reintroduce a proprietary runtime dependency this repo is explicitly free of.

Recommendation: Phase 3.1 targets ork-driver directly. Phase 2.1 exports the .orkpack tile layout, skipping ONNX/RKNN entirely. The RKNN toolchain remains useful for calibration capture when adding a new op or SoC, which is a development-time need, not a runtime one.


7. Open risks, in priority order

# risk why it matters first action
1 MLX FP8 training unproven [?] gates the entire memory plan Phase 1.1 spike before any long run
1a Metal 499k-buffer ceiling per process [M] a long single-process training run dies regardless of free RAM; four jobs on this host died between ~57k calls and step 3,840, one having written nothing. It is a COUNT, not memory, so the Β§3.1 memory plan does not protect against it chunk + resume from the FIRST training commit (Β§3.1a); add a long-run gate to Phase 1.1, whose 1k-step spike sits below the ceiling and would pass anyway
2 Decode is latency-bound, not bandwidth-bound [M] the "2Γ— from INT4" projection assumes bandwidth is binding; it is not, at one core measure test_nf4_decode-style GB/s per core at the target shape before committing to the velocity number
3 Prefill→decode seam quality two quantizations of one model; per-op tests cannot see it. A geometry/precision mismatch that is invisible per-op is not hypothetical: the sibling project measured a one-token difference between training and serving geometry costing 40% of acceptance length, visible only end-to-end [M] Phase 2.3 is a gate, not a task. Compare against a SINGLE-precision control as well as across the seam — without one, a uniformly mediocre result looks like agreement
4 Weight duplication ~1.5Γ— 3.2 GB of weights for a 2.1 B model [D] carry it in the RAM budget from Phase 1
5 Muon/GaLore Metal kernels [?] may not exist at production quality prefer Muon; validate in 1.2
6 Pipelining assumed additive two memory-bound engines on a ~30 GB/s bus are zero-sum [M] keep sequential until DMC load says otherwise
7 Board-safety during long prefill sweeps wide-W prefill has hard-wedged this board, requiring physical SPI reflash [M] cap sweep width; never kill -9 an in-flight submit
8 Prefill chunk size is an IOVA/throughput trade bigger chunks amortize weight DMA over more rows (mcap=192 @ K=1536 [M]) but grow both the A-stage scratch and the egress ring sweep chunk size in Phase 3 against IOVA headroom; do not fix it at 512 by default
9 NPU is single-stream concurrent submits wedge the IOMMU β†’ power cycle [M] one submit owner from the first integration commit, not retrofitted
10 Class portability is unvalidated below RK3588 RK3576 shares the code path with inherited, untested caps [M]; mcap/nmax/core count all move consume SoC caps as parameters, never constants; validate one non-RK3588 member before claiming "class" support
11 8 GB class members 3.15 GB of weights [D] plus KV/state/activations is tight make the host-side W8A8 expert copy optional (NF4 prefill fallback), quantified in 1.4
12 Expert-gather DRAM efficiency at M=1 non-contiguous per-token expert reads waste DRAM page/bank locality, and NF4 codebook dequant costs more per byte than Q4_K β€” the ~15% gap behind ggml on 35B-A3B [M] is this, not the MoE premise measure with test_nf4_decode at the blueprint's expert dims; address via expert-major layout, sorting tokens by expert, prefetch distance
13 Vulkan is not a third GEMM engine Mali measured ~32Γ— behind the NPU on int8 GEMM, and a memory-bound Mali kernel cut CPU+NPU aggregate 29.3 β†’ 16.9 GB/s [M] keep it out of the critical path; Phase 4 only, gated on a DMC-load check

8. What this design does not claim

  • It does not claim NPU decode can be rescued. It cannot, for three independent measured reasons (Β§1.1).
  • It does not claim heterogeneous execution is free. It is additive only across different resources [M].
  • It does not claim GDN-on-NPU works. Measured, it loses and the gap widens with scale [M].
  • It does not claim the iGPU contributes. Measured, Mali is ~32Γ— behind on int8 GEMM and a memory-bound Mali kernel reduces CPU+NPU aggregate throughput [M].
  • It does not claim speculative decoding is available. Β§2.1a prices it; the decode projections in Β§1.5 do not assume it, and a drafter is a second model with its own data budget [M].
  • It does not claim class portability. Only RK3588 is validated; RK3576 caps are inherited and untested [M].
  • It does not claim 6 TOPS is reachable in practice. That is 3 cores Γ— ~2 TOPS on dense W8A8 with all three saturated; our multi-core colsplit measures ~2.2Γ— single-core, not 3Γ— [M].

Related

Optimization-Roadmap Β· Experiment-Log Β· Benchmark-Standards-and-Methodology Β· Model-Recipes Β· Vendor-Kernel-Behaviour Β· Kernel-Modifications

⚠️ **GitHub.com Fallback** ⚠️