Eco - Compile Elm to native code

I am delighted to be able to share my new Elm compiler project with you today.

eco - Elm Compiler Optimized - is a new optimizing compiler infrastructure for Elm. Here is eco - introduction

eco is fully open sourced today - GitHub - eco-lang/eco-compiler: eco compiler for elm · GitHub

Some things about eco that may interest you:

  • Self-compiling “boot strapped” compiler.
  • Front-end in Elm, back-end in C++. No Javascript and no Haskell.
  • Generates x86 native code via MLIR and LLVM (arm64 for Mac soon)
  • Generational Garbage Collector
  • Aims to be 100% Elm compatible (core, json, bytes, http, regex, url, parser, time, already implemented)
  • Can run existing Platform.Worker + NodeJS programs with Elm.init and ports in Javascript linking to a native binary for the Elm program.
  • Int compiles to native 64-bit integers
  • There are > 150,000 elm-test tests against the front-end compiler pipeline.

What are the long-term goals here ?

  • A multi-threaded Elm runtime with parallel Tasks and TEA Actors
  • Support for servers with high core counts and large RAM - eco supports 8TB address space.
  • Throughput and latency to match C++/Rust/zig
  • Ref counting and optimistic mutation optimizations to replace GC.
  • Keep the gradual migration path from existing 0.19 Elm programs on NodeJS
  • Potential pathway to compiling web apps to WASM.

Ok, those are some grand claims and there is a long way still to go to achieve them, but I wanted to share my vision for what I believe will be possible.


The version being made public today is 0.1.0 alpha.

Please note that the alpha label is a suggestion that you should interpret this work as experimental today. There will be bugs, possibly even severe ones. Please find some bugs and tell me about them in the GitHub issues. This is the whole point of making an alpha release and starting the journey of the code from my computer to meet the real world.

Some limitations of eco 0.1.0 alpha:

  • Single threaded, including the whole compiler pipeline, the compiler is not fast.
  • Runtime performance is moderate - roughly the same as running under NodeJS.
  • Minimal IO API (to support the compiler).
  • GC pause times are significant, it is single threaded, your program thread does the collection work.
  • “hello world” binary is 8MB in size - mostly debug symbols. Needs debug stripping and not linking unused kernel code - should be around 100KB after that.
  • 0.1.0-alpha release only supports Linux x86. Windows and Mac builds will follow very soon.

It is risky to make an early release such as this if people are expecting higher performance because it compiles to native. Please be understanding of the early version number and experimental nature of this project at this time.

I am confident that performance can be greatly improved, all the techniques are known, it is just a question of putting in the work to implement them. Right now there are so many levers to pull on to make it faster, the harder choice is choosing the correct levers to pull first.

eco has its compiler front end implemented in Elm. This part started its life as the Guida elm-in-elm compiler. eco adds a new pipeline to that codebase for an optimizing compiler pass that generates native machine code. The back-end code generation part of eco is implemented in C++ and built on top of LLVM and MLIR. There is a well defined bytecode at the boundary between front-end and back-end, called the “eco dialect”.

The compiler is already capable of self compiling. This is a 160K LOC codebase, some of which makes heavy use of continuation passing style. There are over 150,000 tests applied to the code, and over 1000 end-to-end Elm test programs that try to cover many corner cases of the language itself. So you should find that the compiler is well tested and capable of handling large and complex programs.


I began working on eco in November last year. Since this is the second elm-to-native compiler you are hearing about in just a few short weeks! It is worth mentioning that eco is an entirely separate and independant project. eco builds a new compiler pipeline that branches off at the type checking phase to enable more high level optimisations that operate at the language level and make use of type information - such as monomorphization.


Full disclosure - most of the code is implemented by AI, Claude Code specifically. It is not “vibe coded”, there is a strong software engineering and design element to drive that code generation. Without AI this project could have taken 2 or 3 years to implement at least. The structure of the code is good and not a mess. My current feeling is that I should use less AI during a tidy up phase of the code, in order to ensure that the kinds of mistakes that AI can make get reviewed out. But I want to be transparent about this in case you are against AI - you might not like what you find here!

27 Likes

I am starting again.

As I said on the other thread for the last 6 months I worked 40/50/60 hours a week to make this. It is my original work in its entirety but was coded with the assistance of Claude. Please respect the amount of work I have put into this instead of just attacking what I have done. Only a very unkind person would do that.

