BookNotesSlidesP-sets Syllabusdraft · v0.2.49.1
◀▲⌂▶
💬Comments welcome. To leave a note, select any text and click the note / highlight button that pops up — or open the panel with the tab at the top-right (‹). Notes are visible only inside our private review group.
💡 In a hurry? Jump to this chapter’s 4 big lessons ↓

29.2 Modern CPUs: memory hierarchy, parallelism, and what it takes to go fast⧉

A blur spends time both computing weighted sums and moving pixels. The same formula can run very differently when each intermediate is stored as a full image, computed in small tiles, or recomputed where it is needed. Understanding that difference requires distinguishing arithmetic throughput, dependency latency, and memory traffic.

A processor may start several independent multiply-add instructions per cycle even though each result takes several cycles to become available. A dependent load from DRAM can take far longer, but many independent loads can overlap. Comparing “one cycle per multiply” with “hundreds per load” mixes throughput with latency and does not predict the cost of streaming an image.

💡 Big lesson (L29.1) — moving data can cost more than arithmetic

For a kernel limited by data movement, removing intermediate traffic can help more than removing arithmetic. First establish the bottleneck: expensive functions, long dependency chains, and dense numerical kernels can still be limited by computation.

Browser experiments can illustrate these effects under the browser's execution model. They do not recover a processor specification. Reported hardware concurrency may be capped; timings include compiler, runtime, operating-system, and thermal effects.

fig-machine-report
Figure 29.2.1. What your browser will admit about your processor. The few facts a page can query, each with its caveat: logical cores (hardware threads, not cores, and privacy modes report 2 whatever the machine has), a memory size quantized to a power of two, an architecture string on Chromium only, whether WebAssembly SIMD is available (a flag, not a width: WebAssembly vectors are 128 bits wide whatever the silicon has), and the resolution of the clock, which is measured rather than reported. Cache sizes, cache-line size, vector width and bandwidth appear nowhere, which is why the rest of this chapter's live figures measure them.

29.2.1 Why moving data, not doing math, is the bottleneck⧉

Memory latency is the delay for an individual access; bandwidth is the volume transferred per second. Independent requests can overlap latency, but their total traffic still consumes bandwidth. Patterson describes the historical divergence between latency, bandwidth, and computation Patterson 2004.

Suppose a point operation reads one 32-bit sample and writes one 32-bit result. Its minimum explicit traffic is eight bytes per sample. At a sustained 20 GB/s across the relevant memory interface, that gives an upper bound of 2.5 billion samples/s before extra traffic or other limits. Write allocation, additional inputs, and temporary buffers can raise the byte count. Cache reuse can lower traffic at DRAM without reducing the number of instructions that load from cache.

Data movement also costs energy. Horowitz's technology-specific estimates Horowitz 2014 illustrate how an off-chip access can cost much more than a simple arithmetic operation. The ratio depends on technology, operation, precision, and memory level; it is not a universal camera power budget.

fig-flops-vs-bytes-cost
Figure 29.2.2. Why we count bytes, not FLOPs. A bar chart, on a log scale, of the approximate cost of operating on one 32-bit word: a floating-point multiply-add (~1 cycle, a fraction of a picojoule) beside the cost of fetching that word from each level of memory — register, L1, L2, L3, DRAM — rising to ~200 cycles and roughly a thousand times the energy at the DRAM end. The arithmetic bar is a sliver; the DRAM bar dwarfs it. The picture is the lesson: the expensive part of "compute on a pixel" is getting the pixel to the compute unit, which is why optimization targets data movement first. (Schematic; representative numbers, not a benchmark of a specific chip.)

29.2.2 The memory hierarchy and locality⧉

Registers hold the values instructions operate on. Caches keep copies of recently used memory close to a core; larger shared caches and DRAM provide progressively more capacity, usually at higher latency. Sizes, sharing, and speeds vary. There is no fixed tenfold ratio between levels.

Caches transfer data in lines, commonly 64 bytes on many CPUs, though other sizes exist. Reading adjacent values uses more of each fetched line: spatial locality. Reusing values before eviction provides temporal locality. Cached accesses still consume load/store bandwidth and take time; locality makes them cheaper, not free.

