Skip to content

Lab 5 — Performance: -O0 vs -O2 on Your CPU

Goal

Measure the real performance difference between unoptimised (-O0) and optimised (-O2) C code running on the RV32I CPU you built in Project 1. By the end, you will have read assembly output, measured cycle counts with the hardware cycle counter, and computed a concrete speedup.

Prerequisites

  • Project 1 submitted and running: your CPU boots C code over UART and executes it on the Tang Nano 9K.
  • Basic familiarity with reading RV32I assembly (covered in Module 2 classes).

Why this matters

The KWS kernel has ~457K MACs. If the compiler generates even one unnecessary instruction per MAC, that is 457K extra cycles — at 27 MHz, ~17 ms of wasted time. Compiler optimisation is not magic; this lab shows you exactly what it does and does not do.

Step 1 — The benchmark function

Activity 1

Create bench.c with a simple loop that the compiler can optimise in obvious ways:

#include <stdint.h>

// Sum of squares: compute sum of i*i for i = 0..N-1
uint32_t sum_of_squares(uint32_t n) {
    uint32_t acc = 0;
    for (uint32_t i = 0; i < n; i++) {
        acc += i * i;
    }
    return acc;
}

This function has a multiply (which uses the Zmmul extension you added in Project 2), an add, a compare, and a branch — one of each main operation type per iteration.

Step 2 — Measure cycles with rdcycle

Activity 2

Add a timing harness in main.c. Your CPU's Zicntr extension exposes the cycle counter via the mcycle CSR:

#include <stdint.h>

static inline uint64_t read_cycle(void) {
    uint32_t lo, hi;
    asm volatile (
        "rdcycleh %0\n"
        "rdcycle  %1\n"
        : "=r"(hi), "=r"(lo)
    );
    return ((uint64_t)hi << 32) | lo;
}

extern uint32_t sum_of_squares(uint32_t n);

void main(void) {
    const uint32_t N = 1000;

    uint64_t t0 = read_cycle();
    volatile uint32_t result = sum_of_squares(N);
    uint64_t t1 = read_cycle();

    uint32_t cycles = (uint32_t)(t1 - t0);
    // Print cycles and result via UART (use your Project 2 print_uint32 or similar)
    uart_print("cycles: "); uart_print_uint(cycles); uart_print("\n");
    uart_print("result: "); uart_print_uint(result); uart_print("\n");
}

volatile on result prevents the compiler from eliminating the call entirely when it realises the result is unused.

rdcycleh then rdcycle ordering

Reading the high word first, then the low word, can give a wrong result if the low word wraps around between the two reads. For cycle counts under ~4 billion (150 seconds at 27 MHz), the low word alone is sufficient — just use rdcycle. The 64-bit version is shown for completeness.

Step 3 — Compile at -O0 and inspect

Activity 3

Compile bench.c twice and disassemble:

1
2
3
4
5
# -O0: no optimisation
riscv-none-elf-gcc -march=rv32im -mabi=ilp32 -O0 -S -o bench_O0.s bench.c

# -O2: full optimisation
riscv-none-elf-gcc -march=rv32im -mabi=ilp32 -O2 -S -o bench_O2.s bench.c

Compare the two .s files. Count the instructions inside the loop body.

Hint — what to look for

In the -O0 version, look for: - Loads and stores to the stack frame for every variable access (the compiler does not keep i or acc in registers across iterations) - A mul instruction that reads from memory, not from a register

In the -O2 version, look for: - i and acc kept in registers throughout the loop - Possibly a strength-reduction: i*i computed as prev + 2*i - 1 (incrementally) instead of a full multiply each iteration - Fewer total instructions in the loop body

Activity 4

Fill in this table from your disassembly:

-O0 -O2
Instructions in loop body ? ?
Stack loads per iteration ? ?
Stack stores per iteration ? ?
Multiply instruction present yes/no yes/no

Step 4 — Measure on the board

Activity 5

Compile and link the full program (with your Project 2 UART driver) at both optimisation levels and run on the board:

1
2
3
4
5
6
7
# -O0 build
riscv-none-elf-gcc -march=rv32im -mabi=ilp32 -O0 \
    -T link.ld crt0.S main.c bench.c uart.c -o program_O0.elf

# -O2 build  
riscv-none-elf-gcc -march=rv32im -mabi=ilp32 -O2 \
    -T link.ld crt0.S main.c bench.c uart.c -o program_O2.elf

Load each, record the cycle count printed via UART, and compute the speedup:

$$\text{speedup} = \frac{\text{cycles at -O0}}{\text{cycles at -O2}}$$

Hint — typical results

For sum_of_squares(1000), expect roughly 8,000–12,000 cycles at -O0 and 3,000–5,000 cycles at -O2 — a speedup of 2–3×. The exact numbers depend on your CPU's microarchitecture (how many cycles each instruction takes in your multicycle design).

Step 5 — Connect to KWS

Activity 6

The KWS kernel has ~457K MACs. If the compiler saves even 2 instructions per MAC iteration:

  • Savings: 457,000 × 2 = 914,000 cycles
  • At 27 MHz: ~34 ms saved per inference

Using your measured per-iteration savings from Activities 3–5, estimate how many cycles the -O2 compiler saves for the full KWS kernel.

This is why Lab 6 (KWS kernel preview) asks you to compile the kernel at -O2 — and why Project 3 asks for a roofline analysis on top of compiler optimisation, not instead of it.

Checklist

  • bench.c compiled at both -O0 and -O2; assembly compared and table filled in.
  • Cycle counts measured on the board at both optimisation levels.
  • Speedup computed.
  • KWS cycle savings estimated.

Summary

You measured a real, hardware-confirmed speedup from compiler optimisation on your own CPU. The key insight: -O2 eliminates memory traffic by keeping loop variables in registers. For a kernel with 457K iterations, this compounds into tens of milliseconds of saved time — and that is before any hardware acceleration. In Module 5 you will see that the roofline model sets a theoretical ceiling on what software optimisation alone can achieve.