Week 8 · Foundations · Companion to lecture 08b

Foundations —
Where the Data Lives

Arithmetic is cheap. Moving data is the cost. Three pictures of one idea: how far the data is, how it travels, and whether the work pays for the trip. In MPI you wrote every move down. In OpenMP the data still moves; you just do not see the bill.

Concept map

One idea, three pictures. The ladder is how far data travels, the line is how it travels, the roofline is whether the work per byte pays for the trip. Click a node to jump to it.

todaylater
Arithmetic is cheap. Moving data is the cost. Every "my speedup stalled" this semester is some version of: the data was not where the core needed it.

This is not an MPI lecture. It is the ground floor for OpenMP: from Mon Oct 19 the data still moves, silently. Weeks 9–13 are discovering what OpenMP moves for you.

The ladder: distance is time

Grab it from your hand, from the desk, from the shelf, from the basement, from the neighbour's basement, by mail. Each rung several times slower; top to bottom, about 100×.

The numbers behind the rungs

Orders of magnitude for a recent x86 server, not Cardinal measurements. Sizes are Intel's spec for the Xeon CPU Max 9470 and OSC's Cardinal page; confirm with lscpu on Cardinal. The last column is the 2-core test VM, measured on the next screen.

LevelSize (Cardinal node)LatencyTest VM, measured
Register~2 KB (32 × 64 B vectors) per core< 1 ns—
L148 KB per core~1–2 ns1.5 ns
L22 MB per core~4–5 ns4.4 ns
L3~100 MB per socket, shared~20–40 ns15–26 ns
DRAM, local256 GB DDR5 + 64 GB HBM per socket~100 ns120–190 ns
DRAM, remotethe other half~1.5–2× local—
Another nodeNDR200, 25 GB/s~2 µs (Week 5's α)—

NDR200 = 200 Gb/s = 25 GB/s, consistent with the course's β ≈ 1/(20 GB/s). The remote-socket factor is a typical two-socket figure; it was not measured on Cardinal. A Cardinal dense CPU node is 2 × Xeon CPU Max 9470, 52 cores each, 48 usable, 2.0 GHz. One node is shared memory; two nodes are not.

What is the most expensive thing an inner loop can do? Touch another node, which is why MPI makes you write it down. Second most: miss all the way to DRAM on every access, which OpenMP will let you do without a word.

The ladder, measured: chase.cpp

Same program. Only the fit changes. A random cyclic chain of pointers, one per 64-byte line, so every load depends on the previous one and the prefetcher cannot guess.

The chain is walked once with a working set of 16 KB, then again at 256 MB. Average time per load, bottom over top: roughly what ratio?

Top: 1.5 ns. Bottom: 120–190 ns. About 100× from top to bottom, and the program did not change. The step is set by the size of the cache, not the size of your loop.

chase.cpp · average ns per load, min of 3 runs, g++ -O2, test VM
lscpu: 64 KiB L1d and 2 MiB L2 over 2 instances, 33 MiB L3
   4 –  32 KB    1.52 – 1.55 ns
  48 – 256 KB    4.28 – 4.66 ns
        1 MB    15.1 ns
   2 –   4 MB   25.4 – 26.5 ns
        8 MB   120 ns
  16 – 256 MB   157 – 189 ns

Pointer-chase explorer


    

Each flat stretch is one cache level. Each jump is falling out of it. The chart draws the deck's measured range for each stretch as a band; the "fits in" column is read off the VM's lscpu. The largest sizes include TLB misses (4 KB pages), so they overstate pure DRAM latency somewhat. Do not over-read the middle: a VM's L3 is shared with other tenants. The last line is arithmetic from the measured figure, not a measurement.

Touch every double. Then touch every 8th.

traverse.cpp, part 2. A 256 MB array of doubles. Loop 1: x[i] *= 1.000001 for every i. Loop 2: the same, for every 8th i. One-eighth of the work.

const size_t M = 32*1024*1024;                      // 32 Mi doubles = 256 MiB

for (int s : {1, 2, 4, 8, 16, 32, 64}) {
    for (size_t i = 0; i < M; i += s) {
        B[i] *= 1.000001;
    }
}
Commit: how much faster is loop 2 (stride 8) than loop 1 (stride 1)? A ratio.

Hint, if you said 8×: what is the smallest thing memory will give you?

min of 5 runs, test VM
stride  1:  0.0371 s   touches 33,554,432 doubles
stride  2:  0.0306 s   touches 16,777,216
stride  4:  0.0285 s   touches  8,388,608
stride  8:  0.0274 s   touches  4,194,304
stride 16:  0.0245 s   touches  2,097,152
stride 32:  0.0142 s   touches  1,048,576
stride 64:  0.0060 s   touches    524,288

Stride 1 → 8: 0.0371 → 0.0274 s, ratio 1.35, not 8. (Across three runs stride 8 was 0.0274–0.0301 s.) Memory moves in 64-byte lines: 8 doubles. Touch one, pay for all 8. Strides 1 to 8 read every line, so they cost about the same. Time falls only once the stride skips whole lines.

Stride 16 is also nearly the same (0.0245 s; 0.0367 in one run). Likely reason: Intel's adjacent-line prefetcher fetches lines in pairs. "Likely": it was not verified in the VM. And "free" is too strong a word for the other 7 doubles: they are cheap, and the prefetcher also hides much of the first miss on a sequential stream. The real contrast is contiguous vs strided, not 1 miss vs 7 free hits.

The cache-line picture

Eight 64-byte lines of the array are drawn, 8 doubles each. Filled cells are the doubles the loop touches; a line with any filled cell is fetched whole. The time column is the deck's measurement for that stride; the ratio columns are arithmetic on the line size. getconf LEVEL1_DCACHE_LINESIZE prints 64 on the VM and on Cardinal. Apple M-series reports 128: a student benchmarking on a Mac will see a different knee.

Sum an N × N matrix by rows. Then by columns.

traverse.cpp, part 1. Row-major doubles, A[i·N + j]. Same adds, same answer, same N² loads. Rows: next element 8 bytes away. Columns: next element 8·N bytes away.

for (int t = 0; t < 5; t++) {                       // min of 5
    s = 0;
    for (i) for (j) s += A[(size_t)i*N + j];       // row:    stride 1

    s = 0;
    for (j) for (i) s += A[(size_t)i*N + j];       // column: stride N doubles
}
N = 256 is 0.5 MB. N = 4096 is 134 MB. Commit two ratios, column time / row time: at N = 256 and at N = 4096.

Fits in cache: no penalty. Does not: 10×.

$ ./traverse N           (min of 5, each size)
N =  256  (0.5 MB)  row 0.0001 s  column 0.0001 s   1.0x
N =  512  (  2 MB)  row 0.0003 s  column 0.0008 s   2.6x
N = 1024  (  8 MB)  row 0.0014 s  column 0.0112 s   7.9x
N = 2048  ( 34 MB)  row 0.0085 s  column 0.0656 s   7.7x
N = 4096  (134 MB)  row 0.0246 s  column 0.2536 s  10.3x

At N = 4096 a column step is 32 KB: a new line per load, and the line is gone by the time the next column comes back for its other 7 doubles. N = 256 is the trap: 0.5 MB fits in the test machine's L2, so the lines are still there when you come back.

Spatial locality: use the rest of the line you paid for. Rows do; columns do not. Temporal locality: use it again before it is evicted. At N = 256 everything stays in cache. Layout decides time only when you miss. The ratio is not a constant: 1.0× until the matrix outgrows the caches, then 8–12× (three more runs at N = 4096: 9.0, 12.3, 9.2×, noisy VM).

Which lines get pulled


        

A model, not a timing: a 16 × 16 matrix of doubles, so each matrix row is two 64-byte lines and the whole matrix is 32 lines. The cache is drawn as a small LRU set of lines; the counter is line fetches. Loads that hit a line already in the cache are cheap; loads that pull a new line are the cost. The measured ratio at N = 4096 was 10.3×, a little above the model's count, because the prefetcher helps the row walk further.

Arithmetic is cheap. Moving data is the cost. Same arithmetic, 10× the time. Where this returns: OpenMP loops (Oct 19) — which index the threads split and which one the inner loop walks is a layout decision. AoS vs SoA. And MPI already taught it: a column of a row-major matrix needed MPI_Type_vector (Week 6). Same stride, same cost, now inside one node.

The 64-byte line is the unit

One fact, three consequences. 64 bytes on x86 and on Cardinal; Apple M-series reports 128.

Transfer

Memory moves lines, never single bytes. Walk in order and the next 7 doubles arrive with the first.

Reuse

A line touched again soon is still in cache. Loop interchange (ijk → ikj) changes the walk from column to row; blocking (tiles that fit) makes the reused data fit. Different techniques. That is how matmul gets fast.

Coherence

Cores keep each line consistent, a line at a time, and pay for it in traffic. Two threads writing different variables in one line take turns. False sharing, Mon Nov 9.

The rule

Week 6's derived types and pack/unpack did this for messages. Make the bytes you move contiguous.

Which consequence is this?

Each statement is one of the three. Pick, and the row tells you.

Blocking's reuse is what the roofline will call arithmetic intensity, two screens from here.

NUMA: a Cardinal node is two sockets

First touch decides the socket. The OS places a page on the socket of the thread that first writes it, not the one that allocates it. Other socket: roughly 1.5–2× a local access. Correctness tests never see this.

Schematic, not measured: the test container has one NUMA node, and this build did not measure Cardinal. Eight threads, four per socket, sixteen pages. On a node, numactl -H lists the NUMA nodes; OSC says Cardinal's cpu partition runs HBM in flat mode, so HBM typically appears as its own NUMA nodes next to the DDR5 ones. Check numactl -H before saying how many.

The same picture in code

double* b = (double*)malloc(n * 8);  // address only, no pages

for (long i = 0; i < n; i++) {       // serial init:
    b[i] = 0.0;                      // left picture
}

// "omp parallel for" (Mon Oct 19): split the loop
// across threads, each takes a contiguous part
#pragma omp parallel for schedule(static)
for (long i = 0; i < n; i++) {
    b[i] = 0.0;                      // right picture
}

#pragma omp parallel for schedule(static)
for (long i = 0; i < n; i++) {       // same split:
    b[i] = f(b[i]);                  // every read local
}

The OpenMP lines are the first pragma you have seen; Monday Oct 19 explains them. The comment is the whole gloss for today.

A program declares std::vector<double> v(n); and then runs a parallel loop over it. Where do the pages of v live?

std::vector<double> v(n) zero-fills on one thread: the serial init in disguise. Strictly, all pages land on the socket of the thread that ran the init loop (usually socket 0). The serial loop is correct, passes every test, and halves your bandwidth: with every page on one socket, all threads share that socket's memory channels, and half of them pay the remote penalty on top. The exact factor depends on the loop; no number is quoted for Cardinal.

Watch for: the parallel init only helps if the compute loop uses the same split. malloc gives you an address, not a location. The single most common invisible performance bug in OpenMP; it returns Nov 9 and in R08's optional HBM vs DDR5 experiment.

The roofline: two speed limits. The slower one wins.

Flat roof: how fast the cores can compute. Slope: how fast memory can feed them. Across: FLOPs done per byte moved (arithmetic intensity). Which limit is yours?

A loop does 1 FLOP per byte moved from memory. On the assumed node (peak 6.1 TFLOP/s, DDR5 0.5 TB/s), which limit applies, and about what fraction of peak can it reach?

Attainable = min(peak, intensity × bandwidth) = min(6,144, 1 × 500) = 500 GFLOP/s. 500 / 6,144 ≈ 8%. Left of the ridge, memory decides. Most real loops are far to the left of 12.

Read it


    

Model, assumed Cardinal-ish node. Peak: 96 usable cores × 2.0 GHz × 32 FP64 FLOP/cycle (AVX-512: 2 FMA × 8 doubles × 2) = 6,144 GFLOP/s ≈ 6.1 TF, nominal; AVX-512 clocks differ. DDR5: peak 8 ch × 8 B × 4.8 GT/s = 307 GB/s per socket; measured STREAM triad on Xeon Max 9470 (Aurora, arXiv 2504.03632, Table I) 235–245 GB/s per socket → ~0.5 TB/s per node. Ridge = 6,144 / 500 ≈ 12 FLOP/B: about 100 FLOPs for every double loaded. HBM: 1.7 TB/s per node (ridge 3.6); if Cardinal runs quad mode, 1.4 (ridge 4.4). Both axes are logarithmic, which is why the memory limit is a straight line (45° when the two axes share a scale; here the x axis is stretched).

Six things to read off the shape

  1. Two things can hold a loop back. The cores can only do arithmetic so fast. Memory can only deliver bytes so fast. Whichever limit is lower sets the speed.
  2. The axes. Vertical: GFLOP/s actually achieved. Horizontal: arithmetic intensity, FLOPs per byte moved from memory. It describes the loop, not the machine. Low AI: each byte used once and discarded. High AI: each byte reused many times.
  3. The flat roof is the compute limit. It is flat because the cores' top speed does not depend on how you use your data.
  4. The slope is the memory limit. B bytes/s at AI FLOPs per byte gives at most B × AI FLOP/s. Doubling AI doubles the ceiling.
  5. Take the lower of the two. Attainable = min(peak, AI × bandwidth). The shape of a roof over the eaves, hence the name.
  6. The ridge is where the two limits meet. Left of it, memory decides: once bandwidth is saturated, which takes about 12 cores per socket, adding cores does nothing; the fixes are fewer bytes (reuse, blocking) or more bandwidth (HBM). Right of it, the cores decide: more cores and vectorization pay off.
Intensity counts bytes to and from memory, not to registers. It is a property of the algorithm and the loop order.

AXPY, and reuse moves you right

The simplest loop in scientific computing: y[i] = a·x[i] + y[i]. Three numbers: FLOPs, bytes, fraction of peak.

Per element: how many FLOPs, how many bytes to and from memory, and so what intensity? Left or right of the ridge at 12? At most what fraction of the node's 6.1 TFLOP/s?

2 FLOPs (multiply, add). Bytes: read x[i] 8, read y[i] 8, write y[i] 8 = 24. AI = 2/24 = 1/12 ≈ 0.083 FLOP/B. Attainable on DDR5: 0.083 × 500 GB/s ≈ 42 GFLOP/s. 42 / 6,144 ≈ 0.7% of peak.

time check, n = 10^8, model
memory:      2.4 GB / 0.5 TB/s        = 4.8 ms
arithmetic:  2 × 10^8 FLOP / 6.1 TF   = 0.033 ms
memory ≈ 145 × the arithmetic

That ratio is the spine. If you counted 32 bytes (write-allocate fetches the line before writing): good. AI = 1/16, the case is stronger. Either count is defensible if stated.

Where they sit

Model, same assumed numbers as the previous screen. Naive matmul: each multiply-add reads a[i][k] and b[k][j], 16 B for 2 FLOPs → AI 1/8 → ~62 GFLOP/s on DDR5. Blocked, b × b tiles: per step load a tile of A and of B (16b² bytes), do 2b³ FLOPs → AI = b/8. At b = 128 three tiles are 384 KB and fit a 2 MB L2; AI = 16, right of the DDR5 ridge (12.3). Tile size, not n, sets AI; b below about 100 stays on the slope. The ceiling 2n³ / 24n² = n/12 (each matrix moved once, 341 at n = 4096) is the compulsory-traffic bound, reachable only if all three matrices fit in cache; it is not "blocked".

Do threads ever help a memory-bound loop?


    

Model: one core sustains only ~20 GB/s (McCalpin; L2 miss concurrency 48 × 64 B / 130 ns ≈ 23.6 GB/s). DDR5 at ~240 GB/s per socket saturates at about 12 cores. So a bandwidth-bound loop does speed up from 1 thread to about a dozen per socket, then goes flat. Do not let "memory decides" become "threads never help". R06 Step 1b builds on this; its numbers are R06's to measure.

The same computation (matmul) moves from the slope to the roof by reuse alone. HBM lifts the slope, not the roof: AXPY gains ~3.4× (to ~140 GFLOP/s, ~2.3% of peak); blocked matmul is already at the roof, so HBM does nothing for it. Once memory is saturated, more cores do not help a memory-bound loop. More bandwidth, or fewer bytes, does. "I only got 3× on 16 threads" can be the correct, complete answer for a bandwidth-bound loop.

Run it on Cardinal

The program behind the stride and row/column screens is traverse.cpp, the instructor's program; it lives in the deck's _deck_sources, not on a student branch. The listing is on this page. Run it on Cardinal for your own numbers, and say where it ran.

  1. The program

    This is the part the deck shows. Around it: std::chrono::steady_clock, min of 5 runs per case, and the sums printed so the compiler cannot delete the loops. chase.cpp (the pointer chase) sits next to it in _deck_sources and is not listed here.

    // traverse.cpp, part 1: N x N row-major doubles in A
    for (int t = 0; t < 5; t++) {                       // min of 5
        s = 0;
        for (i) for (j) s += A[(size_t)i*N + j];       // row:    stride 1
    
        s = 0;
        for (j) for (i) s += A[(size_t)i*N + j];       // column: stride N doubles
    }
    
    // traverse.cpp, part 2: 256 MiB of doubles in B
    const size_t M = 32*1024*1024;                      // 32 Mi doubles = 256 MiB
    
    for (int s : {1, 2, 4, 8, 16, 32, 64}) {
        for (size_t i = 0; i < M; i += s) {
            B[i] *= 1.000001;
        }
    }
  2. Log in, load the compiler, build, check the line size

    Modules do not persist between sessions; load it every time.

    ssh <your-username>@cardinal.osc.edu
    module load gcc/12.3.0
    g++ -O2 -o traverse traverse.cpp
    getconf LEVEL1_DCACHE_LINESIZE
  3. Get cores of your own before you time anything

    The deck ran it on a login node, which is fine for a first look; a login node is shared, so for numbers you would write down use an allocation.

    salloc -N 1 -n 1 -c 8 -t 00:15:00 -A PCS0313
    module load gcc/12.3.0
    srun -c 8 ./traverse 4096

Experiments

A · The unit

getconf LEVEL1_DCACHE_LINESIZE
lscpu | grep -i cache
What you should see

64. The deck's table says 48 KB L1 per core, 2 MB L2 per core and about 100 MB L3 per socket for the Xeon CPU Max 9470 (Intel's spec); lscpu is how you confirm it. On a Mac you would see 128 and a different knee.

