Gcry: a garbage collector written in pure Crystal

I just released 0.14.0 of gcry. It’s a conservative mark sweep GC written entirely in Crystal, shipped as a shard. No C code, no compiler patches, no fork. You just add require "gcry" and build with -Dgc_none.


Performance

Same host, median of 3, wrk -c 100 -d 30:

Workload gcry vs Boehm Post-GC RSS
Kemal /json (Linux) ~89% throughput 0.79x Boehm
Kemal root (Linux) ~89% throughput 0.78x Boehm
Kemal /json (macOS) ~84% throughput 0.93x Boehm
Kemal root (macOS) ~93% throughput 1.06x Boehm

21% less memory than Boehm on Linux Kemal. Same throughput.


What makes gcry different

It’s a conservative, non-moving, stop-the-world collector. Same family as Boehm, so it works with Crystal’s existing ABI. But it’s written in Crystal, which means:

  • You can read it. The whole collector is about 9,000 lines of Crystal across 32 files. No C macros, no opaque foreign library.
  • You can debug it. Set a breakpoint anywhere. Crystal’s debugger works on it.
  • You can change it. Want to experiment with a different marking strategy? Edit a .cr file and rebuild.

Architecture: mmap-backed size classes, fiber stack roots, conservative mark with layout precise opt-in, deferred madvise, and platform specific STW (Linux signals, Mach thread_suspend on macOS).


What’s new in 0.14.0

This release is all about quality infrastructure:

  • Debug invariant checker (GCRY_DEBUG_INVARIANTS=1). Validates live_objects counter, freelist consistency, chunk index integrity, block overlap detection. At runtime, on every malloc/free/collect.
  • AddressSanitizer and Valgrind on every PR in CI.
  • Coverage reports via kcov on every PR.
  • Deterministic replay fuzzing with --seed and --replay.
  • 24 hour soak tests, thread storm tests, OOM tests, property based heap invariant tests.
  • Heap dump (Gcry.dump_heap) streams live objects as NDJSON for leak hunting.
  • Trace (GCRY_TRACE=1) traces every collect, malloc, and free.

How to try it

# shard.yml
dependencies:
  gcry:
    github: sdogruyol/gcry
{% if flag?(:gc_none) %}
  require "gcry"
{% end %}

puts "Hello from gcry!"
crystal build -Dgc_none app.cr -o app

No special malloc API. No compiler fork. String, Array, Hash, everything works as usual.


Limitations

gcry is a shard and I’m pushing it as far as I can. But there are things you can’t do from a shard. True precise stack scanning, write barriers, and concurrent collection need compiler support. At some point that line has to be crossed. But for now, the shard approach gets you surprisingly far: Kemal class HTTP at near Boehm perf, layout precise heap scanning, fiber stack scrubbing, and full observability. All without touching the compiler.


Github

Hi. gcry looks interesting. I’m curious how you handle parallelism. Does it work with 1.21 Execution Contexts?

Hi Kostya, thanks.

Yes: gcry targets Crystal ≥ 1.21 Execution Contexts. The supported path is the default EC at parallelism 1. STW suspends other OS threads (including the Monitor), and we scan fiber stacks from that world.

Extra parallel contexts are experimental. You can try them with GCRY_TLAB=1. We have CI covering Parallel workers under real process STW, but I wouldn’t call that production-ready yet. GCRY_PARALLEL_MARK=N is also experimental and often hurts HTTP throughput vs N=1, so measure before turning it on.

Short version: default EC / parallelism 1 is what we ship for. Parallel ECs work enough to test, not enough to promise.

What the hell, I’m going to try this with Invidious (my fork, not upstream) to see how many memory it uses and benchmark it’s performance.

Well, I have found an incompatibility with Linux OSes using musl instead of glib.

When trying to compile a program using Gcry with a OS like Alpine, which uses musl, you will get this error:

Showing last frame. Use --error-trace for full trace.

There was a problem expanding macro 'macro_138636048077520'

Code in lib/gcry/src/gcry/heap.cr:216:7

 216 | {% if flag?(:darwin) %}
       ^
Called macro defined in lib/gcry/src/gcry/heap.cr:216:7

 216 | {% if flag?(:darwin) %}

Which expanded to:

 > 1 |
 > 2 |         @mark_pthreads = StaticArray(LibC::PthreadT, 15).new(LibC::PthreadT.new(0))
                                                                                   ^--
Error: undefined method 'new' for LibC::PthreadT.class

And that’s because the Crystal LibC for x86_64-linux-musl defines type PthreadT = Void* which of course, has no new method ;)

So yeah, Gcry only seems to work on ABIs where PthreadT is available.

thanks a lot for trying out gcry! For now I’ve only battle-tested gcry on mainstream Linux and macOS. I’d be happy to have more targets. Feel free to open an issue on gcry or better open a PR :slight_smile:

I’ve already fixed this in Fix musl PthreadT ABI and add musl CI by sdogruyol · Pull Request #17 · sdogruyol/gcry · GitHub, it’s already in gcry master

Please use it like

dependencies:
  gcry:
    github: sdogruyol/gcry
    branch: master

So after some time testing Gcry with Invidious, I have to say that works pretty well in prod, no crashes, no high memory usage, no high cpu usage, seems to be a pretty good drop-in replacement for BohemGC. Here are some graphs that show memory usage and CPU usage:

Invidious is running with the default parallelism set to 1, so the process is running in a single core. 50% of the traffic goes to invidious and the other 50% goes to invidious-gcry, so this should be a fair comparison.

I will test how it works with parallelism set to 2 or more, since Invidious (built with BohemGC, I haven’t tested Gcry) crashes when parallelism is set to 2.

OMG! This is awesome news @Fijxu, I’m super happy. Feel free to ping me for anything about gcry and how I can improve it.

Am I reading the second graph correct that gcry is consuming %50 less CPU time than Boehm?

Yes, but don’t take that as an absolute value for comparison, as Invidious does more tasks in the background (like checking the database to refresh some data, receive updates with HTTP post requests, etc), so the results may not absolutely reflect a day and night difference between Gcry and Bohem since the requests and processing are split in half.

Anyways, here is more benchmark data, a day long run without restarts (Invidious doesn’t need to restart each hour as it says in the docs. if anyone using Invidious here is wondering that)

For a fair comparison, I will run Invidious with Bohem and Invidious with Gcry separately in different days, as I didn’t had metrics for Invidious before (I use Podman to run Invidious, and since I use NixOS, NixOS didn’t have the prometheus-podman-exporter package available to use)

EDIT: I just noticed that I had my NGINX upstreams miss configured, making some requests exclusively go to the Invidious process using Bohem and not being split, so yeah, the graphs here are imprecise.