Repository navigation
regcmd ISA Reference
The consolidated reverse-engineering reference for the RK35xx NPU register-command (regcmd)
matmul path — the durable "lab notebook" behind src/npu.c. Validated on RK3588; the ISA is
shared across the family, the numeric limits are per-SoC (see Adding an SoC).
- Driven through the in-tree
rknpuDRM driver, ioctls on/dev/dri/card1— the PRIMARY card node, notrenderD129(rawRKNPU_SUBMITonly works on the card node). - A matmul is a regcmd program (a list of register writes) placed in a DMA buffer; a
rknpu_taskdescriptor points at it;RKNPU_SUBMITruns it on a core. -
rknpu_task:enable_mask=0xd,int_mask=0x300,int_clear=0x1ffff,regcfg_amount=108,regcmd_addr=<dma>.rknpu_submit:flags=0x5,task_number=1,subcore_task[0]={0,1},core_mask=0x1.
Each entry is a (word0, word1) u32 pair:
reg_offset = word0 & 0xffff
block_id = word1 >> 16
value = ((word1 & 0xffff) << 16) | (word0 >> 16)
Blocks: 0x0201 CNA (feature/conv engine), 0x0801 DPU, 0x1001 PPU / core
output, 0x0101 PC (program control). Patching note: some regs (e.g. 0x1040) appear in
the template more than once — patch all matching (reg,block) entries; the NPU uses a
later copy, so first-match-only leaves a stale value (only shows up on multi-M-tile shapes).
| reg (block) | meaning | value |
|---|---|---|
0x1024 (0201) |
K dims | ((K-1)<<16)|K |
0x1030 (0201) |
weight byte amount |
K*N*2 (fp16) / K*N (int8) |
0x1034 (0201) |
K stride |
K*2 (fp16) / K (int8) |
0x1044 (0201) |
K passes |
K/32 (fp16) / ceil(K/64) (int8) |
0x1088 (0201) |
K | K |
0x107c (0201) |
CBUF entries per activation slice (NVDLA D_ENTRY_PER_SLICE) |
K/8 (fp16) / K/16 (int8) / K/16 (int4) — elements/entry = 8 / 16 / 16 |
0x1020 / 0x102c / 0x1084 (0201) |
M (rows) |
M, 0x10000|M
|
0x1010 (0201) |
rows per internal M-tile | 16 * min(M+1, R) |
0x1040 (0201) |
M-tile schedule | see below |
0x1038 (0201) |
N | 0x1010000 | N |
0x1070 (0201) |
feature (A) IOVA | dma |
0x1110 (0201) |
weight (B) IOVA | dma |
0x4020 (1001) |
output (C) IOVA | dma |
0x4034 / 0x405c (1001), 0x3014 (0801) |
output M dims |
M-1, (M-1)<<16
|
0x403c / 0x4058 / 0x4038 (1001), 0x3018 (0801) |
output N dims |
N-1, (N/4)-1
|
0x000d / 0x000f (1001), 0x00810000
|
CORE_CTRL (execution start) |
0x000d (start), 0x000f (start and WAIT for MAC array to finish) |
0x0101:0x10 (0101) |
PC-chain: next regcmd addr | dma / 0 to end |
0x0101:0x14 (0101) |
PC-chain amount |
0x37 (non-last) / 0 (last) |
Cross-referenced every CNA/DPU register we emit against the mainline accel/rocket header
(rocket_registers.h) + rocket_job.c. Several long-standing labels above are refinements or
corrections — the observed behaviour was right, but the named meaning is more specific (and drives
the register-naming refactor / Exp-2026-07-28 M-Fold + CBUF):
| reg | rocket name | field(s) | correction vs table above |
|---|---|---|---|
0x1024 |
CNA_DATA_SIZE1 |
DATAIN_CHANNEL[15:0], DATAIN_CHANNEL_REAL[29:16]
|
= K (input channels), confirmed |
0x100c |
CNA_CONV_CON1 |
CONV_MODE,IN_PRECISION,PROC_PRECISION,NONALIGN_DMA[30]
|
conv/precision mode |
0x1010 |
CNA_CONV_CON2 |
FEATURE_GRAINS[13:4], KERNEL_GROUP[23:16]
|
"rows per M-tile" ⇒ feature grains hint |
0x1040 |
CNA_CBUF_CON0 |
DATA_BANK[3:0],WEIGHT_BANK[7:4],FC_DATA_BANK[10:8],DATA_REUSE[12],WEIGHT_REUSE[13]
|
"M-tile schedule" ⇒ CBUF bank allocation. The base-slope formula (0xb1−0x0f·(mg−1)) is exactly (DATA_BANK+1, WEIGHT_BANK−1) per step. |
0x1044 |
CNA_CBUF_CON1 |
DATA_ENTRIES[13:0] |
"K passes" ⇒ CBUF data entries. Reduction depth = DATA_BANK × bank_capacity, not this alone. |
0x107c |
CNA_DMA_CON1 |
LINE_STRIDE[27:0] — a line stride, full stop (mainline rocket_registers.h) |
"entries per slice (D_ENTRY_PER_SLICE)" ⇒ input DMA line stride. ⚠ Burst length is NOT here — it is 0x1078 CNA_DMA_CON0 (OV4K_BYPASS[31]/WEIGHT_BURST_LEN[19:16]/DATA_BURST_LEN[3:0]). ★ This is the activation-side stride lever for int4's mc_phys = 2H zero-line padding — not 0x405c. |
0x1080 |
CNA_DMA_CON2 |
SURF_STRIDE[27:0] |
input surface stride (8-byte units) |
0x1088 |
CNA_FC_DATA_SIZE1 |
DMA_CHANNEL[15:0] |
FC (matmul) DMA channel = K |
0x1020/0x1084
|
CNA_DATA_SIZE0 |
DATAIN_WIDTH[26:16],DATAIN_HEIGHT[10:0]
|
matmul M maps to WIDTH (or HEIGHT); M-fold uses WIDTH=M |
0x4020 |
DPU_DST_BASE_ADDR |
[31:0] |
output C base IOVA |
0x4024 |
DPU_DST_SURF_STRIDE |
[31:4] |
output surface stride |
0x4030/0x4034
|
DPU_DATA_CUBE_WIDTH/HEIGHT
|
[12:0] |
output cube dims |
0x4038 |
DPU_DATA_CUBE_NOTCH_ADDR |
NOTCH_ADDR_0[12:0],NOTCH_ADDR_1[28:16]
|
|
0x405c |
DPU_WDMA_SIZE_1 |
WIDTH_WDMA[12:0],HEIGHT_WDMA[28:16]
|
|
0x40c0 |
DPU_SURFACE_ADD |
SURF_ADD[31:4] |
an ABSOLUTE output IOVA (must be patched like 0x4020; leaving a size value here wild-writes → AXI hang). |
Addressing model (rocket_job.c): the regcmd carries absolute IOVAs baked in — no kernel
relocation. Every address reg (0x1070, 0x1110, 0x4020, and 0x40c0) must hold a valid IOVA.
Sizes the CBUF slice for the K input channels: value = K / (elements per CBUF entry), where
elements/entry = 8 (fp16), 16 (int8), 16 (int4) — a 128-bit entry for fp16/int8, and int4 reuses
int8's 16-element entry (only 64 of 128 bits used; it does not double-pack to 32/entry). fp16 (K/8)
and int8 (K/16) are the production-validated values; int4 (K/16) matches int8 and was confirmed by fuzz
(Exp-2026-07-07). This register gates int4 native multi-M batch mode: at K/16 it opens the activation
budget to rows×K ≤ 16384, enabling min(M/2, 16384/K) rows/submit (8 @ K=2048, 32 @ K=512). A dense int4
entry (K/32) — if the HW supports it — would double that; open RE (see [Reverse-Engineering-Roadmap]).
The NPU iterates M in internal tiles of R rows within one submit. R = cbuf_elems/K
(RK3588: cbuf_elems=32768 fp16, 65536 int8 — int8 packs 2× rows/CBUF).
-
0x1010 = 16 * min(M+1, R). -
0x1040(clean power-of-2 K):base - slope*(mg-1), wherelg=log2(K/256),base=0xb1-15*(2^lg-1),slope=15*2^lg,mg=max(1, M/64), floored at0x1b. int8 uses the same formula at effective K = K/2.⚠️ That expression is a LINEAR FIT to a bank split, not a schedule code (measured 2026-08-20, Exp 2026-08-20 fp16 M Envelope and the CBUF Bank Split).v = (WEIGHT_BANK<<4)|DATA_BANKwithDBNK+WBNK == 12at every step — eachmgstep moves one bank from weights to data — and the0x1bfloor is exactlyDBNK ≤ 11 / WBNK ≥ 1(forcingvbelow it hangs the submit). Bank = 32 KB. Consequences:-
fp16 M ceiling =
banks × 32 KB / (K × 2 B):sched=0(template default0xb1, 1 data bank) →M ≤ 16384/K;sched=1(up to 11 data banks) →M ≤ 180224/K. Measured 704@K256, 352@K512, 176@K1024 — the truncatedmg_max*64under-reports by 10–37%. Useorki_f16_mcap(K,sched). -
int8 does NOT follow this — it is measured-correct at the truncated
mg_max*64(704/320/128/64 at K=512/1024/2048/4096). Reason: the split walks data-ward as M grows, soWEIGHT_BANKshrinks, and int8 keeps its weight CBUF-resident (it dies onceWBNK< the weight's bank count) whereas fp16 streams its weight. int8 at N=64:M_max = 64·floor(12/w − 1),w = K/512. Do not unify. -
K=128 (fp16) is broken: it needs half-bank steps (
slope=(int)7.5=7), so the formula emits invalid splits and the envelope is non-monotonic (256 ok, 320 bad, 384 ok, …). Cap at 256. - Never bisect for a ceiling here — the predicate is not monotonic. Scan upward.
-
fp16 M ceiling =
-
Decode (M=1):
0x1010=0x20,0x1040=0xb1— one submit for any K up to ≈10752 elements (raw, dtype-independent; see below). Larger K still needs K-split. - The
0x2000bit in0x1040is a "subsequent M-tile" flag (used when PC-chaining M-tiles), not a K-accumulate — see ruled-out interpretations.
The 0x1040 formula base = 0xB1 − 15·(K/S − 1) (S = 256 fp16 / 512 int8) is calibrated for the prefill
band and overshoots below the reference K: as K drops, K/S < 1 makes the correction term negative, so
base climbs above the template default 0xB1 (0xBC@K64, 0xBE@K32 fp16). A 0x1040 value too large for
the actual contraction length selects an invalid K-reduction atom → the MAC array emits zeros (M=1) or
garbage (M>1). Silicon boundary (bare synth with this schedule active, i.e. sched=1):
| dtype | schedule valid from | zeros/garbles below |
|---|---|---|
| fp16 | K ≥ 96 | K ∈ {32, 64} |
| int8 | K ≥ 256 | K ∈ {32, 64, 96, 128} |
N- and M-independent (confirmed across N 16…512, M 1/8/64). Below the valid K, use sched=0, which leaves
the template 0x1040=0xB1 — correct for K up to ~256 at a single M-tile. Conversely 0xB1 is wrong above
~K=256 at large mc (partial-rows), so 0x1040 must track both K and the row-group; the production paths
(ork_mm_run, mcworker) select it per shape — sched=1 only for pow2 K∈[128,2048) fp16 / K∈{512,1024} int8,
sched=0 (template) otherwise. Register-bisected on silicon: overriding only 0x1040 0xBC→0xB1 at
M=64,K=64 flips zero-output (rel-L2 1.0) to bit-exact (3.8e-8). Full matrix + fix:
Exp-2026-07-12-Matmul-Shape-Boundaries.
-
Feature (A): flat
[M][K]row-major fp16. -
fp16 weights (B):
[Ntile][Ktile][16][32]— N-tile=16, K-tile=32, transposed within tile.B[(k0+kt*32+kk)][n0+nt*16+nl]→bb[nt*KT*16*32 + kt*16*32 + nl*32 + kk]. -
int8 weights:
[Ntile][Ktile][32][32]— N-tile=32. -
Output (C): flat
[M][N]fp32.
| limit | value | handling |
|---|---|---|
| rows per M-tile |
R = 32768/K (fp16) |
internal M-scheduler, or software M-tile |
| contraction slice |
K ≤ 2048 per slice |
K-split, accumulate fp32 partials host-side |
| output width | N ≤ 8192 |
N-tiling (N>8192 silently wrong otherwise) |
| single-op K (M≤R) | raw K ≤ 10752 | dtype- & N-independent; larger K → K-split |
The single-op K ceiling is a raw contraction-length cap at K=10752 exactly (= 21×512 = 336 fp16
/ 168 int8 K-tiles) — not a tile count. Confirmed by tools/ksubmit_probe (RK3588): K=10752
correct, K=10784 wedges the submit; dtype-independent (fp16 dies in the same raw band, 8192 OK
/ 11008 wedge) and N-independent (K=10752 OK at N=512/2048/8192). Near but under cbuf_elems/3
(32768/3 ≈ 10923; 3×10752 = 32256), though not an exact 3K≤cbuf rule. An earlier guess that the cap
was 256 K-tiles (→ int8 reaching K=16384) was refuted by the probe: int8 K=11008 is only 172
tiles yet wedges. This is a hardware limit the closed runtime obeys too (not an ork-driver encoding
bug). See
Performance → Decode single-submit experiment.
The single-op large-K scheduler for general M (M>R) follows a nonlinear CBUF heuristic
(R: K=1024→48, 2048→32, 4096→24, 6144→16, 8192→9; base 0x1040: 0x84,0x48,0x2a,0x39,0x48,0x66,0x84)
— no clean closed form; the K-split hybrid sidesteps it and is the production path.
- Cold start: the first submit to a freshly-allocated output buffer returns stale data. Run it twice once (a warmup), and re-warm whenever the output buffer is reallocated.
- Feature address caching: the NPU caches feature state by address across sequential submits. A single stable reused feature buffer is correct; rotating buffers corrupts results (corruption grows with pool depth). Opposite of the multi-task-in-one-submit case.
-
int8 cold-start wedge → fixed with a reset: the first int8 (w8a8) submit after entering
int8 mode wedges (the job never completes; the kernel logs
job timeout … task counter: 0onmask: 0x1, soft-resets, recovers). The int8 regcmd mode is stateful. Fix: issueRKNPU_ACT_RESET(action 6) when entering int8 mode — it primes the NPU so the first submit completes; cost is one benignsoft resetline, no job-timeout. fp16 never wedges (it cold-starts stale, fixed by the warmup re-run, not a reset). So: reset only on int8 entry; warmup (one throwaway submit, result discarded) for the fresh-buffer stale read on both. -
Multi-core submit — works, but two crash traps. Targeting one core is
core_mask=1<<i(0x1/0x2/0x4); running all cores = round-robin single-core submits, like librkllmrt.-
Populate all
subcore_task[0..2](each{task_start,1}) even for one core.core_mask!=0x1with onlysubcore_task[0]set → NULL deref inrknpu_job_subcore_commit→ Oops. -
Keep the per-submit
RKNPU_ACT_RESETas wedge recovery. It clears the job-queue state after a timed-out submit, not just int8 cold-start. Removing it (RESET once) and then firing many short-timeout/wedging submits — worse, concurrently across cores — corrupts the queue → hang inrknpu_job_nextwith IRQs disabled + RCU stall (hard hang, power-cycle required). RESET after any wedge before the next submit; never storm concurrent submits over wedging values. Validated working on all 3 cores; gives 1.69× decode (see Performance).
-
Populate all
-
Parallel per-core doorbell submit — the completion POLL is the cost, not the submit. When the
wide-K (K-split,
oSk>1, e.g. ffn_down K>4096) colsplit is fanned to per-core parallel threads (each thread submits AND drains its own core, like the mcworker), the submits complete in ~600µs/core (confirmed by per-submit timing) — butork_dyn_end's shared completion poll then re-scans the[Sk][M][Ncore]partial surface and hits its ~300ms miss-timeout (its sentinel scan can't confirm "done" on that surface's write-order, even though the HW already finished), adding ~500ms/matmul → wide-K prefill collapses to ~10 t/s while the identical job on the blocking mcworker runs at ~150 t/s. Fix: once the per-core workers have already drained every core, set aprepolledflag soork_dyn_endSKIPS its (now-redundant) poll and goes straight to the host-accumulate. Result: 9 → 146 t/s. Debugging trap (2026-07-22, cost a full day): every structural hypothesis tested NEGATIVE — buffer count (ORK_CONSOLIDATE_I8), XSPEC profile (XP_MC_MMvsXP_CHAIN_NT), the nonblock0x2flag (blocking colsplit was also slow; nonblock mcworker was fast), thread parallelism, the submit barrier, and poll-scan cost. A regcmd byte-diff proved the two paths' programs identical (only the weight address, reg0x1110, differed run-to-run). Only per-submit vsork_dyn_end-elapsed timing (~600µs submits vs ~502ms poll, NPU load 0% during the stall) localized it to the poll. Lesson: when a job is fast blocking but slow on the async doorbell with an identical regcmd, suspect the completion-detection path, not the submit — time the submit and the poll separately first.
Capture → diff → decode. Run Rockchip's librknnrt matmul under the
regcmd_capture.c
LD_PRELOAD shim, byte-compare its regcmd/buffers against our synthesized version with
identical random data, localize the first divergence, decode that register/layout, repeat.
Uniform-data probes hide layout bugs (they pass while random data diverges) — always diff with
random data.
-
Kernel driver task batching (
task_number > 1) →-EINVALor timeout. The upstream driver'ssubcore_taskarray management and IRQ masks are broken for multi-task queueing. -
Hardware Chaining via
PC_DATA_ADDR→ Stack corruption or race conditions. Jumping between completely separate blocks of108pairs without pausing the parser causes the MAC array to get out of sync. Solution: Concatenate all tasks into a singleregcmdarray and writeCORE_CTRL = 0x000fbetween tasks. This starts the MAC array and instructs the parser to wait for completion before proceeding, achieving zero-overhead chaining as a singletask_number = 1job. -
Cross-submit accumulation via the
0x2000bit → wrong (yields last-slice-only). The on-chip accumulator resets between submits; accumulation only works within one chained submit. -
"PC_DATA accumulation buffer" theory → disproved. A full regcmd diff of librknnrt's two
chained ops at K=4096 shows they differ only in feature/output address (same weights): they
are M-tiles each doing the full K, not K-slices accumulating.
0x2000is the M-tile flag. - General-M large-K via M-tile PC-chaining works but re-reads the full weights per M-tile (~22× for M=512,K=4096) → bandwidth-bound, slower than K-split on RK3588. Kept as a reference primitive for future NPUs with more bandwidth.
Found by the i8fuzz sweep (2026-07-08). 0 in the captured int8 regcmd (REGCMD_I8). Setting it to v
shifts EVERY output row uniformly: physrow = m + v (m=logical row). Confirmed at K=512/M=8/N=64: v=1 → rows
at physrow 1..8, v=4 → 4..11, etc. (rows shifted past the M-row output buffer are lost). It's a destination
base-row offset, NOT a batch stride. ⚠ Neither is 0x405c — mainline names it DPU_WDMA_SIZE_1
(HEIGHT_WDMA[28:16]/WIDTH_WDMA[12:0]), plain output geometry. It is a trigger for int4 multi-M only in
the sense that leaving it at 0 disables a write-DMA bound; it is not a stride in any precision, and
several pages describing it as "the PPU stride" send readers to the wrong register. (0x1014/0x1028
also perturb int8 output layout — stride/count-related — but not coherently.)
Captured programmatically from librknnrt's rknn_matmul_run across all runnable types × a shape grid
(tools: tools/re/mm_cap.c + rknpu_dump.so + decode_reg). The regcmd is 108 register writes =
0x0201 CNA (52) + 0x0801 DPU (5) + 0x1001 PPU/core (51) — the SAME block structure as our own
regcmd_i8.h. The 0x40xx (block 1001) fields below are the PPU output stage (see "Key registers":
0x4034/0x405c = output M dims, 0x403c/0x4058 = output N dims). K, weight IOVA, and the CBUF
sizing live in the 0201 CNA block (0x1024 K dims, 0x1030 weight bytes, 0x107c entries-per-slice,
0x1110 weight IOVA, 0x1010/0x1040 M-tile scheduler).
CORRECTION (2026-07-08): an earlier version of this section claimed the matmul used "only block 1001, bankless, K not in the regcmd." That was a
decode_regbug — its filter kept only domains 1001/2001 and silently dropped the 0201/0801 blocks. Fixed intools/re/decode_reg.c. The matmul is the normal CNA+DPU+PPU program; the genuinely NEW findings here are0x4010(mode reg), the two batch mechanisms, and the symmetric-only platform matrix — all in blocks that survived the filter.
Each entry = 2 u32: addr=w0&0xffff, value=((w1&0xffff)<<16)|(w0>>16), domain=w1>>16
(0x0201=CNA, 0x0801=DPU, 0x1001=this matmul/PPU engine).
| reg | meaning |
|---|---|
| 0x4010 | precision/mode: fp16→f32 a8000002 · fp16→f16 48000002 · int8→i32 80000000 · int8→i8 0 · int4→i16 38000006 |
| 0x4020 / 0x4010[hi] | A base address (steps per task/tile) |
| 0x4034 (1001) | output M dim = M-1 (in-task row count) — already in "Key registers" |
| 0x405c[31:16] (1001) | output M dim = (M-1)<<16 (0 in multi-task int4) — already in "Key registers" |
| 0x403c / 0x4058 (1001) | output N dims: (N-1); 0x4038 = (N/4-1) tiling |
| 0x4050 (1001) | weight stride: int8 0x7fc, int4 0x7fe |
| K (0201) | K IS in the regcmd — 0x1024=((K-1)<<16)|K, 0x1088=K, 0x107c=K/16 (int8). (Earlier "K not in regcmd" was the decode_reg bug.) |
- int8 / fp16 (types 1,2,3,4): in-task batch. M rows in a SINGLE task via 0x4034=M-1 & 0x405c=(M-1)<<16. 1 submit, 1 real regcmd block (108 regs). This IS our int8 M-scheduler — same block, same encoding.
- int4 (type 10, needs B_layout=1 native): MULTI-TASK submit. M=32 → task_number=96 (32 tasks/core × 3 cores) in ONE submit ioctl; each task = 1 row (0x405c=0). task[0,1] full (108 regs), task[2+] lightweight 12-reg incremental (update A addr only). The vendor amortizes submit overhead by packing many tasks per submit, NOT by 0x405c row-count.
The vendor submits task_number=96 and it runs. Multi-task-per-submit IS supported; our batch_probe must have malformed the descriptor. Lever: packing multiple independent matmuls' tasks into one submit could amortize the ~submit-floor that dominates M=1 decode / per-expert MoE GEMV dispatch — a real follow-up.
Probed every enum type via create(); RK3588 rejects all MIXED-precision types with
rknn_matmul_create_dynamic_shape, unsupported matmul dtype : <T> in this platform (ret -5).
- SUPPORTED (symmetric MAC only): 1 fp16×fp16→f32, 2 int8×int8→i32, 3 int8×int8→i8, 4 fp16×fp16→f16, 10 int4×int4→i16 (requires B_layout=1 native prepack).
- UNSUPPORTED on RK3588 (-5, NOT a prepack issue): 5 fp16×int8→f32, 6 fp16×int8→f16, 7 fp16×int4→f32 (w4a16), 8 fp16×int4→f16 (w4a16), 11 int8×int4→i32 (w4a8), 12 fp16×int4→bf16 (w4a16), 15 (UNKNOW). 9 (int8→f32) inconclusive (no create error, no run).
⇒ Confirms the datapath is SYMMETRIC-ONLY: no native w4a16 / w4a8 / w8a16 on RK3588. The RKNN enum is cross-platform header boilerplate (RK3562/66/68/76/88); other SoCs may support the mixed modes. "w4a16" as used by rkllm = int4 weight STORAGE decompressed to a symmetric compute precision (fp16 or int8) before the MAC — a storage/bandwidth scheme, not a native NPU matmul mode. Sensitive-tensor precision on RK3588 = use the native fp16×fp16 path (type 1/4) for those tensors.
Captured single-Conv .rknn (tools/re/models/build_conv.py) on the board via run_rknn + rknpu_dump.so,
sweeping Cout/Cin (weight) and H/W (data). Conv = 0201 CNA (52) + 0801 DPU (5) + 1001 PPU (51) + domain
2001 (0x50xx CDMA/input-feed block, 18 regs) — the 0x50xx block the matmul path does not emit.
domain-2001: 0x5004=0xe (mode), 0x500c=H-1, 0x5010=W-1, 0x5014=Cout-1, 0x5020=input base addr,
0x5044=stride/size, 0x5068=input fill — all dimension-linear.
Swept weight (Cout/Cin, up to conv_big = Cin256/Cout1024, weight ~4.7MB >> 1MB CBUF) and spatial (H/W). No register takes discrete bank-count values (1/2/4/8); conv_big still runs as 1 submit / 3-core split with no extra tasks. The CBUF-relevant fields that DO move are the already-documented 0201 CNA registers:
- 0x1030 = weight byte amount (scales with Cout·Cin·k²: 0x4800 → 0x480000).
- 0x107c = entries per activation slice (scales with the M/spatial dim: 32 @ hw8 → 256 @ hw64).
- 0x1010 / 0x1040 = rows-per-M-tile / M-tile schedule (the nonlinear CBUF heuristic).
⇒ RK3588 has no NVDLA-style explicit D_BANK (bank count) register. The weight/data CBUF split that
NVDLA sets via D_BANK is, on RK3588, an emergent consequence of these continuous dim-derived size
registers + the fixed CBUF — not an independent tunable. So the int4 batch cap (rows×K ≤ 16384) is gated
by 0x107c (entries-per-slice = K/16), NOT a bank knob; halving it via dense int4 packing (K/32) is
the real lever (open RE item), and the vendor's other int4-batch lever is the multi-task submit
(task_number=rows). (Correction: an earlier version said "D_BANK is implicit / no bank register / conv
emits only dim-linear regs" — that read the CNA block as absent due to the decode_reg filter bug; the
0201 CBUF-size regs above were there all along.)
Built single-op .rknn (tools/re/models/build_ops.py, fp16) in the rknn-toolkit2 container, captured each
on the board (run_rknn + rknpu_dump.so), decoded with the fixed decode_reg. Block IDs follow (2^k<<8)|1:
| block id | engine | addr range |
|---|---|---|
| 0x0101 | PC (program control / chain) | — |
| 0x0201 | CNA (conv/feature) | 0x10xx/0x11xx |
| 0x0801 | DPU | 0x30xx |
| 0x1001 | PPU / core output (+ SDP LUT) | 0x40xx |
| 0x2001 | CDMA (input feed) | 0x50xx |
| 0x4001 | PDP (pooling) — NEW | 0x60xx |
| 0x8001 | pool output / 2nd PDP stage — NEW | 0x70xx |
Per-op block signature (regs per block):
| op | blocks used | notes |
|---|---|---|
| matmul (int8/fp16) | 0201·52 + 0801·5 + 1001·51 = 108 | CNA+DPU+PPU |
| conv | 0201 + 0801 + 1001 + 2001 | adds CDMA input feed |
| softmax | 0101 + 1001 + 2001 (27 tasks) | PPU-based, PC-chained |
| add / mul (elementwise) | 1001·51 + 2001·18 | PPU + CDMA, no CNA |
| sigmoid / gelu / silu | 0101 + 1001·1079 + 2001 | 1001·1079 = SDP LUT (~1024 table entries) |
| avgpool / maxpool | 4001·18 + 8001·8 | dedicated PDP engine, no PPU/CNA |
| layernorm | 0101 + 0201·108 + 0801·10 + 1001·102 + 2001 (57 tasks) | full multi-engine chain |
PDP (0x4001) pooling regs (maxpool, in=64×16×16, k2s2): 0x6004=mode(0xe), 0x600c/0x6010=H-1/W-1(in), 0x6014/0x6020=Cin-1, 0x6018/0x601c=H-1/W-1(out), 0x6034=0x00110101 (kernel/stride), 0x6070=input IOVA, 0x60dc=3. 0x8001 (0x70xx) = the output-write stage.
Takeaways: activations are LUT-based (SDP, block 1001 — matches regcmd_silu_lut_f16.h); elementwise add/mul run on the PPU; pooling has its own PDP engine (0x4001/0x8001) untouched by matmul/conv; layernorm decomposes across CNA+DPU+PPU+CDMA (reduction on the conv engine).
Additional single-ops captured (build_ops.py). Refinements to the map above:
- Activation dispatch splits by smoothness: relu / relu6 / leakyrelu = PPU clamp, NO LUT (1001·51 + 2001·18). Smooth (sigmoid / gelu / silu / tanh / hardswish) = SDP LUT (1001·1079).
- Reductions reducemean / globalavgpool = 0201 CNA + 0801 DPU + 0x4001 PDP (mean via the conv + pooling engines). reducemax fell to CPU (empty regcmd). dwconv = the conv engine (0201+0801+1001+2001).
- Data-movement concat / transpose / resize / batchnorm / lrn = PPU (1001) + CDMA (2001) — no dedicated reformat block observed. gemm(Linear) = 1001+2001+0101.
- Block 0x0401 (k=2 in the (2^k<<8)|1 series) was NOT observed across all 24 ops — it stays unmapped (possibly a RUBIK/reshape or an engine RK3588 doesn't exercise via these ops).
Engine blocks confirmed in use on RK3588: 0101 PC · 0201 CNA · 0801 DPU · 1001 PPU/SDP · 2001 CDMA · 0x4001 PDP(pool) · 0x8001 pool-output. (0x0401 unobserved.)
pixelshuffle / pixelunshuffle / convtranspose(deconv) — the classic NVDLA RUBIK (data-reformat) ops — route through the CONV engine (0201+0801+1001) + PPU/CDMA on RK3588, NOT a dedicated 0x0401 block. After ~27 captured ops (matmul all types · conv · elementwise · activations · pooling · reductions · norms · data-reformat), block 0x0401 (k=2) is never emitted — the RK3588 rknn compiler does not use that engine. Confirmed engine set on RK3588: 0101 PC · 0201 CNA · 0801 DPU · 1001 PPU/SDP · 2001 CDMA · 0x4001 PDP · 0x8001 pool-out. Capture campaign (matmul-API + single-op .rknn surfaces) is complete.
Named from the mainline accel/rocket rocket_registers.h (RK3588 offsets — NOT vanilla NVDLA; e.g. the
SDP_RDMA block is 0x50xx: 0x5018=SRC_BASE, 0x501c=BRDMA_CFG, 0x5020=BS_BASE, 0x5034=ERDMA_CFG,
0x5038=EW_BASE) and validated on-board (probes + a captured Conv→Mul fusion). Concepts from NVDLA.
| field | bits | meaning |
|---|---|---|
EW_ALU_ALGO |
[19:16] | ALU function: MAX=0, MIN=1, SUM=2, EQL=3 (NVDLA map_alu_op order; SUM=2 confirmed vs our ADD template). MAX/MIN validated on-silicon (sdp_max_fuzz). |
EW_OP_TYPE |
ALU(add/max/min) vs MUL unit — mul op sets it | |
EW_OP_SRC |
2nd operand from register-immediate vs memory (ERDMA) | |
EW_DATA_MODE |
(with ERDMA_CFG) per-element vs per-channel | |
EW_*_BYPASS, EW_LUT_BYPASS, EW_OP_CVT_BYPASS
|
stage bypasses |
An EW-max/min = ADD-routing (ALU active) + EW_ALU_ALGO; a per-channel scale/EW-mul = the MUL unit +
the 2nd operand via ERDMA.
| field | bits | meaning |
|---|---|---|
ERDMA_DATA_MODE |
[31:30] |
per-element = 1 (full [M][N] 2nd tensor) · per-channel = 0 (broadcast a length-N vector across rows) |
ERDMA_DATA_SIZE |
[3:2] | 1 = ONE_BYTE (int8), 2 = TWO_BYTE (fp16/int16) |
ERDMA_DISABLE |
[0] | 1 = disabled (1-input SDP) |
So per-channel int8 = 0x0000000c (mode0+size1... 0x08 w/ our fill), fp16/int16 per-channel = 0x00000008, |
||
per-element fp16 = 0x40000008. The per-channel b vector is laid CONTIGUOUS [N], NOT surface-strided.
|
Bypass bits: BYPASS[0], ALU_BYPASS[1], MUL_BYPASS[4], RELU_BYPASS[6]; ALU_ALGO[19:16], ALU_SRC[8].
Fully bypassed = 0x53. 0x4048 BS_MUL_CFG has BS_MUL_SRC[0] (reg vs mem). RK3588's 0x501c BRDMA_CFG
is SIMPLIFIED (only BRDMA_DATA_USE[4:1]; no NVDLA PER_KERNEL) — so a per-channel broadcast rides the EW/ERDMA
operand, not the BS stage.
0x4010: OUT_PRECISION[31:29] | IN_PRECISION[28:26] | PROC_PRECISION[2:0], enum int8=0/int16=1/fp16=2.
int8→i32=0x80000000, int8→i8=0, int8→i16=0 (+geom), int8→fp16=0x40000000, fp16→fp16=0x48000002,
fp16→fp32=0xa8000002. Element size 0x40c0 (int8 0x20 / 2-byte 0x40), row stride 0x4050.
A 2-input SDP (ERDMA active) runs as a chained non-first task ONLY when the precision path matches end-to-end:
the vendor conv→mul fusion is all-fp16 (fp16 matmul-out → fp16 SDP). Feeding a fp16-configured SDP with
int16/other bytes STALLS the chained DMA (errno=110). Fix: emit the matmul output in the SDP's precision
(set_f16_out for a fp16 SDP). 1-input SDPs (silu, ERDMA off) chain regardless. ping-pong OFF for any SDP task.
See Exp-2026-07-14-Chained-2-Input-SDP-ERDMA.
A fp16 matmul CAN emit fp16 to memory (was thought impossible). The MATMUL output stage (NOT the conv's):
0x4040 BS_CFG=0x53 (BS FULLY bypassed — the conv's BS-active 0x20150 HANGS the matmul), 0x40c0=0x20,
and 0x4084 DPU_OUT_CVT_SCALE=0x00010001 — bit16 = FP32TOFP16_EN enables the fp32→fp16 writeout
(without it, 0x1, the output is all-zeros). Also sched=0 for small K. The fp16 matmul writes CONTIGUOUS
[M][N], NOT atom-8 — forcing atom-8 output (surf stride M·16, 0x4050=0x2) HANGS the CNA→DPU path
(no fp16-matmul-atom-8 datapath; the vendor always writes contiguous and reshapes via a CNA permutation-conv).
ork_npu_probe_f16_mm_f16out = 512/512 bit-exact. See Exp-2026-07-14-Attention-Perchan-Scale-and-CNA-Strided-Read.
The CNA (matmul activation reader) reads with a real line stride: synth sets 0x107c = K/8 (per-row stride
in 8-channel / 16-byte SURFACE units). Overriding it to pitch/8 and storing the activation at row pitch
S>K reads only the real [M][K] rows at the strided offsets — 512/512 bit-exact (probe ORK_F16_ROWPITCH,
confirmed at 2×/4×/8× pitch). So the matmul consumes non-contiguous operands (permuted-Q, KV-cache views)
directly — the DENSIFY lever, no CPU gather. (Contrast: the DPU-RDMA has NO src line stride — only
0x5040 EW_SURF_STRIDE, 0x5048 LINE_NOTCH, 0x504c SURF_NOTCH — so the SDP cannot read raw contiguous.)
0x4070 DPU_EW_CFG + 0x5034 ERDMA_CFG data-mode fields (from the vendor fp16 per-channel mul, task13)
0x4070 EW_CFG: EW_CVT_TYPE[31], EW_DATA_MODE[29:28] (0=per-channel, 2=per-element), EDATA_SIZE[23:22]
(2=fp16), EW_ALU_ALGO[19:16], EW_OP_SRC[6], EW_OP_TYPE[2] (1=mul), EW_OP_BYPASS[1], EW_BYPASS[0].
0x5034 ERDMA_CFG: ERDMA_DATA_MODE[31:30] (0=per-channel, 2=per-element), ERDMA_DATA_SIZE[3:2] (2=fp16).
Vendor fp16 per-channel mul (per-element, compiler-broadcast): EW=0x20800384, ERDMA=0x8000000a,
0x5044 FEATURE_MODE=0x17849 (FLYING_MODE[bit0-ish] + MRDMA_FP16TOFP32_EN); per-channel = clear the
DATA_MODE bits (EW 0x00800384, ERDMA 0x0000000a). 0x5048 SRC_DMA_CFG.LINE_NOTCH_ADDR[31:19] = N-8 (the
per-row skip for reading an 8-channel tile of a contiguous buffer); 0x504c SURF_NOTCH_ADDR[31:4].
XP_SDP = { KWP_NONE, RC_SDPKW, …, setdt=0 }: RC_SDPKW resets iff !ork_sdp_noreset() (default-SKIP =
keep-warm), setdt=0 leaves last_dt (SDP is transient, no marker). Byte-identical to the historical inline
if(!ork_sdp_noreset()) act(RESET); ALL SDP ops now route via ork_npu_enter(c, c->last_dt, XP_SDP, OCK_NONE).
NEVER set XP_SDP to RC_ALWAYS — that forces the ~105µs reset ORK_SSM_KEEPWARM removes.
The int8-matmul (CNA→DPU, enable=0xd) raises the op-done interrupt ONLY when it writes the native int32
accumulator (0x4010 OUT_PRECISION=int32 = 0x80000000, 0x4050 SIZE_E=7 = 0x7fc). Any requantized
narrow output — set_i16_out (int16), or int8 — never hits the WDMA terminal count → errno=110
ETIMEDOUT. Bisected: 0x4010 + 0x4050 must be the int32 pair; every SIZE_E {0,2,7} × narrow precision
still hangs (the SDP's self-completing SIZE_E=0 does NOT rescue the matmul). Confirmed standalone, chained,
AND via chain_gatesilu_probe (the "proven" int16 chain) — on a FRESH BOOT + clean + pre-session source, so
NOT board-state. The SDP (enable=0x18, DPU-only) DOES emit int16 fine — a different physical pathway.
=> the CNA→DPU matmul's requant→WDMA path is the wedged block; narrow-output matmul is chain-of-int32-only
in practice. Consequence: an O(M·N) int16 per-channel-scaled matmul (int16 matmul→int16 SDP) is not viable;
the O(M·N) route is fp16 matmul (self-completes) + CNA permutation-conv reshape + fp16 SDP. Harness:
tools/i16out_probe.c, probe_i16_out env ORK_MM_I32OUT/ORK_MM_R<reg>. See attention-perchan Exp page.
Shape-INVARIANT (rules out burst-alignment & FIFO-backpressure) + the SDP "loopback" mirror (2026-07-15)
Swept the standalone int16-output matmul across MKN = {1,32,64}, {8,32,32/256}, {8,512,128}, {16,128,512},
{32,256,256}: every shape stalls (errno=110). This kills two "late-ASIC-optimization" repair theories:
a burst-alignment bug (clean power-of-2 rows — 256/512 B — would then self-complete: they don't) and a
FIFO-backpressure/tiling deadlock (a tiny 1×32×64 tile would then drain and complete: it doesn't). The
failure is geometry-independent → the requant→WDMA flush for narrow output is unconditionally broken (a
terminal-flush / clock-gating class bug, not fixable by padding or tiling).
The "loopback" (do the 32→16 requant in a separate SDP pass, not the CNA→DPU output stage) is ALSO dead.
The SDP's main feature RDMA (MRDMA) does not accept an int32 (4-byte) feature input: it clamps to a 2-byte
read and fetches half the bytes, starving the math engine → the same errno=110. Proven shape-invariant —
all 8 0x5044 IN_PRECISION[17:15] enums stall, and it stalls at M=1 (a single row, so it is NOT an
inter-row stride bug; the under-fetch is intra-row). Architectural root (NVDLA-consistent): the DPU compute
lanes (EW-mul, requant CVT) operate on int8/int16/fp16; int32 is accumulator/bias-only and cannot be read
back from DRAM as a feature. So the 32→16 downcast is unavailable by BOTH feeders — CNA-internal (output
CVT fed by the accumulator) and RDMA-external (SDP reading int32 from DRAM). The fp16 dummy-conv reshape
(fp16 self-completes; fp16 IS a valid feature precision) is the undisputed O(M·N) survivor. Harness:
ork_npu_requant_perchan_i32 + tools/requant_i32_probe.c (env ORK_RQ_5044/ORK_RQ_DUMP, argv M N);
tools/i16out_probe.c now takes argv M K N for the shape sweep.
The two closes for out[m][n]=(A·B)[m][n]·scale[n] (attention A·V-normalize) benchmarked head-to-head
(tools/perchan_bench.c, M=head_dim=64, K=seq, N=queries swept, warm):
-
SDP-path (
ork_npu_mm_perchan_f16: matmul → tiny CPU repack → atom-8 SDP, O(M·N)): flat ~40-110µs across N=64..2048 (submit-floor + cheap repack). -
Diagonal (
ork_npu_mm_perchan_f16_diag: matmul → N×N diag matmul, pure-NPU O(M·N²)): 547µs→2517µs (K=512) / 952µs→3658µs (K=2048) — grows with the N×N weight. -
SDP-path WINS at every N by 10-30× (even N=64: 38 vs 547µs). The earlier "diagonal wins at small N" was
WRONG — the diagonal's 2nd matmul is a full submit (N×N weight + M·N² MACs), never submit-floor-cheap.
Verdict: use the SDP-path for attention-normalize; the diagonal's pure-NPU purity costs 10-30× and isn't
worth it. Both cap M≤64 (head_dim=128 needs tiling either way). The O(M·N) CPU repack in the SDP-path is the only
non-NPU part. The N%32 requirement is fixed for the SDP-path:
ork_npu_mm_perchan_f16now accepts any N≥1 by internally padding N to a 32-multiple (zero-pad B columns + scale, extract the real N) — so decode (N=queries=1) works, bit-exact, ~59µs (validated N=1/3/17/33). The diagonal still needs N%32, but that's moot since the SDP-path wins everywhere.
★ OVER-FETCH: the int32-input SDP CAN self-complete (2026-07-15) — but processes int32 as 2 int16 lanes
The 50% under-fetch (above) is a terminal-counter DISAGREEMENT, not a hard block: the RDMA input dims
(0x500c width / 0x5014 channel) are decoupled from the DPU output dims (0x4058/0x405c), as on NVDLA
for crop/pad. With the DPU in int32 (consume 4E) and the RDMA stuck 2-byte (fetch 2E), inflating the RDMA
element count — 0x5014 = 2N-1 (double channel) — makes it fetch 4E bytes so both terminal counters hit the
same cycle: the int32-input SDP self-completes (rc=0, ~30-80µs; ORK_RQ_5014=127 at N=64). So int32 CAN
be read to completion in one pass — fixable in software, contra the earlier "hard datapath gap" read.
HOWEVER the DPU processes the int32 as TWO int16 lanes, not a true 32-bit value: out[even 2k]=low16(a[k]),
out[odd 2k+1]=high16(a[k]) (each int32 spread across two output channels → out[n]←a[n/2], odd≈0 for
a<2^16). The per-channel EW-mul can't recombine the split halves, so a true int32-VALUE requant still isn't a
single op — but for int16-range accumulators the even-channel (low-half) read is directly usable.
RESOLVED (verified with large values, ORK_RQ_BIG): the split is EXACT + COMPLETE — out[2k]=low16(a[k]),
out[2k+1]=high16(a[k]) (a=80000 → lo=14464/hi=1, both correct), so the full int32 = out[2k]+out[2k+1]·65536
is present. BUT recombining the lanes into a scaled result is not achievable in pure NPU: it needs a ×65536
factor, which overflows int8/int16 weights AND the int16 per-channel scale; fp16 recombination overflows for any
hi>0 (a>65504→inf — exactly the large accumulators that need it); and the DPU's single 0x4088 shift can't
apply per-lane (even needs >>shift, odd needs >>(shift-16)). So the boundary is: int32 read-to-completion
= YES (over-fetch); int16-range low-half requant = YES; full-range int32-value requant = NO in pure NPU
(recombination is CPU-only, same cost class as the existing int8-matmul→int32→CPU-requant hybrid, so the
over-fetch is a mechanism discovery, not a new pure-NPU loopback). Confirms DPU compute lanes are ≤16-bit.
Harness: ork_npu_requant_perchan_i32 env ORK_RQ_5014/ORK_RQ_500C/ORK_RQ_BIG.
Stressed the compute-vs-drain ratio to test a clock-gating race (ORK_MM_DUMPOUT = fail-path frontier dump):
M=64,N=128,K=32 (fast compute / heavy drain) and M=16,N=32,K=8192 (slow burn / tiny drain) — both
write ZERO bytes (last-nz-elem=-1), not the partial write a mid-drain clock cut would leave. The
narrow-requant WDMA never commits its first burst. Slow-burn K=8192 didn't rescue it → not a
preemptive-gate-arming race either. Decisive A/B at M=64,N=8192,K=32 (a 1 MB output surface): int32
out completes (rc=0, 2 ms — so the WDMA drains 1 MB fine) while int16 requant out writes 0 bytes — the
only difference is the output precision. So draining works; the requantized-narrow path is structurally
never triggered. All four repair theories (burst-alignment, FIFO-backpressure, drain-time clock cut,
compute-awake race) are refuted — int32 is simply the only terminating matmul output format (a hard
datapath gap, not a timing bug). NB the two feeders fail differently: the matmul CNA→DPU narrow requant
writes 0% (never commits), while the SDP int32-input read writes ~50% (reads/computes half, then the
FIFO starves) — distinct mechanisms, same dead end. probe_i16_out env ORK_MM_DUMPOUT (fail-path frontier)
-
ORK_MM_I32OUT(int32 control).
Derived 2026-10-12 from our own three regcmd captures. No external source carries this. Mainline
rocket_registers.h names these fields but is autogenerated and has no enum values at all; Mesa's
rkt_regcmd.c writes DPU_DATA_FORMAT = 0 and never touches a precision field. NVDLA documents only
INT8/INT16/FP16 and has no INT4 row in its accumulator table, so int4 is a Rockchip addition with no
upstream reference.
0 = int8 1 = int16 2 = fp16 4 = int32 (output only) 6 = int4
Fields: CNA 0x100c CNA_CONV_CON1 — PROC_PRECISION[9:7] / IN_PRECISION[6:4].
DPU 0x4010 DPU_DATA_FORMAT — OUT_PRECISION[31:29] / IN_PRECISION[28:26] / PROC_PRECISION[2:0]
(plus EW_TRUNCATE_NEG[25:16], BN_MUL_SHIFT_VALUE_NEG[15:10], BS_MUL_SHIFT_VALUE_NEG[9:4],
MC_SURF_OUT[3]).
| capture | 0x100c |
PROC / IN | 0x4010 |
OUT / IN / PROC | decodes to |
|---|---|---|---|---|---|
| int8 MM | 0x20000000 |
0 / 0 | 0x80000000 |
4 / 0 / 0 | int8 × int8 → int32 |
| fp16 MM | 0x…0120 |
2 / 2 | 0x48000002 |
2 / 2 / 2 | fp16 → fp16 |
| int4 MM | 0x00000360 |
6 / 6 | 0x38000006 |
1 / 6 / 6 | int4 × int4 → int16 |
Every row decodes to exactly the operation it is, on two independent register blocks.
★★ Consequence: RK3588 has a dedicated PROC_PRECISION = 6 int4 processing mode on both the
convolution input stage and the DPU. int4 is not a software widening of the int8 datapath at the
register level.
Whether the int4 MAC array is correspondingly 2× wide is a separate and still-open question.
Vendor table 3-5 makes it plausible on paper — every tile atom is the same 16 B (A, C) / 1 KB (B) in
every precision, and int4 packs 2× the elements into each, so the int8 1 KB B atom is 32N × 32K = 1024 MACs = exactly one cycle's work at 6 TOPS ÷ 3 cores ÷ 1 GHz ÷ 2. The question reduces to
whether the 1 KB int4 atom (64N × 32K = 2048 MACs) also retires in one cycle. No measurement has
ever come within 10× of the array (best int4 ≈ 105 GMAC/s/core against int8's ~1024 MAC/cycle/core
peak), so every binding constraint found to date is upstream of the MACs: CBUF residency, the H_race
floor, per-program dispatch, and host-side work.
Also note 0x4010 OUT_PRECISION = 1 (int16) for int4 is why the output accumulator saturates at
0x7fff — the same value as ORK_DYN_SENT16, which makes a legitimately saturated element
indistinguishable from "this program never ran". That is a doorbell-protocol collision, not an
arithmetic limit; on real rotated data the accumulator measures mean ≈512, σ≈970, so 32767 is ~33σ.