Lets maybe have some questions about the code, the ideas, future plans. Or perhaps you have tried it out and it is not working or you need some help ?

11 Likes

I posted this before because I really enjoyed this talk. I worked for John O’hara about 20 years ago - he was the one who started AMQP (RabbitMQ).

This talk made me have a lot of thoughts about the kinds of software systems we could build on modern CPUs.

When I think about threaded actor model, CPU affinity and cache aligned architectures, I also think about how could we do this with Elm ? How could we take advantage of this hardware without compromise, yet build in an aesthetically brilliant language (Elm) and use domain modelling to build software that “makes illegal states impossible” at the same time. That is the goal that I am aiming for, although it is still a long way off to achieve it.

So now you know why I posted about Elm + threads: If Elm had threads..?

And there is an experimental API and simulation of it here: GitHub - rupertlssmith/elm-with-threads: What would Elm with threads be like? · GitHub

eco runtime is currently single threaded only. Your program thread even runs the garbage collection! I am already thinking about how to introduce threads into the implementation - the API side seems fairly clear - but on the implementation side, when to have heaps that are shared between threads or not, when considering the question of how to have the highest possible event throughput between threads.

Perhaps it is better to tackle reference counting first, and eliminate much of the GC work.

2 Likes

Exciting to see this announced! I was wondering if it supports reproducible builds (i.e. is the binary produced for a particular CPU architecture deterministic)?

Yes, it should be.

There is a step in the build pipeline where the compiler builds itself and then compares the output to the original to check they are binary equal. For a long time only the MLIR bytecode was passing that test because the binaries contain symbol tables that were not ordered deterministically. That should be fixed now, and the actual executables are deterministic.

3 Likes

Somebody at Elm Camp asked me about the compiler bootstrap process, which I did not do a great job of explaining. It is quite complicated, so here it is:

Stage 1 - The stock npm Elm compiler compiles the Eco source using XHR-based IO, without --optimize to JS (the XHR Eco.Crash uses Debug.todo).

Gate A - Run the E2E suite through Stage 1’s ouput to validate the frontend + MLIR-codegen + runtime + JIT stack.

Stage 2 - JS self-compiles with kernel IO enabled (Eco.Kernel.*, enabling --optimize with Eco.Crash), producing eco-boot.js.

Stage 3 - eco-boot.js compiles itself to eco-boot-2.js.

Stage 4 - eco-boot-2.js compiles itself to eco-boot-3.js, then diffs eco-boot-2.js vs eco-boot-3.js; they must be byte-identical (JS fixed point reached).

Gate B - Run the AOT E2E suite: compile each test via eco-boot-2.js, lower to native ELF via eco-boot-native, run, and check stdout against – CHECK: patterns.

Stage 5 - The fixed-point-verified eco-boot-2.js compiles itself to MLIR producing eco-compiler.mlir

Stage 6 - eco-boot-native lowers eco-compiler.mlir (Eco dialect → LLVM dialect → LLVM IR → object) and links with runtime + C++ kernel static libs into a native x86-64 ELF, eco-compiler.

Stage 7 - The native eco-compiler self-compiles to MLIR, which eco-boot-native lowers into the bootstrapped native executable eco-compiler-boot.

Stage 8 - eco-compiler-boot self-compiles again, is lowered, and the result is compared byte-for-byte against eco-compiler-boot (native fixed point), yielding eco-compiler-boot-2.

Stage 9 - Fuse the front-end (eco-compiler) and the lowering back-end into a single user-facing eco binary.

Stage 9b - eco self-compiles to eco-2; a successful self-compile is the success criterion.

JIT = Just In Time LLVM runner.
XHR = XmlHttpRequest hack, a way to write new Tasks for Elm by hijacking elm/http.
AOT = Ahead Of Time, that is, compilation to native binary.
CHECK patterns = Comments in Elm E2E tests that state what the expected test output should be.
E2E = End to End test suite
LLVM = Low Level Virtual Machine
MLIR = Mid Level Intermediate Representation, lowered to LLVM.

This shows how we go from Elm JS kernel only, through Eco JS kernel and then to Elm+Eco C++ kernel implementations.

