Section 4c - Kernel Vectorization¶
- Section 4 - Performance Measurement & Vector Programming
- Section 4a - Timers
- Section 4b - Trace
- Section 4c - Kernel Vectorization
- Section 4d - Measure-First Kernel Optimization
In section-4a we timed a whole application, and in section-4b we used trace to count the cycles a kernel spends between event0() and event1(). This section uses those cycle counts to look at a kernel from the inside: how the scalar version turns into vector code, how the compiler schedules the vector loop, and how close that schedule gets to what the hardware can do.
We switch from the local copy in section-4b to the vector-scalar multiply example. By default it works on 16-bit data and uses the vectorized kernel. Read the example's summary first, then open its kernel source, scale.cc. The same scale.cc is compiled for both AIE2 (npu1, Phoenix/Hawk Point) and AIE2P (npu2, Strix-class) devices.
Unless a number says otherwise, the cycle counts in this section were measured on an npu2 (AIE2P) device with the default compiler, Peano (llvm-aie). Where the proprietary Chess compiler behaves differently, both numbers are shown. Each kernel call processes 1024 elements. Your counts may be off by a cycle or two, and will move more with a different compiler version.
Before you start: make sure your edits are compiled¶
The exercises below ask you to edit scale.cc and to switch the design between its scalar and vector kernels. Three settings control what actually gets built:
- Which
aie_kernels/is compiled. The kernel library compiles the installed copy ofaie_kernels/(inside the wheel or the install tree), not the file in your clone. To compile the clone's sources instead, pointMLIR_AIE_KERNEL_SOURCESat the checkout before you runmake(see Kernel sources): Without it, edits to<mlir-aie>/aie_kernels/eltwise/scale.ccare silently ignored. The kernel's source bytes are part of the build cache key, so an edited kernel is always recompiled. - Scalar or vector. vector_scalar_mul.py takes
vectorized: CompileTime[bool] = True. There is no command-line flag for it: edit the default toFalseto build the scalar kernel. - Data type and compiler.
make int_bit_width=32 tracebuilds the 32-bit version (the default is 16).make CHESS=true tracecompiles the kernel with Chess instead of Peano, if you have the AIE tools installed.
Run make clean between configurations.
The scalar kernel¶
The scalar code in scale.cc is close to the code we traced in section-4b:
template <typename T>
void scale_scalar(T *a, T *c, T factor, const int32_t N) {
event0();
for (int i = 0; i < SCALE_ELEMS; i++) {
c[i] = factor * a[i];
}
event1();
}
The loop reads each element of a, multiplies it by the scalar factor, and stores it to c. SCALE_ELEMS is a macro: the kernel library passes -DSCALE_ELEMS=<tile_size> when it compiles the kernel, so the trip count is a compile-time constant (1024 here). The N argument is still passed at runtime, and if SCALE_ELEMS is not defined, the loop falls back to N. We come back to why this matters in Trip counts.
AIE API¶
To vectorize this loop we use the AIE API, a header-only C++ library that wraps the processor's low-level intrinsics in portable types and operations. The same source then compiles to efficient instructions for each AIE generation. The AIE API documentation covers every type and operation, with per-architecture tables. The vector-times-scalar multiply we need is the aie::mul overload taking a vector and a scalar, under Arithmetic.
Include the API header in the kernel source:
Vector registers¶
A vector is declared as:
*T - the element type, such as int16_t
* vec_factor - the number of elements, such as 32
A native vector register is 512 bits on both AIE2 and AIE2P, so it holds 32 int16_t values:
| Data type | Elements in 512 bits |
|---|---|
| int32_t | 16 |
| int16_t | 32 |
| int8_t | 64 |
| int4_t | 128 |
The full table of supported vectors is in the AIE API basic types page. A vector wider than 512 bits is legal; it is simply held in two or more registers.
Vector load¶
aie::load_v loads a vector register from local L1 memory:
__restrict promises the compiler that pA1 is the only pointer used to access this buffer. Without it, the compiler must assume that a store through the output pointer could change the input, and it cannot overlap one iteration's loads with the previous iteration's stores. The template argument vec_factor matches the one in the vector declaration.
Vector multiply and store¶
aie::mul takes a vector and a scalar and returns its products in an accumulator register:
acc32 holds 32-bit lanes. Multiplying two int32_t values needs a 64-bit accumulator (acc64), and a 512-bit register then only holds 16 lanes.
The result goes back to memory with aie::store_v:
.template to_vector<T>(shift) converts the accumulator back to a vector of T. On the way it shifts right by shift bits, rounds, and saturates (SRS). The rounding follows the core's rounding-mode register (aie::set_rounding).
The whole vector kernel is then:
template <typename T>
void scale_vectorized(T *__restrict a, T *__restrict c, int32_t factor,
const int32_t N) {
event0();
constexpr int vec_factor = 32;
T *__restrict pA1 = a;
T *__restrict pC1 = c;
const int F = SCALE_ELEMS / vec_factor;
T fac = factor;
AIE_PREPARE_FOR_PIPELINING
for (int i = 0; i < F; i++) {
aie::vector<T, vec_factor> A0 = aie::load_v<vec_factor>(pA1);
pA1 += vec_factor;
aie::accum<acc32, vec_factor> cout = aie::mul(A0, fac);
aie::store_v(pC1, cout.template to_vector<T>(0));
pC1 += vec_factor;
}
event1();
}
scale.cc also has a specialization for int32_t. It is the same loop with vec_factor = 16 and an acc64 accumulator.
Instead of one multiply per element, each iteration now loads 32 elements, multiplies them in one vector operation, and stores 32 results, so the loop runs SCALE_ELEMS / 32 times. AIE_PREPARE_FOR_PIPELINING is a loop pragma from aie_kernel_utils.h. What it does depends on the compiler, as we will see below.
NOTE - AIE kernels can also be written directly with the low-level intrinsics that the AIE API is built on. Intrinsics are specific to one AIE generation, while the AIE API is portable across them.
AIE API quick reference¶
The operations used most often in kernel code:
| Signature | Description | Example |
|---|---|---|
aie::vector<T, N> v |
A vector of N elements of type T. |
aie::vector<int16_t, 32> v; |
aie::load_v<N>(ptr) |
Load N contiguous elements into a vector. |
auto a = aie::load_v<32>(pA); |
aie::store_v(ptr, v) |
Store a vector to local memory. | aie::store_v(pC, v); |
aie::mul(a, b) / aie::mac(acc, a, b) |
Vector multiply / multiply-accumulate into an accumulator. b may be a vector or a scalar. |
auto acc = aie::mul(a, fac); |
aie::accum<AccT, N> acc |
An accumulator of N lanes, such as acc32, acc64 or accfloat. |
aie::accum<acc32, 32> acc; |
acc.to_vector<T>(shift) |
Shift, round and saturate an accumulator back to a vector. | auto v = acc.to_vector<int16_t>(0); |
aie::begin_restrict_vector<N>(ptr) |
A vector iterator over a non-aliased buffer. *it++ loads or stores one vector and advances. |
auto pC = aie::begin_restrict_vector<32>(c); |
aie::reduce_add(v) |
Sum the lanes of a vector to one scalar. | int32_t s = aie::reduce_add(v); |
aie::max(a, b) / aie::inv(v) |
Lane-wise maximum; lane-wise reciprocal. | auto r = aie::inv(v); |
Vectorization Exercises¶
-
Let's trace the scalar kernel first. Set
vectorized: CompileTime[bool] = Falsein vector_scalar_mul.py and build the 32-bit version of the design withmake clean; make int_bit_width=32 trace. Theint_bit_widthargument sets the data type and buffer sizes in both the design (vector_scalar_mul.py) and the host code (test.cpp). Opentrace_vector_scalar_mul.jsonin https://ui.perfetto.dev and measure the time betweenevent 0andevent 1. In the Perfetto waveform, 1 us is one clock cycle. How many cycles did you measure?Show answer
10761 cycles with Peano (10245 with Chess). That is about 10.5 cycles per element.The
tracetarget also runs get_trace_summary.py, which pairs everyevent 0with the nextevent 1and prints the number of kernel invocations and the first/min/avg/max cycles. It is a handy summary for single-core designs like this one. Section 4d covers what it can and cannot tell you in larger designs. -
Now set
vectorizedback toTrueand rebuild the 32-bit version (make clean; make int_bit_width=32 trace). How many cycles now?Show answer
338 cycles with Peano (336 with Chess), about 32x faster than the scalar kernel. -
Finally, build the default 16-bit version (
make clean; make trace). How many cycles now, and why is it faster than the 32-bit vector kernel?Show answer
46 cycles with Peano (44 with Chess). The 16-bit scalar kernel takes the same 10761 cycles as the 32-bit one, so this is about 234x faster than scalar. Two things help. Each iteration handles 32 elements instead of 16, so there are half as many iterations. And a 16-bit multiply is a single native vector instruction, while a 32-bit multiply is built from several 16-bit partial products, which we will see in the disassembly below.
How the Compiler Schedules a Loop¶
The cycle counts above are set almost entirely by how the compiler schedules the inner loop. Three ideas explain them.
VLIW bundles. The AIE core is a VLIW processor. Each instruction word, or bundle, has slots for several operations that issue in the same cycle: two loads, one store, scalar ALU work, a vector multiply, a move, and so on. A bundle takes one cycle whether its slots are full or empty (nop).
Software pipelining. A loop iteration is a chain: load, then multiply, then store. Each step waits several cycles for the one before it. Run iterations one after another and most bundles are empty. A software-pipelined loop overlaps them: while iteration i stores, i+1 multiplies and i+2 loads. Two numbers describe the result: * II (initiation interval) - the number of cycles between the starts of two iterations, which is also the number of bundles in the steady-state loop body. The loop costs about II cycles per iteration. * Stages - how many iterations are in flight at once. The compiler emits a prologue before the loop to fill the pipeline and an epilogue after it to drain it.
Zero-overhead loops (ZOL). The core has loop hardware (the loop start, loop end and loop count registers ls, le, lc) that jumps back from the last bundle of a loop body to the first at no cost. A loop that uses it spends no cycles on branch instructions.
Together they give a rule of thumb:
Predict the count before you measure. When the prediction and the trace disagree, one of them is not measuring what you think.
Reading the schedule: static remarks¶
You do not need hardware to see the schedule. aie.utils.compile.remarks compiles each kernel library build exactly as the JIT does and reports every loop's II, stages, bundle count and whether it is a zero-overhead loop. It honours MLIR_AIE_KERNEL_SOURCES, so with that exported as above it reads your edited sources; without it, it compiles the installed copy:
python -m aie.utils.compile.remarks --target aie2p --only scale --out scale.json --meta scale_meta.json
scale.json is a list of rows in the benchmark-chart format. Each loop gets one row, whose name is <kernel>/loop/<function>/<block>/II, whose value is the II, and whose range holds the rest of the schedule:
{"name": "scale/loop/vector_scalar_mul_vector/for.body.i/II", "unit": "cycles", "value": 5,
"range": "NS=5 pro=23 epi=20 zol=True via=postpipeliner at vector.hpp:1105"}
scale/unpipelined_loops, scale/non_zol_loops, scale/pm_bytes (program memory) and scale/pass_failed_warnings (loop pragmas the compiler could not apply). scale_meta.json holds the same data as one record per loop, with ii, ns, prologue_bundles, epilogue_bundles, zol, pipelined, bundle_count, file and line, plus the scheduler's notes per kernel. Use --target aie2 for AIE2. The tool builds the scale kernel for 32-bit data only. See kernels_library.md for the rest of its output, and section 4d for how to use it when optimizing.
Reading the schedule: disassembly¶
The disassembly shows the exact bundles. The kernel object and the core's linked ELF are in the build's project directory, for the default 16-bit build:
build/final_trace_8192.prj/vector_scalar_mul_vector_<hash>.o
build/final_trace_8192.prj/elfs_main_core_0_2/elfs_main_core_0_2.elf
final_trace_16384.prj. The two numbers in core_0_2 are the core's column and row. A design with several cores has one ELF per core. Disassemble with the llvm-objdump that ships with Peano:
$PEANO_INSTALL_DIR/bin/llvm-objdump -d -z build/final_trace_8192.prj/vector_scalar_mul_vector_*.o > disassembly.txt
-z prints bundles that are entirely nops; without it they are elided as ... and you will miscount. Function symbols carry a short hash prefix, for example <1846b0bf_vector_scalar_mul_vector>. Each line is one bundle: an address, the encoded bundle, then the operations in its slots separated by ;.
| Example operations | Description |
|---|---|
nop, nopa, nopb, nopx, ... |
An empty slot |
mov, add, mul |
Scalar move and arithmetic |
lda, st |
Scalar load / store |
vlda, vldb |
Vector load on load unit A / B |
vmul, vmac |
Vector multiply / multiply-accumulate |
vst, vst.srs |
Vector store / store through the SRS path |
vshuffle |
Vector shuffle |
event |
The event0() / event1() markers |
A zero-overhead loop is set up by writes to ls, le and lc, and its body is marked by two labels:
* <.LBB?_?>: - the first bundle of the loop body.
* <.L_LEnd?>: - the last bundle of the loop body. A label names the bundle on the line after it, so the loop body is everything from the .LBB label up to and including the bundle after .L_LEnd. When the loop is a single bundle, both labels point at the same address.
Schedule Exercises¶
-
Run the remarks tool on the shipped
scale.cc. What II does it report for the vector loop (vector_scalar_mul_vector/for.body.i) and for the scalar loop (vector_scalar_mul_scalar/for.body.i)?Show answer
Vector: II 5 with 5 stages, pipelined, ZOL (NS=5 ... zol=True via=postpipeliner). Scalar: II 42, not software-pipelined (NS=1 ... via=None), ZOL. The compiler unrolled the scalar loop four times, so 42 cycles cover 4 elements. -
Use the rule of thumb to predict the two 32-bit cycle counts from Exercises 1 and 2. How close are you?
Show answer
Vector: 1024 / 16 = 64 iterations × 5 = 320, plus about 18 cycles of fill, drain and function overhead = 338, exactly what the trace shows. Scalar: 1024 / 4 = 256 iterations × 42 = 10752, plus 9 = 10761. -
Build the default 16-bit design and disassemble the kernel object. Find
vector_scalar_mul_vectorand its.LBB/.L_LEndlabels. How many bundles are in the loop body, and which vector operations are in it? How does that explain 46 cycles?Show answer
One bundle:vldb x4, [p0], #0x40(a 64-byte load),vst.srs.2x ... [p1], #0x40(a 64-byte store through SRS) andvmul. It is II 1: the loop loads, multiplies and stores one full 32-element vector every cycle, so 1024 / 32 = 32 cycles, plus 14 of fill, drain and overhead = 46. The bundles before the loop are the prologue: the samevldb/vmulrepeated while the pipeline fills. -
Now look at the 32-bit specialization in the same file (the
scale_vectorized<int32_t>symbol is emitted even in the 16-bit build). Its loop has 5 bundles. Which vector operations are in it?Show answer
Onevmuland threevmac, plusvshuffles. AIE2P has no single 32-bit × 32-bit vector multiply, so the product is assembled from 16-bit partial products. That is why the 32-bit loop is II 5 on 16 lanes, and the 16-bit loop is II 1 on 32 lanes. -
Compile the 16-bit design for AIE2. You do not need an npu1 device to compile:
make clean; make devicename=npu build/final_8192.xclbin, then disassemble the kernel object inbuild/final_8192.prj/. How many bundles is the AIE2 loop?Show answer
Two. On AIE2 each vector load moves 256 bits, so the 512-bit input takes avldaplus avldb, and the output takes two 256-bitvst.srsstores. With only one store unit, two stores need two cycles, so II 2 is the floor on AIE2. AIE2P moves 512 bits per load and per store here, so it reaches II 1. The 32-bit loop is II 8 on AIE2. On an npu1 the 16-bit kernel measured 78 cycles for 1024 elements: 32 iterations at II 2 plus 14 cycles of pipeline fill, drain and call.
Trip Counts¶
A software-pipelined loop needs enough iterations to fill its stages. If the compiler cannot prove that the loop runs at least that many times, it has to pick between a slower schedule that is correct for any count and a guarded fast path. So the compiler's knowledge of the trip count matters as much as the loop body.
In the shipped scale.cc the trip count is SCALE_ELEMS / 32, a compile-time constant, so the compiler knows the loop runs exactly 32 times. Kernels whose size arrives as the runtime N argument do not get that for free. Two pragmas from aie_kernel_utils.h tell the compiler what to expect:
* AIE_LOOP_MIN_ITERATION_COUNT(MIN) - the loop always runs at least MIN times.
* AIE_LOOP_RANGE(MIN, MAX) - the loop runs between MIN and MAX times. It is only a trip-count hint: it does not unroll anything.
This is also where the two compilers differ most:
* Under Chess, AIE_PREPARE_FOR_PIPELINING (chess::prepare_for_pipelining) is what enables software pipelining for the loop. Trip-count hints refine the schedule it picks.
* Under Peano, AIE_PREPARE_FOR_PIPELINING expands to nothing. Peano tries to pipeline every innermost loop whose body is a single basic block (no if inside) on its own, so the trip count it can prove, from a constant or from a hint, decides the schedule.
Trip Count Exercises¶
-
In your
MLIR_AIE_KERNEL_SOURCEScopy ofscale.cc, changeconst int F = SCALE_ELEMS / vec_factor;toconst int F = N / vec_factor;in both vector templates, so the trip count is only known at runtime. Rebuild and trace the 16-bit and the 32-bit designs. What changed?Show answer
With Peano, 16-bit: 46 → 464 cycles; 32-bit: 338 → 1426. The data and the loop body are unchanged, but the 16-bit loop is now II 14 (14 × 32 + 16 = 464) and the 32-bit loop II 22 (22 × 64 + 18 = 1426). In the 16-bit disassembly the loop is still onevldb, onevmuland onevst.srs, now spread over 14 bundles that are mostlynops, with a branch before it that skips the loop whenNis below 32.With Chess, 16-bit: 62; 32-bit: 355.
AIE_PREPARE_FOR_PIPELININGstill lets Chess pipeline the loop. -
Now add
AIE_LOOP_MIN_ITERATION_COUNT(16)on the line afterAIE_PREPARE_FOR_PIPELININGin both templates. Rebuild and trace again.Show answer
Peano: back to 46 and 338 cycles, the same schedules as the compile-time constant. Chess: 44 and 335. -
Which pragma did the work? Remove
AIE_PREPARE_FOR_PIPELININGbut keep the hint, and build the 32-bit design with each compiler. Then remove both.Show answer
32-bit, runtime NPeano Chess AIE_PREPARE_FOR_PIPELINING+MIN_ITERATION_COUNT(16)338 335 MIN_ITERATION_COUNT(16)only338 1415 AIE_PREPARE_FOR_PIPELININGonly1426 355 neither 1426 1426 Under Peano the two builds with and without
AIE_PREPARE_FOR_PIPELININGproduce byte-identical code: the hint does all the work. Under Chess the hint alone does nothing andAIE_PREPARE_FOR_PIPELININGdoes most of it. Kernels that must build with both compilers use both pragmas, which is why they appear together throughoutaie_kernels/. -
Why 16? Try
AIE_LOOP_MIN_ITERATION_COUNT(4)on the 16-bit build with Peano and count the loop bundles.Show answer
Four bundles (II 4). In the compiler's output, Peano settles at II 7 for a minimum of 1 or 2, II 4 for 4, II 2 for 8 and II 1 for 16. (Only the 16 case was traced for this section, at 46 cycles.) The II 1 schedule has 14 stages, so the compiler needs to know at least that many iterations will run. The hint is a promise, not a request: the compiler may drop its short-loop path, so the count must be true for every caller. Give the smallest count that holds for all of them.
Coding for the Architecture¶
The vectorized kernel is 234x faster than scalar, but is it good? To answer that we compare it with what the hardware can do. The vector unit diagrams below are from the AIE-ML (AIE2) architecture manual (am020). AIE2P has the same overall structure, with the differences listed in the table after them.
The Vector Unit - Loads¶

