AMD XDNA2 NPU Backend — Changelog

← Phase 9  ·  Custom llama.cpp backend offloading GGML_OP_MUL_MAT to the AMD XDNA2 NPU via XRT kernel dispatch.  ·  GitHub

Ryzen AI MAX 385  ·  RyzenAI-npu5 (XDNA2)
10

Weight Tile Pre-staging (bo_a Cache)

bo_a DMA elimination · WeightEntry::tile_bos cache · 2026-03-29

Marginal
Summary

Phase 9 identified the 280 µs per-dispatch bo_a DMA (8 MB weight tile copy + sync) as a target for elimination. Phase 10 replaced the per-dispatch bo_a with a lazily-populated cache of pre-staged XRT buffer objects (WeightEntry::tile_bos). Weight tiles are copied and synced to device once on first use, then reused on every subsequent token with zero DMA cost.

Implementation

Removed bo_a from XclbinSlot (saving ~112 MB pre-allocation). Added std::unordered_map<uint64_t, xrt::bo> tile_bos to WeightEntry, keyed by (slot_idx << 32) | tile_row_index. On cache miss (first token), a host_only BO is allocated via group_id(3), populated with the quantised weight tile, and synced once. On cache hit (all subsequent tokens), the pre-staged BO is passed directly to the kernel with no memcpy or sync. BOs are destroyed automatically when the weight cache entry is invalidated (M/K dimension change).

dispatch_tile signature changed from const int8_t * a_tile to xrt::bo & bo_a_staged.

Results
Test Without Phase 10 With Phase 10 Delta
NPU-only prefill pp512 (TILE_N=256) 4.56 t/s 4.71 t/s +3.3%
NPU decode (MIN_N=MAX_N=1) baseline +7% (prior session) +7%

The bo_a DMA cost (280 µs) is only 24% of the 1,155 µs per-dispatch total. Eliminating it saves ~197 ms per 704-dispatch token, but kernel compute (733 µs, 63%) dominates. Phase 10 is kept for code cleanliness and the ~112 MB memory saving, not for throughput.

11

Dispatch Reduction Investigation

xrt::runlist · larger TILE_M xclbins · bo_b sync dedup · compute-bound discovery · 2026-03-30

Compute-bound
1 — Hypothesis

Phase 9 measured ~1,155 µs per tile dispatch (K=4096). With 704 dispatches per token, host-side overhead (submission latency, redundant DMA syncs, per-dispatch bookkeeping) was hypothesised to be a significant fraction. Three approaches were investigated to reduce dispatch count or per-dispatch overhead:

Approach Mechanism Expected Impact
A. Redundant bo_b sync elimination Sync activation BO once per N-tile instead of once per dispatch Save 6 × 134 µs per 7-tile batch (~10%)
B1. TILE_M=4096 xclbins Halve M-tile count (e.g. 14336/4096 = 4 vs 14336/2048 = 7) ~50% fewer dispatches (~20–30%)
B2. TILE_M=14336 xclbins Eliminate M-tiling for FFN layers entirely (1 dispatch vs 7) ~85% fewer dispatches (~30–35%)

A fourth approach — xrt::runlist batch dispatch — was researched but not tested. See section 5.

2 — xclbin Build Validation

Built 8 new xclbins (4 K dimensions × 2 M values) using the mlir-aie whole_array example. All 4-col builds completed in 2–3 seconds each, confirming that xclbin compilation is not a bottleneck for experimentation.

TILE_M K dims built TILE_N Instr file size
4096 2048, 4096, 5632, 14336 256 20 KB each
14336 2048, 4096, 5632, 14336 256 72 KB each (3.5× more DMA ops encoded)

Divisibility constraint: M must be a multiple of m × n_aie_rows = 64 × 4 = 256. Both M=4096 (16×256) and M=14336 (56×256) pass. No upper bound on M was encountered in the compiler.

3 — Benchmark Results
All approaches produced identical throughput

Reducing dispatch count by 2× (M=4096) and 7× (M=14336) had zero measurable effect on prefill throughput. The bo_b sync deduplication also showed no improvement.

Config TILE_M Dispatches per gate_proj (M=14336) pp512 t/s (NPU-only)
Phase 10 baseline 2048 7 × 2 = 14 4.71 ± 0.19
+ bo_b sync dedup 2048 14 (6 fewer syncs) 4.52 ± 0.03
M=4096 xclbins + bo_b fix 4096 4 × 2 = 8 4.56 ± 0.02
M=14336 xclbins + bo_b fix 14336 1 × 2 = 2 4.60 ± 0.01

