Loopsimplementation

Fusion, Fission, Interchange and Tiling

Four restructurings that leave the computation identical and change the order in which memory is touched. Each buys a specific thing — fewer traversals, better vectorizability, unit-stride access, a working set that fits in cache — and each is legal only when it preserves every dependence in the original.

The question

The loop does the same arithmetic either way. Why does the order of the loops change the running time by an order of magnitude?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

A loop nest with a dependence graph over its iterations: for each pair of statements, whether one must execute before the other, and across how many iterations of which loop. That graph is what these transformations are checked against — the loop nest alone does not say which reorderings are safe, and no amount of looking at the code substitutes for computing it.

What this phase may assume or do

A reordering is legal if and only if it preserves the direction of every dependence. Concretely: after the transformation, for every pair of iterations where one wrote a location the other reads or writes, the writer must still execute first. Interchange is legal when no dependence has a direction that would be reversed by swapping the loops; fusion is legal when the second loop does not depend on a later iteration of the first; fission is legal when the statements being separated have no cycle of dependences between them; tiling is legal when the loops being tiled are individually permutable, which is the same condition as interchange applied pairwise.

Key points

  • All four transformations leave the arithmetic identical and change only the order of memory access — which on a real machine is most of the running time.
  • Legality is decided by the dependence graph: a reordering is legal exactly when it reverses no dependence direction.
  • Interchange for unit-stride access is the single largest effect, and it is language-dependent because row-major and column-major layouts want opposite nestings.
  • Fission is most valuable as an enabler: splitting off the statements that defeat the vectorizer lets the rest vectorize.
  • Tiling reduces memory traffic rather than instruction count, and its parameter is a property of the target cache, not of the algorithm.
  • Compilers apply these conservatively because dependence analysis over array subscripts is fragile, which is why aliasing information and affine subscripts matter so much here.

Four transformations, and what each is for

These are not micro-optimizations and they are not about instruction count. Every one of them leaves the arithmetic exactly as it was. What they change is the order of memory accesses, and on a machine where a cache hit is a few cycles and a miss is a few hundred, the order of memory accesses is the program's performance.

Fusion merges two loops over the same range into one. It buys a single traversal instead of two, so a value produced in the first loop is still in cache when the second loop reads it, and it removes one loop's overhead. It costs register pressure — the merged body needs everything both bodies needed live at once — and it can hurt if the two bodies together no longer fit their working set in cache.

Fission (also called distribution) splits one loop into two. It is fusion's inverse and it buys the opposite things: a smaller body with fewer live values, and — most importantly — the ability to vectorize one half when the other half contains something that defeats the vectorizer. Splitting a loop so the vectorizable statements are alone is one of the most common enabling transformations there is.

Interchange swaps the loops in a nest so the innermost one walks memory contiguously. This is the transformation with the largest single effect in the module, because the difference between unit-stride and column-stride access on a row-major array is the difference between one cache miss per cache line and one per element.

Tiling (blocking) restructures a nest so that it works on a block of the data small enough to stay in cache, finishes with it, then moves on. It is the standard technique for matrix multiplication and for any algorithm that revisits data, and it is what turns an operation limited by memory bandwidth into one limited by arithmetic.

What each transformation buys and what it coststypical
TransformationBuysPaysLegal when
FusionOne traversal instead of two; values stay in cache between producer and consumer; less loop overheadMore live values in one body, so higher register pressure; a larger combined working setNo statement in the second loop depends on a later iteration of the first
FissionSmaller bodies, lower register pressure, and the ability to vectorize one half independentlyTwo traversals, so data may be evicted between themThe separated statements have no dependence cycle between them
InterchangeUnit-stride access on the inner loop, turning one miss per element into one per cache lineNothing directly — but it may worsen a different array's access pattern in the same nestNo dependence direction is reversed by the swap
TilingA working set that fits in cache, so revisited data is still residentSubstantially more complex code, extra loop levels, and a tile size that must match the target's cacheThe tiled loops are pairwise permutable

Interchange, and the reason it is worth an order of magnitude

targetThe size of the effect depends on the cache line size, the number of TLB entries and the prefetcher. On a typical 64-byte line, a stride-1 loop over 4-byte elements gets sixteen elements per miss and a column-stride loop gets one — a sixteen-fold difference in misses before TLB pressure is even counted. On a machine with a very large line or a stride-detecting prefetcher that handles the strided case, the gap is smaller.

