Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 7 of 20 minutes free
Free: A good measurement repeats and reports the hardware · The counter you read measures time, not cycles · Practise. With a pass: 3 in-depth parts and 3 interview answers, about 13 minutes.

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.

Interactive · stage by stage The benchmark's checklist, step by step Step through how bench_main.c turns 18 runs of the reduction into one number a stranger can trust.
The benchmark's checklist, step by step, step 5 of 5. Showing fixed input, 3 warm-up runs, 15 timed runs, best and median, report it all. In focus: report it all.fixed input4,096 doubles, every run3 warm-up runsuntimed: caches settle15 timed runscounter read around eachbest and mediannoise only adds timereport it allhardware, MHz, checksum

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

A benchmarked reduction, with warm-up, best and median Python · bench_session.py
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

  1. Question 1 of 2 The benchmark reports the best and the median of 15 timed runs after 3 warm-ups. Why report the best rather than the average?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The best run is the one least disturbed by other work on the machine, so it is closest to the code's own speed.

    Other activity on a shared machine can only slow a run down, never speed it up, so the fastest run is the cleanest measure of the code. The median is reported too, to show how much the machine is adding.

  2. Question 2 of 2 Why run the workload a few times before the timed runs?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: So caches are filled and the core's clock has settled, and the timed runs measure the steady state.

    The first run is cold: it misses in the caches and may run while the clock is still ramping up. Warming the workload first means the timed runs measure the steady state, which is what a hot loop actually sees.

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

  1. Question 1 of 2 The benchmark reads CNTVCT_EL0, whose frequency CNTFRQ_EL0 reports as 1,000 MHz. What does its value measure?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Elapsed time, in units of a nanosecond counted at a fixed rate, regardless of how fast the core is clocked.

    CNTVCT_EL0 is a fixed-frequency counter: its value counts nanoseconds at a steady rate whatever the core is doing, so it measures elapsed time. To turn that into cycles you would need to know the core's clock, which varies.

  2. Question 2 of 2 Why can you not read the number of core clock cycles your loop took from CNTVCT_EL0 alone?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It counts at a fixed frequency, while the core clock varies with the power state and the core, so the two are not the same.

    A fixed-frequency counter measures seconds, not cycles. Because the core clock changes with the power state and differs between performance and efficiency cores, a fixed number of time ticks covers a changing number of cycles, so you cannot convert one to the other without knowing the clock.

Practise

Work on copies of the benchmark’s two files:

bench_main.c: warm-ups, timed runs, best, median and report C · bench_main.c
// 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: the counter reads and the reduction it times S · counters.s
// 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
  1. 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?
  2. 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:

  • best is the smallest time, the run least disturbed by other work.
  • median is 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.

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.

  1. Question 1 of 5 A benchmark prints only the average of 100 timed runs on a shared laptop. What is the main weakness of that number?

    Choose one answer.

    Show the answer: Question 1 of 5

    Answer: Other work on the machine only ever adds time, so the average is dragged up by noise; the best run is a cleaner estimate.

    Timing noise on a shared machine is one-sided: it can only slow a run. So the average overstates the code's own speed. Reporting the best, and the median to show the spread, is more honest.

  2. Question 2 of 5 Why does the benchmark fill its array with a fixed formula and print a checksum of the result?

    Choose one answer.

    Show the answer: Question 2 of 5

    Answer: So the run is reproducible and a reader can see the work was not optimised away.

    A fixed input makes the measurement repeatable, and consuming the result (here printing a checksum) keeps the optimiser from deleting the whole loop as dead code, which would time nothing.

  3. Question 3 of 5 A microbenchmark says a function is twice as fast as another, but the whole program shows no change. What is the most likely reason?

    Choose one answer.

    Show the answer: Question 3 of 5

    Answer: In the whole program the function is not the bottleneck, or its inputs and cache behaviour differ from the microbenchmark.

    A microbenchmark measures one piece in isolation, often all in cache. If that piece is not the program's bottleneck, or sees different data and cache pressure in context, speeding it up changes nothing overall.

  4. Question 4 of 5 You want to time a loop in wall-clock nanoseconds, portably, on Apple silicon from user code. What do you read?

    Choose one answer.

    Show the answer: Question 4 of 5

    Answer: The fixed-frequency counter (CNTVCT_EL0) with its frequency from CNTFRQ_EL0, which gives elapsed time.

    For elapsed time you read the fixed-frequency counter and divide by its frequency. It is a steady time base in user mode; a cycle counter would not give time and usually needs the kernel.

  5. Question 5 of 5 The same loop timed with CNTVCT_EL0 reports a different nanoseconds-per-element on a performance core and an efficiency core. Is the counter broken?

    Choose one answer.

    Show the answer: Question 5 of 5

    Answer: No: the counter ticks at the same fixed rate on both, but the efficiency core runs the work in more time, so more ticks pass.

    The counter is a fixed time base on both cores. An efficiency core runs the loop more slowly, so the loop takes more nanoseconds there; that is a true difference in time, not a counter fault, and it is why you state the core.

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.

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.

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.

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:

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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 perf on Linux, or from a model such as llvm-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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.