Exp 2026 07 28 M Fold Schedule and CBUF Bank Capacity - oRKLLM/ork-driver GitHub Wiki
M-fold schedule RE + the CBUF-bank-capacity mechanism (why a single submit reduces a partial K)
Board work on RK3588 (10.3.0.236). This entry reverse-engineers rkllm's M-folded int8 matmul
schedule (the per-program-faster int8 path we captured), reconciles it against the mainline
accel/rocket register names, and — most importantly — decodes the mechanism behind a puzzling
result (a single submit reducing only part of K) that turns out to name a whole class of registers
we had mislabeled.
TL;DR
- rkllm's faster int8 program folds M into the CNA WIDTH dimension and uses an NC1HWC2 feature
layout (C2=16). We reverse-engineered the full register delta vs ork's
synth_i8(confirmed at M=2 and M=24) and builtsynth_i8_mfold, which reproduces rkllm's captured regcmd bit-for-bit. - Trying to run it as a single task (ork emits one task; rkllm emits a multi-task chain) computes a deterministic but partial result: all-ones A·W at M=16/K=3584 gives a uniform 2400, not 3584. This is not corruption — it is exactly 2400 = 150 of the 224 input channel-surfaces.
- Root cause, via
rocket_registers.h: the registers we'd been calling a "schedule" are the CBUF bank/entry allocation.0x1040 = CNA_CBUF_CON0(DATA_BANK/WEIGHT_BANK/FC_DATA_BANK);0x1044 = CNA_CBUF_CON1(DATA_ENTRIES). A single submit reduces exactlyDATA_BANK × bank_capacitycontraction elements. mfold allocatesDATA_BANK=3→ ~2400. - Consequence: a single submit is correct only when the bank capacity equals K.
capacity < Ktruncates (completes, partial);capacity > Kover-runs (stalls). The capacity steps (~2400/3200/4000 at M=16) never hit K=3584, and raisingDATA_BANKsteals the ~11WEIGHT_BANKs the weight needs — the fixed 12-bank trade-off that forces rkllm's K-slice chain. mfold's ~1.6×/program advantage therefore requires the accumulation chain; it is not a standalone kernel.
The M-fold register delta (vs synth_i8, rocket-named)
Confirmed by diffing ork's synth_i8 output against rkllm's captured regcmd at M=2 and M=24
(same K=3584, N=1216), then rebuilt as synth_i8_mfold (a delta on the synth_i8 baseline):
| reg | rocket name | ork synth_i8 |
mfold (rkllm) | meaning |
|---|---|---|---|---|
0x1020 |
CNA_DATA_SIZE0 |
(1<<16)|M (W=1,H=M) |
(M<<16)|1 (W=M,H=1) |
M folded into WIDTH |
0x1084 |
CNA_DATA_SIZE0 (mirror) |
(1<<16)|M |
(M<<16)|1 |
ditto |
0x1028 |
CNA batch/atomics | 1 | M | |
0x100c |
CNA_CONV_CON1 |
0x20000000 (int32-out) |
0x20000000 (int32-out) |
(int8-out capture had 0) |
0x1010 |
CNA_CONV_CON2 |
16·rows (grains) |
0x20 (FEATURE_GRAINS=2) |
|
0x1040 |
CNA_CBUF_CON0 |
0xb1 (DATA_BANK=1) |
0x93 (DATA_BANK=3) |
CBUF bank split |
0x1044 |
CNA_CBUF_CON1 |
ceil(K/64)=56 |
(K/64)·M=896 |
DATA_ENTRIES |
0x107c |
CNA_DMA_CON1 |
K/16=224 |
4·M=64 |
input DMA burst/stride |
0x1080 |
CNA_DMA_CON2 |
0 | (W·C2)/8 (8-byte units) |
input surface stride |
0x4030/0x4034 |
DPU_DATA_CUBE_WIDTH/HEIGHT |
H=M | W=M-1, H=0 | output cube (M in width) |
0x405c/0x3014 |
WDMA size / dst | (M-1)<<16 | (M-1) | output width |
0x4024 |
DPU_DST_SURF_STRIDE |
linear | M·16·elsz |
NC1HWC2 out surface stride |
0x4038 |
DPU_DATA_CUBE_NOTCH_ADDR |
((s/4-1)<<16)|(N/4-1) |
0 | NC1HWC2 (no notch) |
0x40c0 |
DPU_SURFACE_ADD |
small const | absolute output IOVA | see wedge note |
Feature layout (host packer/de-tiler), NVDLA NC1HWC2, FEATURE_ATOMIC=16:
A[M,K] → [C1=⌈K/16⌉][H=1][W=M][C2=16], off(m,k) = (k/16)·M·16 + m·16 + (k%16); C output the same
with C=N. Weight layout is unchanged — identical to ork's ork_mm_pack_i8 ([⌈N/32⌉][⌈K/32⌉][32] [32], raw signed int8, no zero-point), matching Mesa rkt_coefs.c.
The wild descriptor: 0x40c0 = DPU_SURFACE_ADD is an absolute output IOVA
rocket_job.c confirms addresses are baked absolute into the regcmd — no kernel relocation. Of the
four address-bearing regs in the mfold program, our replay harness patched three (0x1070 A,
0x1110 weight, 0x4020 DPU_DST_BASE_ADDR) but left 0x40c0 at a bogus size value → the WDMA
wrote to an unmapped low IOVA → AXI bus hang / hard wedge (unfixable by buffer oversizing — the
address isn't near any buffer). Fix: treat 0x40c0 as an output IOVA (patch it to the C base).
With that fix the mfold submit fails soft (errno-110 timeout + self-heal) instead of hard-wedging —
the difference between "needs a plug power-cycle" and "recoverable". See
NPU-Quirks and Exp-2026-07-15 NPU Hard-Wedge Circumstances.
The CBUF-bank-capacity mechanism (the real reason for the partial reduction)
All-ones A·W makes the output equal the accumulation count (independent of layout). Measured on silicon at M=16, K=3584 (all values uniform across the output):
0x1040 (CBUF_CON0) |
DATA_BANK |
capacity vs K | result |
|---|---|---|---|
0x93 (mfold) |
3 | ~2400 < 3584 |
truncates → 2400 (completes) |
0x75 (override) |
5 | ~4000 > 3584 |
over-runs → stall (errno-110) |
| small M (2/8/12) | 2–3 | capacity > K |
stall |
So a single submit reduces exactly DATA_BANK × bank_capacity channels. capacity < K → truncate;
capacity > K → stall; capacity == K → correct. ork's normal path instead sets DATA_BANK=1, WEIGHT_BANK=11 (weight-resident) and streams the full K through one data bank — which is why it is
correct for any K. The 0x1040 "M-tile schedule" base-slope formula in the ISA reference is exactly
this bank allocation: each −0x0f step is (DATA_BANK+1, WEIGHT_BANK−1) (bits [3:0]/[7:4]).
Because the bank-capacity steps never equal an arbitrary K, and stealing banks for data evicts the resident weight, a single submit cannot cover a general K — which is precisely why the vendor chains (each task = one bank-full, accumulate). This is the mechanistic reason mfold is a K-slice-chained kernel, not a standalone matmul.
Status / verdict
synth_i8_mfoldis RE-complete and synth-proven (reproduces rkllm bit-for-bit), board-safe (soft-fail) with the0x40c0fix, and its partial-reduction behavior is fully explained by the CBUF bank model.- Not yet a usable matmul: realizing the ~1.6×/program advantage requires implementing the multi-submit K-slice accumulation chain. That is a large build, and the payoff is speculative — ork already beats rkllm on 7B prefill (see Performance). Banked as a documented, chain-required candidate for a future shape→(schedule, tiling, precision) selector.
- Register-naming correction (feeds the ork-driver register-remap refactor):
0x1040=CBUF_CON0,0x1044=CBUF_CON1 DATA_ENTRIES,0x107c=DMA_CON1,0x1080=DMA_CON2,0x1024=DATAIN_CHANNEL,0x1088=FC_DATA_SIZE1.DMA_CHANNEL,0x40c0=DPU_SURFACE_ADD(output IOVA). See the corrected rows in regcmd ISA Reference.
Code: ork-driver src/npu.c synth_i8_mfold + ork_npu_synth_i8_dump (ORK_MFOLD), ork_npu_replay_i8
(now patches 0x40c0); probes tools/re/{synth_dump,validate_mfold,nc1hwc2_test}.c.
CORRECTION (2026-07-30): 0x40c0 is an elem-size CONFIG (not an IOVA); the m8 tile CHAINS bit-exact
The "0x40c0 = absolute output IOVA / patch it to the C base" reading above is SUPERSEDED. 0x40c0
(DPU_SURFACE_ADD) is an element/surface-size CONFIG: 0x20 int8, 0x80 int32, 0x400 for the
M-fold (=128*M at M=8). It is not an address — do not patch it. The captured m8 tile carries
0x40c0=0x400 and replays and chains bit-exact with it preserved untouched; patching it to a C base is
what pointed the WDMA at an unmapped low IOVA and hard-wedged. (ork_regs.h RK_DPU_SURFACE_ADD documents this;
the earlier "IOVA" label was a misread of a magic value.)
The M>8 chain is bit-exact (ork_npu_mfold_chain_cap)
"At M>8 you chain additional M<=8 tasks" — verified on RK3588 (governors=perf): chain P copies of the captured
bit-exact width-8 mfold regcmd (mm_regcmd_m8.txt) as ONE task_number=P submit, shared resident weight,
PC-chained at words 216-219. 0-mismatch / maxerr=0 at M=8/16/32/64, no wedge. Two regcmd-prep requirements
(the real cause of every prior "chain wedges / errno-110"):
- Preserve
0x40c0(config, above) — do not patch it. - Neutralize the captured PC-chain descriptor keeping the reg-ids — zero only the VALUE halves of the
0x0010/0x0014block-0x101entries (exactly likeork_npu_replay_i8). Zeroing whole words 216-219 drops the0x0014next-amount terminator and even a single task hangs.
Open (perf): each M=8 tile carries 0x1040=0xb1 (DATA_BANK=1/WEIGHT_BANK=11) and re-streams the full K*N
weight (~397us) every tile -> linear timing (P1 444 / P2 836 / P4 1618 / P8 3182 us) -> ~7x slower than
ork-normal at M=228 (which amortizes one weight stream over a big M-tile). The ~1.6x needs weight residency
across chained tiles (load-once/reuse; rkllm varies 0x1040 per task) — decode the reuse config offline from a
captured rkllm chain; do NOT sweep 0x1040 on-board.
Reinforcement (2026-07-30): word count is uniform — M>8 fails standalone because tiles inherit chain state
Two confirmations (decode-tool anchored, tools/re/regcmd_decode + the capture task descriptors) that strengthen
"M=8 is the largest STANDALONE full-K tile; M>8 needs the chain":
- Every matmul task is a uniform 232-word / 108-reg regcmd (
regcfg_amount=108for all 4395 matmul tasks in the capture; the 40/56-word blocks are tiny non-matmul setup tasks). So the M>8 standalone failure is NOT a word-count / program-length difference — an M=36 tile is the same size as an M=8 tile. (An earlier 364-word "M=36 tile" was an extraction bug:extract_tile.pyover-ran the block boundary; reading exactly the declared header word count fixes it, and the clean M=36 tile decodes to the same 116 reg-writes as M=8.) - A cleanly-extracted M=36 tile STILL errno-110s as a standalone
task_number=1submit — so the failure is the tile schedule/CBUF config, not truncation. Tellingly, the M=8 tile sets0x40c0=0x400(self-contained) while the M=36 tile omits0x40c0(decode shows it absent) — i.e. the larger-M tile inherits register state from earlier chain tasks and cannot run in isolation. Concrete evidence that big-M tiles are chain-context-dependent (they rely on state the earlier tasks left in the register/CBUF banks), consistent with the K-slice-accumulate reading above.
Net: the M=8-tile weight-resident chain (ork_npu_mfold_chain_cap) is bit-exact but re-streams the weight per
tile; the 1.6x still requires implementing the big-M K-slice-accumulate chain (which supplies the inherited
state), not a drop-in of a captured big-M tile.
CRACKED (2026-07-30): 0x1080 is a SIGNED stride — the "0x0fffffe8 sentinel" was never real
Captured both 0x1080 (CNA_DMA_CON2) variants across the rkllm-7B dump (4114 writes) and settled what its
w1 upper bits mean: they are two's-complement sign extension, 0xfff iff the 16-bit value is negative and
0x000 iff positive — 0/4114 violations. So:
0x1080is a signed 28-bit feature surface stride, not a stride+mode pair and not a sentinel.- The infamous
0x0fffffe8is simply-24(=-3*Mat M=8) in two's complement, read as unsigned. A small negative stride mistaken for a ~268 MB stride is what looked like an "inherit-CBUF / OOB sentinel" and misled this RE for weeks. There is no chain-inherit marker here. - Per-
Mvalues are a planner-chosen set (-3*Mis the common small-M value;2160/Mappears at large M), not a closed formula.
Tooling: tools/re/regcmd_decode now decodes register values by width (16-bit = w0>>16 with w1&0xffff a
separate field; 32-bit for addresses/packed regs) and marks 0x1080 signed, printing e.g.
0x0fffffe8 (signed -24). The M=8 and M=36 tiles now decode with 0 unknown-value / 0 out-of-mask. This
supersedes every "0x1080 sentinel / 0x0fffffe8 = ~2GB stride" note on this page and in
NPU-Quirks — the value was always a signed stride.
Why the strides: 0x1080 is derived from the burst 0x107c (verified on the dump)
The per-tile 0x1080 values are not independent planner choices — they are a mechanical function of the
feature-DMA burst length and the fold width, verified across the rkllm-7B capture:
0x1080 = ±(0x107c − M)— 82%+ of 3887 writes match exactly (residual = per-task multi-write pairing + theburst=4Msubcase). It is a surface-stride correction: after a burst of0x107c, rewind/advance by(burst − M)to land on the next NC1HWC2 fold surface. Sign = traversal direction (rewind−/ advance+).0x107c(feature-DMA line burst)= 4·Mis the dominant value for every M (top-count at M=1,2,4,6,8,10,12,14,16,24,32), hard-capped at 128 (M=36:4·36=144→ 96/128; max0x107cobserved = 128, never exceeds; M=20 is a lone anomaly at 128). Minority per-M values are secondary in-task writes / tiling context.- Therefore the famous
−3·Mstride is simply theburst=4Mcase:0x1080 = M − 4M = −3M(a rewind).
Takeaway: 0x107c/0x1080 are a coupled burst+correction pair keyed to M, not two free planner outputs.
The only free-ish knob on this DMA path is the burst length (~4·M, a bandwidth choice bounded by the 128 cap);
the stride follows. This meaningfully shrinks the "planner is an opaque multi-variable heuristic" conclusion for
the CDMA registers (relevant to the register-naming roadmap, #40).
Burst rule for the anomalies (M=20, M=36): per-output-position, not 4·M
Isolating the clean 108-reg tile body (words 0-215; the earlier per-M "tail" was next-task bleed) gives a single
dominant 0x107c per M:
- Matched single-pass tiles:
0x107c = min(4·M, 128)for M∈{1,2,4,6,8,10,12,14,16,24} and M=32 (=128). Valid burst set is{4, 8·k (k≤8), 96, 128}— granularity jumps to 32 above 64 (no 72/80/88/…), hard cap 128. - Large accumulate tiles (M=20, M=36 — the ones that can't run standalone): the burst is per-sub-block,
tracking output position, not
4·M. For M=36,0x107ccorrelates exactly with the A/C offset:40 @ 0xa000,96 @ 0xa240,128 @ 0xa480— offsets stepping by0x240 = 576 = M·16(one fold line), the burst growing 40→96→128 across the tile's three positions (a progressive per-position DMA prefetch), capped at 128. M=20 → 128 uniformly (its natural4·20 = 80is invalid in the coarse{96,128}region, so it lands at the cap).
So the previously-"opaque multivariable planner" burst is pinned: the free variable for the anomalous tiles is the
sub-block output position, and 0x1080 = ±(0x107c − M) still follows per task. Net for the CDMA schedule
(0x107c/0x1080): matched tiles are 4·M / −3·M; accumulate tiles are position-driven prefetch bounded by
the 128 burst cap.