Programming Languages · Functional Programming

Fold Fusion and the Elimination of Intermediate Lists

How the foldr/build rule removes intermediate lists at compile time, what deforestation generalized it to, and why strictness and sharing break the rewrite.

Composing two list functions allocates a list that exists only to be consumed. map f (map g xs) builds an intermediate list of the same length as the input and immediately discards it, so the program does twice the traversal and allocates one cons cell per element for nothing. The obvious fix is to write the composition by hand, and the interesting question is whether the compiler can do it instead, reliably, without changing the meaning of programs that share the intermediate structure.

The shape of the waste

A cons cell in GHC’s heap is two machine words, a pointer to the head and a pointer to the tail, and a cons cell with an evaluated head and tail therefore occupies those 16 bytes on a 64-bit machine before the boxed elements are counted. A list of n integers costs roughly 16n bytes for the spine plus whatever the boxed integers themselves occupy. Each stage of a chain allocates one such list, so three stages retain one intermediate list at a time and allocate three, and the peak residency is proportional to n and the allocation rate to 3n.

The traversal count is the other half. Each stage walks the spine, so k stages cost k traversals. Neither cost is asymptotically worse than the fused version; both are constant-factor losses that scale with the length of the chain and the size of the elements. The reason fusion matters is that these constants are large in exactly the code where lists are the natural idiom: parsing, transformation, and serialization chains are typically several stages long.

The foldr/build rule

The fusion rule rests on a representation trick. A list producer is written not as a list but as a function that consumes the two constructors:

build :: (forall b. (a -> b -> b) -> b -> b) -> [a]
build g = g (:) []

map' :: (a -> b) -> [a] -> [b]
map' f xs = build (\c n -> foldr (\x acc -> c (f x) acc) n xs)

build applies its argument to the real constructors, so the resulting list is ordinary. The point of the wrapping is that a consumer written with foldr can be applied to g directly, without build ever running:

foldr k z (build g)  =  g k z

Read left to right, this is the fusion rule. The left side allocates the list that build would produce and then folds it; the right side substitutes the consumer’s k and z into the producer’s function, so the producer builds its result directly in the consumer’s algebra. No list exists at any point. map' f composed with sum becomes foldr (\x acc -> (f x) + acc) 0 xs, one traversal, zero cons cells.

The rule is a compiler rewrite, not a source-level transformation. GHC implements it as a RULES pragma attached to build, with the constraint that g must be a syntactic argument so that the rule can fire during inlining. A producer that is not written with build does not fuse, which is why the list functions in base are written in this style: map, filter, take, iterate, and the enumFromTo enumeration are defined with build so that they fuse with a foldr-shaped consumer.

Why the rule is semantically safe

The equation foldr k z (build g) = g k z is not a heuristic. It follows from the parametric type of g: forall b. (a -> b -> b) -> b -> b. By the free theorem for that type, g must be a homomorphism between list algebras, so applying it to (:) and [] and then folding with k and z is the same as applying it to k and z directly. The type is what licenses the rewrite; a function of a less polymorphic type would not satisfy the equation.

This is also why user-written rules are dangerous. A rule is a promise that two expressions are equal for all instantiations, and the compiler will apply it wherever the pattern matches. A rule whose left side is more general than its right side, or whose right side duplicates a variable that appears once on the left, changes the number of evaluations of that expression. Under a lazy language the change is invisible for pure expressions and visible when the expression diverges; under a strict one it is visible for any expression with an effect.

Where fusion fails: seq, sharing, and arity

Three conditions defeat the rule, and each is a real limitation rather than a compiler bug.

Strictness. The equation above holds in a language where foldr k z (build g) and g k z have the same value for every k, z, g. It does not hold when seq can observe the intermediate list. A seq placed on the list argument of the left side forces the spine that build produced; the fused right side never produces that spine, so the same seq forces a different expression or nothing at all. The rule is therefore not valid in the presence of seq, and the practical consequence is that a program relying on seq to control the evaluation of an intermediate list may reach a different ⊥ after fusion. The difference is not a bug in the program’s logic; it is a difference in which of two expressions was demanded first.

