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

Pipelines, latency and dependency chains

Why dependent adds wait for each other, how independent work fills the pipeline, and how several accumulators speed up a sum, measured on Apple silicon.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Explain latency, throughput and instruction-level parallelism
  • Measure the cost of a dependency chain on Apple silicon
  • Rewrite a reduction loop to use several accumulators
  • Read a scheduling model's latency and throughput for one loop

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain latency, throughput and instruction-level parallelism
  • Measure the cost of a dependency chain on Apple silicon
  • Rewrite a reduction loop to use several accumulators
  • Read a scheduling model's latency and throughput for one loop

Before you start

On this page

The chain1 and chain16 loops on this page run the same 98,304 floating-point additions, yet on an Apple M5 Pro chain16 finishes about eight and a half times faster. Nothing was removed: chain1 adds into one register, so each addition waits for the one before, while chain16 adds into sixteen, so the core runs several at once.

A modern core is a pipeline: The division of instruction handling into stages so that a core works on several instructions at once, each in a different stage, finishing about one a cycle when nothing stalls. with more than one adder, and it reorders work to keep them busy. When each instruction needs the previous result, that machinery sits idle and the code runs one addition at a time. When the work is independent, the same core finishes several additions a cycle. Here you measure the gap, name the two numbers behind it, latency: The time from when an instruction starts to when its result is ready for another instruction to use, usually counted in clock cycles. and throughput: How many instructions of a kind a core can finish each clock cycle when their inputs are ready, set by how many execution units it has for them., and break a chain with several accumulator: A register a loop adds into each pass to build a running total; several independent accumulators let a core add in parallel instead of in one chain..

A dependency chain runs at the speed of one step

chains.s writes eight loops that each do 48 additions of 1.0 a pass, spread over a different number of accumulators: chain1 uses one register, chain2 two, up to chain16. An assembler macro writes the bodies, so the only thing that changes between them is how many independent running totals there are:

chains.s: the same 48 additions a pass, over 1 to 16 accumulators S · chains.s
// chains.s (AArch64, macOS): the same 48 floating-point additions a pass, spread over 1 to 16 accumulators.
// double chainK(long passes) adds 1.0 to its K accumulators 48 times a pass and returns their total.
        .text

// CHAIN name, rounds, registers: a function whose loop runs `rounds` rounds of one fadd into each listed register.
// CHAIN _chain3, 16, 16, 17, 18 makes a loop of 16 x 3 = 48 fadds: d16, d17, d18, d16, d17, d18, ...
        .macro  CHAIN name, rounds, regs:vararg
        .p2align 4
        .globl  \name
\name:
        fmov    d1, #1.0                // the value every fadd adds
        .irp    r, \regs
        movi    d\r, #0                 // each accumulator starts at 0.0
        .endr
1:
        .rept   \rounds
        .irp    r, \regs
        fadd    d\r, d\r, d1            // reads the accumulator the previous round wrote
        .endr
        .endr
        subs    x0, x0, #1
        b.ne    1b
        movi    d0, #0                  // after the loop: add the accumulators together, once
        .irp    r, \regs
        fadd    d0, d0, d\r
        .endr
        ret
        .endm

        CHAIN   _chain1, 48, 16
        CHAIN   _chain2, 24, 16, 17
        CHAIN   _chain3, 16, 16, 17, 18
        CHAIN   _chain4, 12, 16, 17, 18, 19
        CHAIN   _chain6, 8, 16, 17, 18, 19, 20, 21
        CHAIN   _chain8, 6, 16, 17, 18, 19, 20, 21, 22, 23
        CHAIN   _chain12, 4, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27
        CHAIN   _chain16, 3, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31

// long adds(long passes), the ruler: 48 integer adds a pass, each waiting for the one before.
        .p2align 4
        .globl  _adds
_adds:
        mov     x1, #0
1:
        .rept   48
        add     x1, x1, #1
        .endr
        subs    x0, x0, #1
        b.ne    1b
        mov     x0, x1
        ret

chain1 is a dependency chain: A run of instructions each of which uses the result of the one before, so they cannot overlap and the run takes the sum of their latencies.: every fadd d16, d16, d1 reads d16 and writes d16, so the next one cannot begin until this one’s result is ready. The core has more than one floating-point adder, but chain1 never gives it a second, independent addition to start, so the adders stand idle between steps. Step through one pass of chain1 beside one pass of chain4 and watch which additions can run together:

Interactive · step by step One chain waits; four chains overlap Step through one pass of chain1 and one pass of chain4, and watch which additions can run together.
One chain waits; four chains overlap, step 6 of 6. Showing chain1, chain4, d16 += 1.0, d16 += 1.0, d16 += 1.0, d16 += 1.0, d16 += 1.0, d17 += 1.0, d18 += 1.0, d19 += 1.0. In focus: d16 += 1.0, d17 += 1.0, d18 += 1.0, d19 += 1.0.needs d16needs d16needs d16chain1chain4d16 += 1.0d16 += 1.0d16 += 1.0d16 += 1.0d16 += 1.0d17 += 1.0d18 += 1.0d19 += 1.0

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

