· 6 min read
Make evaluation visible
Haskell performance improves when you measure demand, allocation, and residency, then make evaluation and representation explicit only at proven hot paths.

The standard Haskell optimization move is to add bangs until the heap graph stops looking haunted. I've done it, and sometimes it even works — but you come out the other side no wiser about where the time actually went.
Laziness itself isn't the problem. The problem is losing track of it: you stop knowing which work is deferred, how often values get rebuilt, and what's keeping them alive long past their usefulness. So the first job is making those costs visible. Bangs and pragmas come after.
Three numbers before one pragma#
Measure the optimized program on a representative input. A development build without optimization describes a program you don't ship, and a toy input rewards shortcuts your real workload never gets to take. GHC's optimization guide treats -O2 as the package of non-dangerous optimizations worth the longer compile times, so start there rather than with a private collection of folklore flags.
ghc -O2 -rtsopts Main.hs./Main +RTS -sThe runtime summary from +RTS -s gives you three different complaints: elapsed and garbage-collection time, total allocation, and maximum residency. GHC's RTS documentation reports them separately because they're separate problems. High allocation means the program keeps manufacturing short-lived objects. High residency means too much stays live. And if mutator time is high while allocation looks modest, the cost is probably in the computation itself — branching, poor locality, that sort of thing.
Allocation is traffic; residency is parking. Reducing one doesn't promise anything about the other. When the summary names the category but not the source, GHC's time, allocation, and heap profilers will point at the cost centre, or at whichever producer is retaining the data. Go in with one question you want the profile to answer.
Benchmark the demand#
A Haskell benchmark doesn't measure a function in isolation — it measures how far a consumer forces the result, and a tidy benchmark makes it easy to erase that distinction without noticing.
import Criterion.Mainmain :: IO ()main = defaultMain [ bench "summarize batch" $ nf summarize fixture ]Criterion's benchmarking guide separates whnf from nf for exactly this reason. Weak head normal form may inspect only the first constructor of a lazy result; normal form consumes the whole structure. Pick whichever matches production — or better, benchmark the real downstream consumer. Otherwise you can make a list producer look fast by timing the first cons cell and quietly sending the rest of the bill to code outside the benchmark.
Force the accumulator, not the language#
Once a profile shows a hot fold retaining a long chain of additions, make that accumulator strict. What you shouldn't do is declare the whole module strict and hope every deferred computation was a mistake.
{-# LANGUAGE BangPatterns #-}import Data.List (foldl')data Totals = Totals !Int !Intsummarize :: [Row] -> Totalssummarize = foldl' step (Totals 0 0) where step (Totals !rows !bytes) row = Totals (rows + 1) (bytes + rowBytes row)Here foldl' evaluates the accumulator at each step, and the strict fields keep the two counters from turning into suspended arithmetic inside an otherwise evaluated Totals constructor. GHC's strictness documentation calls out thunk removal in inner loops as one of the big wins. But the boundary still matters: if a caller might stop after the first match, or only ever reads one field, forcing everything up front performs work the lazy program was correctly skipping.
So put strictness where the consumer already promises to need the value — accumulators, long-lived records — and leave it out where partial demand is the feature.
Change the container#
When allocation dominates, changing the container usually beats any pragma. A linked list is a good stream and an expensive numeric array. String is a list of characters, not a compact text buffer. No amount of enthusiasm for INLINE changes those shapes.
Use a type that says what storage you actually need. The text package stores Unicode text compactly, the bytestring package handles packed bytes, and the vector package offers boxed and unboxed arrays with a fusion framework. For dense primitive data, an unboxed vector removes a layer of pointers before the optimizer sees the loop.
import qualified Data.Vector.Unboxed as Uscore :: U.Vector Int -> Intscore = U.sum . U.map normalize . U.filter validCompiled with optimization, a pipeline like this can fuse into a single loop with no temporary vectors in between. Can, not will — check it, because fusion is an optimization rather than a guarantee, and I've been burned by assuming it fired. When a change of representation does work, it tends to improve cache behavior and garbage collection at the same time, which is more than a pragma usually buys you.
Read Core as a verdict#
Inspect Core only after the profiler has isolated a hot module; before that it's a lot of output and no hypothesis. GHC's Core dump flags let you ask narrow questions. Did the intermediate vector disappear? Did an overloaded loop specialize, and did worker/wrapper produce an unboxed worker?
ghc -O2 -ddump-simpl -ddump-to-file Hot.hsghc -O2 -ddump-rule-firings Hot.hsOnly now should you reach for INLINEABLE, SPECIALIZE, or a rewrite rule. GHC already runs specialization and several unboxing passes at its standard optimization levels, and the same guide warns that aggressive specialization can massively increase code size. A pragma earns its place when Core shows a missed transformation and the benchmark shows the miss costs something.
Then strip the profiling instrumentation and run the optimized, production-shaped binary again. Write down the input, the compiler version, the runtime flags, and the numbers from before and after, next to the change itself — a month from now nobody remembers why the bang is there.
Haskell lets you postpone work without changing what a program means, and that's worth keeping. The job is to find the few places where the postponement costs more than it saves, and fix those, with the numbers open in another window.