# 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](https://github.com/oRKLLM/ork-driver/blob/main/docs/ADDING_AN_SOC.md)). ## Submission model - Driven through the in-tree **`rknpu` DRM driver**, ioctls on **`/dev/dri/card1`** — the PRIMARY card node, **not** `renderD129` (raw `RKNPU_SUBMIT` only works on the card node). - A matmul is a **regcmd program** (a list of register writes) placed in a DMA buffer; a `rknpu_task` descriptor points at it; `RKNPU_SUBMIT` runs it on a core. - `rknpu_task`: `enable_mask=0xd`, `int_mask=0x300`, `int_clear=0x1ffff`, `regcfg_amount=108`, `regcmd_addr=`. `rknpu_submit`: `flags=0x5`, `task_number=1`, `subcore_task[0]={0,1}`, `core_mask=0x1`. ## regcmd encoding 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). ## Key registers | 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) | ### `rocket_registers.h` cross-reference + name corrections (2026-07-28) 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](Exp-2026-07-28-M-Fold-Schedule-and-CBUF-Bank-Capacity)): | 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. ### `0x107c` — entries per activation slice (NVDLA `D_ENTRY_PER_SLICE`) 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 M-tiling scheduler (`0x1010`, `0x1040`) 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)`, where `lg=log2(K/256)`, `base=0xb1-15*(2^lg-1)`, `slope=15*2^lg`, `mg=max(1, M/64)`, floored at `0x1b`. 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|Exp-2026-08-20-fp16-M-Envelope-and-the-CBUF-Bank-Split]]). `v = (WEIGHT_BANK<<4)|DATA_BANK` with **`DBNK+WBNK == 12`** at every step — each `mg` step moves one bank from weights to data — and the `0x1b` floor is exactly `DBNK ≤ 11 / WBNK ≥ 1` (forcing `v` below it **hangs the submit**). Bank = 32 KB. Consequences: - **fp16 M ceiling = `banks × 32 KB / (K × 2 B)`**: `sched=0` (template default `0xb1`, 1 data bank) → `M ≤ 16384/K`; `sched=1` (up to 11 data banks) → `M ≤ 180224/K`. Measured 704@K256, **352@K512**, **176@K1024** — the truncated `mg_max*64` under-reports by 10–37%. Use `orki_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, so **`WEIGHT_BANK` shrinks**, and **int8 keeps its weight CBUF-resident** (it dies once `WBNK` < 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. - **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 `0x2000` bit in `0x1040` is a **"subsequent M-tile" flag** (used when PC-chaining M-tiles), **not** a K-accumulate — see ruled-out interpretations. #### Small-K validity of the `0x1040` schedule (2026-07-12) 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](Exp-2026-07-12-Matmul-Shape-Boundaries). ## Operand tile layouts - **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. ## Limits & tiling (RK3588) | 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](Performance#decode-single-submit-experiment--the-k-ceiling-and-the-iova-blocker). 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. ## State gotchas - **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: 0` on `mask: 0x1`, soft-resets, recovers). The int8 regcmd mode is stateful. **Fix:** issue `RKNPU_ACT_RESET` (action 6) when entering int8 mode — it primes the NPU so the first submit completes; cost is one benign `soft reset` line, 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<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) — but `ork_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 a `prepolled` flag so `ork_dyn_end` SKIPS 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_MM` vs `XP_CHAIN_NT`), the nonblock `0x2` flag (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, reg `0x1110`, differed run-to-run). Only **per-submit vs `ork_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. ## Methodology **Capture → diff → decode.** Run Rockchip's `librknnrt` matmul under the [`regcmd_capture.c`](https://github.com/oRKLLM/ork-driver/blob/main/tools/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. ## Ruled out (don't re-walk these) - **Kernel driver task batching (`task_number > 1`)** → `-EINVAL` or timeout. The upstream driver's `subcore_task` array 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 of `108` pairs without pausing the parser causes the MAC array to get out of sync. **Solution:** Concatenate all tasks into a *single* `regcmd` array and write `CORE_CTRL = 0x000f` between tasks. This starts the MAC array *and* instructs the parser to wait for completion before proceeding, achieving zero-overhead chaining as a single `task_number = 1` job. - **Cross-submit accumulation via the `0x2000` bit** → 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. `0x2000` is 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. ### `0x1068` (0201) — int8 output row-offset (`D_DST` Y-offset analog) 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.) ## Domain-1001 matmul engine (0x40xx block) — vendor RKNN matmul-API capture (2026-07-08) 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_reg` bug** — its filter kept only domains > 1001/2001 and silently dropped the 0201/0801 blocks. Fixed in `tools/re/decode_reg.c`. The matmul is > the normal CNA+DPU+PPU program; the genuinely NEW findings here are `0x4010` (mode reg), the two batch > mechanisms, and the symmetric-only platform matrix — all in blocks that survived the filter. ### (value,target) encoding (calibrated from regcmd_i8.h) 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). ### Register semantics (from shape-sweep diffs) | 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.) | ### TWO distinct batch mechanisms (major finding) - **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. ### ⇒ REFUTES the old batch_probe finding "kernel rejects task_number>1" 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. ### Platform support matrix (RK3588, rknn_matmul_api) — SYMMETRIC MODES ONLY Probed every enum type via create(); RK3588 rejects all MIXED-precision types with `rknn_matmul_create_dynamic_shape, unsupported matmul dtype : 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. ## Conv path (adds domain 2001 / 0x50xx CDMA block) + the D_BANK answer (2026-07-08) 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. ### D_BANK: no explicit bank-count register exists — CBUF residency is the 0201 size regs 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.)* ## Op → engine-block map (single-op .rknn capture, 2026-07-08) 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). ### Op map — expanded battery (2026-07-08, 24 ops total) 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.) ### RUBIK / 0x0401 — unobserved (campaign close) 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. ## SDP element-wise + DPU output-stage registers (2026-07-14, rocket_registers.h + on-silicon) 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. ### `0x4070` DPU_EW_CFG — element-wise op | 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. ### `0x5034` DPU_RDMA_RDMA_ERDMA_CFG — 2nd-operand (EW) read | 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.** ### `0x4040` DPU_BS_CFG / `0x4060` DPU_BN_CFG — bias/scale & batch-norm stages 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.** ### DPU output stages (`0x4010` DATA_FORMAT precision + geometry) — `set_{i8,i16,f16}_out` `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`. ### Chained 2-input SDP (single-submit fused matmul→EW) — the DTYPE-PATH rule 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`. ### fp16 matmul → fp16 output stage (`set_f16_out_fp16in`) — the FP32TOFP16_EN enabler 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`. ### `0x107c` CNA_DMA_CON1.LINE_STRIDE — the matmul reads STRIDED / non-contiguous activations 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]. ### Mode-transition layer: `RC_SDPKW` — the transient SDP entry as data `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. ### int16/narrow-output MATMUL does NOT self-complete (CNA→DPU requant-WDMA), only int32 does 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`. 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. #### ★ Per-channel-scaled fp16 matmul: SDP-path BEATS the diagonal at EVERY N (2026-07-15 benchmark) 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_f16` now 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`. #### Timing-ratio + drain tests: it's STRUCTURAL, not a clock-gate/drain race (2026-07-15) 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). --- ## ★ The precision enum — `PROC_PRECISION` / `IN_PRECISION` / `OUT_PRECISION` **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σ.