Every step as text
  1. Two ways to add four times. Both columns add 1.0 four times. chain1 adds into one register, d16; chain4 adds into four, d16 to d19. The same four additions, grouped two ways.
  2. chain1: each add needs the last. Every fadd in chain1 reads d16 and writes d16, so the second cannot start until the first has written its result: the arrows are true dependencies.
  3. Only one can run at a time. The core may begin the first add. The other three wait on d16, so the adder does one and then stands idle until that result is ready.
  4. One latency per step. When the first result is ready the second starts, then the third, then the fourth. Four adds cost about four latencies, around eight cycles here.
  5. In chain4 nothing waits. chain4 writes d16, d17, d18 and d19. No add reads a register another is writing, so none depends on another: there are no arrows to draw.
  6. Four at once. The core starts all four together and keeps them in flight. The four adds finish in about one latency, so chain4 does the same work far faster.

The driver times every loop, fastest call of 1,001, and prints a ruler: it also times a chain of dependent integer adds, one per cycle, to turn nanoseconds into cycles and find the clock. It checks that each loop added correctly before trusting its timing:

The chain loops timed on Apple silicon, with the integer-add ruler Python · chains_session.py
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session

session.run("""
# The processor and the system the timings come from
/usr/sbin/sysctl -n machdep.cpu.brand_string
sw_vers -productName
# Assemble the loops, compile the timer, and look at the start of chain3's loop as the assembler wrote it
clang -O2 -o chains chains_main.c chains.s
llvm-objdump -d --no-show-raw-insn --no-leading-addr --disassemble-symbols=_chain3 chains | sed -n '6,14p' | sed 's/^[[:space:]]*//'
./chains
""")

Output

# The processor and the system the timings come from
$ /usr/sbin/sysctl -n machdep.cpu.brand_string
Apple M5 Pro
$ sw_vers -productName
macOS
# Assemble the loops, compile the timer, and look at the start of chain3's loop as the assembler wrote it
$ clang -O2 -o chains chains_main.c chains.s
$ llvm-objdump -d --no-show-raw-insn --no-leading-addr --disassemble-symbols=_chain3 chains | sed -n '6,14p' | sed 's/^[[:space:]]*//'
<_chain3>:
fmov	d1, #1.00000000
movi	d16, #0000000000000000
movi	d17, #0000000000000000
movi	d18, #0000000000000000
fadd	d16, d16, d1
fadd	d17, d17, d1
fadd	d18, d18, d1
fadd	d16, d16, d1
$ ./chains
98304 additions a call, fastest of 1001 calls
ruler: one dependent add took 0.2306 ns, one cycle at 4.34 GHz
chains  ns/fadd  cycles/fadd  cycles a step  speed-up
     1   0.4849         2.10           2.10     1.00x
     2   0.2424         1.05           2.10     2.00x
     3   0.1734         0.75           2.26     2.80x
     4   0.1305         0.57           2.26     3.71x
     6   0.1043         0.45           2.71     4.65x
     8   0.0860         0.37           2.99     5.64x
    12   0.0627         0.27           3.26     7.73x
    16   0.0572         0.25           3.97     8.47x
every call added correctly: yes

This output changes from run to run: the nanosecond timings, the clock reading and the cycle columns change from run to run with the machine's load and power state, while the listing, the rows of the table and the check that every call added correctly stay the same

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 chains_session.py

In the run above chain1 takes about 2.1 cycles a step: with nothing to overlap, each step costs the time for one fadd to produce its result. That is why a dependency chain is the slowest way to do the work. The core’s extra adders are there; the code just never asks them to run anything.

Key idea

A dependency chain runs at one operation per operation latency, however many spare execution units the core has, because each step must wait for the result of the step before.

Checkpoint

Check: A dependency chain

  1. Question 1 of 2 In chain1 every fadd reads the accumulator the previous fadd wrote. Why does that make the loop slow?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Each fadd must wait for the previous result, so only one addition is ever in progress.

    chain1 is one dependency chain: fadd n cannot start until fadd n − 1 has produced its result. The core has spare adders, but nothing independent to give them, so the loop runs at one addition per latency.

  2. Question 2 of 2 The run shows chain1 at about 2.1 cycles a step and the ruler shows one dependent integer add at about 1 cycle. What is 2.1 cycles here?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The latency of one fadd: the cycles from its start until its result can feed the next fadd.

    One chain lets nothing overlap, so each step takes exactly the latency of one fadd. The ruler turns nanoseconds into cycles by timing a dependent integer add, one per cycle, to find the clock.

Independent work runs at the same time

Read down the speed-up column of the same run. chain2 is twice as fast as chain1, chain3 nearly three times and chain4 nearly four, while the cycles a step column stays a little over two: each chain still waits for its own last result, and the others run beside it. The additions are the same; only their independence changed. An out-of-order execution: A core starting any instruction whose inputs are ready rather than in strict program order, so independent work proceeds while a slow result is awaited. core looks past program order for instructions whose inputs are ready and starts them on any idle adder. chain4 writes four different registers each pass, so four additions have their inputs ready at once and the core keeps four in flight. That overlap is instruction-level parallelism: Running several independent instructions at the same time within one core, so work that does not depend on earlier results need not wait for it..

Loop Independent chains Speed-up Why
chain1 1 1.0× one chain, nothing overlaps
chain2 2 2.0× two additions in flight
chain4 4 3.7× four in flight
chain8 8 5.6× the adders begin to fill
chain16 16 8.5× every adder kept busy