B · Rows, then columns, at the deck's N

srun -c 8 ./traverse 4096
srun -c 8 ./traverse 256
What you should see

On the test VM: 10.3× at N = 4096 and 1.0× at N = 256. Cardinal's number is yours to find; the deck gives none. The shape should be the same: no penalty while the matrix fits, then roughly an order of magnitude. Write down where it ran.

C · Find the knee

for N in 256 512 1024 2048 4096; do
    srun -c 8 ./traverse $N
done
What you should see

On the VM the ratio went 1.0, 2.6, 7.9, 7.7, 10.3×. The knee sits where N² × 8 bytes outgrows the cache that was holding the lines; on the VM that was between 0.5 MB and 2 MB. Cardinal's L2 is 2 MB per core and its L3 is ~100 MB per socket, so expect the knee somewhere else. Which N moves it, and which cache size does that match?

D · The stride sweep

srun -c 8 ./traverse 4096        # part 2 runs after part 1
What you should see

Strides 1 to 8 within a few tens of percent of each other; the time falling only from stride 16 or 32 on. On the VM stride 1 → 8 was 0.0371 → 0.0274 s. If stride 16 is also close to stride 8, the deck's likely reason is the adjacent-line prefetcher; it was not verified.

E · How many NUMA nodes?

numactl -H
What you should see