Inspect the actual image strides. In a row-major planar array, adjacent horizontal samples are contiguous. In interleaved RGB, channels and pixels have different strides. Views, crops, and padding can change the layout. Prefer an inner loop that follows contiguous storage; a column sweep may repeatedly access distinct lines before returning to useful neighbors.

fig-memory-hierarchy
Figure 29.2.3. The memory hierarchy: why locality dominates. The classic pyramid: registers at the apex, then L1, L2, L3 cache, then DRAM, then SSD/network at the base, each band annotated with its typical size, latency (in cycles and nanoseconds), and bandwidth. Each level is roughly 10× bigger and slower than the one above, with a ~100× cliff at the DRAM boundary. Beside it, a 64-byte cache line drawn as a row of pixels: read one and you get all sixteen, so neighboring (spatial-locality) and reused (temporal-locality) data come nearly free, while a strided or random access wastes most of every line. (Schematic; representative numbers.)

A randomized dependent pointer chase limits overlap between loads and can expose changes as its working set grows. Page translation, prefetching, cache conflicts, and runtime effects also influence the curve, so a timing step is evidence of a transition rather than an exact cache-capacity measurement. On the laptop this chapter was written on, the first step appears near 128 KB, consistent with that chip's L1 data cache, and the far end settles near 100 ns, about sixty times the L1 access it started from. Those numbers describe one machine in one browser session; the figure measures yours.

fig-machine-latency
Figure 29.2.4. Where your caches end, measured here. Time for one dependent load against the size of the buffer being walked, from 8 KB to 96 MB, on the reader's own processor. Each load's address comes from the previous load, so the hardware can neither prefetch nor overlap them and what is left is pure latency. The flat stretches are cache levels and the steps between them are the capacities; the labels are read off the measured curve, not assumed.

Compare row, column, and scattered access on the same data and arithmetic. Consume the result so the compiler cannot remove the work. Separate any permutation-generation overhead from the timed access loop. On the machine this was written on, the column sweep took about five times as long as the row sweep and the scattered order longer still, the difference spent fetching 64-byte lines to use four bytes of each.

fig-machine-pattern
Figure 29.2.5. The same pixels, three orders, timed in the reader's browser. Summing a 16 MB image along rows (consecutive addresses, so every float of each cache line is used: sixteen per line if lines are 64 bytes), down columns (the loop reads four adjacent columns at a time, so each line fetched serves four reads rather than sixteen), and scattered (usually a new line, and often a new page, per read). The arithmetic is identical in all three; the effective bandwidth is not, and the ratio between the first and the last is the cost of ignoring the cache line. Whether the misses go all the way to DRAM depends on the machine's caches. A static copy of this figure shows one saved measurement, not the reader's computer.

The row/column experiment is useful because the numerical answer stays the same. A row sweep consumes adjacent floats from a fetched line. A column sweep jumps by the row stride, so the other samples on that line may be evicted before they are used. Scattered access also makes upcoming addresses difficult to predict. Changing a loop order has altered how useful each memory transfer is, without removing a single addition. On a small image that remains in cache, the penalty can shrink; enlarging the image tests whether capacity is what made the first case forgiving.

The dependent-load experiment asks a different question. Each load supplies the address for the next, so the processor cannot start that next load until the preceding one finishes. A long plateau followed by a rise as the buffer grows suggests that a faster storage level no longer holds the working set. An ordinary image sweep can overlap loads and exploit prefetching, so multiplying this pointer-chase latency by the number of image pixels would predict the wrong running time.

Tiling processes a small region through several stages while useful intermediates remain in cache. A stencil needs a halo around each output tile; adjacent tiles may recompute overlapping halo values. The useful trade is less long-distance traffic for some extra work and scheduling overhead. Include all live intermediates and halos when estimating the working set.

💡 Big lesson (L29.2) — tiling: fit the working set in cache, reuse every byte

Choose tiles large enough to amortize overhead and small enough to retain useful data. Include halo storage and concurrent workers. Tiling helps when it improves reuse; it can add work without benefit to a simple streaming operation.

