Skip to content

regcmd ISA Reference

Michael Fischer edited this page Oct 13, 2026 · 30 revisions

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).

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=<dma>. 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):

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). 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.

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.

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<<i (0x1/0x2/0x4); running all cores = round-robin single-core submits, like librkllmrt.
    1. Populate all subcore_task[0..2] (each {task_start,1}) even for one core. core_mask != 0x1 with only subcore_task[0] set → NULL deref in rknpu_job_subcore_commit → Oops.
    2. Keep the per-submit RKNPU_ACT_RESET as 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 in rknpu_job_next with 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).
  • 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) — 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 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 : <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.

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<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.

★ 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σ.

Clone this wiki locally