Sharing. If the intermediate list is used twice, fusion cannot eliminate it, because the two consumers would each have to re-run the producer. A compiler that fuses in that situation duplicates the producer’s work, which is a pessimization, and one that refuses to fuse leaves the allocation. GHC’s rules carry phase annotations so that build is inlined only at a use site where the rewrite will fire, which is what keeps the duplication out of programs that share a list. A binding whose result is used in two places keeps its list, and the programmer who wants the fused version has to restructure the code so the shared value is something cheaper than a list.

Arity and inlining. The rule fires only after build has been inlined at the use site, which requires the producer to be small enough for the inliner and the consumer to be visible in the same module or to have an unfolding exported across modules. A producer in a separately compiled module that does not export an unfolding cannot fuse, and the fix is an INLINABLE pragma. A producer behind a class method dispatch may not fuse at all, because the dictionary indirection hides the call. These are engineering constraints of the optimizer rather than properties of the calculus, and they mean that fusion’s benefit is uneven across a codebase.

Deforestation, and why the general version lost

Wadler’s deforestation applies the same idea to arbitrary trees. The algorithm repeatedly applies rewrites that push a consumer into a producer, so that a tree built by one function and consumed by another never materializes. It is a general-purpose transformation on a first-order functional language, and it subsumes the list case.

The reason it is not what compilers use is cost and predictability. Deforestation needs to analyse the whole program to find producer-consumer pairs, it can duplicate work when it fuses a shared structure, and its termination is not obvious: a rewrite that pushes a consumer inward can create new opportunities indefinitely. Wadler’s formulation handles termination with a careful measure, and the implementation still has to decide when to stop. A single hand-written rule for the list type is cheaper to implement, easier to verify, and covers the compositions that matter in practice.

The successor in GHC is a family of rules rather than one. foldr/build covers foldr-shaped consumers; augment covers producers that append onto an existing accumulator, with matching foldr/augment and foldl/augment rules so that a fold can build its elements directly in the consumer’s algebra. The union of the rules covers more programs than any single rule, at the cost of more rules that can interact badly with each other and with user-supplied rules.

The cost when it works, and the cost when it does not

When fusion fires, a three-stage chain becomes one loop. The allocation of the intermediate lists goes to zero, the traversal count goes from three to one, and the resulting code is the hand-written loop that a programmer would have written in a strict language. The transformation is purely local after inlining, so the risk is bounded: the worst case is code that does not fuse and behaves as before.

When it does not fire, the program is exactly the unfused program, and the only cost is a missed opportunity. The failure is silent: nothing warns that a chain allocated two lists. A programmer who wants to know has to read the optimizer’s output, and the shape of the output depends on inlining decisions that change between compiler versions.

There is a third case, which is the one that causes trouble: fusion fires but changes the program’s behaviour under seq. The rule is stated for a language in which foldr k z (build g) and g k z have the same value for every k, z, and g, and the equation can fail once seq can observe the half-built list, because the left side has a spine to force and the right side has none. A program that used seq on an intermediate list to decide when the producer’s elements were computed can therefore compute a different expression once the list is gone. The change is in which intermediates are demanded, not in the value produced, which is why a rewrite that is sound for values is not automatically sound for demand.

The right way to read fusion is therefore as a rewrite that is sound for values and not for evaluation order. The universal property of fold supplies the equation; the compiler supplies the mechanism; and seq, sharing, and inlining decide whether the mechanism applies. Programs that need a specific evaluation order should say so with strictness annotations rather than rely on an intermediate list to impose it, and programs that need a specific allocation profile should be written with the fused shape directly rather than hoped for.

For types other than lists the same transformation is expressed as a fusion law between a catamorphism and an anamorphism, where a producer-consumer pair collapses into a single hylomorphism. That generalization is where the list-specific rules come from, and it is also where the termination argument becomes hard.

References

  1. Deforestation: Transforming Programs to Eliminate Trees — Philip Wadler, Theoretical Computer Science, 1990
  2. A Short Cut to Deforestation — Andrew Gill, John Launchbury, Simon Peyton Jones, FPCA 1993
  3. The Implementation of Functional Programming Languages — Simon Peyton Jones, Prentice Hall, 1987
  4. GHC User's Guide — Rewrite rules — GHC documentation