Introduction
Hello! In our last lesson, we saw how unpredictable branches in our code can wreak havoc on CPU pipeline efficiency, causing frequent, costly pipeline flushes. We discovered that code with predictable patterns runs much faster because the CPU's branch predictor can guess correctly most of the time.
This lesson builds directly on that foundation. We'll move from understanding hardware bottlenecks to actively restructuring our code to mitigate them. The learning outcome for this lesson is to apply loop interchange and unrolling to improve cache utilization and instruction scheduling.
We will explore two powerful, compiler-friendly optimization techniques:
- Loop Interchange: A method for reordering nested loops to make memory access patterns more cache-friendly.
- Loop Unrolling: A technique for expanding a loop's body to reduce pipeline stalls and overhead.
By the end of this lesson, you'll understand how to manually apply these transformations to C++ code and, more importantly, you'll grasp why they work by connecting them back to the principles of cache locality and instruction-level parallelism.
Loop Interchange: Optimizing for Cache Locality
In the module on memory hierarchy, we established that main memory (RAM) is slow, and CPUs rely on small, fast caches to hide this latency. We also learned about spatial locality: the principle that if a program accesses a memory location, it's likely to access nearby locations soon. To exploit this, CPUs fetch data from RAM in chunks called cache lines (typically 64 bytes).
The key to good performance is to access memory in a way that makes full use of each cache line fetched. For C++ code, this means remembering one crucial fact: multi-dimensional arrays are stored in row-major order.
This image shows a 2D array and how it's flattened into linear memory. In row-major order, used by C/C++, all elements of a row are contiguous. Accessing A[0][0], A[0][1], A[0][2]... is a sequential memory access (stride of 1), which is ideal for the cache.
When a loop accesses memory with a large, non-unit stride, it "jumps" through memory, fetching a new cache line for each access and using only one element from it. This is incredibly inefficient. Loop interchange is the technique of reordering nested loops to ensure the innermost loop iterates with a stride of 1, maximizing cache line utilization.
A Classic Example: Matrix Multiplication
Matrix multiplication is the canonical example for demonstrating loop interchange. Consider the following naive implementation: C = A * B.
// Naive i-j-k loop order
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
for (int k = 0; k < N; k++) {
// C[i][j] += A[i][k] * B[k][j];
}
}
}
Let's analyze the access pattern in the innermost loop (the k loop):
C[i][j]: Constant within thekloop. Excellent locality.A[i][k]:kis the fastest-changing index, and it's in the last dimension. This access is sequential (row-major). Excellent spatial locality.B[k][j]:kis the fastest-changing index, but it's in the first dimension. Each increment ofkjumpsNelements forward in memory. This is a non-unit stride access. Terrible spatial locality.
To fix this, we can interchange the j and k loops.
// Optimized i-k-j loop order
for (int i = 0; i < N; i++) {
for (int k = 0; k < N; k++) {
for (int j = 0; j < N; j++) {
// C[i][j] += A[i][k] * B[k][j];
}
}
}
Now let's analyze the new innermost loop (the j loop):
A[i][k]: Constant within thejloop. Excellent locality.C[i][j]:jis the fastest-changing index, and it's in the last dimension. Sequential access. Excellent.B[k][j]:jis the fastest-changing index, and it's in the last dimension. Sequential access. Excellent.
By simply swapping the order of two loops, we've transformed one of our memory accesses from cache-hostile to cache-friendly. The performance impact can be dramatic.
C++ cache locality and branch predictability
This video from mCoding provides a clear demonstration of this exact matrix multiplication scenario. It shows the C++ code for different loop orders and the resulting performance difference.
Please watch the segment from 04:32 to 07:03. Pay close attention to the reasoning about why the i,k,j order results in contiguous memory access for all three matrices in the inner loop, and observe the significant performance gain shown in the final benchmark graph.
To reinforce this concept with more data and a slightly different perspective, the following article provides a written explanation and includes performance stats from the perf tool, which clearly shows the reduction in cache misses.
Clean Code in the Eyes of Compiler Engineers (1): Loop Interchange
This blog post from OpenEuler offers a great written explanation of loop interchange, starting from the basics of locality and memory layout.
Read sections 1 and 2 of the article. Focus on how it connects row-major storage to the matrix multiplication example. Note the perf stat output, which shows the L1-dcache-load-misses dropping from ~30% to ~3% after the optimization.
Loop Unrolling: Optimizing for Instruction Scheduling
While loop interchange optimizes for the memory system, loop unrolling primarily optimizes for the CPU's pipeline.
As we've learned, modern CPUs can execute multiple instructions at once (instruction-level parallelism), but they are often limited by data dependencies and loop control logic. A typical loop has a very small body, which doesn't give the instruction scheduler much to work with.
Loop unrolling involves replicating the body of the loop and adjusting the loop control variables accordingly.