fig-tiling-locality
Figure 29.2.6. Tiling keeps the working set in cache. Two ways to run a two-stage filter (blur, then blur again). Left, untiled: stage 1 sweeps the whole image and writes a full-image intermediate out to DRAM; stage 2 reads it all back — every pixel makes a needless round-trip to main memory (red arrows long, to DRAM). Right, tiled: the image is cut into small tiles, and both stages run on one tile before the next; the intermediate lives and dies in L1/L2 cache, never reaching DRAM (red arrows short, within cache). Same FLOPs, far fewer bytes moved — the routine several-fold speedup that comes from locality alone. (Schematic.)

Pointwise stages can often fuse directly without a stored intermediate. Stencil stages need neighborhoods and may benefit from tile or row buffers instead. Test these arrangements at several thread counts: a memory interface that serves one worker comfortably may become the shared limit for many workers.

fig-machine-fusion
Figure 29.2.7. Where the intermediates live, measured under contention. A four-stage chain over a 1024×1024 image, run with image-sized intermediates, with strip-sized intermediates that stay in cache, and with no intermediates at all (each pixel carried through all four stages in a register), on 1 thread and then on up to every core the machine has. Identical arithmetic throughout. Single-threaded the three nearly coincide; with every core running, the version that round-trips its intermediates through memory is several times slower, because bandwidth is the shared resource. A control raises the arithmetic per stage, and the three curves converge again once the stages are expensive enough that the machine is no longer waiting for memory.

Consider the four pointwise stages in Figure 29.2.7. A straightforward implementation makes four full-image passes, leaving a complete image between each pair. A strip implementation passes a small group of rows through all four stages before advancing. A fully fused implementation carries one pixel through the four formulas in a register and writes only the final result. The formulas are identical; the lifetime and location of their intermediate values differ.

Run this comparison with one worker and then with many. If one worker cannot saturate memory bandwidth, the full-image passes may look surprisingly competitive. Several workers sharing the same interface can expose the cost of their extra traffic: adding cores raises the demand for bytes without raising the interface's capacity. Increasing the arithmetic per stage can make the curves converge again because computation now occupies more of the elapsed time. These changes help distinguish a bandwidth explanation from a claim that fusion must always be faster.

For example, compare two stages that each apply a three-tap horizontal filter. Computing the first stage over the full image and then the second requires a full intermediate. To produce 64 consecutive final pixels in a tile, the second stage instead needs 66 intermediate pixels, and those intermediates need 68 input pixels. The two extra intermediate values are the halo. A neighboring tile recomputes some of them, but most intermediate accesses now refer to a small region that can remain in cache.

Increasing the tile size reduces the fraction of halo work but increases live storage. Decreasing it does the reverse and may add task overhead. Include all simultaneously live channels and stages when estimating the working set; a tile dimension by itself does not guarantee a cache hit. The benefit is avoided transfers when the original intermediate would have exceeded cache capacity, not a universal speedup from introducing tile loops.

With a 64-byte cache line and 32-bit samples, one line holds sixteen samples. A scan along contiguous samples can use all sixteen after bringing in that line. A widely strided scan may use only one before moving elsewhere. The unused samples still occupied transfer bandwidth and cache space. Returning to a sample before eviction gives temporal reuse; reading its neighbors gives spatial reuse. Neither eliminates the cost of loads, but both can reduce traffic to a slower level of the hierarchy. Array strides tell you which loop order offers that opportunity.

29.2.3 The roofline: is my kernel compute- or memory-bound?⧉

The roofline model Williams et al. 2009 compares the work done with bytes transferred across a specified memory interface. Let $F$ be the operation count, $Q$ the byte count, and $\mathcal I=F/Q$ the arithmetic intensity. If sustained bandwidth is $B$ and the relevant compute ceiling is $P_{\max}$, then

$$P\leq\min(P_{\max},B\mathcal I),\qquad T\geq\max\left(\frac{F}{P_{\max}},\frac{Q}{B}\right).$$

