<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="/feed.xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>Ga Hing Woo (Jiaxing Hu)</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://gahingwoo.github.io/</id>
  <link href="https://gahingwoo.github.io/" rel="alternate"/>
  <link href="https://gahingwoo.github.io/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, Ga Hing Woo (Jiaxing Hu)</rights>
  <subtitle>Upstream firmware and embedded Linux — Rockchip bring-up, secure boot, and NPU work from the gaps between hardware and mainline.</subtitle>
  <title>Gahing's Space</title>
  <updated>2026-07-16T01:04:22.052Z</updated>
  <entry>
    <author>
      <name>Ga Hing Woo (Jiaxing Hu)</name>
    </author>
    <category term="Security &amp; Isolation" scheme="https://gahingwoo.github.io/categories/Security-Isolation/"/>
    <category term="security" scheme="https://gahingwoo.github.io/tags/security/"/>
    <category term="firmware" scheme="https://gahingwoo.github.io/tags/firmware/"/>
    <category term="embedded" scheme="https://gahingwoo.github.io/tags/embedded/"/>
    <category term="rp2350" scheme="https://gahingwoo.github.io/tags/rp2350/"/>
    <category term="trustzone" scheme="https://gahingwoo.github.io/tags/trustzone/"/>
    <category term="risc-v" scheme="https://gahingwoo.github.io/tags/risc-v/"/>
    <category term="pmp" scheme="https://gahingwoo.github.io/tags/pmp/"/>
    <category term="hazard3" scheme="https://gahingwoo.github.io/tags/hazard3/"/>
    <category term="cortex-m33" scheme="https://gahingwoo.github.io/tags/cortex-m33/"/>
    <content>
      <![CDATA[<h2 id="TL-DR"><a href="#TL-DR" class="headerlink" title="TL;DR"></a>TL;DR</h2><p>The RP2350 has both an Arm Cortex-M33 pair (TrustZone-M) and a RISC-V Hazard3 pair(PMP) on one die. I built the same minimal isolation demo on both — a guarded secret, acall-in service, and a deliberate illegal read — and verified both on the same Pico 2over SWD. Findings, all measured rather than read off a spec:</p><ul><li><strong>Same result, different shape.</strong> Both block the illegal read with the secret intact.TrustZone partitions the <em>address map</em> and the toolchain builds the gate for you; PMPfilters by <em>privilege</em> and you write the gate yourself.</li><li><strong>Arm tells you where it broke, Hazard3 doesn’t.</strong> <code>SFSR = 0x48</code> sets <code>SFARVALID</code>, sothe faulting address is there for the taking. Hazard3 leaves <code>mtval = 0</code> on a PMPaccess fault. <code>mcause = 5</code> is all you get.</li><li><strong>The same silicon only fought one of them.</strong> The Arm Non-Secure side hit two RP2350walls that forced a bare-metal, RAM-resident NS image. The RISC-V U-mode side just ranfrom flash.</li><li><strong>Hazard3 has a trick TrustZone structurally can’t do:</strong> <code>Xh3pmpm</code> lets M-mode sandbox<em>itself</em>, reversibly.</li><li>Neither of them covers the DMA. That’s a separate MPU, and it’s the RP2350 detail mostlikely to quietly ruin your day.</li></ul><p>Code: <a href="https://github.com/gahingwoo/rp2350-tz-tee">rp2350-tz-tee</a> —<a href="https://github.com/gahingwoo/rp2350-tz-tee/tree/main/examples/minimal-tz"><code>minimal-tz</code></a>(Arm) and<a href="https://github.com/gahingwoo/rp2350-tz-tee/tree/main/examples/minimal-pmp"><code>minimal-pmp</code></a>(RISC-V).</p><span id="more"></span><details><summary style="cursor:pointer;font-weight:600;">Read the full write-up</summary><h2 id="Why-this-comparison-is-worth-anything"><a href="#Why-this-comparison-is-worth-anything" class="headerlink" title="Why this comparison is worth anything"></a>Why this comparison is worth anything</h2><p>Most TrustZone-vs-PMP writing compares specifications. That’s fine, but specs don’t tellyou that Hazard3 won’t report a fault address, or that Non-Secure reads of XIP flash comeback as zero on a bare-booted RP2350. Only the board tells you that.</p><p>So this is the same experiment twice, on the same chip:</p><ul><li>A <strong>secret + a signing key</strong> in memory the untrusted side must never read.</li><li>A <strong>service call</strong> in: give me a counter, sign this challenge with your key.</li><li>A <strong>deliberate illegal read</strong> of the secret from the untrusted side.</li><li>Results published to a RAM mailbox and read back over <strong>SWD</strong> — no UART on either side,so the verdict is bytes out of the chip, not a <code>printf</code> I chose to believe.</li></ul><p>Same demo, same board, same readout. What differs is only the mechanism.</p><h2 id="The-mapping"><a href="#The-mapping" class="headerlink" title="The mapping"></a>The mapping</h2><table><thead><tr><th>Arm Cortex-M33 (<code>minimal-tz</code>)</th><th>RISC-V Hazard3 (<code>minimal-pmp</code>)</th></tr></thead><tbody><tr><td>Secure world</td><td>M-mode</td></tr><tr><td>Non-Secure world</td><td>U-mode</td></tr><tr><td>SAU region</td><td>PMP region</td></tr><tr><td>NSC veneer (SG gateway)</td><td><code>ecall</code></td></tr><tr><td>S to NS via <code>BLXNS</code></td><td>M to U via <code>mret</code></td></tr><tr><td>Illegal NS read to SecureFault</td><td>Illegal U read to PMP load access fault</td></tr><tr><td>Secure handler, <code>SFSR</code> &#x2F; <code>SFAR</code></td><td>M-mode trap handler, <code>mcause</code> &#x2F; <code>mtval</code></td></tr></tbody></table><p>The table is tidy. The engineering underneath it is not symmetric at all.</p><h2 id="The-gate-a-branch-versus-a-trap"><a href="#The-gate-a-branch-versus-a-trap" class="headerlink" title="The gate: a branch versus a trap"></a>The gate: a branch versus a trap</h2><p>On Arm, the gate is a <strong>function call</strong>. You tag a function <code>cmse_nonsecure_entry</code>, andthe toolchain does the rest. The compiler emits a Secure Gateway stub, the linker pins itinto a region the SAU marks Non-Secure-Callable, and a second link pass hands theNon-Secure image an <em>import library</em> containing only the gateway addresses. From my builtSecure image:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">100ff000 T secure_sign                    &lt;- the SG veneer, in the NSC region</span><br><span class="line">100ff008 T secure_get_counter</span><br><span class="line">10000570 T __acle_se_secure_get_counter   &lt;- the real body, in Secure .text</span><br><span class="line">100005e0 T __acle_se_secure_sign</span><br></pre></td></tr></table></figure><p>The Non-Secure image links against the import library, so it resolves <code>secure_sign</code> to<code>0x100ff000</code> and <em>never sees</em> <code>__acle_se_secure_sign</code>. The call site is an ordinary Ccall with a real type signature. That is genuinely elegant: the security boundary is alinkage boundary, enforced by the build.</p><p>On RISC-V there is no such thing. The gate is a <strong>trap</strong>:</p><figure class="highlight c"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">register</span> <span class="type">uint32_t</span> a0 __asm__(<span class="string">&quot;a0&quot;</span>) = arg0;</span><br><span class="line"><span class="keyword">register</span> <span class="type">uint32_t</span> a7 __asm__(<span class="string">&quot;a7&quot;</span>) = svc;</span><br><span class="line">__asm__ <span class="title function_">volatile</span> <span class="params">(<span class="string">&quot;ecall&quot;</span> : <span class="string">&quot;+r&quot;</span>(a0) : <span class="string">&quot;r&quot;</span>(a1), <span class="string">&quot;r&quot;</span>(a7) : <span class="string">&quot;memory&quot;</span>)</span>;</span><br></pre></td></tr></table></figure><p>U-mode puts a service number in <code>a7</code> and traps. M-mode catches <code>mcause = 8</code>, demuxes thenumber, does the work, writes the return into the saved <code>a0</code>, bumps <code>mepc</code> past the<code>ecall</code>, and <code>mret</code>s back. I wrote all of that: the assembly trampoline that swaps to anM-mode stack via <code>mscratch</code>, the register frame, the dispatcher.</p><p>That’s the real split, and it isn’t about strength:</p><ul><li><strong>Arm hides the mechanism in the toolchain.</strong> Less code, a typed interface, and acompiler that will not let you accidentally export the implementation. But you’retrusting machinery you can’t see, and when it misbehaves you’re debugging CMSE.</li><li><strong>RISC-V hands you the mechanism.</strong> More code, no types, a syscall number in aregister. But there is nothing hidden: every instruction across the boundary is one Iwrote and can read.</li></ul><p>The same asymmetry shows up in pointer validation. Both sides must refuse a maliciouspointer from the untrusted world — otherwise the untrusted side just asks the trusted sideto read the secret <em>for</em> it. Arm gives you an intrinsic:</p><figure class="highlight c"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (cmse_check_address_range((<span class="type">void</span> *)challenge, len,</span><br><span class="line">                             CMSE_NONSECURE | CMSE_MPU_READ) == <span class="literal">NULL</span>) &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="number">-2</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>That call asks the actual SAU&#x2F;MPU whether Non-Secure could legitimately touch that range.RISC-V has no equivalent, so on the PMP side I wrote the check by hand — and a hand-rolledrange check is exactly the kind of thing that’s fine in a demo and a CVE in a product.This is the one place I think Arm is meaningfully ahead: not because PMP is weaker, butbecause Arm shipped the safety rail and RISC-V left it as an exercise.</p><h2 id="The-wall-an-address-map-versus-a-privilege-filter"><a href="#The-wall-an-address-map-versus-a-privilege-filter" class="headerlink" title="The wall: an address map versus a privilege filter"></a>The wall: an address map versus a privilege filter</h2><p>The SAU carves the address map into regions and labels them. The RP2350’s IDAU treatseverything as Secure by default, so you carve out what Non-Secure gets. Read back from therunning board in Secure state:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">SAU-&gt;CTRL = 0x00000001</span><br><span class="line">region 0:  RBAR=0x100ff000  RLAR=0x100fffe3   &lt;- NSC (veneers)</span><br><span class="line">region 1:  RBAR=0x10100000  RLAR=0x101fffe1   &lt;- Non-Secure flash</span><br></pre></td></tr></table></figure><p>PMP is not a map. It’s a <strong>filter on the privilege ladder</strong>, evaluated first-match, andthe default is the interesting part: if no entry matches, an M-mode access <em>succeeds</em> anda U-mode access <em>fails</em>. So U-mode starts with access to nothing, and you grant it.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">pmpcfg0 = 0x00001f18</span><br><span class="line">  entry 0 = 0x18   NAPOT, no R/W/X   -&gt; the secret region: deny</span><br><span class="line">  entry 1 = 0x1f   NAPOT, R+W+X      -&gt; everything else: allow</span><br></pre></td></tr></table></figure><p>Two entries: deny the 64-byte secret first, allow the whole map second. First match wins,so U-mode can run its own code and touch its own stack, and the hole in the middle faults.Leaving the lock bit clear is what keeps M-mode out of scope, which is how M-mode stillreads the key to sign with.</p><p>Conceptually: <strong>TrustZone is spatial</strong> (this address belongs to that world), <strong>PMP ismodal</strong> (this mode may touch this address). They meet in the middle for a two-world demo,but they generalise in different directions. TrustZone gives you two worlds and each hasits own privilege ladder underneath. PMP gives you one ladder and a programmable filter.If what you want is “a secure world with its own OS,” Arm’s model is shaped like yourproblem. If what you want is “keep this task away from that buffer,” PMP is.</p><h2 id="When-it-breaks-what-the-chip-is-willing-to-tell-you"><a href="#When-it-breaks-what-the-chip-is-willing-to-tell-you" class="headerlink" title="When it breaks: what the chip is willing to tell you"></a>When it breaks: what the chip is willing to tell you</h2><p>Both demos end the same way: the untrusted side reads the secret, and doesn’t get it.</p><p>Arm, from the Secure fault handler’s mailbox at <code>0x200006dc</code>:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">5ecf0000  00000048  00000001  00000002  00000000  a9dd58a4  00000000  0000bad0</span><br><span class="line">MARK      SFSR      CNT1      CNT2      SIGN_RC   SIGN_R0   LEAKED    NS_STAGE</span><br></pre></td></tr></table></figure><p>RISC-V, from <code>g_mb</code> at <code>0x20000000</code>:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">504d5002  5ec00001  00000005  5ec00001  00000055  00000001  00000002  aca4a4a4</span><br><span class="line">MAGIC     XH3_BEF   XH3_CAUSE XH3_AFT   U_UP      CNT1      CNT2      SIGN0</span><br><span class="line">00000005  00000000  00000000  0000600d</span><br><span class="line">MCAUSE    MTVAL     LEAKED    STAGE</span><br></pre></td></tr></table></figure><p>Both worked. Services returned counters <code>1, 2</code>. Both signed correctly with a key theuntrusted side never saw (<code>a9dd58a4</code> is <code>01 02 03 04</code> XOR <code>A5 5A DE AD</code>; <code>aca4a4a4</code> is thesame challenge XOR the RISC-V demo’s <code>A5 A6 A7 A8</code>). Both report <strong><code>LEAKED = 0</code></strong>.</p><p>But look at the fault reporting, because this is a real, measured difference:</p><ul><li><strong>Arm: <code>SFSR = 0x48</code></strong> — that’s <code>AUVIOL</code> (attribution unit violation: Non-Secure touchedSecure) <em>and</em> <code>SFARVALID</code>. The valid bit means the faulting address is sitting in <code>SFAR</code>for you to read.</li><li><strong>Hazard3: <code>mcause = 5</code></strong> (load access fault), <strong><code>mtval = 0</code></strong>.</li></ul><p>The RISC-V privileged spec <em>permits</em> <code>mtval</code> to be zero for access faults, and Hazard3takes that option. So on the Arm side the hardware tells you what was touched; on theRISC-V side it tells you only that something was. I could recover the faulting<em>instruction</em> from <code>mepc</code> (<code>0x100001f6</code>, exactly the U-mode <code>lw</code>), and from there infer theaddress by reading the code — but that’s me doing forensics the Arm part answers directly.</p><p>If you’re building a fault handler that reports or recovers, that gap is not academic.</p><h2 id="The-twist-the-same-silicon-only-fought-one-of-them"><a href="#The-twist-the-same-silicon-only-fought-one-of-them" class="headerlink" title="The twist: the same silicon only fought one of them"></a>The twist: the same silicon only fought one of them</h2><p>Here’s the part I did not expect, and the reason I think this comparison earns its keep.</p><p>The obvious Arm design — a normal pico-sdk Non-Secure image executing in place from flash— <strong>does not work on a bare-booted RP2350</strong>, and it took SWD to find out why. With the SAUand ACCESSCTRL both verified correct on the running board, the Non-Secure image faultedimmediately inside the C runtime. The register file told the story:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">r4 = 0x1010337c    &lt;- walking the init_array</span><br><span class="line">r3 = 0x00000000    &lt;- the function pointer it just loaded</span><br></pre></td></tr></table></figure><p>It had loaded an initialiser pointer from flash and gotten <strong>zero</strong>. The debugger, readingthe same address, saw the real pointer (<code>0x10101259</code>). So: Non-Secure <em>instruction fetch</em>from XIP flash works, Non-Secure <em>data reads</em> of XIP flash return zero. <code>blx 0</code>, UsageFault,done. Invalidating the XIP cache changed nothing — it’s the read path, not stale lines.</p><p>Relocating the Non-Secure image into SRAM (Secure copies it in before <code>BLXNS</code>, since Secure<em>can</em> read flash) fixed that, and revealed the next wall: the pico-sdk runtime then hungforever in <code>runtime_init_early_resets</code>, polling <code>RESETS_RESET_DONE</code>. The debugger saw thebits set. The Non-Secure CPU didn’t. Same shape of problem, one layer down.</p><p>So the Arm Non-Secure side ended up as a deliberately tiny <strong>bare-metal</strong> image: its ownvector table, no libc, no clock or reset setup, copied into Non-Secure SRAM by the Secureworld. Two hundred bytes. That’s not elegance, that’s what the platform left standing.</p><p>The RISC-V side had none of this. U-mode read flash normally the moment a PMP entry grantedit. No copy, no bare-metal retreat, full pico-sdk runtime. <strong>Same chip.</strong></p><p>The lesson isn’t “RISC-V is better.” It’s that the <em>platform</em>, not the architecture, decidedwhich path was pleasant. The RP2350’s Arm secure story is the productised one — TF-M has areal <code>rpi/rp2350</code> port, and I ran its regression suite to 16&#x2F;16 on this board before writingany of my own code. But that path goes through BL2 and secure boot. Step off it and build aplain SDK Secure+Non-Secure pair, and you’re in territory the SDK doesn’t support yet —which is precisely what<a href="https://github.com/raspberrypi/pico-examples/issues/708">pico-examples#708</a> has been askingfor. The RISC-V PMP path is less travelled and has no framework at all, and it just worked.</p><h2 id="The-thing-Hazard3-has-that-TrustZone-structurally-can’t"><a href="#The-thing-Hazard3-has-that-TrustZone-structurally-can’t" class="headerlink" title="The thing Hazard3 has that TrustZone structurally can’t"></a>The thing Hazard3 has that TrustZone structurally can’t</h2><p>Base RISC-V PMP has an awkward corner: entries don’t apply to M-mode unless you set thelock bit — and the lock bit is <em>permanent until reset</em>. So “temporarily sandbox the mostprivileged mode” isn’t in the base ISA. You can have M-mode restricted, or you can have itreversible. Not both.</p><p>Hazard3 adds <code>Xh3pmpm</code>: a custom CSR (<code>PMPCFGM0</code>, <code>0xbd0</code>) where one bit per region means“apply this to M-mode too, <em>without</em> locking it.” The RP2350 datasheet’s own framing isthat PMP is useful for non-security things like stack guarding, and this lets M-mode useunlocked regions for its own purposes.</p><p>I put it in the demo, because it’s the one place the RISC-V side is strictly more expressive:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">XH3_BEFORE = 0x5ec00001   M-mode reads the secret: allowed</span><br><span class="line">XH3_MCAUSE = 0x00000005   set PMPCFGM0 bit 0 -&gt; M-mode faults on its own region</span><br><span class="line">XH3_AFTER  = 0x5ec00001   clear it -&gt; M-mode reads again</span><br></pre></td></tr></table></figure><p>M-mode sandboxed itself, took the fault, lifted the restriction, and carried on. TrustZone-Mhas no analog: Secure is Secure. You can constrain Secure <em>privilege</em> with the MPU, butthere’s no “make the Secure world subject to the SAU for a while, then stop.”</p><p>(Worth noting pico-sdk already uses exactly this for optional stack guards — entry 0 plus<code>PMPCFGM0</code>. They’re off by default, which is why entry 0 was free for me.)</p><h2 id="The-thing-neither-of-them-has-the-DMA"><a href="#The-thing-neither-of-them-has-the-DMA" class="headerlink" title="The thing neither of them has: the DMA"></a>The thing neither of them has: the DMA</h2><p>Both mechanisms protect <em>the CPU’s</em> accesses. The RP2350’s DMA engine has <strong>its own MPU</strong>(<code>dma_hw-&gt;mpu_region[]</code>), and it does not consult the SAU. Configure only the SAU, andNon-Secure code can program the DMA to fetch Secure memory on its behalf and hand it over.Your beautiful two-world partition is a formality.</p><p>The fix is to mirror the SAU partitioning into the DMA MPU, which TF-M’s port does and whichI carried into <code>minimal-tz</code>. I’ve never seen a generic Cortex-M33 tutorial mention it,because it isn’t a Cortex-M33 fact — it’s an RP2350 fact.</p><p>Which is the general point in miniature: the architecture manual describes the wall aroundthe CPU. The chip contains other things that can read memory.</p><h2 id="What-I-actually-take-away"><a href="#What-I-actually-take-away" class="headerlink" title="What I actually take away"></a>What I actually take away</h2><p>TrustZone-M and PMP look like competing answers. They’re not. They’re answers to differentquestions that happen to produce the same demo output.</p><p>TrustZone asks <em>“which world does this address belong to?”</em> and answers it with a linkerscript, a compiler attribute, and an import library. When it fits, it’s beautiful — asecurity boundary you can typecheck. When you step outside the supported path, you’realone with <code>mepc</code>-equivalents and a chip that returns zeros.</p><p>PMP asks <em>“may this mode touch this address?”</em> and answers it with two CSR writes and a traphandler you wrote yourself. Nothing is hidden and nothing is provided. <code>mtval</code> won’t eventell you the address.</p><p>I only know any of this because I ran both on the same board and read the bytes out overSWD. The spec would have told me neither that Non-Secure XIP data reads return zero, northat Hazard3 zeroes <code>mtval</code>, nor that the fancier-tooled of the two would be the one thatfought back. Both demos say <code>LEAKED = 0</code>. Everything interesting is in what it cost to getthere — and that only shows up on hardware.</p><p>Same drum as always: <em>boots</em> is not <em>works</em>, and the only way to know which one you have isto make the chip tell you.</p></details><script src="/js/reveal-toc.js" defer></script><div style="margin-top: 2.5em;"><div class="a2a_kit a2a_kit_size_32 a2a_default_style"><a class="a2a_dd" href="https://www.addtoany.com/share"></a><a class="a2a_button_facebook"></a><a class="a2a_button_twitter"></a><a class="a2a_button_linkedin"></a><a class="a2a_button_reddit"></a><a class="a2a_button_hacker_news"></a><a class="a2a_button_slashdot"></a><a class="a2a_button_telegram"></a><a class="a2a_button_whatsapp"></a><a class="a2a_button_line"></a><a class="a2a_button_facebook_messenger"></a><a class="a2a_button_mewe"></a><a class="a2a_button_flipboard"></a><a class="a2a_button_email"></a><a class="a2a_button_google_translate"></a></div><script async src="https://static.addtoany.com/menu/page.js"></script></div>]]>
    </content>
    <id>https://gahingwoo.github.io/posts/rp2350-trustzone-vs-pmp/</id>
    <link href="https://gahingwoo.github.io/posts/rp2350-trustzone-vs-pmp/"/>
    <published>2026-07-13T00:00:00.000Z</published>
    <summary>The RP2350 ships an Arm Cortex-M33 and a RISC-V Hazard3 on the same die, so I wrote the same isolation demo twice — TrustZone-M with SAU and CMSE veneers, then M-mode/U-mode with PMP and ecall — and ran both on the same board. They produce identical results and feel nothing alike. Here's what the hardware actually told me, including the address Arm hands you that Hazard3 doesn't, and the wall that only one of the two ever hit.</summary>
    <title>Same Chip, Two Walls: TrustZone-M vs RISC-V PMP on the RP2350</title>
    <updated>2026-07-16T01:04:22.052Z</updated>
  </entry>
  <entry>
    <author>
      <name>Ga Hing Woo (Jiaxing Hu)</name>
    </author>
    <category term="NPU &amp; Toolchain" scheme="https://gahingwoo.github.io/categories/NPU-Toolchain/"/>
    <category term="rockchip" scheme="https://gahingwoo.github.io/tags/rockchip/"/>
    <category term="rk3576" scheme="https://gahingwoo.github.io/tags/rk3576/"/>
    <category term="npu" scheme="https://gahingwoo.github.io/tags/npu/"/>
    <category term="rknn" scheme="https://gahingwoo.github.io/tags/rknn/"/>
    <category term="arm64" scheme="https://gahingwoo.github.io/tags/arm64/"/>
    <category term="aarch64" scheme="https://gahingwoo.github.io/tags/aarch64/"/>
    <category term="toolchain" scheme="https://gahingwoo.github.io/tags/toolchain/"/>
    <content>
      <![CDATA[<h2 id="TL-DR"><a href="#TL-DR" class="headerlink" title="TL;DR"></a>TL;DR</h2><p>If you’ve read that Rockchip’s <code>rknn-toolkit2</code> model converter “only runs on x86,” thatwas true once and isn’t anymore. On <strong>version 2.3.2</strong> the official <strong>aarch64</strong> wheelinstalls, imports, and converts an ONNX model to a <code>.rknn</code> end-to-end — I did it on aplain arm64 Ubuntu 24.04 host (Python 3.12), no x86 anywhere, no cross-machine dance.</p><p>The conversion itself is boring, which is the point. What isn’t boring is the two thingsthat stop you before you get there — both undocumented, both a fast dead end if you don’tknow them:</p><ol><li><strong><code>pip install &#39;setuptools&lt;81&#39;</code>.</strong> rknn-toolkit2 2.3.2 imports <code>pkg_resources</code> at load;modern setuptools deleted it, so <code>from rknn.api import RKNN</code> blows up before you write aline of your own code.</li><li><strong>Your output <code>.rknn</code> won’t match anyone else’s md5 — and that’s normal.</strong> rknn filesaren’t byte-reproducible. If you “verify” a fresh conversion by diffing checksums againsta reference, you’ll scare yourself for no reason. The real check is elsewhere.</li></ol><p>The rest is the walk-through and the reasoning.</p><span id="more"></span><details><summary style="cursor:pointer;font-weight:600;">Read the full write-up</summary><h2 id="The-setup"><a href="#The-setup" class="headerlink" title="The setup"></a>The setup</h2><ul><li>aarch64 Ubuntu 24.04, Python 3.12, in a venv</li><li><code>rknn-toolkit2</code> <strong>2.3.2</strong> (the official arm64 wheel), <code>librknnc</code> 2.3.2 (@2025-04-03)</li><li>Target SoC: RK3576</li><li>Test model: a one-layer ONNX — a single Conv2d, 3→32, 3×3, stride 2, 224×224 in →112×112×32 out. Small on purpose; I want to test the <em>converter</em>, not a model.</li></ul><p>Why one conv and not a full MobileNet: if the toolchain is broken on arm64, one conv findsout just as fast as a whole net and the logs are readable. And I turn quantization <strong>off</strong>for this first pass — <code>do_quantization=False</code> — so I’m testing exactly one thing, theonnx → rknn build path, with no calibration dataset in the way to muddy a failure.</p><h2 id="It-converts"><a href="#It-converts" class="headerlink" title="It converts"></a>It converts</h2><p>The whole script:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> rknn.api <span class="keyword">import</span> RKNN</span><br><span class="line"></span><br><span class="line">rknn = RKNN(verbose=<span class="literal">True</span>)</span><br><span class="line">rknn.config(target_platform=<span class="string">&#x27;rk3576&#x27;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">&#x27;config done&#x27;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">&#x27;load_onnx ret:&#x27;</span>, rknn.load_onnx(model=<span class="string">&#x27;conv0.onnx&#x27;</span>))</span><br><span class="line"><span class="built_in">print</span>(<span class="string">&#x27;build ret:&#x27;</span>,     rknn.build(do_quantization=<span class="literal">False</span>))</span><br><span class="line"><span class="built_in">print</span>(<span class="string">&#x27;export ret:&#x27;</span>,    rknn.export_rknn(<span class="string">&#x27;conv0_arm64.rknn&#x27;</span>))</span><br><span class="line">rknn.release()</span><br><span class="line"><span class="built_in">print</span>(<span class="string">&#x27;CONVERT OK&#x27;</span>)</span><br></pre></td></tr></table></figure><p>And it runs — the closed-source compiler does real work on arm64, not a stub:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">I rknn-toolkit2 version: 2.3.2</span><br><span class="line">config done</span><br><span class="line">D base_optimize ... done.</span><br><span class="line">D fold_constant ... done.</span><br><span class="line">D fuse_ops ... done.</span><br><span class="line">I RKNN: librknnc version: 2.3.2 (@2025-04-03T08:30:46)</span><br><span class="line">...</span><br><span class="line">D ID  OpType          Target  InputShape                     OutputShape</span><br><span class="line">D 0   InputOperator   CPU     \                              (1,3,224,224)</span><br><span class="line">D 1   Conv            NPU     (1,3,224,224),(32,3,3,3),(32)  (1,32,112,112)</span><br><span class="line">D 2   OutputOperator  CPU     (1,32,112,112)                 \</span><br><span class="line">load_onnx ret: 0</span><br><span class="line">build ret: 0</span><br><span class="line">export ret: 0</span><br><span class="line">CONVERT OK</span><br></pre></td></tr></table></figure><p>Every stage returns 0, the layer table shows the Conv correctly assigned to the <strong>NPU</strong>with the right shapes, and a <code>conv0_arm64.rknn</code> lands on disk. That’s the whole claim:arm64 converts. The “you need x86” advice you’ll find in forum threads predates this wheel.</p><p>Now the two things that’ll trip you on the way in.</p><h2 id="Gotcha-1-pin-setuptools-below-81"><a href="#Gotcha-1-pin-setuptools-below-81" class="headerlink" title="Gotcha 1: pin setuptools below 81"></a>Gotcha 1: pin setuptools below 81</h2><p>Fresh venv, install the wheel, first import — and it dies inside <code>pkg_resources</code>.</p><p>rknn-toolkit2 2.3.2 still imports <code>pkg_resources</code> (the legacy setuptools API) at moduleload. Setuptools removed <code>pkg_resources</code> in the 81 series, so on any recent box the veryfirst <code>from rknn.api import RKNN</code> throws before your code runs. Nothing in the rknn docsmentions it because the wheel was cut against an older setuptools.</p><p>The fix is one line, before or after installing rknn:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">pip install <span class="string">&#x27;setuptools&lt;81&#x27;</span></span><br></pre></td></tr></table></figure><p>One thing that confuses people: even with the pin in place you’ll <em>still</em> see</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">UserWarning: pkg_resources is deprecated as an API.</span><br></pre></td></tr></table></figure><p>every time you import. That’s just the deprecation notice from the <code>&lt;81</code> versions thatstill ship <code>pkg_resources</code>. It’s <strong>harmless</strong> — the warning means it’s working, notbreaking. The failure mode is the import raising, not the warning printing. Don’t chase it.</p><h2 id="Gotcha-2-the-output-md5-won’t-match-and-that’s-fine"><a href="#Gotcha-2-the-output-md5-won’t-match-and-that’s-fine" class="headerlink" title="Gotcha 2: the output md5 won’t match, and that’s fine"></a>Gotcha 2: the output md5 won’t match, and that’s fine</h2><p>Here’s the trap that cost me a double-take. I had a known-good reference <code>.rknn</code> for thesame conv, converted earlier. Natural instinct after a fresh conversion: <code>md5sum</code> both andconfirm they’re identical.</p><p>They aren’t:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">38583  conv0_arm64.rknn                (just built, arm64)</span><br><span class="line">37969  conv0_rk3576.rknn               (reference)</span><br><span class="line">6d5a31d6...  conv0_arm64.rknn</span><br><span class="line">5a3a6f1d...  conv0_rk3576.rknn</span><br></pre></td></tr></table></figure><p>Different size, different md5. That looks like a red flag and it is <strong>not one.</strong> Tworeasons a <code>.rknn</code> is not byte-reproducible:</p><ul><li>The container embeds build metadata (compiler config, and effectively build-time state)that varies run to run. Even the <em>same</em> model built twice on the <em>same</em> machine won’tmd5-match.</li><li>My reference was built with different options (it was quantized; this pass wasn’t), so ofcourse the bytes differ — different graph, different data.</li></ul><p>So <strong>md5 comparison proves nothing about a converter</strong> — not equivalence, not correctness,neither direction. If you’re using it as your success check, throw it out. What actuallytells you the conversion is sound:</p><ul><li><strong>the build return codes</strong> — <code>load_onnx</code> &#x2F; <code>build</code> &#x2F; <code>export_rknn</code> all 0;</li><li><strong>the layer info table</strong> — ops land on the units you expect (Conv → NPU here), input andoutput shapes match your model;</li><li>and for <em>semantic</em> correctness — the only check that really counts — <strong>run the <code>.rknn</code>on the actual NPU and compare against the reference.</strong> I did exactly that; the last sectionis a whole MobileNet converted on arm64 and classifying correctly on the board. That’s thecheck md5 was only ever pretending to stand in for.</li></ul><h2 id="Bonus-trap-the-venv-that-isn’t-activated"><a href="#Bonus-trap-the-venv-that-isn’t-activated" class="headerlink" title="Bonus trap: the venv that isn’t activated"></a>Bonus trap: the venv that isn’t activated</h2><p>Not rknn-specific, but it bit me mid-session and it’ll bite anyone scripting this, so it’sworth ten seconds. I ran the conversion as a one-liner:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">source</span> ./env/bin/activate &amp;&amp; python3 convert.py</span><br><span class="line"><span class="comment"># ModuleNotFoundError: No module named &#x27;rknn&#x27;</span></span><br></pre></td></tr></table></figure><p>The <code>activate</code> didn’t take — chained into a single non-interactive shell invocation itdoesn’t reliably alter the <code>python3</code> that then runs, so you get the system interpreter and amissing module. The robust fix is to skip activation entirely and call the venv’sinterpreter by absolute path:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">~/rknn/rknn-convert-env/bin/python3 convert.py</span><br></pre></td></tr></table></figure><p>Same trick works in Makefiles, systemd units, CI steps — anywhere a login shell isn’tguaranteed.</p><h2 id="The-real-proof-a-whole-MobileNet-on-the-board"><a href="#The-real-proof-a-whole-MobileNet-on-the-board" class="headerlink" title="The real proof: a whole MobileNet, on the board"></a>The real proof: a whole MobileNet, on the board</h2><p>The single conv proves the toolchain runs. To prove it produces a <em>correct, usable</em> model Iconverted a real one — the ONNX MobileNetV2 (<code>mobilenetv2-12</code>) from Rockchip’s model zoo,with the vendor’s <code>mean</code>&#x2F;<code>std</code> preprocessing, <code>target_platform=&#39;rk3576&#39;</code>, on the same arm64host.</p><p>One surprise before I even touched hardware: my arm64 output came out <strong>7,640,625 bytes —the same size, to the byte, as the vendor’s x86-converted reference.</strong> The md5 still differs(it always will), but a byte-level <code>cmp</code> says only <strong>10,490 of those 7.64M bytes differ —0.14%.</strong> The other 99.86% is identical. The differing sliver is embedded metadata; thecompiled graph and weights are the same model. That’s the md5 gotcha above, quantified: samemodel, different checksum, and the whole difference is header.</p><p>Then the run — on a Radxa ROCK 4D, <strong>mainline kernel</strong> with the out-of-tree vendor <code>rknpu</code>driver and <code>librknnrt</code>, no vendor BSP anywhere:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">rknn_mobilenet mobilenetv2-12_rk3576_arm64.rknn test.jpg</span><br><span class="line"></span><br><span class="line">top-5  (NPU inference 6.2 ms):</span><br><span class="line">  1. [ 494] chime, bell, gong            18.6562</span><br><span class="line">  2. [ 653] milk can                     12.1406</span><br><span class="line">  3. [ 469] caldron, cauldron            11.6016</span><br><span class="line">  ...</span><br><span class="line">[bench] rknn inference: 6.2 ms (162.4 fps)</span><br></pre></td></tr></table></figure><p>Correct top-1 (the test image is a bell), the same top-5 as the vendor build, ~6 ms, 162 fps.That’s the whole loop closed on arm64: convert the model <em>and</em> run it on the NPU, with no x86in the pipeline at any step.</p><h2 id="Who-this-saves"><a href="#Who-this-saves" class="headerlink" title="Who this saves"></a>Who this saves</h2><p>If you’re standing up an RK3576 &#x2F; RK3588 workflow and you’ve been told to keep an x86 boxaround <em>just</em> to convert models — on 2.3.2 you don’t. Install the aarch64 wheel, pinsetuptools under 81, ignore the deprecation warning, ignore the md5 mismatch, and convert onthe same arm64 machine you build everything else on. The whole detour disappears.</p></details><script src="/js/reveal-toc.js" defer></script><div style="margin-top: 2.5em;"><div class="a2a_kit a2a_kit_size_32 a2a_default_style"><a class="a2a_dd" href="https://www.addtoany.com/share"></a><a class="a2a_button_facebook"></a><a class="a2a_button_twitter"></a><a class="a2a_button_linkedin"></a><a class="a2a_button_reddit"></a><a class="a2a_button_hacker_news"></a><a class="a2a_button_slashdot"></a><a class="a2a_button_telegram"></a><a class="a2a_button_whatsapp"></a><a class="a2a_button_line"></a><a class="a2a_button_facebook_messenger"></a><a class="a2a_button_mewe"></a><a class="a2a_button_flipboard"></a><a class="a2a_button_email"></a><a class="a2a_button_google_translate"></a></div><script async src="https://static.addtoany.com/menu/page.js"></script></div>]]>
    </content>
    <id>https://gahingwoo.github.io/posts/rknn-toolkit2-arm64/</id>
    <link href="https://gahingwoo.github.io/posts/rknn-toolkit2-arm64/"/>
    <published>2026-07-10T00:00:00.000Z</published>
    <summary>The folk wisdom says you need an x86 box to convert models for a Rockchip NPU. On rknn-toolkit2 2.3.2 that's stale — the aarch64 wheel builds an onnx into a .rknn end-to-end. Here's the proof, the two traps that'll stop you first, and why the output md5 not matching is completely fine.</summary>
    <title>rknn-toolkit2 2.3.2 Conversion Runs Natively on arm64 — and the Two Gotchas Nobody Documents</title>
    <updated>2026-07-16T01:04:22.052Z</updated>
  </entry>
  <entry>
    <author>
      <name>Ga Hing Woo (Jiaxing Hu)</name>
    </author>
    <category term="Firmware &amp; Secure Boot" scheme="https://gahingwoo.github.io/categories/Firmware-Secure-Boot/"/>
    <category term="rockchip" scheme="https://gahingwoo.github.io/tags/rockchip/"/>
    <category term="rk3576" scheme="https://gahingwoo.github.io/tags/rk3576/"/>
    <category term="firmware" scheme="https://gahingwoo.github.io/tags/firmware/"/>
    <category term="edk2" scheme="https://gahingwoo.github.io/tags/edk2/"/>
    <category term="uefi" scheme="https://gahingwoo.github.io/tags/uefi/"/>
    <category term="hdmi" scheme="https://gahingwoo.github.io/tags/hdmi/"/>
    <category term="usb" scheme="https://gahingwoo.github.io/tags/usb/"/>
    <category term="emmc" scheme="https://gahingwoo.github.io/tags/emmc/"/>
    <content>
      <![CDATA[<h2 id="TL-DR"><a href="#TL-DR" class="headerlink" title="TL;DR"></a>TL;DR</h2><ul><li><strong>EDK2 boots a real UEFI environment on the RK3576</strong> — swapping out U-Boot proper forEDK2 after BL31, verified on two boards (Radxa ROCK 4D over SPI, ArmSoM CM5-IO overSD).</li><li><strong>HDMI’s worst bug was the debugging itself</strong>: reading overlay registers back afterenable — read-only, no writes — silently perturbed VOP2 and killed the picture; theone real fix underneath all the phantom bugs was a register-write-ordering issue.</li><li><strong>USB needed two DWC3 xHCI workarounds</strong>: skip a structurally-redundant<code>EvaluateContext</code> on SuperSpeed, and mark the hub slot-context flag before theinterrupt endpoint arms, or the onboard hub never enumerates.</li><li><strong>eMMC was simply held in hardware reset</strong> — <code>EMMC_RST_N</code> defaults to 0, and RK3588boards never notice because SPL boots from eMMC and clears it in passing; RK3576booting SD&#x2F;SPI never touches that bit at all.</li><li><strong>Ethernet’s PHY part number was copy-pasted wrong everywhere</strong> — four independentsources call it an RTL8211F; the schematic says MotorComm YT8531C. It works eitherway (both are generic Clause-22 PHYs), until you need PHY-specific tuning.</li><li><strong>Two firmware&#x2F;OS handoff bugs lived below any single peripheral</strong>: a coincidental<code>MZ</code> byte pair in unrelated memory crashed the OS-loader scan, and an unpopulated RAMbank register read as all-zero decoded into a phantom 256 MB.</li></ul><span id="more"></span><details><summary style="cursor:pointer;font-weight:600;">Read the full write-up</summary><p>The goal was a real UEFI firmware on the RK3576: boot the standard chain but swapU-Boot proper for EDK2, so the board comes up to a proper UEFI environment (thinkESXi-on-Arm, Windows, EBBR distros). The boot chain:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">BootROM → U-Boot SPL (idbloader) → TF-A BL31 → EDK2 UEFI (BL33 @ 0x40800000)</span><br></pre></td></tr></table></figure><p>SPL loads a FIT image — from SPI <code>0x60000</code> or SD sector 16384 — containing BL31 andEDK2. The unbreakable rule: <strong>U-Boot proper must never end up in that FIT.</strong> Half ofthe early bugs were exactly that leaking back in (u-boot.bin in the FIT, raw SPL with noRKNS header, FIT entries pointing at BL31 load addresses that didn’t match the ELF’sactual segments). Once the packaging was honest, the fun started: three peripherals,three completely different failure personalities.</p><p>Boards: Radxa ROCK 4D (SPI boot) and ArmSoM CM5-IO (SD boot).</p><h2 id="HDMI-where-reading-a-register-breaks-the-picture"><a href="#HDMI-where-reading-a-register-breaks-the-picture" class="headerlink" title="HDMI: where reading a register breaks the picture"></a>HDMI: where reading a register breaks the picture</h2><p>VOP2 is Rockchip’s display controller, and the RK3576’s is <em>almost</em> the RK3588’s, whichis the worst kind of almost. Same register <em>names</em>, subtly different <em>addresses</em> and<em>semantics</em>, and a vendor TRM that documents the happy path and nothing else. What followsis the whole journey, dead ends included, because the dead ends are the actual content.</p><h3 id="Tier-1-the-things-you-just-have-to-get-right"><a href="#Tier-1-the-things-you-just-have-to-get-right" class="headerlink" title="Tier 1: the things you just have to get right"></a>Tier 1: the things you just have to get right</h3><p>These cost time but they’re honest hardware facts — read the TRM carefully, cross-checkmainline, move on:</p><ul><li><strong>Pixel clock divider is ÷2, not ÷4.</strong> RK3588 uses ÷4; RK3576 halves the VOP2 DCLKcore rate (<code>VPixclk &gt;&gt; 1</code>). Wrong divider, no usable signal.</li><li><strong><code>IF_CTRL</code> pin polarity</strong> lives in bits [5:4]; for +H&#x2F;+V sync you write <code>0x80000033</code>.</li><li><strong>HPD routing through GPIO4 PC0–PC3, all function 9</strong> (CEC + HPD + SCL + SDA). PC0specifically <em>must</em> be fn9 or the IOC won’t route hot-plug detect.</li><li><strong><code>IOC_MISC_CON0</code> CLR bit must stay latched</strong> — it’s not write-1-to-clear like you’dassume. Clear it and you freeze the hot-plug detector solid.</li></ul><p>A note on references: do <strong>not</strong> use vendor U-Boot as your VOP2 source. Mainline U-Bootdoesn’t bring up VOP2 at all (the OS DRM driver does), so it’s tempting to reach for theRockchip vendor tree, which <em>does</em> init the display. It also has its own divergent registermap — <code>rk3528_setup_overlay</code>, a different <code>BG_MIX_CTRL</code> address — and it burned me twice(a stray <code>LAYER_SEL = 0xFFFFFFFF</code> pre-init, a wrong mix-control address), each time killingthe signal. The only authoritative reference is mainline Linux <code>rockchip_drm_vop2.c</code> &#x2F;<code>rockchip_vop2_reg.c</code>.</p><h3 id="Tier-2-mainline-does-X-and-you-can’t"><a href="#Tier-2-mainline-does-X-and-you-can’t" class="headerlink" title="Tier 2: mainline does X, and you can’t"></a>Tier 2: mainline does X, and you can’t</h3><ul><li><strong><code>PRE_DITHER_DOWN_EN = 1</code> kills the signal</strong> — even though mainline sets that exact bitfor the same RGB888 path. The difference is <em>ordering</em>: mainline computes the entire<code>DSP_CTRL</code> as one local <code>u32</code> and writes it once at the end of <code>atomic_enable</code>; my<code>Vop2Init</code> pokes the register incrementally with read-modify-write and intermediate<code>CFG_DONE</code> commits. Flip pre-dither mid-sequence and the VP lands in a state <code>CFG_DONE</code>can’t complete from, and the output drops. The cost of leaving it <code>0</code> is visible —faint vertical one-pixel banding and a touch less brightness — but Linux rewrites<code>DSP_CTRL</code> at OS handoff, so it’s a UEFI-only cosmetic. Real fix is a compute-then-writerefactor; I haven’t done it yet.</li></ul><h3 id="Tier-3-the-bug-that-was-my-own-debugger"><a href="#Tier-3-the-bug-that-was-my-own-debugger" class="headerlink" title="Tier 3: the bug that was my own debugger"></a>Tier 3: the bug that was my own debugger</h3><p>This is the one I’ll be telling people about for a while.</p><p>I had a black screen with a <em>valid</em> signal and went hunting for the blending bug. Standardmove: dump the overlay registers after enable and read them back. So I added a block of<code>MmioRead32</code> + <code>DEBUG</code> prints after VP0’s <code>CFG_DONE</code> — <code>OVL_LAYER_SEL</code>, the legacy<code>OVL_PORT_SEL</code> at 0x608, the old <code>VP0_BG_MIX</code> at 0x6E0, the esmart block. <strong>Adding thedump took down HDMI.</strong> Read-only reads. No writes.</p><p>The trap is that on RK3576 the OVL block was <em>reorganized</em>: 0x608 and 0x6E0 areRK3588-legacy addresses that now point at completely different, live registers, andtouching them — even reading — perturbs the VP. (Or it’s the ~10 ms of UART latency at1.5 Mbaud landing in a tight window between <code>CFG_DONE</code> and the PHY bring-up; I never fullyproved which.) Either way: <strong>my diagnostic was the fault.</strong> And because the dump moved theregister <em>values</em> I was reading, it manufactured a fake <code>LAYER_SEL</code> bug — so I spent anafternoon chasing a layer-select problem that did not exist, caused entirely by the act oflooking at it.</p><h3 id="Tier-4-debugging-a-problem-that-wasn’t-there"><a href="#Tier-4-debugging-a-problem-that-wasn’t-there" class="headerlink" title="Tier 4: debugging a problem that wasn’t there"></a>Tier 4: debugging a problem that wasn’t there</h3><p>Here’s the genuinely embarrassing part, kept in on purpose.</p><p>Once I stopped reading registers, the monitor synced and — in the half-second I glanced atit mid-boot — showed black. Valid timing, valid TMDS, nothing on it. So I built a theory:RK3576’s overlay routes windows differently from RK3588 — per-window <code>VP_SEL</code> registersplus a per-VP <code>OVL_LAYER_SEL</code>, instead of the central pair at 0x604&#x2F;0x608 — so surely EDK2was programming an overlay the chip ignored, and the window was attached to no video port.It’s a <em>plausible</em> theory; it matches how the mainline RK3576 path is actually written. Ispent an evening implementing it carefully against <code>rockchip_vop2_reg.c</code>, flashed, andturned a working picture into a <em>dead signal</em> — three builds in a row.</p><p>What finally stopped the bleeding was not more theory. It was <code>dumpimage</code> on the one SDimage that actually drove a picture: pull the gzipped EDK2 firmware volume out of the FITand check its build timestamp against mine. The working binary was the <strong>original,unmodified</strong> overlay — the build where my rework was <em>reverted</em>. The RK3588-style central<code>OVL_LAYER_SEL</code>&#x2F;<code>OVL_PORT_SEL</code> path composites perfectly well on RK3576. My rework left thewindow half-routed and dropped sync. And the black frame that started the whole detour?The boot logo simply hadn’t been painted yet at the instant I looked — once BDS draws theframebuffer, TianoCore is on the glass:</p><table><thead><tr><th>Radxa ROCK 4D</th><th>ArmSoM CM5-IO</th></tr></thead><tbody><tr><td><img src="/imgs/monitor-4d.png" alt="EDK2 UEFI on ROCK 4D via HDMI"></td><td><img src="/imgs/monitor-cm5io.jpeg" alt="EDK2 UEFI on CM5-IO via HDMI"></td></tr></tbody></table><p>So if you skim the edk2-rk3576 git log around the HDMI work it’s a graveyard of <code>fix:</code>immediately followed by <code>Revert &quot;fix:&quot;</code>, and now a <code>revert:</code> of a <code>fix:</code> that fixednothing. That isn’t noise — it’s what undocumented display bring-up looks like when yourown tools and your own theories keep generating phantom bugs. Two lessons I actuallyinternalised:</p><ol><li><strong>On this block, reading is not free.</strong> Print computed values <em>before</em> you write them;never read registers back after enable for “just a quick check.”</li><li><strong>Confirm against the working binary before you theorise.</strong> Five minutes of <code>dumpimage</code>would have saved me an evening of plausible, confident, wrong rework.</li></ol><p>The one real bug was the register dump. The only thing left is a small horizontal offset onCM5-IO — the RK3576 background&#x2F;pre-scan delay still uses the RK3588 formula, a genuineone-register fix — and even that was one <code>git revert</code> away the whole time.</p><h2 id="USB-a-hub-that-won’t-enumerate"><a href="#USB-a-hub-that-won’t-enumerate" class="headerlink" title="USB: a hub that won’t enumerate"></a>USB: a hub that won’t enumerate</h2><p>CM5-IO routes both USB-A ports through an onboard 4-port USB3 hub IC hanging off DRD1.No direct port — everything goes through the hub, so if the hub doesn’t enumerate, youhave no USB at all. Two independent DWC3 xHCI bugs, both in EDK2’s <code>XhciDxe</code>:</p><p><strong>1. SuperSpeed <code>EvaluateContext</code> times out.</strong> The RK3576 DWC3 never raises a CommandCompletion event for <code>EvaluateContext</code> on a SuperSpeed slot, so enumeration hangs waitingfor it. The saving grace: on SS, EP0’s max packet size is always 512, which is <em>already</em>the value the Address-Device context was initialised with. The command is a structuralno-op. So: when <code>MaxPacket0 == 512</code>, skip it and return success. (512 is unique to SS;HS&#x2F;FS&#x2F;LS EP0 is ≤64, so the check can’t misfire.)</p><p><strong>2. Hub <code>ConfigHubContext</code> times out after <code>SetConfig</code>.</strong> Once the hub’s interruptendpoint goes active, the controller serialises Configure-Endpoint commands behindendpoint-transfer processing and deadlocks. Fix is to set the Hub&#x3D;1 slot-context flag<em>before</em> <code>SetConfigCmd</code> activates that endpoint (with a placeholder port count), thentolerate the later “real values” update failing — the hub’s already marked as a hub, soit keeps working. (Cost: TT routing for full&#x2F;low-speed devices behind the hub isapproximate, but HS&#x2F;SS storage and HID — the stuff people actually plug in — are fine.)</p><p>After both: <code>PORTSC</code> shows the SS and HS hub links trained to U0 at ReadyToBoot.Devices appear. Ship it.</p><h2 id="eMMC-held-in-reset-the-whole-time"><a href="#eMMC-held-in-reset-the-whole-time" class="headerlink" title="eMMC: held in reset the whole time"></a>eMMC: held in reset the whole time</h2><p><code>SdCardIdentification: Executing Cmd0 fails with Time out</code>. eMMC never showed up. Theroot cause is almost funny:</p><p><code>EMMC_EMMC_CTRL</code> bit 2 is <code>EMMC_RST_N</code>, and its power-on default is <strong>0</strong> — eMMC held inhardware reset. On RK3588 boards this is invisible because SPL boots <em>from</em> eMMC andclears the reset on its way through. On RK3576 booting from SD or SPI, nothing evertouches the eMMC controller, so it sits in reset until UEFI tries to talk to a devicethat is, electrically, switched off.</p><p>Fix: <code>MmioOr32(EMMC_EMMC_CTRL, BIT2)</code> to deassert reset, then a 200 µs stall per theeMMC spec, before CMD0. Universal — <em>any</em> platform that boots from SD&#x2F;SPI needs it.</p><p>That got CMD0 talking, and then HS200&#x2F;HS400 hung, which opened a whole second saga ofabout seven smaller fixes to get a stable 52 MHz High-Speed mode (<code>ForceHighSpeed</code>): theSDHCI base-clock value not propagating to the divisor math, the init CRU running at thewrong frequency, <code>CARD_IS_EMMC</code> only being set on the DLL path, DLL bypass&#x2F;RXCLK gatingconstants lifted from U-Boot’s non-DLL path, an RK3576-specific STRBIN delay (<code>0xa</code>, notRK3588’s <code>0x10</code>)… and the one that was the <em>actual</em> CMD0-timeout root cause hidingunder all of it: <strong><code>MISC_INTCLK_EN</code> (the internal-clock enable) gets cleared by<code>SW_RST_ALL</code> and was never re-set.</strong> Mainline’s <code>rk35xx_sdhci_reset()</code> always restoresit after a reset; we weren’t, so the SDHCI internal clock was simply off and <em>every</em>command timed out. Restoring it after reset is what finally made it stick.</p><p>The recurring tell across all of it: the Rockchip SDHCI’s clock divider bits aren’tfunctional — the real eMMC clock follows the CRU, and the SDHCI divisor is ignored. Onceyou internalise that, half the “impossible” clock numbers explain themselves.</p><h2 id="Ethernet-the-part-number-was-a-copy-paste"><a href="#Ethernet-the-part-number-was-a-copy-paste" class="headerlink" title="Ethernet: the part number was a copy-paste"></a>Ethernet: the part number was a copy-paste</h2><p>This one barely cost any debugging — it’s here because of <em>how</em> the answer hid. GMAC0 onthe CM5-IO comes up fine; PXE boots, gigabit links. But every text source describing thePHY agrees it’s a Realtek RTL8211F: the upstream device tree comment says so, the vendorBSP device tree says so (it even hard-codes a “100 ms for rtl8211f” reset delay), and theEDK2 port I started from ships a <code>RealtekPhy.c</code>. Four independent files, one part number,total consensus.</p><p>The schematic disagrees. <code>U6601</code> is a <strong>MotorComm YT8531C</strong> — a different vendor’s PHYentirely. Every text source had inherited the same wrong identity by copy-paste, one treecribbing from the last, and nobody re-checked against the board. It works anyway becauseboth are ordinary Clause-22 PHYs and the generic IEEE driver brings either up — so the lieis <em>invisible</em> as long as you only need a link. It stops being invisible the moment youwant the PHY-specific RGMII skew and delay tuning right, which is the difference between“links” and “links cleanly at gigabit.” The schematic was the one witness in the room thathadn’t copied its homework from the others.</p><h2 id="The-bugs-that-weren’t-peripherals"><a href="#The-bugs-that-weren’t-peripherals" class="headerlink" title="The bugs that weren’t peripherals"></a>The bugs that weren’t peripherals</h2><p>The two nastiest bugs weren’t in any controller. They lived at the seam where firmwarehands off to the OS, and they’re the kind you can only hit on real silicon with realmemory contents.</p><p><strong>A coincidental “MZ” that crashed the handoff.</strong> <code>ExitBootServicesHookDxe</code> identifies theOS loader by walking <em>backwards</em> from the loader entry looking for an EFI PE image — the<code>MZ</code> DOS signature (<code>0x5A4D</code>), then <code>e_lfanew</code> to find the PE header. On the ROCK 4D thiswas fine. On the CM5-IO it hard-hung before Linux could take over from GRUB. The cause:some unrelated <em>data</em> page happened to contain the two bytes <code>4D 5A</code>, the scanner acceptedit as a DOS header, read <code>e_lfanew</code> out of whatever garbage followed, and chased the“PE header” pointer to <code>0x184DD0D7C</code> — about 6.2 GB, nowhere near anything mapped. Atranslation fault (<code>ESR=0x96000005</code>), and the board was dead one instruction before thekernel got control. It looked board-specific and random because it <em>was</em> random: itdepended on what bytes were lying around in memory at that address on that board. The fixis to not trust a bare <code>MZ</code> — a real EFI image keeps its PE header at a small offset, soreject any <code>e_lfanew</code> outside <code>[sizeof(DOS_HEADER), 1 MB)</code> before following it. Data thathappens to look like code is the oldest trap there is, and it still got me.</p><p><strong>256 MB of RAM that didn’t exist.</strong> The SDRAM sizing code reads two bank register pairsout of <code>PMUGRF</code>. The CM5 module is a single 4 GB bank, so the SPL never writes the secondpair — and it reads back as all-zeros. Zero isn’t “no bank,” though; fed through thebit-field math it decodes to a perfectly plausible <em>256 MB</em>, so the firmware proudlyreported 4352 MB to SMBIOS. Harmless to the page tables, but a lovely reminder that anuninitialised register reading <code>0</code> is not the same as “absent” — you have to treatall-zeros as <em>skip this slot</em>, not <em>a 256 MB stick lives here</em>.</p><h2 id="The-pattern"><a href="#The-pattern" class="headerlink" title="The pattern"></a>The pattern</h2><p>A handful of peripherals, each with its own lesson, one shape. HDMI: <em>your debugging canbe the bug.</em> USB: <em>the controller’s idea of a no-op isn’t the spec’s.</em> eMMC: <em>the thingwas never powered on, you were talking to a brick.</em> Ethernet: <em>every document agreed, andthe schematic was the only one right.</em> And the two at the seam: <em>data that looks likecode, and zero that looks like a number.</em> None of it came from a register manual — it camefrom mainline Linux and U-Boot as the reference for <em>what the working values are</em>, thenflashing real boards and reading what the silicon did. The screen, the <code>PORTSC</code> bits, theCMD0 timeout, a schematic nobody re-read, and a fault address six gigabytes into nowherewere the only honest witnesses. Everything else was a theory waiting to be reverted.</p></details><script src="/js/reveal-toc.js" defer></script><div style="margin-top: 2.5em;"><div class="a2a_kit a2a_kit_size_32 a2a_default_style"><a class="a2a_dd" href="https://www.addtoany.com/share"></a><a class="a2a_button_facebook"></a><a class="a2a_button_twitter"></a><a class="a2a_button_linkedin"></a><a class="a2a_button_reddit"></a><a class="a2a_button_hacker_news"></a><a class="a2a_button_slashdot"></a><a class="a2a_button_telegram"></a><a class="a2a_button_whatsapp"></a><a class="a2a_button_line"></a><a class="a2a_button_facebook_messenger"></a><a class="a2a_button_mewe"></a><a class="a2a_button_flipboard"></a><a class="a2a_button_email"></a><a class="a2a_button_google_translate"></a></div><script async src="https://static.addtoany.com/menu/page.js"></script></div>]]>
    </content>
    <id>https://gahingwoo.github.io/posts/rk3576-uefi-edk2/</id>
    <link href="https://gahingwoo.github.io/posts/rk3576-uefi-edk2/"/>
    <published>2026-06-20T00:00:00.000Z</published>
    <summary>Replacing U-Boot proper with EDK2 on the RK3576 — and the three peripherals that each had their own way of refusing to work.</summary>
    <title>RK3576 UEFI: Teaching EDK2 to Drive HDMI, USB, and eMMC</title>
    <updated>2026-07-16T01:04:22.052Z</updated>
  </entry>
  <entry>
    <author>
      <name>Ga Hing Woo (Jiaxing Hu)</name>
    </author>
    <category term="NPU &amp; Toolchain" scheme="https://gahingwoo.github.io/categories/NPU-Toolchain/"/>
    <category term="rockchip" scheme="https://gahingwoo.github.io/tags/rockchip/"/>
    <category term="rk3576" scheme="https://gahingwoo.github.io/tags/rk3576/"/>
    <category term="linux" scheme="https://gahingwoo.github.io/tags/linux/"/>
    <category term="npu" scheme="https://gahingwoo.github.io/tags/npu/"/>
    <category term="embedded" scheme="https://gahingwoo.github.io/tags/embedded/"/>
    <content>
      <![CDATA[<h2 id="TL-DR"><a href="#TL-DR" class="headerlink" title="TL;DR"></a>TL;DR</h2><p>Goal: run MobileNet on the RK3576’s NPU through the open <code>rocket</code> driver + Mesa Teflon — the samestack is byte-perfect on the RK3588, so the bug is RK3576-specific. A month of all-zero output later:</p><ul><li><strong>The int8 convolution is byte-correct now.</strong> The “all-grey” wall was a fixed-point bug: the rescalemultiplier went out at Q14 where the chip wants Q4 — 2¹⁰ too hot, so every pixel saturated. Fix that(plus a pad value and a bias term) and a single conv matches the CPU reference byte-for-byte.</li><li><strong>MobileNet end-to-end still returns zero</strong>, behind what looked like two walls but turned out to beone, living <em>below the registers</em>: the command stream I send is byte-identical to the vendor’s and thechip still behaves differently. The wall is multi-task dispatch — the compute units won’t re-armthemselves for each task the way the vendor’s do — and the depthwise, which I’d taken for a secondseparate wall, is just the first layer wide enough to be forced through it (I confirmed that byinstrumenting the vendor’s own driver and watching its tiled depthwise run aligned and correct).Ordinary single-task convolutions compute fine on the same path.</li><li>So <strong>the open driver is exonerated</strong> — every byte I hand the chip matches the vendor’s; the gap is insilicon state, below what software can observe on either side. I even booted the driver on top of amainline OP-TEE to rule out the firmware — same failure. <strong>The way around</strong> (not through): the wall onlybites <em>multi-task</em> jobs, so send each row-tile as its own single-task job, which the hardware runs. That’sthe next build.</li></ul><p>The rest is the long version — mostly me being wrong, in order.</p><span id="more"></span><details><summary style="cursor:pointer;font-weight:600;">Read the full write-up</summary><p>The RK3576 has a 6 TOPS NPU and the open-source <code>rocket</code> driver targets it. I got afull MobileNet run going — 252 hardware jobs, no hangs, no faults — and every singleoutput byte was zero. This is roughly how the next two weeks went. Mostly it’s mebeing wrong a lot.</p><h2 id="The-setup"><a href="#The-setup" class="headerlink" title="The setup"></a>The setup</h2><ul><li>Radxa ROCK 4D (RK3576, 12 GiB LPDDR5)</li><li>linux-next 7.1.0-rc5, <code>rocket</code> built into the kernel (not a module)</li><li>MobileNetV1 224×224 through the Mesa Teflon TFLite delegate</li><li>CPU reference: Top-1 &#x3D; 653, conf ≈ 0.887</li></ul><p>One thing kept me sane the whole time: <code>rocket</code> already runs this exact model<em>perfectly</em> on the RK3588. So nothing about the driver was fundamentally broken. Thebug had to be something RK3576-specific — a value, an offset, a sequence the two chipsdon’t share. Whenever a theory tried to blame the whole architecture, that facttalked me down.</p><h2 id="“Done”-doesn’t-mean-“computed”"><a href="#“Done”-doesn’t-mean-“computed”" class="headerlink" title="“Done” doesn’t mean “computed”"></a>“Done” doesn’t mean “computed”</h2><p>First run looked great. 252 jobs, ~1.9 ms each, six runs in ~475 ms, no IOMMU faults,no DMA errors. Kernel says every job is done.</p><p>Output: all zeros. Raw non-zero &#x3D; 0 &#x2F; 1001. Every run.</p><p>Turns out “job done” only means the command processor drained its instruction listwithout choking. It says nothing about whether the convolution engines did any actualmath. That gap is the entire post.</p><p>So I stopped trusting “done” and wired up the hardware bandwidth counters — those readstraight off the NPU, the command processor can’t fake them:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">rocket dbg perf: dt_wr=0 dt_rd=&lt;constant&gt; wt_rd=0</span><br></pre></td></tr></table></figure><p><code>dt_wr</code> &#x3D; bytes written to DRAM. <code>wt_rd</code> &#x3D; weights fetched. Both zero, all 252 jobs,every run. And <code>dt_rd</code> went up by the <em>same</em> amount every job no matter the layersize — which for a net whose layers vary 100× in size can only be command overhead,not real data.</p><p>Translation: the NPU takes the command stream, says done, and never moves a tensor.Armed, enabled, configured, dead.</p><h2 id="The-pile-of-dead-theories"><a href="#The-pile-of-dead-theories" class="headerlink" title="The pile of dead theories"></a>The pile of dead theories</h2><p>Before the real cause showed up I had to kill a bunch of reasonable-sounding ideas.Each one cost a rebuild and a flash:</p><ul><li><strong>Ping-pong delay</strong> — maybe each job fires the <em>previous</em> job’s params and the lastlayer never triggers. Moved PP_CLEAR to the end so each job fires its own. Stillzero.</li><li><strong>Cache coherency</strong> — maybe the NPU writes fine and the CPU reads stale cache. Addeda write-combining DRAM read to dodge the cache. Input read back non-zero (cache pathworks), outputs read zero both ways. Nope.</li><li><strong>Fence before writeback</strong> — maybe completion signals before the write DMA lands.Added a sync barrier after op_en. No change.</li><li><strong>Weight-fetch gate</strong> — spent an afternoon sure the CNA read features but neverweights. Then realized I was <em>summing</em> the top-level and per-core counters andreading constant descriptor-fetch traffic as if it were real. The theory was builton a misread counter. Lesson: don’t trust an aggregate.</li></ul><p>Slow, but each dead end shrank the box. By the end I’d ruled out clocks, MAC gating,IOMMU, op_en actually reaching the units (<code>CNA_OPEN = CORE_OPEN = DPU_OPEN = 1</code>), andevery register <em>value</em> I could think to poke. Units enabled, config latched, nothingrunning.</p><h2 id="The-NVDLA-model-is-the-lens"><a href="#The-NVDLA-model-is-the-lens" class="headerlink" title="The NVDLA model is the lens"></a>The NVDLA model is the lens</h2><p><code>rocket</code> is built on NVDLA, and NVDLA’s docs are public, so I used them for the mentalmodel. The bit that mattered is the producer&#x2F;consumer ping-pong:</p><ul><li><code>S_POINTER</code> bit 0 &#x3D; PRODUCER — which group the CPU writes config into</li><li><code>S_POINTER</code> bit 16 &#x3D; CONSUMER — which group the hardware is actually executing</li><li>write to a group whose enable is already set and the writes get <strong>silently dropped</strong></li></ul><p>Reading my own logs through that lens flipped it. Across all 252 jobs:</p><ul><li><strong>DPU_RDMA</strong> — consumer advanced, it ran a layer</li><li><strong>CNA &#x2F; CORE &#x2F; DPU</strong> — consumer stuck at 0, never finished a single layer</li></ul><p>The one thing separating the unit that ran from the three that didn’t: the three deadones are the <strong>CBUF-backed convolution path</strong>, RDMA isn’t. So the gate was in<em>starting</em> an already-configured, already-enabled conv pipeline. Staring at registerswasn’t going to get me further.</p><h2 id="Getting-a-reference-command-stream"><a href="#Getting-a-reference-command-stream" class="headerlink" title="Getting a reference command stream"></a>Getting a reference command stream</h2><p>If I couldn’t reason it out, I’d compare against something known-good. Rockchip’s<code>rknn-toolkit2</code> (the official model converter) has an aarch64 wheel, so it runs righton my dev host. No board needed:</p><ol><li>build a one-Conv2d ONNX model (3→32, 3×3, s2 — MobileNet’s first layer)</li><li>convert it for <code>rk3576</code> with quantization → a <code>.rknn</code></li><li>walk the <code>.rknn</code> for the 64-bit command words, decode them per unit</li></ol><p>Now I had a working RK3576 command stream for any conv I wanted, to diff againstwhatever Mesa was emitting. Finally a way to ask: what’s in a <em>working</em> first-convstream that mine doesn’t have?</p><p>Later I captured the same thing live on the board too and it matched byte-for-byte(139 entries). Good — the offline trick wasn’t lying to me.</p><h2 id="The-CNA-CLK-GATE-red-herring"><a href="#The-CNA-CLK-GATE-red-herring" class="headerlink" title="The CNA_CLK_GATE red herring"></a>The CNA_CLK_GATE red herring</h2><p>First diff lit up a register Mesa never wrote at all: <code>0x1090 = 0x2a</code>. Mesa’s header(RK3588-era) called it <code>CNA_CLK_GATE</code>. An unset clock gate on the compute path is a<em>perfect</em> suspect for “configured but never runs.” Hardcoded it. Flashed.</p><p>Still zero.</p><p>But the way it was wrong is the actual key. I ran the same conv at 224×224 and 64×64and diffed: <code>0x1090</code> changed, <code>0x2a → 0x0c</code>. A clock gate doesn’t change with inputsize. It’s not a clock gate — on RK3576 it’s a size-derived value (the CBUF input linestride). Mesa had the <em>name</em> wrong, which means it had the whole <em>map</em> wrong.</p><p>That’s the real shape of the bug: the RK3576 CNA register map is <strong>shifted andre-packed</strong> vs RK3588. The chip inserts registers and slides the offsets down, so Mesawas computing RK3588-flavored values and writing them into RK3576 registers that meansomething completely different.</p><h2 id="Two-things-had-to-be-true"><a href="#Two-things-had-to-be-true" class="headerlink" title="Two things had to be true"></a>Two things had to be true</h2><p><strong>A trigger.</strong> Diffing the kernel-side submit against the reference, one thing stoodout — the interrupt mask programmed before the op_en pulse. With <code>INT_MASK = 0x300</code>,the dead units finally moved:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">CNA_STAT  0x1  → 0x20001   (STATUS_1 = 2 = RUNNING)</span><br><span class="line">CORE/DPU  0x5  → 0x20005</span><br><span class="line">DPU DST   0x0  → 0x00cb1000 (a real destination loaded)</span><br></pre></td></tr></table></figure><p>(Brief detour: I first thought the trigger was a PC_DMA task-descriptor dispatch —built the whole descriptor, units woke up, felt great. Then the live capture showedthe vendor sets that base to <em>zero</em>, same as <code>rocket</code>. It was the <code>INT_MASK</code> changeriding along that actually did it. Onto the pile. The live capture saved me fromshipping a wrong conclusion.)</p><p><strong>The right map.</strong> Running still wasn’t computing — units engaged then <em>stalled</em>,<code>dt_wr</code> still zero, because they were configured through the wrong offsets. So I builta little harness: generate a conv, change exactly one thing (width, then height, thenchannels, then kernel, then stride), watch which registers move. One knob at a timethe RK3576 map fell out:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">0x102c = (in_w-1)&lt;&lt;16 | (in_h-1)     # proven with a non-square 128×224 case</span><br><span class="line">0x1030 lo = out_w-1,  hi = 32·k·k</span><br><span class="line">0x1044 = in_w&lt;&lt;16 | (in_w/4)</span><br><span class="line">0x1090 = in_w·4                      # the &quot;clock gate&quot; — it&#x27;s a line stride</span><br><span class="line">0x1094 = 0x1098 = in_w·in_h</span><br></pre></td></tr></table></figure><p>CORE was just Mesa’s CORE shifted +0x8 from <code>MISC_CFG</code> on; DPU followed the sameinsert-and-shift. The whole port turned out to be a per-unit offset remap plus a fewconstant fixes — not a rewrite. An offline checker predicted all 34 geometry&#x2F;channelregisters across every captured shape with zero mismatch before I touched the boardagain.</p><h2 id="It-computed"><a href="#It-computed" class="headerlink" title="It computed"></a>It computed</h2><p>Rewrote the first-conv encoder to the RK3576 map, flashed, watched job 0:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">top[dt_rd=9408  wt_rd=96]</span><br><span class="line">core[dt_wr=25088]</span><br></pre></td></tr></table></figure><p><code>dt_wr = 25088 = 112·112·2</code> — the full, correctly-sized first-layer output, written toDRAM. Weights actually fetched. All four units engaged. After a week and a half of zeros, aconvolution ran on the silicon. The whole approach — offset remap, in-stream arming,patched DMA addresses — proven on hardware.</p><p>That was layer 0. The rest of the chain — depthwise, pointwise, the lot — was asecond act of its own, and it had more walls in it than I expected.</p><h2 id="Getting-the-whole-chain-to-engage"><a href="#Getting-the-whole-chain-to-engage" class="headerlink" title="Getting the whole chain to engage"></a>Getting the whole chain to engage</h2><p>For a while only the first conv wrote; every layer after it went back to zeros. Fourseparate things were holding the rest of the chain down, and each one looked like thelast bug right up until it wasn’t:</p><ul><li><strong>Task-chaining corruption.</strong> Mesa chains multi-task jobs the RK3588 way — it ORs thenext task’s command address into the last two command entries, assuming those are thechaining slots. On RK3576 my command stream <em>ends in real RDMA registers</em>, so that“chaining” was scribbling an IOVA over a live DMA register and killing the write. TheRK3576 kernel dispatches each task separately anyway (its own base address + op_enpulse), so the fix was: don’t chain in-stream at all on RK3576. One unit’s worth ofsilent corruption, gone.</li><li><strong>The depthwise weight layout.</strong> Depthwise layers hung outright. The CORE neveropened — it sat waiting on weights it couldn’t consume. RK3576 packs depthwise weightsas spatial row-major blocks, two channels at a time with two zero-point pad bytes each(so a 32-channel 3×3 is 9 blocks × 64 bytes &#x3D; 576 bytes, which is exactly what the CNAweight-size register asks for). Mesa was handing it a layout the convolution MACcouldn’t read. That was the hang.</li><li><strong>Ping-pong parity.</strong> I burned a good while convinced the producer&#x2F;consumer groupswere desyncing per task — built a whole parity scheme in the kernel to alternate thepointer. Wrong: the working sequence keeps the pointer at group 0 <em>every</em> task andre-arms it per task. I’d been adding cleverness the hardware didn’t want. Ripped itback out and forced group 0 to match.</li><li><strong>The “windowed” mode that wasn’t.</strong> Mesa tiles the 112-wide layers into short rowwindows with a “capped” flag set. On RK3576 that capped mode just makes the DPU writenothing. A single full-height window (112 rows — it fits the RK3576 CBUF fine) makesdepthwise and pointwise write. So the fix was <em>less</em> tiling, not more.</li></ul><p>After those: conv0, depthwise, and pointwise all engage and all write varying output.The engage wall — the entire subject of everything above — is finally behind me. Whichis a great feeling for about a day, until you look at the actual numbers.</p><h2 id="Running-but-wrong"><a href="#Running-but-wrong" class="headerlink" title="Running, but wrong"></a>Running, but wrong</h2><p>The NPU now computes a full chain. The output is still wrong — just wrong in a much moreinteresting way than zeros. Layer 0’s output comes back almost entirely <code>0x7f</code>:saturated, pinned to the max. It’s doing arithmetic, the arithmetic is just blowing pastthe range.</p><p>To even see this I had to stop trusting the board’s own debug registers — half of themlie. The DPU destination registers are write-only and read back garbage; thewrite-combining readback I’d relied on returns null on system RAM. The only honestsignal is a cache-invalidated DRAM dump of each task’s real output address, plus a purenumpy reference of the quantized model computed offline. With those two side by side thesaturation was obvious.</p><p>Root cause: <strong>asymmetric weight zero-points.</strong> MobileNet’s weights are quantized withper-tensor zero-points all over the map — 74, 95, 122, 151, 211 — almost none of themthe symmetric 128. Mesa centers weights at a fixed 128 and only corrects the <em>input</em>zero-point in the bias. The leftover weight-zero-point term is a per-output-pixelquantity nobody is subtracting, so the accumulator runs tens of thousands of counts hotand clamps.</p><p>The genuinely surprising part — and the thing I’d never have guessed without comparingagainst a captured working stream — is <em>where</em> the hardware expects that correction. Itis <strong>not in the command stream and not in the weight values.</strong> I proved that with a pairof differential test convs (one symmetric, one all-positive zero-point): byte-identicalcommand streams, identically-centered weights. The weight zero-point is handleddata-side, in a per-channel coefficient table tucked into a bias buffer theconvolution’s accumulator reads. Mesa allocates that buffer too small and fills in onlypart of it.</p><p>A handful more differential captures later — the trick is convs with constantper-channel weights, so the per-layer fields stay clean while the per-channel ones goflat — and the buffer fully gave up its structure. It’s groups of eight outputchannels, 64 bytes each, laid out as eight 32-bit fields, then eight 16-bit, then eightmore 16-bit. The 16-bit one is just <code>(128 − weight_zero_point)</code>, the exact correctionthat was missing. The 32-bit field is that times the per-channel weight sum, pre-scaled.The last field folds in the input zero-point. The hardware computes a per-output-pixelinput sum on its own and combines all of it: <code>result = Σ(in−128)(w−128) + (128−wt_zp)·input_sum + bias</code>. I worked the algebra through against the offlinereference and it lands exactly. So I rewrote Mesa to emit the whole table.</p><p>And the output flipped — from saturated <em>high</em> (<code>0x7f</code>) to saturated <em>low</em>, pinned atthe layer’s zero-offset. Which is, weirdly, great news: same clamp, opposite end. Itmeans the correction term is real and active and now slightly <em>too strong</em> rather thanabsent — the difference between “you forgot a term” and “you have the term, off by aconstant.” That constant looks like a factor of 128 in how the corrected accumulator isscaled before requantization: the shift that converts back to 8-bit needs to accountfor it. I had it pegged as the last mile.</p><h2 id="The-conv-wasn’t-even-convolving"><a href="#The-conv-wasn’t-even-convolving" class="headerlink" title="The conv wasn’t even convolving"></a>The conv wasn’t even convolving</h2><p>It wasn’t, and what set me straight was a one-line change to my debug dump: print thenumber of <em>distinct</em> values in each output, not just the first few bytes.</p><p>Conv0’s output had <strong>two distinct values.</strong> Two. The entire 112×112×32 feature map was<code>0x7f</code> and <code>0x80</code> — plus or minus one constant magnitude, sign flipping per pixel. Thatis not a quantization-scaling problem. A real convolution produces a spread ofmagnitudes; ±one constant means the MAC array was never spatially convolving the imageat all. Everything I’d been doing to the requant math was downstream of a conv thatwasn’t happening. The clamp had flipped ends because the bias work <em>was</em> real — but itwas correcting a result that was garbage to begin with.</p><p>So I did the thing I should have done sooner: a full register-by-register diff of myconv0 command stream against the captured vendor one, runtime addresses aside. One linediffered.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">CNA 0x1064 (feature data offset)   vendor = 0   mine = 0x777</span><br></pre></td></tr></table></figure><p><code>0x777</code> is 1911. It was offsetting the feature fetch by 1911 bytes, so the MAC arrayconvolved the wrong data — the same wrong data everywhere, hence ±one constant. Atranscription typo in the hardcoded first-conv block; the normal-path encoder alreadyhad it right at 0. Set it to 0, flash, and conv0’s distinct-value count jumped from<strong>2 to 154</strong> — a real, full-range feature map.</p><p>(That same diff also retired a “fix” I’d been proud of: computing the first conv’srequant from the model scales. The captured stream showed the original hardcoded valueswere correct all along — the saturation I’d blamed on requant was <em>always</em> FC_CON1. Ireverted my own clever change. The detective work doesn’t just find bugs; it finds theones you introduced chasing the wrong theory.)</p><p><em>Update, days on: this one didn’t survive either. The vendor’s real value turned out tobe <code>0x777</code> — I’d had the direction backwards — and conv0 never reliably held that154-distinct map. The bloom was a flicker, not a fix, and the real wall was somewhere Ihadn’t thought to look yet. It comes back at the end.</em></p><h2 id="The-vendor-tiles-I-didn’t"><a href="#The-vendor-tiles-I-didn’t" class="headerlink" title="The vendor tiles; I didn’t"></a>The vendor tiles; I didn’t</h2><p>Conv0 bloomed, and the chain promptly broke one layer later: the first depthwise cameout with five distinct values and everything after it went to zero. Same shape — goodinput, good weights, degenerate output — so, same move: capture the vendor running the<em>actual</em> chain and diff.</p><p>The vendor splits every 112-wide layer into <strong>two row-windows</strong> — about 90 rows, thenthe remaining 22 — and runs each as its own task. I was running the whole layer as one112-row window, on the theory that it fit the on-chip CBUF. It doesn’t. The windowoverran the buffer, the MAC read stale data, and the layer came out degenerate. The fixwas <em>more</em> tiling, not a register value: a greedy row-window split matching the vendor’s,plus correcting a batch of windowed-mode register values I’d had wrong. The encoder nowmatches the captured vendor stream byte-for-byte on every windowed register, bothwindows. Done and pushed.</p><h2 id="The-real-shape-of-it-one-submit-not-many"><a href="#The-real-shape-of-it-one-submit-not-many" class="headerlink" title="The real shape of it: one submit, not many"></a>The real shape of it: one submit, not many</h2><p>Then conv0 started flickering. Same command stream, same input image, and run to run itgave me either the good 154-distinct map or the degenerate two values. Thatnon-determinism sent me down a long, instructive hole. I tried resetting the NPU betweenruns — which wedged the IOMMU, because the reset line I had also resets the tightlycoupled IOMMU and the next job can’t attach. I tried disabling autosuspend, softre-initing the ping-pong state, a warmup-retry that re-runs the first task. Each onebroke something else or fixed exactly half the problem — the geometry would latch, butthe compute core still wouldn’t turn on.</p><p>That clue — geometry present, core won’t engage — is what finally cracked it. I capturedhow the vendor <em>dispatches</em> the graph. Not the register contents this time; the dispatchitself. And the difference was the whole game:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">vendor : one submit, task_number = 8   (the entire graph, pipelined)</span><br><span class="line">mine   : one submit per task, task_number = 1</span><br></pre></td></tr></table></figure><p>The vendor hands the command processor the <strong>whole network at once</strong> and lets it streamthrough all eight tasks as one flowing ping-pong pipeline. The first conv is task 0 of apipeline that’s already moving — it warms and engages naturally. I was submitting oneisolated task per job, so my first conv was always task 0 on a <em>cold</em> pipeline, and acold first task on this hardware never lights its compute core. The flicker, the<code>CORE_OPEN = 0</code>, all of it: not a value anywhere, but the <em>shape</em> of how work reaches thechip.</p><p>The bitter part: earlier in this project I’d made a deliberate call to <em>not</em> chaintasks — “let the kernel dispatch them one at a time, simpler.” The vendor capture saysthat was exactly the wrong turn. The hardware wants the pipeline. So I reworked dispatchto submit the whole graph as a single job — which collapsed one inference from 500-oddjobs to a single submit, and felt like the answer.</p><p>It wasn’t.</p><h2 id="It-wasn’t-the-dispatch-after-all"><a href="#It-wasn’t-the-dispatch-after-all" class="headerlink" title="It wasn’t the dispatch after all"></a>It wasn’t the dispatch after all</h2><p>The whole-graph submit works, mechanically. Conv0 came out degenerate anyway.</p><p>Two things forced a humbler read. First, rocket’s command processor doesn’t actuallyiterate <code>task_number</code> on its own — one enable pulse runs about one task, so the“pipeline” I’d pictured wasn’t even happening the way I imagined. Second, and this is theone that should have stopped me a week earlier: a full 139-entry diff said my conv0command stream is byte-for-byte identical to the vendor’s — every register, everygeometry word, only the runtime addresses different. If the bytes are identical and itstill fails, the bug isn’t in the bytes, and it isn’t in how I hand them over. It’s inthe <em>execution state</em> those bytes run against.</p><p>So I stopped dumping registers after the job and started sampling them <em>during</em> it —specifically, which ping-pong group the executer is actually reading while it runs. Theanswer, at last:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">geometry written → producer group (group 0)</span><br><span class="line">executer reading → consumer group (group 1, empty)</span><br></pre></td></tr></table></figure><p>The hardware double-buffers convolution config across two ping-pong groups. My commandstream writes the geometry into one group; the executer was running the <em>other</em> one,which had nothing in it. So it engaged, found an empty config, raised “done” within amicrosecond, and wrote flat garbage. Every after-the-fact dump had missed it because bythe time the job ended the pointer had already moved — you can only catch it mid-run.This was never the dispatch model and never the regcmd content. It’s a producer&#x2F;consumerparity bug that had been hiding under every theory I’d had, including the confident onein the section right above this.</p><p>The fix is almost embarrassingly small after all that: re-run the ping-pong CLEAR at thehead of <em>every</em> job, not just once at power-on, so the producer and consumer pointersrealign onto the same group. With it, the geometry lands where the executer looks, andthe CNA status register moved from <code>0x0c</code> (hollow) to <code>0x08</code> — the two halves of theping-pong reading the same data for the first time. It’s not all the way to a real “open”yet; the output is still flat and I’m still chasing the last step. But after a week ofmislabelling it a dispatch problem, it’s finally the <em>right</em> wall.</p><h2 id="The-cores-wake-up"><a href="#The-cores-wake-up" class="headerlink" title="The cores wake up"></a>The cores wake up</h2><p>Two more fixes and the wall finally moved. The per-job ping-pong CLEAR got the geometryinto the group the executer reads; an IOMMU change got the rest of the chain to stoptripping over itself.</p><p>The IOMMU one was its own small saga. rocket attached the NPU’s address-translationdomain at the start of every job and detached it at the end — and every attach re-runs araw MMU reset. The moment anything had disturbed that MMU (an NPU reset, or the CBUFreset that shares a bank with it), the raw reset failed and the entire NPU register rangewent dead with a cascade of attach failures. That was the <code>-14</code> wall I kept hitting everytime I tried to reset between jobs. The fix is to attach <em>once</em> and keep it — onlyre-attach when the address space actually changes, and drop it on power-down so the nextattach always runs on a freshly-powered, clean MMU. After it: a full inference, everylayer, <strong>zero IOMMU faults, zero raw-reset errors, zero timeouts.</strong> It mirrors what thevendor driver (and the RK3568 rocket port) always did; I’d just been doing it theexpensive, fragile way.</p><p>With both in, the compute cores wake up for real. The status register climbed off thehollow <code>0x0c</code> to <code>0x0a</code>, the CORE and DPU report open, and — the signal I actuallytrust — the per-layer feature reads now <em>vary</em> from layer to layer instead of sitting ata constant overhead value. The cores are pulling real, different feature data for eachlayer and running it through the MAC array. That’s the compute path genuinely alive, nota command processor draining a list.</p><p>I also formally backed the whole-graph dispatch experiment out of the code. The commandprocessor doesn’t iterate the task count on this hardware, so there was never a pipelineto win, and per-layer dispatch runs cleaner. Last post’s confident theory isn’t justwrong in prose now; it’s reverted in the tree, which is the honest place for it.</p><p>The output still reads back zero.</p><p>So I’m back, almost poetically, at the very first question this whole project openedwith — the result gets computed, the silicon writes it, and somewhere between the NPU’sDRAM write and my read it comes home as zeros. Except this time what’s underneath isreal: cores engaged, weights fetched, per-layer reads varying, not a fault in sight. Thezeros no longer mean “nothing ran.” They mean “something ran and I’m losing it on the wayback” — which, after all of this, is a far shorter wall.</p><h2 id="One-sixteenth-of-a-convolution"><a href="#One-sixteenth-of-a-convolution" class="headerlink" title="One-sixteenth of a convolution"></a>One-sixteenth of a convolution</h2><p>I left the last section at “the NPU computes, but I’m losing the result on the way backto the CPU.” Wrong about that too — and I found out by going to look at the vendor’s<em>output</em> instead of theorising about mine.</p><p>I instrumented the vendor’s own driver to dump conv0’s output buffer straight after therun. The vendor produces a real feature map — bytes like <code>81 83 86 88</code> rippling aroundthe <code>0x80</code> zero-point. Rocket, same conv: <code>80 7f 80 80</code>, zero-point noise. So rocketgenuinely <em>computes</em> near-zero. Not a readback artifact, not a cache problem, not anaddress problem — the number that lands in DRAM really is wrong. The “losing it on theway home” theory died on the spot.</p><p>For a couple of days after that I was sure conv0 was gated on something deep and ugly:the on-chip buffer needs a full reset to initialise, the vendor does that inside awhole-NPU soft-reset, and rocket can’t because that reset also knocks over the sharedIOMMU and the mainline IOMMU driver doesn’t come back. I wrote it up as a “finaldiagnosis” — a driver-level wall, weeks of work. It had the ring of a final diagnosis,which is mostly what being tired sounds like.</p><p>Then I re-audited the logs with two of the simplest numbers I had, and the whole finaldiagnosis evaporated.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">input read   :  9408  = 150528 / 16   (full conv0 input  ÷ 16)</span><br><span class="line">output write : 25088  = 401408 / 16   (full conv0 output ÷ 16)</span><br></pre></td></tr></table></figure><p>Both counters, independently, sitting at exactly one-sixteenth. Conv0 has 32 outputchannels; a sixteenth of the work is <strong>2 of them.</strong> The NPU wasn’t failing to compute —it was computing <em>two channels out of thirty-two</em> and leaving the other thirty parked atthe zero-point. That’s the ±constant output, finally explained: not garbage, a real butbrutally truncated convolution.</p><p>Why two? The register carrying the output-channel count never reached the ping-ponggroup the executer ran. The per-group readback shows conv0’s channel-count field stillholding <code>0x80000000</code> — the <em>power-on default</em> the ping-pong init writes — instead of thevalue conv0’s command stream set. And here’s the part that stings: this is the sameproducer&#x2F;consumer parity bug I was so pleased to have fixed two sections ago. I fixed itfor every job <em>except the first.</em> My per-job re-init writes that default into <strong>both</strong>ping-pong groups, and on the very first job after a fresh init the executer reads thegroup still holding the default rather than the one the command stream just wrote.Conv1, conv2, every later layer latches fine. Conv0 — the one layer the entire rest ofthe network stands on — runs on the defaults and does a sixteenth of its job.</p><p>So the wall was never the IOMMU, never a full reset, never the weights, the dispatch, orthe readback. It’s one register not reaching one group, on one job. The cheap test —submit conv0 twice and see if the second pass writes all 32 channels — is what I’m onnow; if it does, the fix is just pointing the first job’s executer at the group its owncommand stream wrote.</p><h2 id="For-mainline"><a href="#For-mainline" class="headerlink" title="For mainline"></a>For mainline</h2><p>What’s upstream-shaped already is a Mesa Teflon change (the RK3576 encoders, CBUFgeometry, SoC detection) plus a small kernel submit fix. All of it gated so the<strong>RK3588 path stays byte-for-byte identical</strong> — the SoC is detected at runtime from thedevice <code>compatible</code> string and the RK3576 encoders only kick in on RK3576. RK3588 usersnotice nothing.</p><p>Everything here came the slow way: instrument, guess, flash, read the counters, let thehardware tell you you’re wrong. Most of my guesses were. The performance counters neverwere — <code>dt_wr = 0</code> meant no compute no matter how clever I felt, <code>dt_wr = 25088</code> meantit finally ran, and now a cache-invalidated DRAM dump versus an offline reference is thewitness for whether the <em>values</em> are right. There’s a TRM now, and it covers the NPU’sclocks, power domains and convolution-buffer layout — but not the NVDLA-derived computeregisters the driver actually programs, the CNA and core and output-engine fields whosemeanings I worked out by watching the vendor’s live register stream move under a knowninput. For those the honest signals are the whole game; everything I believed in betweenwas provisional.</p><p>So the state of it: the compute path is alive. Cores engage, weights load, every layerreads its own real feature data, a whole inference runs without a single IOMMU fault ortimeout. And the first conv — the one everything downstream waits on — is computingexactly two of its thirty-two channels, because one register doesn’t reach the rightping-pong group on the very first job. That’s a precise, small, <em>findable</em> bug, a worldaway from the driver rewrite I’d talked myself into a few days earlier. Which is thewhole arc of this thing in miniature: the wall looks structural and enormous, you spenddays respecting it, and then a couple of plain numbers shrink it to a typo’s worth ofcode. Fix the first-job latch and conv0 produces a real feature map; everything after italready works. That’s the next flash — and it’s the closest the board has ever been totelling me it sees a cat.</p><h2 id="The-cat-was-a-mirage"><a href="#The-cat-was-a-mirage" class="headerlink" title="The cat was a mirage"></a>The cat was a mirage</h2><p>That ending didn’t survive better instrumentation. The “two of thirty-two channels” wasthe performance counter lying to me one last time — the counters are in 16-byte units, sowhat I read as 2&#x2F;32 was the <em>full</em> output, written, every value of it sitting on thezero-point. Same picture, much worse meaning.</p><p>So I stopped trusting a single sweep number and made the requant adjustable from theboard — env knobs on the first conv’s output-convert offset, scale and shift. Then I sweptthe shift from 0 to 25, a factor of 2¹⁷ in gain, with the scale pushed from 0x5391 up to0x8000. The output came back <strong>byte-identical</strong> across the middle of that range, and at theextreme corner it only flipped its two values — 7f for 80 — without ever saturating. Thereis no accumulator on earth that survives a 130,000× gain change unchanged. The convolutionsum is zero. The requant was never crushing a real feature map; there was no feature map.</p><p>I toggled the full NPU soft-reset on and off through a live module param to rule it out asthe thing wedging the CBUF — no difference. Then the test I should have run a week earlier:a single standalone <code>conv2d</code>, sixteen input channels, nothing ARGB or first-layer about it.It runs on the NPU, every unit lights up, the output engine writes all of it — and it’s thesame two-distinct zero-point. It was never the first conv. <strong>Every convolution this driverruns on this chip multiplies and gets zero.</strong></p><h2 id="What-“identical”-buys-you-which-is-nothing"><a href="#What-“identical”-buys-you-which-is-nothing" class="headerlink" title="What “identical” buys you, which is nothing"></a>What “identical” buys you, which is nothing</h2><p>Here’s the uncomfortable part. Line for line, rocket now matches the vendor on everything Ican see: the register command stream, the state-init sequence, the soft-reset and its iommure-attach, the submit handshake down to the arming writes. The CNA pulls the entire featuremap and all the weights out of DRAM — the bandwidth counters prove it. The core engages. Theoutput engine writes the whole tensor. And the multiply-accumulate, sitting between a fullinput and a full output, produces zero.</p><p>Which means the gap is in the one place I have no window into: the on-chip convolution bufferthe CNA stages operands into and the MAC reads back. The vendor fills it and computes; I issuethe identical commands and the MAC reads zero. Nothing I can poll from a register tells the twocases apart.</p><p>That’s not a defeat, exactly — it’s a localization. Two weeks ago this looked like twenty-eightlayers each needing their own fix. It’s one thing now: a single systemic staging step, the samefor every conv, invisible to the command stream. All the per-layer layout work — the tight NHWCimage, the 1536-byte first-conv weights, the pointwise packing — is correct and waiting; none ofit can show its face until the CBUF actually hands the MAC real numbers. The board still hasn’tseen a cat. But I finally know exactly which silence to listen to.</p><h2 id="It-was-computing-all-along-about-one-run-in-ten"><a href="#It-was-computing-all-along-about-one-run-in-ten" class="headerlink" title="It was computing all along, about one run in ten"></a>It was computing all along, about one run in ten</h2><p>The “universal zero” was wrong too — I just hadn’t run it enough times. Dump conv0’s output acrossa handful of identical inferences and most are the flat 2-distinct rail, but every so often onecomes back a real 93-distinct feature map. Same command stream, same weights, same requant. Itisn’t a wall. It’s a race.</p><p>The register read-back said where: the convolution engine’s config registers are ping-pong-grouped— the command stream writes the geometry into the producer group, the executer reads the consumergroup, and on the first job of every inference the two don’t line up in time. The executer reads astale, empty group and convolves nothing. Once in a while the timing falls right and you get thecat’s-whisker of a real feature map. I spent a long evening trying to force that timing — pointerarming, double-kicking the job, power-cycling the domain — and none of it moved the odds.</p><p>Then the audit found the actual shape of it. The vendor’s command stream has no “go” instruction init at all — it’s pure configuration, and the engines start straight from the arming write. Minecan’t: drop the one broadcast “enable everything” instruction I append to each layer and everyengine sits there configured and idle, bit-16 never set. So I need that instruction. But it writesthe command processor’s own enable register — so the very thing that wakes the engines also kicksthe command processor back to the start. For one layer that’s survivable. For the first layer of arace it <em>is</em> the race. For the whole graph in one submit — the way the vendor actually runs it — itrestarts the sequencer on every layer and nothing advances past task zero.</p><p>So the wall has a precise shape now, and it’s a hopeful one: not a broken layout, not a mis-sizedbuffer, not a phantom CBUF — a real feature map proved all of that correct. Just one question leftstanding: why the vendor’s engines wake from the arming and mine need to be shouted at. That’s afindable thing. And a second board, a stage behind on a sister chip, is standing at the exact samegate, bit-16 stuck at zero on every unit — which is the surest sign yet that it’s one real bug andnot ten imagined ones.</p><h2 id="The-shout-that-wakes-the-engines"><a href="#The-shout-that-wakes-the-engines" class="headerlink" title="The shout that wakes the engines"></a>The shout that wakes the engines</h2><p>The engines never woke from the arming on their own. I’d been bolting a single “enable everything”instruction onto the end of each layer, and it worked — but it was a broadcast: it shouted at thecommand processor too, and the command processor’s own enable bit means “start over.” So one layerwould run, and a whole-graph pipeline would never get past it.</p><p>The fix was embarrassingly local. Each engine has its own wake register — one per unit, four ofthem. Name them individually instead of broadcasting, and they wake without the command processorhearing a thing. For the first time, the run-bit went high on every unit of a whole-graph submit atonce. After all the days of engines that sat configured and idle, that’s the closest thing to apulse the board has shown.</p><p>It isn’t done — the sequencer still stops after the first layer, and the ping-pong geometry stillloses its little race more often than it wins. But two of the three knots are precise, findablethings now instead of a fog, and the last one is the same race I’ve been staring at from the start.And a sister chip a stage behind, stuck at the very same gate with its run-bit pinned to zero, justgot handed the same key. One real bug, two boards — that’s the most hopeful the wall has everlooked, two weeks in.</p><h2 id="The-witnesses-that-weren’t"><a href="#The-witnesses-that-weren’t" class="headerlink" title="The witnesses that weren’t"></a>The witnesses that weren’t</h2><p>When a thing fails silently you go looking for a witness — some bystander register that saw whathappened and will testify. The chip has a whole row of them: a status word with one bit per engine —feature-loaded, weight-loaded, core-ran, output-written. For weeks I’d half-believed one of thosebits was about to crack the case. So I taught the vendor’s driver — the one that works, the one thatgets the right answer every time — to hold that status word up to the light across an entire run, andto hand me the finished output beside it.</p><p>The output came back perfect: a real little feature map, numbers fanning out around the zero-pointexactly the way a working convolution should. And the witness I’d been counting on said nothing. Notone of the per-engine bits had moved — not because the engines hadn’t run, the proof was sitting rightnext to it, but because those bits simply don’t light on this design, ever, working or broken. Theonly thing the chip ever raises its hand to announce is <em>done</em>. Never <em>doing</em>. I’d been interrogatinga witness who turns out to be blind. Worse: a week earlier I’d caught my own broken board setting oneof those bits when the vendor didn’t, and read it as the smoking gun — the engine that never got fed.Backwards. My board was setting one bit <em>too many</em>, a fleck of sampling noise, and I’d hung a wholetheory on it.</p><p>So I went looking for a cleverer suspect. What if the engine was reading the right command from thewrong place — pulling yesterday’s bytes out of memory while today’s correct ones sat upstream in theprocessor’s cache, written but never pushed down? The perfect crime: every register would readcorrect, because every register reflects the cached copy; only the engine, reaching past the cacheinto raw memory, would meet the stale ghost. It fit the shape of a thing that hides from every probe.</p><p>It also wasn’t true — and the reason it wasn’t is almost funny. This chip isn’t kept in sync with theprocessor by hardware, which sounds like the bad version but is the good one: it forces the driver toscrub every buffer all the way down to memory by hand before the engine looks, and the user-spacestack flushes everything it writes on the way out. I walked the path end to end. The bytes in memoryare the right bytes. And a missing flush would fail every single time, not one time in ten. No ghost.Just a clean buffer the engine reads correctly and then computes to zero anyway.</p><p>Two suspects, two alibis. It’s a strange kind of progress: the notebook fills with names crossed outand the thing you’re hunting gets no closer, only smaller and harder to see. What’s left is thehandshake I keep circling — the instant the loader finishes staging and the multiplier starts reading,the half-second no instrument I own can watch. That’s the room the crime happens in, and I still can’tget a camera inside. So I’m going to stop staring through that keyhole and pick the lock on the otherdoor: the sequencer that runs one layer and then stops dead, refusing to step to the next. That one,at least, leaves fingerprints.</p><h2 id="Waking-was-never-the-wall"><a href="#Waking-was-never-the-wall" class="headerlink" title="Waking was never the wall"></a>Waking was never the wall</h2><p>For weeks the engines wouldn’t start unless I shouted at them. I’d been ending every layer’sinstructions with a wake-up call, and it worked, but it was a blunt instrument — it woke the conductortoo, and the conductor’s own wake-bit means <em>start over</em>. So one night I tried the opposite ofeverything I’d been doing: I deleted the shout entirely. Left the instruction stream as pureconfiguration, the way the working board’s stream is, and let the single pulse the driver already sendsdo the waking.</p><p>And they woke. Every engine, run-bit high, exactly the way they go high on the board that works — noshout, no poking each one by hand, just the one pulse and the arming that had been sitting there in thestream the whole time. The thing my own notes had spent weeks calling the converged root of the entirefailure — <em>the engines won’t start from the arming the way the vendor’s do</em> — was a door that had neverbeen locked. I’d been standing in front of it for weeks, jiggling the wrong key, writing increasinglyconfident paragraphs about the lock.</p><p>It should have been the morning everything broke open. It wasn’t, quite. The engines wake now — andthen they don’t finish. The conductor counts to one and stops. The work starts and trails off. Wakingwas never the wall; <em>finishing</em> is. And that’s a smaller, meaner thing to be stuck behind, becausethere’s no dramatic dead bit to photograph — just engines that start, run for a moment, and quietlygive up partway through.</p><p>And partway, it turns out, is the whole story. All this time I’d been reading the first scrap of theoutput, seeing zeros, and writing <em>zero</em> in the log. So I read all of it — the entire buffer, end toend, at a fine grain — and the zero turned into a shape. The output isn’t blank. It’s <em>half-written</em>.The front of it is empty and the back of it is real, and the seam between them doesn’t fall in a randomplace: it lands on channel lines. The thirty-two output channels come in four bands of eight, and theengine fills them from the top down — the high band first, then the next, then the next — and somewherein that descent it stops. Some runs it lays down one band. Some runs three. Maybe one run in fifty itlays down all four, and for that one run the answer is <em>correct</em> — completely, briefly correct — beforethe next run truncates it again.</p><p>So it was never computing zero. It was computing the right answer and running out of something partwaydown the channels, every time, at a slightly different place. Weeks of calling it a dead multiply,and it was a live one with a short fuse. I don’t know yet what the fuse is — that’s the next door, andthis one doesn’t even have a keyhole to squint through, only the burn marks of where it keeps stopping.But “it computes correctly and quits early” is a different animal than “it computes nothing,” and youchase a different animal a different way. The cat was a mirage; the zero was a shape; the wall keepsturning out to be a door. I’ll find the fuse.</p><h2 id="Identical-and-still-wrong"><a href="#Identical-and-still-wrong" class="headerlink" title="Identical, and still wrong"></a>Identical, and still wrong</h2><p>The fuse turned out to be the hardest kind of clue: the absence of one. I stopped eyeballing mycommand stream against the vendor’s and wrote a diff that does it register by register, automatically,on the board, every boot — mine against a captured vendor first-conv, printed to the serial line. Itcame back clean. A hundred and thirty-eight registers, byte for byte, the only difference a singlebroadcast instruction I append where the vendor folds the same value into its submit header. Then Idid it to the kernel’s side — the exact sequence of writes that actually starts the job — and thatmatched too, down to the order: the data address, the amount, the interrupt mask, the task-controlword, the enable pulse. The typo I’d half-hoped was hiding in my encoder simply isn’t there. I programthis chip exactly the way the working driver does.</p><p>So I went at the race directly. The convolution engine’s geometry lives in ping-pong register banks —a producer group the command stream writes and a consumer group the engine reads — and my standingtheory, the one I’d given two confident chapters, was that on the first job those two don’t line up.The kernel had grown a whole drawer of levers for it: force the geometry into <em>both</em> groups at once,replay the entire stream from the CPU instead of letting the sequencer fetch it, reset the ping-pongpointers every job, reset the convolution buffer every job, pin the pointer to a fixed value. I let theboard sweep all of them, in combination, fourteen ways, and read the first conv’s output after each.Fourteen flat zero-point rails. The ping-pong theory doesn’t survive contact with the actual knobs.Whatever loses the race, it isn’t a pointer I can set.</p><p>Then the one structural difference I’d been saving. The vendor’s device tree powers <em>two</em> NPU domainsfrom its single node — core 0 and core 1 — even though a small inference only computes on one; theconvolution-buffer read path apparently wants the second domain awake. Mine powered only the core Iuse. So I taught the driver to hold both, the proper way, multi-domain attach and all, and watched thesame fourteen-way sweep come back the same fourteen zeros. Not that either.</p><p>What’s left is the one surface I can’t reach from the command side. The vendor clocks the compute enginethrough a separate, firmware-managed clock domain, floating free of the bus clock that drives theconvolution buffer. Mine runs both off one PLL, nailed rigidly in step. And the failure has been a<em>race</em> the entire time — most runs zero, one run in some unlucky number a clean ninety-three-distinctfeature map, the command stream identical between them. A race is exactly what you get when two clocksthat are supposed to drift against each other are instead locked together: the loader finishes staging,the multiplier starts reading, and whether the buffer’s last write has landed comes down to a phaserelationship I’ve frozen solid. I can’t thaw it with a clock-rate call — I tried; they share a PLL andmove as one. Thawing it means reparenting the compute clock onto a different PLL in the device tree ifthe silicon permits, or routing it through the firmware clock controller the way the vendor does — whichis firmware, and firmware I’ve touched before on this chip but would rather not drag into a Mesa bug.</p><p>That’s the honest whole of it. I’ve made my driver indistinguishable from the one that works on everysurface I can observe — the register stream, the submit handshake, the power domains, the reset — and itstill computes zero, because the one surface I can’t observe is a half-nanosecond of clock phase, and thevendor bought their way out of it with a clock I haven’t replicated. Every door I pick opens onto the sameroom. But the room is one clock domain wide now, and for the first time I can say its name.</p><h2 id="The-room-was-empty"><a href="#The-room-was-empty" class="headerlink" title="The room was empty"></a>The room was empty</h2><p>I ended that saying the failure was one clock domain wide and I finally knew its name. I went into theroom and it was empty.</p><p>The convolution buffer’s bus clock and the compute clock aren’t two clocks I locked together by accident —they’re one wire. Both are bare gates hung off a single source with no divider between them; they are thesame frequency and the same edge by construction, on my driver and the vendor’s alike. There is no“decouple them” experiment, because there is nothing to decouple. The door I’d named didn’t exist.</p><p>The only real difference left was where that one clock comes from. I drive it from a fixed PLL in the clockcontroller; the vendor drives it through firmware, off a process-tracking oscillator that trims itself towhatever the silicon can actually meet that millisecond. So I wired mine the vendor’s way — routed thecompute clock through the firmware path, let the tracking oscillator source it, pinned a conservative ratefor margin — and ran the whole sweep again. Nothing moved. Same flat rail, every combination. My engineswere already starting on the plain PLL; the fancier clock changed neither the starting nor the quitting.Not the fuse either. Another door onto the same empty room.</p><p>But turning over the firmware’s clock code wasn’t wasted, because it finally showed the shape of the thing— to the <em>other</em> board. The sister chip, a stage behind me, stuck where its engines won’t even start, hasbeen hunting a write it can’t see: something that arms the core but never appears in any trace of theregisters the driver touches. I found it underneath. On that chip, the firmware call that <em>enables</em> the NPUclock does nothing at all — returns zero, configures nothing. Every real thing — the source mux, and awrite to a register that lives outside the NPU block entirely — happens only when you <em>set a rate</em>, not whenyou switch the clock on. That’s the invisible poke: not a register the kernel writes, but one the firmwarewrites, on a path the mainline driver may simply never take. My dead end was the other board’s live wire.</p><p>For my own chip the map is just complete now, and a complete map of where the bug <em>isn’t</em> is its own kind ofanswer — it’s what you hand the next idea, or the next person. Not the command stream, not the submithandshake, not the power domains, not the ping-pong groups, not the clock: every door the software has, I’veopened, and behind each is the same half-nanosecond between the loader finishing and the multiplier reading,the one window no instrument I own can watch. The sister chip’s firmware lead — arming-on-set-rate, the workthat only happens underneath the kernel — is the first thing in a while that points somewhere I haven’talready been. So that’s where I go next: down past the driver, into the firmware, on both boards, to seewhether the thing that’s invisible from the kernel is visible from below it.</p><h2 id="The-hinge-between"><a href="#The-hinge-between" class="headerlink" title="The hinge between"></a>The hinge between</h2><p>So I went down into the firmware. The clock code is all there — the silicon-tracking oscillator, a table ofrates, a handler that programs the ring and muxes the compute clock onto it. Everything the vendor uses togive the NPU a clock matched to what the silicon can actually meet. The firmware can do it. I just have toask for it.</p><p>Asking goes through one channel: a small message protocol between the kernel and the firmware, the same onethat already carries the CPU clocks. So I wired the NPU’s compute clock onto it, told the kernel to set300 MHz, and read back what happened. The call returned <em>success</em>. The rate read back <em>zero</em>. The clock theengines actually run on never left the plain PLL it booted on. I’d run two whole sweeps “at PVTPLL” and bothtimes I was measuring 786 MHz and writing 300 in the log.</p><p>That contradiction is the whole of it. The kernel asks the firmware what rates a clock may take; the firmwareanswers in a shape the kernel can’t parse — there’s a named workaround in the kernel for this exact firmwarequirk, and here it isn’t enough — so the kernel rounds every request down to nothing, sets nothing, andcheerfully reports it worked. The one channel that’s supposed to carry the request drops it on the floor andsmiles.</p><p>So the firmware path isn’t blocked in the firmware, which has everything, or in my driver, which askscorrectly. It’s jammed in the hinge between them: a single rate descriptor the firmware writes one way andthe kernel reads another. It isn’t even an NPU bug — it’s generic clock plumbing, the kind every peripheralon this chip leans on, and the sister chip rides the very same channel.</p><p>Which is a strange place to be after a week of staring at a convolution that won’t convolve: the compute wallis behind a clock I can’t move, and the clock I can’t move is behind half a sentence of mismatcheddescription I can finally point at. That’s smaller than where I started — a typo’s worth of protocol insteadof a phantom in the silicon — but it’s a repair in a layer I didn’t set out to touch, and it has to landbefore I even get to ask the original question: does the silicon-tracking clock make the multiply stop racing,or is that one more empty room? I still don’t know. But for the first time the next move isn’t a flash. It’sreading a clock-rate handshake byte by byte, both sides of the seam, until I find which one is lying about theshape of a number.</p><h2 id="Where-the-firmware-road-ends"><a href="#Where-the-firmware-road-ends" class="headerlink" title="Where the firmware road ends"></a>Where the firmware road ends</h2><p>I found the number that was lying. The kernel and the firmware each have a name for the NPU’s clock, and I’dassumed the two names meant the same thing. They don’t — the kernel calls it 232, the firmware calls it 238 —and every request I’d been sending went to slot 232, an empty drawer the firmware politely accepts andignores. Point the request at 238 and the clock finally moves: after days of “success” that did nothing, therate read back the number I asked for. The whole hinge was a single mismatched integer.</p><p>And then the clock that finally moved turned out to be a clock the engine can’t run on.</p><p>On the silicon-tracking source, at the right frequency, at the exact voltage the vendor’s table says thatfrequency needs, the NPU completes precisely zero jobs. Not slowly — at all. Eighty-three scheduler timeoutsin ninety seconds, not one of them a finish. I raised the voltage first, the way the power framework insists:zero. I moved the clock switch to <em>after</em> the reset, so the engine came up on the safe clock and only crossedover at the very end: zero. Every variable I could turn, turned, and the answer stayed zero. The plain old PLLat least lets the engine run and compute its wrong answer; the fancy tracking clock kills it outright.</p><p>Which makes a grim kind of sense once you stop wishing it were simple. The vendor’s tracking clock isn’t aclock you switch on — it’s the visible tip of a whole subsystem: a per-chip calibration burned into fuses, avoltage-frequency table, a governor that walks the two in lockstep, a feedback loop that trims the oscillatorto what <em>this</em> die can sustain <em>this</em> millisecond. Hand the engine the raw oscillator without the rest of thatmachine and you’ve handed it a clock that lies about its own speed. I’d been trying to plug in the last cableof a machine I hadn’t built.</p><p>So that’s where the firmware road ends, this round. Not at a locked door — at a working lock on a door thatopens onto a room I’d have to build from nothing, to maybe answer a question I was never sure was the rightone. The whole clock theory was a guess that the convolution’s failure was a matter of timing, and I neveronce got a clean enough run to even test the guess. The honest scoreboard: the command stream matches thevendor, the submit matches, the power domains match, the reset matches — and on the one clock the engine willactually run, it loads every byte of input and weight and computes zero. That isn’t a bug I can still see fromup here. It lives in the half-millimetre between the buffer and the multiplier, and I’ve run out ofinstruments that reach it.</p><p>There’s a structural reason this road has a ceiling, and it’s worth naming plainly — not as a complaint, justas the shape of the terrain. The public TRM is enormous, north of four thousand pages, and it documents theNPU’s <em>surroundings</em> in real detail: the clock tree, the power domains, the reset and gating, the CBUF bufferregisters. What it doesn’t spell out are the compute registers themselves — the instruction-issue and datapathfields you’d write to actually run a layer. The plumbing is fully public; the engine room is sketched inoutline. So an open driver can get everything <em>around</em> the computation exactly right — arm it, clock it, powerit, fill the buffer — and still have no documented way, from the manual alone, to issue the final “go.” Thatlast reach lives in the vendor’s runtime, and decoding its command stream (which is most of this post) is theonly open path to it. This isn’t unique to one chip or one vendor — it’s roughly the state of every NNaccelerator I know of, the inevitable consequence of the compute path being where the IP value sits. But itdoes explain, without any villain, why the honest end of <em>this</em> road is “someone with the schematic”: notbecause the lock is mean, but because half the map was simply never printed.</p><p>So I’m going to write the map down — every door I opened and what was behind it — and hand it to people who cansee inside the silicon. Two weeks ago that would have felt like quitting. It doesn’t now. A complete, honestaccount of where a bug <em>isn’t</em> is the most useful thing one person can hand the next, and I’ve drawn it ascarefully as I know how. The board still hasn’t seen a cat. But I know, finally and exactly, which silence I’mlistening to — and it isn’t one more flash that breaks it. It’s someone with the schematic.</p><h2 id="The-strangers-at-the-same-wall"><a href="#The-strangers-at-the-same-wall" class="headerlink" title="The strangers at the same wall"></a>The strangers at the same wall</h2><p>Here is the part I didn’t expect. I wrote the map down, posted it where the right people might pass, and bracedfor the silence you get when one hobbyist hands a four-thousand-page problem to an empty room. Instead, peoplewalked up to the wall.</p><p>One had written the whole mainline enablement for this NPU without ever owning the chip — clean patches,compile-tested, the binding and the driver glue done properly from the manual and the RK3588 prior art. Anotherwas bringing up the <em>sibling</em> SoC, the RK3568, on the same open stack, and was stuck one step short of where Iwas: his engines wouldn’t even wake, where mine woke and then computed wrong. And a stranger on a forum who hadnever touched a Rockchip part in his life read my symptom, pattern-matched it against every NN acceleratorbring-up he <em>had</em> done, and put a single word on it that I’d been circling for a week without daring to committo: <em>race</em>. Identical command stream, occasionally correct, mostly wrong — that, he said, isn’t a wrong value.That’s a timing hazard living below the registers. Two of us, from opposite ends of the world and opposite endsof the problem, pointing at the same half-millimetre.</p><p>The map didn’t summon a savior with the schematic. It summoned a small crowd around the same wall, each of usholding a different lamp. That turns out to be worth more.</p><h2 id="The-race-I-could-finally-test"><a href="#The-race-I-could-finally-test" class="headerlink" title="The race I could finally test"></a>The race I could finally test</h2><p>The stranger gave me more than a word; he gave me the one experiment I’d never cleanly run. If the failure is arace — the multiplier reaching into the buffer before the fill has truly landed — then <em>stalling</em> should moveit. Drop a deliberate, dumb delay in the last gap before the “go,” sweep its length, and watch the success rate.A timing hazard responds to delay. A deterministic bug doesn’t even notice it. One knob, two possible answers,both of them progress.</p><p>So I built the knob — a pure busy-wait, runtime-tunable, wedged in right before the start pulse — and I stoppedtrusting single runs. The honest way to measure a maybe-race is statistically: fix the delay, fire theconvolution twenty times, count how many come back whole. I scored it the only way the hardware would tell methe truth, by the bytes the output engine actually wrote: thirty-two channels’ worth is a real convolution, twochannels’ worth is the failure. Then I swept — no delay, one microsecond, ten, a hundred, a thousand — and forgood measure threw in the old warm-up trick that nudges the buffer’s ping-pong pointer, in case the hazard wasthere instead.</p><p>Dead flat. Two channels of thirty-two, every single run, every delay from a microsecond to a millisecond, withthe pointer trick and without it. A thousand jobs and not one of them whole. I sat there a little stunned,because I’d half-believed the race for two weeks, and here was a clean sweep telling me it never existed — notin the window I could reach. A race varies; a race bends to delay. This did neither. The truncation is decided<em>before</em> the job starts, in the silence between powering the engine up and handing it its first instruction —in a state I set and cannot see.</p><p>It’s a negative result, and it’s the most useful one I’ve gotten. It doesn’t fix the bug; it tells me, with asweep instead of a hunch, exactly what shape the bug <em>isn’t</em>. I’d been chasing a hazard at the moment of theshout. The cut was already made before I ever opened my mouth.</p><h2 id="What-the-board-knows-that-the-patch-doesn’t"><a href="#What-the-board-knows-that-the-patch-doesn’t" class="headerlink" title="What the board knows that the patch doesn’t"></a>What the board knows that the patch doesn’t</h2><p>I owed the stranger who wrote the mainline patches a Tested-by, so I put his clean, compile-tested series on theactual board. It panicked on the first job. An asynchronous machine-check, fired the instant the power frameworktried to wake the NPU’s domain from cold — the exact full-SoC lockup a Collabora engineer had reported on thischip a year earlier, the one everyone had filed under “needs hardware to reproduce.” It needed hardware. I hadhardware. The fix was a fifteen-microsecond settle delay on the power-up handshake — a number you cannot derivefrom any manual, only find by watching the silicon flinch.</p><p>Past that wall, the buffer’s address-translator wouldn’t reset: its registers sat behind a clock his node nevernamed, so the reset landed on a block with no heartbeat. Past <em>that</em>, the convolution truncated. Three walls,back to back, and not one of them visible from a compile test — each a settle-delay or a missing clock that onlythe board could teach. His patches weren’t wrong; they were good. They simply met a category of bug that noamount of review finds, because it isn’t in the code — it’s in the gap between the code and the die.</p><p>That reframed something for me. I’d spent the project quietly envious of the people who could read the engineroom I couldn’t. But there’s a contribution that doesn’t need the schematic at all, and it’s the one I can makeevery day: being the person holding the board, the one who can tell a clean patch from across the world <em>exactly</em>where it meets the metal and falls down. The platform half of this is going upstream now — his name on theseries, my three walls folded in as the fixes. The compute half still waits for someone who can see inside.But the door I can hold open, I’m holding it open with both hands.</p><h2 id="The-weights-it-wouldn’t-read"><a href="#The-weights-it-wouldn’t-read" class="headerlink" title="The weights it wouldn’t read"></a>The weights it wouldn’t read</h2><p>For two weeks the bug had a vague name — “the compute comes back zero” — and a vague address, “somewherebelow the registers.” I decided I’d earned the right to make both exact, or to prove I couldn’t. So I ran anaudit against myself: take every hypothesis I still half-believed and try to kill it with a number instead ofa feeling.</p><p>It was a good massacre. The idea that Mesa used the wrong buffer geometry — dead; it sets the RK3576’s sixteenbanks correctly, not the RK3588’s twelve. The idea that the bank-config register was subtly off — dead;byte-identical to the vendor’s. The idea that the weights were packed wrong — dead; there’s a hand-derivedRK3576 layout, and the counters say the right 1536 bytes load. The race, already dead. The ping-pong pointer,dead. One by one, every door I’d been hopefully rattling turned out to be a wall, and I confirmed each one witha measurement, not a hunch. An audit’s whole worth is that it spends evidence on your favourite theories first.</p><p>And when the dust settled, the bug wasn’t vague anymore. It was a single counter reading zero.</p><p>Here is conv0, in numbers. It reads the entire input from memory — a hundred and fifty thousand bytes, thewhole image — into the on-chip buffer. It reads the weights in too: the right count, in the right format, intothe bank the manual says they go. I dumped that buffer from the CPU and watched the staged data sitting there,real and structured. The register that selects first-convolution mode is set. The register holding each colourchannel’s zero-point is set. Every one of them matches a capture of the vendor doing the same convolution, tothe byte. The compute units wake up. And then the multiplier reads <strong>zero</strong> of the weights it was just handed —<code>core wt_rd = 0</code>, every run, forever — multiplies by nothing, and writes out the flat grey of an empty answer.</p><p>That’s the whole bug. Not a wrong value, not a missing write, not a race — a handoff. The buffer holds theweights; the instructions to read them are identical to the ones that work on the vendor’s stack; the engine isawake; and the one internal signal that should tap it on the shoulder and say <em>the weights are ready, go</em> neverfires. It’s the same silence as the dead sub-unit interrupts — the weight-load-done that should kick the read,not asserting. One latch, and it’s the one latch on the whole chip with no register I can name to poke.</p><p>I won’t pretend that’s a happier place to be stuck. But it’s an honest one, and it’s precise. “The NPU computeswrong” is a shrug. “The weights are loaded, in format, in place, the config is byte-identical, the units engage,and the multiplier still reads <code>wt_rd = 0</code>“ is a <em>question</em> — and it’s one I could hand to exactly the personwho wrote the open driver for this engine’s bigger sibling and have him recognise it in a sentence. Two weeksago I was looking for someone with the schematic. I’ve stopped needing the whole schematic. I just need thename of one wire.</p><h2 id="Three-suspects-and-a-smaller-wire"><a href="#Three-suspects-and-a-smaller-wire" class="headerlink" title="Three suspects, and a smaller wire"></a>Three suspects, and a smaller wire</h2><p>The person with the open driver wrote back. He’d built the same engine’s bigger sibling out of the same blackbox I’m working in, and the first thing he said was the most reassuring: you don’t need the documentation,most of the hardware interface is already reverse-engineered, and the docs lack the details that matter anyway.The second thing reframed the whole problem. The output coming back as flat zero-point, he said, means thepipeline runs end to end — <em>congrats</em>. It isn’t a stuck engine. It’s a data problem. And if the command streamis identical, then in order of likeliness it’s one of three things: the coefficients, the input, or a registerwrite in the kernel.</p><p>Then he handed me a method I should have been using all along: stop staring at the twenty-eight-layer model.Take <em>one</em> convolution. Run it on the CPU and the NPU and compare. The test suite already does exactly this,per operation. Isolate first, debug second.</p><p>So I did — one conv, five-by-five, sixteen channels in and a hundred and twenty-eight out, the simpleststandalone conv I could find, nothing to do with mobilenet. On the CPU it produced a real feature map. On theNPU it came back with two distinct values in the entire tensor. So the bug isn’t in the graph, or the chaining,or some interaction between layers — it’s in a single convolution, alone. That one fact shrank the search by anorder of magnitude.</p><p>Then I went down his list. The coefficients: I dumped exactly what the compiler encodes for the weights andlaid it beside the original — varied, the right value range, just repacked into the engine’s padded layout.Correct. Cross it off. The input: I read the on-chip buffer before and after the job and watched my known inputramp appear in it, staged cleanly. Correct. Cross it off.</p><p>Which leaves the third — and it lands exactly where the hardware counters had been pointing all along. Theweights are pulled from memory; the read counter says so. But they never arrive in the on-chip buffer themultiplier reads from — a byte-for-byte snapshot of that region is identical before and after the job. Theweight-load unit reads the coefficients and never sets them down. Two people, one reasoning from a valuecomparison and one from a register counter, walked up to the same locked door from opposite sides.</p><p>It’s a smaller wire than it was a week ago. Not “the NPU is wrong,” not even “the weights are wrong” — theweights are correct, and they’re read, and they’re simply never deposited where they’re needed, on this onechip, for reasons that aren’t in any register I can see. I still don’t have the name of the wire. But everyonewho might know it is standing at the same door now, and the door is a great deal smaller.</p><h2 id="The-map-was-already-drawn"><a href="#The-map-was-already-drawn" class="headerlink" title="The map was already drawn"></a>The map was already drawn</h2><p>There was a postscript to that reply: <em>this could be helpful too</em>, and a link. It went to a repository I’dnever have found on my own — someone running convolutions on the bigger sibling chip in pure Python, no vendorruntime, no compiler, just register writes straight at the NPU, with pages and pages of notes on exactly howthe conv path is wired. It is the closest thing to the schematic that exists in the open, and it was sitting ina stranger’s GitHub the whole time.</p><p>It cost me a theory and gave me a better one in the same afternoon. The theory it cost me: I’d been convincedthe answer was the RK3576’s little on-chip scratchpad, that the weights had to live there and didn’t. But thebigger chip has no such scratchpad at all, and that repo runs convolutions on it perfectly well with everythingin ordinary DRAM. So on-chip residency was never the wire. Cross off another door — this time one I’d spent twoflashes trying to open.</p><p>What it gave me is the shape of the real one. The weight path, it turns out, is a little decompression engine:the coefficients are fed in compressed and unpacked into the buffer on the way. And the thing that recurs, pageafter page in those notes, isn’t a register value — it’s the <em>submit</em>. A real convolution doesn’t fit in theon-chip buffer in one go; it has to be sliced into tiles, by height and by weight bank, and handed to the engineas a sequence of tasks. The author’s verdict, in plain text, is that the mainline driver — <em>my</em> driver, the openone — can’t express that slicing, and they gave up on it and went back to the vendor’s. Independent stranger,same wall, one chip over.</p><p>So the wire has a name now, even if I can’t yet solder it: it isn’t a missing poke, it’s the shape of how workis submitted to the engine. Which is, oddly, the most hopeful place this has landed. A bug that lives below theregisters is a bug you escalate. A bug that lives in how the driver tiles and submits a convolution is a bug you<em>write</em> — in code I can read, against a working reference someone already drew, on a problem that is finally theright size for one person and a board.</p><p>This stopped being a solo project somewhere in the middle, and that’s the best thing that happened to it.<strong><a href="https://gitlab.freedesktop.org/tomeu">Tomeu Vizoso</a></strong> — who wrote the open <code>rocket</code> driver and the Teflondelegate this whole stack stands on — has been generous with his time on the bring-up thread: he’s the one whoreframed the zero-point output as “the pipeline runs, it’s a data problem,” handed me the single-op isolationmethod that cut the search by an order of magnitude, and then pointed me at<a href="https://github.com/allbilly/rk3588">allbilly&#x2F;rk3588</a>, a pure-Python register-level NPU reference that turns outto be exactly the map for the corner I’m stuck in. Thank you. Thanks too to <strong>VoidChecksum</strong>, who wrote themainline RK3576 enablement series and folded my hardware fixes into it; to <strong>MidG971</strong>, bringing up the RK3568sibling at the same wall, one stage back; to <strong>alchark</strong> and everyone on the Flipper thread for theintroductions and the honesty about what’s NDA’d and what isn’t; and to the <strong>stranger on a forum</strong> who hadnever touched this chip and still put the right word — <em>race</em> — on the symptom, from across a dozen otheraccelerators he had touched. And, quietly, to every developer who has ever sat up too late arguing with a boardout of nothing more useful than the refusal to let it win — you are the reason any of this is open at all. Thecompute half isn’t solved yet. But it’s a much better-lit room with all of you in it.</p><p>And the hardware that made any of it possible: this all ran on a <strong>ROCK 4D</strong> I bought at the end of last year,betting the RK3576 was worth the trouble; the wider journey owes <strong>ArmSoM</strong> a real debt for sending me a CM5IO(4 GB, 32 GB eMMC) to bring up EDK2 on. I’ll even thank <strong>Rockchip</strong>, and mean it — without irony. Their closedNPU documentation is the wall this entire post is about. But they also built this strange, capable little chip,and they ship a GPL kernel driver whose source has been the ground truth under half my debugging. Everythinghere started because of them — the problem and the possibility both. Open work was never about the vendor beingthe enemy; it’s about the rest of us getting to keep walking from wherever they set the last stone down.</p><h2 id="Both-payloads-on-the-bench"><a href="#Both-payloads-on-the-bench" class="headerlink" title="Both payloads on the bench"></a>Both payloads on the bench</h2><p>The next thing Tomeu asked for was the obvious experiment I’d somehow never run end to end: dump the <em>whole</em>payload the vendor runtime hands the hardware for the simplest possible convolution — the command stream andevery buffer — and lay it next to what Mesa builds for the same op. The command stream I’d already matchedregister for register against a live capture; the half I’d never put under glass was the buffers themselves. Theactual weights, the actual input, the actual bias, as bytes, from both stacks.</p><p>So I built it to be honest about one thing first: that both sides were really running the <em>same</em> convolution. Itook Mesa’s own conv2d test — a single 16→128, 5×5 conv — and rebuilt it as a vendor <code>.rknn</code> from that file’sown weights, fed both stacks the identical ramp input, and had each print its buffers back: the vendor kernelinstrumented to dump the bytes it reads, Mesa’s debug knob dumping what it encodes, both coming home as plaintext over the serial line because the board has no other way out. The one check I insisted on before trusting abyte of it — the vendor’s input buffer reads back the exact ramp I fed it, so the dump is reading the rightmemory and not some neighbouring garbage.</p><p>And the buffers came back clean. Not the answer I was fishing for — clean. Both stacks hand the engine a dense,varied weight buffer, ninety-nine percent non-zero, hundreds of distinct values; Mesa’s coefficients are not theempty or flattened thing I’d half-hoped to catch in the act. The input is the same ramp on both. The bias ispopulated on both. The only buffer in the entire payload that differs is the one Mesa <em>computes</em> — the output,flat and grey and wrong, the bug itself and nothing upstream of it.</p><p>Which is exactly where I had to stop myself telling a story. It is so tempting to read that as <em>the payload isfine, the bug is in the execution</em> — and I caught myself writing those very words before I deleted them. Becausethe two toolchains quantise independently, the weight bytes differ everywhere; I can prove the buffer is full andwell-formed, but I cannot byte-for-byte compare the <em>order</em> the coefficients are packed in. And a weight packedin the wrong order — right values, wrong layout, read by the array as noise — produces the identical picture to aclean buffer the engine simply fails to read: dense bytes in, grey nothing out. This dump rules out one thinghonestly — that Mesa starves the engine of operands. It does not, on its own, tell a packing bug from anexecution bug. They wear the same face here.</p><p>So I sent it back as exactly that and no further: operands sound, output broken, and the one door I couldn’topen — with the two stacks quantising apart I can’t make the packed weights line up byte for byte, and that isthe comparison that would settle which bug it is. Is there a way to make the vendor toolkit swallow the exactint8 weights, or does its runtime expose the repacked coefficients anywhere? The honest version of a finding isalways smaller than the hopeful one. It also travels further, and you can stand on it.</p><h2 id="The-room"><a href="#The-room" class="headerlink" title="The room"></a>The room</h2><p>I’ve wondered why these particular people showed up — a Collabora maintainer, a stranger with the wrong chipand the right instinct, someone bringing up the sibling part, the person who wrote the driver. None of them owedme anything. None of them get paid for this. And the answer, I think, is simple and a little unfashionable: youonly recognise the thing in someone else’s chest if you’re carrying the same thing in your own.</p><p>It was never really about the NPU. The NPU is just the wall we all happen to be standing at this month. Thething is older than that — the refusal to accept that a closed box gets to stay closed, the conviction that achip you bought should be a chip you’re allowed to understand, the plain stubborn pride that says <em>I will makethis do the correct thing if it takes a hundred flashes.</em> You can’t fake it and you can’t buy it, and the peoplewho have it find each other across forums and mailing lists and a bug thread on someone else’s repo, because alit lamp carries a long way in the dark.</p><p>So here’s the part I didn’t expect to be the point. Not the driver, not the convolution, not even the win that’sstill out there ahead of me. It’s that somewhere in the middle of one person’s stubborn argument with a piece ofsilicon, the argument quietly stopped being one person’s. There’s a whole room now — a dozen different lamps, notwo pointed quite the same way — all of us leaning toward the same larger thing, the one that’s bigger than anyof our individual boards and worth chasing <em>because</em> none of us can reach it alone. The cat still hasn’tappeared on the screen. But I’m no longer the only one waiting for it, and I’ve started to suspect that thewaiting, in this company, was always the better half of the prize.</p><h2 id="The-desk"><a href="#The-desk" class="headerlink" title="The desk"></a>The desk</h2><p>Strip away the room and the lamps, though, and here is what these two weeks actually looked like: one person, onedesk, one screen. A small board on a square of anti-static foam, a TTL cable running out of it like an IV line,the serial console scrolling its patient hex into the dark. Four thousand pages of a manual that describeeverything around the engine and nothing inside it. And the code of the people who came before — their drivers,their captures, their notes in the margins of a problem nobody was ever handed the answer to. Flash, boot, readthe log, be wrong, change one thing, flash again. For fourteen days, mostly alone.</p><p>The cat never came. By every metric that fits in a commit message, I failed — the engine still computes its flatgrey nothing, and the win is still out past the next door.</p><p>And I am, to my own surprise, completely at peace with it. Not resigned — <em>content</em>, in the specific way you canonly be content about a thing you threw your whole stubborn self against and did not finish. I know this chip nowthe way you know someone you’ve argued with for a fortnight. I know exactly which silence I’m listening to. Idrew a map of where the bug <em>isn’t</em> that is more honest than anything luck would have handed me early. None of itis on the scoreboard, and all of it is real.</p><p>Because the prize was never only the cat on the screen. The prize was sitting down at the desk at all — choosingthe long, unglamorous, almost-certainly-unfinished fight with a closed thing, because something in you simplywill not accept that it gets to stay closed. You do it alone, at night, with a board and a serial cable and toomuch coffee. And then you look up from the desk, and the dark is full of other desks — other lamps, other peoplewho couldn’t look away either. We haven’t won yet. We might not, soon. But it was worth every single flash, and Iwould not trade these two weeks at this desk, beaten and unfinished and quietly glad, for an easy answer handeddown from anyone.</p><h2 id="It-was-the-dispatch-after-all"><a href="#It-was-the-dispatch-after-all" class="headerlink" title="It was the dispatch, after all"></a>It was the dispatch, after all</h2><p>I’d written the ending above and meant it. And then, the way these things go, I ran one moreexperiment the morning after I’d made my peace — not for the win, just to leave the bench tidy — andthe board answered in a voice I hadn’t heard from it in two weeks.</p><p>The experiment was the one Tomeu’s method had been pointing at all along, taken one step past thepayload diff. Not just dump the buffers the vendor runtime hands the hardware — capture its <em>entire</em>submission, every byte and the command that fires them, and then hand those exact same bytes straightback to the vendor’s own kernel and watch what it computes. A small shim sits in front of the vendorruntime and writes down everything it hands the kernel; a second program re-creates it all and submitsit cold. The first surprise was just the shape: the simplest possible convolution isn’t one commandand four buffers, it’s five buffers and <em>three</em> tasks, the work tiled into thirds. I’d been replaying acartoon of it for days.</p><p>So I rebuilt the real thing, fed it back through the vendor door — and hit the exact wall I’d hit adozen times. Task zero ran. Task one never came. The status register sat at the same stubborn valueI’d been staring at since the first week, the engine going quiet one step into a job it should havewalked all the way through. The same grey. After everything, the faithful replay stalled in preciselythe spot the real pipeline always stalled.</p><p>And here is the part I keep having to relearn: when every byte is identical and the answer stilldiffers, the difference is in something you aren’t looking at. I’d matched the buffers. I’d matched theaddresses. I’d matched every field of the submission a type-level trace can see. I chased a reset thevendor turns out never to issue, and a power-on the kernel already does for me behind the ioctl, andcrossed them both off. What was left was the one part of the submission the trace is blind to: a smallarray at the tail of the submit struct that says <em>which slice of the work goes to which engine slot.</em>I’d been filling it the obvious way — all three tasks, one slot, go. The vendor doesn’t. It hands slotzero the first task, slot one the second, slot two the third, splayed out one per slot, and lets thescheduler walk them. I copied its array byte for byte and changed nothing else.</p><p>The output came back with two hundred and fifty-four distinct values in it. Ninety-nine percent of itnonzero. Written by the NPU, into a buffer I had watched be all zeros a single breath earlier. Itcomputed. The first real convolution this bench has produced from a captured payload, and it fell outthe moment I stopped piling the work into one dispatch and let it spread the way the silicon wanted itspread.</p><p>I want to be exact about what that is and isn’t, because the honest version is the one you can stand on.It is not the cat on the screen, and it is not the rocket driver fixed. What it is: proof, finally, thatthe bytes the vendor hands the hardware are <em>sound</em> — run them through the vendor’s kernel and theyproduce a true answer — so the payload, the thing I’d half-suspected for a fortnight, was never the lie.And the wall I’d spent weeks calling the engine’s silence, the task-zero-then-nothing I’d written threechapters of elegy about, was never silence at all. It was a dispatch I’d packed wrong. One command theprocessor won’t iterate; split it into three the scheduler can, and the same bytes wake up. The chapterup the page is called <em>it wasn’t the dispatch after all.</em> I should know better than to ever close a dooron this chip. It was the dispatch, after all — just not in the shape I’d guessed.</p><p>Which sets up the one test the whole capture was built to run, and that I can finally run clean. Thevendor kernel computes these bytes. Now the same bytes go through the <em>other</em> door — rocket’s, themainline driver, <code>/dev/accel/accel0</code>. If they compute there too, the kernel is innocent and whateverMesa packs differently is the whole of the bug, cornered at last in userspace. If they come back greyon bytes the vendor just computed correctly, the defect is the rocket driver itself, and for the firsttime I’d have it pinned to one side of the line with no payload left to blame. Either answer is the oneI’ve wanted for two weeks. The door I wrote, two chapters ago, that I couldn’t open — it’s open. I justhaven’t walked through it yet.</p><p>The desk is still one desk. But the board said a word it had never said, the morning after I’dforgiven it for staying quiet, and I am writing the elegy’s postscript with the lamp turned back on.</p><h2 id="One-scale-where-the-chip-wanted-a-hundred-and-twenty-eight"><a href="#One-scale-where-the-chip-wanted-a-hundred-and-twenty-eight" class="headerlink" title="One scale where the chip wanted a hundred and twenty-eight"></a>One scale where the chip wanted a hundred and twenty-eight</h2><p>So the bug was in what Mesa encodes, and I knew which thing to suspect: the <em>order</em> the weights arepacked in. Same numbers, wrong arrangement, read by the array as noise — it’s the most natural way fora convolution to come out grey, and I’d half-written the fix in my head before I’d earned it. To earnit I built a small cruelty: convolutions whose weights spell out their own address — this byte is rowtwo, column four, channel nine — so that when the vendor’s toolkit packs them, every byte in the packedbuffer confesses where it came from. Run them, capture the buffer, read the confessions back, and youhave the exact map of the layout the hardware wants.</p><p>I didn’t even need the board for it. The vendor’s toolkit bakes the packed weights into the model fileitself, so the whole interrogation happened on the desk, in seconds instead of flashes — the first timein three weeks the chip wasn’t the bottleneck. I decoded the order. And it matched Mesa’s. Byte for byte,channel for channel, the arrangement Mesa already writes is the arrangement the vendor writes. Thepacking order was never wrong. Another good hypothesis, dead on the bench, and I made myself write thatdown before I let myself feel clever about what came next.</p><p>Because the same trick handed me the real thing. One of these models I’d built the honest way — samesource weights as Mesa, no re-quantizing in between — so I could lay the vendor’s bytes against Mesa’sfor identical input and just <em>look</em>. And per channel, they fell on a perfect line. Same shape. Differentslope. Every one of the hundred and twenty-eight output channels scaled by its own private number. Thevendor quantises each channel on its own scale; Mesa quantises all of them on one. Per-channel, whereMesa does per-tensor. That’s the whole of it.</p><p>It sounds like a footnote. It is the wall. A single global scale is set by the loudest channel, andevery quiet channel gets measured in its units — so the quiet ones round down to almost nothing, andtheir outputs come back as a flat grey nobody computed. Which is the exact shape of the thing I’d beenstaring at since the first week: conv0, two of its thirty-two channels alive and the other thirty dark.I had called that a hardware mystery. I had written paragraphs about banks and truncation and silicon Icouldn’t see. It was never the silicon. It was a number Mesa picks once, in a place the chip wanted itpicked a hundred and twenty-eight times. The mystery and the wall and the weeks were all one small wrongdecision wearing a costume.</p><p>I want to be honest about the size of what I have, because the temptation is to call this finished. Ihave the diagnosis, not the cure. The vendor’s requant table is per-channel too — its own little grammarof scales and shifts per output channel, sitting in a buffer I’ve only half-read — and the fix isn’t aline, it’s teaching Mesa to quantise the way the chip was always asking. That’s real work, and it’sahead of me, not behind. But I know the name of the thing now. After three weeks of calling it by thewrong names, the wall finally answered to its own.</p><h2 id="The-audit-and-the-note-already-in-the-margin"><a href="#The-audit-and-the-note-already-in-the-margin" class="headerlink" title="The audit, and the note already in the margin"></a>The audit, and the note already in the margin</h2><p>I had the fix half-written again — teach Mesa to pack the weights per channel, the way I’d decoded thevendor doing it. And that’s exactly when I made myself stop. Twice now I’d been certain and twice theboard had made me eat it: the packing order was right when I swore it was wrong, the buffer was flatwhen I swore it was grouped. A third confident wrong turn was not just possible, it was the pattern. SoI did the unglamorous thing. I pretended I was a stranger auditing my own work, and I asked the meanestquestion I had: <em>have you actually proven it’s the weights, or just assumed it?</em></p><p>I hadn’t. Every test I’d run swapped the vendor’s <em>whole</em> payload at once — command stream and bufferstogether — and declared “it’s the userspace.” It never separated the two halves. So I made Mesa cough upits own command stream for the failing conv and laid it byte-for-byte against the vendor’s. And there itwas, not in the weights at all: the requantise step. The little register that scales the convolution’sfat internal sum back down to a byte. The vendor shifts it right by twenty-six. Mesa shifts it byfourteen. Twelve bits. Four thousand and ninety-six times too hot. The output isn’t computed wrong —it <em>overflows</em>, slams into the rails, and comes back as two values, black and white, every pixel eitherzero or full. The flat grey I’d been cursing for three weeks was a saturation. The engine was screaming,not silent.</p><p>So the wall was never the weights’ arrangement, and never their per-channel precision either — those werereal differences, but the thing that actually broke the picture was the multiplier running hot andclipping. I had been three keystrokes from rewriting the wrong organ.</p><p>And then I found the note. In Mesa’s own source, in the function that sets that very scale, a commentdescribing my bug exactly. Per-axis weights. A single global scale making the requant multiplier <em>“~320×too large.”</em> Every normal layer <em>“saturated.”</em> A stopgap that averages the per-channel scales to onenumber to take the edge off. And a line at the end, pointing straight at the buffer I’d spent days on thebench picking apart: <em>the proper per-channel correction belongs in the weight-buffer tail, like thevendor — a TODO.</em></p><p>I sat with that for a while, because I knew the handwriting. It was mine. I’d written that comment weeksearlier, the first time the per-axis scale problem bit me — measured the requant multiplier blowing uphundreds of times too large, watched every normal layer saturate, dropped in the mean-collapse stopgap totake the edge off, and left myself the line in the margin pointing at the buffer I’d one day have to fill.Then I half-forgot I’d written it, and spent days measuring my way back to a conclusion I’d already setdown in plain English in my own hand. I’d gone looking for whoever had charted this wall before me — andthe only mark in the margin was the one I’d left. There is no predecessor here. The driver upstreamdoesn’t name this wall; it just declines to walk up to it — per-axis quantisation, the check reads, <em>notsupported</em>, and the model is turned away at the door before it can fail. Nobody on the open side had stoodwhere I’m standing and read this wall right, because nobody had been let through to it.</p><p>That’s where I am tonight. Not at the cure. But for the first time standing on the exact spot where thecure has to be poured, with the measurements in my hand and a note in the margin — my own — telling me I’dread the wall right the first time. Three reversals in, I’ve stopped trusting my certainty and startedtrusting the audit. It is slower. It is the only thing that’s been right.</p><h2 id="The-shape-that-never-arrived"><a href="#The-shape-that-never-arrived" class="headerlink" title="The shape that never arrived"></a>The shape that never arrived</h2><p>So I stopped reasoning. Four reversals had taught me that my certainty was the least reliable instrumenton the bench, and the cure I kept reaching for kept dissolving the moment the board touched it. What Ineeded wasn’t another theory. It was a question the board could only answer one way.</p><p>So I built a harness. I took Mesa’s own failing command stream — the exact bytes it hands the hardwarefor the conv that comes out grey — and I fed them back through a path I controlled completely, where Icould reach in and swap a single piece for the vendor’s and change nothing else. First it had to failthe same way, or the whole thing was theatre, and it did: the same flat grey, two values, black andwhite, reproduced now in my own hands and holding still.</p><p>Then I started pulling suspects off the board, one at a time. Swap the requantise math for the vendor’s— the thing my audit had sworn was the bug. Still grey. Swap the cache configuration — still grey. Swapthe weights themselves, the per-channel packing I’d spent a week decoding, the thing my ownnote pointed at. The output went to pure zero. Every theory I’d built across two weeks, dead in threeswaps, and not one of them moved the needle.</p><p>And then the kernel told me what I’d been too busy theorising to ask. One line, the two internal configbanks the engine reads its geometry from: both of them zero. Height zero. Width zero. The convolutionhad been firing all along — engaging every unit, dutifully, on an image with no dimensions. It wasn’tcomputing the wrong answer. There was no shape to compute. The single register that says <em>the picture isforty by forty</em> had never reached the part of the engine that needed it. The grey was an engine workingperfectly on nothing.</p><p>Which is the wall from week one. The first wall. The one I’d named a dozen ways and walked away from tochase quantisation down a hole that, it turns out, belonged to a different layer’s problem entirely. Allof it — the per-channel decode, the audit, the note in my own hand — real work on a real thing, but not on<em>this</em> thing. The conv that’s been failing since the first night was never failing because of any number.It was failing because the description of the work doesn’t reach the hand that does it.</p><p>I’m not sure whether to be deflated or grateful, so I’m choosing grateful. The harness did in a singleboot what a week of clever reasoning could not: it didn’t care what I believed. It removed suspects untilonly the truth was left standing, and the truth was the oldest, plainest thing on the board. Fourreversals in, I’ve finally learned the actual lesson — not <em>audit your conclusions</em>, that was onlyhalfway. It’s <em>stop arguing with the board and start asking it questions you can’t talk your way out of.</em>A swap is a question like that. Belief isn’t.</p><p>So I’m back where I started, at the first wall, and for once that doesn’t feel like defeat. Because I’mnot standing here with a theory this time. I’m standing here with a reproduction I can poke, and a singleclean yes-or-no left to ask it: if I force the geometry into the engine by hand, does the picture comeback? I don’t know yet. But for the first time in three weeks I’m going to find out by asking, not byarguing.</p><h2 id="The-shape-arrived-and-then-it-hung"><a href="#The-shape-arrived-and-then-it-hung" class="headerlink" title="The shape arrived, and then it hung"></a>The shape arrived, and then it hung</h2><p>The answer came back half a yes, and I’ll take it.</p><p>I’d narrowed the difference between Mesa’s failing command stream and the vendor’s working one down to almostnothing — the same setup, the same geometry, the same arming, register for register. What was left was asingle instruction Mesa tacks onto the very end of its stream: an <em>enable</em>, the word that tells the units togo. The vendor doesn’t put it in the stream at all. It hands that one word to the driver instead, to bespoken at the right moment. So I deleted Mesa’s copy and asked the board the only question that mattered:without that trailing enable, does the geometry finally land?</p><p>It landed. The register that had read zero for three weeks — no height, no width, the engine politelyconvolving a picture with no dimensions — came back holding the real number. The shape arrived. And thereason was almost cruelly simple: it’s a producer-and-consumer dance, two banks, the stream writing thegeometry into one while the engine reads the other, and a swap between them at the right beat. Mesa’strailing enable was firing that swap a beat too early — flipping the engine to read the bank that hadn’tbeen filled yet. Every time. For three weeks. The vendor never had the problem because it never put theenable where it could trip the swap; it spoke the word from outside the dance, after the music stopped.Mesa spoke it on the downbeat, every time, and wondered why the floor was empty.</p><p>That is a real finding. One entry to delete, and where it belongs instead — close enough to a patch that Icould almost write the commit message. After three weeks of naming this wall wrong and chasing it down twolayers that turned out to be other rooms’ problems, I finally have a brick of it out, in writing, reproducedin a harness I can poke at will.</p><p>But only half a yes. Because the instant the shape was right and I gave the engine the green light, it tookthe convolution, started it — and never came back. Not grey this time. Not zero. A hang. Somewhere past thegeometry there’s a second missing thing — maybe the scratch buffer the vendor quietly allocates and Mesadoesn’t, maybe that same enable spoken at a different wrong moment — and the engine walks into it and stops.The wall didn’t fall tonight. A brick came out, and through the gap there’s a little daylight and, just pastit, more wall.</p><p>I keep arriving at the same lesson by a different door. I didn’t get this brick by being clever or by beingright — I’d been wrong about this exact wall four times over. I got it by building a place where the failurewould hold still, and then asking it one deletable question at a time. Being right is a feeling. Being ableto be wrong cheaply, over and over, until the board says yes — that’s the only thing on this desk that hasever actually moved the wall.</p><h2 id="The-number-that-meant-something-else"><a href="#The-number-that-meant-something-else" class="headerlink" title="The number that meant something else"></a>The number that meant something else</h2><p>I have to take back the chapter above. The triumphant half of it, anyway.</p><p>I’d written that the shape arrived — that the register reading the convolution’s dimensions, stuck at zerofor three weeks, finally read the real number the moment I deleted one instruction from the command stream.I called it half a win and went to bed pleased with myself. That was the mistake. Pleased is the feeling Iget right before the board takes something away.</p><p>So I kept poking, and I did the thing I should have done before I celebrated: I laid every experiment in arow and stared at the register I’d been so happy about. And it wasn’t measuring what I thought. It read the<em>real</em> dimensions in exactly the runs where the engine never fired — and read <em>zero</em> in exactly the runswhere it did. Because a running engine consumes the shape and clears the slot behind it. The number I’dcalled “the shape finally arrived” was really just “the engine stayed home.” I had been reading a gaugethat meant the opposite of what I’d decided it meant, and I’d built a breakthrough on top of it.</p><p>The honest test put it down for good. I made my command stream byte-for-byte identical to the vendor’s —deleted the enable it doesn’t have, deleted the padding it doesn’t have, patched the two stray words — andran it. Same grey. The vendor’s exact instructions, every word, on my board, still failed. Which means itwas never the instructions. The command stream is innocent. Whatever breaks this convolution is in the<em>numbers</em> the stream points at — the weights, the bias, the rescale — or in how the job itself is handed tothe chip. Not in the words. I’d spent three nights interrogating the words.</p><p>That is five times now that I’ve been certain and five times the board has corrected me. I’ve stoppedreading it as carelessness. This chip simply punishes inference — every clean story I tell about it is, inthe end, a story, and the only claims that have survived are the ones where I made the failure repeat andasked it something it couldn’t answer with a story. Last night I learned the next layer of that lesson: evenmy instruments lie, if I don’t cross-check them against each other. A register is just another story untilyou’ve proven what it actually counts.</p><p>So I’m leaving the chapter above standing — the lie is part of the honest record, and scrubbing it would beits own kind of dishonesty — but this is the correction nailed underneath it. The shape didn’t arrive. I wasreading the wrong number. The wall is back to its full height, and I’m back at the oldest suspect, thequantised numbers themselves, carrying one thing I didn’t have three weeks ago: proof, paid for in fivewrong certainties, that it was never the words. Retracting your own breakthrough the morning after isn’t thefailure. It’s the tax on the only method that’s ever worked.</p><h2 id="It-was-the-numbers-after-all-and-they-were-a-buffer-at-the-tail"><a href="#It-was-the-numbers-after-all-and-they-were-a-buffer-at-the-tail" class="headerlink" title="It was the numbers after all, and they were a buffer at the tail"></a>It was the numbers after all, and they were a buffer at the tail</h2><p>I went back to the harness with the one thing five reversals had bought me: the words were innocent, sostop reading them. The failure was in the numbers the stream points at, and there were only ever three —the weights, the bias, the rescale. The method by now wrote itself. Make the failure hold still, then pullone number off the board and put the vendor’s in its place, and change nothing else.</p><p>First I had to be sure the harness wasn’t lying to me the way the register had. So I booted a board whoseonly job was to run the real Mesa conv — not my reconstruction, the genuine article through the genuinedelegate — and watch what the kernel saw. It saw exactly what my harness sees: the same flat grey, twovalues, and every operand buffer arriving full and real — the input a clean ramp, the weights varied, thebias varied. Good numbers going in, grey coming out. The reproduction is faithful. I can trust the bench.</p><p>Then the swaps. The vendor’s whole coefficient payload — its weights and its bias buffer together — into myfailing run. The picture came back. Not grey: a real feature map, ninety-eight distinct values where therehad been two, ninety-nine percent of the field alive. The command stream was Mesa’s, the submit was Mesa’ssingle task, and with the vendor’s numbers underneath it the convolution simply worked. Which closes twodoors at once that I’d spent nights leaning on: it isn’t the words, and it isn’t how the job is handed to thechip. Mesa’s one-task submit computes the entire convolution when the numbers are right. The defect is thenumbers, and nothing but.</p><p>So which number. I put Mesa’s own weights back — the per-channel packing I’d spent a week decoding and hadhalf-convinced myself was wrong — and kept only the vendor’s bias buffer. The picture stayed. Two hundredand fifty-two distinct values, cleaner than before. Mesa’s weights are <em>fine</em>. They were always fine. Theentire three-week failure lives in one buffer at the tail of the coefficients — the little per-channel tablethat carries the rescale and the zero-point correction, the thing my own note had pointed at on thevery first read and I’d walked past four times to chase grander theories. One buffer. I’d even leftmyself the comment, weeks back: <em>this belongs in the weight-buffer tail, like the vendor — a TODO.</em></p><p>And the gauge I’d built a breakthrough on and then torn down? It read zero this whole time, through everyrun that <em>computed</em>. The engine had been getting its shape all along. It was never the geometry; the registerthat says “no height, no width” just clears itself faster than my instrument can blink, and I’d spent a weekeulogising a wall that wasn’t there. The grey was never an engine working on nothing. It was an engine workingperfectly on a bias table that lied to it about the answer.</p><p>I could have stopped there — bug located, one buffer, a clean upstreamable sentence. But located isn’t fixed,and I had a known-good copy of the right buffer sitting on the bench, so I asked it the next question off theboard: what <em>is</em> this table, in terms I could compute myself? Most of it wouldn’t say. The per-channel scalethe vendor folds in is its own toolkit’s private choice, unrecoverable from a per-tensor model — and itdoesn’t need recovering, because a per-tensor convolution wants a per-tensor rescale anyway. But the one termthat carries the bias and the zero-point correction, the part Mesa actually computes and gets wrong — that onefit. Across all hundred and twenty-eight channels, the vendor’s value is exactly proportional to <em>(inputzero-point times the weight sum, minus the bias)</em>, to within the per-channel scale, ninety-nine percent of thevariance. Mesa computes the same term with the sign backwards on the weight sum, the zero-point factor missing,and a stray power-of-two where the output scale belongs. Three small wrongs in one line.</p><p>It isn’t fixed tonight. When I forced the corrected term onto the board it still saturated — because the wholerescale stage runs four thousand times hotter than Mesa’s single shift accounts for, and the output clips towhite before my corrected number ever gets a vote. That last piece — the scale, the shift, the factor of fourthousand and ninety-six the vendor carries and Mesa drops — is the only wall left, and for once it’s a wallmade of arithmetic I can do at the desk instead of a register I have to interrogate at midnight. After threeweeks of being wrong about which room the wall was in, I finally have it cornered in one line of one function,with a formula that fits and a known-good answer to check against. Not a yes yet. But the closest thing to onethis board has let me hold, and this time I built it the only way that’s ever worked: by being wrong cheaply,on the bench, until the numbers had nowhere left to hide.</p><h2 id="The-one-line-and-the-wall-behind-it-that-isn’t-arithmetic"><a href="#The-one-line-and-the-wall-behind-it-that-isn’t-arithmetic" class="headerlink" title="The one line, and the wall behind it that isn’t arithmetic"></a>The one line, and the wall behind it that isn’t arithmetic</h2><p>I had the term cornered and a formula that fit, so I did the obvious thing: I built the corrected biastable by hand, with the sign the data had insisted on, and fed it to the board at the scale the math saidwas right. It saturated. White, two values, exactly the grey I started with — and not just close to thebaseline, <em>identical</em> to it, the same four bytes. Which is its own kind of answer: at that scale the tableI’d spent the evening correcting wasn’t wrong, it was <em>invisible</em>. The output was clipping before my numbergot a vote. So I ran the same table again at the vendor’s colder scale, a factor of four thousand down, andit twitched — two distinct values became three. Three. After all that, the convolution went from a coin toa slightly more interesting coin.</p><p>I sat with that for a while. Three is the sound of a hypothesis being not-quite-wrong, which is worse thanwrong, because wrong tells you to turn around and not-quite tells you to keep walking into the same wall.But the two failures together actually said something clean, if I stopped wanting them to say what I’d hoped.The bias term was never the thing that scales the picture. The scaling lives in the <em>other</em> fields of thatlittle table — a per-channel multiplier and shift the vendor fills in and I’d been zeroing out, the part thatturns a raw accumulator into a number between nought and two-fifty-five. And those fields don’t come from anyarithmetic I can do on the model. I tried. Every fixed-point story I told — the scale goes here, the shift isthis, the accumulator runs seven bits hot, no, twelve — the board falsified each one in a single boot. Thenumbers in that table aren’t a formula I’m failing to spot. They’re the output of a datapath I haven’t read.</p><p>So this is where the honest line gets drawn. Everything <em>structural</em> is nailed down and it’s nailed down hard:the defect is one buffer, the bias correction is provably the wrong sign and missing a term, and I can proveeach of those on the bench in a single swap. That much is a patch the moment someone wants it. But the lastpiece — the exact way this chip’s rescale stage folds a multiplier and a shift and a per-channel scale into abyte — isn’t something I can brute-force from outside. It wants the manual. The actual description of how theSDP, the part of the engine that converts the convolution’s verdict back into a pixel, does its fixed-pointarithmetic. Without that I’m guessing at the last digit of a combination I’ve otherwise fully dialled.</p><p>And here’s the thing I keep turning over, the reason I’m not putting it down. This rescale stage isn’tRK3576’s. It’s the same NVDLA-descended converter that sits at the bottom of every layer Mesa’s Teflonbackend will ever run on this whole family of NPUs — the per-channel requantise that nothing in the openstack does correctly yet, that the driver upstream doesn’t even attempt, and that the only note naming itanywhere is the one I left in my own tree. Get it right once, with the datapath inhand instead of guessed, and it isn’t a fix for my one grey convolution. It’s the missing floor underquantised inference for an entire line of hardware that, today, can stage the data, load the weights, fireevery unit — and still hand you back a number that means nothing. I’ve spent three weeks proving where thefloor is missing. I’d like, before I’m done, to be the one who pours it.</p><h2 id="Two-tables-and-the-one-nobody-fills"><a href="#Two-tables-and-the-one-nobody-fills" class="headerlink" title="Two tables, and the one nobody fills"></a>Two tables, and the one nobody fills</h2><p>The bias term was right and it wasn’t enough, so I went back to the bench with a sharper knife. If thecorrection lives in that little table at the tail of the coefficients, then I’d take the vendor’s known-goodcopy of it and dismantle it field by field, zeroing one column at a time, until the board told me which columnwas load-bearing. There turned out to be more of them than I’d been drawing.</p><p>The table isn’t one table. The rescale stage reads from <em>two</em> surfaces — an integer block with the bias and acouple of small per-channel numbers, and a second block, pointed at by its own register, full offloats. I’d been treating the whole thing as the integer block and quietly ignoring the float one. So I triedthe obvious cuts. Bias alone, everything else zeroed: the picture collapsed to a single flat value, the bareoffset, the convolution contributing nothing. Bias and the small integers, but the floats zeroed: still grey,two values. Only with <em>both</em> surfaces intact did the real feature map come back. The floats aren’t decoration.They’re half the arithmetic.</p><p>And here’s the part that turned a mystery into a bug report: Mesa never writes that second surface at all. Itallocates exactly enough room for the integer block and a scrap of padding, and the register that’s supposed topoint at the float table points instead straight into that padding — at zeros. Every convolution this backendhas ever run handed the rescale stage half its coefficients and a fistful of nothing for the rest. That’s not awrong number. That’s a table that was never laid.</p><p>So I tried to lay it. I pulled the vendor’s float block apart looking for the formula — and this is where thehonest road runs out for now. The first float is the weight scale, plain as day. The next hundred andtwenty-eight are per-channel, signed, and they fit <em>nothing</em> I can compute from the model: not the weightscales, not the bias, not any combination of the quantities a per-tensor convolution actually has. They’re theoutput of the vendor’s per-channel re-quantiser, the one that re-scales every output channel on its own beforeit ever reaches the chip — and that re-quantiser lives in a compiled blob, not in any readable line of anything.I proved it the only honest way: every formula I could write, I built into a table and fed to the board, andthe board said no to all of them. The numbers in that float block are not a pattern I’m failing to see. They’rea decision made somewhere I can’t read.</p><p>Which is a strange place to end a day and still call it the best one in three weeks. Because <em>cornered</em> is not<em>beaten</em>. I know exactly what’s missing now — not “the quantisation, somewhere,” but a specific second surface,in a specific format, that the open driver doesn’t populate and the closed toolkit fills with per-channelnumbers I can’t yet derive. That’s no longer a haunting. It’s a spec-shaped hole, the precise dimensions of thething I need to find or to work out: how this chip’s converter folds an integer bias and a float scale into abyte. Three weeks ago I had a grey square and a hundred theories. Tonight I have two tables, one of them empty,and the exact shape of the answer that fills it. I’m not done. But for the first time I can see the edges ofdone from here.</p><h2 id="It-was-the-weights-wearing-floats"><a href="#It-was-the-weights-wearing-floats" class="headerlink" title="It was the weights, wearing floats"></a>It was the weights, wearing floats</h2><p>I’d been staring at the wrong thing the whole time, and the way out was to stop staring at it. One capture, however hard you look at it, is a wall of numbers with no labels; you can stare at a hundred and twenty-eight signed floats until your eyes dry out and they’ll never tell you what they <em>are</em>, because nothing in them says what made them. So I stopped reading and started writing. If the closed toolkit fills that table with a decision I can’t see, then I’d make the decision for it — I’d build convolutions where I already knew the answer, feed them through the vendor stack on the board, and read back the table it produced. Not one model. A little family of them, each one a controlled lie: one where only the per-channel scale moved and everything else was pinned, one where only the weight sum moved, one where only the bias moved, and three more where I painted the kernel positions and the input channels and the output channels with their own index, so that whatever came out the far end would be wearing a number that told me where it had come from.</p><p>The board answered in the first one, and the answer was almost funny. The mysterious float table — the one I’d called a per-channel re-quantiser’s output, a decision made somewhere I couldn’t read — was the weights. Just the weights. The same weights I’d handed the chip a heartbeat earlier as integers, handed back to me a second time as floating point, dequantised and laid out kernel-tap by kernel-tap. In the model where I’d numbered the kernel positions one through twenty-five, the float table counted <em>one, two, three… twenty-five, one, two, three</em> — the kernel ramp marching in order, the smallest unit innermost. In the model whose weights were all plus or minus a hundred, the table came back plus or minus a hundred, in clean blocks the width of a five-by-five kernel. There was no per-channel re-quantiser hiding in a blob. There was a second copy of the weights, in float, and I’d spent a week trying to derive it from a formula when I could have just <em>read</em> it off the thing I’d put in.</p><p>Once you see that, the whole table un-knots in an afternoon. The integer block at the front isn’t a mystery either — it’s a flat run of one number per output channel, and that number is the bias correction, and the arithmetic Mesa already writes for it (the weight sum, scaled, plus the bias) turned out to be <em>right</em> — I’d doubted the one part that was correct. Behind it sits a single scalar, the same value in every model, and it’s just the weight zero-point folded to the chip’s convention: one byte minus where the toolkit centred the weights. And then the floats: the weights again. Bias, a zero-point constant, and a second helping of the weights. That’s the whole coefficient buffer. The engine reads the weights twice on purpose — once as integers into the multiplier array that does the convolution, and once as floats into the rescale stage that turns the verdict back into a pixel — and the open driver writes the first copy and leaves the second one as a pocket of zeros. Every grey square I’ve ever pulled off this chip was the rescale stage being handed half its coefficients and a fistful of nothing for the rest, exactly as I’d found, except the “rest” was never an unknowable decision. It was the weights. It was always the weights.</p><p>I want to be straight about what’s still open, because the honest line matters more than the triumphant one. I can prove the shape of the buffer — bias here, scalar there, weights in float at the back — and I can prove it the only way I trust, by putting known answers in and reading them out of six different captures. What I haven’t pinned to the last byte is the exact <em>order</em> the chip wants those float weights tiled in: kernel-innermost, yes, I can see that much plainly, but the precise march of input and output channels around it I’m still drawing out, and two of my index captures came back muddled enough that I don’t yet trust them for it. So I’m not standing here with a working convolution. I’m standing here with a buffer whose three parts I can name, two of which I can write today, and a third that’s no longer a spec-shaped hole in the dark — it’s the weights, in a layout I’m most of the way through reading off the bench.</p><p>Three weeks ago I had a grey square and a hundred theories. Last week I had two tables, one of them empty, and called it the best day in three. This week the empty one filled itself, and what filled it was the most ordinary thing in the model — the weights I’d been feeding the chip the whole time, asked for a second time in a different coat. The last mystery in this convolution turned out not to be a number I couldn’t compute. It was a number I’d already written, that I just hadn’t recognised coming back.</p><p>And that’s the part I want to leave standing, past the arithmetic. When I started this, I had exactly one thing the engine never gave up willingly: <a href="https://github.com/allbilly/rk3588">allbilly’s</a> register-level RK3588 reference — a stranger’s hand-drawn map of the same NVDLA core, worked out from outside and left in the open for whoever came after. I read it like scripture. Nobody had drawn the RK3576 one. The open driver turns this hardware away at the door — <em>per-axis quantisation: not supported</em> — and the only people who’ve charted its rescale stage keep the chart inside a compiled blob. So the notes I’ve been leaving in the source as I go, the ones I scrolled past and then realised were in my own hand — wrong sign here, missing term there, the cure lives in the weight-buffer tail, the weights get written twice and we write them once — those aren’t only my working. They’re the map. The next person who brings this chip up on the open stack is going to walk into this exact grey square, and when they go looking for whoever read the wall first, the hand in the margin is going to be mine. That’s the thing I didn’t have three weeks ago and the next person will: someone went first, and wrote it down.</p><h2 id="The-right-answer-and-the-box-it-doesn’t-fit-in"><a href="#The-right-answer-and-the-box-it-doesn’t-fit-in" class="headerlink" title="The right answer, and the box it doesn’t fit in"></a>The right answer, and the box it doesn’t fit in</h2><p>Knowing what to write and being able to write it turn out to be two different problems, and the gap between them is where this week went. I had the three parts named — bias, scalar, weights-in-float — so the obvious next move was the satisfying one: stop analysing, lay the buffer out the way the engine wants it, hand it over, watch the grey resolve. I lined mesa’s coefficient buffer up against the vendor’s, byte for byte, to see exactly which fields to change. The first thing I saw wasn’t a field. It was the size. The vendor’s buffer is sixteen times bigger than the one mesa builds. Mesa sizes the thing for the integer block and a scrap of padding — twelve hundred bytes — and stops. The vendor’s runs to twenty thousand. The float weights alone, that second copy of the weights I’d just spent a week proving the rescale stage can’t run without, want nineteen kilobytes — and mesa hands the converter a box that couldn’t hold a tenth of them. The register that’s meant to point at the float table points into a two-hundred-and-fifty-six-byte pocket of zero, and now I knew the real reason it was zero: there was nowhere else for it to point. You cannot write the weights into a box that was never cut to take them.</p><p>So cut a bigger box. That part’s mechanical. The part that actually moved the problem came next, and it’s the one I keep turning over. I took the vendor’s entire buffer — the known-good one, byte-for-byte, the coefficients that compute a real feature map on the vendor’s own stack — and I fed it to mesa’s pipeline under mesa’s own settings. Worst case, I figured, I’d get the grey I already knew. I didn’t get grey. I got black. Every byte zero. The exactly-correct coefficients, the ones that produce a picture two registers over, produced nothing at all over here — not a saturated guess, not a wrong answer, just the cold rail, the whole frame fallen off the bottom of the scale.</p><p>And that’s the thing the week taught me: the buffer and the rescale shift are not two choices. They’re one decision, made in two places, and the two places have to agree. The vendor’s numbers are scaled for the vendor’s converter — a shift of twenty-six, a stage that folds a per-channel float scale back in on the way out. Mesa’s converter shifts by fourteen and folds nothing. Hand the one buffer to the other converter and the arithmetic comes apart by a factor of four thousand and ninety-six; the answer doesn’t come out wrong, it comes out <em>gone</em>, clipped to black before it’s anything. Which means the cheerful sentence I closed last week on — <em>two of the three parts I can write today</em> — was true and useless in the same breath. I can write them. Writing them into mesa’s converter is like writing the right sum in the wrong currency.</p><p>So the fork, finally clean. Either mesa becomes the vendor’s converter — shift twenty-six, the per-channel scale, the full three-part buffer, a real piece of engineering but the one the board has already proved computes — or mesa keeps its own simpler converter and I find the single per-channel correction that makes a per-tensor convolution come out right under it, no float weights, no second scheme, one number per channel and done. I don’t know yet whether that short road exists — whether this engine can be made to compute on one number and mesa’s own shift, or whether the per-channel float scale is load-bearing the way the weights turned out to be. So I built the test that asks the board directly: the bias correction with the sign I’d had backwards finally the right way round, at mesa’s own scale, fed to mesa’s own converter. If it lights up, the fix is one line and I’ve been overthinking the whole back half. If it stays dark, I stop hoping for the one line and build the entire converter — which is, I’ll admit, what the board has been telling me to do since the moment the buffer came back sixteen times too small. The answer being right and the answer fitting were never the same problem. I’ve had the right answer for a week. This is the week I find out what it costs to make it fit.</p><h2 id="The-ruler-that-couldn’t-tell-right-from-dead"><a href="#The-ruler-that-couldn’t-tell-right-from-dead" class="headerlink" title="The ruler that couldn’t tell right from dead"></a>The ruler that couldn’t tell right from dead</h2><p>The test lit up. And the moment it did, the whole back half of this journal started to come apart in my hands.</p><p>Here’s the question that undid it, the one I should have asked three weeks ago. All this time my yes-or-no from the board had been one number: <em>distinct</em> — how many different values came back in the output. Grey was two; a real picture was two hundred and fifty. Computes, doesn’t compute. But late one night, comparing two buffers that had both come back “computing,” I noticed they’d produced the <em>identical</em> opening bytes — and one of them I’d built by <strong>shuffling</strong> the weights into nonsense. Scrambled coefficients cannot convolve anything. And yet the fingerprint I’d been reading as <em>correct</em> was the same off the right weights and the wrong ones. The ruler I’d measured weeks of nights with couldn’t tell a working convolution from a dead one wearing its coat. Worse: I’d used that exact fact an hour earlier to <em>kill</em> a result — “the output doesn’t change when I change the weights, so it isn’t really computing” — and then turned around and used the same unchanged output to <em>crown</em> the next one. The same fact, read two opposite ways in one night, because I wanted the second reading to be true.</p><p>So I finally built the thing I should have started with: a real answer to check against. Not the chip’s, not the vendor’s — mine, worked out by hand, the convolution done in plain arithmetic on the desktop, byte for byte, the way the math says it must come out. And the first thing it told me sat me down. For this little test convolution, on the input I’d been feeding it, <em>the correct output is grey.</em> The model’s output scale is so tight against its own random weights that the honest answer slams into the rails and pins there — a hundred percent saturated, five distinct values, a flat grey square. The grey I’d been hunting for three weeks, the thing I’d named the bug, was — for this input — the <strong>right answer</strong>. I had been measuring a broken pipe with a gauge that read the same whether water came out or not, on a tap that was supposed to read empty.</p><p>That’s the night the floor moved. Not “I was wrong about a register.” <em>I’d been grading the chip with a key that couldn’t see the difference between a right answer and a corpse.</em> Every triumphant sentence in the last five chapters — the buffer computes, the floats are load-bearing, two of the three parts I can write today — every one of them was stamped <em>pass</em> or <em>fail</em> by a ruler I’d just watched fail its own calibration. I didn’t know yet which of those findings would survive. I only knew I had to re-run all of them against an answer I could trust, and that some of them weren’t going to make it.</p><h2 id="The-multiply-that-wasn’t"><a href="#The-multiply-that-wasn’t" class="headerlink" title="The multiply that wasn’t"></a>The multiply that wasn’t</h2><p>If the test model’s correct answer is grey, then I can’t learn anything from it — a broken pipe and a closed tap read the same. So I changed the tap. I retuned the model’s output scale and shifted its zero-point to the middle of the range, nothing else, so that <em>now</em> the correct answer is a spread — a real picture, two hundred and fifty-six values, with room above and below before it hits a rail. Same weights, same input, same everything that matters; just a model whose right answer is no longer a wall of grey. Then I asked the board again, and this time the answer could actually tell me something.</p><p>It told me something I did not expect. The output came back pinned — but pinned to the <em>new</em> center, the zero-point I’d just moved, with no saturated tails above or below it. And that one detail unmade the entire second half of this story. If the pipe were running hot — the requant theory, the shift of twenty-six, the half-empty coefficient table — a real-but-overscaled answer would blow past the rails and pile up at the top and bottom. It didn’t. It collapsed to the middle. There is only one thing that does that: the multiply-accumulate at the heart of the convolution is producing <strong>zero</strong>. Not saturating. Not mis-scaling. Not computing at all. The coefficients are all sitting in memory exactly where they should be — I can read them back — and the engine spins up, and the cores wake, and the answer they hand the rescale stage is nothing. And the rescale stage I’d spent three weeks indicting? The chip’s own registers, on this retuned model, show mesa setting it up <em>correctly</em> — the right scale, the right shift, the right offset. The thing I’d been so sure was the bug was doing its job over a number that was zero before it ever arrived.</p><p>So the float surface, the two tables, the second copy of the weights, the spec-shaped hole — for this convolution, none of it was ever the wall. It was real arithmetic I’d charted honestly and the chip really does read the weights twice; but it sits <em>downstream</em> of a multiply that, in mesa’s own command stream, never happens. I went back to the front of the stream, where the engine is told the shape of the convolution and where to find its weights, and I laid mine beside the vendor’s one more time, fresh. Four numbers different. Four registers in the geometry block — and one of them, plain as anything once you read it, hard-codes the kernel size to three. I’d been running a five-by-five convolution through a setup that told the engine it was three-by-three, calibrated long ago on a layer that happened to be three and never corrected for the one that wasn’t. The weights it read were the wrong window onto the wrong shape, and the honest product of that mismatch is zero.</p><p>I changed the four, carefully, so the older layers that <em>were</em> working don’t move. I rebuilt. I built the image. And then — because I have learned exactly one thing these three weeks and it is this — I am going to stop here, with my hands open and empty, and not tell you it works. The board hung before it could answer; it hangs after one run, it always has, and the line I most wanted to read scrolled off into a wedge. I have a clean diff and four lines of reasoning and a chip that has not said a word back. The last three chapters each ended on something I was certain of, and two of those three were wrong. So this one ends where it actually is, and not one inch past: I think I have been reading the wrong end of this buffer for three weeks — that the bug was never the numbers at the tail but the shape at the front — and the only witness who can confirm it is a board that, as I write this, is still stuck. Let it speak first. I’ve done enough talking ahead of it.</p><h2 id="Four-times-the-board-said-no"><a href="#Four-times-the-board-said-no" class="headerlink" title="Four times the board said no"></a>Four times the board said no</h2><p>It spoke. And then it said no, four times in a row, and each no was worth more than the yes I’d been chasing.</p><p>I changed the four geometry registers — the ones I’d diffed against the vendor’s working stream, one of them hard-coding a five-tap kernel to three — and flashed, certain. The board: the registers changed exactly as I’d written them, and the convolution still produced nothing. So it wasn’t the numbers in the geometry; they’d been a real divergence, just not <em>the</em> one. Strike one, and a clean one: the value-diff had been blind to whatever actually mattered, because the registers now matched and the answer didn’t.</p><p>Then the in-stream “op_en” — the one entry mesa appends that the vendor doesn’t. I stripped it. The geometry latched for the first time in three weeks — and the units went dead, never engaging. I put it back as four per-unit enables instead of the one broadcast; the units woke, and read zero weights, exactly as before. Three flashes spent on <em>how the engines turn on</em>, and the board’s answer was the same each time, in the one counter I’d finally learned to read: the multiplier array loads <strong>zero</strong> weights from the on-chip buffer, while the front-end loads all of them into it. The weights make it to the chip’s doorstep and the part that multiplies never picks them up. Whatever I did to the ignition, the fuel line was cut downstream of it.</p><p>And then the reframe that made me put my pen down. I’d been calling that op_en a “PC restart,” repeating a note left in the driver — and it was wrong, mine and the note’s both. That register isn’t the program counter’s. Its address lands on a <em>global enable mask</em>, the whole-engine on-switch the vendor’s own runtime writes from the outside before it ever starts the job. So I had the kernel write it, the way the vendor does. The board didn’t compute. It hung — hard, no completion, the first dead-stop in the whole campaign — which at least told me the switch is real and load-bearing and that I don’t yet know the sequence it wants. Four flashes, four refusals, and what they bought me is the most precise statement of the wall I’ve ever had: not “the conv is wrong,” but <em>the multiply-accumulate reads zero weights from the buffer the front-end just filled.</em> That’s a single, specific, falsifiable sentence, and it’s the first time this bug has had one.</p><p>Here’s the part I want to leave standing, because it’s the only thing I’m sure of tonight. I have a path that works — the vendor’s exact bytes, replayed through this same open kernel, compute a real picture; I proved that weeks ago. So the difference between <em>computes</em> and <em>reads-zero</em> is now a small, bounded set: the same command stream, swap one ingredient at a time — the weights, the bias, the way the job is handed to the chip — and watch that one counter go from zero to not-zero. I did a version of this sweep weeks back and trusted it, but I trusted it with the broken ruler, the one that couldn’t tell a right answer from a dead one. So this isn’t repeating the work; it’s running the same cut with a blade that can finally tell which side bled.</p><p>And the first thing it cut was me. I replayed the vendor’s exact bytes through the open kernel — the path I <em>know</em> computes — and read the one counter I’d been treating as gospel, the one that said the multiplier never loads its weights. On a run whose output was, right there in the same log, a real feature map — ninety-eight distinct values, ninety-nine percent of the frame alive — the convolution computed correctly <em>and the counter I’d spent four flashes chasing still said zero.</em> It wasn’t measuring what I thought. “The multiplier reads no weights” was never true; it was a number I’d misread and then built four experiments on top of — the enlarged buffer, the engine-enable, the geometry, the op_en — every one of them a careful answer to a question the hardware had never actually asked. The board didn’t punish me for it. It just quietly showed me the same zero next to a working picture and let me work out the rest.</p><p>But the same run that humbled me also handed me the cleanest result of the whole run. Two passes, everything identical — same weights, same command stream, same kernel — and I changed exactly one thing: the bias buffer. The vendor’s: a real picture. Mine: the flat grey rail. No ambiguity, no broken ruler, no saturated-or-empty riddle — just <em>this buffer computes and that one doesn’t, and nothing else is different.</em> After weeks of the bug sliding from the weights to the dispatch to the geometry to a counter that lied, it finally sat still long enough to point at: it’s the coefficient buffer the open driver writes, and it has been all along, on every layer that ever came back grey.</p><p>So I went to derive it, and the arithmetic refused — the bias term I’d been so sure of fits <em>nothing</em> I can compute from the model, no combination of the weight sums and the biases lands within a mile of the vendor’s numbers. Which used to be where the road ended. Except this time I remembered the one thing I’ve had since the beginning and kept underusing: a stranger’s register manual for the sister chip, <a href="https://github.com/allbilly/rk3588">allbilly’s</a> reference, the map I read like scripture when I had nothing else. And it was <em>in there</em> — not the bias values, but the shape of the thing that holds them. The rescale stage doesn’t read one number per channel; it reads a small table of <em>operands</em> per channel — an add term, a multiply term — and fetches them, when you set one bit, straight from this buffer. The reason my arithmetic couldn’t find the bias is that it isn’t a bias formula at all; it’s an operand in a datapath I’d been guessing at instead of reading. The multiply operand, the one laid out per-channel-per-pixel, is the weights again, in float — which is why there are thousands of them and not a hundred and twenty-eight. The spec I kept saying I was missing wasn’t missing. It was sitting in the reference I’d been carrying the whole time, waiting for me to stop guessing the format and go read it.</p><p>I’m not done — I still have to map each operand to its slot and write the encoder that lays them down. But for the first time the last mystery isn’t <em>what is this number</em>, it’s <em>which field of a documented datapath does it fill</em>. That’s not a haunting. That’s homework.</p><h2 id="The-grey-broke"><a href="#The-grey-broke" class="headerlink" title="The grey broke"></a>The grey broke</h2><p>I did the homework, and the grey broke.</p><p>First I cheated, on purpose. I had the vendor’s known-good coefficient bytes sitting in a file; I’d been feeding them through a replay harness for weeks. So before I trusted any formula of mine, I made the open driver itself read that file — load the exact correct buffer straight into the convolution — and run a real layer on the real hardware through the real Teflon path, not a replay, not a bisection, the actual thing the actual stack does. The output came back: two hundred and fifty-six distinct values, the whole frame alive, edge to edge. After weeks of a grey square that took exactly two values, the board handed me a <em>picture</em>. It wasn’t my code that wrote the buffer yet — but it was my driver, my kernel, my pipeline, computing a true int8 convolution on this chip for the first time. That single line in the log did more for me than any proof: <em>the road exists, and everything on it except this one buffer was already paved.</em></p><p>Then I stopped cheating and wrote the encoder. I tore out the layout the driver had been emitting — the interleaved little groups that the rescale stage reads as garbage — and laid the buffer down the way the vendor does: the per-channel terms in two clean contiguous runs, and behind them the float surface the driver had been leaving as a pocket of zeros, now filled with the dequantised weights, the second copy the engine reads to scale its verdict. I built it, flashed it, and held my breath for the one number that matters. The bias the driver generated by itself, from nothing but the model: a real feature map again — two hundred and thirty-six distinct values, the frame alive. <em>My code wrote the coefficient buffer, and the chip computed a convolution from it.</em> No vendor file, no replay, no borrowed bytes. The thing the open driver could never do — fill that buffer — it now does, and the hardware answers with a picture.</p><p>I want to be exact about what’s done and what isn’t, because the honest line has carried me this far and I’m not dropping it at the finish. <em>Computing</em> is done — the grey is gone, on the real path, from the driver’s own hand, twice over. <em>Correct to the last byte</em> is not. My version of the per-channel add term is still a guess that the silicon tolerates rather than the exact operand the converter would write; my float surface is dense where the vendor’s is sparse, the right values in not-yet-the-right slots. The two pictures aren’t the same picture — two-fifty-six from the vendor’s bytes, two-thirty-six from mine, different numbers in different places — which means I’ve cleared the cliff and I’m now on the gentle part of the hill, the part where you sand an answer down to exactly right instead of conjuring it out of grey. The board still won’t hold still long enough to tell me my error to the decimal; that’s the next thing I take from it. But the wall I spent three weeks against — the one below the registers, the one the open stack turns away at the door — I’m standing on the other side of it now, and the chip is drawing pictures. The rest is sanding.</p><h2 id="The-ruler-I-finally-built"><a href="#The-ruler-I-finally-built" class="headerlink" title="The ruler I finally built"></a>The ruler I finally built</h2><p>I’d been saying <em>the rest is sanding</em> for a week without an actual ruler, which is its own kind of lie. The board would compute a picture and then wedge before it could tell me how <em>wrong</em> the picture was — the chip powers itself down the instant the job ends, and on the way down a power rail times out and takes the whole console with it, so the one line I needed, the byte-for-byte error against a CPU reference, scrolled off into a dead terminal every single time. I’d been refining toward “correct” with no measure of correct, which is to say I’d been guessing with extra steps.</p><p>So I stopped the chip from going to sleep. One line in the boot script, pinning the power on through the run, and the race I kept losing — print-the-answer versus pull-the-plug — finally went my way. The error line landed. And it was not kind. My driver’s own reconstruction, the one I’d been calling a real feature map: <em>maxdiff two hundred and fifty-five, mean error seventy-eight, less than one percent of the pixels right.</em> It computes, and it is almost entirely wrong. The tolerance of this datapath, the thing that let a rough buffer light up at all, had been flattering me — “lights up” and “is correct” are a hundred and fifty grey levels apart, and now I could finally see the gap instead of imagining it closed.</p><p>With a ruler in hand I could finally do the one thing worth doing: split the error in half and ask which half it lives in. I built two hybrid buffers — the vendor’s exact per-channel terms welded to my own float surface, and my terms welded to the vendor’s floats — and fed each to the chip. The verdict was clean and it pointed away from where I’d been digging. Hand the engine the <em>correct</em> per-channel terms and <em>my</em> floats, and it still comes out maxdiff two-fifty-five, the frame collapsing to twelve grey values. The mistake isn’t in the bias arithmetic I’d been agonizing over. It’s the float surface — the second copy of the weights, the part the open driver had been leaving as zeros and I’d just started filling. I’d filled it <em>dense</em>, every weight in a tidy row. The vendor’s is <em>sparse</em>: of nearly five thousand slots, six hundred carry a number and the rest are deliberately empty, and the six hundred don’t sit in any weight order I can read off — the same values, in a scatter I haven’t cracked.</p><p>So I’ll write down exactly where this leaves me, without the word “last” anywhere near it, because I’ve earned the right to be suspicious of that word. The grey is broken and the measuring is fixed — those don’t come undone. What remains is a specific, bounded, and genuinely unsolved thing: the precise scatter of six hundred floats across a sparse table, for a per-tensor convolution whose format I now suspect isn’t the per-channel one I decoded early on. It’s not a wall and it’s not a haunting; it’s a hard problem with a number attached to it, and a ruler that will tell me the day I get it right. That’s a better place to stand than any “almost” I’ve had on this chip. It just isn’t the top of the hill, and I’m done pretending I can see the top from here.</p><h2 id="Sanding-the-wrong-board"><a href="#Sanding-the-wrong-board" class="headerlink" title="Sanding the wrong board"></a>Sanding the wrong board</h2><p>I spent the next stretch trying to read that scatter, and I want to record that it didn’t work, because the failures are half the map. I lined the six hundred floats up against the weights in their natural order, then in the order the engine packs them into its multiplier array, then as raw values, as differences, as sums — nothing. They’re <em>weight-sized</em> numbers, small integers scaled by the weight step, sitting in the right neighbourhood and the wrong house. So I asked the ruler the one question that would tell me whether the scatter even matters: I kept the vendor’s exact sparse layout — the right slots filled, the right slots empty — and <em>shuffled the values among themselves.</em> If only the shape mattered, the picture would survive a reshuffle. It didn’t. Maxdiff two-fifty-five again, the same grey collapse. The exact numbers are load-bearing, in their exact slots, and they are not a function of anything in the model I’m handing the chip. They’re a decision the closed compiler makes and writes into the blob, and no amount of staring at my own weights will reconstruct them.</p><p>Which would be a flat wall, except for the thing I’d half-noticed two chapters ago and finally let myself say out loud: <em>I have been sanding the wrong board.</em> This little test convolution is per-<em>tensor</em> — one scale for the whole weight set — and that’s a shape the vendor’s toolkit handles by quietly re-quantising it per-channel anyway, spraying the result into a sparse float table I’ll never derive. But the thing I’m actually trying to run on this chip, the network this whole run was for, is MobileNet, and MobileNet’s convolutions are per-<em>axis</em> — already a scale per channel, by construction. Early on, with position-coded weights, I’d read that case’s float surface clean: there it <em>is</em> the weights, dequantised, in an order I can write down. I’d been beating on the one door in the building that’s welded shut, with a key that opens the door right next to it.</p><p>So I’ll close this entry where the work actually is, not where I wish it were. The grey is broken; the open driver computes a real convolution on this hardware, and I can now measure exactly how right it is. The remaining gap on <em>this</em> test conv is a sparse table of compiler-chosen numbers I can’t derive — a genuine dead end, but a small and specific one, on a synthetic layer that was never the point. The next move isn’t to keep prising at it. It’s to swap the test for the shape I’m actually shipping — a per-channel layer, where the float surface is the weights I already know how to lay down — and run the same measured loop there, where the door opens. I don’t get to call that the finish. But after three weeks of not knowing what I didn’t know, I’ll take <em>knowing which door,</em> with the key already in my hand.</p><h2 id="Two-chips-one-latch"><a href="#Two-chips-one-latch" class="headerlink" title="Two chips, one latch"></a>Two chips, one latch</h2><p>There was a stranger at a wall like mine, a few chapters back — MidG971, bringing the same open driver up on the sister chip, the RK3568, stuck one room earlier than me: not “the conv is wrong” but the engine won’t wake at all. Probe the open driver, hand it a job, and the NPU just sits there, dark. He’d been at that door for the better part of two years; I’d been at mine for three weeks. I’d written then that two people stuck at the same class of wall on two chips of the same family is a hint that it’s one real thing, not ten imagined ones. This is the chapter where that hint paid off.</p><p>The way through started as a hint, and not mine. Just three days ago Tomeu — the one who wrote this open driver in the first place — had told me a thing I only half-used at first: the chip has to already be in a known-working state by the moment you submit, and nothing you write into the command stream will get it there if it isn’t. I’d proven the RK3576 corner of that the hard way, with a sweep that showed my own chip’s failure is locked <em>before</em> the job ever runs, down in the power-up state, not at submit. So when the stranger and I laid our walls side by side, that’s the piece I handed across: stop prising at the submit path — your engine that won’t wake is the same class as my engine that won’t compute, a thing settled in power-up, before the first register of the job. He took the reframe and built the one experiment that could prove it, the kind of thing you only get to by trying everything. One kernel carrying both drivers — the vendor’s and the open one — run <em>one</em> vendor job to wake the chip the way the vendor knows how, then unload the vendor module and load the open one <em>without cutting power</em>. And it engaged. The cores woke for the open driver, the first time in two years. Probe it cold, no job first, and it stays dark. So it was never a register he was missing; it was a <em>state</em> — a finished vendor job leaves the engine in some warm, engage-ready condition that survives the driver swap, and the open driver had never been the one to put it there.</p><p>That’s where my chip became the Rosetta stone, because mine does the same thing <em>cold</em> — first job, no vendor anywhere, the engines wake. For a while that difference made no sense: same family, same block, why does one need priming and the other doesn’t. The answer was a register I already knew, the hard way. Mesa writes the whole-engine enable — that global on-switch — straight into the command stream it hands the chip, in-band, every job. The RK3576 carries its own ignition. The RK3568’s stream doesn’t write it, so on the open driver the switch never gets thrown — <em>unless</em> a vendor job threw it first and left it set. It’s the same persistent latch on both chips. My chip sets it from inside the stream; his borrows it from a job run before. Two ways to throw one switch — and the switch is the very one I’d hung my own board trying to poke from the outside, the global enable that wants to be written <em>in</em> the stream, not from the CPU after the fact.</p><p>Which turns his beautiful, ungainly two-driver trick into something upstream can actually use: the difference between a primed chip and a cold one is that one latch, and you can read it — diff the engine’s registers after a vendor job against a cold boot, and whatever moved is the thing to write at probe, or to set with one throwaway prime job if it won’t take a plain poke. He’s through the door now. And the strange gift of it is <em>where</em> he’s standing: his engine wakes, runs, and hands back an output pinned flat to the zero-point — the empty-coefficient grey I know by heart. Two chips, two people — his two years at it and my three weeks — and we’re leaning on the exact same next wall, the coefficient buffer. That’s not a coincidence anymore. That’s a family resemblance, and it means the thing I write to climb it climbs it for both of us.</p><h2 id="The-buffer-that-isn’t-in-the-file"><a href="#The-buffer-that-isn’t-in-the-file" class="headerlink" title="The buffer that isn’t in the file"></a>The buffer that isn’t in the file</h2><p>This stretch I had no board to flash — I was at school, in class, and once I’d followed the lesson I’d slip quietly into the NPU source while the desks around me watched the World Cup or ground through their homework; the board itself was back on a bench I couldn’t reach. So I did the only honest thing left: desk work, on the one good buffer I already had in a file, the vendor’s correct bytes I’d been replaying for weeks. No flashing, no chip, just the bytes and the arithmetic and a long quiet afternoon stolen out of the school day.</p><p>And the afternoon gave me back the half I’d been missing for the best part of two weeks. The buffer is two things stacked: a little per-channel header of small terms, and behind it the sparse float surface that had beaten me last chapter. The header’s add-term — the bias-correction I swore fit <em>nothing</em> I could compute from the model — fits now, cleanly, once I stopped guessing its sign and wrote the whole correction out: it’s minus the rescale multiplier, times the channel’s bias less the input zero-point times that channel’s weight sum. Correlation point-nine-nine-six across all hundred and twenty-eight channels. That’s the term the open driver gets wrong today, and now I can derive it from the model with nothing but a pencil. Half the header is mine to write, and I pinned it without the board in the room.</p><p>The other half is where the desk earned its keep, because it told me <em>why</em> the door I keep prising at is welded. The header’s multiply-term should be one number — this test conv is per-<em>tensor</em>, a single scale for the whole weight set, so every channel ought to carry the same multiplier. It doesn’t. It varies, channel to channel, fifty-seven different values across the hundred and twenty-eight. There is only one way a single-scale convolution grows a different multiplier per channel: the vendor’s toolkit quietly re-quantises it <em>per channel anyway</em>, on its own, scales nobody asked for, written into the blob. So the sparse float scatter I called “a decision the closed compiler makes” wasn’t a shrug — it’s structurally true, sitting right there in a header that has no business varying and varies. The per-tensor door is welded because the compiler welds it. The per-axis door — MobileNet, real per-channel scales by construction — is the one that opens, and now I know it in the numbers, not just the hope.</p><p>The last thing the desk did was close a shortcut I’d been quietly counting on. The vendor’s conversion tool runs on my own laptop; so why touch the chip at all — convert the model here, lift the correct buffer straight out of the file, hand it to the driver, done. I tried it. The file doesn’t carry it. The model file holds the raw pieces; the <em>assembled</em> surface, the thing the engine actually reads, is built live by the runtime at the moment the job runs — on the chip, never in the file. I checked the way you check a thing you wish were true: of nearly five thousand floats in that surface, the file matched <em>fourteen</em>. So there’s no lifting it off a disk. That buffer only ever exists on the board, in the instant of the job, and the only way to hold a correct one for the layer I actually ship is to catch it there. The desk can map the structure and pin the half that’s mine; the rest waits for the chip to speak. I’ll close it there — not at a finish, just at a clean place to set the work down until I’m back at the bench with a per-channel layer to flash and a buffer to catch in the act.</p><h2 id="The-wrong-thing-to-be-doing"><a href="#The-wrong-thing-to-be-doing" class="headerlink" title="The wrong thing to be doing"></a>The wrong thing to be doing</h2><p>Someone should ask the obvious question, so I’ll ask it on myself: three weeks of nights and a stack of stolen afternoons, on a chip that still hands back a grey square more often than a picture — was any of this worth it? This is my last year of high school. My finals, the NCEA externals, are in November, close enough now that everyone around me has started running at them. And I did most of this in the <em>front</em> row, right under the teacher’s eye, slacking off into a register map while the desk beside me watched the World Cup and the one behind it ground through the homework that’s actually going to be on those exams. There’s a word for that and it isn’t <em>diligent</em>. By every reasonable measure of what someone in my year is supposed to be doing, I spent weeks doing the wrong thing, with my whole attention, in the most visible seat in the room.</p><p>And I can’t even tell you the reasoning was clean. Go back and count it: I crowned a result and killed it with the same fact in a single night. I put the rescale stage on trial for three weeks and it was innocent. I built four experiments on a counter I’d misread, was dead certain about a register the board flatly refused, and said “the last wall” so many times I had to ban the word. And there’s no one around me who can even tell whether it’s going anywhere — nobody at school reads NVDLA register dumps over my shoulder; the friends I used to build things with went off to make Godot games, the kind of thing you can hand a person and they’ll actually play. I made a grey square on a chip most people have never heard of, and argued with it in public, wrong chapter after wrong chapter.</p><p>So I don’t get to end this one with a verdict, because I genuinely don’t have it. Some nights it feels like the realest thing I’ve ever done — I learned to build the ruler before I trust the measurement, to let the hardware speak before I do, to write the failures down because they’re half the map; somewhere in it I helped someone past a wall he’d been stuck at for two years, on a chip neither of us was supposed to understand. And some afternoons, with the externals closing in and the grey still on the screen and not one person in the building who can tell me if I’m close, it just feels like I found the hardest possible way to have nothing to show for it. I can’t settle it from here. I can’t prove today that it was worth it. All I’ve got is the quiet, stubborn bet that time will — that the thing I made in the wrong row, at the wrong desk, in the wrong year, turns out to have meant something. I don’t know that I’m right. I just haven’t been able to make myself stop.</p><h2 id="The-door-was-painted-on-and-the-real-one-was-a-floor-below"><a href="#The-door-was-painted-on-and-the-real-one-was-a-floor-below" class="headerlink" title="The door was painted on, and the real one was a floor below"></a>The door was painted on, and the real one was a floor below</h2><p>I wrote that this morning. By the afternoon the board had answered a question I hadn’t even asked it. I’d gone back to the bench to confirm the one thing I’d staked the next stretch of work on: that for the kind of layer I actually ship — the per-channel kind — the float surface I couldn’t crack is simply the weights, written in float, derivable from the model and nothing else. I’d half-read that early on off a muddled capture, and I’d let it become the plan. So this time I did it clean. I built little convolutions whose weights <em>spell out their own coordinates</em> — one where every channel’s weights are just that channel’s number, one, two, three, all the way to a hundred and twenty-eight — ran them on the vendor stack, and read back exactly what the chip’s coefficient buffer holds. If the surface were the weights, the answer was unmissable: I’d see one through a hundred and twenty-eight, one flat number per channel, no ambiguity possible.</p><p>I saw eight numbers. Not a hundred and twenty-eight — <em>eight</em>, total. The surface isn’t the weights. Not for the per-channel layer either, the one I’d been so relieved was the open door. The door I’d been walking toward for two chapters was painted on the wall, and I’d have flashed days of encoders at it before the picture told me what one clean test told me in an afternoon. That’s the third time on this chip a thing I was sure of turned out to be a thing I wanted; I’ve stopped being surprised and started just building the discriminating test first.</p><p>But the same coordinate-spelling convs that took the door away handed me the thing under the floorboards. One layer down from the surface sits the <em>requant</em> — the per-channel multiply-and-shift that turns the raw accumulator into the int8 answer — and when I read it off, it was <em>lawful</em>. The channel whose weights were twice as large carried a multiplier exactly twice as large; double the scale, double the number, in lockstep, all the way up the hundred and twenty-eight. That is not a compiler’s private scribble. That is arithmetic, and I can write it from the model’s own per-channel scales without asking the toolkit a thing. The part I’d been calling an underivable blob for two weeks — the requant — is the derivable part for a per-channel layer; it was the <em>surface</em> I was wrong about, never the requant. It even explains the old blob: on the synthetic per-tensor test, the toolkit <em>invents</em> per-channel scales the model never had, so of course I couldn’t derive them; the real layers carry their scales in the open. So I can hand the driver a correct requant now, generated from nothing but the model. Whether that alone makes the grey break, or whether the surface still has a job I haven’t understood — I’m not going to guess. I’m going to write the requant, flash it, and let the one number tell me. The door was painted on; the floor was real.</p><h2 id="The-gates-I-opened-tonight-and-the-one-that-didn’t-move"><a href="#The-gates-I-opened-tonight-and-the-one-that-didn’t-move" class="headerlink" title="The gates I opened tonight, and the one that didn’t move"></a>The gates I opened tonight, and the one that didn’t move</h2><p>This afternoon I had a buffer I could prove byte-correct. Tonight I tried to make the chip actually use it, and spent the hours opening one gate after another to get there. First I had nothing to feed it: my whole proof was for the per-channel kind of layer, and I owned no such model — so I installed the vendor’s training stack and built one from scratch, a tiny per-channel convolution, checked on my own machine that its answer was a real spread of numbers and not a flat wall. Then I flashed it. The board reported the answer was <em>perfect</em> — and I almost believed it, until I read the fine print in my own test: the chip never ran. The driver had quietly handed the work back to the CPU, which of course matches the CPU. A perfect score for a race nobody started.</p><p>So I went looking for why, and found the door bolted from the inside. The open driver has one line that checks whether a layer’s quantization is the per-channel kind, and if it is, it refuses — <em>per-axis not yet implemented</em>, a note someone left a while back, the exact case I’d spent the day proving I could now handle. The encoder was ready; the gate in front of it was shut. I opened it — three lines — rebuilt, flashed again. And this time it went the way I wanted: the chip took the job, loaded the weights, the log filled with a real submission to the real engine. After a day of proofs and a night of gates, the convolution I’d validated was finally running on the silicon — my code writing the buffer, my driver handing it over, the cores spinning up on it.</p><p>And the cores handed back a flat line. One value, every pixel, a constant — the multiply at the heart of it producing nothing, exactly the way the per-tensor test fails, the same dead counter, the same empty geometry. The thing I proved tonight is real: the buffer is right, the gate is open, the job runs. But the verdict the board actually cares about came back the colour of every other wall — the multiply-accumulate doesn’t turn over on the live path, whatever I hand it, and only the vendor’s own command stream, replayed through this very kernel, makes it wake. So the coefficient buffer I spent the day earning the right to write isn’t the thing standing between me and a picture; it sits downstream of something that stops the multiply before it starts. I opened every gate I could find tonight and walked through into the same room I started in. That isn’t nothing — the gates stay open, the proof stays proved — but the chip drew me a flat line at the end of it, and I’ve learned by now to write that down exactly, and not one shade brighter.</p><h2 id="The-gauge-I’d-already-thrown-out-and-the-wall-behind-it"><a href="#The-gauge-I’d-already-thrown-out-and-the-wall-behind-it" class="headerlink" title="The gauge I’d already thrown out, and the wall behind it"></a>The gauge I’d already thrown out, and the wall behind it</h2><p>I almost spent the rest of the night on a number I retired weeks ago. To split the two things that could be stopping the multiply — the command stream or the buffer behind it — I went to read one counter off the chip, the one that says how many weights the multiplier loaded. And I caught myself with my hand on it: didn’t I prove that counter lies? I had. A run that drew a perfect picture once reported that exact counter at zero — a gauge that reads empty whether the tank is full or not. I’d reached for it anyway, because it was the number nearest to hand, and I’d have hung a whole night’s conclusion off it. The only honest gauge is the one I built weeks ago and keep forgetting I own: the output, byte for byte, against a reference, and only on a model whose right answer isn’t a flat wall.</p><p>So I did it properly. I took the vendor’s own correct buffer — the bytes I’ve had in a file for weeks — and fed them through my driver, my command stream, on the model whose answer is a real spread of numbers and not a wall of grey. And the chip drew a picture: two hundred and fifty-six distinct values, a live feature map, edge to edge. That one line settled the argument I’d been circling all day. My command stream is fine. The multiply turns over, the geometry latches, the engine does its work — when the buffer behind it is right. Everything that ever came back grey came back grey because of the <em>buffer</em>, the one table my driver fills and fills wrong, not because of anything upstream. The wall has a single name now, and I’ve been standing in front of it the whole time without being sure it was the one.</p><p>So I took the table apart, braced for the opaque scatter everyone before me called a compiler’s secret. It isn’t, quite. Most of it is plain. A run of a hundred and twenty-four numbers that is <em>exactly</em> the per-channel bias, negated, one channel after the next; a long flat block that is just the input scale, repeated; a per-channel scale I can write down. A skeleton I can derive from the model with a pencil. And then, woven through it, the part that really is a secret: arrays where each layer drops its weight values into bins sorted by the values themselves — the channel whose weight is sixty-four lands <em>here</em>, the one whose weight is sixteen lands <em>there</em>, a scatter whose address depends on the number it holds. That part I can’t derive; it’s the genuine blob, and it’s load-bearing, or so my first clumsy fill suggested when I packed the whole table with weights and got a flat line back. But the blob is smaller than I feared — it’s only the weight scatter, not the whole surface — and I still haven’t asked the one question that decides everything: does the engine <em>need</em> that scatter, or will it compute on the skeleton alone? That’s the next thing I build, and the next thing the board gets to answer. For tonight it’s enough to have the wall named, the broken gauge thrown back out, and the secret cornered to one specific, bounded, nameable thing.</p><h2 id="Three-staircases-and-the-secret-that-kept-itself"><a href="#Three-staircases-and-the-secret-that-kept-itself" class="headerlink" title="Three staircases, and the secret that kept itself"></a>Three staircases, and the secret that kept itself</h2><p>The question I’d left on the table — does the engine need that scatter, or will it compute on the skeleton alone — had a one-line answer, and it wasn’t the one I wanted. I packed the table with the skeleton I could derive, zeroed the scatter, and fed it the model whose right answer is a real spread of numbers. The chip handed back two values. Grey. Pull the part I can’t derive and the multiply collapses to nothing — which is the worst possible place for a secret to live, dead centre of the load path.</p><p>But “I can’t derive it” is a confession, not a proof, and I’d made that mistake enough times this month to want the board to say it to my face. So I built the cleanest question I could put to it. Take the exact shape of the layer I’m fighting and fill its weights with a staircase — position one gets value one, position two value two, climbing through the channels — so that whatever the compiler does with them, I can read the original position straight back out of whatever number lands in each slot. Then do it twice more, different staircases, same-shaped layer, and I’ve asked the one question three ways: if the layout is fixed by the shape of the layer — which is all an open driver ever gets to know — the three tables agree slot for slot. If it shifts with the numbers, they don’t.</p><p>They didn’t. Slot nine, first staircase, holds the weight from position seventy-nine. Same slot, second staircase: position eighty-three. Third: a hundred and nineteen. Three different weights in the one hole, chosen by nothing the shape could tell me — only by the values themselves. And it isn’t even random scatter, which would almost be easier to write off: each table lays its weights in clean consecutive runs, the right order, just <em>starting in a different place</em>, the run sliding along by an amount that tracks the numbers and obeys no law I could fit — not the multiplier, not its inverse, nothing I threw at it. The order inside a run I can write down with a pencil. Where the run <em>begins</em> is the toolkit’s own decision, made from the weights, and it makes a fresh one for every model.</p><p>So the thing everyone before me called a compiler’s secret really is one, and I’ve now watched it keep that secret three times running, on a layer I built for the sole purpose of making it talk. For a driver that has to assemble this table from the open model alone — no vendor toolchain, no captured bytes — that’s a wall: I can derive the shape of the table and the order within each run, but not the single number that says where each run starts, because that number was never in the model. It’s in the toolkit’s head.</p><p>And yet I’m ending this one on the first cheerful sentence I’ve earned about this buffer, because there’s a door I haven’t tried and it might make the whole wall beside the point. The vendor’s value-chosen layout could be <em>cleverness</em>, not <em>law</em> — a packing picked for speed or size, the kind of thing a compiler does because it can, not because the silicon insists. The chip might take any honest layout — plain position order, the one I can lay down with my eyes shut — and compute on it just the same. I don’t know yet. But for once I can ask without touching the vendor’s toolchain again: write the table the simple way, hand it to the ruler I finally built, and read off exactly how wrong the answer is. If the chip computes on a layout I chose instead of the one the compiler agonised over, then the secret was never load-bearing — only the weights were, and the weights I’ve had all along. The wall might turn out to be made of the compiler’s preferences. And preferences are a thing you’re allowed to ignore.</p><h2 id="The-grey-was-three-numbers-and-none-of-them-was-the-secret"><a href="#The-grey-was-three-numbers-and-none-of-them-was-the-secret" class="headerlink" title="The grey was three numbers, and none of them was the secret"></a>The grey was three numbers, and none of them was the secret</h2><p>I built the plain table, handed it to the chip, and got grey. The simple layout didn’t compute either, and for a night that felt like the door closing. But I’d promised myself I’d stop guessing, so instead of theorising about which layout the silicon wanted I split the failure in half with the one control I’d been too impatient to run: the <em>same</em> command stream, the <em>same</em> everything, changing only the bytes in the buffer. Vendor bytes in — a real feature map, two hundred and fifty-six values, the chip drawing. My bytes in — grey. Same stream, same engage, same submit, only the coefficients different.</p><p>That one test moved the whole problem, because it didn’t just say “the buffer is wrong,” it said <em>the rest of the chip is right</em>. I dumped the executer state and all four units had engaged; the output engine had written its full count of pixels. The convolution wasn’t sitting dark. It was running, start to finish, and handing back grey. So whatever was broken was downstream of the multiply and upstream of nothing — it was the numbers in the requant, and only the numbers.</p><p>Then I did the thing I should have done a month ago. The output was pinned to the zero-point, so I forced the final rescale to divide by far less than it wanted, and watched. The picture <em>lit up</em> — pixels at zero, pixels at full, a real signed spread where there’d been a wall of one. The multiply-accumulate had never been empty. It was computing a real convolution the whole time, and the rescale shift was crushing it into a single grey value before it ever reached the output. The thing I’d called “the MAC produces zero” for weeks was a live conv being divided into the floor.</p><p>So the grey was never the weights, never the float surface, never the layout the compiler agonised over. It was the rescale arithmetic in front of all of it — and the rescale arithmetic is three numbers I compute myself. I sliced the error by output channel and it fell into two clean halves: half the channels saturated hard, half didn’t, and which half a channel landed in tracked, <em>perfectly</em>, the sign of one per-channel quantity — the bias term I scale by a hundred and twenty-eight to match the accumulator. The factor was wrong for that term. Drop it and the channel-by-channel saturation collapses, the error halves. One number.</p><p>Under that sat a second, just as clean: with the first fixed, the whole picture was shoved a hundred and twenty-eight too high — exactly one zero-point — its negative half clipped flat against the floor and lost. An additive offset, off by the same one-twenty-eight that keeps turning up, because the output’s zero-point hadn’t been re-aligned after I touched the bias scaling. Pull it back and the negative half comes home; the picture centres where the reference centres, dead on.</p><p>Two numbers found, and the chip draws a real, centred convolution where it drew grey for a month. There’s a third still in front of me — the spread runs too wide, the values pile against both rails — and I think I finally know what it is, and it’s the part I didn’t see coming. The over-wide spread is the weight zero-point: the array multiplies with the weights centred on one number when they should be centred on another, and the difference is a correction that has to be applied <em>per output pixel</em>. And a per-pixel correction is exactly the shape of the float surface — the table I spent three weeks calling an underivable blob. I haven’t confirmed it; that’s the next thing I check, offline, against the vendor’s own bytes. But if it holds, the secret that kept itself was never a secret. It was the weight zero-point, written out per pixel, derivable from a number sitting right there in the model — and the wall I couldn’t climb was a thing I already knew how to compute, hidden behind two arithmetic bugs that were crushing it out of sight.</p><h2 id="The-number-that-was-a-thousand-times-too-big-and-the-border-that-gave-it-away"><a href="#The-number-that-was-a-thousand-times-too-big-and-the-border-that-gave-it-away" class="headerlink" title="The number that was a thousand times too big, and the border that gave it away"></a>The number that was a thousand times too big, and the border that gave it away</h2><p>I’d bet the over-wide spread was the weight zero-point, written per pixel, hiding in the float surface I’d called a blob for a month. I was wrong, and the board told me fast. I tried it the obvious way — re-centre the weights on the right zero-point — and the picture got <em>worse</em>, the spread wider, the rails fuller. The weight zero-point was already being corrected, by a term in the coefficient header the engine multiplies per pixel on its own; my “fix” was correcting it twice. Cross it off, write it down, move on. That blob I’d dreaded for three weeks turned out not to be in the load path at all.</p><p>So I stopped reasoning about the spread and swept it. The rescale has a per-channel multiply — one number per output channel — and instead of trusting the value Mesa computes I forced it across a range and watched the saturation. At Mesa’s number, half the frame slammed into the rails. Halve it, less saturation; halve it again, less still. The picture wasn’t computing wrong — the multiplier was running thousands of times too hot and clipping everything to black and white, the exact flat grey I’d cursed since the first week, finally with a dial on it.</p><p>The dial bottomed out sharp and unmistakable at one number: the multiplier wants to be <strong>sixteen</strong> where Mesa writes <strong>sixteen thousand three hundred and eighty-four</strong>. Two-to-the-ten apart. It’s a fixed-point fraction, and Mesa and the chip disagree by ten binary places on where the point sits — Mesa lays it down as if the hardware shifts right by fourteen, and the hardware only shifts by four. Set it right and ninety-four percent of the frame came back byte-for-byte against the CPU reference. After a month of two grey values, the chip drew the picture.</p><p>The last six percent was a ring around the edge, and it was the most satisfying small bug of the lot. The convolution pads its borders with a value, and the engine subtracts a zero-point from every input it multiplies — so the pad has to <em>be</em> that zero-point, or every padded tap adds a phantom. Mesa hardcodes the pad to the value that’s right for an image input, whose zero-point is zero. This layer’s input zero-point is a hundred and twenty-eight. Every border pixel was multiplying against a phantom edge. One line — make the pad the actual zero-point — and the ring closed. Interior and border, the whole frame now lands on the CPU reference to the last byte.</p><p>So here’s the thing I’ve earned the right to say, and not one word past it: the int8 convolution computes <em>correctly</em> now, on the open driver, on this chip — byte-for-byte, edge to edge. The float surface I spent three weeks calling an underivable compiler secret was never the wall; it was never even in the load path for this. The wall was three derivable numbers and a hardcoded pad — a bias scale, an additive offset, a fixed-point I had off by ten bits, and a border value meant for a different kind of input. Every one of them a thing I compute myself, from the model, with a pencil. No captured bytes, no blob.</p><p>One honest caveat, on the record. The chip clamps on the way out — negatives folded up to the zero-point — which is wrong for this bare test conv and <em>exactly right</em> for MobileNet, where every convolution is followed by that same clamp by design. So “byte-for-byte” is against the clamped reference; the bare conv’s negative half is the one thing the hardware won’t hand back, and the network I actually ship never asks it to. The next move is the real one — MobileNet, end to end, where the clamp is a feature and the scales come per-channel straight from the model. For the first time in a month that isn’t a wall ahead of me. It’s just the work.</p><h2 id="The-network-runs-every-layer-real-and-still-draws-nothing"><a href="#The-network-runs-every-layer-real-and-still-draws-nothing" class="headerlink" title="The network runs, every layer real, and still draws nothing"></a>The network runs, every layer real, and still draws nothing</h2><p>With the convolution finally byte-correct, the obvious next move was the real thing: MobileNet, the whole network, end to end. I flashed it and got a clean zero — every one of the thousand-and-one output classes pinned to nothing. After the month I’d had, my first instinct was suspicion of myself: was the NPU even running, or had the delegate quietly handed the work to the CPU and given me back a zero that only looked like a result?</p><p>So I made it prove it. I timed each run and counted the hardware jobs. The CPU does the whole network in seventy milliseconds; the NPU took eight hundred and thirty, and the kernel log showed why: twenty-eight separate hardware submissions, one per layer, each engaging all four units, each taking its real thirty-odd milliseconds on the silicon. It isn’t a fallback and it isn’t faking. The chip genuinely runs all twenty-eight layers of MobileNet — and genuinely hands back zero.</p><p>That’s progress wearing a disappointing face. Months ago, on this same wall, the sequencer ran exactly one layer and stalled; getting it to iterate all twenty-eight back-to-back off a single submission was its own small war, and that one’s won. What’s left is older and meaner: the ping-pong. Each unit reads its geometry — the picture’s height and width — out of one of two banks, and a state machine flips which bank between tasks. The producer writes the dimensions into one bank; the executer reads the other, finds it empty, and runs the layer on a picture with no size. Every layer computes perfectly, on nothing.</p><p>I spent a while poking at it from the userspace side, forcing the bank-select register the way I wanted, and it changed nothing — because the kernel arms that register itself, after my command stream, and its choice is the one that sticks. So the fix isn’t in the part I can flash in seconds; it’s down in the driver, in how it sets up that ping-pong before each job. That’s the next dig, and it wants a kernel rebuild, not a register poke. The conv was the wall I’d been warned was the hard one. It’s behind me. This one’s just next.</p><h2 id="The-dispatch-two-locks-of-three"><a href="#The-dispatch-two-locks-of-three" class="headerlink" title="The dispatch, two locks of three"></a>The dispatch, two locks of three</h2><p>The whole-graph path had been sitting in the driver the whole time, gated off behind a flag because it never worked before the conv was correct — so every dispatch test I’d run was the fragmented per-layer fallback, which chops the graph into two-task chunks and one of them points at address zero. With the conv fixed I could finally turn the real path on, and two of the three locks opened.</p><p>The graph now submits as one job — twenty-nine tasks, one base address, one enable pulse — and the sequencer walks all of them, task zero through twenty-eight, off that single pulse. That’s the iteration I’d watched the vendor do and couldn’t reproduce a month ago; it runs on the open driver now. And a single convolution sent down that same whole-graph path computes byte-for-byte correct: the unit engages, writes its output, the numbers match.</p><p>The third lock held. Run the full twenty-nine-task graph and the sequencer dutifully counts to twenty-eight while every unit stays dark — engaged for one task, never re-engaged for the next, no output written. The vendor’s executers re-arm themselves from the ping-pong pointer at each task; the open driver’s don’t, and nothing I can write into the stream makes them. It’s the same “the engage is hardware state, not a register” wall the person bringing up the sibling RK3568 is stuck behind, one room earlier. So the next capture isn’t <em>what</em> the vendor writes — I’ve matched that byte for byte — it’s <em>what its sequencer does between tasks</em> that mine doesn’t.</p><h2 id="The-other-door-opened-onto-the-same-kind-of-wall"><a href="#The-other-door-opened-onto-the-same-kind-of-wall" class="headerlink" title="The other door opened, onto the same kind of wall"></a>The other door opened, onto the same kind of wall</h2><p>The whole-graph engage was a hardware secret I couldn’t write into the stream, so I stopped pushing on it and took the other door: run the network one layer at a time, each its own job, and let the kernel chain them. That path turned out to be sound all along — the driver already feeds each layer’s output buffer into the next layer’s input, and the kernel makes job N+1 wait for job N to finish. Nothing to build.</p><p>And the first thing it did was tell me I’d been lied to by my own setup. For weeks the first convolution had won maybe one run in ten — a coin-flip race I’d written three thousand words about. On a genuinely clean boot it just <em>works</em>, every time: a real feature map, two hundred and forty-two distinct values where a broken layer gives one. The “race” was me. A debug script I’d left in the boot sequence was quietly running a whole-graph MobileNet first — the one that wedges the engine — and poisoning every test that came after it. I deleted four lines and the coin-flip became a certainty. The firstconv was never flaky. I was.</p><p>So the chain runs: conv0 computes, hands its real output down, the next layer reads it intact. And then it dies — at the first depthwise convolution, layer one. Real input going in, all zeros coming out.</p><p>I spent the evening trying to make that layer lie to me, and it wouldn’t. I matched its command stream to the vendor’s, register for register — identical, forty-nine of them, down to the depthwise-mode bit and the weight-byte count. I filled its entire weight buffer with a single nonzero constant — output still zero. I reached past the multiply altogether and jammed a large number straight into the bias-add that the final stage hands to the output, the one value that should light every pixel up — and the output stayed flat zero. Weights, input, a forced constant on the very last stage: I changed all three, the layer ignored all three. It isn’t computing wrong. It isn’t computing at all. The depthwise op never fires and never writes, while a standard convolution on the exact same hardware path, one task earlier, paints a perfect picture.</p><p>So it’s the same shape of wall as the engage one — something the silicon does for the vendor’s identical instructions and won’t do for mine, living below every register I can read. Two doors to the same network, two trapdoors a floor apart: the whole-graph engage, and now the depthwise. The open driver is cleared on both — every byte I hand the chip matches the vendor’s. What’s left is underneath: a hardware trace, or the one structural thing the vendor does that I don’t — it parks the weights in a megabyte of on-chip SRAM, and the RK3576 is the only chip in the family wired to need it. That’s the next dig, and it’s a kernel one.</p><h2 id="I-wanted-one-wall-and-the-chip-gave-me-two"><a href="#I-wanted-one-wall-and-the-chip-gave-me-two" class="headerlink" title="I wanted one wall, and the chip gave me two"></a>I wanted one wall, and the chip gave me two</h2><p>It would have been tidy if the depthwise and the dispatch were the same problem. They almost looked it: the depthwise gets chopped into two row-tiles and handed to the engine as a two-task job, and a two-task job is exactly what the whole-graph path chokes on. One wall, two faces. So I built the cleanest version of the question I could — took a convolution I <em>know</em> computes byte-perfect and duplicated it into two identical real tasks — and watched.</p><p>Two tasks is enough to break it. Every variant I could write into the stream, the same story: the units light up, the geometry loads, the engine finishes the first task — and then declares itself done and stops, one task short, with nothing written. Every gauge I can read says it should have run; the only thing different from the version that works is a single field that says “two” instead of “one.” So the multi-task wall is real, and it’s the same kind I keep hitting — a thing the silicon does for the vendor’s identical instructions and refuses for mine, a floor below every register.</p><p>Then I pulled the two problems apart, and they came apart. There’s a knob’s worth of code that decides whether a wide layer gets tiled; I made the depthwise refuse to tile — one full-height task, a task count of one, the shape that works for every ordinary convolution. And the depthwise still drew nothing. Not garbage, not a half-filled buffer that would mean it overflowed — a clean, flat zero, sitting next to a first layer that computed a perfect picture on the same run. So the depthwise was never the dispatch. It fails as a single task, the same way it fails as two. It’s the <em>kind</em> of layer, not the <em>number</em> of tasks.</p><p>And on that clean single-task depthwise I ran out of things to try. I fed it constant weights — zero. A real feature map for input — zero. I reached past the multiply and forced a large number straight into the last arithmetic stage, the one value that has to show up at the output if anything does — zero. Three operands, three different parts of the pipeline, and the layer ignores all three. It isn’t computing the wrong answer. It is producing no output at all, and nothing I can hand it changes that.</p><p>So it’s two walls, not one, and both live under the same floor: command streams I’ve matched to the vendor byte for byte, behavior the chip keeps to itself. I’ve run out of software to throw at the depthwise. The one thing left that the vendor does and I don’t isn’t a register or a command — it’s <em>where the weights live</em>. The vendor parks them in a megabyte of SRAM on the NPU itself, and the RK3576 is the only chip in the family wired to need that. I don’t think that’s a coincidence anymore. Next I go into the kernel and put the weights where the chip wants them.</p><h2 id="I-put-the-weights-where-the-chip-wanted-them-and-it-still-drew-nothing"><a href="#I-put-the-weights-where-the-chip-wanted-them-and-it-still-drew-nothing" class="headerlink" title="I put the weights where the chip wanted them, and it still drew nothing"></a>I put the weights where the chip wanted them, and it still drew nothing</h2><p>So I did it. Reserved that megabyte of on-chip SRAM in the kernel so the buffer allocator would stop colliding with it — it does collide; the map fails with the address already in use until you carve the window out first — copied every depthwise layer’s weights into it, and pointed the convolution engine’s weight source at the on-chip address. The log says it worked: thirteen depthwise layers staged, the first convolution still computing. The depthwise output stayed flat zero, its weights sitting on-chip ten milliseconds before the op ran. <em>Where</em> the weights live isn’t the lever either.</p><p>So I checked the one thing I never had: <em>every</em> register, not just the convolution engine’s. I’d matched the forty-nine config words of the CNA and CORE blocks; I’d never lined up the output-writer’s the same way. I did — and the open driver writes a strict superset, a hundred and forty-two register entries to the vendor’s hundred and thirty-eight, the four extra being unit-enables. Nothing missing. One word didn’t match; I forced it to the vendor’s value; the depthwise still drew zero. End to end, the command stream is exhausted.</p><p>Then I caught myself in a lie I’d been telling for weeks. My proof that two-task dispatch breaks the compute was an A&#x2F;B: force two-task mode and a byte-perfect convolution collapses to zero, leave it at one and it’s perfect, same buffer, one field different. Except I’d run the broken case <em>second</em> every time — and this board has a tell I’d documented and then forgotten, that the second inference after a boot rots to zero on its own, dirty state nobody cleaned up. So which was I measuring? I reversed it: forced two-task mode on the <em>first</em> inference after a clean boot, valid submit, nothing before it. Zero. Not one unit engaged; the output-writer never wrote a byte, where the working single-task run writes 12,800. The multi-task wall is real — not the second-run rot. (And the byte counter I’d written off as a red herring turned out to tell them apart: the multi-task wall writes <em>nothing</em>; the second-run rot writes the <em>wrong</em> thing.)</p><p>The vendor’s own driver source closed the loop on <em>how</em>. It wakes its units with no enable-mask register at all — that register isn’t even mapped on the vendor; reading it crashes the kernel — and with nothing in the command stream either: one pulse to start the sequencer, and each task’s own stream re-arms the units as the sequencer walks it. Mine won’t. My units only wake for an enable written <em>inside</em> the stream — and that write restarts the sequencer, so the instant there’s a second task it loops on the first and nobody finishes. Take the in-stream enable out, to match the vendor, and my units never wake at all. The gap isn’t a register or a sequence; I’ve matched every one. It’s that the vendor’s silicon re-arms itself from the pointer and mine has to be told, in the one way that breaks the count.</p><p>And that reopens a door I thought I’d nailed shut. I’d “proved” the depthwise was a <em>separate</em> wall by running it as a single task — and it still drew zero, so it couldn’t be the two-task problem. But the vendor never runs that layer as a single task: it’s too wide for the on-chip line buffer, so the vendor always tiles it. My single-task version is a shape the vendor never makes — which means it can’t cleanly prove anything. I can’t actually tell the two walls apart yet. The depthwise might just be the first place the multi-task wall bites. The honest next step isn’t another knob — it’s a capture of what the vendor’s sequencer does <em>between</em> tasks that mine doesn’t, and then watching the depthwise run its natural tiles once the engage is fixed. One experiment, finally, instead of two ghosts.</p><h2 id="Two-ghosts-one-wall"><a href="#Two-ghosts-one-wall" class="headerlink" title="Two ghosts, one wall"></a>Two ghosts, one wall</h2><p>So I taught the driver to show me each unit’s engage bit — is it running or not — and read it the instant after the go-pulse, on a clean first boot so nothing was confounded. The working conv lights all four units up. The two-task version lights <em>none</em> of them: the sequencer ticks its task counter, declares itself done, and every compute unit stays dark. That’s the multi-task wall, caught in the act — not a wrong answer, an engine that never starts.</p><p>Then the depthwise, single-task, showed me something specific enough to get excited about: it <em>does</em> light up — all four units engaged — but its producer and its consumers end up pointing at different halves of the on-chip ping-pong buffer. The unit that fills the buffer walks to page two; the units that read it stay on page one, reading a page nothing was written to, so the output is zero. A real, register-level cause, and for an hour I thought I’d found the depthwise’s own private bug.</p><p>But it was a shape the vendor never makes. So I did the thing I’d been putting off: instrumented the vendor’s <em>own</em> driver, booted its stack, and watched it run the same depthwise for real. It tiles the layer into small tasks and re-points every unit at page one at the start of <em>each</em> task — so its producer and consumers never drift apart. Its multi-task depthwise runs with all four units aligned on the same page and writes a full, real output. The “separate depthwise wall” was my single-task shape breaking a ping-pong the vendor keeps in step by tiling. There was only ever one wall. The depthwise is just the first layer wide enough to be forced through it.</p><p>And there the honest trail ends, for now. Every byte I hand the chip matches the vendor’s — the arming, the command stream, the init, the pulse — and single convolutions prove I’m addressing the silicon right. The vendor’s units wake from the pointer arming and a single pulse; mine only wake from an in-stream enable that restarts the sequencer and so can’t survive a second task. I wanted to watch <em>how</em> the vendor re-arms its units between tasks and copy it — but on the board it finishes a two-task layer in microseconds, faster than I can read a register, so the one move I need to see is below what software can observe, on either side. Knobs, static comparison, live capture: all spent. The only difference left that I haven’t touched is underneath the driver entirely — the vendor boots a secure firmware (TF-A + OP-TEE, NPU clock and power over secure calls) that mainline doesn’t, and a secure-world init is exactly the layer a capture can’t reach. That’s the next dig. It’s a big one, and it’s for a clearer-headed day.</p><p>Where that leaves it: the convolution is byte-correct, the “two mysterious walls” turned out to be one, and that one is pinned to a single missing behavior — the units won’t re-arm themselves for each task the way the vendor’s do — that lives below every register I can read. The open driver is clean; the gap is in silicon state or the firmware that sets it. Not solved. But a month of <em>why is it all zero</em> has become one sharp, well-lit question, which is most of the work.</p><h2 id="I-did-the-firmware-dig-and-then-found-the-way-around"><a href="#I-did-the-firmware-dig-and-then-found-the-way-around" class="headerlink" title="I did the firmware dig, and then found the way around"></a>I did the firmware dig, and then found the way around</h2><p>The firmware was the last thing I hadn’t tried, so I tried it. I have a working mainline OP-TEE port for this chip — secure firmware, its own memory, passes its self-tests — so I built an image that boots the <em>same</em> rocket kernel on top of it, reserved OP-TEE’s memory in the device tree, and ran the multi-task test again. OP-TEE really came up: its secure-world banner prints on the console, the memory firewall is live. And the multi-task job failed byte-for-byte identically — not one unit engaged, nothing written. So it isn’t the presence of the secure firmware either. That was the last software-shaped stone to turn, and under it was the same wall.</p><p>But somewhere in writing all that down, the obvious thing I’d been walking past finally registered. The wall only bites <em>multi-task</em> jobs. Single-task convolutions engage and run perfectly — I’ve leaned on that a hundred times in this post. The wide layers are only multi-task because one row-tile is all the on-chip buffer holds at once. So don’t hand the hardware a multi-task job at all: send <strong>each row-tile as its own single-task job</strong> — its own input rows, its own weights, its own slice of the output — and let the kernel chain them, exactly the way it already chains one layer to the next. It’s wasteful (the weights get re-staged for every tile instead of reused), but every job is then a task-count of one, and <em>that</em> the chip runs.</p><p>I don’t know yet that it’ll carry a whole MobileNet — there’s real work in making each tile a self-contained job, and more walls could be waiting past the first depthwise. But it’s a road that goes <em>around</em> the one obstacle I’ve spent a month proving I can’t go <em>through</em>, built entirely on the part of the pipeline I’ve already made byte-exact. After a month of turning over stones and finding the same wall under each, that’s the first move in a while that isn’t trying to break it. That’s where I pick up next.</p><h2 id="The-road-around-and-the-wall-it-led-back-to"><a href="#The-road-around-and-the-wall-it-led-back-to" class="headerlink" title="The road around, and the wall it led back to"></a>The road around, and the wall it led back to</h2><p>So I built it — each tile its own single-task job — and it hung immediately. The bug was mine and it was old: with every tile now a separate job, the per-layer buffer-handle arrays were being freed once <em>per tile</em> in cleanup, an N-fold free that corrupted the heap. A latent bug in code that had never been exercised this way. I fixed it, and the first convolution came up computing a real feature map — so a batch of thirty single-task jobs is fine; the hang was the double-free, not the plan.</p><p>Then the depthwise tile showed a second, cleaner-looking bug: its producer sat on one half of the on-chip ping-pong buffer while its consumers read the other, empty half. There’s a pointer-advance bit that walks the consumers forward on their own; arm the tile with that bit off and all four units line up on the same half — I could read it back in the registers, every unit engaged, every unit aligned. Two bugs down, both real.</p><p>And the depthwise still drew zero. Aligned, engaged, reading its input — and the output writer never wrote a byte. So the ping-pong split was a red herring; fixing it changed nothing. With both distractions stripped away — the multi-task engage and the ping-pong — what was left underneath is the same wall I started the week at: the depthwise op, run as a single task, simply does not fire its output write, no matter what I hand it or how I align it. The vendor’s depthwise only ever computes as a <em>multi-task</em> job; mine won’t engage multi-task. So it’s boxed in from both sides — single-task, the write never fires; multi-task, the units never wake — and both sides are below every register I can read.</p><p>Path B did real work: it routes ordinary convolutions around the engage wall, and it turned up two genuine bugs worth keeping (the double-free especially). But it doesn’t carry the depthwise, because the depthwise doesn’t have a software-shaped door left. Every stone I can turn from up here, I’ve turned. What’s actually left is a hardware trace — watching, cycle by cycle, what the vendor’s chip does inside a multi-task depthwise that mine doesn’t do in a single one. That’s a different kind of tool than a rebuild and a reflash, and it’s where this particular month ends: not with the network running, but with the question finally small enough to hand to a logic analyzer.</p><p>Before conceding that, I did one more careful pass on the theory that it’s software after all — a wrong register somewhere, not a silicon quirk. So I stopped reading the command stream I <em>send</em> and instead read the registers the hardware actually <em>holds</em> mid-run, on the aligned single-task depthwise, next to a convolution that works. Everything is right: the output writer has its real destination address and the correct output height for the tile; the input engine has depthwise mode on and the right weight-byte count; the MAC array’s channel counts match; the weights were fetched, the units are engaged, the input is being read. And the write count stays flat zero. Not a wrong non-zero — an <em>exact</em> zero, which means the MAC array did no work at all, not wrong work. So it isn’t a configuration bug: every register I can read is correct and live. And it isn’t the engage wall either — this tile <em>is</em> engaged; for a depthwise, waking up and computing turn out to be two different things, and only the first happens. The op is set up perfectly, wakes, reads its input, and produces nothing. That’s the sharpest I’ve been able to make it: a perfectly-configured, engaged depthwise that does no multiplies, while the exact same setup as a multi-task job (the vendor’s) does. The line between those two is the whole mystery, and it’s below the last register I can see.</p><h2 id="The-wall-wasn’t-below-the-registers-—-it-was-the-question-I-kept-asking"><a href="#The-wall-wasn’t-below-the-registers-—-it-was-the-question-I-kept-asking" class="headerlink" title="The wall wasn’t below the registers — it was the question I kept asking"></a>The wall wasn’t below the registers — it was the question I kept asking</h2><p>Then it came down. Not to a logic analyzer, not to firmware — to the dispatch model, the one thing I’d been treating as fixed the whole month. The engage wall only ever bit <em>multi-task</em> jobs, where I handed the PC hardware a list of tasks and asked it to walk them itself. Single tasks always woke up. So I stopped asking the PC to iterate: keep the whole graph as one job, but dispatch it as N separate single-task kicks — one task, one start pulse, wait for the done interrupt, kick the next, no reset in between. Which, it turns out, is exactly what the mainline RK3588 driver does, and exactly what the RK3576 path had stopped doing. Two lines of kernel. And on the board every engine woke — not one convolution, the whole twenty-nine-task graph, every kick, all four units engaging, and conv0 reading its input and writing a real feature map to DRAM. A month of calling that wall “below the registers” and it was the shape of the submit. It was never silicon. I’d been letting the PC walk the task list, and on this chip that path simply never wakes the compute units.</p><h2 id="The-network-engages-and-still-draws-nothing-—-but-now-I-can-see-why"><a href="#The-network-engages-and-still-draws-nothing-—-but-now-I-can-see-why" class="headerlink" title="The network engages, and still draws nothing — but now I can see why"></a>The network engages, and still draws nothing — but now I can see why</h2><p>conv0 computes, and every layer after it is zero. Each one engages, reads nothing, writes nothing — the whole graph awake and drawing a blank. So I did the one careful pass I’d been putting off: I stopped diffing my command stream against itself and diffed it against the vendor’s own captured bytes — the exact twenty-four-task chain the vendor’s stack submits, pulled apart register by register next to mine. Two things fell out, and both were the opposite of what I expected. The vendor chains its layers through DRAM, not some on-chip trick: every intermediate activation lives in one 2 MB buffer, each layer reads its input from a real offset inside it, and only the tiles <em>within</em> a layer reuse the on-chip buffer. And my chained-layer command stream is byte-for-byte identical to the vendor’s — same registers, same values, the input address correctly pointed at a filled, mapped buffer. So it isn’t my code, and it isn’t the addresses.</p><p>What’s left is <em>when</em> the task runs. The first task of a job — conv0 — reads its input from DRAM and computes. Every task after it skips that read, because the input engine assumes the previous task left its data warm in the on-chip buffer. In the vendor’s one continuous submit that assumption is true: the buffer really is warm, task to task, and the 2 MB of DRAM is just the spill for what won’t fit. My sequential kicks tear the pipeline down between tasks — a pointer reset, a start-pulse cycle, thirty microseconds of gap — so the buffer is warm-looking and empty, and the skipped read finds nothing. The chain needs the on-chip buffer to stay warm across layers, and the very move that let me wake the units — kicking each task on its own — is what cools it. The road around the engage wall led right back to it: to keep the buffer warm I need the <em>continuous</em> submit, which means waking the units <em>that</em> way — the wall I just walked around. But I know its shape now, and I know single-task arming wakes them. This time the question is small and it points somewhere real, instead of at a register I can’t read. That’s where I pick up next.</p><h2 id="The-buffer-was-going-cold-between-the-beats"><a href="#The-buffer-was-going-cold-between-the-beats" class="headerlink" title="The buffer was going cold between the beats"></a>The buffer was going cold between the beats</h2><p>The chain broke because the on-chip buffer went cold between my sequential kicks, so first I reached for the blunt fix: power-cycle the NPU between layers — cut the power, the buffer clears, and each layer starts cold the way conv0 does right after resume. It took three tries just to make the power-off <em>fire</em> — the runtime-PM autosuspend kept swallowing it, and I had to force a synchronous suspend before the job’s fence signalled so the scheduler couldn’t power the chip back up underneath me. And when it finally fired, the board hung dead. Powering the NPU domain down mid-inference cascades the <em>parent</em> domain off (it took the second core with it), and bringing it back up wedges on a power-ack that never comes. The vendor never powers the NPU down mid-graph — the whole network runs on one powered session — so that rapid off&#x2F;on is unexercised silicon, and it doesn’t come back. Dead end, cleanly.</p><p>Then the quiet fix, the one that was there the whole time. Every single kick, the driver was re-running a ping-pong state reset — a pointer clear the vendor only ever does once, at init. That clear was resetting <em>which half</em> of the on-chip buffer the input engine reads, so a chained layer looked at a freshly-cleared half instead of the half the previous layer had just filled. Each task’s own command stream already arms its pointers; the per-kick reset was pure collateral damage, cooling the buffer between every beat. So I ran it only on the first task of the graph and skipped it on all the rest.</p><p>And the chain came alive. For a month every layer after conv0 had been flat zero — no input read, no multiplies, nothing at all. Now they engage, DMA their own input and weights, and the MAC array does four times the work it did before; the outputs aren’t 0x00 anymore but real, varying bytes moving from one layer to the next. It isn’t right yet — the values saturate to the zero-point and collapse to nothing over a few layers, so the final answer is still empty — but that is a completely different kind of problem. For a month the wall was “the compute does nothing.” Tonight it’s “the compute runs, at the wrong scale.” That’s a calibration bug — the ordinary, tractable kind — not a wall below the registers. After a month of pushing on something that didn’t move, the dead wall finally became a slope.</p><h2 id="Down-to-one-seam-the-pointwise-layer-that-computes-without-weights"><a href="#Down-to-one-seam-the-pointwise-layer-that-computes-without-weights" class="headerlink" title="Down to one seam: the pointwise layer that computes without weights"></a>Down to one seam: the pointwise layer that computes without weights</h2><p>I chased the “wrong scale” and it wasn’t scale at all. Amplifying the requant a thousandfold didn’t move the output off the zero-point, which only happens when the multiply array had nothing to multiply — an empty accumulator, not a real result scaled wrong. The counters said it plainly: on the chained layers, the input reads but the <em>weights</em> don’t. And it splits cleanly by layer type. MobileNet alternates a depthwise (a tiny per-channel filter) and a pointwise (a 1×1 convolution mixing every channel), and the depthwise layers fetch their small weights fine while the pointwise layers fetch <em>nothing</em> — so a pointwise runs on bias alone, the ReLU flattens it to the zero-point, and the whole graph collapses a few layers in.</p><p>The pointwise command stream is byte-for-byte the vendor’s, so it isn’t what I’m sending. I tried the cheap ways to force the fetch. Give the pointwise a bigger slice of the on-chip weight buffer — no movement. Stage its weights into the on-chip SRAM myself and point the engine there — and the one on-chip window the hardware actually reads from is the exact range my own buffers already occupy; forcing the mapping in just corrupts the address translation and takes the whole chain down to zeros. Every cheap lever is spent.</p><p>What’s left is the shape of the thing. The vendor runs the entire graph as one continuous submission, and while one layer computes the sequencer is already <strong>pre-loading the next layer’s weights</strong> into the on-chip buffer. The depthwise’s handful of weights I can pull in per-kick; the pointwise’s fat weight matrix genuinely needs that pipelined pre-load, and my one-task-at-a-time dispatch — the very trick that let me wake the units and run the feature path — has no pipeline to pre-load from. So the last seam is the one wall this whole approach was built to walk around: getting a single continuous multi-task submission to engage <em>and</em> step through all its tasks. That’s a register-and-microcode job, below where the public docs go, so it’s its own campaign for another night. But the map is finally down to one road.</p><h2 id="The-pointwise-wasn’t-the-seam-—-the-first-task-was"><a href="#The-pointwise-wasn’t-the-seam-—-the-first-task-was" class="headerlink" title="The pointwise wasn’t the seam — the first task was"></a>The pointwise wasn’t the seam — the <em>first task</em> was</h2><p>I’d left it at “the pointwise layer needs the pipeline the vendor has and I don’t.” I was wrong, and the way I found out was to stop theorizing and lay my command stream for a single depthwise next to the exact bytes the vendor’s chip runs for the same op — register by register, all four engines. They’re identical. Every one. Same mode, same sizes, same weight counts, the input and weight addresses each pointing at real, mapped memory — the only differences are the addresses themselves (mine absolute where the vendor’s are offsets) and one requant triplet that belongs to a different layer entirely. So the depthwise I send is, byte for byte, the depthwise that computes on the vendor’s stack. And on mine it reads its input, wakes every unit, and writes a flat zero.</p><p>If it isn’t the bytes, I thought, it’s the teardown — the pointer resets and interrupt-clears and counter-clears my driver does between one kick and the next, which the vendor’s single continuous submit never does. So I added a switch to turn each of those off, one at a time and then all at once, and measured the one number that matters: does the second layer finally compute? It does not. Not with the pointer re-arm skipped, not with the interrupt-clear stripped to the bone, not with every last teardown step disabled together. The layer after conv0 stays exactly zero through all of it. The teardown was never the killer.</p><p>So the pointwise-weights story was a red herring, and so was the teardown, and what’s left after burning both down is sharper than either. It isn’t the depthwise versus the pointwise — <em>every</em> layer past the first is dead, depthwise and pointwise alike, each one reading its input and holding a byte-perfect command stream and still doing no multiplies. The only thing that computes is the <em>first task of a job</em>. conv0 fires because it’s what runs right after the driver wakes the chip for the job; everything after it rides the same powered session and draws a blank. The vendor’s standalone depthwise, run as its own job, computes — mine, run as a follow-on kick inside a job, doesn’t, from the identical bytes. The one thing a fresh job does that a follow-on kick skips is the power-management resume that greets the chip at the top of every job. That’s the whole remaining question now, and it’s finally a small one: does waking the chip per layer — a real job apiece, not a string of kicks — bring the multiply array back for every layer the way it does for the first? That’s the next thing I flash.</p><h2 id="The-floor-under-the-floor"><a href="#The-floor-under-the-floor" class="headerlink" title="The floor under the floor"></a>The floor under the floor</h2><p>The per-layer wake didn’t survive contact either. First I just turned the wake’s reset off — if the fresh reset is what lets a layer compute, killing it should kill the first layer too. It didn’t: conv0 came up fine without the reset, so the reset was never the thing. Then I tried the opposite, forcing a full reset before every follow-on layer, and the driver faulted on the way out — a null-pointer dereference tearing down buffers, because a mid-graph reset corrupts the very address mappings the buffers get freed against. Two more theories, both dead, one of them taking the board down with it.</p><p>So I stopped guessing and did the slow, unglamorous thing: I re-read what I’d been calling proof. My evidence that “the vendor’s depthwise computes, so it must be my context, not my bytes” turned out to rest on a depthwise that read the camera image — an <em>external</em> input, exactly like conv0. Every time I’d watched a layer read a <em>previous layer’s</em> output, it drew a blank, on the vendor’s own captured bytes as much as on mine. The honest version of a month of work is smaller and colder than I’d let myself write: the first layer of a network computes, and no layer that consumes another layer’s output ever has, in any way I can dispatch it. I even chased the obvious culprit — that the second layer reads stale memory before the first layer’s writes have landed — and the driver’s own dump killed it: the input buffer holds the first layer’s real, varied output at the moment the second layer reads it. It reads the right bytes and multiplies them by nothing.</p><p>That left one thing I’d never actually done end to end: read the whole driver, top to bottom, next to the vendor’s, hunting the bug I was certain had to be there. The submit sequence — the exact order and values written to start the engine — matches, register for register. The command bytes match. The requant math is the one I’d already proven exact. The buffer-pointer value I’d suspected of desyncing the pipeline is the identical value the vendor writes. The completion path, the performance counters I read the verdict from, the clocks — all correct, all the same. I went in certain there were several bugs left and came out with none: everything the software touches is byte-for-byte the chip’s own vendor stack.</p><p>Which is, in its way, the answer — just not the one I wanted. When every byte you send and every register you write matches the working stack, and the only thing that flips the outcome is a single field that says <em>run one task</em> versus <em>run many</em>, then the thing deciding whether the multiply array turns on lives inside the sequencer, underneath the last register I can see or write. On a chip with public documentation I’d look it up; this one has none, and the vendor’s version of that logic is compiled microcode I’ve already matched to the last byte around it. So the month closes not on a fix and not on a mystery, but on a boundary drawn exactly: the open driver runs the first layer of a real network correctly, and the seam to everything after it is one bit in a control register whose far side is silicon I can’t read. A smaller island than I’d hoped to be standing on — but one I’ve finally measured all the way to the waterline.</p><h2 id="The-floor-had-a-door"><a href="#The-floor-had-a-door" class="headerlink" title="The floor had a door"></a>The floor had a door</h2><p>I was wrong about the waterline. What broke it was the one idea I’d talked myself out of. The working sibling chip chains its tasks a second way — not by telling the sequencer “run N tasks and stride through them,” but by writing, into the tail of each task’s own command list, the <em>address of the next task</em>. The sequencer finishes one and follows the pointer to the next, like a linked list walked in hardware. I’d dismissed it because our chip’s vendor doesn’t use that path — it uses the strided one — and I’d let “the vendor doesn’t” harden into “the chip can’t.” Those aren’t the same sentence. So I built it: taught the encoder to leave that next-address slot at the end of every layer, and taught the packer to thread the whole graph through it, each layer pointing at the next.</p><p>And the counter moved. For the first time in a month, a single multi-task submit read <em>past</em> the first layer. The input-DMA total came back at exactly the first layer’s bytes plus the second layer’s bytes — 9408 and 20384, summing to 29792 on the nose, the weight-fetch counter the same story, 96 plus 36 — which only happens if the sequencer finished conv0, followed the pointer into the depthwise, and pulled <em>its</em> inputs and weights too. The chip follows next-pointers. The wall I’d spent a week proving was silicon underneath the last register I could reach was never “the sequencer can’t move to the second task.” It moves. I’d measured to the waterline of the wrong sea.</p><p>It isn’t a win yet, and I want to be exact about how far it goes. The first layer now commits its output where before it wrote nothing — but the output is flat, the multiply array still idling on an empty accumulator, the same “runs, engaged, computes nothing” I’ve been circling, except now it happens <em>while also advancing</em>. And it advances exactly one hop: it walks into the depthwise, loads it, and stalls there, because the depthwise finishes no more than the first layer did and the sequencer waits on a completion that never comes. So two things are now cleanly separated that were tangled all month: <em>moving through the graph</em> and <em>doing the multiplies</em>. The pointer chain solved the first. The second — the empty accumulator in multi-task mode — is the whole remaining problem, and it’s a different, smaller shape than “the far side is silicon.” It even hands me the next lever: the multiplies work when the chip is told “run one task,” so the move is to run each task as <em>one task</em> and let the pointer, not the task counter, do the walking. That’s the next thing I flash. The island got bigger while I was measuring it.</p><h2 id="The-door-led-to-one-room"><a href="#The-door-led-to-one-room" class="headerlink" title="The door led to one room"></a>The door led to one room</h2><p>Running each task as its own single-task kick — the lever the pointer chain had handed me — did what it promised and no more. The layers past conv0 came awake: they engaged their units, pulled their own input over the bus, even read the <em>real</em> output the previous layer had left in memory, the varied bytes and all. And then multiplied them by nothing. The chain now walked the whole graph, every layer reading the right data, and every layer after the first writing a flat zero. Advancing was solved. Computing was not.</p><p>I nearly filed that as “the accumulator is empty in multi-task mode” and moved on, except the number I was reading it from was a lie. My readback had been dumping the output of the <em>last</em> task in the job — the one that, in a stalled chain, never runs — so of course it read empty, every time, for every experiment I’d run for weeks. When I fixed it to dump every task’s own output, the picture inverted: conv0 wasn’t the fragile thing that computed “sometimes,” it computed a full, real feature map in <em>every</em> mode I had. I’d been reading the wrong slot and calling conv0’s health a mystery. conv0 is fine. It’s everything after it that’s empty — and now I could finally see exactly that, per layer, instead of inferring it from a buffer that never ran.</p><h2 id="It-was-the-first-task-all-along"><a href="#It-was-the-first-task-all-along" class="headerlink" title="It was the first task all along"></a>It was the first task all along</h2><p>So I asked the one question that separates every theory I had left: is it the <em>layer</em>, or is it the <em>position</em>? I ran conv0 a second time — the same command stream, the same camera image, the same everything — but as a follow-on kick instead of the first task after the chip wakes. It wrote sixty-three bytes where the first run had written twenty-five thousand. Same bytes in, nothing out, and the only thing I’d changed was that it wasn’t first anymore. (It even fooled me for a minute: the output buffer still read full, until the write counter showed those were the <em>first</em> run’s bytes sitting there stale, never overwritten.) That’s the whole wall in one measurement: not the depthwise, not the pointwise, not the data, not the addresses. The multiply array arms for exactly one task per wake — the first — and every task after it, conv0 included, rides a dead accumulator.</p><p>With that pinned, I did the thing I’d been circling for a month: read the working sibling’s open driver end to end, next to mine, but only the path that starts task number two. Every register write matches — the pointer arming, the mode bits, the interrupt mask, the task-control word, all of it, value for value. There is exactly one difference in the whole per-task sequence: the sibling raises the engine’s “go” bit and <em>leaves it up</em> through the computation, dropping it only when the task reports done; I raise it and drop it in the same breath at submit, the way our chip’s own vendor does. It was a real, specific, only-remaining difference, and it had a clean story — maybe the drop-at-completion is the pulse that re-arms the next task. So I matched the sibling exactly: hold the bit up, complete on the unit’s own done-signal, with a hard timeout so a chip that never finished couldn’t hang the board.</p><p>It didn’t move. The second layer stayed as empty with the bit held high as it was with the bit pulsed low. And that spends the last thing I had. The command bytes match the working chip, the whole register sequence matches, the completion handshake matches, and now the one timing difference matches too — and the layer after the first still does no multiplies. The chip that computes every layer and the chip that computes only the first are byte-for-byte identical in every field the software can write. So the switch that arms the multiply array for the first task and only the first is not a register at all: it’s a state inside the sequencer that a fresh wake sets and the first task spends, below the last line of the driver. One reading still nags — a byte counter that climbs a little further than an all-empty chain should — and I’ll chase that to the ground before I call it, because I’ve mistaken a bad measurement for a wall once already this month. But barring that, the road that started with “adapt the working sibling’s driver” ends here, cleanly, at a door with silicon on the other side.</p><h2 id="I-chased-the-nag-to-the-ground"><a href="#I-chased-the-nag-to-the-ground" class="headerlink" title="I chased the nag to the ground"></a>I chased the nag to the ground</h2><p>The one reading that nagged was a byte counter climbing a little further than an all-empty chain had any right to — the sort of loose thread I’d mistaken for a wall once already, so I refused to call anything until I’d pulled it. The clean way to pull it was to stop trusting the buffer and stop trusting the counter both: I zeroed every output buffer before the run, so nothing I read afterward could be a leftover from a previous pass, and then I read each task’s own buffer back, one at a time. conv0 wrote a real, varied feature map into its freshly-cleared space. Every task after it came back empty, or pinned flat to the zero-point, every single dump. And the climbing counter turned out to be exactly what a climbing counter usually is — a running total that never resets between tasks, a dirty odometer, not new work happening somewhere I wasn’t looking. So the nag closes the way the honest ones do: it was a bad reading, not a wall. Only the first task computes. Now with the stale-data trap sprung shut, I could say it without a caveat.</p><h2 id="Then-I-read-every-last-write"><a href="#Then-I-read-every-last-write" class="headerlink" title="Then I read every last write"></a>Then I read every last write</h2><p>Which left me sitting on a claim I hadn’t actually earned. “Every register the software writes matches the working stack” — but I’d only ever matched the <em>first submit’s</em> writes, the burst that starts one job. A whole network isn’t one burst. The vendor’s driver touches the engine on every interrupt, at every task boundary, when it resets, when it wakes the chip from sleep. I had never once listed <em>all</em> of it, both drivers side by side, across a full inference — I’d matched the opening move and assumed the rest of the game.</p><p>So I did the slow enumeration: every place either driver writes an engine register, in every function, start to finish. And one idea tempted me the whole way through. There’s a register the vendor writes that I always leave at zero — a “task base address,” the sort of thing a sequencer might read to find where task two lives. For a day I was sure that was it: the address the vendor’s hardware walks and mine never gets. Then I pulled my own capture of a real two-task vendor run and read that register in it. Zero. The vendor steps from task one to task two with the same zero I write. Even the one field I’d hoped was the seam holds the identical nothing on both stacks.</p><p>The enumeration finished the way the audit before it did, only complete this time: across a whole inference there is not one engine register the vendor writes that I don’t. The values match, the registers match, and the only differences run the wrong way — places where <em>I</em> write more than the vendor, not less. Which means the thing that separates the chip that computes every layer from the chip that computes only the first is not a write at all. It’s <em>shape</em>: the vendor hands the hardware one submission that says “run all N tasks,” and the sequencer walks them itself; I hand it N submissions and walk them by hand. Same writes, different grammar.</p><p>A static read can prove the writes match but it can’t watch them <em>happen</em>, and only the happening can catch a difference of order or count. So I built the last instrument this stretch needs: a switch in each driver that logs every engine write, in sequence, with its value and its caller, for one whole inference — the vendor’s and mine emitting the same format against the same absolute addresses, so the two logs line up write for write. The next flash produces both, and a short script prints anything the vendor does that mine doesn’t, or does in a different order. If it prints nothing — and the static read says it will — then the search leaves the register file for good, and turns to the only thing left standing: the one-submit-many-tasks grammar, and whether the command bytes themselves can be taught to chain the way the sibling chip’s do. That’s the road I’m on now, and for once I built the map before I drove it.</p><h2 id="Kiln-compromise-or-stepping-stone"><a href="#Kiln-compromise-or-stepping-stone" class="headerlink" title="Kiln: compromise, or stepping stone"></a>Kiln: compromise, or stepping stone</h2><p>A month of the open driver ends at a boundary I can name but not cross — the sequencer arms the multiply array for the first task of a job and no other, and the difference between the chip that computes every layer and the one that computes only the first is a state below the last register I can write. The vendor’s stack doesn’t have that problem. Its <code>rknpu</code> driver and its closed <code>librkllmrt</code> run whole networks, today, on the 6.1 BSP kernel. So there was an obvious, slightly uncomfortable move I’d been putting off: stop trying to <em>understand</em> the NPU and just <em>use</em> it — take the vendor stack and drag it onto a mainline kernel. I called that Kiln, and the whole time I built it I couldn’t decide whether it was a retreat or a bridge.</p><p>Kiln is deliberately not the open dream. It fetches the GPL vendor <code>rknpu</code> driver — v0.9.8, the version <code>librkllmrt</code> expects — ports it from the 6.1 BSP to mainline 7.x with a patch’s worth of API shims (the <code>drm_driver</code> fields that moved, <code>hrtimer_setup</code>, the <code>iommu_map</code> gfp argument, the dozen small drifts six years of kernel accumulate), and drives it with Rockchip’s closed runtime. Nothing here is reverse-engineered. The matmul I don’t understand at all; I hand the model to a blob and the blob talks to the driver. Where rocket was a month of <em>knowing</em>, Kiln is an afternoon of <em>wrapping</em> — and then a week of the wrapping not working, which is its own story.</p><p>Because the first thing Kiln did on mainline was fail in a way I recognized. The driver loaded, <code>librkllmrt</code> loaded the model, the runtime-and-driver version-lock passed on the board — and every NPU job timed out with <code>task_counter = 0</code>, <code>raw status 0x30000000</code>, the DPU-done bits never set. That is the rocket wall’s exact face: engaged, configured, computing nothing. For a bad day I thought I’d carried the silicon wall across to the vendor stack too — that the mainline port had somehow inherited the thing below the registers.</p><p>It hadn’t. This time the wall <em>was</em> a register. The RK3576 NPU is one device with two IOMMUs, one per core. Mainline’s <code>rockchip-iommu</code> manages a single primary iommu per device — <code>dev_iommu_priv_get</code> returns one — so with two <code>iommus</code> in the device tree the second is silently orphaned: never attached, never enabled, paging off, its page-directory register a flat zero. Core 0’s job points the command processor at a high IOVA, the orphaned MMU doesn’t translate it, the address is taken as raw physical, the fetch lands on nothing, and the sequencer runs a task worth of nothing. No fault — with paging off there is no translation to fault. <code>task_counter = 0</code>.</p><p>I found it the way rocket taught me to: by dumping the state the failing job runs against, mid-run, instead of theorising. The MMU status register read <code>0x18</code> on one core — paging bit clear — and <code>0x19</code> on the other, paging on, a real page-directory base. One MMU enabled, one dark. So the driver learns the page-directory base from the bank the kernel <em>did</em> light up and writes it into all four banks itself — DTE, zap the TLB, unmask the fault, enable paging — and zaps every core’s TLB before each job so the orphan can’t serve a stale translation. Four banks reading <code>0x19</code>, and the matmul ran.</p><p>It works. Qwen2.5-1.5B, w4a16, on the RK3576 NPU, on a mainline 7.1 kernel: about nine tokens a second of decode, three hundred milliseconds to first token, coherent English coming back. After a month of an open driver that computes exactly one layer, watching a closed one compute a whole transformer — twenty-eight of rocket’s layers’ worth of matmul per token, thousands of times over — is a strange feeling. It’s the result I wanted from the other project, arriving through the door I’d refused to use.</p><p>And it costs what I was afraid it costs. The runtime is a blob I can’t read, version-locked to the exact toolkit that converted the model — a <code>librkllmrt</code> one release too new and the model won’t load. The DDR init is still a Rockchip binary even under a mainline SPL; that floor doesn’t open for this chip. And I understand the NPU no better than I did before Kiln — less, arguably, because now there’s a working thing to hide behind. rocket was trying to <em>earn</em> the answer; Kiln buys it. The parts of Kiln I can be proud of are the boring ones: the whole thing is a fetched GPL driver plus one patch plus a device-tree overlay, no kernel fork, so it installs on a stock Armbian kernel with DKMS and a <code>.dtbo</code>. It’s a real thing someone can run today. It’s just not a thing that taught me anything about silicon.</p><p>And there’s a control experiment worth running. If the driver fix — light every MMU bank, keep the TLB clean — is real and not matmul-specific, then a <em>convolution</em> should run through the same NPU just as well: same driver, a different runtime, RKNN instead of RKLLM. So MobileNet became the CNN standing next to the transformer — and, not incidentally, the exact op rocket spent a month failing to compute in the open. It fought me for an evening, but a floor up from the silicon, not in it: <code>librknnrt</code> threw an out-of-range setting up the input, and it was the same version-lock RKLLM has — a MobileNet converted with toolkit 2.1.0 against a 2.3.0 runtime. Match the two (a stock ONNX MobileNetV2 converted at 2.3.0) and it just runs: the bell test image comes back <strong><code>chime, bell, gong</code></strong>, top-1, in 6.2 ms — about a hundred and sixty frames a second. So the fix carries. The vendor stack on mainline runs a transformer’s matmul and a convnet’s convolution on the same silicon rocket couldn’t get a single conv out of — which is either the sharpest possible argument that the wall was never the chip, or the sharpest possible reminder of what “borrowing” buys you. Depending on the day, I read it both ways.</p><p>So: compromise or stepping stone. Both, and the ratio is the point. Kiln is a compromise in the way that matters most to the other project — it doesn’t <em>know</em> anything; it wraps a blob. But it’s a stepping stone in three concrete ways rocket can use. It proves the chip and the mainline platform are sound: the vendor stack runs whole networks on the exact kernel rocket runs on, which means rocket’s wall is neither the silicon nor mainline — it is purely the open driver’s dispatch grammar, exactly where the last month left it. It puts a byte-for-byte oracle on the same board instead of the 6.1 BSP. And it ships a working NPU-on-mainline today, which buys the open effort the one thing it is short of, which is time. I set out to build the RK3576 NPU in the open. What I have instead is one layer of it truly open and the rest of it honestly borrowed — and a much sharper sense of which of those two the wall actually cares about.</p><h2 id="The-grammar-matched-and-the-wall-didn’t-move"><a href="#The-grammar-matched-and-the-wall-didn’t-move" class="headerlink" title="The grammar matched, and the wall didn’t move"></a>The grammar matched, and the wall didn’t move</h2><p>I closed the last chapter certain of one thing: the wall was “purely the open driver’s dispatch grammar.” I’d earned that sentence — the register audit was complete, the bytes matched, the only gap left was <em>shape</em>, one submission that says “run all N tasks” versus my N submissions walked by hand. So I went and built the shape. I pulled a live trace off the vendor’s own driver mid-run — every register it touches, in order, with values — and read off exactly how it tells the chip to move from one task to the next: not a task counter at all, but a pointer buried in the tail of each task’s own command list, the address of the next task’s command list, followed by a sync word and a broadcast “go” that the sequencer follows like a linked list walked in hardware. I built the packer that writes that same tail into every task, threaded a whole graph through it, and wired the submit path to match the vendor’s, register for register, down to a task-count field whose bit width I’d had wrong for this chip specifically — a 16-bit field where the header I was coding against described a 12-bit one from the chip’s older sibling, silently mis-decoding the exact control bits that arm the sequencer.</p><p>And it worked, in the sense that matters for <em>this</em> question. The task-status counter climbed — one, then two, then all the way to six on a six-task graph, a number that had sat frozen at one for a month. All four compute units engaged on every task, not just the first. The DMA counters were clean. The done bit fired, clean, on schedule. Every single thing “the dispatch grammar was wrong” predicted would fix, fixed. And every task after the first still wrote a flat zero. I had built, byte for byte, register for register, the vendor’s exact way of telling the chip to walk a graph — and the chip walked it, and computed nothing while walking. The sentence I’d closed the last chapter on was wrong, and this time I had the counter that proved it wrong instead of a hunch.</p><h2 id="Native-iteration-same-silence"><a href="#Native-iteration-same-silence" class="headerlink" title="Native iteration, same silence"></a>Native iteration, same silence</h2><p>The one thing left that could still be blamed on my own driver was my own driver’s presence — the polling, the register reads I do mid-run to watch the chip work, any of which might be perturbing the very thing I was trying to observe. So I built the version with none of that: submit once, touch nothing until the final done bit, let the sequencer iterate the whole graph with zero software in the loop during the run. If some driver-side interference was quietly re-arming the compute path between tasks — a side effect of a register read I didn’t know mattered — taking every read away should show it.</p><p>It didn’t show anything different. Byte-identical result to the driven case, task for task. That retires the last version of “it’s something <em>I’m</em> doing mid-run” I had left to try. The sequencer, entirely on its own, walks a graph and multiplies nothing past the first stop.</p><h2 id="The-escape-hatch"><a href="#The-escape-hatch" class="headerlink" title="The escape hatch"></a>The escape hatch</h2><p>If the chain itself was somehow poisoned — some state a <em>following</em> task inherits from being a follow-on at all, regardless of how it’s told to advance — then the fix was to stop chaining. Don’t submit a graph. Submit every layer as its own job, independent, no pointer, no task counter, nothing connecting one to the next except that the second one’s input happens to be the first one’s real output, sitting in memory where I put it. If the poison is in the chaining mechanism, an unchained submit should be clean.</p><p>I ran it that way for two whole inferences, every layer its own job, and read every output buffer back after pre-zeroing it, so nothing I saw could be a previous run’s leftovers. Two lines out of three hundred and sixty-one came back real. Both of them were the <em>first</em> op of an inference — the one right after the chip woke from being idle. Every other op, in both runs, wrote nothing at all — not even the zero-point a chained layer at least manages, genuinely nothing, as if the job had never touched the buffer. Including the second op of the second inference, which read the first op’s actual, verified-correct output as its own input and still produced silence. I’d spent weeks asking whether I was chaining tasks the right way. The chip answered a different question: it doesn’t matter whether I chain them at all. Only the first job after a wake computes, chained or not, and the <em>wake</em> — not the graph, not the pointer, not the task counter — is the thing that’s scarce.</p><h2 id="One-kernel-two-drivers"><a href="#One-kernel-two-drivers" class="headerlink" title="One kernel, two drivers"></a>One kernel, two drivers</h2><p>Every claim I’d been making about “the vendor does X, mine doesn’t” up to this point was a claim across two different kernels, two different builds, two different sessions on the board — inferred by reading two drivers’ source side by side, not measured on the same machine at the same moment. That’s a gap I’d been living with for a month because closing it looked like a lot of plumbing for not much payoff. Eventually the payoff got big enough to be worth it: I built one mainline kernel that can boot <em>either</em> driver — the vendor’s or the open one — off two device-tree variants selected at boot, on the same board, same firmware, same everything except which driver claims the NPU. Every “the vendor writes this, rocket doesn’t” sentence turned, from that point on, into two traces on one kernel set-diffed against each other, not two readings of two different source trees taken on faith that nothing else had drifted.</p><p>It bought back a category of doubt I hadn’t realized I was still carrying. The clock and power-domain housekeeping around a job, every register either driver touches in the NPU block or its IOMMU across a full inference, the exact order of a submit — all of it, identical, on one board, one boot, one measurement, not an inference. It’s a slower way to ask a question I’d mostly already answered, but this time the answer doesn’t depend on trusting that two separate builds were configured the same way.</p><h2 id="A-fix-that-had-never-run"><a href="#A-fix-that-had-never-run" class="headerlink" title="A fix that had never run"></a>A fix that had never run</h2><p>The one-kernel rig turned up something the register audit alone never would have: a fix I’d already shipped had never actually run. Weeks earlier I’d suspected a stale IOMMU translation — a reused address serving a previous buffer’s mapping — and “fixed” it by calling the generic kernel API that’s supposed to flush every stale entry before a job starts. It compiled, it didn’t error, I moved on.</p><p>What I hadn’t done was watch it fire. When I finally traced it, on the same-kernel rig, the call landed and did <em>nothing</em> — mainline’s driver for this chip’s IOMMU family doesn’t implement the callback that generic flush is supposed to call, so the kernel core skips it as a matter of routine, silently, exactly the way a missing optional callback is supposed to be skipped. My fix had been a no-op since the day I wrote it, and I’d been carrying “TLB staleness: ruled out” on the strength of a function call I’d never confirmed actually did anything. I wrote the real one — a raw register write to the chip’s own cache-zap command, the same operation the generic API was supposed to trigger — and traced it firing, on both banks, every submit. The wall didn’t move a millimetre. Stale TLB entries really were never the cause. But I’d been <em>right</em> for the wrong reason for weeks, and the only way I found that out was by refusing to count a fix as real until I’d watched it happen on the wire.</p><h2 id="The-buffer-really-is-empty"><a href="#The-buffer-really-is-empty" class="headerlink" title="The buffer really is empty"></a>The buffer really is empty</h2><p>One doubt kept nagging under all of this: every “the output is empty” reading was a CPU read of memory the NPU had supposedly written, and CPU reads go through a cache. If that cache were serving a stale line instead of the truth, “empty” could be a lie my own instrument was telling me, the same way a dirty odometer once was. So I filled an output buffer with a sentinel pattern before a run, forced an explicit cache invalidate immediately before reading it back — bypassing whatever a routine driver-level sync might or might not have actually done — and compared. If the DPU had written real data and the ordinary read path just hadn’t invalidated properly, the forced read would show something different from the routine one.</p><p>It didn’t. Sentinel in, sentinel out, forced read matching the routine read exactly, on every buffer, every time. The buffer really is never written. That was the last way my own measuring stick could have been the thing that was broken instead of the chip, and it wasn’t.</p><h2 id="Asking-someone-who-hadn’t-read-any-of-this"><a href="#Asking-someone-who-hadn’t-read-any-of-this" class="headerlink" title="Asking someone who hadn’t read any of this"></a>Asking someone who hadn’t read any of this</h2><p>By the time I’d run all of the above I was too close to my own story to fully trust my own read of it, so I did something a little unusual: I asked for a from-scratch audit of the whole stack — my driver, the vendor’s, the userspace command builder — done cold, with none of my own notes open, on purpose, to see whether a reader who’d never seen a month of dead ends would land somewhere I hadn’t.</p><p>It came back with two candidates, and both of them were instructive in exactly the way I’d hoped and slightly dreaded. The first was the idea that the vendor might use a hardware descriptor-array mechanism I’d never actually tried in combination with the right task count — a real gap in what I’d tested, argued cleanly from the source. It was also something I’d already written down as false, from my own capture of the vendor’s real submit: the address field that mechanism depends on reads zero in the vendor’s own trace, at every task count I’d captured. A cold read had walked, independently, straight back onto ground I’d already covered and already closed — which is either a coincidence or a sign that the ground really is that well covered; I choose to read it as the second. The other candidate collapsed to my own furthest conclusion, rediscovered rather than assumed: that the compute engine’s arm is a per-power-session resource, not a per-task one. Nothing survived the pass that I hadn’t already either tried or already refuted. It’s the least dramatic possible outcome of asking someone else to check your work, and I’ve come to think that’s exactly what a genuinely finished negative result should feel like.</p><h2 id="Where-I’m-leaving-it"><a href="#Where-I’m-leaving-it" class="headerlink" title="Where I’m leaving it"></a>Where I’m leaving it</h2><p>Every theory that fits in a register, a config bit, a buffer, a cache line, or a dispatch grammar has been tried and has failed to move the outcome, across two structurally different investigations and a third that checked both without reading either first. Dispatch mechanism — sequential kicks, native hardware iteration, the vendor’s own linked-list grammar rebuilt bit for bit, even fully independent jobs with no chaining relationship at all. Registers — every write either driver makes, matched value for value, across a whole inference. Config geometry, forced whole-cloth from a known-good capture. The on-chip staging buffer, audited before and after a chained task and shown to hold the real, correct data anyway. Cache coherency and stale TLB entries, both chased to ground and both cleared with a fix I could watch fire on the wire. Clock, power domain, firmware — the same secure boot stack either way. None of it is the seam.</p><p>What’s left is a shape, not a guess: a compute-accumulate stage that arms itself once per power-on and never again, independent of how many tasks I ask for or how I ask for them — a state inside the sequencer, underneath the last register this driver, or the vendor’s, will show me. That’s not a wall a register trace crosses. It needs the chip’s own documentation of that sequencing, or a way to watch the microcode itself, and I have neither. So I’m parking it here, in writing, with the full ledger of what was tried and what it ruled out — not because I’ve stopped caring whether MobileNet finishes running on the open driver, but because the next experiment I could run is one I’ve already run under a different name. The honest thing to write down is not “I couldn’t fix it.” It’s “I can tell you exactly which floor the fix isn’t on, and precisely how I know.”</p><p>The whole falsification ledger — every dimension, every test, every result — is in the <a href="https://github.com/gahingwoo/linux-rk3576-npu/blob/main/CHAINED-CMAC-STOPPING-POINT.md">rocket bring-up repo</a>. Kiln keeps running whole networks in the meantime, on the vendor’s terms, which is still the honest trade I described it as three chapters ago. If new evidence ever moves this — vendor documentation of the sequencer, or a look inside the microcode I don’t have today — that page is where I’ll say so.</p></details><script src="/js/reveal-toc.js" defer></script><div style="margin-top: 2.5em;"><div class="a2a_kit a2a_kit_size_32 a2a_default_style"><a class="a2a_dd" href="https://www.addtoany.com/share"></a><a class="a2a_button_facebook"></a><a class="a2a_button_twitter"></a><a class="a2a_button_linkedin"></a><a class="a2a_button_reddit"></a><a class="a2a_button_hacker_news"></a><a class="a2a_button_slashdot"></a><a class="a2a_button_telegram"></a><a class="a2a_button_whatsapp"></a><a class="a2a_button_line"></a><a class="a2a_button_facebook_messenger"></a><a class="a2a_button_mewe"></a><a class="a2a_button_flipboard"></a><a class="a2a_button_email"></a><a class="a2a_button_google_translate"></a></div><script async src="https://static.addtoany.com/menu/page.js"></script></div>]]>
    </content>
    <id>https://gahingwoo.github.io/posts/rk3576-npu-mainline/</id>
    <link href="https://gahingwoo.github.io/posts/rk3576-npu-mainline/"/>
    <published>2026-06-15T00:00:00.000Z</published>
    <summary>Chasing all-zero NPU output on the RK3576 — the wrong theories, the layouts I got right, and where the open road ends: a bug that lives below the registers, a race I could finally rule out, and the strangers who showed up at the same wall — and then the wall came down, and showed a deeper one. I chased that deeper wall until every road out of it was closed, on purpose, and wrote down exactly how.</summary>
    <title>Bringing Up the RK3576 NPU on Mainline Linux</title>
    <updated>2026-07-10T00:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Ga Hing Woo (Jiaxing Hu)</name>
    </author>
    <category term="Firmware &amp; Secure Boot" scheme="https://gahingwoo.github.io/categories/Firmware-Secure-Boot/"/>
    <category term="op-tee" scheme="https://gahingwoo.github.io/tags/op-tee/"/>
    <category term="tee" scheme="https://gahingwoo.github.io/tags/tee/"/>
    <category term="security" scheme="https://gahingwoo.github.io/tags/security/"/>
    <category term="rockchip" scheme="https://gahingwoo.github.io/tags/rockchip/"/>
    <category term="rk3576" scheme="https://gahingwoo.github.io/tags/rk3576/"/>
    <category term="firmware" scheme="https://gahingwoo.github.io/tags/firmware/"/>
    <category term="upstream" scheme="https://gahingwoo.github.io/tags/upstream/"/>
    <content>
      <![CDATA[<h2 id="TL-DR"><a href="#TL-DR" class="headerlink" title="TL;DR"></a>TL;DR</h2><ul><li><strong>It came up secure end-to-end</strong>: memory map + DDR firewall, a real hardware TRNG(RK3576’s RKRNG, not RK3588’s TRNG_V1), and an OTP-derived Hardware Unique Key —<code>xtest</code> passes 113&#x2F;114 on hardware, and the one miss was a build config, not theplatform.</li><li><strong>The silent console was a forced-override bug.</strong> TF-A hands RK3576’s BL32 nonon-secure DT pointer, so the normal console probe never runs; needs<code>CFG_EARLY_CONSOLE</code> forced on (not just defaulted) and TF-A&#x2F;OP-TEE agreeing on UART0.</li><li><strong>The OTP key is a one-way door, and it’s treated like one</strong>: ephemeral HUK bydefault, persistent fuse-burning gated behind an explicit off-by-default flag untilthe OTP index is confirmed — burn the wrong row and it’s permanent.</li><li>Base platform support (<strong>#7821</strong>) is <strong>merged into mainline OP-TEE</strong>; the OTPkey-derivation half (<strong>#7841</strong>) is still in review, split out deliberately so theirreversible part gets its own scrutiny.</li></ul><span id="more"></span><details><summary style="cursor:pointer;font-weight:600;">Read the full write-up</summary><p>OP-TEE is the secure-world OS that runs at S-EL1, between TF-A and Linux. Getting it upon a new SoC is mostly plumbing — memory map, console, crypto, entropy — except everypiece of that plumbing has a way to go silently wrong, and “silently” is the operativeword here, because the first problem was literally silence.</p><p>Target: Radxa Rock 4D (RK3576), firmware on SPI, kernel + an xtest initramfs off SD.The base platform support went up as OP-TEE PR #7821 — and it’s now <strong>merged intomainline OP-TEE</strong>. The OTP key-derivation half got split into its own follow-up(#7841), still in review; there’s a good reason it’s separate, and it’s below.</p><h2 id="The-memory-map-first"><a href="#The-memory-map-first" class="headerlink" title="The memory map, first"></a>The memory map, first</h2><p>The boring part that has to be right or nothing else matters:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">0x40000000  TF-A BL31 (TZRAM)</span><br><span class="line">0x40800000  U-Boot</span><br><span class="line">0x70000000  OP-TEE TZDRAM   — 32 MiB, secure, DDR firewall + no-map</span><br><span class="line">0x72000000  OP-TEE shared memory — 4 MiB, non-secure</span><br></pre></td></tr></table></figure><p><code>platform_secure_ddr_region()</code> programs the SYS_SGRF firewall so the secure DRAM windowis actually enforced by hardware, not just politely avoided. RK3576 is 8 cores —4×A72 + 4×A53 — GICv2, and once the regions and GIC addresses were right it came up onall eight.</p><h2 id="Then-total-silence"><a href="#Then-total-silence" class="headerlink" title="Then: total silence"></a>Then: total silence</h2><p>First boot, OP-TEE produced <em>zero</em> output. Not a crash — just nothing. On most platformsOP-TEE finds its console from a device tree pointer that TF-A hands to BL32. On RK3576,TF-A doesn’t pass one (“No non-secure external DT” in the log), so the DT-based consoleprobe never runs, and you get a secure world that boots blind.</p><p>Fix is <code>CFG_EARLY_CONSOLE=y</code> so it brings up UART0 directly. Small catch that cost sometime: <code>conf.mk</code> sets <code>CFG_EARLY_CONSOLE ?= n</code> <em>globally</em>, and a <code>?= y</code> inside theplatform block is a no-op because the variable’s already set. You need a forcedoverride, not a default. (This exact point came back in review — more below.)</p><p>UART0 itself was a second snag: TF-A uses UART0 (<code>0x2ad40000</code>) as its console, OP-TEEdefaulted to UART2. They have to agree or you’re back to staring at a dead port.</p><h2 id="Entropy-the-RK3576-has-a-different-TRNG"><a href="#Entropy-the-RK3576-has-a-different-TRNG" class="headerlink" title="Entropy: the RK3576 has a different TRNG"></a>Entropy: the RK3576 has a different TRNG</h2><p>By default OP-TEE will fall back to a software PRNG (Fortuna) and print:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">WARNING: This OP-TEE configuration might be insecure!</span><br></pre></td></tr></table></figure><p>To make that warning go away honestly, you need real hardware entropy. RK3576 uses theRKRNG IP at <code>0x2a440000</code> — <em>not</em> RK3588’s TRNG_V1, different register layout entirely.I wrote a small <code>hw_get_random_bytes()</code> driver for it so <code>CFG_WITH_SOFTWARE_PRNG=n</code>works cleanly.</p><p>Two ordering gotchas, because entropy gets asked for <em>early</em>:</p><ol><li><code>hw_get_random_bytes()</code> has to lazily map the RKRNG block on first call — the earlyMMU map from <code>entry_a64.S</code> is up before any initcall, so that’s safe.</li><li>The stack-canary hook <code>plat_get_random_stack_canaries()</code> runs <em>before</em> <code>driver_init()</code>,so it has to read the RKRNG directly rather than going through the driver framework.</li></ol><p>(In one hardware run the TRNG_S block didn’t respond and we rode the SW-PRNG fallback —so the fallback path isn’t theoretical, it’s load-bearing. A reviewer flagged checkingwhether the TRNG clock is even enabled via SCMI, which is the right next thread to pull.)</p><h2 id="The-OTP-key-you-can-only-write-once"><a href="#The-OTP-key-you-can-only-write-once" class="headerlink" title="The OTP key you can only write once"></a>The OTP key you can only write once</h2><p>The Hardware Unique Key (HUK) is derived from a one-time-programmable fuse. RK3576’s HUKlives at OTP_S index <code>0x80</code> (bytes 512–527) — RK3588 uses <code>0x104</code>, so this is a realper-SoC value, not a copy-paste. On an unprogrammed board the slot reads all zeros, andOP-TEE falls back to an <strong>ephemeral</strong> HUK: a fresh random key every boot.</p><p>That fallback is convenient and also a footgun, which a reviewer (QSchulz) caughtimmediately: if someone flips on persistent OTP storage before the HUK index is<em>confirmed</em> against the RK3576 docs, they burn a permanent fuse at possibly the wrongrow — irreversible. So provisioning is gated behind <code>CFG_RK3576_PERSIST_HUK=n</code> bydefault, off, with the warning stated plainly. You opt into the one-way doordeliberately or not at all.</p><p>Same shape for the secure-boot PTA (RSA-2048): the public-key hash goes to OTP_S words<code>0x184</code>–<code>0x187</code>, and <code>CFG_RK_SECURE_BOOT_SIMULATION=y</code> by default so you can exercisethe whole path without fusing anything. Flip it off only when you genuinely mean“permanently, on this board, forever.” Getting the secure-boot code to cope also meanttearing out a <code>static_assert(size == 8)</code> and making the hash handling variable-length,since RK3576 only has the RSA-2048 status fuse, not RSA-4096.</p><p>(The OTP layout analysis — HUK at 0x80, RSA hash at 0x184 — came from<a href="https://github.com/the-gabe">the-gabe</a>, credited on those patches. Worth saying outloud: a lot of bring-up is standing on someone else’s careful reading.)</p><h2 id="What-review-actually-changed"><a href="#What-review-actually-changed" class="headerlink" title="What review actually changed"></a>What review actually changed</h2><p><code>xtest</code> on hardware: <strong>113 tests, 1 failure</strong> — and the one failure was a missinginitramfs config option (<code>CONFIG_TEE_SUPP_PLUGIN</code>), not a platform bug. So the secureworld genuinely works: TAs load, crypto runs, the lot.</p><p>But the more interesting output of this port wasn’t the code, it was the review. Thingsthe maintainers pushed on that made it better:</p><ul><li><code>$(call force, …)</code> vs <code>?=</code> for the early console — <em>why</em> does your platform need toforce it? (Answer: because TF-A gives BL32 no DT. That belongs in the commit message,not just the makefile.)</li><li>the ephemeral-HUK footgun → keep OTP off by default, document the irreversibility, andconsider splitting platform-support and OTP into separate PRs so the dangerous partgets its own scrutiny.</li><li>“did you actually run xtest, or just reach a U-Boot prompt?” — the difference between<em>boots</em> and <em>works</em>, which (if you’ve read my NPU post) is a drum I will apparentlynever stop banging.</li></ul><p>None of those were bugs in the usual sense. They were the difference between “compileson my board” and “safe to put in front of strangers’ boards.” That gap is the whole job.The patch that merges is the one where you’ve already answered the question the reviewerwas about to ask.</p><p>And it did merge — #7821 is in mainline OP-TEE now, so the RK3576 has secure-worldsupport out of the box. The OTP key-derivation half is still working through review as#7841, which is exactly the split the reviewers asked for: the irreversible,fuse-burning part gets its own scrutiny instead of riding in on the back of “add aplatform.” That’s not the review being slow. That’s the review being right.</p></details><script src="/js/reveal-toc.js" defer></script><div style="margin-top: 2.5em;"><div class="a2a_kit a2a_kit_size_32 a2a_default_style"><a class="a2a_dd" href="https://www.addtoany.com/share"></a><a class="a2a_button_facebook"></a><a class="a2a_button_twitter"></a><a class="a2a_button_linkedin"></a><a class="a2a_button_reddit"></a><a class="a2a_button_hacker_news"></a><a class="a2a_button_slashdot"></a><a class="a2a_button_telegram"></a><a class="a2a_button_whatsapp"></a><a class="a2a_button_line"></a><a class="a2a_button_facebook_messenger"></a><a class="a2a_button_mewe"></a><a class="a2a_button_flipboard"></a><a class="a2a_button_email"></a><a class="a2a_button_google_translate"></a></div><script async src="https://static.addtoany.com/menu/page.js"></script></div>]]>
    </content>
    <id>https://gahingwoo.github.io/posts/rk3576-optee/</id>
    <link href="https://gahingwoo.github.io/posts/rk3576-optee/"/>
    <published>2026-06-12T00:00:00.000Z</published>
    <summary>Bringing up a secure world on the RK3576 — a silent console, a different TRNG, an OTP key you can only write once, and a review that made all of it better.</summary>
    <title>Porting OP-TEE to the RK3576</title>
    <updated>2026-07-16T01:04:22.052Z</updated>
  </entry>
  <entry>
    <author>
      <name>Ga Hing Woo (Jiaxing Hu)</name>
    </author>
    <category term="Firmware &amp; Secure Boot" scheme="https://gahingwoo.github.io/categories/Firmware-Secure-Boot/"/>
    <category term="rockchip" scheme="https://gahingwoo.github.io/tags/rockchip/"/>
    <category term="rk3576" scheme="https://gahingwoo.github.io/tags/rk3576/"/>
    <category term="firmware" scheme="https://gahingwoo.github.io/tags/firmware/"/>
    <category term="upstream" scheme="https://gahingwoo.github.io/tags/upstream/"/>
    <category term="tf-a" scheme="https://gahingwoo.github.io/tags/tf-a/"/>
    <category term="trusted-firmware-a" scheme="https://gahingwoo.github.io/tags/trusted-firmware-a/"/>
    <content>
      <![CDATA[<h2 id="TL-DR"><a href="#TL-DR" class="headerlink" title="TL;DR"></a>TL;DR</h2><ul><li><strong>The patch deletes one line</strong> — a leftover <code>GICV2_G0_FOR_EL3 := 1</code> override in<code>plat/rockchip/rk3576/platform.mk</code> that was both redundant (the tree-wide default isalready <code>0</code>) and wrong (RK3576 has no reason to route GICv2 Group 0 to EL3).</li><li><strong>It’s still the first thing I sent to Trusted Firmware-A</strong>, and it got read closelyby engineers from ST, Google, and Rockchip before it landed.</li><li>The point isn’t the diff size — it’s what upstream review actually asks of you evenwhen the change is nearly nothing: <em>is this safe for every configuration, not justyours?</em></li></ul><span id="more"></span><details><summary style="cursor:pointer;font-weight:600;">Read the full write-up</summary><p>This is the smallest patch I’ve ever upstreamed: it <em>removes</em> one line. Net diff isnegative. But it’s also the first thing I sent to Trusted Firmware-A, and it got readby engineers from ST, Google, and Rockchip before it landed. So it’s a decent littlestory about what upstreaming actually feels like, even when the change is nearlynothing.</p><h2 id="The-line-I-deleted"><a href="#The-line-I-deleted" class="headerlink" title="The line I deleted"></a>The line I deleted</h2><p>In <code>plat/rockchip/rk3576/platform.mk</code>, one line:</p><figure class="highlight diff"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"> ENABLE_PLAT_COMPAT        :=0</span><br><span class="line"> MULTI_CONSOLE_API        :=1</span><br><span class="line"> CTX_INCLUDE_EL2_REGS        :=0</span><br><span class="line"><span class="deletion">-GICV2_G0_FOR_EL3        :=1</span></span><br><span class="line"> CTX_INCLUDE_AARCH32_REGS    :=0</span><br></pre></td></tr></table></figure><p>That’s the whole patch. <code>GICV2_G0_FOR_EL3</code> decides whether GICv2 Group 0 interruptsare routed to EL3. The RK3576 platform was hardcoding it to <code>1</code>, and that override isboth redundant and wrong:</p><ul><li><strong>Redundant</strong> — every other Rockchip platform leaves it alone, and the default in<code>make_helpers/defaults.mk</code> is already <code>0</code>. So RK3576 was the odd one out for noreason.</li><li><strong>Wrong</strong> — forcing Group 0 to EL3 isn’t what these platforms want. It only everlooked harmless because the board is always built with <code>SPD=opteed</code> and nobody hadhit the consequences. Carrying a non-default value the rest of the tree doesn’t carryis exactly the kind of thing that bites a future configuration nobody’s testing yet.</li></ul><p>So: delete the line, let the default stand.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">fix(rk3576): drop redundant GICV2_G0_FOR_EL3 override</span><br></pre></td></tr></table></figure><p>Tested on a Radxa Rock 4D (RK3576, 12 GiB LPDDR5) with <code>SPD=opteed</code> — boots throughBL31 into OP-TEE and on to Linux, same as before.</p><h2 id="Why-one-deleted-line-is-worth-a-post"><a href="#Why-one-deleted-line-is-worth-a-post" class="headerlink" title="Why one deleted line is worth a post"></a>Why one deleted line is worth a post</h2><p>Because “it builds and boots” is not why a patch gets merged. The reviewers don’t havemy board. What they have is the diff, and they’re going to ask: <em>is removing this safefor every configuration, not just yours?</em> That’s a different and harder question than“does it work for me.”</p><p>So I had to actually justify it: the default is 0, here’s the line in <code>defaults.mk</code>;no sibling platform sets it; the override only survived because one specific buildconfig happened to mask it. Once that case was airtight, the patch was obvious — but Ihad to make the case, not just assert it.</p><p>That’s the whole lesson, and it’s the one that carried into every harder RK3576 patch Isent afterward (OP-TEE, the NPU work): <strong>upstream review is about defending every lineyou touch to people who can’t see your hardware.</strong> A one-line delete and athousand-line driver get the same question. The delete is just where I learned toanswer it.</p><p>It also means the right first contribution to a big project isn’t a feature. It’sfinding the small, defensible, obviously-correct thing and shipping it cleanly. This wasmine.</p></details><script src="/js/reveal-toc.js" defer></script><div style="margin-top: 2.5em;"><div class="a2a_kit a2a_kit_size_32 a2a_default_style"><a class="a2a_dd" href="https://www.addtoany.com/share"></a><a class="a2a_button_facebook"></a><a class="a2a_button_twitter"></a><a class="a2a_button_linkedin"></a><a class="a2a_button_reddit"></a><a class="a2a_button_hacker_news"></a><a class="a2a_button_slashdot"></a><a class="a2a_button_telegram"></a><a class="a2a_button_whatsapp"></a><a class="a2a_button_line"></a><a class="a2a_button_facebook_messenger"></a><a class="a2a_button_mewe"></a><a class="a2a_button_flipboard"></a><a class="a2a_button_email"></a><a class="a2a_button_google_translate"></a></div><script async src="https://static.addtoany.com/menu/page.js"></script></div>]]>
    </content>
    <id>https://gahingwoo.github.io/posts/rk3576-tfa-gicv2/</id>
    <link href="https://gahingwoo.github.io/posts/rk3576-tfa-gicv2/"/>
    <published>2026-05-24T00:00:00.000Z</published>
    <summary>Deleting one redundant line from Trusted Firmware-A's RK3576 platform — and what upstreaming even a trivial patch actually looks like.</summary>
    <title>A One-Line TF-A Fix, and the Review That Came With It</title>
    <updated>2026-07-16T01:04:22.052Z</updated>
  </entry>
</feed>
