Embedded Systems / Laboratory
LABORATORY 05

Hardware Acceleration: the Same Algorithm, Ten Times Faster

Duration: 3 hours Reference: Chapter 9 Platform: Raspberry Pi 5 · ARM NEON Book reference chapter RO versiunea română

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.

VariantWhat changesEffort
1. Scalaran ordinary loop, one pixel per iterationthe reference
2. Auto-vectorizedthe same code, different compilation flagszero - just recompilation
3. Explicit NEONintrinsic functions, 8 pixels per instructionmedium
4. NEON + threadsall four cores work simultaneouslysmall, 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:

The formula, in integer arithmetic
gray = (77·R + 150·G + 29·B) >> 8
The coefficients 77, 150 and 29 are the 8-bit approximations of the weights 0.299 / 0.587 / 0.114, and their sum is exactly 256 - so the final division becomes a simple 8-position shift. No floating-point operation, no division. This is not a textbook simplification: it is exactly what every image-processing library does.

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.

Scalar processing versus lane-based processing
Press "run" and watch the difference The top row advances by one value per step. The bottom row advances by sixteen. Both do exactly the same calculation, on the same processor. The only difference is that the second variant uses the register width that already exists anyway.

What can and cannot be vectorized

Vectorizes wellCannot be vectorized
the same operation on many independent elementsiteration i depends on the result of iteration i-1
sequential memory accessaccess through indirection: a[b[i]]
number of iterations known before the loopthe loop stops on a data-dependent condition
branches that can be expressed with masksfunction calls inside the loop body
Data dependency is the fundamental barrier
cannot be vectorized - and no compiler will do it
for (int i = 1; i < n; i++)
    a[i] = a[i-1] * 0.9 + b[i] * 0.1;   // recursive filter
Each result needs the previous one. It is not a compiler limitation but an algorithmic one: the computations are not independent, so they cannot be done simultaneously. When you run into a loop that nothing accelerates, first ask yourself whether this is the reason.

4Preparation

  1. Check what the processor knows
    capabilities
    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), asimd must 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.

  2. Create the working folder
    folder
    mkdir -p ~/si-lab/lab05 && cd ~/si-lab/lab05
  3. 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_throttled must return 0x0. 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.

common.h
#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
Three decisions that make the difference between a measurement and an opinion
  1. 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.
  2. Median, not mean - a single run disturbed by the operating system shifts the mean, but does not touch the median.
  3. 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

gray.c - scalar variant
#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:

what the compiler vectorized
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
Read the output carefully With -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.
The 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:
one promise that changes the generated code
void gray_scalar(const uint8_t* restrict rgb, uint8_t* restrict gray, size_t pixels)
Recompile and compare the messages. It is one of the few situations where a single word changes performance by dozens of percent.

Variant 3 - explicit NEON

gray_neon.c
#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);
    }
}
Why the hand-written variant wins Three instructions here do the work of dozens of scalar instructions:
  • 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.
The compiler has no way of knowing that the data is interleaved in groups of three. You know it.

Variant 4 - NEON on all cores

gray_parallel.c
#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);
    }
}
compile and run
gcc -O3 -mcpu=cortex-a76 -fopenmp \
    main.c gray.c gray_neon.c gray_parallel.c -o comparison
./comparison

7The comparison program

main.c
#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:

VariantTimeMpixels/sSpeedup
scalar, -O218.4 ms1131.00×
scalar, -O3 (auto-vectorized)6.9 ms3012.67×
explicit NEON2.6 ms7987.08×
NEON + 4 threads0.9 ms230520.4×
Why NEON does not give exactly 8× We process 8 pixels per instruction, so we would expect 8×. We get around 7×, and the difference has a concrete explanation: memory becomes the bottleneck. A 1920x1080 image in RGB format takes up 6 MB - more than the processor's cache. At some point, the compute unit waits for the data, not the other way around. That is why the four-thread variant gives less than 4× on top of NEON: the four cores share the same bus to memory. This is a physical limit, not a code defect.

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?

frame_energy.py
#!/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()
The result that ties everything together The NEON variant draws more power - the vector units switch more transistors per cycle. But it runs for seven times less time. The energy per frame drops by roughly five times.

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.
The practical consequence for battery-powered devices A phone that decodes a movie with vector instructions does not do it because it otherwise could not keep up - it does it because otherwise the battery would last half as long. The same logic explains why video codecs, cryptography and neural networks all have hand-written implementations in vector assembly.

9The theoretical acceleration limit

We sped up the conversion twenty times. But what if it is only part of the complete program?

Amdahl's law
total speedup = 1 / ( (1 − p) + p/s )
where p is the fraction of time taken up by the accelerated part, and s is how many times we accelerated it.
How much of the program we accelerate (p)Local speedup s = 20×Total speedupLimit, 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×
The practical consequence If the part you optimized takes up half the execution time, you will never exceed 2×, no matter how well you write the code. Measure first where the time goes, then optimize. A profile obtained in five minutes can save you a week of work in the wrong direction:
where the time goes
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

MethodTypical speedupEffortWhen it is justified
A better algorithmunlimitedlargealways, first
Compilation flags1.5-3×minimalalways, right after
Hand-written SIMD4-8×mediumregular loops, over lots of data
More coresup to the core countmediumindependent tasks
GPU10-100×largelarge volumes, massively parallel computation
FPGA10-1000×very largemedium series, strict latency requirements
Dedicated circuit100-10000×enormousvery large series (codecs, cryptography)
The order is not accidental A better algorithm beats any hardware acceleration. An n·log n sort beats a vectorized quadratic sort, however well it is written. SIMD is the step taken after the algorithm is the right one - not instead of it.
What the Raspberry Pi 5 offers on top The board has a VideoCore VII graphics processor, usable through OpenGL ES or Vulkan for parallel computation, and a PCIe connector to which an AI accelerator can be attached. It has no integrated FPGA, but chapter 9 of the book covers reconfigurable architectures at length - and the principle is the same: move the repetitive computation to wherever it can be done in parallel.

10Assignments

  1. Compile the scalar variant with -O0, -O2 and -O3. Note the times and explain the difference between them.
  2. Run -fopt-info-vec-missed on the scalar variant without restrict. Copy into your report the exact reason the compiler gives, then add restrict and show how the message changes.
  3. Run the comparison program and fill in the table with your own values. Check the correctness column for every variant.
  4. 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.
  5. Measure the energy per frame for all three variants. Draw a chart with two axes: time per frame and energy per frame.
  6. 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.
  7. Modify the NEON variant to process 16 pixels per iteration instead of 8, using vld3q_u8 and the full 128-bit registers. Measure whether you gain anything further and explain the result through the memory-bandwidth limit.

11Deeper-dive challenge

The convolution filter Grayscale conversion is the most favorable case possible: each pixel is computed independently, and memory access is perfectly sequential. Now try something harder - a 3x3 smoothing filter, where each output pixel depends on its nine neighbors.

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?
Measure the speedup obtained and compare it with the one from the grayscale conversion. Explain in your report why it is smaller - the answer says everything you need to know about the practical limits of vector computation.

12Self-check questions

13Resources