Exp 2026 07 16 Dynamic Submit API and Doorbell - oRKLLM/ork-driver GitHub Wiki
Exp 2026-07-16 β Dynamic-Submit API, Doorbell Progress, and the task_number Ceiling
A host-driven, NONBLOCK submission layer over the rknpu chain path: submit a matmul chain and watch it,
steer it, and free it mid-flight from userspace on the stock kernel (no MMIO, no driver change). This
page documents the submission model it exposed, the API (ork_dyn_* / ork_dyn_queue_* / ork_pc_* in
src/npu.c), the two validated wins, and the negative results that pin down exactly what the hardware/kernel
will and won't allow. Board 10.3.0.236, RK3588, kernel 6.1.115-vendor-rk35xx-sram.
1. The submission model β a fixed-length track
A SUBMIT ioctl hands the kernel a task list (array of rknpu_task, each with a regcmd_addr +
regfg_amount), the regcmd buffer (the programs; each ends with a descriptor at a fixed word: 0x0010
= next-program address, 0x0014 = next-program size, 0 = "I am terminal"), and task_number.
The load-bearing fact, established by measurement:
A submitted job runs exactly
task_numberprograms and then completes.task_numberis fixed at submit time and is immutable. The kernel's completion bookkeeping is locked to it.
This yields a hard asymmetry:
- You can run FEWER programs than declared β HALT works: writing
0x0014 = 0into a future program's live regcmd (doorbell-timed, before the sequencer reaches it) makes it terminal β the chain stops there and frees the NPU early. Validated:halt(8)on a 16-op chain β outputs 0..8 computed, 9..15 never run. - You can NEVER run MORE programs than declared β LOOP and EXTEND both fail (see Β§5).
The forward advance between programs is task-indexed and bounded by task_number β not a runtime jump
that follows the in-regcmd 0x0010 pointer wherever it points. Decisive measurement: a chain submitted with
task_number = 1 runs only program 0 even though program 0's 0x0010 points at program 1. If the
hardware blindly followed 0x0010, it would have continued; it did not.
2. The doorbell β host-observable progress, NONBLOCK
Submit with flags |= RKNPU_JOB_NONBLOCK (0x2) so the ioctl returns before the HW finishes. Each program's
output slot doubles as a progress doorbell: seed every output's last element to a sentinel
(0x7fffffff) before submit, then busy-poll it with dc civac (invalidate β read from DRAM). When a slot
holds a real value the op that wrote it has completed. ork_dyn_progress = highest completed op index.
Overhead is a few Β΅s/op; per-op progress is cleanly observable in submit order (validated by
tools/doorbell_id_probe, tools/static_id_probe).
3. The API (src/npu.c, header include/ork_npu.h)
ork_dyn_begin(c, S, tasks)β build S int8 matmul programs, submit NONBLOCK (single-core chain).ork_dyn_begin_mc(c, S, tasks, nc)β multi-core: partition acrossnccores, one NONBLOCK submit per core (Β§4).ork_dyn_progress(h)/ork_dyn_halt(h, at)/ork_dyn_end(h)β poll / early-exit / drain+writeback.- budget:
ork_dyn_max_steps()/ork_dyn_steps(h)/ork_dyn_remaining(h). ork_dyn_queue_*β a chunk-pipeline: accumulate tasks, run a chunk NONBLOCK while the caller does other work (CPUβNPU decode split), auto-split work >chunk_maxinto successive clean chunks.ncoreselects single/multi-core;lingerdefaults to one submit floor (~167 Β΅s β coalescing past a floor isn't floor-bound, so lingering longer buys nothing).ork_pc_compile/run/freeβ precompiled-program cache (Β§6).
v1 constraints (all paths): M = 1, DT_I8, K % 512 == 0 && K <= 4096, output C resident in an
ork_dma_alloc buffer (for the doorbell). Input A is copied into a scratch DMA buffer β zero-copy A
miscomputes at M=1 (the known ZC-A bug), so it is never read in place.
4. Multi-core NONBLOCK stream β the DISPATCH-half win
ork_dyn_begin_mc partitions the S matmuls across the NPU cores, builds each core's sub-chain into its
per-core buffers (c->mrc[i]/mtk[i]/maf[i], the same buffers mcworker/run_stream_i8 use), and
submits each NONBLOCK on core_mask = 1<<i. Core-partitioned concurrency is the proven-safe pattern
(run_stream_i8 already runs the 3 cores concurrently via blocking pthreads); NONBLOCK simply lets the host
poll doorbells instead of parking threads. No wedge observed.
Board head-to-head (N=16β32, M=1, int8, NPU-only wall time):
| path | time | vs single | vs blocking stream |
|---|---|---|---|
single-core queue (begin) |
988 Β΅s | 1.0Γ | β |
multi-core NONBLOCK (begin_mc) |
436 Β΅s | 2.27Γ | 1.38β2.30Γ |
blocking 3-core stream (ork_mm_run_stream_i8, the #14 dispatch) |
602 Β΅s | β | 1.0Γ |
Multi-core NONBLOCK beats even the blocking 3-core stream (1.38β2.30Γ) because the doorbell rendezvous has
no thread-join round-trip. Commit b9ce004; bench tools/ork_dyn_queue_bench.c.
5. Negative results β LOOP and EXTEND are not viable on the stock kernel
Both attempts to run more than task_number programs failed, consistent with Β§1:
- EXTEND (
ork_dyn_append, redirect0x0010forward into a program beyond the submitted count): the HW walks into territory the kernel's accounting doesn't know about βRKNPU: job abort, ret: -22, ~10 s timeout, soft-reset. Kept behindORK_DYN_RESERVE+ opt-in flag as documented negative-result RE. - LOOP / persistent spin (
ork_dyn_spin_probe, circular chain: program 0's0x0010self-loop to keep a job alive for zero-resubmit feeding): the sequencer walked forward instead of looping (task-indexed, bounded bytask_number), and the backward0x0010disagreed with the forward task list β corrupt output (0/8 correct), soft-reset.tools/dyn_spin_probe.c.
Conclusion: 0x0010 redirect is not a runtime jump you can steer; only 0x0014 = 0 halt is a
working dynamic control. A true persistent/looping job would need a driver re-arm/loop change or MMIO PC
control β not reachable from userspace on the stock kernel. steer_probe only ever validated halt;
generalizing it to "redirect works" was the error these two probes corrected.
The wrap is therefore chunk + resubmit β each chunk a clean task_number-bounded job; the inter-chunk
bubble is ~one submit floor (167 Β΅s), < 0.1 % at large chunks. Multi-core NONBLOCK makes each chunk's dispatch
cheap enough that a persistent job isn't needed.
6. Precompiled-program cache (regime A) β the BUILD-half win
The per-token host build β synth_i8 + validate_regcmd + memcpy of ~108 regs per program + per-op
scratch-A alloc/free churn β is the reducible ~95 Β΅s of the submit floor. For a fixed chain with pinned
buffers the program is identical every token (weight/shapes/addresses baked in); only the activation
contents change. So ork_pc_compile builds the S-program chain once into a static pool (the task list
references the pool β a "program domain" indirection, like a page table over scattered pages), and
ork_pc_run re-runs it with just an A-refresh + submit.
Board bench (S=16, M=1, int8, single-core), tools/ork_pc_bench.c:
| per token | |
|---|---|
synth-every-call (ork_dyn_begin) |
2652 Β΅s |
precompiled (ork_pc_run) |
440 Β΅s β 6.03Γ |
| compile (one-time) | 329 Β΅s |
T_pc β the raw single-core NPU floor β the host build is essentially eliminated. Commit 83ea8b2.
Two regimes: A = fixed dense decode chain (built, above); B = varying MoE (a precompiled per-expert program pool, composed per token by selecting descriptors β the literal "compose a domain from cached pages" case; not yet built).
7. How the two halves compose
Multi-core NONBLOCK cuts the dispatch half of the floor; the precompiled cache cuts the build half. They stack. The decode application is the swap-hidden #14 split: the NPU runs its int8 sensitive share via a multi-core NONBLOCK (precompiled) chain while the CPU runs the int4/NF4 bulk in parallel, rendezvousing on the doorbell β the NPU share hidden behind the CPU window at near-zero added time.
7b. M>1 and fp16 doorbell β what the completion word actually tracks
Generalizing the doorbell from M=1 to M>1 and from int8 to fp16 exposed that "poll the last output
element" is precision- and layout-specific. Findings (board 10.3.0.236, tools/ork_dyn_test.c D=int8-M>1,
E=fp16-M>1):
-
int8 M>1 β poll every row's last col. A single global-last-element (
M*N-1) doorbell RACES: the M-tile scheduler does not write rowM-1colN-1strictly last across rows. Within a row the last col is written last, soork_dyn_done_ipolls all M rows' last cols (Mβ€64, cheap). Validated D 16/16 across ~80 runs (single-core A M=1 likewise). Commit62e937c. -
The fp16 "output write-order permutation" hypothesis is DISPROVEN. A seed-ALL-elements + snapshot probe (seed every
M*Noutput word to the sentinel, fire the round, and the instant the row-last-col doorbell reports done, snapshot which words are still sentinel) found zero elements written after their row's last col. fp16 does not tile the output surface differently from int8; the doorbell word is correct. (Cross-checked against the docs:NPU-Quirksrecords fp16 mode/warm and0x1040schedule miscompute behavior, and the "C[0] correct / C[last] wrecked" coherency trap β but no fp16 output write-order quirk.rocket_registers.h/NVDLA give the DPU output-stage0x40xxstrides identically for both.) -
The real fp16 failures are coherency + cold-mode, not ordering. Two effects, both fp16-only:
- Clean-before-write. fp16 direct-output needs every output cache line cleaned to DRAM before the
NPU write (
dc cvac), or dirty/stale host lines in the output interior race the NPU's writes and corrupt it β the same DMA cache-coherency class as theORK_ZC_OUTfix.ORK_MC_SEED's fp16 branch now seeds every element (not just last-cols). This alone moved E from computing wrong ~80 % of runs (out[0][0]=0x0, "done" in ~14 Β΅s) toout[0][0]=0x44000000=512.0 correct. - In-submit drain. fp16
begin_mcpolls the real round to completion before returning (int8 stays truly async β the doorbell win is int8's); deferring the first poll toork_dyn_endlet it read the throwaway warm round's stale last-cols.
- Clean-before-write. fp16 direct-output needs every output cache line cleaned to DRAM before the
NPU write (
-
Residual, unresolved: a systemic ~10β20 % "all-16-fail" race β and it is NOT the warmβreal handoff. When it fails, most/every task is wrong (0/16, 1/16, 6/16 all seen). Initially read as a warmβreal double-submit collision on the shared per-core buffers, but every warm-handoff lever was tested and none fixes it: re-
ork_npu_enterbetween the warm and real rounds (16/20, unchanged), full-surface warm-drain before reseed (regressed to 19/25), extra post-round settle (18/20), and β decisively β a blocking (NONBLOCK-flag-dropped) fp16 warm submit that guarantees the warm job retires before the real round: it ran clean (no EINVAL/soft-reset on the mc path,task_number=Pper core) but regressed reliability to ~53 % (8/15). Two further levers, prompted by "we already have chains that swap int8βfp16 reliably (run_multicore/run_stream_f16) β what do they do differently?", were then ported intobegin_mcand both plateau at ~85 %, no better than the NONBLOCK doorbell:- Blocking establishment =
run_multicore's exact protocol βreps = mwarm?1:2BLOCKING passes per core (flagsw/o0x2),bsync FROM_DEVICEafter each, same HW-chain +ork_ppflags()=0x5 ping-pong: 21/25 (84 %). (Confirms ping-pong and HW-chaining are NOT the differentiators βrun_multicoreuses both and is reliable.) - SW-chain handoff β submit each task standalone (
task_number=1,subcore_task={p,1}, no in-regcmd0x0010/0x0014walk) instead of HW-chainingP: also flaky (12/16 seen). - Neutral all-zero fp16 "MAC-array reset" primer(s) between warm and real (a K=512,N=16 zero matmul
submitted
nΓper core, blocking): 1Γ=flaky (10/16), 2Γ=18/20, 4Γ=13/20. Non-monotonic and inside the noise band β the 2Γ "90 %" was luck, not signal; priming the MAC array does not control the failure. - SDP-stage primer (a neutral all-zero
ork_npu_ewmul_f16+ re-establish fp16 matmul mode + cold re-warm, before the real round): 1/4 β worse. Priming the output-processor stage does not help either. - Per-core scratch + copy-back structure (force
direct=0so each core writes its ownmcc[i]scratch, single-threaded copy-back inend()β i.e.run_multicore's output structure instead of the shared in-place buffer): 4/20 (20 %) β dramatically WORSE. So the shared-in-place output buffer is not the culprit; the direct in-place path is in fact the best fp16 config found (~85 %).
So the residual fp16 flakiness is not the warm handoff, not blocking-vs-NONBLOCK, not the HW-chain-vs-SW-chain handoff, not MAC-array carryover (matmul primers), not SDP-stage carryover (ewmul primer), and not the shared in-place output buffer (per-core scratch is worse). Across ~10 distinct establishment/handoff/priming/structure hypotheses the direct-in-place NONBLOCK path (~85 %) is the best; nothing reaches bit-exact reliability. The failure is timing/state-dependent in a way no host-side ordering, priming, or output-placement change reaches β the reliability that
run_multicorehas comes from its whole concurrent-pthreads execution model, not any transplantable knob. Verdict: fp16 multi-core is a job for the proven syncrun_multicore/run_stream_f16; the async doorbell stays an int8 lever and fp16begin_mcstays gated. (Definitive resting point, ~10 hypotheses exhausted.) Whatrun_multicorehas thatbegin_mcdoes not is the whole execution structure (per-core pthreads running concurrently, per-core scratchCC+ a scatter-copy to the caller's buffer, and N-slice chains), not any single submit-flag/handoff knob. fp16 computes bit-exact when the real round is polled to completion in-place (probe: 16/16); the divergence is in the fast in-begin_mcpath. So fp16begin_mcstays gated behindORK_DYN_F16; the default async path is int8-only and correct, and callers needing fp16 multi-core should use the proven syncrun_multicore/run_stream_f16(the async doorbell is an int8 decode lever). Open lever: givebegin_mcfp16 its own per-core scratch + threaded structure (i.e. becomerun_multicorewith a doorbell) rather than reuse the int8 in-place chain. - Blocking establishment =
-
mc
ork_dyn_endfix (general). The500 Β΅s-no-progressearly break inend()exists only to detect a single-corehalt/appendthat stopped a chain short β both rejecth->mc. For an mc chain a plateau just means cores haven't caught up, so the break bailed withn<S; it is now gated!h->mc(mc waits for all-done or the 3 s hard timeout). Fixes a latent early-exit for mc chains (int8 and fp16).
The failure is a K-reduction SHORT-COUNT (not coherency) β the actual mechanism
Classifying every output element on a failing run (tools/ork_dyn_test.c E, A=B=1 so each dot must = 512.0)
pins the nature down precisely β no element is ever sentinel(unwritten), zero, or random garbage; every
wrong value is 512.0 minus an exact multiple of 32:
| mode | signature | arithmetic |
|---|---|---|
| systemic 0/16 | ALL 8192 elems = 320.0 (0x43a00000) uniformly |
512 β 192 = 512 β 6Γ32 |
| near-miss 15/16 | exactly one M-row (256 elems) = 480.0 (0x43f00000) |
512 β 32 = 512 β 1Γ32 |
fp16 reduces K in K/32 = 16 passes (0x201:0x1044 = K/32); a value of 512 β nΒ·32 is the reduction
dropping n of the 16 K-sub-blocks. So the fp16 mc failure is an arithmetic under-accumulation in
the K-reduction schedule (0x201:0x1040/0x1010 β the fp16 M-scheduler / CBUF-residency regs
NPU-Quirks already flags as fp16's load-bearing/fragile ones), not a doorbell, cache-coherency, or
mode-establishment fault. Systemic = the whole round under-counts K uniformly (a global-schedule miss);
near-miss = a single M-row's reduction drops one block. This is why none of the ~10 establishment/handoff/
priming/structure levers helped β they can't fix a reduction that drops K-blocks. Next lever (grounded,
untested): the fp16 concurrent path's CBUF residency / 0x1040 K-schedule β e.g. force full-K residency
(0x1010) or sweep 0x1040 via ork_f16_fuzz_add(0x201,0x1040,Β·) for the M=2 concurrent case; the win
that made run_multicore fp16 reliable may simply be that its per-core layout keeps the full K resident.
Desk register comparison vs the reliable path β the K-schedule regs are IDENTICAL
Following the short-count classification, the K-reduction registers were compared desk-first (logged from
synth() via ORK_SYNTH_LOG) between begin_mc's fp16 path and the reliable make-test fp16 path, for the
E-test shape (K=512, N=256, M=2):
begin_mcfp16:synth(mc=2, K=512, sched=1, cbuf=32768)β0x1040=162 (0xa2),0x1010=48,0x1044(Kpass)=16.synth()is a shared function;run_multicorecalls it with the samemc/K/sched, and0x1040depends only onK(mg=1formcβ€64),0x1010onmin(mc+1,R)withR=pow2βcbuf/Kββ₯3. So for this shape the two paths produce byte-identical K-schedule registers. "Match the reliable config" does not apply β the config already matches.- But the reliable make-test fp16 shapes never exercise K=512/sched=1 M>1.
test_matmul's fp16 runs K=1536/2048 βsched=0(K=1536 non-pow2; Kβ₯2048 out of the[128,2048)window). So the fragilesched=10x1040M>1 fp16 path is not validated-reliable anywhere;begin_mcis using an unproven fragile path, not diverging from a proven one.
Two register-grounded board experiments then ruled out the schedule value and concurrency (paced, no
wedge): forcing sched=0 for begin_mc fp16 (0x1010=48, no 0x1040=162 override) β still flaky
(4/16, 0/16, 10/16); forcing single-core (nc=1, no 3-core DMA contention) β also flaky (13/20).
So the short-count is not the 0x1040 value and not cross-core concurrency. Since the systemic mode
is uniform (all 16 tasks drop the same last 6 K-blocks β 320) and it persists single-core, the residual is
a runtime weight/activation-DMA-vs-reduction race intrinsic to begin_mc's chained fp16 setup β the
K-blocks the MAC consumes aren't all delivered, non-deterministically β which the standalone probe_f16_mm
(task_number=1, ACT_RESET-per-call, 2 blocking reps) avoids but no begin_mc-shaped variant reproduced.
probe_f16_mm stages the activation identically (raw [M][K]), so it is not an activation-layout bug.
Net: registers are not the lever; fp16 stays gated. Deeper root-cause = why the chained fp16 reduction
intermittently under-consumes K, a runtime-DMA question left open.
Board incident
One hard wedge (no ping/ssh) during a rapid-fire loop of the flaky fp16 test β the early-exit freed
scratch buffers while the mis-executing fp16 job was still in flight (IOMMU fault). Recovered by an HA
power-cycle of the Rock 5B Plug (no SPI corruption; back in ~90 s). Once fp16 drains to completion before
free, back-to-back runs are clean.
8. Board-safety note
The 0x0010-redirect probes (append, spin) each left the NPU/IOMMU fragile via repeated
RKNPU: soft reset events; a later op then hard-wedged the board (no ping/ssh), recovered by an HA
power-cycle of the smart plug (no SPI corruption). The wedge source is specifically the redirect experiments;
the validated paths (begin_mc, ork_pc, halt) run clean. See also
NPU Hard-Wedge Circumstances.
Code
src/npu.c:ork_dyn_begin/ork_dyn_begin_mc/ork_dyn_progress/ork_dyn_halt/ork_dyn_end/ork_dyn_append(experimental) /ork_dyn_spin_probe(negative) /ork_dyn_queue_*/ork_pc_*.- Tools:
ork_dyn_test.c,ork_dyn_queue_bench.c,ork_pc_bench.c,dyn_spin_probe.c,steer_probe.c,doorbell_id_probe.c,static_id_probe.c. - Commits (
feat/tiered-i4-residence):b94a4aa(API + queue),b9ce004(multi-core NONBLOCK),83ea8b2(precompiled cache + spin negative result).