4 — Memory & batching

Batching turns memory-bound into compute-bound

Emmanuel Pilliat — ENSAI 3A

Goals

  • How long the CPU waits for memory.
  • Loop order can cost more than the maths.
  • Batching: more work per byte loaded, on the CPU.

Today’s enemy: the distance to your data.

▶ Script: 4_memory_and_gpu.jl (§1–3) · Notebook: Memory & GPU · 🐍 numpy (Colab)

⬅ 3 — Threads & concurrency · 5 — GPU ➡

Part A — the CPU waits for memory

The memory hierarchy

  • Add two floats: < 1 ns. Fetch from RAM: ~100 ns.
Level Size Latency If L1 = 1 s
registers ~100s B ~0 instant
L1 32–64 KB / core ~1 ns 1 s
L2 256 KB–1 MB ~4 ns 4 s
L3 8–32 MB ~15 ns 15 s
RAM 16–64 GB ~100 ns ~2 min

Caches bet on locality

  • Temporal: used now → used again soon.
  • Spatial: used now → neighbors next.
  • Memory moves in lines of 64 B = 8 Float64.

Fast code honors that bet.

Bet before you look 🎲

Sum the same array many times, from 8 KB to 64 MB.

L1 ≈ 1 ns, RAM ≈ 100 ns.

How much slower per access at 64 MB?

(~100×? Write it down.)

▶ Script: cell “1. The memory hierarchy — the cache cliff”

The cache cliff, measured

for _ in 1:passes                 # repeated_sum(a, passes)
    @inbounds @simd for i in eachindex(a)
        s += a[i]
    end
end
footprint ns / access lives in
8 KB 0.049 L1
4 MB 0.128 L2 (18 MiB)
64 MB 0.317 RAM (> 24 MiB L3)

Steps visible. But 6.5×, not ~100×. Why?

Why 6.5×? Bandwidth vs latency

  • Table = wait for one unpredictable access (latency).
  • Our loop reads in order → hardware loads next lines early.
  • We pay the flow rate (bandwidth), not the wait.

100× is real when the next address is unpredictable (linked list, random indices).

Two walks, same maths

Sum an 8000×8000 matrix, two loop orders:

# COLUMN by column
for j in axes(M, 2)
    for i in axes(M, 1)
        s += M[i, j]
    end
end
# ROW by row
for i in axes(M, 1)
    for j in axes(M, 2)
        s += M[i, j]
    end
end
  • Julia: column-major (M[i,j], M[i+1,j] adjacent).
  • numpy: row-major. Your reflexes are reversed.

▶ Script: cell “2. Locality — the same sum, walked two ways”. Predict first.

11.9× for swapping two loops

8000² Float64 = 512 MB ≫ cache:

walk time each 64 B line…
columns 30.4 ms fully used (8 values)
rows 361.4 ms one value, then evicted
  • Same result: 11.9× slower.
  • Rule: first index in the inner loop.

Wrong order: right result, silently slow.

Prove it’s the cache 🔬

If Julia just “preferred columns”, the gap would show at any size.

Shrink the matrix. When does the gap vanish?

▶ Script: cell “Proof it IS the cache”

Prove it’s the cache — debrief

size footprint rows / columns
100² 78 KB — fits 0.9×
4000² 122 MB — spills 7.2×
  • Gap appears when data outgrows the cache.

Not “columns are fast”: the cache decides.

numpy hides it, doesn’t remove it

C-order vs F-order arrays, 8000²:

numpy ratio why
A.sum(axis=0) ≈ 1× numpy reorders
copy F→C / C→C 13.2× (109.5 / 8.3 ms) can’t reorder a copy

numpy hides it; Julia shows it. Neither removes it.

Allocating is memory traffic too

z = 2 .* x .+ y      # NEW 76 MiB array
z .= 2 .* x .+ y     # reuses z

▶ Script: cell “Allocating is memory traffic too”

n = 10⁷ time allocated
z = … 54 ms 76 MiB
z .= … 17 ms 0 bytes

One dot: ~3×. But measure: the gain isn’t always there.

Part B — batching: memory-bound → compute-bound

Arithmetic intensity: FLOP per byte

n×n, Float64 FLOP bytes intensity
mat × vec 2n² 8n² 0.25 — constant
mat × mat 2n³ 8n² n/4 — grows
  • Low: compute waits for loads → memory-bound.
  • High: each value reused → compute-bound.

Compare GFLOP/s, not seconds.

▶ Script: cell “3. Arithmetic intensity”

Batching = mat×vec → mat×mat

  • A layer = matrix product. W * x: each weight used once.
  • B examples as columns of X: W * X reuses each weight B times.
y = W * x     # 1 example
Y = W * X     # B examples

Not a GPU trick: it raises intensity. Must pay on the CPU.

Batching on the CPU: bet 🎲

One layer W (4096² Float32), B examples as columns.

What happens to the time per example?

▶ Script: cell “Batching: one layer W, B examples”

Batching, measured on the CPU

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

18.4× per example, 13.1 → 242.3 GFLOP/s. No GPU.

Why B = 1024 barely helps any more

2551.8 → 172.9 (B=64) → 152.6 (B=256) → 138.5 µs: flattens.

  • W = 64 MiB > 24 MiB L3: a fixed RAM stream per batch.
  • Batching divides it by B:

\[\text{time per example} \approx \text{compute} + \frac{\text{stream } W}{B}\]

  • B ≈ 256: second term tiny.

Flat = success: now compute-bound.

Going further

⬅ Previous: 3 — Threads & concurrency · Next: 5 — GPU ➡