The first bound is a rate; the second is a minimum time. Count a floating-point multiply-add as two operations when using conventional FLOP/s. The bandwidth term limits low-intensity work. The compute term limits sufficiently high-intensity work. The crossover is $\mathcal I=P_{\max}/B$.

fig-roofline
Figure 29.2.8. The roofline: one plot that tells you what to optimize. Attainable performance (GFLOP/s, vertical, log) versus arithmetic intensity (FLOPs per byte moved, horizontal, log). A slanted roof = bandwidth × intensity rises from the left until it meets the flat roof = peak compute. A few imaging kernels are plotted as dots: a point op and a 3×3 blur (low intensity) sit far left under the bandwidth roof — memory-bound, so only moving fewer bytes helps; a large matmul / convolution (high intensity) sits right, up against the compute roof — compute-bound, so vectorization helps. Which side of the ridge point your kernel falls on decides which optimization can possibly work. Williams et al. 2009 (Schematic plus a few representative kernel dots.)

These are bounds, not predictions that a kernel reaches either ceiling. A serial dependency, poor vectorization, branch behavior, launch overhead, or insufficient concurrent memory requests can leave it far below both. A kernel on the nominal memory side can still benefit from vectorization if its scalar code never generated enough traffic to reach the bandwidth limit.

A microbenchmark can estimate attainable rates for an execution environment. Use independent arithmetic chains to measure throughput; repeatedly updating one value can measure dependency latency instead. Choose working sets deliberately and distinguish warm cache traffic from DRAM traffic. On the laptop here, WebAssembly gave a compute roof near 12 GFLOP/s and a streaming bandwidth near 36 GB/s, so a ridge at about 0.34 FLOPs per byte. The execution environment moves that ridge: native code with the chip's full vector width raises the compute roof far more than the bandwidth roof, which slides the ridge right and pushes kernels that looked compute-bound back into the memory-bound camp.

fig-machine-roofline
Figure 29.2.9. Your roofline, swept live. Values are streamed from a 32 MB buffer and given a controlled number of multiply-adds each, from one to thirty-two, so the kernel walks from the memory-bound end of the arithmetic-intensity axis to the compute-bound end. The measured points sit under the lower of the two roofs. The compute roof, the streaming bandwidth and the ridge point are fitted to this kernel's own measurements on the reader's machine, in the reader's browser: an envelope for this kernel in this runtime, not an independently measured hardware peak, and not a data-sheet number.

29.2.4 The forms of parallelism⧉

Instruction-level parallelism overlaps independent operations within a core. Data dependencies constrain it: ten independent accumulators expose more overlap than ten updates to the same accumulator.

SIMD applies an operation to several lanes of a vector instruction. For 32-bit floats, 128, 256, and 512 bits hold four, eight, and sixteen lanes. The instruction set and enabled target determine the available widths. Contiguous access and independent iterations make vectorization easier; gathers, masks, and reductions permit other patterns at additional cost.

fig-machine-simd
Figure 29.2.10. What a vector unit buys on two kinds of kernel. The same C compiled to WebAssembly twice, with SIMD128 enabled and disabled, run in the reader's browser: a polynomial on 32 KB of data that fits in L1, and a sum over 16 MB that does not. Four lanes buy several times on the compute-bound kernel and almost nothing on the memory-bound one. Four lanes rarely buy exactly 4×, because the scalar build already gets part of the way by issuing independent instructions in parallel.

The two SIMD tests separate benefits that are easily confused. The polynomial repeatedly computes on a 32 KB working set; the streaming sum visits 16 MB. Four float lanes offer the polynomial more arithmetic throughput. For the sum, reducing the number of arithmetic instructions helps only until fetching the samples becomes the limiting rate. The original measurements recorded about 2.7× for the polynomial and 1.4× for the sum with four-lane WebAssembly SIMD. Those are examples from the manuscript's measurement session, not promises for the reader's processor. The experiment above lets you test the distinction again.

Even for the polynomial, four lanes need not give a fourfold speedup. The scalar build can overlap independent instructions, the vector build still has loop and load overhead, and the two compilations may make different optimization choices. Compare generated work as well as vector width. A small SIMD gain on a streaming kernel can be evidence that scalar execution already approaches the available bandwidth.

