Haskell/GHC refuses to compile ugly code
Tags: haskell, functional, testing, programming
Published: 2023-04-14 07:58:38

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][large-record] for details)
* A big derived instance (e.g. `Generic` or `Read`)
* Lots of instances
* A really big module (e.g. 20K Loc `Types` module)

[large-record]: https://well-typed.com/blog/2021/08/large-records/

> :mountain: 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.

## :mag: Core

You can view Core of a particular module by adding the following line to the top of the corresponding Haskell file:

```haskell
{-# 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.

```haskell
{-# 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:

```haskell
-- 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:

* https://gitlab.haskell.org/ghc/ghc/-/issues/22824

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.

### :computer: 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:

![Middle Of Core](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q181w4m8u8it945bhzw4.png)

After we see the problem, we need to fix it.

### :hammer: 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.

> :point_up: 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 :hammer_and_pick:

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!