Recently, I experienced an unusual challenge when upgrading a 60K LOC Haskell project from GHC version 9.0 to 9.2, and I'd like to share my investigation journey and how I eventually fixed the problem.
Grab a cup of tea, and let's begin the journey!
TL;DR Don't write ugly code, otherwise, it won't compile later 🙅♀️
🐞 The Problem
The compilation of a single 200-lines testing file required too much memory, and the build step was constantly failing on CI due to the Out-Of-Memory (OOM) error.
The description of the issue is straightforward but the reasons are mysterious.
What made the situation worse, is that my local laptop has 48GB RAM while the CI runner apparently is a teapot. So I wasn't able to reproduce this problem locally.
🕵🏻♀️ Investigation
GHC is the Haskell compiler. It's an extremely powerful tool. It does lots of stuff, and that's why it usually requires more time and memory than you may think it should.
However, sometimes it takes unexpectedly more resources. This may happen for various reasons, including but not limited to:
- A big record type (see relevant blog post for details)
- A big derived instance (e.g.
Generic
orRead
) - Lots of instances
- A really big module (e.g. 20K Loc
Types
module)
⛰️ The gist: usually, something is really big.
In my case, nothing really seemed particularly alarming:
- 202-lines module with 69 lines of imports
- A single testing function comprising 124 lines
- No new types defined
- No instances derived
You may develop an intuition for troubleshooting similar problems and be able to make pretty good educated guesses after many years with Haskell. But the most robust way to see what's actually going on is to check Core - the intermediate representation of Haskell AST after parsing, desugaring and optimising.
🔍 Core
You can view Core of a particular module by adding the following line to the top of the corresponding Haskell file:
{-# OPTIONS_GHC -ddump-simpl #-}
By default, Core is pretty ugly and unreadable. So you may need to add a few extra options to actually be able to read it.
{-# OPTIONS_GHC
-ddump-simpl
-dsuppress-idinfo
-dsuppress-coercions
-dsuppress-type-applications
-dsuppress-uniques
-dsuppress-module-prefixes
#-}
Core looks like really verbose and explicit Haskell with lots of type annotations and argument passing but at least with the above options you're able to recognize some parts of it.
Here is an example from the problematic module:
-- RHS size: {terms: 46, types: 68, coercions: 18, joins: 0/0}
$wcheckUnblockedEmail
:: LogFunc
-> IO Connection
-> (Connection -> IO ())
-> Int
-> Int
-> Vector (LocalPool Connection)
-> State# RealWorld
-> (# State# RealWorld, () #)
$wcheckUnblockedEmail
= \ (ww
:: LogFunc
Unf=OtherCon [])
(ww1 :: IO Connection)
(ww2 :: Connection -> IO ())
(ww3 :: Int)
(ww4 :: Int)
(ww5 :: Vector (LocalPool Connection))
(w :: State# RealWorld) ->
case $wrunDBWithTransactionFunction
(withTransaction1 `cast` <Co:14>)
(lvl15 `cast` <Co:4>)
ww
ww1
ww2
ww3
ww4
ww5
w
of
{ (# ipv, ipv1 #) ->
case ipv1 of {
Nothing -> $wexpectationFailure lvl5 ipv;
Just queuedTask ->
case queuedTask of { QueuedTask dt ds4 dt1 ds5 ds6 ds7 ds8 ->
case ds5 of { MagicLinkEmail ds2 ds3 ds10 ds11 ->
case ds11 of lwild {
__DEFAULT ->
case dataToTag# lwild of { __DEFAULT -> lvl6 ipv lwild };
MagicLinkEmailModeLogin -> (# ipv, () #)
}
}
To be able to diagnose Haskell problems with Core and understand what's actually going on, you typically spend 5 years doing GHC development or a PhD in Haskell.
In my case, I was lucky enough to come across a similar problem earlier which turns out to be a GHC bug:
The problem was that GHC generates a lot of Core due to exponential inlining (and maybe something else). Like, really A LOT.
The Core has 3300 LOC while the original source code is 200 LOC which is 16.5x blow up (sick !!!).
As you noticed earlier, Core is really verbose. But this huge growth is too much.
💻 Code
Now, what about the code? The module has only a single test for the email auth scenario. The test is 130 lines long.
But if we look in the middle of the generated Core, we notice the following:
- It's really nested
- There's a pattern-matching in almost every function call and this introduces more nestedness and more matching
- Records in Haskell are types with multiple fields in disguise and pattern matching on them every time results in lots of extra code
- GHC also inlines library functions
And here is a part of Core in question:
After we see the problem, we need to fix it.
🔨 Fix
A quick fix would be to tune some GHC flags to prevent the compiler from doing what it's doing.
Unfortunately, after some experimentation, it turned out to be impossible.
☝️ You can't change GHC! You need to accept it for what it really is.
So I was left with only one solution: refactor code ⚒️
I suspected that GHC inlines lots of functions, so the idea behind the fix is to reduce inling. But how do I do this?
The test suite calls multiple functions in a single do
-block. I can chunk this block into separate logical functions and add {-# NOINLINE #-}
pragmas to those functions.
If needed, I can then move those functions to other modules to decrease the size of this particular module but this turned out to be not needed.
Luckily, after this refactoring, I noticed lots of code duplication in the module, so I was able to remove redundant code as well.
The new Haskell source code grew up to 288 LOC but the generated Core became 1900 LOC which is only 6.5x increase. Much nicer!
As an additional benefit, the relevant parts of this scenario test became more visible which makes understanding, extending and fixing the test suite in the future much easier.
Conclusion
In this post, I explained how you can use GHC Core to diagnose some non-trivial problems, and described a solution for this particular problem.
However, I'm still surprised to encounter such a problem in the first place. The offending module didn't look particular scary and didn't use any fancy GHC features. I was frustrated to see such a problem on a code many people would classify as "boring Haskell".
Moreover, the investigation of the problem required to dive into the internals of the GHC optimiser. I personally don't think that knowledge of such details for a mundane task like a GHC upgrade is a reasonable expectation from an average Haskell developer. Stuff like this doesn't help to convince people to use Haskell 😮💨
On the bright side, I learned something new while working on the problem. Besides, I highlighted only the problem, while I had much more enjoying moments when using Haskell!
Top comments (4)
This kind of thing is really useful!
Happy to write useful posts!
Thanks Dmitrii for sharing your experience so detailed.
You're welcome!