FForge
← Labscodegen-03Code generation●●●○

llvm-mca — latency-bound vs throughput-bound, predicted then measured

codegen/03_llvm_mca.cneeds: llvm-mca, tools/bench.h, perf stat2 steps
0/2 steps
what this lab teaches

llvm-mca simulates a loop body on a model of the CPU's pipeline and reports how many cycles per iteration the DEPENDENCIES and the PORTS allow. It cannot see memory misses or branches, but for the arithmetic core of a loop it answers the question "is this bound by latency or by throughput?" before you run anything. Two loops doing the same multiply-add work: one recurrence (every iteration waits for the previous product) and four interleaved recurrences.

evidence to leave with
Predicted 4.0x, measured 4.5x; mca's over-charge of a fused load+add explained
first, get the code
run this
$ git clone https://github.com/adit4443ya/debug_labs.git && cd debug_labs
then build it
run this
$ (cd codegen && ./build.sh)   -> bin/03_llvm_mca, bin/03_llvm_mca.s (with LLVM-MCA-BEGIN/END markers)
$ The markers come from `__asm__ volatile("# LLVM-MCA-BEGIN name")` in the
$ source: they survive into the .s as comments and delimit regions.
walk it, one step at a time
run this
$ llvm-mca -mcpu=alderlake bin/03_llvm_mca.s | grep -E '^\[|Iterations|Total Cycles|IPC|Block RThroughput'
what it printed on the machine that wrote this lab
[0] Code Region - chain1
Iterations:        100          Total Cycles:      603
IPC:               0.33         Block RThroughput: 1.0
[1] Code Region - chain4
Iterations:        100          Total Cycles:      606
IPC:               1.32         Block RThroughput: 4.0

Read it: chain1 needs 603 cycles for 100 iterations = 6.0 cycles per element, although its Block RThroughput (the port limit) is 1.0: the loop is LATENCY-bound six times over. chain4 processes 4 elements in the same 6.06 cycles = 1.5 cycles per element: the four chains overlap.

run this
$ llvm-mca -mcpu=alderlake -timeline -iterations=3 bin/03_llvm_mca.s   (chain1 region)
what it printed on the machine that wrote this lab
Instruction Info:
[1]    [2]    [3]    [4]    [5]    [6]    Instructions:
 1      3     1.00                        imulq   %rdx, %rax
 2      6     0.33    *                   addq    (%rdi,%rcx,8), %rax
Timeline view:
[0,0]     DeeeER    .    .    .   imulq   %rdx, %rax
[0,1]     DeeeeeeER .    .    .   addq    (%rdi,%rcx,8), %rax
[1,0]     D======eeeER   .    .   imulq   %rdx, %rax
[1,1]     D======eeeeeeER.    .   addq    (%rdi,%rcx,8), %rax
[2,0]     .D===========eeeER  .   imulq   %rdx, %rax

Read it: D = dispatched, e = executing, E = executed, = = waiting on a dependency. Iteration 1's imul waits (======) for iteration 0's add: the chain through %rax. Latency column: imul 3, add-from-memory 6.

run this
$ ../tools/pin.sh -q perf stat -e cycles:u,instructions:u ./bin/03_llvm_mca
what it printed on the machine that wrote this lab
  chain1    2.287 ns/elem
  chain4    0.508 ns/elem
  Ratio measured: 4.5x. Ratio predicted: 6.03 / 1.5 = 4.0x. The model got
  the SHAPE right (latency-bound vs throughput-bound, ~4x) and the
  direction of every conclusion you would draw from it.