All tests: Vulkan hidden (libggml-vulkan.so renamed), MIN_N=2, ngl=0, 3 repetitions, Meta-Llama-3.1-8B-Instruct-Q4_K_M. Backend reported XDNA only.

4 — Root Cause: Compute-Bound, Not Dispatch-Bound
Key Discovery

The NPU is compute-bound, not dispatch-bound. Reducing dispatch count from 14 to 2 per matmul (7× reduction) did not change throughput because the total MACs are identical — a single M=14336 dispatch performs the same 14336 × K × 256 int8 MACs as seven M=2048 dispatches. The AIE kernel tiles M internally across 4 rows of cores regardless of the host-side tile size.

This conclusively disproves the Phase 9 projection that pre-staging + dispatch reduction could reach ~15 t/s. That projection assumed per-dispatch overhead (bo_b sync, kernel launch, readback) was the bottleneck. In reality:

Component Time (K=4096 tile) % of total Phase 11 finding
bo_a DMA (weight tile) 280 µs 24% Eliminated by Phase 10 (+3%)
bo_b sync (activations) 134 µs 12% Dedup had no effect
Kernel compute 733 µs 63% Dominant — unchanged by tile size
Readback (bo_c sync) 5 µs 0.4% Negligible

Achieved throughput: 2.93 GOPS on int8 matmul (measured from 2.147B MACs / 733 µs kernel time).

Theoretical peak: 4 columns × 5 cores/column × 16 int8 lanes × 1.5 GHz = 480 GOPS.

Utilisation: 2.93 / 480 = 0.6%. The int8 matmul kernel achieves less than 1% of the AIE2P peak, indicating that the mlir-aie whole_array kernel is heavily bottlenecked by DMA-to-compute synchronisation, memory access patterns, or incomplete vectorisation for the int8 datapath.

5 — xrt::runlist (Researched, Not Tested)

XRT provides xrt::runlist (/usr/include/xrt/experimental/xrt_kernel.h) for batch dispatch: add N xrt::run objects, call execute() once, wait() once. A working example exists at mlir-aie/test/npu-xrt/add_one_two_runlist/test.cpp.

This was not tested because the Phase 11 tile-size experiments proved the bottleneck is kernel compute time, not submission overhead. Even if runlist eliminated all per-dispatch submission latency (~5–10 µs each × 704 = ~5 ms total), that represents <1% of the ~213 ms/token compute budget. The XRT docs also do not guarantee hardware pipelining — runs may execute serially on XDNA2.

6 — Corrected Performance Model
Phase 9 projection invalidated

Phase 9 estimated 95 µs per dispatch with pre-staging, projecting ~15 t/s decode. This assumed kernel compute at ~67 µs — roughly 11× faster than the measured 733 µs. The 95 µs figure was aspirational, not derived from measurement. The actual physical ceiling with the current kernel is ~4.7 t/s prefill and ~0.7 t/s decode.

Backend Prefill pp512 Decode Status
XDNA NPU (Phase 10, 4-col prefill) 4.71 t/s ~0.7 t/s Compute-bound at 0.6% AIE utilisation
Vulkan iGPU (ngl=99) ~530 t/s 44.09 t/s 112× faster prefill, 63× faster decode
CPU only ~4.3 t/s 3.76 t/s Comparable to NPU prefill

NPU prefill (4.71 t/s) is now roughly equivalent to CPU-only performance (~4.3 t/s). The NPU adds no meaningful advantage over CPU for either prefill or decode with the current kernel.

7 — What Would Move the Needle

The only path to meaningful NPU improvement is kernel-level optimisation — increasing AIE utilisation from 0.6% toward theoretical peak. This is outside the scope of the host-side llama.cpp backend and requires changes to the mlir-aie kernel code:

