Blog
Blog Details

Why one Elixir module took a minute to compile

Christian Koch
October 30, 2026
A compiler pipeline: source code flows through a chain of passes, one pass loops sixteen times, and bytecode comes out.

How we traced a 106-second build down to one optimizer pass, and what we learned that applies outside compilers too.

Background: why an Elixir team is reading the Erlang compiler

Gearflow builds AI-powered sourcing software for fleets. Our platform is written in Elixir, with Phoenix.

Elixir runs on the BEAM, Erlang's virtual machine, and it uses Erlang's compiler. The Elixir compiler expands macros and turns each module into Erlang's abstract format. The Erlang compiler then optimizes that code and writes the .beam file. So a slow Elixir build can spend its time in Erlang's optimizer, in code that no Elixir developer wrote directly.

That is where our build time went.

We recently profiled one of our Phoenix application builds and found something surprising: a huge amount of time was being spent inside beam_ssa_opt, an Erlang compiler module that runs optimizations on the compiler's static single assignment representation, or SSA. In the build we were looking at, one module alone was taking around 58 seconds out of a 106-second build.

Here is how we narrowed that down, and what we found.

First: a suspicious timing pattern

Erlang’s back end accounted for 88% of compile CPU. The beam_ssa_opt pass accounted for 60% of measured pass time.

We disabled optimizer passes as an experiment. With no_ssa_opt, one compile dropped from 59 seconds to 22 seconds, but the generated code changed. Individual sub-pass switches saved 4 to 11 seconds. The no_ssa_opt_live experiment crashed the compiler.

The useful clue was that one disabled sub-pass saved more time than that sub-pass itself consumed. Its direct cost did not explain the result. Something else changed when we removed it.

On its own, that is only a clue. It is definitely not proof that a specific optimizer pass is at fault. But it made us suspicious that some work was being repeated, so we decided to look more closely.

Narrowing it down to a single function

To investigate, we added tracing so that we could see, for each function being compiled:

  • which optimizer passes ran
  • how many times the fixpoint in beam_ssa_opt ran
  • how the internal representation of the function changed between rounds

A "fixpoint" here just means: keep running optimization passes until the code stops changing, then stop. In this case the fixpoint is configured to run at most 16 rounds.

The trace output showed us exactly which function was slow, and it also showed something odd: the label counter for the reproducer’s function increased by 1 on every round, even though the final compiled code looked the same.

In one local run, our tracer reported 17 calls to beam_ssa_opt:fixpoint/6 for those 16 optimization rounds.

The cause: try blocks plus a non-idempotent optimization

The functions we were seeing problems with had a specific shape: they contained try expressions.

We are not saying that every use of try necessarily triggers this. But the functions we tested with this pattern did.

Inside beam_ssa_opt, the ssa_opt_try pass calls a helper called shrink_try/3. That helper looks at a try block and its success block, and tries to hoist some instructions so they can run earlier.

The surprising part is what happens when there is nothing to hoist.

Instead of leaving the code alone, shrink_try/3 still creates a new empty hoist block and a new label. That means every round of the fixpoint produces slightly different intermediate code, even when the optimization did not actually move anything.

Because the code changed, the fixpoint thinks it needs to run the function through another round. For a large function, that gets expensive fast: several expensive passes end up running 16 times over the same function.

Later, another pass removes the empty hoist block, which is why the final compiled code can still come out the same. The main cost is compile time.

A small reproducer

We put together a small reproduction case here: erlang/otp#12046.

It generates a module with one large function containing a try, compiles it, and reports:

  • how many times beam_ssa_opt:fixpoint/6 was called
  • how the function's label count changed between rounds
  • how much time beam_ssa_opt took

Here are the results we saw locally:

  • 50 clauses: 17 fixpoint calls, beam_ssa_opt time 0.04 s
  • 200 clauses: 17 fixpoint calls, beam_ssa_opt time 0.20 s
  • 800 clauses: 17 fixpoint calls, beam_ssa_opt time 0.84 s

The important pattern is that the fixpoint runs to the limit instead of converging early.

A possible fix: do not split the block if nothing was hoisted

The change we experimented with locally is pretty small: if hoist_try_is/4 returns no instructions to hoist, then do not create the extra block and label.

The core of the patch is this branch:

{[], _} ->
    shrink_try([{SuccLbl, SuccBlk0} | Bs], Count0,
               [{TryLbl0, TryBlk} | Acc0]);

With that local patch, the fixpoint converged immediately in our reproducer:

  • 50 clauses: 2 fixpoint calls, beam_ssa_opt time 0.01 s
  • 200 clauses: 2 fixpoint calls, beam_ssa_opt time 0.05 s
  • 800 clauses: 2 fixpoint calls, beam_ssa_opt time 0.21 s

What we checked locally

These are local experimental results, not a production compiler rollout.

We tested the patched beam_ssa_opt.beam on OTP 29.0.6 in two ways.

First, we compiled 948 standalone OTP library modules with the stock and patched versions. For those modules, the executable code chunks in the generated .beam files were byte-identical.

Second, we compiled a Phoenix application with 2,139 modules. 2,115 of those modules produced matching code chunks. The other 24 differed even between two stock compiler runs, so these checks do not establish byte identity for those 24 modules.

For compile time in that application, we saw:

  • full build: 106.1 seconds stock, 88.5 seconds patched
  • largest module: about 57 to 59 seconds stock, about 39 to 40 seconds patched
  • beam_ssa_opt CPU time for the full build: 138.8 seconds stock, 115.2 seconds patched

We have not run the full compiler test suite with this patch.

Current status

As of October 29, 2026, the upstream issue is still open and we have not seen a maintainer reply yet: erlang/otp#12046. The issue does not identify a release with this fix.

Start with pass timings

For an Elixir build, enable Erlang compiler pass timings:

ERL_COMPILER_OPTIONS='[time]' mix compile --force

This forces recompilation, so compare equivalent builds. Pass timings locate expensive phases; per-function tracing shows which function repeats the work. The public issue includes that tracer and its match specification.

We kept the patched compiler as an experiment and did not ship it.

What we think is useful here, even if you never touch a compiler

Two parts of this investigation felt pretty general:

  1. Measure by function, not just by module or by total build time. Once we could see which function was slow, the problem got much smaller.
  2. When timing numbers do not add up, repetition is worth investigating. In our case, the fixpoint running to its maximum round count turned out to be the key clue.

If you are hitting pathological compile times in Erlang, one possible workaround is to split very large functions apart, but we have not measured that as a guaranteed fix.

Scale your fleet. Not the chaos.