WHERE THE MODEL IS WRONG, AND WHY THAT IS FINE:
  At ~2.1 GHz, 2.287 ns = 4.8 cycles per element, not 6. The real chain is
  imul (3) + add (1) = 4 cycles: the LOAD in `addq (mem),%rax` is not on
  the chain (its address does not depend on %rax), so the core issues it
  early and only the 1-cycle add waits. mca charges the fused load+add its
  full 6-cycle latency to the chain: a known limitation of modelling a
  micro-fused instruction as one unit. Similarly chain4 measured 1.07
  cycles/elem vs 1.5 predicted. Use mca for RELATIVE questions ("is it the
  chain?", "would 4 accumulators help?", "which port is saturated?") and
  the machine for absolute numbers.
what mca cannot see

cache misses (it assumes every load hits), branch mispredicts, the frequency, the front-end on real code layout, and anything across the region boundary. A loop that mca says runs at 1 cycle/iter and the machine runs at 100 is memory-bound: go to the cache track.

the rules

A1. For an arithmetic loop, run mca before optimizing: if Total Cycles >> Iterations x Block RThroughput, you are latency-bound and need more independent chains (accumulators), not faster instructions. A2. Read the Instruction Info latencies: the longest dependent path through the loop is your speed limit. A3. Confirm with cycles/element from perf stat; expect mca to be within ~30% on the absolute, and right on the ratio.

take it further

1. Run mca with -mcpu=skylake and -mcpu=znver4: different latencies, different conclusions? (imul latency is 3 on all; add-from-memory differs.) 2. Make chain8 (eight recurrences). mca: Block RThroughput 8.0, Total Cycles ~8 per 8 elements = 1.0/elem: now PORT-bound. Measure it. 3. Replace the multiply by an add: chain latency 1+1; how many chains until the ports (Block RThroughput) become the limit? ============================================================

read the source · codegen/03_llvm_mca.c
#include "../tools/bench.h" /* One recurrence: each iteration's multiply depends on the previous result (latency-bound). */ __attribute__((noinline)) uint64_t chain1(const uint64_t *a, size_t n) { uint64_t x = 1; for (size_t i = 0; i < n; i++) { __asm__ volatile("# LLVM-MCA-BEGIN chain1" ::: "memory"); x = x * 0x9E3779B97F4A7C15ull + a[i]; __asm__ volatile("# LLVM-MCA-END" ::: "memory"); } return x; } /* Four independent recurrences over interleaved elements: the same work, 4 chains in flight (throughput-bound). */ __attribute__((noinline)) uint64_t chain4(const uint64_t *a, size_t n) { uint64_t x0 = 1, x1 = 2, x2 = 3, x3 = 4; for (size_t i = 0; i + 3 < n; i += 4) { __asm__ volatile("# LLVM-MCA-BEGIN chain4" ::: "memory"); x0 = x0 * 0x9E3779B97F4A7C15ull + a[i]; x1 = x1 * 0x9E3779B97F4A7C15ull + a[i + 1]; x2 = x2 * 0x9E3779B97F4A7C15ull + a[i + 2]; x3 = x3 * 0x9E3779B97F4A7C15ull + a[i + 3]; __asm__ volatile("# LLVM-MCA-END" ::: "memory"); } return x0 ^ x1 ^ x2 ^ x3; } int main(int argc, char **argv) { size_t n = argc > 1 ? strtoull(argv[1], 0, 10) : (1u << 20); /* 8 MB: L3-resident, so memory is not the bound */ uint64_t *a = malloc(n * 8); for (size_t i = 0; i < n; i++) a[i] = i; int reps = 50; uint64_t t0, r; t0 = bench_ns(); r = 0; for (int k = 0; k < reps; k++) r ^= chain1(a, n); BENCH_KEEP(r); printf("chain1 %7.3f ns/elem\n", (double)(bench_ns() - t0) / ((double)n * reps)); t0 = bench_ns(); r = 0; for (int k = 0; k < reps; k++) r ^= chain4(a, n); BENCH_KEEP(r); printf("chain4 %7.3f ns/elem\n", (double)(bench_ns() - t0) / ((double)n * reps)); return 0; }
◇
Finish the lab: write the evidence note

A lab is done when you can say what it showed on your machine — not when you have read it.