![Linegraph tracking a Mercury account balance](/blog/_next/image?url=https%3A%2F%2Fcdn.sanity.io%2Fimages%2F73xfpczb%2Fproduction%2Fd2e09e02e10240ba7142b6fb0a195f77d03927cb-931x500.png%3Ffit%3Dmax%26auto%3Dformat&w=1080&q=90)
At Mercury, we’ve developed a file-watching recompiler for Haskell projects called ghciwatch, which loads a GHCi session (a Haskell REPL) and instructs it to reload or add modules when files in your project change. Ghciwatch is very similar in design to Neil Mitchell’s excellent ghcid, but with a few new tricks up its sleeve:
- Ghciwatch is blazing fast, even for the largest Haskell projects, loading our backend server monolith nearly 12 times faster than haskell-language-server.
- Ghciwatch natively supports Cabal’s
--repl-no-load
option to massively speed up startup and reload times by only loading the modules you need. - Ghciwatch seamlessly loads newly-created modules and can handle deleted and removed modules without restarting the underlying GHCi session.
Check out the user manual to get started with ghciwatch.
Why not use haskell-language-server?
While HLS works great for smaller projects, the Mercury backend monolith consists of about 10,000 modules containing about 1.2M lines of Haskell code. For projects that large, HLS’s performance starts to break down: on my 2022 Mac Studio (M1 Ultra, 20 cores, 64GB memory), HLS takes a full 32 minutes to load the project. After it’s loaded everything, making changes to project files will start reloads that take at least 1.5 minutes (changes to modules nested deeper in the dependency graph can take as long as 5 or 10 minutes to reload, if HLS is able to reload at all). HLS is also very memory intensive, consuming 50GB of RAM after loading the project and climbing from there.
In contrast, ghciwatch takes 2.7 minutes to load the entire project and about 7 seconds to reload. With Cabal’s --repl-no-load
option, modules are only loaded when they’re changed: as a result, ghciwatch can start up in about 6 seconds. The first change to a module will take much longer, depending on how many modules need to be loaded (between one second and two minutes), but subsequent reloads take between 0.2 and 7 seconds.
Ghciwatch is a relatively thin wrapper around GHCi, so it’s able to take advantage of GHCi’s speed directly by interpreting modules, leveraging bytecode linking, and keeping artifacts cached more reliably between runs.
Before building ghciwatch, we spent a while trying to make HLS faster; as of the time of writing, Mercury is the largest organizational backer of HLS on Open Collective, and we’ve also hired contractors like Well-Typed to optimize HLS. While a number of incremental improvements have been made, there’s a limit to how fast HLS can get. HLS is built on top of GHC’s internal API and GHC is a batch compiler, which means that HLS’s latency and responsiveness can’t really be improved without implementing a new Haskell parser and type checker with different design goals. In his excellent “Why LSP?” blog post, matklad explains why batch compilers serve poorly as a basis for a language server:
[A] batch compiler is optimized for maximum throughput, while a language server aims to minimize latency (while not completely forgoing throughput). Adding a latency requirement doesn’t mean that you need to optimize harder. Rather, it means that you generally need to turn the architecture on its head to have an acceptable latency at all.
Practical evidence seems to support this argument:
Language servers are a counter example to the “never rewrite” rule. [The] majority of well regarded language servers are rewrites or alternative implementations of batch compilers.
Both IntelliJ and Eclipse wrote their own compilers rather than re-using javac inside an IDE. To provide an adequate IDE support for C#, Microsoft rewrote their batch compiler written in C++ into an interactive self-hosted one (project Roslyn). Dart, despite being a from-scratch, relatively modern language, ended up with three implementations (host AOT compiler, host IDE compiler (dart-analyzer), on-device JIT compiler). Rust tried both — incremental evolution of rustc (RLS) and from-scratch implementation (rust-analyzer), and rust-analyzer decisively won.
Although we’re investigating larger changes to support our Haskell engineers in the future, we need tools for our engineers to use in the interim. Ghciwatch is fast enough for local development, and we’ve integrated Joseph Sumabat’s language server static-ls and GHC plugin hiedb-plugin, which leverage HIE files to provide features like go-to-definition, hover documentation, and even automatic imports without the lag and memory usage of HLS.
Why reimplement ghcid?
Ghcid is only about 2,000 lines of Haskell, is no longer being developed, and is architecturally unamenable to implementing the features we wanted. Additionally, Rust’s notify crate is much more flexible and mature than Haskell’s fsnotify package, with features like runtime reconfiguration that make watching and responding to file changes easy to implement. As a result, we decided to go with a fresh implementation and build our replacement from the ground up.
One issue we wanted to fix with ghcid is caused by a GHCi bug which renders a GHCi session unusable after a module is moved or deleted. Ghcid doesn’t differentiate between file modifications and deletions, so engineers must manually restart it when modules are moved. In contrast, ghciwatch examines how files change in order to determine when the underlying GHCi session needs to be restarted. Ghciwatch also supports a number of lifecycle hooks so that you can use tools like hpack
to automatically regenerate your .cabal
files before a restart.
Ghcid also assumes that the set of files loaded in the underlying GHCi session will never change, and as a result it’s unable to add new files when they’re created. This is another scenario which requires engineers to manually restart their ghcid session. Ghciwatch tracks the set of loaded files, so it knows when to :reload
the session and when to :add
a new module. Ghciwatch also tracks if each module was loaded as a file path or as a module name to work around another GHCi bug.
Additionally, we wanted to forward GHCi output to the user as soon as it’s printed, so that engineers can see the compilation progress during longer reloads. Ghcid, in contrast, just shows a “Reloading…” indicator during this process. Printing output for the user requires figuring out when GHCi is printing a prompt, which is a bit tricky because prompt lines don’t end in a newline, so a standard line-buffered streams is insufficient here (we solved this with an abstraction we call an IncrementalReader
).
Ghcid only reloads the session when Haskell files change, meaning it will ignore changes to other files that impact the build, like persistent
models or shakespeare
templates. While it’s possible to pass additional filenames to the --reload
or --restart
options, ghcid won’t be able to detect newly-created files that match the same patterns. Ghciwatch supports matching files against globs using the full gitignore
syntax so that it knows exactly when to reload and restart.
Like ghcid, ghciwatch is able to evaluate Haskell code in comments for quick testing and debugging, but ghciwatch’s eval comments are able to access top-level bindings of the module they’re defined in, including unexported bindings.
What’s next?
Ghciwatch has fully replaced ghcid in our development environment, but that doesn’t mean we’re done with it. We’re experimenting with replaying warnings for stale files, a TUI display that could include a progress bar when modules are compiling, and an interactive prompt for running Haskell code just like in an unmanaged GHCi session.
You can try ghciwatch right now; check out the user manual and installation instructions to get started!
Rebecca Turner