Cognithesis — an instrument for curiosity

cognithesis.← Return to the engine
← Systems

SILICON / 01

The NPU Is Real, and It's Behind Three Locked Doors

AMD's XDNA NPU is a genuine separate accelerator — 32 AI Engine tiles on the Infinity Fabric. AMD wraps it in ONNX Runtime, Vitis AI, and XRT. We stripped all three, reverse-engineered the DRM ioctls, and got greedy: ran it with the CPU at once for 3.9× PyTorch.

In this entry

Series: Drivers From Scratch — Part 2 Part 1: The Apple Neural Engine · Part 2: AMD XDNA (this post) · Part 3: NVIDIA Kepler (K80)

Status: Complete. Everything here is public — github.com/joshmorgan1000/xdna-npu, MIT license. One-header raw driver, pre-compiled kernels, the whole reverse-engineering log.

(Codex note: file links below point at GitHub; swap for Torizo viewer links at publication.)

0. Cold open

Part 1 ended with a confession: I’d assumed Apple’s Neural Engine worked the way everyone imagines it — a separate accelerator block, its own silicon, reached through the vendor’s stack. It wasn’t. AMD’s XDNA NPU is what I’d pictured the ANE being, and unlike the ANE, it’s real: a genuine array of AI Engine tiles on the Infinity Fabric, with its own DMA engines, its own firmware, its own asynchronous command submission.

So why haven’t you programmed it?

Because AMD wrapped it in three locked doors. The official path: export an ONNX model, feed it to ONNX Runtime, which talks to Vitis AI, which talks to XRT, which talks to the kernel driver. At the bottom, the actual NPU instruction set sits behind NDA’d Xilinx IP licensing. The vendor’s message is friendlier than Apple’s — “deploy ONNX models with our Python APIs” instead of “no” — but the architecture is the same bet: you’ll never want to see the metal.

The marketing calls it an “AI Capable Engine” with “up to 50 TOPS.” I wanted to know what is it. Skip ONNX, skip Python, skip all of it — go straight to the source.

1. What’s actually in there

The NPU in a Ryzen AI 9 HX 370 (Strix Point) is an AIE2P array — 32 AI Engine core tiles in an 8×4 grid, plus 8 memory tiles and 8 shim tiles for DMA, all hanging off the Infinity Fabric it shares with the Zen 5c cores and the DDR5 memory controller. Clock: 1800 MHz. Theoretical max: 58 TOPS.

         Col 0   Col 1   Col 2   Col 3   Col 4   Col 5   Col 6   Col 7
Row 5:  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]
Row 4:  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]
Row 3:  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]
Row 2:  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]  [CORE]
Row 1:  [ MEM]  [ MEM]  [ MEM]  [ MEM]  [ MEM]  [ MEM]  [ MEM]  [ MEM]
Row 0:  [SHIM]  [SHIM]  [SHIM]  [SHIM]  [SHIM]  [SHIM]  [SHIM]  [SHIM]

This is the architecture most people assumed the Apple Neural Engine was — right down to my own wrong prior from Part 1. XDNA is the real thing: dedicated accelerator on its own interconnect, firmware mailbox, asynchronous execution. AMD’s is just better documented, because the AIE lineage comes from Xilinx Versal, and parts of that ecosystem leaked into the open over the years. Which is exactly what made this crack possible.

One quirk worth knowing before you try to talk to it: the silicon doesn’t do FP32. The tiles’ native math is BF16 multiply with FP32 accumulate (plus INT8/INT16 paths). AMD’s own docs frame this as an inconvenience — “the hardware automatically converts to BF16 during compilation” — and that framing is the tell. The conversion isn’t a limitation of your model; it’s a property of the hardware that the whole software stack exists to hide from you.

2. Cracking it open, bottom-up

The only component you can actually read is the Linux kernel driver, amdxdna, which is mainline and open. Everything above it is a black box with an NDA. So: reverse-engineer the whole stack from the bottom.

Step 1: the DRM ioctls. The driver exposes a small ioctl surface on /dev/accel/accel0 — CREATE_BO (buffer objects), CREATE_HWCTX, CONFIG_HWCTX, EXEC_CMD, SYNCOBJ_TIMELINE_WAIT. We traced the kernel driver source to decode the ABI: a 64MB page-locked device heap, BO_DEV buffers for instruction/PDI payloads, BO_SHMEM for data, PASID-mapped DMA so the NPU can address userspace memory directly.