Threads expose larger independent tasks, such as tiles. Physical cores may run them concurrently; simultaneous hardware threads share parts of a core. Scaling can stop at memory bandwidth, synchronization, load imbalance, or task overhead. Avoid having workers write different values in the same cache line, which can cause false sharing.

fig-machine-cores
Figure 29.2.11. How many cores actually help, estimated two ways. Aggregate throughput against thread count, estimated from workers that are each calibrated separately (a weak-scaling estimate, not a synchronized batch run), for arithmetic that stays in each core's cache and for a sweep that has to come from memory, against a dashed line for perfect scaling. The first curve indicates the parallelism available, which is rarely the count the browser reports: hardware threads share cores, efficiency cores are slower, and other tabs are running. The second flattens, or turns down, once memory is saturated, with cores left idle.

The core-scaling experiment makes the same comparison at a larger granularity. In the cache-resident case, each worker has its own small buffer and mostly uses resources local to a core. In the streaming case, each worker sweeps 16 MB and competes for a shared supply of data. Plot aggregate completed work per second, rather than assuming that the number of workers is the speedup.

In the original manuscript's sixteen-thread laptop measurement, cache-resident arithmetic scaled by about nine, while streaming throughput peaked around eight threads and then decreased. The recorded numbers illustrate a possible outcome; the manuscript did not preserve enough machine configuration to make them a reproducible processor benchmark. The interpretation remains useful when reading your own curves: shared execution resources, unequal core speeds, and overhead can keep the arithmetic curve below ideal scaling, while a flat streaming curve says that additional workers are failing to obtain more data per second. More threads can then add contention without increasing useful throughput.

GPU SIMT groups threads for execution and combines multithreading with caches and other local storage; it does not replace caching altogether. Distributed execution adds communication among machines. Asynchronous execution can overlap independent transfers and computation, provided buffers remain valid and dependencies are synchronized.

💡 Big lesson (L29.3) — Amdahl vs Gustafson

For fixed work, an idealized speedup on $p$ workers is $1/[s+(1-s)/p]$, where $s$ is the serial fraction of the one-worker time Amdahl 1967. A 5% serial fraction caps that speedup at 20. Gustafson considers growing the workload with the worker count Gustafson 1988. These are different assumptions; neither eliminates communication, bandwidth limits, or parallel overhead.

fig-parallelism-ladder
Figure 29.2.12. The forms of parallelism, fine to coarse. Five panels side by side, each with a one-line note on the imaging work that fits it. (1) ILP — overlapping pipeline stages within one core (the CPU finds it; help it with independent work). (2) SIMD — one instruction over N vector lanes (8–16 pixels at once; the inner-loop win). (3) Multicore threads — image tiles handed to different cores (near-linear for embarrassingly parallel work, until memory bandwidth saturates). (4) SIMT — thousands of GPU threads in lockstep groups (uniform per-pixel work → Chapter 29.6, Hardware backends: GPU, NPU, DSP). (5) Distributed — across machines, communication-bound (training, huge batches). (Schematic.)

Amdahl’s law adds a limit even before memory contention: work that remains serial cannot be divided among more cores. If one tenth of the original execution time remains unchanged, accelerating the rest without bound still gives at most a tenfold overall speedup. Thread launch, synchronization, and imbalance can reduce that gain further. This is why a fast parallel inner loop is not enough; measure the fraction of the complete pipeline it actually accelerates.

29.2.5 Measuring and improving a kernel⧉

Start from a release build, a representative input, and a correctness check. Profile to find expensive stages, then test a specific explanation: too much traffic, poor reuse, limited vectorization, a serial reduction, or too little work per task. Change one factor and measure again.

Benchmark both the kernel and the complete path that matters to the application. For steady-state timing, warm up JIT compilation and allocate reusable buffers beforehand; report cold-start compilation and allocation separately when they affect the user. Synchronize asynchronous work before stopping the timer. Run enough repetitions for stable measurements, consume outputs, and report variation as well as a central value. Record hardware, compiler, target flags, dimensions, precision, thread count, and whether inputs were cache-resident. Sustained camera workloads also need thermal steady-state measurements.

