← Phase 9
·
Custom llama.cpp backend offloading
GGML_OP_MUL_MAT to the AMD XDNA2 NPU via XRT kernel dispatch. ·
GitHub
bo_a DMA elimination · WeightEntry::tile_bos cache · 2026-03-29
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.
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.
| 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.
xrt::runlist · larger TILE_M xclbins · bo_b sync dedup · compute-bound discovery · 2026-03-30
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.
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.
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.
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.
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.
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.
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:
--trace xclbins would reveal per-core IPC and stall cycles.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.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.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 |
|---|---|---|
| H2 | Corrected “16 AIE columns” → 8 physical columns | phase6.html |
| H3 | Updated ggml-xdna.h header (ranges, MIN_N/MAX_N, multi-slot docs) | ggml-xdna.h |
| H4 | Fixed stale “~42 t/s” claim (was Vulkan, not NPU) | env-template.sh |
| M2 | Documented double quantisation rationale (Q4→F32→int8) | ggml-xdna.cpp |
| M3 | Replaced atoll with strtoll + errno (2 sites) | ggml-xdna.cpp |
| M4 | Documented weight cache key stability assumption (LoRA caveat) | ggml-xdna.cpp |
| M5 | Removed duplicate test file, updated README path | README.md |
| M6 | Added $XILINX_XRT to CMake HINTS | CMakeLists.txt |
| L3 | Auto-detect amdgpu hwmon in bench-power.sh | bench-power.sh |
| S4 | Set kernel_ready=false after NPU timeout | ggml-xdna.cpp |
| 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.
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.
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.
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