FForge
← Labscache-05Memory hierarchy●●●○

Memory-level parallelism, and when software prefetch helps

cache/05_mlp_prefetch.cneeds: tools/bench.h, tools/pin.sh1 steps
0/1 steps
what this lab teaches

A DRAM access costs ~140 ns (lab 02) but the core can have many in flight at once. Latency-bound code (each load depends on the previous) pays the full cost every time; throughput-bound code overlaps them. Three experiments on the same 128 MB random cycle: 1. K independent dependent chains: ns per load falls almost linearly with K (memory-level parallelism, MLP). 2. ONE chain with "jump pointers": each node also stores the node D steps ahead, so we can prefetch it. Software prefetch turns a latency-bound walk into a throughput-bound one: 7-10x. 3. Independent loads whose addresses are already in an array: software prefetch does NOTHING, because the out-of-order core already sees the addresses and overlaps the loads itself.

evidence to leave with
145 -> 12 ns/load with 16 chains; 140 -> 15 ns with jump pointers; 0% change on an index-array gather
first, get the code
run this
$ git clone https://github.com/adit4443ya/debug_labs.git && cd debug_labs
then build it
run this
$ cd cache && ./build.sh
produces bin/05_mlp_prefetch [D]
walk it, one step at a time
run this
$ ../tools/pin.sh -q ./bin/05_mlp_prefetch 8
what it printed on the machine that wrote this lab
== 1: K independent dependent chains over 128 MB (steps per chain = 4000000) ==
  chains      ns/load
       1        144.7
       2         68.3
       4         35.0
       8         18.1
      16         12.3

== 2: ONE chain with jump pointers (D=8): prefetch the node D steps ahead ==
no prefetch               140.2 ns/load
prefetch(ahead)            21.0 ns/load

== 3: independent loads with addresses in an index array: prefetch distance ==
 distance      ns/load
        0         11.1
        4         11.4
       16         11.4
       64         11.1
run this
$ ../tools/pin.sh -q ./bin/05_mlp_prefetch 16      (experiment 2 only)
what it printed on the machine that wrote this lab
no prefetch               156.1 ns/load
prefetch(ahead)            15.4 ns/load
how to read it

1. 1 chain = 145 ns per load: pure latency. 16 chains = 12.3 ns: the core keeps ~12 misses in flight (the load buffer / fill buffers set the ceiling; on this core it flattens around 10-16). Same total work; the only difference is DEPENDENCY. This is the single most important idea for data-structure performance: independent lookups should be issued together (batching), not one after another. 2. The jump-pointer trick: the address of node i+D is known at node i, so `__builtin_prefetch` issues its miss D steps early. With D=8, 140 -> 21 ns; with D=16, 156 -> 15 ns, approaching the 16-chain throughput. This is how B-trees, skip lists and hash tables get "prefetchable": you store or compute the next-next address early. Cost: 8 bytes per node and one extra load (from the same line). 3. When addresses come from an index array, the loads are already independent: the core issues them back to back and prefetching changes nothing (11.1 vs 11.4 ns). Software prefetch only helps when the hardware could NOT have known the address in time. Adding prefetches to code like this costs instructions and can hurt.

the rules

M1. Latency-bound code is limited by dependency, not by bandwidth. Ask "how many misses can be in flight?" before "how fast is memory?". M2. Restructure for independence first: batch lookups, process K keys at once, use breadth over depth. M3. Prefetch only when (a) the loads are dependent AND (b) you can compute the address at least one memory latency (~140 ns = the time for D steps of the hot loop) ahead. Measure; the wrong distance or a prefetch the hardware already did is pure overhead.

take it further

1. D = 2 and D = 4: prefetch too close to its use hides little. 2. Shrink BYTES to 8 MB (L3) and rerun: MLP gains shrink because L3 latency is short and the chain is limited by other things. 3. `perf stat -e cycles:u,instructions:u` on chains=1 vs chains=16: instructions identical, cycles 10x apart. IPC is the tell. ============================================================

