Section 4a - Timers¶
- Section 4 - Performance Measurement & Vector Programming
- Section 4a - Timers
- Section 4b - Trace
- Section 4c - Kernel Vectorization and Optimization
We begin by first looking at timers for measuring application performance and what that tells us. The performance of an accelerated AI Engine application involves a number of components on the software stack, from invoking the application at the OS level, to passing control on to the kernel drivers, moving and dispatching work to the AIE array, running the accelerated application on AIE cores, and finally returning the data to the application for next-step processing. The most straightforward way to capture the performance of this entire stack of communication and processing is with an application timer, also known as the "wall clock" time. This gives us the upper bounds for how long an AIE accelerated application takes but adds to it the OS and kernel driver overhead. This is something that can be minimized when running multiple iterations of an accelerated program or running a sufficiently compute intensive application. Let's take a look at how we add the "wall clock" timer to an example program.
The Compact Form: @iron.jit + run_iters¶
In a @iron.jit-decorated design, calling the design at the host side is what runs the kernel. The IRON helper aie.utils.benchmark.run_iters wraps that call site in a warmup + iters loop, captures NPU-side time (from the kernel result) and end-to-end host time, and returns a BenchmarkResult. The pair is exactly what the explicit chrono-loop in test.cpp does — in one line:
from aie.utils.benchmark import print_benchmark, run_iters
bench = run_iters(
vector_scalar_mul, a_in, f_in, c_out,
warmup=opts.warmup, iters=opts.iters,
)
print_benchmark(bench)
vector_scalar_mul.py uses this directly; running make run (or python3 vector_scalar_mul.py --iters 10 --warmup 4) prints the canonical avg/min/max us pair for both NPU and end-to-end timings.
The rest of this section walks down through what's happening underneath, using the explicit chrono-loop in test.cpp and the manual accumulator loop in test.py.
Application timer - Modifying test.cpp¶
Adding the application timer is as simple as noting a start and stop time surrounding the calling of the kernel function. We can use the clock timer from the chrono library which is included via #include <chrono> but this may already be imported by other libraries (this is the case in our test.cpp). Then we record the start and stop time of our chrono timer with timer function calls surrounding our kernel function as follows:
auto start = std::chrono::high_resolution_clock::now();
unsigned int opcode = 3;
auto run = kernel(opcode, bo_instr, instr_v.size(), bo_inA, bo_inFactor, bo_outC);
run.wait();
auto stop = std::chrono::high_resolution_clock::now();
float npu_time = std::chrono::duration_cast<std::chrono::microseconds>(stop - start).count();
std::cout << "NPU time: " << npu_time << "us." << std::endl;
Multiple iterations¶
A timer for a single kernel function call is a useful starting point for understanding performance but there can be a lot of variability and overhead for a single call that is smoothed out when run multiple times. In order to benchmark the steady-state kernel run time, we can add code around our kernel call to execute multiple times and capture the minimium, maximum, and average time that our kernel takes.
In our example test.cpp, we wrap our calls within a for loop (based on num_iter or number of iterations).
unsigned num_iter = n_iterations + n_warmup_iterations;
for (unsigned iter = 0; iter < num_iter; iter++) {
<... kernel run code ...>
}
for (unsigned iter = 0; iter < num_iter; iter++) {
<... kernel run code ...>
if (iter < n_warmup_iterations) {
/* Warmup iterations do not count towards average runtime. */
continue;
}
<... verify and measure timers ...>
}
for (unsigned iter = 0; iter < num_iter; iter++) {
<... kernel run code, warmup conditional, verify, and measure timers ...>
npu_time_total += npu_time;
npu_time_min = (npu_time < npu_time_min) ? npu_time : npu_time_min;
npu_time_max = (npu_time > npu_time_max) ? npu_time : npu_time_max;
}
std::cout << "Avg NPU time: " << npu_time_total / n_iterations << "us." << std::endl;
std::cout << "Min NPU time: " << npu_time_min << "us." << std::endl;
std::cout << "Max NPU time: " << npu_time_max << "us." << std::endl;
In addition, if you have an estimate of the number of MACs each kernel execution takes, you can report additional performance data such as GFLOPs as can be seen in the matrix multiplication example test.cpp.
Verifying NPU output: aie.utils.verify¶
Benchmarking is paired with a correctness check. AIE kernels are
often LUT approximations or use saturating arithmetic, so the canonical
host comparator is not np.array_equal — too strict for bf16 or LUT
outputs — but a tolerance-aware sibling in
aie.utils.verify:
from aie.utils.verify import count_mismatches, nearly_equal
# Count tolerance violations; stop on the first inf/nan from either side
# (the LUT's behaviour outside its defined input range is not part of
# the contract).
errors, n_checked = count_mismatches(actual, ref, rtol=0.05)
print(f"{errors} / {n_checked} samples outside tolerance")
# Same comparator, boolean mask form — useful when you want to inspect
# exactly which entries failed.
mask = nearly_equal(actual, ref, rtol=0.05)
nearly_equal(a, b, *, rtol=0.128, atol=None) returns a boolean
ndarray with True where |a - b| < max(atol, rtol * (|a| + |b|)).
Both inputs are coerced to float32 (enough headroom for bf16); NaN
on either side yields False (matches IEEE semantics). Defaults to
rtol=0.128 (12.8%) to match the test_utils::nearly_equal C++
helper that test.cpp testbenches use, so Python and C++ host harnesses
agree on what counts as a passing run.
count_mismatches(actual, ref, *, rtol=0.128, atol=None, stop_at_nonfinite=True)
returns (errors, n_checked). n_checked is less than len(ref)
when stop_at_nonfinite=True (the default) and an inf/nan appears
— the verification halts there rather than counting every saturated
sample as a violation. Pass stop_at_nonfinite=False to count
everything.
aie.utils.verify also exposes assert_pass(actual, expected, *, rtol=None, atol=None, fail_msg=None)
— a one-liner that runs the comparison, prints PASS! on success, and
sys.exit("FAIL!") on mismatch. With both rtol and atol set to
None (the default), it uses np.array_equal for exact compare (the
right choice for integer / bit-exact pipelines); passing rtol= opts
into the tolerance comparator above.
Exercises¶
-
Build + run the
@iron.jitdesign withmake runand note the reported NPU + end-to-end averages. (The defaults are--warmup 4 --iters 10; override via--warmup N --iters Mon the python command line.) What did you see? -
Build the xclbin + insts pair via
make all, then run the explicit C++ host withmake run_cppand note the reported "wall clock" time.Show answer
Answer can be anywhere from 300-600us -
The design was run once with a single iteration and no warmup. Run again with
make run_cpp— same defaults. What reported Avg NPU time did you see this time?Show answer
Answer can still be anywhere from 300-600us but is likely different than before -
Set iterations to 10 with
make run_cpp-10(passes--iters 10). What reported Avg NPU time do you see this time?Show answer
This time, we see a narrower range between 300-400 us -
Finally, add 4 warmup iterations to cut higher outliers when the application is first run by calling
make run_cpp-10-warmup. This passes--warmup 4to the executable. What reported Avg NPU time do you see this time?Show answer
This time, we see an lower average range between 200-300 us