I Built A Crystal Compiler With Parallel/Incremental Compilation

Hey everyone,

I’ve been working on incremental compilation for Crystal and I’m at the point where I need people with real projects to try it out and tell me what they see.

What is this?

crystal-alpha is a build of the Crystal compiler from my incremental-compilation branch. It tracks dependency changes at the file level and skips recompilation of files that haven’t changed. The result is that after your first compile, subsequent compiles while you’re developing should be significantly faster.

It also includes WASM compilation support (wasm32-wasi target), but the main thing I’m looking for feedback on right now is the incremental compilation experience during active development.

Install (macOS only right now)


brew tap crimson-knight/crystal-alpha

brew install crystal-alpha

That’s it. This installs alongside your existing crystal compiler - it doesn’t replace it.

Usage

It works exactly like crystal but the command is crystal-alpha:


# Build your project

crystal-alpha build src/my_app.cr -o bin/my_app

# Run specs

crystal-alpha spec

# Watch mode - recompiles on file changes

crystal-alpha watch src/my_app.cr

Watch Mode

This is where incremental compilation really shines. Instead of manually rebuilding after every change, run:


crystal-alpha watch src/my_app.cr -o bin/my_app

Watch mode uses macOS native kqueue file monitoring — it detects the moment you save a file and triggers an incremental rebuild automatically. Only the files you changed (and anything that depends on them) get recompiled. Everything else is cached.

A few things that make this practical for real development:

  • --run — Executes your binary after each successful compile. When you save a file, it kills the running process, recompiles, and restarts it. This is the one you want for web apps:

crystal-alpha watch src/my_app.cr -o bin/my_app --run

  • --clear — Clears the terminal before each compile so you get a clean output

  • Debouncing — Waits 300ms after a save before compiling, so rapid saves from your editor don’t cause repeated builds

  • Error recovery — If your code has an error, it prints the error and keeps watching. Fix the file, save, and it tries again immediately

The point of watch mode is that you leave it running in a terminal while you work. Save a file, see the result in seconds. For a project that normally takes 30+ seconds to compile from scratch, you should see incremental rebuilds in the low single digits.

What I want you to do

  1. Install it

  2. Go to your project directory

  3. Do a cold compile: crystal-alpha build src/your_app.cr -o bin/my_app

  4. Change a file - any file, even just add a comment

  5. Compile again

  6. Tell me how long both compiles took

You don’t need precise benchmarks. Even just “cold compile took about 30 seconds, second compile took about 4 seconds” is exactly what I’m looking for.

If you have a larger project, that’s even better. The bigger the project, the more dramatic the difference should be.

Bonus: Try watch mode for a real development session. Start crystal-alpha watch src/my_app.cr -o bin/my_app --run, make some edits, and tell me what the experience feels like compared to your normal workflow.

What to report back

  • What project you tested with (rough size is fine - “small CLI tool” vs “web app with 50 models”)

  • Cold compile time (rough is fine, time output works too)

  • Incremental compile time after a small change

  • Did it produce a correct binary? (aka Does your app still work?)

  • Any errors or weird behavior

Known limitations

  • macOS only (Apple Silicon and Intel) for now

  • This is an alpha - if something breaks, I want to hear about it

  • The first compile will always be the same speed as normal Crystal since there’s nothing cached yet

Thanks for helping test this. The goal is to make Crystal development feel as fast as editing a scripting language while keeping all the performance benefits of a compiled language.

5 Likes

I tried uploading this PDF of the final benchmarking that I did with some of the basic tests that I have, but so far this is some of the results that you can review and analyze at your leisure.

2 Likes

This is really exciting. Incremental compilation has long been considered a difficult problem for Crystal, so if it’s actually working, that’s a significant achievement. But I’ll be honest — what I want far more than using the results is understanding what new ideas made it possible. The desire to understand outweighs the desire to use.

I feel something similar about AI-driven research in general. Even if AI discovers something useful and it gets applied industrially, just consuming the output wouldn’t satisfy me. I need to trace the reasoning myself, even imperfectly — that’s where the real satisfaction fires up. And I think this matters more in the Crystal community than elsewhere, because many of us are here out of passion rather than profession.

(Translated from Japanese with Claude)

Is your repo private?

$ brew tap crimsonknight/crystal-alpha
==> Tapping crimsonknight/crystal-alpha
Cloning into '/opt/homebrew/Library/Taps/crimsonknight/homebrew-crystal-alpha'...
remote: Repository not found.
fatal: repository 'https://github.com/crimsonknight/homebrew-crystal-alpha/' not found
Error: Failure while executing; `git clone https://github.com/crimsonknight/homebrew-crystal-alpha /opt/homebrew/Library/Taps/crimsonknight/homebrew-crystal-alpha --origin=origin --template= --config core.fsmonitor=false` exited with 128.