Once the compiler self-builds, we could just go eco → eco → eco from that point onwards. But it remains important to keep the entire pipeline running for some time. For example, maybe there is some bug in the fixpoint compiler that cannot be removed, and we need to go right back to the original Elm compiler to fix it. Unlikely, but sensible to keep the road open anyway.

6 Likes

I’d love to hear about the plans for the multi-core concurrency system + how the memory management system may work with it. Do you have any details to share? :slight_smile:

What’s the in-memory representation used for each of the Elm data-types? Is monomorphisation used, or is something like NaN-boxing or pointer tagging used?

How is JavaScript code run for ports? Does the runtime spawn a NodeJS instance and communicate with it somehow?

It seems probable that ports would be the slowest part of an Elm program once that C-comparable performance goal is reached. Would there be some native code version of ports?

2 Likes

Undecided yet, but here are some thoughts, and if it gives you some good ideas then please suggest them.

Currently single threaded, with two level GC. There is a nursery heap, and an old generation with sized buckets. Once an object survices collection a few times in the nursery, it is moved to old gen and only a small % of objects ever make it. Nursery uses a fast Cheney collector, old gen uses incremental marking and sweep. The collector thread is your program thread, so it steals around 10% of your program time to do GC. There is also a scheme for large objects, which allocate directly into the old gen and overlap with the normal oldgen buckets and some larger ones too - that might have been a bit of a mistake and large objects may work better with their own space entirely.

Due to Elms immutability, there can never be a pointer from oldgen to nursery, which eliminates a significant amount of GC complexity. Due to being single threaded, achieving the GC program pause is also very simple, as is allocating and bumping the allocation pointer without being concerned about concurrency.

For multi-threaded, I think it will continue to make sense for each thread to have its own nursery. The harder choice is the old gen.

If old gen is shared, it means sending events from one thread to another is going to always be zero copy - provided we allocated or bump events into oldgen that is. But the cost is a multi-threaded oldgen.

If old gen is per-thread, it means sending events from one thread to another is going to mean copying, but it will be simpler - so perhaps I try this first and do not worry about performance.

But, there may be some third way. Static analysis to know at compile time which objects can ever be shared and a separate allocation space for those. Something like that - if you ever heard of such a system, post a link or a paper.

Heap representation is: eco-compiler/runtime/src/allocator/Heap.hpp at master · eco-lang/eco-compiler · GitHub

So tagged values on the heap.

Int, Float and Char can all be unboxed. So if you have Tuple2 (String, String) you end up with a heap allocated tuple pointing to 2 heap allocated Strings. If you have Tuple2 (Int, Float) you end up with a single heap allocated tuple with an Int and a Float directly inside it. Int is a proper 64-bit integer. Char is UTF-16

The ABI for compiled Elm code and kernel functions allows Int, Float, Char to be passed unboxed. There is some escape analysis that will allow small objects to be allocated and passed on the stack. Deeper escape analysis is blocked by closures and needs some closure optimisation work first to unlock it.

Heap pointers are inside 64-bit words. 3 bits are used for constants, “”, {}, (), Nothing, True and False. Currently 40-bits are used for pointers, heap is 8 byte aligned, so shift left by 3 = 43 which yield 8Tb addressable. Space for more, but if you can afford 8Tb of RAM at todays prices! I do want to support big iron servers though.

When hosting from Javascript, the compiler outputs a binary which is essentially a NodeJS plug-in. You can do the Elm.init and ports from the Javascript side, but your program runs as a native binary.

To see an example of this working, this project here was originally written for Elm + NodeJS, but will compile and run without any changes at all as Eco + NodeJS:

Build it with:

eco make src/elm/Top.elm --output=src/elm.node

Which will yield an elm.node and an elm.js that contains this line to load the binary:

module.exports = require('./elm.node');

Yes, you can also start up the program from C++ and write ports in C++ and have zero Javascript. I think the Javascript plugin thing is quite neat though as a way of getting started and trying out some existing Platform.worker code with no changes at all.

One limitation of eco currently, is that it does not yet have a proper IO library. There is one built in with kernel code in the Eco.* space, but it is really the minimal amount of IO that that compiler itself needs. So if you do try that out, bear in mind that it is not an API for the long run, and we need something to fill that role - actually would love to hear peoples thoughts on that. Or I can propose something and present it and gather feedback, then implement once it looks right.

