When an algorithm is too slow, the first instinct is to ask for a faster processor. Usually that is not needed: the processor you already have can process sixteen values with a single instruction, but it only does so if explicitly told to. In this lab we write the same algorithm in four variants and measure not just how fast each one runs, but also how much energy it consumes.
1Objectives of the lab
- Understanding the single-instruction, multiple-data (SIMD) computing model
- Checking what the compiler vectorizes on its own and what it cannot vectorize
- Writing code with NEON intrinsic functions and understanding the instructions generated
- Correctly measuring execution time, with warm-up and repetitions
- Calculating energy per frame - and discovering that the fast variant is also the economical one
- Applying Amdahl's law to estimate the acceleration limit
- Placing SIMD among the other forms of acceleration: more threads, GPU, FPGA, dedicated circuits
2Purpose of the lab
We pick a simple and useful algorithm - converting a color image to grayscale - and write it in four variants, from the most naive to the most optimized. We measure all of them, with the same data set, on the same board, then add the power measurement from the first lab and calculate the energy per frame.
| Variant | What changes | Effort |
|---|---|---|
| 1. Scalar | an ordinary loop, one pixel per iteration | the reference |
| 2. Auto-vectorized | the same code, different compilation flags | zero - just recompilation |
| 3. Explicit NEON | intrinsic functions, 8 pixels per instruction | medium |
| 4. NEON + threads | all four cores work simultaneously | small, on top of variant 3 |
The algorithm
The human eye does not perceive the three colors equally: it is far more sensitive to green than to blue. The standard conversion accounts for this:
3Why SIMD
An ordinary processor processes one value per instruction. But its registers are 128 bits wide, and a pixel is 8 bits. The remaining 120 bits sit unused.
SIMD - Single Instruction, Multiple Data - fills the register with sixteen 8-bit values and applies the same operation to all of them at once. The same instruction, the same execution time, sixteen times more work done.
What can and cannot be vectorized
| Vectorizes well | Cannot be vectorized |
|---|---|
| the same operation on many independent elements | iteration i depends on the result of iteration i-1 |
| sequential memory access | access through indirection: a[b[i]] |
| number of iterations known before the loop | the loop stops on a data-dependent condition |
| branches that can be expressed with masks | function calls inside the loop body |
for (int i = 1; i < n; i++)
a[i] = a[i-1] * 0.9 + b[i] * 0.1; // recursive filter4Preparation
- Check what the processor knowscapabilities
lscpu | grep -i "model name\|flags\|architect" cat /proc/cpuinfo | grep -m1 Features
On the Raspberry Pi 5 (Cortex-A76, 64-bit ARMv8.2-A),
asimdmust appear - the official name of the 64-bit NEON set. On the aarch64 architecture, NEON is mandatory, so no special compilation flag is needed to enable it. - Create the working folderfolder
mkdir -p ~/si-lab/lab05 && cd ~/si-lab/lab05
- Fix the measurement conditions
Without this step, results vary from one run to another and the comparisons are worthless.
stable conditions# fix the frequency at maximum, so we do not measure the governor instead of the algorithm echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor # no graphical interface sudo systemctl isolate multi-user.target # check that there is no thermal throttling vcgencmd measure_temp && vcgencmd get_throttled
Mandatoryget_throttledmust return0x0. If the processor is thermally throttled, you will be measuring the cooler's effectiveness, not the code's.
5The measurement framework
Before the algorithm, we need a correct measurement method. This is the part that most people get wrong: a single run of a short loop measures noise, not performance.
#ifndef COMMON_H
#define COMMON_H
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
/* Test image: 1920 x 1080, three bytes per pixel. */
#define WIDTH 1920
#define HEIGHT 1080
#define PIXELS ((size_t)(WIDTH) * (HEIGHT))
/* Monotonic clock: does not jump backward when the system clock is synced. */
static inline double now(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return t.tv_sec + t.tv_nsec * 1e-9;
}
/* Deterministic image: the same data on every run, hence comparable. */
static void fill(uint8_t* rgb, size_t pixels) {
for (size_t i = 0; i < pixels; i++) {
rgb[i * 3 + 0] = (uint8_t)((i * 7) & 0xFF);
rgb[i * 3 + 1] = (uint8_t)((i * 13) & 0xFF);
rgb[i * 3 + 2] = (uint8_t)((i * 29) & 0xFF);
}
}
/* Correctness check: a fast but wrong variant is worth nothing. */
static int compare(const uint8_t* a, const uint8_t* b, size_t n, const char* name) {
for (size_t i = 0; i < n; i++) {
if (a[i] != b[i]) {
printf(" ERROR in %s at pixel %zu: %u != %u\n", name, i, a[i], b[i]);
return 0;
}
}
return 1;
}
/* Measurement with warm-up and repetitions: returns the MEDIAN time, not the mean.
The median is not skewed by a single run disturbed by the system. */
static int cmp_double(const void* x, const void* y) {
double a = *(const double*)x, b = *(const double*)y;
return (a > b) - (a < b);
}
#define REPETITIONS 11
static double measure(void (*function)(const uint8_t*, uint8_t*, size_t),
const uint8_t* input, uint8_t* output, size_t pixels) {
/* Warm-up: brings the data into cache and stabilizes the frequency. */
for (int i = 0; i < 3; i++) function(input, output, pixels);
double times[REPETITIONS];
for (int i = 0; i < REPETITIONS; i++) {
double t0 = now();
function(input, output, pixels);
times[i] = now() - t0;
}
qsort(times, REPETITIONS, sizeof(double), cmp_double);
return times[REPETITIONS / 2];
}
#endif- Warm-up - the first run brings the data into cache and lets the processor climb to maximum frequency. It is systematically slower and must be discarded.
- Median, not mean - a single run disturbed by the operating system shifts the mean, but does not touch the median.
- Checking the result - an "optimized" variant that gives a different result is not an optimization, it is a bug. Comparing against the reference variant is mandatory.
6The four variants
Variant 1 - scalar, the reference
#include "common.h"
void gray_scalar(const uint8_t* rgb, uint8_t* gray, size_t pixels) {
for (size_t i = 0; i < pixels; i++) {
uint32_t r = rgb[i * 3 + 0];
uint32_t g = rgb[i * 3 + 1];
uint32_t b = rgb[i * 3 + 2];
gray[i] = (uint8_t)((77 * r + 150 * g + 29 * b) >> 8);
}
}Variant 2 - same source, different compilation flags
The compiler can vectorize this loop on its own. We ask it to tell us whether it succeeded:
gcc -O2 -c gray.c -o /dev/null -fopt-info-vec-optimized echo "--- now with O3 ---" gcc -O3 -mcpu=cortex-a76 -c gray.c -o /dev/null -fopt-info-vec-optimized
-O2, GCC does not vectorize by default. With -O3 a message of the form
"loop vectorized using 16 byte vectors" appears. If instead -fopt-info-vec-missed
shows up with an explanation, that is the valuable information: the compiler tells you exactly what
stopped it. The most frequent cause is that it cannot prove the input and output regions do not overlap
in memory.restrict keyword
It promises the compiler that two pointers do not point to the same memory region. Without this promise,
the compiler must assume the worst case and gives up on vectorization:
void gray_scalar(const uint8_t* restrict rgb, uint8_t* restrict gray, size_t pixels)
Variant 3 - explicit NEON
#include "common.h"
#include <arm_neon.h> /* the NEON intrinsic functions */
void gray_neon(const uint8_t* rgb, uint8_t* gray, size_t pixels) {
/* The weights, replicated across all 8 lanes of the register. */
const uint8x8_t w_r = vdup_n_u8(77);
const uint8x8_t w_g = vdup_n_u8(150);
const uint8x8_t w_b = vdup_n_u8(29);
size_t i = 0;
for (; i + 8 <= pixels; i += 8) {
/* vld3_u8 loads 24 bytes and SPLITS them automatically across the
three channels: 8 R values, 8 G values, 8 B values. Exactly what
we need for interleaved data - an instruction a compiler
rarely picks on its own. */
uint8x8x3_t px = vld3_u8(rgb + i * 3);
/* Widening multiply: 8 bits x 8 bits -> 16 bits, so nothing overflows.
77 * 255 = 19635, so 8 bits would not have been enough. */
uint16x8_t sum = vmull_u8(px.val[0], w_r);
/* Widening multiply AND accumulate, in a single instruction. */
sum = vmlal_u8(sum, px.val[1], w_g);
sum = vmlal_u8(sum, px.val[2], w_b);
/* Shift right by 8 AND narrow back to 8 bits,
also in a single instruction. */
vst1_u8(gray + i, vshrn_n_u16(sum, 8));
}
/* Tail: the remaining pixels, when their count is not a multiple of 8.
This part is very easy to overlook and produces a wrong strip in the image. */
for (; i < pixels; i++) {
uint32_t r = rgb[i * 3 + 0], g = rgb[i * 3 + 1], b = rgb[i * 3 + 2];
gray[i] = (uint8_t)((77 * r + 150 * g + 29 * b) >> 8);
}
}vld3_u8- splits the interleaved R,G,B,R,G,B... data into three separate registers. A compiler generates a long sequence of permutations for this.vmlal_u8- multiplies and accumulates in a single operation, widening to 16 bits.vshrn_n_u16- shifts and narrows at once, instead of two instructions.
Variant 4 - NEON on all cores
#include "common.h"
#include <omp.h>
void gray_neon(const uint8_t* rgb, uint8_t* gray, size_t pixels); /* variant 3 */
void gray_parallel(const uint8_t* rgb, uint8_t* gray, size_t pixels) {
int threads = omp_get_max_threads();
/* Split into chunks that are a multiple of 8, so each thread can use
the full NEON path, without its own tail. */
size_t chunk = ((pixels / threads) / 8) * 8;
#pragma omp parallel for schedule(static)
for (int t = 0; t < threads; t++) {
size_t start = t * chunk;
size_t count = (t == threads - 1) ? (pixels - start) : chunk;
gray_neon(rgb + start * 3, gray + start, count);
}
}gcc -O3 -mcpu=cortex-a76 -fopenmp \
main.c gray.c gray_neon.c gray_parallel.c -o comparison
./comparison7The comparison program
#include "common.h"
void gray_scalar(const uint8_t*, uint8_t*, size_t);
void gray_neon(const uint8_t*, uint8_t*, size_t);
void gray_parallel(const uint8_t*, uint8_t*, size_t);
typedef struct {
const char* name;
void (*function)(const uint8_t*, uint8_t*, size_t);
} Variant;
int main(void) {
uint8_t* rgb = malloc(PIXELS * 3);
uint8_t* output = malloc(PIXELS);
uint8_t* reference = malloc(PIXELS);
if (!rgb || !output || !reference) return 1;
fill(rgb, PIXELS);
/* Correctness reference, computed once. */
gray_scalar(rgb, reference, PIXELS);
Variant variants[] = {
{ "scalar", gray_scalar },
{ "NEON", gray_neon },
{ "NEON + 4 threads", gray_parallel },
};
printf("image %d x %d = %.1f million pixels\n\n",
WIDTH, HEIGHT, PIXELS / 1e6);
printf("%-16s %10s %12s %10s %8s\n",
"variant", "time", "Mpixels/s", "speedup", "correct");
printf("%s\n", "------------------------------------------------------------");
double t_reference = 0;
for (size_t v = 0; v < sizeof(variants) / sizeof(variants[0]); v++) {
memset(output, 0, PIXELS);
double t = measure(variants[v].function, rgb, output, PIXELS);
if (v == 0) t_reference = t;
int correct = compare(output, reference, PIXELS, variants[v].name);
printf("%-16s %8.2f ms %12.1f %9.2fx %8s\n",
variants[v].name, t * 1000.0, PIXELS / t / 1e6,
t_reference / t, correct ? "yes" : "NO");
}
/* How many frames per second does this mean, in practice? */
printf("\nat 30 frames per second, the budget is %.1f ms per frame\n",
1000.0 / 30.0);
free(rgb); free(output); free(reference);
return 0;
}Typical results
Your values will differ, but the ratios should be similar:
| Variant | Time | Mpixels/s | Speedup |
|---|---|---|---|
scalar, -O2 | 18.4 ms | 113 | 1.00× |
scalar, -O3 (auto-vectorized) | 6.9 ms | 301 | 2.67× |
| explicit NEON | 2.6 ms | 798 | 7.08× |
| NEON + 4 threads | 0.9 ms | 2305 | 20.4× |
8Energy per frame
This is where the lab connects back to the first one. A faster variant draws more power - but for less time. Which one wins?
#!/usr/bin/env python3
"""Measures the energy consumed by each variant of the algorithm."""
import subprocess
import sys
import threading
import time
sys.path.append("../lab01") # reuse the measurement from the first lab
from power_pmic import measure
PERIOD = 0.05 # sample at 20 Hz
FRAMES = 200 # how many frames we process for each variant
def energy_of_a_run(command):
"""Runs the command and integrates the power over its whole duration."""
samples = []
stop = threading.Event()
def sample():
while not stop.is_set():
p, _ = measure()
if p:
samples.append((time.time(), p))
time.sleep(PERIOD)
thread = threading.Thread(target=sample, daemon=True)
thread.start()
t0 = time.time()
subprocess.run(command, stdout=subprocess.DEVNULL, check=True)
duration = time.time() - t0
stop.set()
thread.join(timeout=1)
if len(samples) < 2:
return duration, 0.0, 0.0
# Trapezoidal integration: more accurate than multiplying by the mean.
energy = 0.0
for (t1, p1), (t2, p2) in zip(samples, samples[1:]):
energy += (p1 + p2) / 2 * (t2 - t1)
average_power = energy / (samples[-1][0] - samples[0][0])
return duration, average_power, energy
def main():
print("Measuring idle consumption (5 s)...")
t0 = time.time()
idle = []
while time.time() - t0 < 5:
p, _ = measure()
if p:
idle.append(p)
time.sleep(PERIOD)
p_idle = sum(idle) / len(idle)
print("idle: %.3f W\n" % p_idle)
print("%-16s %9s %9s %10s %13s" %
("variant", "duration", "power", "energy", "mJ / frame"))
print("-" * 62)
results = []
for name, index in [("scalar", "0"), ("NEON", "1"), ("NEON + 4 threads", "2")]:
duration, power, energy = energy_of_a_run(
["./comparison_repeated", index, str(FRAMES)])
per_frame = energy / FRAMES * 1000 # millijoules
useful = (power - p_idle) * duration / FRAMES * 1000
results.append((name, duration, power, energy, per_frame, useful))
print("%-16s %7.2f s %7.2f W %8.2f J %11.2f" %
(name, duration, power, energy, per_frame))
most_economical = min(results, key=lambda r: r[4])
reference = results[0][4]
print("\nmost economical: %s (%.1fx less energy than the scalar variant)"
% (most_economical[0], reference / most_economical[4]))
if __name__ == "__main__":
main()This is exactly the "race to idle" strategy from the first lab, applied at the instruction level instead of the frequency level. We did not change the voltage, we did not change the frequency - we made better use of the existing hardware, and gained speed and economy at the same time. It is the only optimization that comes with no trade-off.
9The theoretical acceleration limit
We sped up the conversion twenty times. But what if it is only part of the complete program?
| How much of the program we accelerate (p) | Local speedup s = 20× | Total speedup | Limit, even with s = ∞ |
|---|---|---|---|
| 50 % | 20× | 1.90× | 2.00× |
| 80 % | 20× | 4.17× | 5.00× |
| 95 % | 20× | 10.26× | 20.00× |
| 99 % | 20× | 16.81× | 100.00× |
sudo apt install -y linux-perf perf stat ./comparison perf record -g ./comparison && perf report --stdio | head -30
Where SIMD sits among the other forms of acceleration
| Method | Typical speedup | Effort | When it is justified |
|---|---|---|---|
| A better algorithm | unlimited | large | always, first |
| Compilation flags | 1.5-3× | minimal | always, right after |
| Hand-written SIMD | 4-8× | medium | regular loops, over lots of data |
| More cores | up to the core count | medium | independent tasks |
| GPU | 10-100× | large | large volumes, massively parallel computation |
| FPGA | 10-1000× | very large | medium series, strict latency requirements |
| Dedicated circuit | 100-10000× | enormous | very large series (codecs, cryptography) |
10Assignments
- Compile the scalar variant with
-O0,-O2and-O3. Note the times and explain the difference between them. - Run
-fopt-info-vec-missedon the scalar variant withoutrestrict. Copy into your report the exact reason the compiler gives, then addrestrictand show how the message changes. - Run the comparison program and fill in the table with your own values. Check the correctness column for every variant.
- Intentionally remove the tail loop from the NEON variant and run it with an image whose pixel count is not a multiple of 8. Describe what happens and why the bug is hard to notice visually.
- Measure the energy per frame for all three variants. Draw a chart with two axes: time per frame and energy per frame.
- Apply Amdahl's law: if, in the complete application, the conversion takes up 60 % of the time, what total
speedup do you get with the NEON + threads variant? Verify the result with
perf. - Modify the NEON variant to process 16 pixels per iteration instead of 8, using
vld3q_u8and the full 128-bit registers. Measure whether you gain anything further and explain the result through the memory-bandwidth limit.
11Deeper-dive challenge
You will run into three real problems that the conversion does not have:
- Three rows at once. Three lines of the image must be read simultaneously. How do you keep them in registers without reloading the same data three times?
- The edges. What happens at the first and last row, where neighbors are missing? Compare the two usual solutions: handling the edges separately or padding the image with a border.
- Overflow. The sum of nine 8-bit values does not fit in 16 bits if the weights are large. When must you switch to 32 bits, and how much does that cost you in lost lanes?