Ooh, I see what the problem is. It’s missing the - from your GitHub username.

Running on an M4 Max.

Started with an empty build cache:

➜  malakai git:(main) ✗ crystal clear_cache

Building the web server entrypoint:

➜  malakai git:(main) ✗ time crystal-alpha build -o bin/web src/web.cr
ld: warning: search path '/opt/homebrew/Cellar/crystal-alpha/1.20.0-dev-incremental-2/lib/crystal' not found
ld: warning: search path '/opt/homebrew/Cellar/crystal-alpha/1.20.0-dev-incremental-2/lib/crystal' not found
ld: warning: search path '/opt/homebrew/Cellar/crystal-alpha/1.20.0-dev-incremental-2/lib/crystal' not found
ld: warning: search path '/opt/homebrew/Cellar/crystal-alpha/1.20.0-dev-incremental-2/lib/crystal' not found
ld: warning: search path '/opt/homebrew/Cellar/crystal-alpha/1.20.0-dev-incremental-2/lib/crystal' not found
crystal-alpha build -o bin/web src/web.cr  31.85s user 4.47s system 158% cpu 22.885 total
I don't know what caused those linker warnings but I did notice that this is about 50% slower than mainline Crystal
➜  malakai git:(main) ✗ time crystal build -o bin/web src/web.cr
crystal build -o bin/web src/web.cr  22.17s user 2.61s system 160% cpu 15.407 total

On second compilation (with one line changed):

➜  malakai git:(main) ✗ time crystal-alpha build -o bin/web src/web.cr
crystal-alpha build -o bin/web src/web.cr  9.42s user 2.60s system 172% cpu 6.973 total
About 75% slower than mainline Crystal this time
➜  malakai git:(main) ✗ time crystal build -o bin/web src/web.cr
crystal build -o bin/web src/web.cr  5.42s user 2.20s system 190% cpu 4.005 total

The compilations using mainline Crystal were done the same way as with crystal-alpha: crystal clear_cache first, then making the exact same change before the second compilation.

Woops! I’ve updated the post here, thanks for catching that. I had my coding assistant validate these steps but I missed that it noticed the username was wrong and just adapted it while working and then claimed everything worked as expected :rofl: great example of why we still need checks and balances!

Thank you for doing that test. When you’re doing cold starts like you did there, the performance is actually going to be less. That’s a bit of a trade with the approach of an incremental compilation because In order to make it possible, there’s additional parsing and caching that has to happen, which means that we raise the floor of the performance for the fastest that it can be, but we bring down. The top end when you’re using the file watcher and the caching mechanism.

So the real magic here with incremental compilation is that watch command, because it’s already got the file structure cached in memory, and it’s actively watching files being changed. So when you make a change to a file, it should now be significantly reduced in its compilation time. I mean, looking at yours, I’m not sure how large your project is, but you should be able to make file changes and see a near instant recompilation and restart of the process.

I’ll investigate the linker issues and see if that improves anything.

Great question, @kojix2. Let me walk through the ideas behind it.


Traditional Crystal Compilation

Every time you run crystal build, the compiler runs the full pipeline from scratch, regardless of what changed. Here is what that looks like:

Every stage runs unconditionally. On a project like Kemal or Spider-Gazelle, this is ~7 seconds cold. The same 7 seconds applies even if you only changed a comment in one file, because the compiler has no memory of the previous build.


Incremental Compilation Pipeline

The incremental approach layers a set of early-exit checks and caching layers onto the same pipeline. None of the individual ideas are surprising in isolation — fingerprinting, AST caching, object file reuse — but combining them all at each stage produces compounding savings.


What Gets Cached and Why

File fingerprints

After each successful build, the compiler writes a JSON cache file (incremental_cache.json) recording the mtime, byte size, and MD5 hash of every source file that was required. On the next build, it can check mtime+size first (cheap stat call) before falling back to the content hash. If everything matches and the output binary exists, the entire compilation — semantic analysis, all codegen — is skipped. Measured on Kemal: 7,248ms → 301ms on a no-change warm build.

AST parse cache

Even when files have changed, files that have not changed do not need to be reparsed. An in-memory ParseCache maps filename → {content_hash, AST}. On a watch-mode rebuild, unchanged files return a clone of the cached AST. (ASTs must be cloned because semantic analysis mutates nodes in place.)

Top-level signatures