The NodeJS route will let you write whatever IO you need as ports at this early stage.

2 Likes

This is an elm-doc-preview for the eco kernel, which has a C++ implementation. This primarily exists to support the compiler and provides the minimal amount of IO that the compiler needs to run. It is not the API for a core eco kernel that will be supported longer term; it is temporary, but it can be used to try out eco.

The eco compiler already knows how to find the package for this, if the install bundles are used, it just looks relative to where the eco binary is on the file system to find them.

So you can put "eco/kernel": "1.0.0" in direct dependencies in the elm.json, then write code against these APIs, and do some basic IO.

The MVar stuff gives you Haskel-like MVar - mutable var. In the original Elm Haskel compiler this is used to signal between threads when dependencies are ready, and lazily reduce the dependency graph in the correct order for compilation.

In Guida, this is just a Dict implemented on the Javascript side, with no real inter-thread signalling, and Process.spawn is a no-op. Produces the same result, a reduce on the depenency graph in the correct order, but single threaded.

In eco, I decided to keep this in, as once threads are implemented it should be fairly simple to restore the multi-threaded compilation of the original Elm compiler, which might really help compile times.

This is great! I’ve been thinking about implementing an Elm interpreter lately, or maybe a compiler, because I think it might work out as a great scripting language. But I also wanted to make a compiler! And in just a few weeks, two projects came out :tada:

So, first, congratulations for this milestone!

I’ll keep an eye on the project, but since you mentioned throughput matching C++, Rust and Zig, I wanted to mention something that IMO only Zig has and should be matched: the ability to cross-compile to easily for different targets.

I think that tooling is very important and having a self container compiler that is easy to distribute and makes easy to compile to all the supported targets is great. If it can also format, handle the project files, dependencies and provide a repl, that’s just awesome.

But hey, however it goes, good luck!

1 Like

You might be interested in what @miniBill has been working on then! GitHub - miniBill/elm-interpreter · GitHub (Don’t want to detract from the main discussion, but seemed worth mentioning here.)

1 Like

I never tried it, but cross compilation is supported by LLVM, so perhaps it can be done. For now, I was thinking that it just compiles a binary for whatever machine you run it on.

My original eco idea, years ago, was “elm compiler offline”. A version of the elm compiler that would consume packages from the file system and run completely offline. Unbundle the packaging in order to allow that to be handled by some third party tool.

In general I am not such a fan of the idea of having a compiler that bundles many tools into a single executable. That is the direction that Guida went in, and eco forks Guida. But I removed the format and test commands as I do not believe the compiler is the right place for them.

I would also consider removing install/uninstall/bump/publish and having a separate tool for that. publish is also not enabled currently, to avoid accidental polution of the elm package registry by anything other than the original Elm compiler.

I should probably have mentioned before that is something I regard as being important - to leave the existing Elm ecosystem undisturbed and to try and be careful not to do anything that might damage it.

BTW: I recently modified my port of the Elm compiler so that it can also be called from the command line and is byte-for-byte compatible with the original. (The reason for this is so that I can better test compatibility with upcoming Elm versions.) On this occasion I reconsidered implementing these commands (bump, diff, publish) as well, but your thoughts are exactly the reason why I didn’t do so.

3 Likes

This is what zig also does as far as I understand. Well it might have changed a bit since because now there is increased focus on extending the native code gen backend with optimizations and move away from LLVM backend. But doing the thing zig does comes also at significant cost. They bundle clang and all sorts of system headers to the compiler to make cross-compilation seamless. They also implemented their own linkers I believe. It’s relatively heavy and costly to maintain feature I would imagine.

1 Like

Also worth mentioning that I went with Guida instead of your port, for 2 reasons really.

  1. Guida already functioned like a drop-in replacement for elm from the CLI, which yours did not have at the time I was deciding.

  2. Yours looked even more like Haskell !

It would be interesting to take another look at your compiler though. In particular how your port of the typechecker is. There is a sort of two-level continuation passing style thing going on in Guida, which must be an artifact of porting from Haskell and also ensuring it does not blow the stack.

My monomorphization implementation is a bit unsatisfying, in the sense that I got maybe only 60% of the way to solving the general problem, then patched and patched and patched until it worked. Which means that I do not have 100% confidence that it is fully correct, although it does pass quite a comprehensive suite of tests.