Past four chains the gain slows, because the core can start only so many additions a cycle. The cycles a step column shows it: from six chains on it climbs above the latency, to 4.0 at sixteen, because each chain now waits for a free adder as well as for its own result. chain16 does a fadd every 0.25 cycles: the loop has gone from waiting on latency to waiting on adders.

Checkpoint

Check: Instruction-level parallelism

  1. Question 1 of 2 chain3 does the same 48 additions a pass as chain1 but finishes nearly three times faster. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Its three accumulators are independent, so three additions can be in progress at once.

    With three independent chains the core always has work whose inputs are ready, so it keeps three additions in flight. That is instruction-level parallelism.

  2. Question 2 of 2 What does an out-of-order core need in order to run two instructions at the same time?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The two instructions must not depend on each other's results, and an idle execution unit for each.

    Two instructions overlap when neither needs the other's result and a unit is idle for each. Separate accumulators give the core exactly that.

Latency and throughput are different numbers

The run measured two different things. One chain measured latency: the cycles from an addition’s start until its result can feed the next one, about 2.1 cycles for fadd here. Sixteen chains measured throughput: how many additions the core finishes each cycle when their inputs are ready, about four. A single fadd in isolation and a loop full of independent fadds run at speeds that differ by more than eight times, and both are correct answers to “how fast is fadd”.

One chain Sixteen chains
cycles a fadd about 2.1 about 0.25
fadds a cycle about 0.5 about 4
what it measures latency throughput

The ceiling on the speed-up follows from the two numbers: one chain does one fadd every 2.1 cycles and the core can do four a cycle, so the most independence can buy is 2.1 × 4, about eight and a half, which is what sixteen chains reached. The same arithmetic says about eight additions in flight should fill four adders, yet chain8 reached only 5.6×: a real core does not overlap independent work perfectly, so the measured table, not the arithmetic alone, says how many accumulators pay.

Key idea

Latency is the wait for one result; throughput is how many results a cycle. A dependency chain pays the latency; independent work approaches the throughput, and latency times throughput is the most parallelism can win.

Checkpoint

Check: Latency and throughput

  1. Question 1 of 2 Which statements about latency and throughput are true, as the lesson uses the words?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • Latency is the cycles from an instruction's start until its result is ready.
    • Throughput is how many such instructions can finish each cycle when inputs are ready.
    • A single dependency chain runs at the latency, not the throughput.

    Latency is the wait for one result; throughput is how many can be produced a cycle. A chain pays the latency; independent work approaches the throughput.

  2. Question 2 of 2 On this core one fadd has a latency of about 2.1 cycles and the throughput is about 4 fadds a cycle. About what is the most speed-up you can expect from adding independent accumulators?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: About 8.4 times, the latency times the throughput.

    One chain runs at one fadd per latency, 1 / 2.1 a cycle. The ceiling is the throughput, 4 a cycle. The product of latency and throughput, about 8.4, is the most independent chains can buy, and sixteen chains reach it.

Several accumulators break the chain

Most real chains are not written by hand; they hide in a reduction, a loop that folds an array into one value. sum.c is the plain shape, one running total carried from element to element:

sum.c: a reduction with one accumulator C · sum.c
// sum.c: the loop every reduction starts as. One accumulator, s, carries the whole sum from element to element.
double sum(const double *a, long n) {
    double s = 0.0;
    for (long i = 0; i < n; i++)
        s += a[i];
    return s;
}

That single s is a dependency chain exactly like chain1. Splitting it into several independent totals and adding them once at the end breaks the chain, and it is the same transformation the compiler can do for you. But it may not always: regrouping a sum is reassociation: Regrouping the operations of a sum or product, such as (a+b)+(c+d) for a+b+c+d; safe for integers but able to change the rounding of floating-point values., and reassociating floating-point numbers can change the rounding. The session compiles sum.c three ways and then runs the two double builds on the same 65,536 values:

sum.c at -O2, with and without -ffast-math, and the integer loop Python · reassoc_session.py
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session

session.run("""
# The loop of sum.c at -O2: Clang may not reorder floating-point additions, so one chain carries the sum
clang -O2 -S -o strict.s sum.c
awk '/Inner Loop Header/ { f = 1; next } f { print } f && /b[.]ne/ { exit }' strict.s
# -ffast-math allows reassociation: the loop gets accumulators
clang -O2 -ffast-math -S -o fast.s sum.c
awk '/Inner Loop Header/ { f = 1; next } f { print } f && /b[.]ne/ { exit }' fast.s
# Integer addition can be regrouped freely, so the same loop over long gets accumulators at plain -O2
clang -O2 -S -o isum.s isum.c
awk '/Inner Loop Header/ { f = 1; next } f { print } f && /b[.]ne/ { exit }' isum.s
# Both double builds of sum.c, linked with the same sum_main.c and run on Apple silicon on the same numbers
clang -O2 -c -o sum_strict.o sum.c
clang -O2 -ffast-math -c -o sum_fast.o sum.c
clang -O2 -o strict sum_main.c sum_strict.o
clang -O2 -o fast sum_main.c sum_fast.o
./strict
./fast
""")

Output