Vector registers are filled by two load units working in parallel from local L1 memory. They feed the permute blocks and then the multiplier. Not every load instruction exists on both units: in section 4d, a converting bf16 load that exists only on load unit A sets the pace of a loop.
The Vector Unit - Multiply and Add (MAC)¶
The multiplier supports the AIE data types. An optional post-add step (heavily used by matrix multiplication) sums products before they land in the accumulator registers. The best-scheduled code issues one vector MAC every cycle.
The Vector Unit - SRS and Stores¶
Results go back to L1 through a single store unit. The SRS (shift-round-saturate) path converts accumulators to vectors on the way out; the upshift (UPS) path goes the other way, from vectors to accumulators.

The Vector Unit - Shift/ Shuffle/ Adder Path¶
A parallel path performs shifts, shuffles, simple additions, comparisons and other vector functions without using the multiplier.

AIE2 and AIE2P differences that change this example¶
| AIE2 (npu1) | AIE2P (npu2) | |
|---|---|---|
Loads in the 16-bit scale loop |
vlda + vldb, 256 bits each |
one vldb, 512 bits |
Stores in the 16-bit scale loop |
two 256-bit vst.srs |
one 512-bit vst.srs.2x |
16-bit scale loop |
II 2 | II 1 |
32-bit scale loop |
II 8 | II 5 (32-bit multiply built from 16-bit partial products) |
| int16 matrix-multiply instruction | 4x4x4, 64 MACs | 4x4x8, 128 MACs |
float vector multiply |
no native instruction; emulated with several bf16 multiplies | no native instruction; emulated with several bf16 multiplies |
Scalar float multiply and divide |
library calls (__mulsf3, __divsf3) |
library calls (__mulsf3, __divsf3) |
The first four rows are Peano's output for this example. The matrix-multiply shapes are from the AIE API matrix multiplication table. The last row applies to scalar float code with Peano on both architectures: every scalar float multiply or divide in a hot loop is a function call. Section 4d shows how to find them, and what removing them gained.
Multiplier Utilization Efficiency¶