To my thinking, there is a more elegant way to do monomorphization, which is to re-use the typechecker. Bind some type args to the type you are specializing to, then re-solve.

So looking into typechecker implementations will be worthwhile to understand how it is implemented, and how efficiently it can run. (and not just because all I do is copy other peoples stuff! :wink: )

You have absolutely no reason to justify this decision. Décio and I are not competitors. I ported the compiler for my own purposes. If the code helps someone else: great. If not: no problem.

I just mentioned my port to support your decision for removing publish.

I tried to keep as close to the Haskell code as I could. (That’s your point 2 above :grin:) I haven’t looked at Guida for a long time, so I don’t know whether there are fundamental differences in this part of the code.

I thought it might be interesting to show how the eco compiler has been developed, also in the interests of being transparent about where ideas came from, and in what order they get incorporated into the work. It is certainly interesting for me to look back over it!

This was generated by AI, I apologize if that is irritating to some people. All I did here was git log > gitlog.txt then feed the gitlog to AI and request that the story of the development timeline be told.

Eco Compiler Development Timeline

  1. Mid November 2025 — The runtime begins.
    The project starts with CMake scaffolding for a new C++ runtime, followed almost immediately by the definition of the heap model: object headers, value layouts, heap tags, pointer representation, and the first shape of Elm values in native memory.

  2. Late November 2025 — The garbage collector becomes the first major foundation.
    A generational garbage collector is built and tested heavily. The work quickly expands from a nursery collector into old-generation allocation, promotion, compaction, stack roots, property-based testing, stress tests, and a written theory of how the heap and collector are meant to behave.

  3. Late November 2025 — The project direction broadens from runtime to compiler.
    The roadmap starts to describe Eco as more than a runtime experiment. The work begins to connect the heap, GC, LLVM stack maps, MLIR, and the eventual compiler pipeline into one coherent system.

  4. Early December 2025 — The compiler codebase is brought in.
    The Guida compiler is imported under the compiler tree, giving Eco a serious starting point for the Elm front end. Around the same time, the first Eco MLIR dialect definitions and TableGen build support appear.

  5. Early December 2025 — Type-preserving IR becomes a central design decision.
    Eco adds a typed optimized IR for the MLIR backend. This is an important architectural moment: rather than lowering from an erased representation, the native backend is built around preserving enough type information to drive monomorphization, ABI choices, and correct native code generation.

  6. Early to mid December 2025 — Monomorphization and the MLIR backend take shape.
    A monomorphization pass is added, initially driven by lambda sets. This is where the compiler starts to take Elm’s polymorphic, higher-order code and turn it into specialized native forms suitable for MLIR and LLVM.

  7. Mid December 2025 — First end-to-end Elm-to-JIT execution.
    Eco reaches a major milestone: end-to-end Elm-to-JIT compilation works for the first time. Kernel modules are built as static libraries, runtime symbols are registered with the JIT, and Elm E2E tests begin to verify real compiled programs.

  8. Late December 2025 to January 2026 — Function calls, closures, and runtime correctness are hardened.
    Work focuses on partial application, closure calling, tail calls, boxed calling conventions, Debug output, and the relationship between high-level Elm functions and the native runtime ABI. This period turns the early backend into something capable of handling more realistic Elm code.

  9. January 2026 — Test infrastructure becomes a major asset.
    Codegen tests, isolated runners, parallel test execution, and more systematic regression testing are added. This is the point where the project begins to look less like a prototype and more like a compiler engineering effort with a growing safety net.

  10. Late February 2026 — The bootstrap IO layer is introduced.
    The compiler’s IO is routed through the new Eco kernel/XHR layer, with both C++ and JavaScript kernel implementations. This is an important step toward bootstrapping, because the compiler now has a clearer path to running through Eco-controlled IO abstractions rather than relying only on the original JavaScript environment.

  11. Early March 2026 — Bootstrapping becomes explicit.
    The compiler gains --kernel-package and --local-package options, the bootstrap process is documented, and the stage 5 bootstrap path emits the compiler to MLIR. Around the same period, eco-boot-native appears as an AOT compiler binary.

  12. Late March 2026 — Eco moves to binary MLIR bytecode output.
    The compiler adds binary MLIR bytecode output and then switches to a streaming bytecode encoder. This is a major artifact-format milestone: Eco is no longer just emitting text-like intermediate output, but producing a compact serialized compiler artifact.

  13. Late March to April 2026 — Precise GC integration with LLVM becomes a central focus.
    Eco adds LLVM statepoint-based GC roots, safepoints, stack-map scanning, relocation handling, and stack unwinding. This is a difficult part of the project: the native compiler and the garbage collector have to agree exactly about where live pointers are during compiled execution.

  14. Mid April 2026 — The compiled-code GC story is made much more robust.
    Work on RewriteStatepointsForGC, shadow root frames, stack-map-based scanning, runtime saved state, and libunwind integration strengthens the path from generated MLIR/LLVM code to safe execution under the Eco garbage collector.

  15. Early to mid May 2026 — Higher-order Elm code and native closure dispatch mature.
    Several commits focus on closure calls, partial applications, higher-order kernels, closure captures, and typed-result dispatch. This is a major language-coverage phase: Eco is made better at handling the patterns that real Elm programs rely on.

  16. Mid May 2026 — Representation optimization is explored and then deliberately narrowed.
    Escape analysis and unboxed aggregate specialization are developed, instrumented, and evaluated. Some of that work is later removed or scaled back, which looks like an important engineering decision: correctness and maintainability are preferred over keeping an optimization path that was not yet paying its way.

  17. Late May 2026 — Static Linux distribution and native HTTP/kernel support arrive.
    Eco gains a static musl/libc++ build path, AOT static linking, release-binary portability work, and a real libcurl-backed elm/http kernel. Package download behaviour, archive extraction, structured IO errors, and HTTP test failures are worked through.

  18. Early June 2026 — Release packaging and public-facing documentation take shape.
    The project adds portable distribution bundle packaging, a v0.1.0 release bundle, kernel sources, examples, a .deb package, improved Docker build documentation, a split documentation tree, and a minimal Getting Started guide. This is the transition from “compiler under construction” to “project someone else can try.”

  19. 8–12 June 2026 — A deep monomorphization correctness push.
    A large set of regression tests exposes subtle bugs around let-bound number values defaulting to Int when their use sites demand Float. The fixes make specialization more demand-driven, covering destructuring, closures, records, tuples, containers, and pattern matching. This is one of the most significant compiler-correctness efforts in the history.

  20. 11–17 June 2026 — Native embedding and cross-platform support land.
    Eco implements native ports, C and Node.js host embedding, JSON flags, .so and .node output, glibc shared-output support from the static musl bundle, and Node event-loop liveness handling. Immediately after that, the project moves onto macOS and Windows: statepoint/stackmap experiments, platform seams, macOS AOT bundles, Windows unwind and linking work, and GitHub workflows for Linux, macOS, and Windows. By mid June, Eco has moved from a Linux-focused native compiler toward a serious cross-platform toolchain.