Step 2: the command format. Hooking XRT’s own ioctls revealed the CMD BO layout the driver builds: an ERT_START_CU packet (opcode 0 — the same packet format Versal’s embedded runtime uses), raw kernel arguments at byte offsets the xclbin declares, forwarded by the driver to NPU firmware as a MSG_OP_EXECUTE_BUFFER_CF. The firmware is a mailbox protocol. Once you know the message shape, you can write it yourself.

Step 3: the three showstoppers, which consumed the better part of a week and are the actual folklore of this hardware:

  • The syncobj timeout must be absolute CLOCK_MONOTONIC + offset, not relative. Relative timeouts expire instantly. Nothing in the uapi header tells you this; the driver source quietly adds the clock reading before handing the wait to the dma-fence layer.
  • PDI buffer objects (the precompiled tile programs) must be BO_DEV, not BO_SHMEM — device-resident, not host-shared memory. Get this wrong and the NPU firmware reads garbage instructions.
  • The output buffer needs a CPU cache sync before execution, not after — stale cache lines on the host side mean the NPU’s writes race your last read. Backwards from what intuition suggests.

And one more, which was pure archaeology: the precompiled kernel that comes with the tooling targets NPU1 — Phoenix Point. The Strix chip is NPU2. Different architecture designation, different tile layout; kernels compiled for the wrong one mostly “work” in ways that produce plausible garbage. (Later verified the whole stack end-to-end with XRT 2.23 on the same machine: [0000:c6:00.1] NPU Strix aie2p 6x8, firmware 255.0.11.69. The mini-PC on my desk, the same one that hosts my vector search benchmarks.)

The result of steps 1–3 is xdna_raw.hpp: a single header-only file that talks directly to the NPU. No XRT. No ONNX Runtime. No Vitis AI. No Python. Zero dependencies. Raw ioctls into /dev/accel/accel0, BO allocations, command submission, syncobj waits — the entire driver surface in one readable file.

3. The kernels

Talking to the shim is one thing; making the tiles do math is another. The tile programs themselves are compiled from MLIR-AIE (the IRON flow from the open Xilinx ecosystem) — we compiled our own matmul kernels for NPU2 and shipped the pre-compiled PDI (tile programs) plus instruction streams (DMA descriptors) in the repo, so nobody needs the toolchain to run them.

Each kernel invocation: DMA descriptors move A and B tiles from host memory through the shim into memory tiles, the compute tiles run BF16 MAC with FP32 accumulate, and C DMA’s back. The active kernel is a 4-column, 128×128×128 tile: 16 compute cores, good general-purpose shape. We also compiled and benchmarked the alternatives — the honest numbers, because a couple of them lost:

  • 8-column kernel (all 32 cores): 52 GFLOPS — slower end-to-end than 4-column. More tiles, more DMA pressure.
  • Full-dimension 64×128×784: 125 GFLOPS for the L1 matmul of our MNIST net — 5.6× faster for that one shape, useless as a general kernel.
  • Universal 800×128×800: wastes DMA on small matmuls.
  • Dual hardware contexts: work, but context-switch penalty kills throughput.

BF16 rounding matters more than you’d think, too: all fp32→bf16 conversions add the 0x8000 rounding bias before truncation. Truncation-only rounding biases every conversion downward, and the bias compounds over epochs and quietly hurts convergence. One instruction, the difference between a model that trains and one that drifts.

4. Then we got greedy

One engine was never the plan. The Ryzen AI mini-PC is a three-ring circus of compute: 32 AIE2P tiles, Zen 5 cores with AVX-512 (and the same silicon runs my vector engine’s JIT kernels on other days), and an integrated Radeon 890M on the GPU side. None of them share execution resources — which, after Part 1’s contention benchmark, is a claim I now test instead of assume.