The table in this diagram gives the number of MACs per cycle for each precision. Another source is Table: Supported Precision Width of the Vector Data Path in the AM020 spec for AIE2. Those peak figures are for matrix multiplication, where the post-add step sums several products into each accumulator lane. An elementwise multiply produces one result per lane, so it uses at most one multiplier per accumulator lane: 32 lanes of 16-bit data per instruction.
MAC efficiency¶
Each kernel call multiplies 1024 16-bit values. At 32 per cycle the ideal is 1024 / 32 = 32 cycles; on AIE2P we measured 46.
Total MAC efficiency is the product of two ratios: * MAC schedule efficiency: ideal MAC cycles / actual cycles = 32 / 46 = 70%. * Per-clock MAC utilization: MACs used / MACs available = 32 / 128 = 25% on AIE2P (32 / 64 = 50% on AIE2).
Total: 70% × 25% ≈ 17%. That sounds poor, but an elementwise multiply simply has no sums for the matrix hardware to do. The more useful question is what limits the schedule.
Load/ Store bandwidth efficiency¶
On AIE2P the loop issues one 512-bit load, one multiply and one 512-bit store every cycle. II 1 is the smallest II any loop can have, so the loop body is as fast as it can be; the remaining 14 cycles are filling and draining a 14-stage pipeline and entering and leaving the function. On AIE2 the two 256-bit stores per vector need two cycles through the single store unit, so the compiler's AIE2 schedule cannot beat 64 cycles for this kernel regardless of the multiplier; an npu1 measured 78.
Data movement efficiency¶
Compute is only half the picture. Each call's 1024 × 16-bit input has to arrive through a DMA and its output has to leave through one. The next exercise measures how long that takes.
Optimization Exercises¶
-
Trace the default 16-bit design again and open the waveform. Hover over the blocks of
PortRunning0(input DMA) andPortRunning1(output DMA). How many cycles does each 2 KiB chunk take?
Show answer
About 256 cycles on npu2 (8 bytes per cycle). The 32-bit design moves 4 KiB per chunk in about 512 cycles. The kernel needs only 46 cycles per chunk, so this design is limited by data movement by a factor of about 5: the core waits for data most of the time. Makingscalefaster would not make the application faster. -
A kernel with more compute per byte balances this better. The single-core matrix multiply example by default multiplies 512x512x512
int16_tmatrices in kernel calls of m×k×n = 32x32x32 (32,768 MACs per call). The AIE2P int16 kernel uses the 4x4x8 matrix-multiply instruction, 128 MACs. What is the ideal cycle count per call?Each call reads a 32x32Show answer
32,768 / 128 = 256 cycles.int16_ttile of A and of B, 2 KiB each, on separate channels. At the rate you measured in the previous exercise, how long does moving them take?Show answer
About 256 cycles for each tile, in parallel. On paper compute and data movement are balanced. -
Navigate to the single-core matrix multiply example, run
make clean; make trace, and look at theget_trace_summary.pyoutput. What are the first and min cycle counts?Show answer
First 537, min 70 (Peano, npu2). 537 is the matrix-multiply kernel: its inner loop is II 4 with four 4x4x8 MACs per iteration, one MAC instruction per cycle, and the whole call is predicted at 536 cycles including each block's prologue and epilogue. That is 48% of the 256-cycle ideal and well above the data movement time.The min of 70 is not a faster matmul call. The same core also runs a
zerokernel that clears the output accumulator before each block, and it carries its ownevent0()/event1(). The summary pools both kernels into one list. The summary also counted fewer invocations than the design makes, because the trace buffer filled. Section 4d explains how to separate the two populations and why you should check before quoting any trace-summary number.
Loop Pragma Reference¶
The macros in aie_kernel_utils.h let one kernel source carry hints for both compilers. Put them on the line directly before the for. Each expands differently under each compiler, and several do nothing under Peano:
| Macro | Peano (#pragma clang loop ...) |
Chess | Notes |
|---|---|---|---|
AIE_LOOP_MIN_ITERATION_COUNT(n) |
min_iteration_count(n) |
chess::min_loop_count(n) |
Must be true for every call. Measured: 1426 → 338 on the 32-bit scale with a runtime N (Trip Count Exercises). On AIE2 it let axpy's runtime-count loop overlap (269 → 87), with a plain loop kept for shorter rows. In two other kernels the compiler reported that it cost the zero-overhead loop, so check zol after adding it. |
AIE_LOOP_MAX_ITERATION_COUNT(n) |
max_iteration_count(n) |
chess::max_loop_count(n) |
|
AIE_LOOP_RANGE(a, b) |
min + max iteration count | min + max loop count | A trip-count hint only. It does not unroll. |
AIE_LOOP_UNROLL(n) |
unroll_count(n) |
chess::unroll_loop(n) |
Measured: ×4 on a latency-bound loop took add from 390 to 150 cycles (4d). |
AIE_LOOP_UNROLL_FULL |
unroll(full) |
chess::unroll_loop() |
Needs a constant trip count. Measured: keeping an accumulator array in registers took conv2dk1_i8 from 7623 to 504 cycles (4d). |
AIE_LOOP_NO_UNROLL |
unroll(disable) |
chess::no_unroll |
|
AIE_TRY_INITIATION_INTERVAL(n) |
pipeline_initiation_interval(n) |
nothing | Asks Peano's pipeliner to try this II. Not measured in this guide. |
AIE_PREPARE_FOR_PIPELINING |
nothing | chess::prepare_for_pipelining |
Enables pipelining under Chess. Under Peano the code is byte-identical with or without it. |
AIE_PREPARE_FOR_POSTPIPELINING |
pipeline(disable) |
nothing | Under Peano this turns pipelining off for the loop. |
AIE_LOOP_HINT(key, value) / AIE_LOOP_GPR_REALLOC |
hint(key, value) / hint(aie-gpr-realloc, 1) |
nothing | Peano back-end tuning hints. |
AIE_NO_PREPARE_FOR_PIPELINING, AIE_MODULO_SCHEDULING_BUDGET_RATIO(n), AIE_KEEP_SW_LOOP, AIE_PEEL_PIPELINED_LOOP(n), AIE_KEEP_FREE_FOR_PIPELINING(r), AIE_ALLOCATE(r), AIE_NO_HW_LOOP, AIE_LOOP_FLATTEN |
nothing | the matching Chess attribute | Chess-only scheduling controls. |
Two per-architecture definitions from aie_arch.h sit beside these pragmas in library kernels: AIE_BF16_LANES is the bf16 vector-multiply width (16 on AIE2, 32 on AIE2P), and AIE2_RESTRICT puts __restrict on a pointer parameter only on AIE2, whose pipeliner needs it to overlap a streaming loop.
The Peano expansions are the ones active by default. Check aie_kernel_utils.h for the exact spelling before relying on a macro. A pragma that expands to nothing leaves the compiled code byte-identical, so the quickest way to know whether a pragma did anything is to compare the II the remarks tool reports, or the disassembly, with and without it. Whether it made the kernel faster is a question for the hardware: section 4d shows how to answer it with the kernel benchmark.
Peano flags that are not pragmas can be passed to one kernel through the compile_flags of its ExternalFunction. Section 4d has one measured example, a higher pipeliner stage limit.