1 Like

I do remember your MLIR package getting published (I may stalk the #packages channel too much :sweat_smile:) and wondering what you’d be using it for. Exciting to see where it’s all going!

Thanks! And you just reminded me of something. That package got folded into the compiler as it was easier to work on that way, I should really tidy it up and extract as a package again.

Support for streaming MLIR bytecode was added, so that the whole lot does not need to be in memory at once, was necessary to get it to run without OOM on self-build.

BTW - I looked into your typechecker port because it is simpler and easier to follow than the Guida port. But the guida port introduced a more complex framing of the same algorithm for reasons.

Stack overflow can happen if you just recurse into a large AST, so it added trampolines via CPS style coding. And for speed a union find that is Array backed, But the CPS style stuff was not fully necessary and implemented actually in a 2 layer wrapper, so the code was very difficult to follow.

I considered converting it to a simple work-list style encoding, using a List as explicit stack, but even that is overkill for much of it. AST tends to have a linear “spine” and branching around that, and the spine is unlikely to be crazily deep.

So I now refactored it to recurse down the spine and use work-lists for some of the branches to attain a good balance of stack safety and speed whilst stripping out much of the complexity.

Next I am going to look at whether the type checking solver engine can be re-used to drive monomorphization - in theory yes. Type checking is find the most general type. Monomorphization is effectively bind some type vars to concrete types, then re-solve and extract specializations. Kinda!