AIE Code Creator — AMD XDNA NPU Programming¶
This skill teaches you how to write efficient designs for AMD XDNA NPUs (AIE2 in Phoenix/Hawk, AIE2P in Strix/Krackan) using the IRON Python API and write the C++ AIE kernels that run on the compute tiles.
An IRON design has two halves:
- Structural / data-movement code (Python) — describes the AIE-array topology: which tiles run which tasks, what
ObjectFifos connect them, and what the host-sideRuntimedoes to feed/drain data. - Compute kernel code (C++) — the actual functions that execute on each compute tile, written against the AIE API (
aie::vector,aie::mmul, ...) and compiled into.ofiles referenced by the Python design.
The design is compiled to MLIR, then linked against the C++ kernel objects and a host runtime to produce an xclbin that runs on the NPU.
You often don't have to write either half from scratch. mlir-aie ships aie.iron.kernels (maintained, vectorized kernels for element-wise ops, reductions, activations, matmul, conv2d, vision) and aie.iron.algorithms (whole-design templates like transform_parallel and reduce that build the fifos, workers, and runtime for you). A multi-core bf16 element-wise design is ~15 lines of Python and zero C++. Start there and drop to hand-written code only where nothing fits — that's how you get a correct baseline fast, which is the thing worth optimizing later. See references/builtin_kernels.md.
Two libraries, one boundary. The IRON API, @iron.jit, aie.iron.kernels, aie.iron.algorithms, and the aie.iron / aie.helpers packages all ship in the base Xilinx/mlir-aie toolchain (the mlir_aie wheel) — everything in this skill runs on that alone, except where noted. The amd/IRON repo is a separate downstream operator library (package iron, e.g. iron.common, iron.operators); its helpers — MLIROperator, AIEContext, run_test, verify_buffer, AIERuntimeArgSpec — are not in the wheel and require an amd/IRON checkout. When you use one of those, say so, and prefer wheel-only equivalents unless the user is working inside amd/IRON.
Targets mlir-aie 1.4.0. Version 1.4.0 reworked the Runtime/Program API into an eager-callback style (Runtime(seq_fn, fn_args), fill/drain as ObjectFifoHandle methods, Program(device, rt, workers=[...])) — a breaking change from 1.3.x's Runtime()/rt.sequence()/rt.fill()/rt.start() style. This skill's wheel-only examples assume 1.4.0 throughout; older pinned versions are out of scope. The one deliberate exception is the amd/IRON MLIROperator pattern (see complete_examples.md Example 8), which still pins an older wheel and intentionally keeps the old Runtime API — don't "fix" that snippet to the new style.
Three other 1.3.x names are gone in 1.4.0 and show up in stale examples across the internet: aie.iron.placers / SequentialPlacer (placement is now the --aie-place-tiles compiler pass; resolve_program() takes no placer), Worker(placement=...) (the kwarg is tile=), and NpuTensor.cpu() (use .numpy(), or .to("cpu") to move residency). If you catch yourself writing any of them, you're pattern-matching on old code.
Defaults¶
If the user does not specify, assume:
- Kernel source: a built-in from
aie.iron.kernelsif one covers the op and dtype; hand-written C++ only otherwise. - API level: High-level — an
aie.iron.algorithmstemplate if the topology fits, elseProgram/Worker/ObjectFifo. Let the compiler place tiles. - Target: current device (auto-detect via
iron.get_current_device()); if you must pick, default toNPU2(AIE2P). - Data type:
bfloat16(best balance of accuracy and throughput on AIE). - Parallelism: multi-core (data-parallel split across all available compute tiles in 1+ columns).
Drop to the lower-level API only when the user needs a hand-scripted DMA channel body, a custom flow not covered by iron.Flow/iron.PacketFlow, or is porting an existing MLIR-AIE example 1:1.
Workflow when generating a design¶
1. Identify the shape of the computation: element-wise, reduction, matmul/conv, stencil/sliding-window, pipeline, broadcast?
2. Check whether a built-in already covers it (references/builtin_kernels.md):
- Is the compute in
aie.iron.kernels? (relu,add,mul,scale,softmax,gelu,silu,reduce_add/min/max,mm,mv,cascade_mm,conv2dk1/k3, vision ops, …) - Does the topology match an
aie.iron.algorithmstemplate? (transform,transform_parallel,transform_binary,transform_parallel_binary,reduce,for_each, therow_at_a_time/sliding_3rowconv pipelines)
If both, the design is a few lines inside an @iron.jit function and you are done — skip to step 6. Check dtype and tile-size support before committing: several kernels are bf16-only or fixed-tile.
3. If the topology doesn't fit a template, pick the matching skeleton from references/patterns.md and build the ObjectFifo/Worker/Runtime graph explicitly — but still pass a kernels.* kernel into it if one exists.
4. If no built-in kernel fits, write the C++: pick a template from references/kernel_intrinsics.md, then wire it in via Kernel(...) or ExternalFunction(...). Argument types must match the extern "C" signature exactly.
5. Choose data sizes that respect hardware limits and divisibility constraints. For MMUL, read the geometry off the kernel (kernels.mm(...).mac_dims) rather than hardcoding (r, s, t) — it differs by architecture and dtype.
6. Verify against pitfalls in references/pitfalls.md before declaring done.
When asked to produce a complete design, deliver the Python design file, a short build/run snippet, and — only if you had to hand-write one — the C++ kernel file. Say explicitly when you used a built-in kernel instead of writing one, so the user knows where the compute actually comes from.
Reference files¶
Load the ones you need; don't pre-read all of them.
| File | When to read |
|---|---|
references/builtin_kernels.md |
Read first. aie.iron.kernels catalog, aie.iron.algorithms templates, In/Out/CompileTime[T] jit signatures, .mac_dims |
references/architecture.md |
Hardware limits, data types & vector widths, tile layout, NPU1 vs NPU2 |
references/python_api.md |
Program, Worker, ObjectFifo, Runtime, Kernel, Buffer, iron.jit signatures and examples |
references/patterns.md |
Copy-ready Python skeletons: single-core, multi-core data-parallel, broadcast, split/join, producer-consumer pipeline, reduction, RTP+barrier |
references/kernel_intrinsics.md |
C++ kernel cheatsheet: aie::add/mul/mac, aie::load_v/store_v, MMUL/accumulator, reductions, broadcast — with template skeletons |
programming_guide/section-3/README.md / section-4b |
Building/running a design end-to-end, enabling trace |
references/pitfalls.md |
Anti-patterns: deadlocks, bad placement, alignment, restrict misuse, divisibility, RTP races |
references/low_level_api.md |
Lower-level API (@device, @core, explicit tile(col,row)) — only when the high-level API is insufficient |
references/complete_examples.md |
Nine full runnable designs: library form (no C++) → passthrough → inline source_string kernel → precompiled Kernel → TAP/task_group → split/join → ReLU jit → two-stage pipeline → MLIROperator pattern (last one is amd/IRON-only; see below) |
assets/python_design_skeleton.py |
Drop-in template for a JIT-compiled multi-core design |
assets/kernel_skeleton.cc |
Drop-in template for a templated, vectorized C++ kernel |
assets/architecture-diagram.txt |
ASCII layout of NPU1 (4-col AIE2) and NPU2 (8-col AIE2P) tile arrays with placement sentinels |
C++ kernel coding rules (always apply, unless told otherwise)¶
- C++17, no exceptions, no RTTI, freestanding-friendly.
- Fixed-width integer types:
int8_t,int16_t,int32_t,uint8_t, ... — never bareint. - Templated on data type and on size constants when reasonable; provide thin
extern "C"wrappers with concrete types for MLIR linkage. - Vectorized via the AIE API (
aie::vector,aie::mmul,aie::add/mul/mac), not compiler-specific intrinsics likev32bfloat16orbroadcast_to_v16int32unless the AIE API has no equivalent. __restricton every pointer passed into the hot loop, so loads can move ahead of stores. On AIE2 it was often the difference between a loop that pipelines and one that doesn't (add_weighted1026 → 137 cycles on npu1). Library kernels spell itAIE2_RESTRICTfromaie_arch.h, because their AIE2P code was tuned without it.- bf16 multiply width: 32 lanes on AIE2P, 16 on AIE2. Library kernels take it from
AIE_BF16_LANESinaie_arch.h. - Loop annotations from
aie_kernel_utils.h—AIE_PREPARE_FOR_PIPELINING,AIE_LOOP_MIN_ITERATION_COUNT(n),AIE_LOOP_RANGE(min, max),AIE_LOOP_UNROLL(n),AIE_LOOP_UNROLL_FULL. Under the default Peano/AIECC flow,AIE_PREPARE_FOR_PIPELININGandAIE_LOOP_FLATTENare no-ops — only Chess honors them.AIE_LOOP_MIN_ITERATION_COUNT(n),AIE_LOOP_RANGE(min, max), andAIE_LOOP_UNROLL_FULLare real under both backends. Keep all of them in (they cost nothing under Chess), but never rely onAIE_PREPARE_FOR_PIPELININGalone. For a small, fixed-trip-count inner loop that branches or switches on the loop index (e.g. a 3×3 window), reach forAIE_LOOP_UNROLL_FULLrather thanAIE_LOOP_RANGE— a live branch on the loop variable can block the modulo scheduler regardless of iteration-count hints (seereferences/pitfalls.md). event0()/event1()around the hot region for trace-based profiling.constexprfor vector widths and loop trip-count math.- Saturation/rounding explicitly set for integer kernels that can overflow:
aie::set_saturation(aie::saturation_mode::saturate).
Python design coding rules¶
- Prefer
aie.iron.kernels/aie.iron.algorithmsover rebuilding equivalent compute or topology by hand (seereferences/builtin_kernels.md). - Declare
@iron.jittensor arguments withIn/Out/InOut, and specialization knobs as keyword-onlyCompileTime[T]parameters. An unannotated non-tensor parameter with a default is rejected at decoration time — there's no runtime-scalar plumbing, so the default would be baked in and any per-call override silently ignored. - Use
range_fromaie.iron.controlflow, not Python'srange, for loops that should be emitted as AIE loops. Plainrangeunrolls at design-build time and will explode your code size or fail. - Type tensors with
np.ndarray[(N,), np.dtype[T]]— the shape and dtype must match what the kernel expects. - ObjectFifo
depthmust be at least the producer-consumer working set; default to2(ping-pong); raise for deeper pipelines. - Every
acquire(n)must be paired withrelease(n). Mismatches deadlock silently. - The argument list passed to
Worker(fn, [...])must match the order offn's parameters. - Use multi-core (split/join or per-column workers) whenever the problem trivially data-parallelizes — a single Worker leaves 7 cores idle on NPU2.
Where this skill stops¶
This skill covers getting a correct, reasonable first design written. Upstream mlir-aie
ships a phased skill family (mlir-aie/skills/) for what comes before and after — don't
re-derive their methodology here, point at it:
| Phase | Skill | Covers |
|---|---|---|
| Prepare the model | aie-model-baseline |
Quantization scheme, ONNX export, bit-exact numeric oracle |
| Validate pre-hardware | aie-dataflow-presim |
Threaded ObjectFifo mock for deadlock/depth bugs; prove novel decompositions in numpy first |
| First hardware bring-up | aie-hw-bringup |
Block-by-block bring-up against the oracle, methodical bisection |
| Optimize (micro) | aie-kernel-opt |
Make one compiled kernel faster: the remarks report, then a back-to-back A/B on the NPU |
| Optimize (macro) | aie-dataflow-opt |
NOOP-ablation ranking, placement/overlays, DMA bandwidth modeling |
Two habits from those skills are worth carrying into design time, because they're cheap now and expensive later: validate one block at a time against a reference rather than wiring the whole chain and hoping, and write the smallest standalone design that exercises a mechanism you haven't built before instead of debugging it inside the full design.
Quick triage cheatsheet¶
| User says | Reach for |
|---|---|
| "Element-wise op (add/mul/relu/scale) on a vector" | builtin_kernels.md kernels.add/mul/relu/scale + transform_parallel — only fall back to patterns.md §Element-wise + kernel_intrinsics.md §Element-wise if the dtype/tile isn't supported |
| "Matrix multiply / GEMM / conv" | builtin_kernels.md kernels.mm/conv2dk* (+ .mac_dims), then patterns.md §GEMM-style + kernel_intrinsics.md §MMUL |
| "Sum / max / argmax / softmax" | builtin_kernels.md kernels.reduce_*/softmax + algorithms.reduce, then patterns.md §Reduction |
| "Same data to many cores" | patterns.md §Broadcast |
| "Split a big tensor across N workers" | patterns.md §Distribute/Join |
| "Two-stage pipeline (kernel1 → kernel2)" | patterns.md §Producer-consumer pipeline |
| "Tunable parameter from host" | patterns.md §RTP + WorkerRuntimeBarrier |
| "Deadlock / hang" | pitfalls.md §Forgetting the trailing release after a sliding-window loop |
| "Wrong results / garbage output" | pitfalls.md §Vector size divisibility + §MMUL divisibility + §ObjectFifo type vs. kernel signature |
| "Output is all zeros" | pitfalls.md §Device name doesn't match the hardware |
| "My fix changed nothing" | pitfalls.md §Stale JIT/xclbin cache |
| "Slow / not pipelining" | pitfalls.md §Missing __restrict, then the aie-kernel-opt skill |
| "How do I run / test this?" | programming_guide/section-3/README.md |