A two-dimensional array in C is stored row-major: a[i][j] and a[i][j+1] are adjacent in memory, while a[i][j] and a[i+1][j] are a whole row apart. A loop nest that varies i innermost therefore touches one element from each of many distant locations, and the hardware — which fetches a whole cache line and prefetches sequentially — gets no benefit from either mechanism.

Swapping the loops so j varies innermost makes the access pattern contiguous. Every cache line fetched supplies the next several iterations, the prefetcher recognises the stride, and the same arithmetic completes several times faster with no change in instruction count. This is the clearest available demonstration that instruction count is a poor proxy for time.

Fortran stores column-major, so the correct nesting is the mirror image — which is exactly why the same numerical kernel written from a Fortran textbook and transcribed literally into C is slow, and why this is one of the few optimizations worth knowing by hand.

Interchange on a row-major array
Before
for (int j = 0; j < N; j++)
    for (int i = 0; i < N; i++)
        a[i][j] = a[i][j] * 2;
After
for (int i = 0; i < N; i++)
    for (int j = 0; j < N; j++)
        a[i][j] = a[i][j] * 2;
Legal only when

Every iteration reads and writes only a[i][j] for its own (i, j), so no iteration depends on any other: the dependence graph has no cross-iteration edges at all, and any permutation of the loops computes the same result. Only the memory access order changed, and it changed from stride-N to stride-1.

Illegal when

A dependence direction would be reversed. In a[i][j] = a[i-1][j+1] + 1, the value read comes from a previous i and a later j; swapping the loops makes the read happen before the write that produced it, and the result is wrong. This is why a compiler computes the dependence directions rather than pattern-matching on the loop shape — the two nests look nearly identical in source.

Tiling: the transformation that changes the asymptotics of the memory traffic

Matrix multiplication touches every element of both inputs many times. Done naively, by the time the algorithm comes back to a row it walked away from, that row has been evicted, so the total memory traffic is far larger than the data. Tiling fixes this by processing a block at a time: load a tile of each input, do all the arithmetic that involves those tiles, then move on.

The arithmetic is unchanged — the same multiplies and adds in a different order, which for floating point means tiling is a reassociation and technically alters results, one of the reasons high-performance libraries are explicit about their blocking. What changes is that each element is loaded roughly once per tile pass instead of once per use, which converts a memory-bandwidth-bound kernel into a compute-bound one.

The tile size is the parameter, and it is a property of the machine: it should make the working set of the tiles fit comfortably in the target cache level, usually with multiple levels of tiling for multiple levels of cache. This is why hand-tuned BLAS libraries and autotuning frameworks exist, and why a compiler that tiles automatically — which several do, using the polyhedral model — must be told or must guess the cache sizes.

The shape of a tiled loop nest — three loops become six
1for (int ii = 0; ii < N; ii += T)
2 for (int jj = 0; jj < N; jj += T)
3 for (int kk = 0; kk < N; kk += T)
4 for (int i = ii; i < min(ii+T, N); i++)
5 for (int j = jj; j < min(jj+T, N); j++)
6 for (int k = kk; k < min(kk+T, N); k++)
7 c[i][j] += a[i][k] * b[k][j];

The inner three loops are the original nest restricted to a T-by-T-by-T block; the outer three walk the blocks. T is chosen so that three T-by-T tiles fit in the target cache level — a machine parameter, not an algorithmic one, which is why the same code needs retuning per target.

Why compilers do less of this than you might expect

implementationLLVM has -mllvm -polly (an out-of-tree-derived polyhedral optimizer) and GCC has Graphite (-floop-nest-optimize); neither is on by default at any standard optimization level, and both apply only to affine nests. GCC applies simple interchange under -floop-interchange at -O3. Expecting automatic tiling from a default build is expecting something mainstream toolchains do not do.

These transformations are well understood and have been implemented for decades, and mainstream compilers still apply them cautiously. The reason is that legality depends on dependence analysis over array subscripts, and dependence analysis over arbitrary subscripts is undecidable in general and expensive in practice. A subscript like a[f(i)] or a[idx[i]] defeats it entirely; a pointer that might alias defeats it before it starts.