The main benefits of loop unrolling are:
- Increased Instruction-Level Parallelism (ILP): A larger loop body contains more instructions. If these instructions are independent of one another (e.g., they operate on different data), the CPU's scheduler can issue them in parallel to different execution units, hiding latencies and increasing throughput.
- Reduced Loop Overhead: Fewer iterations mean fewer increment/decrement instructions, fewer conditional tests, and fewer branches. This not only saves instructions but also reduces the potential for branch mispredictions.
The video below explains this concept in detail, going down to the assembly level to show how unrolling helps resolve pipeline stalls.
This lecture by Prof. Dr. Ben H. Juurlink provides an excellent, in-depth look at loop unrolling.
Please watch from the beginning to 09:50, and then the summary from 11:05 to 12:34. Focus on: The definition of loop unrolling. How to handle loop counts that are not a multiple of the unrolling factor (the main unrolled loop plus a 'cleanup' loop). The critical insight: how unrolling, combined with register renaming, allows the scheduler to reorder instructions to eliminate stalls caused by data dependencies. The summary of pros (reduced overhead, better scheduling) and cons (code size, register pressure).
As the video highlighted, loop unrolling is not a free lunch. The main drawbacks are:
- Increased Code Size: A larger binary can lead to more instruction cache misses if the loop body becomes too large.
- Increased Register Pressure: The unrolled loop needs more registers to hold the intermediate values of the multiple operations happening in parallel. If the CPU runs out of registers, it has to "spill" them to the stack (memory), which can negate the performance gains.
Choosing the right unrolling factor is a balancing act, and modern compilers are often quite good at performing this optimization automatically. However, manual unrolling can be beneficial in performance-critical code where you have specific knowledge about the hardware target.
Synthesis: Combining Interchange and Unrolling
Loop interchange and loop unrolling are not mutually exclusive. A very common and effective optimization strategy is to first use loop interchange to improve cache locality, and then use loop unrolling on the new, cache-friendly innermost loop to improve instruction throughput.
Let's return to our i-k-j matrix multiplication. The innermost loop is now:
// Innermost loop of the i-k-j version
for (int j = 0; j < N; j++) {
r[i][j] = r[i][j] + a[i][k] * b[k][j];
}
We can unroll this j loop by a factor of 4:
// Innermost j-loop, unrolled by 4
for (int j = 0; j < N; j+=4) {
r[i][j+0] = r[i][j+0] + a[i][k] * b[k][j+0];
r[i][j+1] = r[i][j+1] + a[i][k] * b[k][j+1];
r[i][j+2] = r[i][j+2] + a[i][k] * b[k][j+2];
r[i][j+3] = r[i][j+3] + a[i][k] * b[k][j+3];
}
In this unrolled version, a[i][k] can be loaded into a register once and reused four times. The four r[i][j+...] updates are independent of each other and can be aggressively scheduled by the CPU. This combines the benefits of both techniques.
The following resource provides a summary of these techniques and their benefits.
[PDF] Performance and Optimisation - Jan Thorbecke
These slides from a High-Performance Computing course provide concise summaries and examples of the optimizations we've discussed.
Please review the content on the following slides: Slides 25-29: Review the Loop interchange section. Notice the clear diagrams showing access patterns and the C example which confirms our previous findings. Slides 34-36: Review the Loop Unrolling section for another definition and example. Slides 107-110: This is the key synthesis. Look at the Matrix multiply & unrolling inner example, which is exactly the combination we just discussed. Slide 83: The Optimization Benefits table provides a perfect summary of how each technique impacts instruction and memory performance.
Conclusion
In this lesson, we moved from theory to practice, learning two fundamental loop optimization techniques that make your code work with the hardware, not against it.
Key Takeaways:
- Loop Interchange reorders nested loops to make the innermost loop access memory sequentially (unit stride). This maximizes spatial locality and dramatically improves cache hit rates. It primarily targets the memory system.
- Loop Unrolling expands the body of a loop to reduce overhead and, more importantly, to increase the number of independent instructions available. This allows the CPU's instruction scheduler to better exploit Instruction-Level Parallelism (ILP). It primarily targets the CPU's pipeline and execution units.
- A powerful strategy is to combine these techniques: first, use interchange to get the memory access right, then use unrolling on the new inner loop to improve pipeline throughput.
- Both techniques have trade-offs. Unrolling increases code size and register pressure, so the unroll factor must be chosen carefully.
Preview of the Next Lesson
We have now learned how to optimize for a single level of the cache. But modern systems have a deep memory hierarchy (L1, L2, L3 caches). What if our data is too large to fit in L1, but fits in L2? Simple loop interchange and unrolling might not be enough. In our next lesson, we will tackle this problem by learning about loop tiling (or blocking), an advanced technique that optimizes for multiple levels of the memory hierarchy at once.