read the source · cache/05_mlp_prefetch.c
#include "../tools/bench.h" #define BYTES (128ull << 20) #define NODES (BYTES / 16) /* node = {next, ahead}: 16 bytes, both in one line */ static void sattolo(uint64_t *nxt, size_t n, uint64_t *seed) { for (size_t i = 0; i < n; i++) nxt[i] = i; for (size_t i = n - 1; i > 0; i--) { size_t j = (size_t)(bench_rand(seed) % i); uint64_t t = nxt[i]; nxt[i] = nxt[j]; nxt[j] = t; } } /* K independent chains started at K different points of the same cycle. */ __attribute__((noinline)) static uint64_t chase(const uint64_t *p, int k, uint64_t steps) { uint64_t idx[16]; for (int c = 0; c < k; c++) idx[c] = (uint64_t)c * (NODES / 17); for (uint64_t s = 0; s < steps; s++) for (int c = 0; c < k; c++) idx[c] = p[2 * idx[c]]; uint64_t r = 0; for (int c = 0; c < k; c++) r += idx[c]; return r; } /* ONE chain, but each node also stores the node D steps ahead ("jump pointer"), so we can prefetch it. */ __attribute__((noinline)) static uint64_t chase_jump(const uint64_t *p, uint64_t steps, int use_prefetch) { uint64_t idx = 0; for (uint64_t s = 0; s < steps; s++) { if (use_prefetch) __builtin_prefetch(&p[2 * p[2 * idx + 1]], 0, 0); /* address known NOW, needed D steps later */ idx = p[2 * idx]; } return idx; } /* Independent loads with addresses known in advance (an index array): does prefetch help here? */ __attribute__((noinline)) static uint64_t gather(const uint64_t *p, const uint32_t *ix, uint64_t n, int dist) { uint64_t s = 0; for (uint64_t i = 0; i < n; i++) { if (dist) __builtin_prefetch(&p[2 * ix[i + dist]], 0, 0); s += p[2 * ix[i]]; } return s; } int main(int argc, char **argv) { int D = argc > 1 ? atoi(argv[1]) : 8; uint64_t seed = 7; uint64_t *nxt = malloc(NODES * 8); sattolo(nxt, NODES, &seed); uint64_t *p = malloc(BYTES); /* p[2i] = next, p[2i+1] = D steps ahead */ uint64_t *order = malloc(NODES * 8); /* walk the cycle once to know who is D ahead */ uint64_t cur = 0; for (uint64_t k = 0; k < NODES; k++) { order[k] = cur; cur = nxt[cur]; } for (uint64_t k = 0; k < NODES; k++) { uint64_t i = order[k]; p[2 * i] = nxt[i]; p[2 * i + 1] = order[(k + (uint64_t)D) % NODES]; } free(order); free(nxt); uint64_t steps = 4000000; printf("== 1: K independent dependent chains over 128 MB (steps per chain = %llu) ==\n", (unsigned long long)steps); printf("%8s %12s\n", "chains", "ns/load"); for (int k = 1; k <= 16; k *= 2) { uint64_t t0 = bench_ns(); uint64_t r = chase(p, k, steps); BENCH_KEEP(r); printf("%8d %12.1f\n", k, (double)(bench_ns() - t0) / (double)(steps * (uint64_t)k)); } printf("\n== 2: ONE chain with jump pointers (D=%d): prefetch the node D steps ahead ==\n", D); uint64_t t0 = bench_ns(); uint64_t r = chase_jump(p, steps, 0); BENCH_KEEP(r); printf("%-22s %8.1f ns/load\n", "no prefetch", (double)(bench_ns() - t0) / (double)steps); t0 = bench_ns(); r = chase_jump(p, steps, 1); BENCH_KEEP(r); printf("%-22s %8.1f ns/load\n", "prefetch(ahead)", (double)(bench_ns() - t0) / (double)steps); uint64_t n = 8000000; uint32_t *ix = malloc((n + 64) * 4); for (uint64_t i = 0; i < n + 64; i++) ix[i] = (uint32_t)(bench_rand(&seed) % NODES); printf("\n== 3: independent loads with addresses in an index array: prefetch distance ==\n"); printf("%9s %12s\n", "distance", "ns/load"); int dists[] = {0, 4, 16, 64}; for (size_t d = 0; d < sizeof dists / sizeof *dists; d++) { t0 = bench_ns(); r = gather(p, ix, n, dists[d]); BENCH_KEEP(r); printf("%9d %12.1f\n", dists[d], (double)(bench_ns() - t0) / (double)n); } 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.