fig-machine-probe
Figure 29.2.13. The machine probe: this chapter, measured on the reader's processor. Seven panels behind tabs, the smaller figures above being single panels of the same page. The kernels are compiled from C to WebAssembly ahead of time, twice, with and without vector instructions, and run in workers, so the numbers come from real compiled code rather than from JavaScript timing itself. Nothing is timed once: the browser deliberately coarsens its clock, so every kernel is repeated until the batch is long enough for that not to matter. In print or any static copy, the panels show one saved example measurement, not the reader's computer.
💡 Big lesson (L29.4) — separate the algorithm from its execution schedule

The algorithm specifies the result; the schedule controls loop organization, parallelism, storage, and recomputation. Changing the schedule can change both traffic and executed arithmetic, especially at tile halos. Halide makes these choices explicit so you can compare them without obscuring the algorithm.

A profile becomes useful when it identifies a mechanism. False sharing, for example, means workers update different values on one cache line, causing coherence traffic between cores even though the program shares no individual value. Padding or assigning contiguous disjoint regions can change that traffic. Load imbalance is different: one worker receives more work and holds up completion. More threads do not repair either problem automatically. They need changes to data layout or task assignment.

29.2.6 Why photography is hard for the machine⧉

A pipeline couples many stages, often with neighborhoods, reductions, pyramids, and branches to several consumers. A 12-megapixel RGB float image alone occupies about 144 MB, before intermediates or burst frames. Whether that fits in cache depends on the machine, but storing many such intermediates is expensive.

The scheduling problem is to keep useful regions available while supplying enough independent work. Materializing a producer once avoids recomputation but retains its storage; inlining it avoids a buffer but may repeat its work at every use. Tiling can compromise between them. This producer-consumer trade is central to Halide [@ragan-kelley-etal-2012; @ragan-kelley-etal-2013].

The difficult choice is often across stages rather than within one loop. A producer may feed both a pyramid and a full-resolution branch, so storing its output helps reuse but lengthens its lifetime. Fusing it into one consumer can require recomputing it for the other. Small tiles improve locality yet increase overlapping halo work and scheduling overhead. Large tasks expose less parallelism. These interacting choices explain why the schedule deserves its own representation instead of being buried in a single hand-written loop nest.

29.2.7 Keep the comparison valid⧉

Compare candidate changes at the same output contract: boundary rules, precision, and acceptable numerical error. After an optimization, run the checks in Chapter 9.1, Developing, testing and debugging and inspect image differences. An apparent speedup from omitted work, an unfinished GPU dispatch, or a changed approximation does not measure the same implementation task.

Recap: big lessons of this chapter

💡 Big lesson (L29.1) — moving data can cost more than arithmetic

For a kernel limited by data movement, removing intermediate traffic can help more than removing arithmetic. First establish the bottleneck: expensive functions, long dependency chains, and dense numerical kernels can still be limited by computation.

💡 Big lesson (L29.2) — tiling: fit the working set in cache, reuse every byte

Choose tiles large enough to amortize overhead and small enough to retain useful data. Include halo storage and concurrent workers. Tiling helps when it improves reuse; it can add work without benefit to a simple streaming operation.

💡 Big lesson (L29.3) — Amdahl vs Gustafson

For fixed work, an idealized speedup on $p$ workers is $1/[s+(1-s)/p]$, where $s$ is the serial fraction of the one-worker time Amdahl 1967. A 5% serial fraction caps that speedup at 20. Gustafson considers growing the workload with the worker count Gustafson 1988. These are different assumptions; neither eliminates communication, bandwidth limits, or parallel overhead.

💡 Big lesson (L29.4) — separate the algorithm from its execution schedule

The algorithm specifies the result; the schedule controls loop organization, parallelism, storage, and recomputation. Changing the schedule can change both traffic and executed arithmetic, especially at tile halos. Halide makes these choices explicit so you can compare them without obscuring the algorithm.