Assembly (x86-64, AArch64, RISC-V) Module 14 – Performance and microarchitecture
Measuring cycles: counters, perf and llvm-mca
Time a loop you can trust: benchmark hygiene, why a fixed-frequency counter gives time and not cycles, the counters of three ISAs, and perf and llvm-mca.
What you will learn
- Measure a loop with a fixed-frequency counter, following benchmark hygiene
- Explain what a time counter measures and why it is not a cycle count
- Recognise a benchmark that times deleted work or the counter's step, and fix it
- Read the time and cycle counters of each ISA and say who may read them
- Use llvm-mca and perf for a static estimate and a real profile
Before you start
What you’ll learn · Before you start
What you will learn
- Measure a loop with a fixed-frequency counter, following benchmark hygiene
- Explain what a time counter measures and why it is not a cycle count
- Recognise a benchmark that times deleted work or the counter's step, and fix it
- Read the time and cycle counters of each ISA and say who may read them
- Use llvm-mca and perf for a static estimate and a real profile
Before you start
On this page
Two runs of the same loop can take the same number of core cycles and very different numbers of nanoseconds, because a core’s clock speed changes with its power state and with which core runs the work. So before you trust a measurement, answer one question: does your counter count time or cycles? The microbenchmark: A small program that times one operation or loop in isolation; useful for comparing instruction sequences but easily misled by effects a whole program would not see. below times a loop on Apple silicon and reports its best of 15 runs, the counter’s frequency and the hardware, so that its one number means something.
A fixed-frequency counter, read with the hygiene that makes the number repeat, gives you time. Cycles take one of
three routes: the cycle counter that each ISA guards behind the kernel, perf: A Linux profiling tool that reads the hardware performance counters while a program runs and reports its cycles, instructions, cache misses and branch misses., which reads
the real hardware counters on Linux, and llvm-mca, which estimates cycles from a model of one core without running
the code.
A good measurement repeats and reports the hardware
A single timing on a shared machine means little. The benchmark follows a short checklist, which the figure steps through: fixed inputs, so the run repeats; warm-up: Running a workload a few times before timing it, so caches are filled and the clock has settled and the timed runs measure the steady state rather than the first cold pass. runs, so the caches and the clock settle; many timed runs, of which it keeps the best and the median; and the hardware and the counter’s frequency printed beside the result.
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The same input every time. The array of 4,096 doubles is filled the same way on every run, so every run does the same work and two runs can be compared.
- Warm up before timing. Three untimed runs bring the array into the caches and let the clock settle, so the first, slower pass is never one of the timed ones.
- Time many runs, not one. Each of the 15 timed runs reads the fixed-frequency counter before and after the loop, which gives 15 elapsed times instead of one.
- Keep the best and the median. Other work on a shared machine can only add time, so the best run is the least disturbed one; the median shows how much the machine adds.
- Report what a stranger needs. The result is printed with the hardware, the counter frequency and a checksum, which shows that the loop really ran and that the run repeats.
It reads the fixed-frequency counter: A counter that advances at a constant frequency independent of the core clock, such as AArch64's CNTVCT_EL0; it measures elapsed time, not the number of core cycles. in counters.s around a reduction
over 4,096 doubles:
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run("""
# Build the benchmark (the counter reads are in counters.s) and run it
clang -O2 -o bench bench_main.c counters.s
./bench
""") Output
# Build the benchmark (the counter reads are in counters.s) and run it $ clang -O2 -o bench bench_main.c counters.s $ ./bench Apple M5 Pro, counter 1000 MHz sum of 4096 doubles = 8.8951038970 best 3333 ns (0.814 ns/elem), median 3500 ns, over 15 timed runs after 3 warm-ups
This output changes from run to run: the nanosecond timings change from run to run with the machine's load and the core the work lands on, while the checksum, the counter frequency and the shape of the report stay the same
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bench_session.py
The best run is the one least disturbed by other work, because noise on a shared machine only ever adds time; the median beside it shows how much the machine is adding. The checksum shows that the run repeats and that the loop really ran. A compiler may delete a call whose result nobody uses, and the timer then brackets nothing.
Key idea
Report a benchmark so a stranger could trust it: fixed inputs, warm-ups, the best and median of many timed runs, and the hardware and counter named.
Checkpoint
Check: Benchmark hygiene
The counter you read measures time, not cycles
The benchmark printed counter 1000 MHz. CNTFRQ_EL0 reports the counter’s effective frequency, so the counter’s
value counts nanoseconds, whatever the core is doing. That is what fixed-frequency means, and it is why the counter
is a good clock. It is also why you cannot read core cycles from it. A performance core and an efficiency core run at
different speeds, and each changes speed with its power state, so a fixed number of time ticks covers a changing
number of core cycles.
| Fixed-frequency counter | Cycle counter | |
|---|---|---|
| Counts | elapsed time, at a steady rate | the core’s own clock cycles |
| Changes with the core clock | no | yes |
| Readable in user mode | yes, here | usually only with kernel permission |
| Good for | wall-clock timing | cycle-exact analysis |
A frequency is the counter’s unit, not a promise of how often its value changes. A counter that changes less often than its unit suggests cannot time work shorter than one change: such work reads as zero or as one whole change.
So the benchmark honestly reports nanoseconds, not cycles. Each ISA pairs its time counter with a
cycle counter: A counter of the core's own clock cycles, such as a performance unit's cycle count; reading it from user code usually needs the operating system's permission., which user code may usually read only with the kernel’s permission.
perf is the Linux profiler that reads the processor’s
performance counter: A hardware counter of microarchitectural events such as cycles, retired instructions, cache misses and branch mispredictions, read through the operating system's profiling interface. while a program runs: cycles, cache misses, branch
mispredictions. llvm-mca reads machine code and a model of one named core and estimates the cycles without running
anything, so it gives the same answer on every machine.
Checkpoint
Check: What the counter measures
Practise
Work on copies of the benchmark’s two files:
// bench_main.c: a small benchmark that follows the hygiene checklist and reports what a reader needs to trust it.
// It times sum_doubles over a fixed 4,096-element array with the fixed-frequency counter, after warm-up runs,
// and reports the best and the median of the timed runs, the counter's frequency, and the hardware.
#include <pthread/qos.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/sysctl.h>
uint64_t read_counter(void);
uint64_t counter_freq(void);
double sum_doubles(const double *a, long n);
enum { N = 4096, WARMUP = 3, TIMED = 15 };
static double a[N];
static int by_value(const void *x, const void *y) {
uint64_t p = *(const uint64_t *)x, q = *(const uint64_t *)y;
return (p > q) - (p < q);
}
int main(void) {
pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0); // ask for a performance core
for (long i = 0; i < N; i++) a[i] = 1.0 / (double)(i + 1); // fixed inputs: the run repeats exactly
double freq = (double)counter_freq();
volatile double sink = 0;
for (int w = 0; w < WARMUP; w++) sink += sum_doubles(a, N); // warm up: caches and clocks settle
uint64_t ticks[TIMED];
for (int r = 0; r < TIMED; r++) {
uint64_t t0 = read_counter();
sink += sum_doubles(a, N);
ticks[r] = read_counter() - t0;
}
qsort(ticks, TIMED, sizeof ticks[0], by_value);
double best_ns = ticks[0] * 1e9 / freq;
double median_ns = ticks[TIMED / 2] * 1e9 / freq;
char cpu[64] = "?";
size_t len = sizeof cpu;
sysctlbyname("machdep.cpu.brand_string", cpu, &len, NULL, 0);
printf("%s, counter %.0f MHz\n", cpu, freq / 1e6);
printf("sum of %d doubles = %.10f\n", N, sum_doubles(a, N));
printf("best %.0f ns (%.3f ns/elem), median %.0f ns, over %d timed runs after %d warm-ups\n",
best_ns, best_ns / N, median_ns, TIMED, WARMUP);
(void)sink;
return 0;
} // counters.s (AArch64): read the fixed-frequency time counter from user mode, and a small workload to time.
.text
// uint64_t read_counter(void): the current value of the virtual count register.
.globl _read_counter
_read_counter:
isb // finish earlier work before sampling, so the reading is not early
mrs x0, cntvct_el0 // CNTVCT_EL0: a fixed-frequency counter, readable in user mode here
ret
// uint64_t counter_freq(void): how many counter ticks make one second.
.globl _counter_freq
_counter_freq:
mrs x0, cntfrq_el0 // CNTFRQ_EL0: the counter's frequency in Hz
ret
// double sum_doubles(const double *a, long n): the fixed workload the benchmark times.
.globl _sum_doubles
_sum_doubles:
movi d0, #0
mov x2, #0
1: cmp x2, x1
b.ge 2f
ldr d1, [x0, x2, lsl #3]
fadd d0, d0, d1
add x2, x2, #1
b 1b
2: ret - Lower the warm-up count to zero and raise the timed runs to 50, then compare the best and the median with the original. Which moves more, and why?
- Change the workload from 4,096 doubles to 400,000 and predict, before you run, whether the nanoseconds per element rise (the array no longer fits in the fastest cache).
The exercise writes the benchmark’s report step: drop the warm-ups, then return the best and the median of the timed runs.
Exercise · Easy · Python
Report the best and median of timed runs
The benchmark on the page times the reduction 18 times: 3 warm-up runs, which it ignores, then 15 timed runs, of which it reports the best and the median. Write the Python function that turns the raw times into that report:
summarise(times_ns, warmups) takes the times of every run in the order they ran, the first warmups of them being warm-ups, and returns a tuple (best, median) of the runs after the warm-ups:
bestis the smallest time, the run least disturbed by other work.medianis the middle time once the timed runs are in order; with an even number of timed runs it is the mean of the two middle ones.
Leave the caller's list as it was: the times are a record of the runs, in their order. So summarise([9000, 4100, 3000, 2400, 2250, 2375], 3) is (2250, 2375): the first three runs are warm-ups, however slow.
The sample tests include a run of 18 times like the benchmark's, and warm-ups that are faster than every timed run, which a report must still leave out.
Starter code · report.py
def summarise(times_ns, warmups):
"""Drop the first `warmups` runs, then return (best, median) of the timed runs."""
return 0, 0 # replace with your code The sample tests · test_report.py
"""Sample tests: a run like the benchmark's 3 warm-ups and 15 timed runs, and the edges of a report."""
from report import summarise
BENCHMARK = [9000, 4100, 3000, # the 3 warm-ups
2375, 2250, 2400, 2375, 2500, 2333, 2291, 2375, 2416, 2458, 2291, 2375, 2541, 2333, 2750]
def test_a_run_like_the_benchmark():
assert summarise(BENCHMARK, 3) == (2250, 2375)
def test_the_example_of_the_prompt():
assert summarise([9000, 4100, 3000, 2400, 2250, 2375], 3) == (2250, 2375)
def test_an_even_number_of_timed_runs():
assert summarise([10, 4, 3, 1, 2], 1) == (1, 2.5)
def test_warm_ups_faster_than_every_timed_run_are_still_left_out():
assert summarise([1, 7, 9, 8], 1) == (7, 8)
def test_no_warm_ups_and_one_run():
assert summarise([5], 0) == (5, 5)
def test_the_callers_list_is_left_as_it_was():
times = [30, 10, 20]
summarise(times, 0)
assert times == [30, 10, 20] A hint
Slice the warm-ups off first with times_ns[warmups:], then sort what is left with sorted, which returns a new list and leaves the caller's alone. With n timed runs in order, the middle one is at index n // 2 when n is odd; when n is even the two middle ones are at n // 2 - 1 and n // 2.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Practice that adapts to you
Practise this lesson
5 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Two benchmarks that lie, and the fix for each Pro: opens with a Pro, Premium or Ultimate pass
Two mistakes make a benchmark report a number that has nothing to do with the code: the compiler deletes the work it was meant to time, or the counter’s step is longer than the work. Both are reproduced below on Apple silicon, each beside its fix.
In this part:
- Catching a timer that brackets deleted work
- How often the counter really changes, measured
2 runnable examples (C, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Three ISAs, three ways to read a counter, and their limits Pro: opens with a Pro, Premium or Ultimate pass
Each ISA has a time counter and a cycle counter, read by its own instructions, and each draws the same line between what user code may read freely and what the kernel guards. The instructions are assembled below, each with what it counts and who may read it.
In this part:
- Three ISAs, three ways to read a counter
- Keeping the counter read where the program puts it
1 runnable example (Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
A profiler and a model: perf and llvm-mca Pro: opens with a Pro, Premium or Ultimate pass
Two tools answer the questions a fixed-frequency counter cannot. One reads the real hardware counters while the program runs; the other estimates the cycles from a model without running it at all. They complement each other, and each has a limit worth stating.
In this part:
- perf reads the hardware counters for you
- A model estimates the cycles without running
- The rules the counters, the profiler and the model leave you with
2 runnable examples (Python, Shell) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
A warm-up: you time a function with rdtsc, divide the ticks by the clock speed you looked up, and report cycles.
What is wrong with that?
The time-stamp counter is invariant on current x86-64: it ticks at a fixed reference rate, not at the core’s clock,
so dividing its ticks by a nominal clock speed does not give the core cycles the code took. The core’s clock is not
even constant: it changes with the power state and turbo, so there is no single number to divide by. What rdtsc
gives honestly is elapsed time: ticks divided by the invariant rate are seconds. For core cycles, read the core’s
cycle counter through the kernel, or use a tool such as perf.
Three deeper questions follow: a counter that disagrees with a profiler, trusting a model, and getting core cycles at all:
-
Mid to senior
A loop's wall-clock time (from a fixed-frequency counter) looks fine, but it is slower than you expected from the instruction count. What does a profiler add that the time counter cannot, and how would you use it?
-
Mid to staff
llvm-mca says your rewritten loop is 2x faster, and you want to put that figure in a pull request. When is it safe to trust, when must you measure instead, and how would you present it?
-
Senior to staff
You need core-cycle counts for a micro-optimisation, portably across x86-64, AArch64 and RISC-V, from a normal user program. What are your options and their trade-offs, and what would you actually do?
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What makes a benchmark result trustworthy?
Fixed inputs, warm-up runs, the best and the median of many timed runs, and a report that names the hardware and the counter used.
-
You time a function with rdtsc and divide the ticks by the advertised clock speed. What is wrong?
The time-stamp counter on current x86-64 ticks at a fixed reference rate, not at the core's clock, and the core clock itself changes with the power state and turbo. Ticks divided by the counter's own rate give elapsed time; core cycles need the core's cycle counter or a tool such as perf.
-
Does a counter's stated frequency tell you how often its value changes?
No. The frequency is the counter's unit, not how often its value really steps, so check the real step before trusting a short timing.
-
Why can a timed call whose result nobody uses report almost no time?
The compiler may delete a call whose result is never used, so the timer brackets nothing. Use the result, or check the compiled code, before trusting the number.
-
Where can a reported cycle count come from?
From a cycle counter that the kernel guards, from perf on Linux, or from a model such as llvm-mca. A reported number should say which of them it came from.
Key takeaways
- A trustworthy benchmark uses fixed inputs, warms up, reports the best and median of many runs, and names the hardware and the counter.
- A fixed-frequency counter, such as AArch64’s
CNTVCT_EL0, measures elapsed time at a steady rate, not core cycles, because the core clock varies with the power state and the core. - A counter’s frequency is its unit, not how often its value changes, and a call whose result nobody uses may be compiled away: check both before you trust a short timing.
- Cycles come from a cycle counter the kernel guards, from
perfon Linux, or from a model such asllvm-mca, and a reported number names which one it came from.
Terms in this lesson
- cycle counter
- A counter of the core's own clock cycles, such as a performance unit's cycle count; reading it from user code usually needs the operating system's permission.
- fixed-frequency counter
- A counter that advances at a constant frequency independent of the core clock, such as AArch64's CNTVCT_EL0; it measures elapsed time, not the number of core cycles.
- invariant TSC
- A time-stamp counter that ticks at a constant rate whatever the core's power state, so it works as a steady wall-clock timer; the processor reports its presence through a CPUID bit.
- microbenchmark
- A small program that times one operation or loop in isolation; useful for comparing instruction sequences but easily misled by effects a whole program would not see.
- perf
- A Linux profiling tool that reads the hardware performance counters while a program runs and reports its cycles, instructions, cache misses and branch misses.
- performance counter
- A hardware counter of microarchitectural events such as cycles, retired instructions, cache misses and branch mispredictions, read through the operating system's profiling interface.
- time-stamp counter
- An x86-64 counter read with RDTSC or RDTSCP that advances at a fixed reference rate rather than the core's varying clock, so it times elapsed seconds rather than counting core cycles.
- warm-up
- Running a workload a few times before timing it, so caches are filled and the clock has settled and the timed runs measure the steady state rather than the first cold pass.
References
- Intel 64 and IA-32 Architectures Software Developer Manuals, Vol. 3 (Invariant TSC) and Vol. 2 (RDTSC, RDTSCP) (Intel Corporation)
- Arm A-profile A64 Instruction Set Architecture (CNTVCT_EL0, CNTFRQ_EL0, PMCCNTR_EL0, PMUSERENR_EL0, ISB) (Arm Limited)
- RISC-V Technical Specifications: the Zicntr counters (TIME, CYCLE, INSTRET) (RISC-V International)
- Linux kernel user guide: sysctl settings (perf_user_access for user-mode cycle reads) (The Linux Kernel documentation (kernel.org))
- Linux perf: the perf-stat, perf-record, perf-report and perf-annotate commands (Linux perf wiki)
- llvm-mca: LLVM Machine Code Analyzer (LLVM Project)
- Apple developer documentation: mach_absolute_time and the system time base (Apple Inc.)
Report a problem with this lesson
Kept only in this browser. Your Learn progress