Not every file change is equal. If you fix a bug inside a method body — the implementation changes but the method’s name, argument types, and return type restriction are unchanged — then no file that calls that method is affected. After parsing, the compiler extracts a FileTopLevelSignature per file containing:

  • Type declarations (class/struct/module/enum/lib/alias, with parent and generic params)
  • Method signatures (name, arg names, type restrictions, return restriction, abstract flag)
  • Include/extend mixins
  • Top-level constants
  • Whether the file contains top-level macro calls (macros get structural treatment since they can generate arbitrary types)

If the old and new signatures are identical, the change is classified as body-only. If any structural component differs, it is classified as structural and dependent files are invalidated.

File-level dependency graph

During semantic analysis, every time a method call is resolved across file boundaries, the compiler records that the calling file depends on the file that provides the definition. This builds a directed graph: user_file → [provider_files].

On the next build, this graph is consulted when a body-only change is detected. If no other file depends on the changed file, the change is isolated — the semantic results for the rest of the program are guaranteed identical. This is the foundation for future per-file semantic skipping.

Module-to-object-file mapping

Crystal’s multi-module codegen assigns one LLVM module per type. After codegen, the compiler records which source files contributed methods to each LLVM module. On the next build, if all source files that contributed to a module are unchanged, the compiler skips IR generation for that module entirely and reuses the .o file from the cache directory. The linker still runs, but with fewer freshly-compiled inputs.

Link skipping

If every .o file was reused from cache, the linker inputs are byte-for-byte identical to the previous build. The linker is skipped entirely and the existing binary is kept.

Allocation hints

The previous compilation’s data structure sizes (string pool, type count, def count, union count) are stored in the cache and used to pre-size hash tables on the next build. This reduces rehash overhead during semantic analysis for large projects.


The Warm vs. Cold Build Question

One thing worth discussing for CI and remote systems: the cache artifacts (incremental_cache.json plus the .o files in the Crystal cache directory) must exist for most of these optimizations to apply.

On a first build (or any build where the cache is absent — fresh CI runner, clean checkout, changed compiler version), the incremental path still runs a full compilation. The file fingerprinting overhead is a net cost on a cold build, not a saving.

A reasonable approach for these scenarios is a fast non-incremental fallback: when no valid cache exists, the compiler can take a streamlined path that skips the cache load/save overhead but still applies the parallelism wins (parallel parsing with require graph discovery, parallel bc+obj codegen across N threads/processes). Essentially: the parallel parsing and parallel codegen phases from the incremental work are beneficial on any build, cache or not — they just need to be separated from the cache-dependent skipping logic. This way remote CI gets the parallel speed benefits without paying the overhead of building a dependency graph that will be thrown away on the next ephemeral runner.


Measured Results

To give a sense of the compounding effect (no-change warm build vs. a stock cold build on the same project):

Project Stock cold Warm (no change) Savings
Kemal 7,248ms 301ms 95%
Spider-Gazelle 7,296ms 361ms 95%
Lucky 7,226ms 377ms 94%

The dominant saving in the fully-warm case is the whole-compilation skip (fingerprint check → done). The module-level .o reuse and link skip kick in on the “some files changed” case, which is the common edit-compile cycle.


Summary

The incremental approach is a layered set of early exits, each building on information from the previous successful build:

  1. No files changed → skip everything, 250ms turnaround
  2. Files changed, body-only, isolated → skip dependent invalidation
  3. Files changed, structural → run semantic, but skip codegen for unaffected modules
  4. Some modules dirty, some clean → reuse .o files for clean modules
  5. All modules clean → skip the linker too

Each layer saves time independently, and they compose. The cache is invalidated automatically when the compiler version, codegen target, flags, or prelude change, so stale artifacts are never a correctness risk.

1 Like

This assumption doesn’t necessarily hold. Parameter and return types are only restrictions, not actual types. The values are not cast to them.
Changing the method body can easily affect the actual return type or cause implications for parameter types, without changing any of the type restrictions.

For example, uncommenting the line in this example would not alter the signature of ::foo, but it breaks code semantics outside of it.

def foo(x : Int32) : Int32?
  # return nil if x.zero?
  1 // x
end

foo(3) + 1 # Error: undefined method '+' for Nil (compile-time type is (Int32 | Nil)) (if line 2 is uncommented)

This kind of dynamic is a tough nut to crack for compiling Crystal incrementally.

This seems to be trying to compile the output file as a source file:

➜  malakai git:(main) ✗ crystal-alpha watch src/web.cr -o bin/web --run
[watch] Compiling src/web.cr...
Error: file 'bin/web' is not a valid Crystal source file: Unexpected byte 0xcf at position 0, malformed UTF-8