Faster Elm Builds

Antew Code
6 min readFeb 23, 2018

--

I recently joined a company using Elm for a new project, and it has been awesome, Elm is a great platform for front-end development and it has been a joy to work with. Unfortunately, as the project grew, so did our compile times, and eventually it frustrated us enough that we set aside some time to work on it.

First, I made a script to loop through every file in the project, touch it, and time how long it took for the rebuild (in seconds in the graph below).

0.18 elm-make

A full rebuild was averaging two minutes, incremental builds ranged from 5 to 45 seconds. We use webpack to handle asset bundling and hot reloading in development, so like most developers I initially went looking in our webpack config for a way to improve things, but I somewhat-quickly found (by profiling our webpack build through Chrome’s dev tools), that most of the time was being spent in elm-make rather than webpack.

Existing solutions

There is a lot of advice spread between gists, elm-discuss, discourse, #compile-time on slack, and probably other places I’m not aware of, I tried most of what I found, which included:

  1. Converting complex case (abc, xyz) of statements to nested versions.
  2. Running with a single CPU (I’m on a mac, and found you can control this through “Instruments”, which is included with XCode). On linux you can install sysconfcpus.
  3. Generating a module graph (https://github.com/justinmimbs/elm-module-graph) and trying to remove some of the most expensive connections, which led to…
  4. Mucking around with source-directories in elm-package.json so that we could include mock/stubbed versions of certain files in dev mode
  5. Crying in a corner

At this point I had carved only four seconds off of our 45 second builds, which was…not ideal. The real root cause is that we have heavily interlinked modules, so changing one file causes all the files that import it to be recompiled, and then all the files that import that file, and so on, but I didn’t have time for that large of a refactor.

I didn’t want to go back to the team with a measly four second improvement, so I went back to something I seen earlier with the sysconfcpus trick.

GHC Runtime Control

Performance reduced when running on multi-core ( which is by default ) #159

The key takeaway was the GHC Runtime Control, which allows you to tweak how Haskell runs your program, similar to the JVM’s -Xms256m -Xmx2048mparameters. In order to access some of the flags the executable must be compiled with them baked in, or with an option enabled that allows them to be configured at runtime (elm-make #179).

Fortunately for us, a profiling flag is available on the 0.18 release of elm-make, so I tried it against our project.

$ elm-make +RTS -s -RTS src/elm/Main.elm
Success! Compiled 196 modules.
Successfully generated index.html
43,116,486,160 bytes allocated in the heap
17,031,444,432 bytes copied during GC
63,816,952 bytes maximum residency (431 sample(s))
6,048,656 bytes maximum slop
180 MB total memory in use (0 MB lost due to fragmentation)
Tot time (elapsed) Avg pause Max pause
Gen 0 82618 colls, 82618 par 95.822s 25.837s 0.0003s 0.0033s
Gen 1 431 colls, 430 par 22.468s 3.312s 0.0077s 0.0417s
Parallel GC work balance: 8.07% (serial 0%, perfect 100%)TASKS: 18 (1 bound, 17 peak workers (17 total), using -N8)SPARKS: 0 (0 converted, 0 overflowed, 0 dud, 0 GC'd, 0 fizzled)INIT time 0.000s ( 0.001s elapsed)
MUT time 28.172s ( 18.465s elapsed)
GC time 118.290s ( 29.149s elapsed)
EXIT time 0.002s ( 0.012s elapsed)
Total time 146.467s ( 47.627s elapsed)
Alloc rate 1,530,449,397 bytes per MUT secondProductivity 19.2% of total user, 59.2% of total elapsedgc_alloc_block_sync: 2417038
whitehole_spin: 0
gen[0].sync: 212
gen[1].sync: 904359

That’s a lot of time spent in the garbage collector (“GC” above), most of the build in fact! Richard Feldman had done some research on the available flags and their consequences here, so I decided to give it a go and recompile elm-make to support the runtime flags. I know next to nothing about Haskell, but the docs over on the elm-platform repo made it really easy to get set up, you just need the 7.10.3 release of Haskell for your platform https://www.haskell.org/platform/prior.html (thank you John Grogan for the better link!)

Once I had my own elm-make compiled with -rtsopts it was time to get cracking! I wrote a quick-and-dirty little Docker setup to try out different configurations (https://github.com/antew/elm-make-speed-tests) and went at it.

The most effective set of flags I found was -A128M -H128M -n8m, which dropped build times considerably.

Success! Compiled 196 modules.
Successfully generated index.html
43,215,686,872 bytes allocated in the heap
483,688,584 bytes copied during GC
23,029,528 bytes maximum residency (11 sample(s))
481,184 bytes maximum slop
1177 MB total memory in use (0 MB lost due to fragmentation)
Tot time (elapsed) Avg pause Max pause
Gen 0 31 colls, 31 par 3.731s 1.326s 0.0428s 0.1946s
Gen 1 11 colls, 10 par 0.548s 0.125s 0.0113s 0.0396s
Parallel GC work balance: 10.11% (serial 0%, perfect 100%)TASKS: 18 (1 bound, 17 peak workers (17 total), using -N8)SPARKS: 0 (0 converted, 0 overflowed, 0 dud, 0 GC'd, 0 fizzled)INIT time 0.001s ( 0.002s elapsed)
MUT time 17.556s ( 18.064s elapsed)
GC time 4.279s ( 1.451s elapsed)
EXIT time 0.003s ( 0.003s elapsed)
Total time 21.840s ( 19.520s elapsed)
Alloc rate 2,461,636,862 bytes per MUT secondProductivity 80.4% of total user, 90.0% of total elapsedgc_alloc_block_sync: 75645
whitehole_spin: 0
gen[0].sync: 1654
gen[1].sync: 9882

The latest versions of elm-webpack-loader supports a pathToMake flag that allows you to configure what elm-make to use, at the time I generated this graph the flags -A128M -n4m were the best I had found, so this chart is compile times with that version. I believe if I were to re-run it with -A128M -H128M -n8m I would see another second or two improvement on the files that take longest.

0.18 elm-make is in blue
elm-make with -A128M -n4m is red
Each dot is incremental compile time for one file

Comparison of incremental compile times

We cut off a huge chunk of time on the slowest files! All of our incremental rebuilds are now under 20 seconds, and when you’re building a new feature and compiling often the extra 20–25 seconds that you get back really adds up over time.

Will this help me?

Try running your build with the +RTS -s -RTS options and see if a lot of time is being spent in the garbage collector (“GC” in the output), if it is, this should help.

If your MUT time is high instead it may be large case (x, y, z) of statements that are causing it, or potentially even worse ones lines this example below, which takes six minutes to compile on my old windows box.

module LongListMatchtype Letters = 
A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P
badCase: String
badCase =
case [] of
[ A ] -> ""
[ A, B ] -> ""
[ A, B, C ] -> ""
[ A, B, C, D ] -> ""
[ A, B, C, D, E ] -> ""
[ A, B, C, D, E, F ] -> ""
_ -> ""

What can I do?

I would recommend that you compile your own version of elm-make from source just to be safe. This Dockerfile has a (horribly inefficient) example of doing it.

If you want to trust random strangers on the internet, I have posted binaries for linux and mac, they don’t have the RTS options preconfigured, so you can try out different variations with a command like:

$ elm-make +RTS -A128M -H128M -n8m -RTS src/Main.elm

Integrating it into your build

If you are using node-elm-compiler the recent PR #65 allows configuring runtime options, and there is an open PR on elm-webpack-loader to allow configuring it through there (#133)

Elm 0.19

From discussions on the #compile-time channel on the Elm slack, the core developers have run tests with different combinations of flags for 0.19 and have already solved this. If you would simply like to wait until 0.19 you may see a great improvement in build times that way!

--

--