# The loop of sum.c at -O2: Clang may not reorder floating-point additions, so one chain carries the sum
$ clang -O2 -S -o strict.s sum.c
$ awk '/Inner Loop Header/ { f = 1; next } f { print } f && /b[.]ne/ { exit }' strict.s
	ldp	q1, q2, [x9, #-32]
	mov	d3, v1[1]
	mov	d4, v2[1]
	ldp	q5, q6, [x9], #64
	mov	d7, v5[1]
	mov	d16, v6[1]
	fadd	d0, d0, d1
	fadd	d0, d0, d3
	fadd	d0, d0, d2
	fadd	d0, d0, d4
	fadd	d0, d0, d5
	fadd	d0, d0, d7
	fadd	d0, d0, d6
	fadd	d0, d0, d16
	subs	x10, x10, #8
	b.ne	LBB0_5
# -ffast-math allows reassociation: the loop gets accumulators
$ clang -O2 -ffast-math -S -o fast.s sum.c
$ awk '/Inner Loop Header/ { f = 1; next } f { print } f && /b[.]ne/ { exit }' fast.s
	ldp	q4, q5, [x9, #-32]
	ldp	q6, q7, [x9], #64
	fadd.2d	v0, v4, v0
	fadd.2d	v1, v5, v1
	fadd.2d	v2, v6, v2
	fadd.2d	v3, v7, v3
	subs	x10, x10, #8
	b.ne	LBB0_5
# Integer addition can be regrouped freely, so the same loop over long gets accumulators at plain -O2
$ clang -O2 -S -o isum.s isum.c
$ awk '/Inner Loop Header/ { f = 1; next } f { print } f && /b[.]ne/ { exit }' isum.s
	ldp	q4, q5, [x8, #-32]
	ldp	q6, q7, [x8], #64
	add.2d	v0, v4, v0
	add.2d	v1, v5, v1
	add.2d	v2, v6, v2
	add.2d	v3, v7, v3
	subs	x10, x10, #8
	b.ne	LBB0_5
# Both double builds of sum.c, linked with the same sum_main.c and run on Apple silicon on the same numbers
$ clang -O2 -c -o sum_strict.o sum.c
$ clang -O2 -ffast-math -c -o sum_fast.o sum.c
$ clang -O2 -o strict sum_main.c sum_strict.o
$ clang -O2 -o fast sum_main.c sum_fast.o
$ ./strict
total 11.667578183235785, fastest call 0.498 ns a value
$ ./fast
total 11.667578183235779, fastest call 0.072 ns a value

This output changes from run to run: the per-value timings change from run to run with the machine's load and power state, while the two totals, the listings and the order of the lines stay the same

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 reassoc_session.py

At plain -O2 the double loop keeps one chain: Clang may not change the order of floating-point additions, because (a + b) + c and a + (b + c) can round differently, and its 0.50 ns a value is close to the 0.48 ns a fadd of chain1. With -ffast-math it is allowed to reorder, and the loop gains four vector accumulators (fadd.2d v0, v4, v0 and three more, each adding two doubles at once), so eight running totals overlap and the loop runs about seven times faster, for the same reason chain8 beat chain1. The integer version gets accumulators at plain -O2 with no flag, because two’s-complement addition is associative: regrouping it never changes the result.

The price is in the last digit. The strict build printed 11.667578183235785 and the fast build 11.667578183235779: the same 65,536 numbers added in a different order, rounded a little differently. Neither is wrong.

Exact-matching a reassociated sum

A test that checks a floating-point total against one saved value will fail the moment -ffast-math, a new compiler or a wider vector regroups the sum. Decide the accuracy the result needs and test within a tolerance; the exact bit pattern is not stable across builds.

Checkpoint

Check: Several accumulators

  1. Question 1 of 2 At plain -O2 Clang gives the integer sum loop several accumulators but leaves the floating-point sum as one chain. Why the difference?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Floating-point addition is not associative, so regrouping may change the result; integer addition is, so it is safe.

    Regrouping a floating-point sum can change its rounding, so the compiler keeps the single chain unless -ffast-math permits the change. Integer addition is associative, so the same regrouping is always safe.

  2. Question 2 of 2 The two double builds printed totals that differ in the last digit, 11.667578183235785 against 11.667578183235779. What does that show?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Adding the same numbers in a different order can round differently, so the fast build's answer is not identical.

    The strict build adds left to right; the reassociated build adds four interleaved streams and combines them. Same numbers, different grouping, so the rounding and the last digit differ.

A model estimates the cost without running

The same reasoning holds on x86-64 and RISC-V, but timing their code takes their processors, and Apple silicon runs neither natively. llvm-mca fills the gap: it reads a block of machine code and a scheduling model: A compiler's table of each instruction's latency and how many can issue per cycle on one named processor, used to estimate how fast a block of code runs. of one named core, then reports the cycles, deterministically and without running anything. The session analyses the one-, three- and six-chain loops on AMD’s Zen 4 model and on SiFive’s P670 model, 24 additions a pass:

The chain loops in llvm-mca's models of Zen 4 and the SiFive P670 Python · mca_session.py
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session

session.run("""
# llvm-mca runs each loop 100 times through LLVM's scheduling model of one processor and counts the cycles
llvm-mca --version | head -2
# x86-64: the model of AMD's Zen 4 cores (znver4); the instruction table shows addsd's latency and cost
llvm-mca -mtriple=x86_64-linux-gnu -mcpu=znver4 -iterations=100 chains_x86.s | grep -E '^\\[[0-9]\\] Code Region|^Total Cycles|^Block RThroughput'
llvm-mca -mtriple=x86_64-linux-gnu -mcpu=znver4 -iterations=100 chains_x86.s | grep -m1 -E '^ 1 .*addsd'
# RISC-V: the model of SiFive's P670 core
llvm-mca -mtriple=riscv64-linux-gnu -mcpu=sifive-p670 -iterations=100 chains_riscv.s | grep -E '^\\[[0-9]\\] Code Region|^Total Cycles|^Block RThroughput'
llvm-mca -mtriple=riscv64-linux-gnu -mcpu=sifive-p670 -iterations=100 chains_riscv.s | grep -m1 -E '^ 1 .*fadd[.]d'
""")

Output

# llvm-mca runs each loop 100 times through LLVM's scheduling model of one processor and counts the cycles
$ llvm-mca --version | head -2
Homebrew LLVM version 23.1.2
  Optimized build.
# x86-64: the model of AMD's Zen 4 cores (znver4); the instruction table shows addsd's latency and cost
$ llvm-mca -mtriple=x86_64-linux-gnu -mcpu=znver4 -iterations=100 chains_x86.s | grep -E '^\[[0-9]\] Code Region|^Total Cycles|^Block RThroughput'
[0] Code Region - one-chain
Total Cycles:      7203
Block RThroughput: 12.0
[1] Code Region - three-chains
Total Cycles:      2404
Block RThroughput: 12.0
[2] Code Region - six-chains
Total Cycles:      1205
Block RThroughput: 12.0
$ llvm-mca -mtriple=x86_64-linux-gnu -mcpu=znver4 -iterations=100 chains_x86.s | grep -m1 -E '^ 1 .*addsd'
 1      3     0.50                        addsd	xmm0, xmm15
# RISC-V: the model of SiFive's P670 core
$ llvm-mca -mtriple=riscv64-linux-gnu -mcpu=sifive-p670 -iterations=100 chains_riscv.s | grep -E '^\[[0-9]\] Code Region|^Total Cycles|^Block RThroughput'
[0] Code Region - one-chain
Total Cycles:      4803
Block RThroughput: 12.0
[1] Code Region - three-chains
Total Cycles:      1604
Block RThroughput: 12.0
[2] Code Region - six-chains
Total Cycles:      1204
Block RThroughput: 12.0
$ llvm-mca -mtriple=riscv64-linux-gnu -mcpu=sifive-p670 -iterations=100 chains_riscv.s | grep -m1 -E '^ 1 .*fadd[.]d'
 1      2     0.50                        fadd.d	ft0, ft0, ft11

This output changes from run to run: llvm-mca is deterministic, so the cycle counts do not change between runs; the pattern allows only the fixed output the tool prints

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 mca_session.py

Read the instruction table first. On Zen 4 addsd has a latency of 3 cycles and a reciprocal throughput, the RThroughput column, of 0.50 cycles, meaning two can start each cycle. The one-chain region takes 7,203 cycles for 100 passes, about 3 cycles an addsd: the latency, because one chain cannot overlap. Three chains take 2,404, about 1 cycle each, and six chains 1,205, about 0.50, the throughput ceiling that Block RThroughput: 12.0 predicted for the 24-instruction block. The SiFive P670 model gives fadd.d a latency of 2, so its one-chain region is 4,803 cycles and its six-chain region reaches the same 0.50. Different cores, the same story: a chain runs at the latency, and enough independent chains reach the throughput.

A model is not a measurement. llvm-mca’s own documentation notes that it models the core’s scheduler, not the branch predictor or the memory system, and treats every load as a cache hit. A loop whose data does not fit in cache runs at the speed memory can deliver it, far below the model’s estimate, so measure on your own data before you count on a number.

Checkpoint

Check: A scheduling model

  1. Question 1 of 2 Why can llvm-mca give cycle counts for x86-64 and RISC-V loops on Apple silicon, which runs neither natively?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: llvm-mca models a named core deterministically and needs no machine of that kind to run on.

    llvm-mca reads the instructions and a core's scheduling model and reports cycles, deterministically and without running the code, which is why it can show figures for cores other than the one it runs on.

  2. Question 2 of 2 In llvm-mca's Zen 4 model, addsd has a latency of 3 cycles. A loop of 24 addsd instructions all in one chain runs how many cycles a pass in that model?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 72 cycles

    One chain runs at the latency, so 24 × 3 = 72 cycles a pass, which is why the model reports 7,203 cycles for 100 passes of the one-chain region.

Practise

Work on copies of chains.s and sum.c, above, and of the two drivers that time them; the text diff tool lines up two listings side by side.

chains_main.c: times every loop against the integer-add ruler C · chains_main.c
// chains_main.c: times chain1 ... chain16 on the same work, with the integer add chain as a ruler.
// Every call does 2,048 passes of 48 additions. Each loop is called 1,001 times, all of them taking turns,
// and the fastest call counts: a slower call measured something else running on the machine too.
#include <pthread/qos.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

double chain1(long), chain2(long), chain3(long), chain4(long), chain6(long), chain8(long), chain12(long), chain16(long);
long adds(long);

enum { CALLS = 1001, PASSES = 2048, PER_PASS = 48, LOOPS = 8 };

static uint64_t now(void) { return clock_gettime_nsec_np(CLOCK_UPTIME_RAW); }

static int by_value(const void *a, const void *b) {
    uint64_t x = *(const uint64_t *)a, y = *(const uint64_t *)b;
    return (x > y) - (x < y);
}

int main(void) {
    // The highest quality-of-service class asks macOS for a performance core.
    pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0);
    double (*loop[LOOPS])(long) = {chain1, chain2, chain3, chain4, chain6, chain8, chain12, chain16};
    const int chains[LOOPS] = {1, 2, 3, 4, 6, 8, 12, 16};
    static uint64_t t[LOOPS + 1][CALLS];       // the last row times the ruler
    const double work = (double)PASSES * PER_PASS;
    int right = 1;
    for (int c = 0; c < CALLS; c++) {
        uint64_t t0 = now();
        long sum = adds(PASSES);
        t[LOOPS][c] = now() - t0;
        if (sum != (long)work) right = 0;
        for (int i = 0; i < LOOPS; i++) {
            t0 = now();
            double total = loop[i](PASSES);
            t[i][c] = now() - t0;
            if (total != work) right = 0;
        }
    }
    for (int i = 0; i <= LOOPS; i++) qsort(t[i], CALLS, sizeof t[i][0], by_value);
    double cycle = t[LOOPS][0] / work;         // ns for one dependent integer add: one cycle
    printf("%.0f additions a call, fastest of %d calls\n", work, CALLS);
    printf("ruler: one dependent add took %.4f ns, one cycle at %.2f GHz\n", cycle, 1 / cycle);
    printf("chains  ns/fadd  cycles/fadd  cycles a step  speed-up\n");
    for (int i = 0; i < LOOPS; i++) {
        double ns = t[i][0] / work, cycles = ns / cycle;
        printf("%6d  %7.4f  %11.2f  %13.2f  %7.2fx\n", chains[i], ns, cycles, cycles * chains[i],
               (double)t[0][0] / t[i][0]);
    }
    printf("every call added correctly: %s\n", right ? "yes" : "no");
    return right ? 0 : 1;
}
sum_main.c: times one build of sum() on 65,536 values C · sum_main.c
// sum_main.c: sums the same 65,536 numbers, 1/1 + 1/2 + ... + 1/65536, with whichever build of sum() it is linked
// with, and prints the total to 17 significant digits and the fastest of 501 calls.
#include <pthread/qos.h>
#include <stdint.h>
#include <stdio.h>
#include <time.h>

double sum(const double *a, long n);

enum { N = 65536, CALLS = 501 };
static double a[N];

int main(void) {
    pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0);
    for (long k = 0; k < N; k++)
        a[k] = 1.0 / (double)(k + 1);
    double total = 0.0;
    uint64_t best = UINT64_MAX;
    for (int c = 0; c < CALLS; c++) {
        uint64_t t0 = clock_gettime_nsec_np(CLOCK_UPTIME_RAW);
        total = sum(a, N);
        uint64_t t = clock_gettime_nsec_np(CLOCK_UPTIME_RAW) - t0;
        if (t < best) best = t;
    }
    printf("total %.17g, fastest call %.3f ns a value\n", total, (double)best / N);
    return 0;
}
  1. In chains.s, make the ruler _adds add into two registers in turn, x1 and x2, and combine them at the end with add x0, x1, x2. Predict before you run what happens to the one cycle at line and to every cycles column, then explain why a ruler must be a single dependency chain.
  2. Build sum.c at -O2 and at -O2 -ffast-math and run both with the commands of the reassociation session. Note the two totals and the two times, then raise the array to 4,000,000 values and see whether the gap between the builds changes.
  3. In mca_session.py, change -mcpu=znver4 to -mcpu=skylake and compare addsd’s latency and the one-chain cycles with Zen 4’s.

The exercise turns the two limits of this page into a model: given the latency, the number of adders and the number of accumulators, predict the cycles a pass takes, and check the model against the Zen 4 and P670 numbers recorded above.

Exercise · Medium · Python

Predict a reduction's cycles from latency and units

A reduction pass does adds floating-point additions spread evenly over chains accumulators. Each accumulator's additions wait for one another, and each takes latency cycles; the core has units adders, and each adder starts one addition a cycle. Write two Python functions that predict what the scheduling models on the page report:

  1. cycles_per_pass(adds, latency, units, chains): the cycles one pass takes. Two limits apply, and the slower one wins: each accumulator's own chain of additions, one after another, and the adders, which can start only units additions a cycle in all.
  2. chains_needed(latency, units): the fewest accumulators that keep every adder busy, so that adding one more accumulator no longer makes the pass faster.

The page's Zen 4 model has an addsd latency of 3 and two adders, and reported 7,203 cycles for 100 passes of 24 additions on one chain, 2,404 on three and 1,205 on six. So cycles_per_pass(24, 3, 2, 1) is 72, cycles_per_pass(24, 3, 2, 3) is 24 and cycles_per_pass(24, 3, 2, 6) is 12: the model's totals are 100 times these, plus a few cycles to start and finish.

The sample tests are the two models the page recorded, Zen 4 and SiFive P670, and a few made-up cores.

Starter code · cycles.py

def cycles_per_pass(adds, latency, units, chains):
    """Cycles one pass takes: `adds` additions over `chains` accumulators, on `units` adders of `latency` cycles."""
    return 0  # replace with your code


def chains_needed(latency, units):
    """The fewest accumulators that keep all `units` adders busy."""
    return 0  # replace with your code
The sample tests · test_cycles.py
"""Sample tests: the Zen 4 and SiFive P670 models the page recorded, and a few made-up cores."""

from cycles import chains_needed, cycles_per_pass


def test_zen4_one_chain_runs_at_the_latency():
    assert cycles_per_pass(24, 3, 2, 1) == 72


def test_zen4_three_chains():
    assert cycles_per_pass(24, 3, 2, 3) == 24


def test_zen4_six_chains_reach_the_adders():
    assert cycles_per_pass(24, 3, 2, 6) == 12


def test_zen4_twelve_chains_are_no_faster_than_six():
    assert cycles_per_pass(24, 3, 2, 12) == 12


def test_p670_one_chain():
    assert cycles_per_pass(24, 2, 2, 1) == 48


def test_p670_three_chains():
    assert cycles_per_pass(24, 2, 2, 3) == 16


def test_p670_six_chains():
    assert cycles_per_pass(24, 2, 2, 6) == 12


def test_a_share_of_a_cycle_is_kept():
    assert cycles_per_pass(10, 3, 1, 4) == 10
    assert cycles_per_pass(10, 3, 4, 4) == 7.5


def test_one_adder_is_the_limit_at_once():
    assert cycles_per_pass(8, 1, 1, 1) == 8
    assert cycles_per_pass(8, 1, 1, 4) == 8


def test_chains_needed_on_the_recorded_models():
    assert chains_needed(3, 2) == 6
    assert chains_needed(2, 2) == 4


def test_chains_needed_on_made_up_cores():
    assert chains_needed(4, 2) == 8
    assert chains_needed(2, 4) == 8
    assert chains_needed(5, 1) == 5
    assert chains_needed(1, 3) == 3
A hint

One accumulator does adds / chains additions in a row, so its chain takes adds / chains * latency cycles. All the additions together need adds / units cycles of adder time. A pass cannot be faster than either, so it takes the larger. The adders are all busy once the chains are no longer the limit, that is once latency / chains has fallen to 1 / units.

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

11 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 11 What is a dependency chain?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: A run of instructions each using the result of the one before, so they cannot overlap.

    In a dependency chain each instruction needs the previous result, so the processor cannot overlap them and the time is the sum of their latencies.

  2. Question 2 of 11 A hot loop computes s = ((s + a) + b) + c, three additions into one running total each pass. A profiler says it is latency-bound. What change most directly raises its speed?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: Keep three separate running totals and add a, b and c into them, combining the totals after the loop.

    The single total s is one chain. Three independent totals let the core overlap the additions, the same change the accumulator loops on the page make, combined once at the end.

  3. Question 3 of 11 What is instruction-level parallelism?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: A core running several independent instructions at the same time, overlapping work that does not depend on earlier results.

    Instruction-level parallelism is a single core overlapping independent instructions, each on an idle unit. It is not multiple cores, processes or a wide SIMD instruction.

  4. Question 4 of 11 Which pair of AArch64 instructions can an out-of-order core run at the same time?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: fadd d17, d17, d1 and fadd d18, d18, d1

    Two instructions overlap only when neither needs the other's result. The independent pair writes d17 and d18 and reads neither's output, so both start at once.

  5. Question 5 of 11 The run reaches 0.25 cycles a fadd with sixteen chains, its fastest loop. About how many fadds a cycle is that, the core's throughput for this instruction?

    Type a number.

    Show the answer: Question 5 of 11

    Answer: 4 fadds/cycle

    0.25 cycles a fadd is one fadd every quarter of a cycle, so four fadds a cycle. That is the rate at which the core's adders start additions, so it is the ceiling that more chains approach but cannot pass.

  6. Question 6 of 11 A colleague reads "fadd: latency 2 cycles" and concludes the loop can do one fadd every 2 cycles at best. When is that wrong?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: When the fadds are independent, because then several overlap and the loop approaches the throughput, about four a cycle here.

    A latency figure bounds one dependency chain. With independent work the core overlaps the operations and the loop runs near the throughput, here about four fadds a cycle rather than one per 2.

  7. Question 7 of 11 Which is the throughput of an instruction?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: How many of them the core can finish each cycle when their inputs are ready.

    Throughput is the rate at which independent copies finish, a cycle at a time, decided by the number of units for that instruction; latency is the wait for one result.

  8. Question 8 of 11 The same summing loop over long gets several accumulators at plain -O2, with no extra flag. Why is that safe?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: Two's-complement integer addition is associative, so regrouping the sum never changes the result.

    Integer addition gives the same result in any grouping, so the compiler may use several accumulators at -O2. Floating-point addition may round differently when regrouped, so it waits for -ffast-math.

  9. Question 9 of 11 You add -ffast-math to speed a floating-point reduction and the program's totals change in the last few digits. A test that checks the total against a saved value now fails. What is the right response?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: Decide what accuracy the result needs and set the test's tolerance to it, since both totals are valid sums in different orders.

    -ffast-math lets the compiler regroup the sum, which changes the rounding. Neither total is wrong, so an exact-match test is the wrong check; set a tolerance that reflects the accuracy the result actually needs.

  10. Question 10 of 11 In the Zen 4 model the one-chain region reports 7,203 cycles for 100 passes and the three-chain region 2,404. What do those numbers say about addsd on that model?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: Its latency is about 3 cycles, and three chains overlap enough that an addsd finishes every cycle.

    72 cycles a pass for one chain is 3 per addsd, its latency; 24 a pass for three chains is about 1 each, one addsd finished every cycle. The model's two adders reach their ceiling of 0.5 cycles an addsd only at six chains.

  11. Question 11 of 11 llvm-mca reports a loop at 0.5 cycles an element, but the same loop on real hardware runs far slower on a large array. What most likely explains the gap?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: The model assumes every load hits the cache and ignores memory speed, so a large array that misses runs at the speed memory delivers it.

    llvm-mca models only the core's scheduling and assumes cache hits. A loop whose data does not fit in cache is limited by memory bandwidth and latency, which the model does not account for, so measure on real data.

Interview questions

A warm-up: a loop sums an array into one double and a profiler calls it latency-bound. What does that mean, and what is the first thing you try?

Latency-bound means the loop’s speed is set by how long each operation takes to produce a result the next one needs, not by how much the core could do in parallel. The sum is one dependency chain: each s += a[i] waits for the previous s. The first thing to try is several independent accumulators, say four partial sums added into in turn and combined at the end, so four additions are in flight at once. In C that often means building with -ffast-math, or writing the partial sums by hand, and accepting that the total may change in its last digits because the additions are regrouped. Then measure: the gain stops once the core’s adders are full, which on the machine on this page took sixteen accumulators and gave about eight and a half times the speed.

Two deeper questions build on this lesson, one on proving the speed-up with a model in a build pipeline and one on latency against throughput:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A teammate wants proof that your rewrite of a hot dot-product loop is faster, in a way that comes out the same on every build machine and for everyone who reruns it. How would you give repeatable evidence, and what are its limits?

    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

    A junior engineer reads "the add unit has latency 4 and throughput 2 per cycle" and concludes a summing loop can do at best one add every 4 cycles. Explain what they got right and wrong, and how you would show it.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

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

  1. How fast can a dependency chain run, however many execution units the core has?

    One operation per operation latency. Each step waits for the result of the step before, so spare execution units sit idle.

  2. What is the difference between latency and throughput?

    Latency is the wait for one result; throughput is how many results of a kind the core can finish each cycle. A chain pays the latency, independent work approaches the throughput, and latency times throughput bounds what parallelism can win.

  3. How did several independent accumulators change the speed of the addition loop?

    With sixteen independent partial sums the loop ran about eight and a half times faster, because the out-of-order core could overlap the additions until every adder was busy. Fewer accumulators gave less.

  4. A loop that sums an array into one double is latency-bound. What does that mean, and what do you try first?

    Each s += a[i] waits for the previous s, so the loop runs at the speed of one dependent addition. Try several independent accumulators combined at the end, then measure, and accept that the total may change in its last digits.

  5. Why does a compiler split an integer sum into several accumulators by itself but not a floating-point sum?

    Integer addition is associative, so regrouping cannot change the result. Regrouping floating-point additions changes the rounding, so the compiler does it only with -ffast-math, which can change the last digits of the total.

  6. What does llvm-mca tell you, and what does it leave out?

    It estimates the cycles of a block of instructions on a named core, deterministically and without running it. It models only the core's scheduler and assumes every access hits the cache, so real data must still be measured.

Key takeaways

  • A dependency chain, where each instruction needs the previous result, runs at one operation per operation latency, no matter how many execution units the core has spare.
  • Independent instructions let an out-of-order core overlap work; sixteen independent accumulators made the addition loop about eight and a half times faster, once every adder was busy.
  • Latency is the wait for one result and throughput is how many results a cycle; a chain pays the latency, independent work approaches the throughput, and their product bounds the speed-up.
  • A compiler gives a reduction several accumulators by itself when the operation is associative, as integer addition is; floating-point addition needs -ffast-math, which can change the last digits of the total.
  • llvm-mca estimates the cycles of a block on a named core deterministically and without running it, but it models only the core’s scheduler and assumes cache hits, so measure on real data.

Terms in this lesson

accumulator
A register a loop adds into each pass to build a running total; several independent accumulators let a core add in parallel instead of in one chain.
dependency chain
A run of instructions each of which uses the result of the one before, so they cannot overlap and the run takes the sum of their latencies.
instruction-level parallelism
Running several independent instructions at the same time within one core, so work that does not depend on earlier results need not wait for it.
latency
The time from when an instruction starts to when its result is ready for another instruction to use, usually counted in clock cycles.
out-of-order execution
A core starting any instruction whose inputs are ready rather than in strict program order, so independent work proceeds while a slow result is awaited.
pipeline
The division of instruction handling into stages so that a core works on several instructions at once, each in a different stage, finishing about one a cycle when nothing stalls.
reassociation
Regrouping the operations of a sum or product, such as (a+b)+(c+d) for a+b+c+d; safe for integers but able to change the rounding of floating-point values.
scheduling model
A compiler's table of each instruction's latency and how many can issue per cycle on one named processor, used to estimate how fast a block of code runs.
throughput
How many instructions of a kind a core can finish each clock cycle when their inputs are ready, set by how many execution units it has for them.

References

Related tools

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.