Elm Compiler Fork — Benchmark Showcase

Each example below compiles the same (or, where noted, an equivalent) small Elm program with the official Elm 0.19.1 compiler on the left and with this fork's patched compiler on the right. Nothing runs until you open a link, and each one opens as a standalone page in a new tab — so a heavy benchmark (or one of the two Worker demos' deliberately blocking "old" sides) only ever freezes its own tab, never this page or any other example.

1. List Pipeline Fusion

A chain of List.filter / List.map stages ending in List.foldl or List.sum normally allocates one full intermediate list per stage, then throws every one of them away except the last. This fork recognizes that shape at compile time and fuses the whole pipeline into a single loop that never allocates the intermediate lists at all — a technique called deforestation, or fusion.

Expected: roughly 8×–16× faster, growing with pipeline length.

pipeline4 : List Int -> Int
pipeline4 xs =
    xs
        |> List.filter isValid
        |> List.map transform
        |> List.filter isBig
        |> List.foldl (+) 0

Old (Elm 0.19.1)

Open in new tab ↗

New (this fork)

Open in new tab ↗

2. Html/Producer-Chain Fusion

The same fusion idea, but for a chain with no final fold — the shape you get from real UI code like Html.ul [] (List.map viewItem (List.filter isVisible items)). There's no single accumulator to fold into, so the compiler instead fuses the chain into one loop that builds only the final list.

Expected: roughly 2.75×–8.17× faster.

chain4 : List Int -> List String
chain4 xs =
    xs
        |> List.filter isValid
        |> List.map transform
        |> List.filter isBig
        |> List.map final

Old (Elm 0.19.1)

Open in new tab ↗

New (this fork)

Open in new tab ↗

3. TRMC Stack-Safety

Naive recursive list-building (n :: buildList (n + 1) end) isn't tail-recursive in the usual sense — the :: happens after the recursive call returns — so a plain compiler has no choice but to grow the JavaScript call stack by one frame per element. This fork detects this "tail recursion modulo cons" (TRMC) shape and rewrites it into a loop that builds the list without growing the stack at all.

Building a 1,000,000-element list this way crashes the official compiler's output; this fork's output finishes in well under a second.

buildList : Int -> Int -> List Int
buildList n end =
    if n > end then
        []
    else
        n :: buildList (n + 1) end

Old (Elm 0.19.1)

Open in new tab ↗

New (this fork)

Open in new tab ↗

4. Kernel List Shape Padding

Every freshly-built Elm list node has the same two-field shape ({ $, a, b }), but a non-empty node and the empty-list value [] don't share that shape in the generated JavaScript by default, which costs V8 an extra hidden-class check on every access. This fork pads [] to match, in --optimize builds only. It's a small, allocation-shape-level win rather than an algorithmic one.

Expected: roughly +8% on workloads dominated by many short, freshly-built lists — this is the smallest win in this showcase, deliberately included to show not every fix is a headline number.

sumShortLists : Int -> Int
sumShortLists reps =
    List.foldl
        (\_ acc -> acc + List.sum (List.range 1 5))
        0
        (List.range 1 reps)

Old (Elm 0.19.1)

Open in new tab ↗

New (this fork)

Open in new tab ↗

5. Unwrapped Higher-Order Functions

Every call through a generic higher-order function like List.map or List.foldr normally goes through Elm's generic A2/A3 arity-dispatch helpers, even when the callback's arity is already known statically. This fork bypasses that dispatch and calls the callback directly whenever the whole-program analysis can prove it's safe.

Expected: a real, positive win, though the exact size varies by machine and by which function is measured (map benefits more than foldr here) — the original optimization work measured +27–28% on a multi-stage pipeline; a single call like this doesn't also benefit from the fusion optimizations shown above, so don't be surprised if the two sides land further apart than that.

mapOnce : List Int -> List Int
mapOnce xs =
    List.map (\n -> n * 2 + 1) xs

Old (Elm 0.19.1)

Open in new tab ↗

New (this fork)

Open in new tab ↗

6. Record-Update Inlining (new syntax)

This fork adds dotted-path record updates: { model | account.address.city = "Berlin" } instead of manually re-nesting three record updates by hand. The official compiler simply can't parse that — it's new surface syntax, not just a runtime difference — so the "old" panel below runs the verbose, semantically-equivalent code Elm developers had to write before. On top of the syntax, this fork's code generator now always compiles a record update to a direct object literal in --optimize builds, instead of sometimes calling a runtime helper.

Expected: a clear win that scales with how many fields get copied, shown here at two record sizes — the original optimization work measured roughly +7%–35%; single-record-update calls are so fast that the exact ratio varies noticeably by machine and browser.

-- New syntax (this fork only):
setCity model =
    { model | account.address.city = "Berlin" }

-- Old (official Elm), same result by hand:
setCity model =
    let
        account = model.account
        address = account.address
    in
    { model | account = { account | address = { address | city = "Berlin" } } }

Old (Elm 0.19.1)

Open in new tab ↗

New (this fork)

Open in new tab ↗

Worker.run: Web Worker Offloading

Worker.run is a new fork-only feature (backed by the separate andre-dietrich/worker package) that runs a plain top-level function on a dedicated Web Worker and hands back a Task. The official compiler doesn't trust that package as a source of kernel code at all, so the "old" panels below run the exact same computation directly, blocking the main thread — not a compile-error showcase, just how it had to be written before.

Fibonacci — does the UI stay responsive?

A naive, exponential fib n takes a few real seconds around n = 42 (both panels default to that). Both panels tick a counter every 100ms while running — watch whether it keeps moving.

fib : Int -> Int
fib n =
    let
        go k = if k < 2 then k else go (k - 1) + go (k - 2)
    in
    go n

-- new side only:
Task.attempt GotResult (Worker.run fib n)

Old (Elm 0.19.1, blocking)

Open in new tab ↗

New (this fork, Worker.run)

Open in new tab ↗

Ackermann — does a crash take the whole page down?

ackermann(3, n)'s doubly-nested recursion isn't a shape either compiler can optimize away, so both eventually hit a real JavaScript stack overflow — around n = 12 on the main thread, a little earlier (around n = 10) inside the Web Worker, which gets a smaller default stack than the main thread in this browser. Try n = 9 first (succeeds on both), then n = 13. The point isn't that the crash goes away — it's what happens around it.

ackermann : Int -> Int
ackermann n =
    let
        go m k =
            if m == 0 then k + 1
            else if k == 0 then go (m - 1) 1
            else go (m - 1) (go m (k - 1))
    in
    go 3 n

Old (Elm 0.19.1, blocking)

Open in new tab ↗

New (this fork, Worker.run)

Open in new tab ↗