Potential kernel improvements
  • Vectorised int8 MAC: Current kernel may not fully utilise the 16-wide int8 SIMD lanes on AIE2P. Profiling with --trace xclbins would reveal per-core IPC and stall cycles.
  • DMA/compute overlap: The kernel uses fifo_depth=2 (ping-pong) but may still stall on DMA waits between transfer blocks. Increasing FIFO depth or restructuring the ObjectFIFO pipeline could hide DMA latency.
  • Larger per-core tiles (m, k, n): Current micro-tiles are 64×64×64. Larger tiles reduce DMA overhead relative to compute. The constraint is per-core local memory (typically 32–64 KB on AIE2P).
  • Dedicated int8 matmul kernel: The whole_array example is a general-purpose matrix multiplication. A kernel specifically optimised for the LLaMA layer shapes (M=4096/14336, fixed K, small N for decode) could achieve significantly higher utilisation.
  • AMD official NPU kernels: AMD's Ryzen AI Software SDK may ship optimised int8/int4 kernels with higher AIE utilisation. These are not currently compatible with the XRT dispatch model used here.
8 — Phase 10 Audit Fixes (Committed Separately)

A full-project audit was completed alongside Phase 10, addressing 13 findings across 8 files. These were committed separately from Phase 10 to maintain clean git history:

ID Fix File
H2Corrected “16 AIE columns” → 8 physical columnsphase6.html
H3Updated ggml-xdna.h header (ranges, MIN_N/MAX_N, multi-slot docs)ggml-xdna.h
H4Fixed stale “~42 t/s” claim (was Vulkan, not NPU)env-template.sh
M2Documented double quantisation rationale (Q4→F32→int8)ggml-xdna.cpp
M3Replaced atoll with strtoll + errno (2 sites)ggml-xdna.cpp
M4Documented weight cache key stability assumption (LoRA caveat)ggml-xdna.cpp
M5Removed duplicate test file, updated README pathREADME.md
M6Added $XILINX_XRT to CMake HINTSCMakeLists.txt
L3Auto-detect amdgpu hwmon in bench-power.shbench-power.sh
S4Set kernel_ready=false after NPU timeoutggml-xdna.cpp
9 — Commits
Hash Description
28716bc1f fix: audit findings — strtoll, hwmon auto-detect, docs corrections
3be46a84f feat: Phase 10 — bo_a weight tile pre-staging cache

Phase 11 produced no code changes. The bo_b sync deduplication was tested and reverted (no measurable benefit). The M=4096 and M=14336 xclbins remain in the mlir-aie build directory for future use but are not referenced by the backend.

10 — External Validation

A literature review (full survey) confirms our Phase 11 findings are consistent with independent research:

Finding External source Key result
0.6% AIE utilisation is expected for unoptimised kernels NPUEval (arXiv:2507.14403), DPUV4E (arXiv:2506.11441) ~10% mean vectorisation for auto-generated code; <5% for small BERT MM layers
Same XDNA2 silicon achieves 76% peak with optimised GEMM “Striking the Balance” (arXiv:2512.13282) 38.05 TOPS int8 on Strix Point — 127× our throughput
DMA-compute overlap is the single biggest gain Striking the Balance, GAMA (arXiv:2504.09688) 28% loss from missing overlap alone; γ = 0.72 for int8×int32
NPU-vs-GPU gap is industry-wide, not AMD-specific NITRO (Intel), ASPLOS’25 (Qualcomm), Orion (Apple) No vendor’s NPU beats its GPU for LLM decode
NPU’s only advantage is power efficiency FastFlowLM, arXiv:2603.23640 <2W NPU vs ~25W GPU (>10× perf/watt)

This project appears to be the furthest any independent developer has publicly reached with custom NPU workloads on XDNA2. No upstream llama.cpp NPU backend exists for any vendor. See the full research survey for 21 cited sources.

11 — Conclusion
Finding

The XDNA2 NPU backend has reached an AIE kernel compute ceiling. All host-side optimisations (weight pre-staging, dispatch reduction, DMA deduplication, larger tiles) have been exhausted with negligible effect. The int8 matmul kernel achieves 0.6% of theoretical AIE2P peak throughput. Further improvement requires kernel-level changes in the mlir-aie compiler or a fundamentally different NPU kernel architecture.

Practical Recommendation

For LLaMA 8B inference on Strix Halo: use Vulkan exclusively (530 t/s prefill, 44 t/s decode). The NPU backend serves as a proof-of-concept for the XDNA2 dispatch path and as a testbed for future kernel improvements, but does not provide competitive throughput with the current mlir-aie int8 matmul kernel.

Phase 12 — Micro-Tile Optimization →  ·  ← All phases  ·  Phase 9 — Root Cause Investigation  ·  Research Survey