A list of NUMA nodes with their CPUs and memory. The deck does not say how many Cardinal shows: the DDR5 halves, and in flat mode the HBM typically as its own nodes beside them. Count them, and note which CPUs belong to which, before you say anything about "the other socket". Then exit to release the allocation.

Before Lab R04 (Fri Oct 16): repo cloned on Cardinal before 8:30; sizeof(Particle) worked out on paper; job in by 9:20. The sizeof(Particle) exercise is the same idea as the cache line: the compiler aligns fields; memory aligns lines. Mon Oct 19 is OpenMP I. When an OpenMP curve surprises you, ask where the data lived.

Check yourself

Six questions, instant feedback, nothing recorded.

1. In chase.cpp's curve, what sets where each jump happens?
Same program, only the fit changes. Each flat stretch is one cache level; each jump is falling out of it. Top 1.5 ns, bottom 120–190 ns, about 100×.
2. Touching every 8th double of a 256 MB array instead of every double did one-eighth of the work. The measured time ratio, stride 1 over stride 8?
0.0371 → 0.0274 s. Memory moves in 64-byte lines, 8 doubles. Touch one, pay for all 8. Time falls only once the stride skips whole lines.
3. Summing a 256 × 256 matrix by columns cost the same as by rows (1.0×). At 4096 × 4096 it cost 10.3×. Why the difference?
Temporal locality: use it again before it is evicted. Layout decides time only when you miss. A column step at N = 256 is 2 KB, 32 lines apart; it is the fit, not the stride, that saves it.
4. A two-socket node. One thread zeroes the whole array, then 96 threads compute on it with a static split. What happens?
First touch: the page goes to the socket of the thread that first writes it. The serial loop is correct, passes every test, and halves your bandwidth. Initialize the way you compute. std::vector<double> v(n) is the serial init in disguise.
5. On the assumed node (6,144 GFLOP/s peak, 500 GB/s DDR5), a loop with intensity 1 FLOP/B is limited by
Attainable = min(peak, AI × bandwidth). The ridge is at 6,144 / 500 ≈ 12 FLOP/B; 1 is far left of it, so memory decides. 42 GFLOP/s is AXPY, at 1/12.
6. HBM delivers 1.7 TB/s instead of 0.5. Which loop gains, and why?
AXPY goes from ~42 to ~140 GFLOP/s (~2.3% of peak) and is still on the slope. Blocked matmul at AI = 16 was compute-limited already. Reuse moves you right; bandwidth lifts the slope.
← → keys also work