This is why Fortran has historically produced faster numerical code than C from the same algorithm: Fortran arrays cannot alias, so the dependence analysis has a chance. It is also why restrict exists and why the polyhedral frameworks — Polly in LLVM, Graphite in GCC — restrict themselves to *affine* loop nests, where subscripts are linear functions of the loop indices and the whole nest can be modelled as a set of integer points with an exact legality test.

The practical consequence for an engineer is that interchange and tiling are among the few loop transformations still worth doing by hand, in the specific case of a numerical kernel over dense arrays. Everything else in this module is better left to the compiler; these two are frequently blocked by information the compiler cannot recover.

How it works

The steps, in the order the compiler takes them.

  • Build a dependence graph over the loop nest: for each pair of accesses, determine whether they may touch the same location and, if so, in which direction across which loop.
  • For interchange, check that no dependence has a direction vector that becomes negative under the permutation; if none does, the loops are permutable.
  • For fusion, check that the loops have the same trip count and that no statement in the second body reads a location written by a later iteration of the first.
  • For fission, partition the body's statements by the strongly connected components of the dependence graph; each component must stay in one loop, and separate components can be separated.
  • For tiling, verify pairwise permutability of the loops to be tiled, then strip-mine each loop into an outer block loop and an inner element loop and permute the block loops outward.
  • Choose the tile size from the target cache sizes, usually with one level of tiling per cache level, and clean up the boundary iterations.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A numerical kernel is many times slower than an equivalent one with the loops in the other order, with identical instruction counts. Every element access is a cache miss because the inner loop strides across rows.
  • A loop refuses to vectorize because one statement in the body contains a call or a loop-carried dependence; splitting it manually vectorizes the rest, and the engineer had no way to know that from the diagnostics alone.
  • Fusing two loops raises the live-value count past the register file, the allocator spills, and the fused loop is slower than the two it replaced.
  • A tiled kernel is tuned on one machine and deployed on another with a smaller L2; the tiles no longer fit and the transformation buys nothing while costing all its complexity.
  • A floating-point result changes after tiling or fusion reorders the accumulation, and a bit-exact regression test fails on code that is numerically fine.

When it helps

  • Dense numerical kernels: matrix multiply, stencils, convolutions, image filters — anything that revisits data and is limited by memory rather than arithmetic.
  • Pipelines of array traversals produced by high-level code, where fusion removes intermediate arrays entirely — the transformation that array languages and query compilers exist to perform.
  • Loops that nearly vectorize, where fission is what separates the vectorizable part from the part that blocks it.

When it hurts

  • When the fused body's working set no longer fits in cache, or its live values no longer fit in registers — fusion has a maximum beyond which it is a regression.
  • When the transformation changes floating-point association and downstream code compares results bit-for-bit.
  • When applied by hand to code that was clear and is now a six-deep nest with boundary handling, for a kernel that is not actually hot.

What it costs

Every one of these is paid by something.

  • Fusion buys locality between producer and consumer and pays in register pressure and combined working-set size; past either limit it reverses sign, which is why fusion and fission are both real optimizations rather than one being the good direction.
  • Interchange buys unit-stride access for one array and can cost it for another in the same nest — a nest touching two arrays with different layouts has no permutation that is optimal for both.
  • Tiling buys a large reduction in memory traffic and pays in code complexity, an extra parameter that must track the target's cache hierarchy, and boundary handling that is easy to get subtly wrong.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Change the data layout instead of the loop order: storing a matrix in a blocked or Morton order makes the naive traversal contiguous, moving the cost from the loop to the representation.
  • Use a library. BLAS implementations are tiled, vectorized and tuned per microarchitecture by people who do only that; hand-tiling a matrix multiply to beat one is rarely a good use of time.
  • Use a domain-specific language that owns the schedule: Halide separates the algorithm from the loop order explicitly, and a query compiler fuses operator pipelines by construction — [[dsl]].
  • Enable a polyhedral pass and let it search the legal schedule space, which is what Polly and Graphite do — powerful for affine nests, inapplicable to everything else.

See it for yourself

