Reading GHC's +RTS -s Report Before You Tune the GC
Every few weeks someone in a Haskell chat posts the same message: their service uses too much memory or pauses at odd moments, they have read that the garbage collector can be tuned, and they want to know which flags to set.

Every few weeks someone in a Haskell chat posts the same message: their service uses too much memory or pauses at odd moments, they have read that the garbage collector can be tuned, and they want to know which flags to set. Usually a helpful person replies with "try -A64m" and the thread ends there. Sometimes that works. Often it does nothing, and occasionally it makes things worse, because nobody looked at what the runtime was already reporting.
GHC's runtime ships with a summary report that answers most of these questions before you touch a single tuning flag. You get it by adding +RTS -s to the command line. The output looks dense the first time, and the documentation explains each line without saying much about which ones matter. This is the walkthrough I wish I had been handed when I started documenting performance work on the open source projects I contributed to.
Getting the report at all
The first stumbling block is that a normal GHC binary refuses most runtime options. If you run your program with +RTS -s and get an error about RTS options being disabled, you need to relink with -rtsopts. In a Cabal project, that goes in the ghc-options field of the executable stanza. Alternatively, -with-rtsopts=-s bakes the flag into the binary so it prints the report every time, which is handy for a benchmark executable and annoying for anything else.
With Cabal, the least confusing route is to build first, ask cabal list-bin myservice where the executable landed, and run that binary directly with +RTS -s -RTS followed by your program's own arguments. That way Cabal never gets a chance to swallow the runtime flags. The report prints to stderr when the program exits. For a long-running service, that means you need a clean shutdown path, or the report never appears. If your service only ever dies from SIGKILL, fix that first.
Run a realistic workload. A report from a program that started, parsed a config file and exited tells you nothing about how it behaves at hour six.
The lines that matter, in the order I read them
The report has around twenty lines. I look at six of them, and I look at them in a specific order because each one changes how to read the next.
| Line in the report | What it measures | What I look for |
|---|---|---|
| Productivity | Share of time spent running your code rather than collecting garbage | Above roughly 80 percent, GC is probably not your problem |
| bytes maximum residency | Peak amount of live data seen at a major collection | Whether it is stable or grows with run time |
| total memory in use | What the process actually took from the operating system | Its ratio to maximum residency |
| bytes allocated in the heap | Total allocation over the whole run | Mostly context; large numbers are normal |
| bytes copied during GC | Live data the collector had to move | How it compares to allocation |
| Gen 0 and Gen 1 rows | Collection counts and pause times per generation | Max pause on Gen 1, and Gen 0 count |
Productivity first
This single number decides whether the rest of the report is worth your afternoon. If productivity sits at 92 percent, the collector is taking 8 percent of your run time, and even a perfect tuning job buys you very little. Go profile your actual code instead. If it sits at 60 or 70 percent, the collector is a real cost, and the rest of the report will tell you why.
Residency next, and the warning about it
Maximum residency is the peak live heap, but it is only sampled at major collections. The report tells you how many samples it took, in brackets. If that says 3 samples on a run that lasted ten minutes, the true peak could be well above what is shown. Treat it as a lower bound.
The more useful question is whether residency grows. Run the same workload for two minutes and then for twenty. If maximum residency roughly doubles or keeps climbing, you are looking at a space leak, and no runtime flag fixes a space leak. That is a code problem, usually an accumulating thunk or a lazy field that holds on to far more than it should. Our article on lazy evaluation in Haskell covers how those build up.
Then the memory ratio
GHC's default collector for the old generation is a copying collector, and copying needs room to copy into. So the memory the process holds is commonly two to three times the maximum residency. People see a 400 MB process with 150 MB residency and assume a leak. That ratio is normal. If it is closer to four or five times, the -c flag (compacting collection for the oldest generation) or the -F factor are worth reading about.
The advice I would push back on
The single most repeated tip is to raise the allocation area with -A. The idea is sound. GHC allocates constantly, short-lived data dies in the nursery, and a bigger nursery means fewer minor collections and fewer chances for young data to get promoted before it dies. For programs that allocate heavily with modest live data, it can be a large win.
But it is not free, and it is not universal. With -threaded and -N, each capability gets its own nursery, so -A64m on a 16-core machine reserves about a gigabyte before your program allocates anything meaningful. On a container with a 1.5 GB memory limit, that is a surprise nobody enjoys. And if your problem is long Gen 1 pauses rather than a high Gen 0 count, a bigger nursery barely touches it.
The fix should match the line that looks wrong. A rough mapping I use:
- High Gen 0 count and low productivity, stable residency: try a larger -A, increasing in steps and re-running the report each time.
- Long max pause on Gen 1 hurting latency: look at the non-moving collector, switched on with -xn, which trades some throughput for shorter old-generation pauses.
- Poor scaling with -N, lots of time in parallel GC: try -qn to cap the number of GC threads, or -qg to turn parallel collection off and compare.
- Growing residency: stop tuning and take a heap profile. -hT profiles by closure type and works on a normal, non-profiled build.
One worked example
A log-ingest service I helped document a while back reported 69.4 percent productivity under its normal load, with Gen 0 collections in the hundreds of thousands per hour and a maximum residency that held steady around 210 MB across a long run. Steady residency ruled out a leak. A huge Gen 0 count with stable live data is the textbook case for the nursery being too small.
The team tried -A16m, then -A32m, running the same replayed log file each time. At 32 MB, productivity rose to just under 88 percent. They stopped there rather than going to 64, because the service ran with -N8 and the extra quarter-gigabyte of nursery would have pushed it close to its container limit for very little extra gain. The report made that trade-off visible. A blind -A64m from a chat thread would have bought a few more points of productivity and a container that got killed during traffic spikes.
The runtime is already telling you what is wrong. Tuning without reading the report is guessing with extra steps.
One honest caveat. I cannot give you thresholds that hold for every workload. A batch job that runs for forty seconds can happily live with 70 percent productivity, while a latency-sensitive API might care only about Gen 1 max pause and ignore everything else. Treat the numbers above as places to start looking, not pass marks. And measure under realistic load, because, as with failures that unit tests never catch, a quiet laptop rarely shows the behaviour that matters in production.
Before you change a flag this week
Pick the Haskell service or tool you are most worried about, relink it with -rtsopts, and capture two +RTS -s reports: one from a short run and one from a run at least ten times longer under the same workload. Write down productivity, maximum residency and total memory in use for both. If residency grows, take a -hT heap profile next. If it does not, you have a baseline to measure every tuning flag against. There is more on running Haskell in production in the systems section.
More from the blog
Systems
Why Distributed Systems Fail in Ways Unit Tests Never Catch
A distributed system can pass every single unit test, every integration test, and a full staging environment soak test, and...
Systems
Why Systems Programmers Are Choosing Haskell for New Projects
Systems programming has long been dominated by C, C++, and more recently Rust.
Open Source
Choosing an Open Source License for a Haskell Library
Open the license field of a hundred random packages on Hackage and you will see the same two answers again and again:...