So the multi-engine pipeline: each worker thread owns a batch and an engine assignment. NPU workers submit double-buffered DMA sets — while the NPU computes tile N from buffer set 0, the CPU packs tile N+1 into set 1, async submission via timeline syncobjs keeping the firmware queue full. Four workers share the NPU through 16 CMD BO slots with atomic round-robin indexing, because the firmware happily accepts commands from multiple threads. CPU workers run a hand-written AVX-512 GEMM — 6×16 micro-kernel, 6 ZMM accumulators living in registers across the K loop, BF16 inputs widened to fp32 by shift-left-16. No framework, no library, just intrinsics. A pthread_barrier synchronizes gradient accumulation across all workers, data-parallel SGD with everyone stepping on the same weights.

MNIST training (784 → 128 → 10, batch 64, same data, same net):

Configuration Samples/sec Wall time Test accuracy Speedup
PyTorch CPU (12 threads, MKL) 45,657 19.7s 94.8% 1.0×
NPU single-thread 45,120 19.9s 94.6% 1.0×
NPU 4-thread (pipelined) 98,762 9.1s 91.7% 2.2×
4 NPU + 4 CPU ~180,000 ~3.3s 93.9% 3.9×

The single-threaded NPU row is the one to sit with for a second: one NPU thread matches PyTorch’s twelve CPU threads. Same speed, one-twelfth the cores, on a $600 mini-PC. The 4+4 row is what happens when you stop treating the SoC’s engines as an either/or: both compute paths firing in parallel, nearly quadruple PyTorch end-to-end.

(GPU was in that original tri-engine plan — HSA dispatch to the 890M is in progress, with the setup script building the runtime and compiling the kernel. The GPU engine is a placeholder in the tri-engine test today, and I won’t put a number on it until it runs.)

5. Part 1 vs Part 2: the compare-and-contrast nobody asked for

Apple M4 (SME2) AMD Strix (XDNA)
Architecture za matrix tiles on the CPU AIE2P core tiles on Infinity Fabric
Access smstart/smstop — synchronous, nanoseconds DRM ioctls — asynchronous, ~100µs submit+wait
What the vendor calls the interesting part “Neural Engine” (a software story) “XDNA NPU” (actual separate silicon)
Official path CoreML/BNNS ONNX Runtime → Vitis AI → XRT
The gap between story and silicon The destination is the CPU itself The destination is three doors deep
Documentation none kernel driver open, ISA NDA’d, toolchain open-ish
Our throughput there 8.5 TOPS INT8 (raw SME) 180K samples/sec (multi-engine MNIST)

My favorite irony of the pair: AMD’s XDNA is the architecture everyone assumed Apple’s Neural Engine was — the pattern-match was so natural that Apple’s camouflage worked partly because AMD had already documented the honest version of the design. People looked at Apple’s marketing through AMD’s architecture and saw a familiar shape. The shape was a decoy.

Same story, different silicon: the marketing says “AI accelerator,” the reality is matrix math hardware behind a software abstraction. Strip the abstraction, talk to the metal. AMD just made you dig through one more layer to do it — and left the bottom layer (the kernel driver) sitting in mainline Linux where anyone could read it. For which: credit where due. That driver is why this project took a week instead of a quarter.

6. Run it yourself

git clone https://github.com/joshmorgan1000/xdna-npu
cd xdna-npu
./setup.sh          # builds ROCR runtime + GPU kernel + downloads MNIST
./run_build.sh

./build/tests/test_mnist data          # single-thread NPU vs PyTorch baseline
./build/tests/test_mnist_mt data 4     # 4-thread pipelined NPU
./build/tests/test_mnist_tri data 4 4  # the 180K number: 4 NPU + 4 CPU

Requirements: Ryzen AI (Strix Point or later), Linux with amdxdna loaded, /dev/accel/accel0 accessible. bench/pytorch_mnist.py is the baseline — run it, so the 3.9× is your number and not my claim. There’s a single-file dashboard with the architecture diagram and convergence curves, same stack as the Part 1 project.

7. Next: the vendor that says the building no longer exists

Part 1’s vendor hid the door behind a marketing story. Part 2’s vendor locked three doors but left the key in mainline Linux. Part 3’s vendor is a different animal entirely: NVIDIA built the K80, sold it to datacenters as the definition of AI hardware, wrote a driver for it — and then retired the driver, dropping Kepler support entirely. The card still works; the software says it doesn’t exist. That’s not a locked door. That’s a door with a “condemned” sign on it, behind which sits 384 GB of perfectly good VRAM if you happen to own sixteen of them.

Which I do. See you in Part 3.

← Back to systems