The flag, dump or tool that shows you this directly.

  • GCC: -floop-interchange (on at -O3) with -fopt-info-loop reports interchanges applied and refused; -floop-nest-optimize enables the Graphite polyhedral pass.
  • LLVM: -mllvm -polly with -mllvm -polly-show visualises the schedule it derived; opt -passes=loop-interchange -debug-only=loop-interchange prints the dependence reasoning.
  • Measure the memory behavior rather than the time: perf stat -e cache-misses,L1-dcache-load-misses before and after an interchange shows the mechanism directly.
  • valgrind --tool=cachegrind gives per-line miss counts, which is the fastest way to find the loop whose stride is wrong.
  • For a tiling experiment, sweep the tile size in a benchmark and plot it: the curve has a plateau whose edges are the cache sizes, and seeing that plot once makes the whole transformation concrete.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The loops do the same work, so the order does not matter." The arithmetic is the same and the memory traffic is not, and on current hardware the memory traffic is the time.
  • "The compiler will reorder my loops if it helps." It will only if it can prove the reordering preserves every dependence, which requires disambiguating array subscripts and pointers. Aliasing and non-affine subscripts block it routinely.
  • "Tiling is a compiler optimization." Mainstream compilers do not tile by default at any optimization level. It is done by hand, by a library, or by a polyhedral pass that must be explicitly enabled.
  • "Fusion is better than fission." They are opposites and both are optimizations. Which one helps depends on whether the loop is limited by traversal count or by body size.

Misconceptions

The claim, and what is actually true.

Cache-friendly code is a matter of using less memory.
It is a matter of the order of access. The same amount of data touched in a different order can differ by an order of magnitude in time, with identical instruction counts.
These transformations are what -O3 does.
-O3 enables more inlining, unrolling and vectorization. Interchange is available and limited; tiling is not part of it at all in mainstream toolchains.
A compiler that does not tile my matrix multiply is a weak compiler.
It is a compiler that cannot prove the subscripts are independent, or that has no cache-size model to pick a tile with. Give it affine subscripts, non-aliasing pointers and a polyhedral pass and the answer changes.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

Memory is fetched in blocks and kept in a small fast cache. If a loop walks memory in order, each fetched block serves many iterations; if it jumps around, each iteration pays for its own fetch. These transformations reorder the loops so the walking is in order and so the data being reused is still in cache when it is reused.

practical

For dense array code, check the inner loop's stride first — it is the highest-value thing to look at and the easiest to fix. Use cachegrind or perf stat -e cache-misses rather than guessing. If a loop will not vectorize, try splitting it. And before hand-tiling anything, check whether a library already does it, because it almost certainly does it better.

advanced

The polyhedral model is the general framework: represent each statement instance as an integer point in a polyhedron defined by the loop bounds, represent dependences as affine relations between points, and then any affine schedule that respects those relations is a legal reordering — fusion, fission, interchange, skewing and tiling all become instances of choosing a schedule. That reformulation is powerful enough to search the space with an integer linear program, and it is limited by the requirement that bounds and subscripts be affine, which excludes indirect indexing, data-dependent bounds and most pointer-based code. Everything mainstream compilers do outside the polyhedral passes is a hand-coded special case of what this framework generalises.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationGCC applies loop interchange at -O3 under -floop-interchange and offers Graphite for polyhedral nest optimization; LLVM has a loop-interchange pass that is not enabled by default and Polly as an opt-in. Automatic tiling is not part of a default build in either. Assuming a compiler tiles is the single most common overestimate of what these toolchains do.
targetTile sizes and the payoff from interchange depend on cache line size, cache capacity at each level, TLB reach and prefetcher behavior. A kernel tuned for one microarchitecture routinely loses a significant fraction of its advantage on another, which is why autotuning libraries exist rather than fixed constants.
specReordering floating-point accumulations changes results, because IEEE-754 addition is not associative. Fusion, tiling and any reduction reordering are therefore not value-preserving for floating point, and a compiler needs reassociation permission to perform them on a reduction — while a hand-written tiled kernel simply makes the choice on your behalf.

If you were asked this in an interview

  • Why is for i { for j { a[i][j] } } so much faster than the same nest with the loops swapped, in C?
  • When is loop fusion the wrong transformation?
  • What does a compiler need to prove before it can interchange two loops, and what typically prevents it?

Connections