Benchmarking on Real Hardware#
Numbers measured under Qemu and numbers measured on an Odroid-C4 are not the same kind of object, and a measurement harness that is correct under Qemu can be quietly, badly wrong on the board. This page is the list of things that bit us while producing the startup measurements (process spawn and Linux-guest boot, tracked vs untracked), and what to do instead.
If you are looking for the sel4bench/nanobench harness and its benchmarks.csv output, that is a
different tool — see Benchmarking.
Audit for serial I/O inside timed regions#
Attention
This is the single most important thing on this page. Under Qemu the UART is effectively
instantaneous, so a stray printf inside a timed region costs nothing and you will never notice it. On
real hardware, every line blocks on the 115200-baud UART for roughly 6–8 ms. A debug print you forgot
about does not perturb your measurement — it becomes your measurement.
Before you trust any number measured on the board, audit the timed path for prints.
Two separate instances of this bug made the tracked configuration look far slower than it actually is.
Both are fixed on the cellulos branch, but they are worth understanding because the bug class will
recur.
1. PD_CREATION_DBG — #define X 0 tested with #ifdef#
libsel4gpi/include/sel4gpi/pd_creation.h contained:
#define PD_CREATION_DBG 0
...
#ifdef PD_CREATION_DBG /* WRONG: true for *any* definition, including 0 */
#ifdef asks whether the macro is defined, not whether it is true. Defining it to 0 — the
idiomatic way to say “off” — left it on. Sixteen debug printfs were therefore always compiled in,
and they fired inside the timed region of the tracked PD-spawn path.
The effect on the numbers was not subtle:
Tracked process spawn |
Overhead vs untracked |
|
|---|---|---|
With the stray prints |
70.9 ms |
+756 % |
With them gone |
10.2 ms |
+25.1 % |
The header now uses #if PD_CREATION_DBG. To sweep for the same bug elsewhere:
grep -rn '#ifdef .*_DBG' projects/sel4-gpi/
Any hit that is paired with a #define ... 0 is always-on.
2. pd_client_dump() inside VM creation#
apps/vmm/src/osm-vmm/vmm.c called pd_client_dump() unconditionally while creating a guest. That
prints a CSV row per model node and edge — around 220 lines — from inside the timed VM-creation
phase. At 6–8 ms a line, that is well over a second of pure UART time attributed to VM creation.
It is now gated behind GPI_EXTRACT_MODEL, i.e. the GPIExtractModel CMake option. Build your
benchmark images with -DGPIExtractModel=OFF.
Wall-clock source: use CNTPCT_EL0#
Read the ARM physical counter
CNTPCT_EL0. This requiresKernelArmExportPCNTUser=ONso that user level may read it.Do not use
CNTVCT_EL0. The virtual counter traps unless it is explicitly exported; keepKernelArmExportVCNTUser=OFFand stay off it.The counter runs at approximately 1 GHz on the C4, but read
CNTFRQ_EL0rather than hardcoding it and convert ticks → milliseconds with that.
Reading CNTPCT_EL0 is what lets you sanity-check a measured interval against a stopwatch, which is how
we caught the debug-print inflation above: 70.9 ms of “PD creation” is not a plausible amount of work,
and the counter agreed with the wall clock, so the time was real — it was just being spent in the UART.
Timestamp VM boot phases on the HOST, not in the guest#
Attention
The guest’s own printk timestamps cannot see host-side stalls, and they look authoritative enough
that you will believe them.
Measuring the Linux-guest boot from inside the guest, the Run /init line appears at:
[1.044] untracked
[1.064] tracked
— a difference of about 2 %, which would suggest the tracked VMM costs essentially nothing. The true host-side gap for that phase was about 2 seconds.
The guest’s clock only advances when the guest is running. Any time the host spends — in the VMM, in the GPI server, blocked on the UART — is simply invisible to it. The guest cannot measure its own suspension.
Always derive VM boot phase timings from host-side timestamps: timestamp each line as it arrives at
the host on the serial console, and take your phase boundaries from those. That is what
campaign.py does.
Automation: scripts/odroid-c4-bench/#
The startup campaign is automated end-to-end in the OSmosis repo under scripts/odroid-c4-bench/.
campaign.py#
Runs an unattended measurement campaign: relay power-cycle → interrupt u-boot → tftpboot → boot →
capture the console with a host-side timestamp on every line, per configuration, for N iterations.
Its CONFIGS map is the canonical encoding of the measurement points:
Config |
Tests |
Done regex |
|---|---|---|
|
|
|
|
|
|
|
|
|
It expects the corresponding prebuilt images to be present in /srv/tftp as image, vm-untracked and
vm-tracked, and it uses the host/board addresses 10.42.0.1 / 10.42.0.2.
serial_shell.py#
Drives a root shell on the board’s Linux over the serial console (log in, run a command, return the output). This is how you bootstrap the board before it has any networking — see Linux and KVM on the Odroid-C4.
Note
These scripts currently hardcode RELAY = /dev/ttyUSB0 and CONSOLE = /dev/ttyUSB1. That mapping is
not stable and may well be the reverse on your host. Install the udev rule from
Identify the USB devices by chip and point the scripts at
/dev/odroid-relay and /dev/odroid-console instead.