blog :: Post "profiling-ghc-programs"
Profiling GHC Programs
2026-08-30 · 9 min
Profiling a lazy language is not quite profiling a strict one: the work attributed to a function may have been caused by a thunk something else built, and the number you most often need is allocation rather than time. This is the toolchain, in the order you should actually reach for it.
A little history
GHC's cost-centre profiler comes out of work by Sansom and Peyton Jones in the early 1990s on how to attribute cost in a non-strict language at all. Their answer was the cost centre: a lexical marker in the source that the runtime charges time and allocation to, so the numbers refer to places in your program rather than to whatever the evaluator happened to be doing. That model is still the basis of -p today. The later additions are the eventlog and ThreadScope for concurrency, and, from GHC 9.2, info-table profiling, which maps heap objects back to source locations without a profiling build at all.
Start with -s
Before building anything special, ask the runtime what it did. This costs nothing and frequently ends the investigation.
Requires -rtsopts at link time, so the binary accepts +RTS flags at all.
ghc-options: -rtsopts
Run it.
$ cabal run myprog -- +RTS -s
The lines worth reading, out of the thirty it prints.
9,241,776,184 bytes allocated in the heap412,336,912 bytes copied during GC78,443,008 bytes maximum residency (14 sample(s))Gen 0 8823 colls, 0 par 1.204s 1.210sGen 1 14 colls, 0 par 0.831s 0.833sINIT time 0.001sMUT time 2.140sGC time 2.043sTotal time 4.184s%GC time 48.8%Productivity 51.2% of total user
Three numbers decide what to do next. Productivity below roughly 80% means the garbage collector is your problem, not your code. Maximum residency that climbs with input size when it should not is a space leak. Total bytes allocated is the best single proxy for time in a GHC program, and the one to watch across changes.
A quick check on whether GC time is just an undersized nursery. If this closes the gap, tune -A rather than the program.
$ cabal run myprog -- +RTS -s -A64m
Building for profiling
Cost-centre profiling needs the whole dependency tree rebuilt with profiling libraries. The first build is slow; they are cached afterwards.
Build everything, including dependencies, with profiling.
$ cabal build --enable-profiling
Where the cost centres come from. -fprof-late, from GHC 9.4, inserts them after optimization, so the profile reflects a program that was optimized normally.
-fprof-auto every binding, including local ones-fprof-auto-top top-level bindings only-fprof-auto-exported exported bindings only-fprof-late after optimization; least distortion
Set it in the cabal file, under a flag so a normal build is unaffected.
ghc-options: -rtsoptsif flag(profiling)ghc-options: -fprof-late
Mark a region by hand when the automatic centres are too coarse or too fine.
parseAll :: [ByteString] -> [Record]parseAll xs = {-# SCC "parseAll" #-} map parse xs
The caveat that matters: -fprof-auto places cost centres where they can block inlining, so the profiled binary may have a different performance shape from the one you ship. When a profile disagrees with -s, trust -s and re-profile with -fprof-late or a handful of manual SCC annotations.
Time and allocation profiles
Run it, and get myprog.prof.
$ cabal run --enable-profiling myprog -- +RTS -p
The top of the file: a flat ranking. This answers where the time went.
COST CENTRE MODULE %time %allocparse Parser 41.2 38.7normalize Record 22.8 31.1lookupKey Index 18.4 2.2main Main 6.1 9.0
Below it, the call tree. individual is the cost of that centre alone; inherited includes everything it called. A small individual with a large inherited means the cost is further down.
individual inheritedCOST CENTRE entries %time %alloc %time %allocMAIN 0 0.0 0.0 100.0 100.0main 1 6.1 9.0 100.0 100.0parseAll 1 0.2 0.1 64.0 69.8parse 1048576 41.2 38.7 63.8 69.7normalize 1048576 22.6 31.0 22.6 31.0
entries is often the most informative column. A function called far more times than the input size explains is usually being recomputed inside a loop.
$ grep -n "entries" myprog.prof | head
Turn the profile into a flamegraph, which is easier to read than the tree once it is deep.
$ cabal install ghc-prof-flamegraph$ ghc-prof-flamegraph myprog.profwrites myprog.svg
JSON output, if you want to diff profiles across runs in CI rather than read them.
$ cabal run --enable-profiling myprog -- +RTS -pj
Heap profiles
A time profile says where cycles went. A heap profile says what is on the heap and why it is still there, which is what you need for a leak. Start with -hT, because it needs no profiling build.
Breakdown by closure type. No rebuild required, so this works on a binary you already have.
$ cabal run myprog -- +RTS -hT -i0.1
The breakdowns, roughly in order of how often they help.
-hT by closure type no -prof needed-hc by cost centre stack needs -prof-hd by closure description needs -prof-hy by type needs -prof-hr by retainer needs -prof; who is holding it-hb by biography lag / drag / void / use
Render the .hp file to PostScript. -c colours the bands, which is what makes a stacked area chart readable.
$ hp2ps -c -e8in myprog.hp$ ps2pdf myprog.ps
Or write an eventlog and render HTML, which is interactive and the better default now. -l-agu restricts the eventlog to the heap profiling events.
$ cabal install eventlog2html$ cabal run myprog -- +RTS -hT -l-agu$ eventlog2html myprog.eventlogwrites myprog.eventlog.html
Narrow a large profile to the bands you care about, rather than squinting at forty of them.
$ cabal run myprog -- +RTS -hc -hcparse,normalize
A band that grows linearly and never falls is a leak. A large THUNK or _thunk band under -hT means unevaluated work is accumulating, which is the common case and usually fixed by a strict fold, a bang pattern, or StrictData on the offending record. -hr is what you run when you know what is leaking but not who is keeping it alive.
Info-table profiling
From GHC 9.2, the compiler can emit a map from info tables to source locations. That makes it possible to attribute heap objects to the line that allocated them without a profiling build, which is the single biggest practical improvement to this workflow in years, and it is the way to profile something you cannot easily rebuild the world for.
Compile with the map. The second flag gives each constructor its own info table, so identical constructors from different sites stay distinguishable.
ghc-options: -finfo-table-map -fdistinct-constructor-tables
Profile by info table, then render.
$ cabal run myprog -- +RTS -hi -l-agu$ eventlog2html myprog.eventlog
Each band in the output now carries the source location that allocated it.
Record.hs:42:17 normalize 31.4 MiBParser.hs:88:9 parse.go 18.2 MiB
The cost is binary size, since the map is embedded. It is worth keeping enabled in a staging build so a production-shaped binary can be profiled on demand.
Concurrency
Write a full eventlog from a threaded program.
ghc-options: -threaded -rtsopts -eventlog
Run across four capabilities, logging everything.
$ cabal run myprog -- +RTS -N4 -l
Open it in ThreadScope to see per-capability activity, and the gaps where everything stalls on GC or on one thread.
$ cabal install threadscope$ threadscope myprog.eventlog
Spark statistics, for anything using par. Large fizzled or GC'd counts mean the parallelism is not paying off.
$ cabal run myprog -- +RTS -N4 -sSPARKS: 1048576 (12043 converted, 0 overflowed, 0 dud,982141 GC'd, 54392 fizzled)
Two more things worth knowing
A stack trace on an exception, which GHC otherwise will not give you. Needs a profiling build.
$ cabal run --enable-profiling myprog -- +RTS -xc
ghc-debug attaches to a running process and walks its live heap, for the leaks that only appear after hours in production.
$ cabal install ghc-debug-brick$ ghc-debug-brick
The loop, in order: +RTS -s first, and stop there if productivity is fine and residency is flat. If time is the problem, -fprof-late and -p, reading entries as carefully as %time. If memory is the problem, -hT to see what, -hi to see where, and -hr to see who is holding it.