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 loop does the same arithmetic either way. Why does the order of the loops change the running time by an order of magnitude?
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.
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.
| Transformation | Buys | Pays | Legal when |
|---|---|---|---|
| Fusion | One traversal instead of two; values stay in cache between producer and consumer; less loop overhead | More live values in one body, so higher register pressure; a larger combined working set | No statement in the second loop depends on a later iteration of the first |
| Fission | Smaller bodies, lower register pressure, and the ability to vectorize one half independently | Two traversals, so data may be evicted between them | The separated statements have no dependence cycle between them |
| Interchange | Unit-stride access on the inner loop, turning one miss per element into one per cache line | Nothing directly — but it may worsen a different array's access pattern in the same nest | No dependence direction is reversed by the swap |
| Tiling | A working set that fits in cache, so revisited data is still resident | Substantially more complex code, extra loop levels, and a tile size that must match the target's cache | The tiled loops are pairwise permutable |
Interchange, and the reason it is worth an order of magnitude
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.
for (int j = 0; j < N; j++)
for (int i = 0; i < N; i++)
a[i][j] = a[i][j] * 2;for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++)
a[i][j] = a[i][j] * 2;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.
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.
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
-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-loopreports interchanges applied and refused;-floop-nest-optimizeenables the Graphite polyhedral pass. - LLVM:
-mllvm -pollywith-mllvm -polly-showvisualises the schedule it derived;opt -passes=loop-interchange -debug-only=loop-interchangeprints the dependence reasoning. - Measure the memory behavior rather than the time:
perf stat -e cache-misses,L1-dcache-load-missesbefore and after an interchange shows the mechanism directly. valgrind --tool=cachegrindgives 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.
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.
-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.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?