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.
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.
| Level | Size (Cardinal node) | Latency | Test VM, measured |
|---|---|---|---|
| Register | ~2 KB (32 × 64 B vectors) per core | < 1 ns | — |
| L1 | 48 KB per core | ~1–2 ns | 1.5 ns |
| L2 | 2 MB per core | ~4–5 ns | 4.4 ns |
| L3 | ~100 MB per socket, shared | ~20–40 ns | 15–26 ns |
| DRAM, local | 256 GB DDR5 + 64 GB HBM per socket | ~100 ns | 120–190 ns |
| DRAM, remote | the other half | ~1.5–2× local | — |
| Another node | NDR200, 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.
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.
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;
}
}
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
}
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.
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.
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.
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?
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
- 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.
- 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.
- The flat roof is the compute limit. It is flat because the cores' top speed does not depend on how you use your data.
- 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.
- Take the lower of the two. Attainable = min(peak, AI × bandwidth). The shape of a roof over the eaves, hence the name.
- 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.
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.
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.
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.
-
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_sourcesand 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; } }
-
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
-
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
doneWhat 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 1What 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.
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.