Batching turns memory-bound into compute-bound
The compilation module made one core fast; the threads module used many. Today’s enemy is neither — it is the distance to your data.
▶ Notebook (demo + solution): Memory & GPU — one notebook for this deck and the GPU deck.
⬅ Previous: 3 — Threads & concurrency · Next: 5 — GPU ➡
| Level | Typical size | Latency | Analogy (if L1 = 1 s) |
|---|---|---|---|
| registers | ~a few hundred B | ~0 | instant |
| L1 | 32–64 KB / core | ~1 ns | 1 second |
| L2 | 256 KB–1 MB | ~4 ns | 4 seconds |
| L3 | 8–32 MB | ~15 ns | 15 seconds |
| RAM | 16–64 GB | ~100 ns | ~2 minutes |
We sum the same array over and over, growing its footprint from 8 KB to 64 MB.
The table says: L1 ≈ 1 ns per access, RAM ≈ 100 ns.
How much slower is the 64 MB version, per access?
(~100×? Write your number down.)
function repeated_sum(a, passes) # footprint = a, accesses ×passes
s = zero(eltype(a))
for _ in 1:passes
@inbounds @simd for i in eachindex(a)
s += a[i]
end
end
return s
end| footprint | ns / access | where it lives (this machine) |
|---|---|---|
| 8 KB | 0.049 | L1 |
| 4 MB | 0.128 | L2 (18 MiB) |
| 64 MB | 0.317 | RAM — spills the 24 MiB L3 |
The steps are real: you can see the hierarchy appear. But the cliff is 6.5×, not ~100× — why?
@simd sequential stream — the address pattern is perfectly predictable.The 100× latency is real — a predictable access pattern hides it. Random pointer-chasing has no pattern to prefetch: it pays the full ~100 ns per hop.
Remember this: the memory system rewards predictability. “Locality” is the code word for it.
Sum every element of an 8000×8000 matrix — two loop orders:
A[i,j] and A[i+1,j] are neighbours in memory.8000² Float64 ≈ 512 MB — far beyond any cache:
| walk | time | each 64 B cache line is… |
|---|---|---|
| columns (with the grain) | 30.4 ms | fully used: all 8 Float64 |
| rows (against it) | 361.4 ms | used for one element, then evicted |
Getting it wrong doesn’t give a wrong result — just silently slow code, which is sneakier.
If the gap were “Julia prefers columns”, it would show at any size. It doesn’t:
| size | footprint | rows / columns ratio |
|---|---|---|
| 100² | 78 KB — fits in cache | 0.9× — no gap at all |
| 4000² | 122 MB — spills | 7.2× |
The claim is not “columns are fast”. It is: the cache decides — and this experiment isolates it.
Same experiment in numpy, C-order vs F-order layout (8000²):
| numpy | ratio | why |
|---|---|---|
A.sum(axis=0), C vs F |
≈ 1× | nditer reorders the walk into memory order |
np.copyto(C, F) vs C, C |
13.2× (109.5 vs 8.3 ms) | a layout-mismatched copy cannot be reordered |
sum: someone wrote the reordering for you.The penalty is identical in both languages. numpy hides it; Julia lets you see it. Neither removes it.
How much work do you do per byte you load?
| operation (n×n, Float64) | FLOP | bytes loaded | intensity |
|---|---|---|---|
| mat × vec | 2n² | 8n² (all of W) | 0.25 FLOP/byte — constant |
| mat × mat | 2n³ | 8n² | n/4 FLOP/byte — grows with n |
⚠ Don’t compare their seconds — they do different amounts of work. The honest unit is GFLOP/s: work per second. One number, and it says by itself which resource is saturated.
W * x: every weight is loaded, used once, evicted. Memory-bound.X and W * X reuses each loaded weight B times. That is a mat×mat. Compute-bound.Batching is not a GPU trick. It changes the arithmetic intensity of the problem itself. If that’s true, it must already pay on the CPU — let’s measure it.
Dense layer W (4096 → 4096, Float32), B examples as columns:
| B | total | µs / example | GFLOP/s |
|---|---|---|---|
| 1 | 2.55 ms | 2551.8 | 13.1 |
| 8 | 5.65 ms | 706.0 | 47.5 |
| 64 | 11.06 ms | 172.9 | 194.1 |
| 256 | 39.06 ms | 152.6 | 219.9 |
| 1024 | 141.80 ms | 138.5 | 242.3 |
Batching pays on the CPU, today, no GPU in sight. The GPU deck pushes the same idea much further.
Per example: 2551.8 → 172.9 (B=64) → 152.6 (B=256) → 138.5 µs. The curve flattens. Why?
W is 4096² × 4 B = 64 MiB — it can never sit in the 24 MiB L3. Every batch must stream W from RAM: that is the fixed cost.Flattening is the success signal: the memory-bound → compute-bound conversion is complete. Past that point, bigger batches only add latency and memory — the classic serving trade-off.
copyto, sum(axis=0), batching): python/4_memory_and_gpu_python.ipynb⬅ Previous: 3 — Threads & concurrency · Next: 5 — GPU ➡