gcry v0.18.0
gcry
The garbage collector Crystal deserves — written in Crystal.
Conservative mark–sweep. Ship as a shard. One flag replaces Boehm.
gcry runs at ~87% of Boehm's throughput with ~0.80x the RSS (Linux).
In one line
{% if flag?(:gc_none) %} require "gcry" {% end %}
crystal build -Dgc_none app.cr -o app
String, Array, Hash — everything allocates on gcry. No API changes. One line to swap Boehm out, one line to swap it back.
Near-Boehm performance: ~87% throughput at ~0.80x RSS (Linux).
Who is this for?
- You use Crystal in production and want to understand how memory works.
- You've hit a Boehm limitation and want a collector you can debug.
- You contribute to Crystal and want the language to own its runtime.
- You're curious — one
crystal build -Dgc_noneand you'll see.
Crystal >= 1.21. Linux (x86_64 + aarch64) and macOS (arm64 + x86_64).
A GC you can actually own
Boehm works. Nobody is denying that. But Crystal's most intimate runtime component is a C library — one you can't read, can't debug, can't change.
| Boehm | gcry | |
|---|---|---|
| Language | C | Crystal |
| Integration | Built-in C library | Shard (shards update) |
| Debug | C stack frames | Crystal stack traces |
| Modify | Recompile C + patch Crystal | Commit to shard |
| Metrics | Nothing built-in | HDR histograms + Prometheus |
| Ownership | Upstream C project | Your community |
Readable. Debuggable. Changeable. Yours.
How it works
┌──────────────────────────────────────────────┐
│ Crystal runtime │
│ (GC.malloc → GC.realloc → GC.free → …) │
└────────────────────┬─────────────────────────┘
│
┌────────────────────▼─────────────────────────┐
│ require "gcry" (shard) │
│ │
│ ┌──────────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ Heap │ │ Mark │ │Sweep │ │Roots │ │
│ │ mmap │ │ STW │ │ free │ │ fiber│ │
│ │ size-cls │ │ mark │ │release│ │ stack│ │
│ └──────────┘ └──────┘ └──────┘ └──────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Metrics │ │ Layout │ │Platform │ │
│ │Prometheus│ │ precise │ │ Linux │ │
│ │HDR pause │ │ type_id │ │ Darwin │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└────────────────────────────────────────────────┘
Build with -Dgc_none → Crystal skips libgc → gcry reopens module GC. No compiler patch. No linker tricks. One flag.
How to use it
# shard.yml
dependencies:
gcry:
github: sdogruyol/gcry
shards install
# src/app.cr
{% if flag?(:gc_none) %}
require "gcry"
{% end %}
puts "hello from gcry"
crystal build -Dgc_none app.cr -o app && ./app
10 seconds. Or try without even cloning:
docker run --rm crystallang/crystal:1.21.0 sh -c '
mkdir -p /tmp/demo/src && cd /tmp/demo
cat > shard.yml <<EOF
dependencies:
gcry:
github: sdogruyol/gcry
EOF
cat > src/demo.cr <<EOF
{% if flag?(:gc_none) %} require "gcry" {% end %}
puts "hello from gcry"
EOF
shards install
crystal build -Dgc_none src/demo.cr -o /tmp/demo-app && /tmp/demo-app
'
No clone. No install. Just Docker.
You only need the normal Crystal install (1.21+). Install the shard, build with -Dgc_none, done. You do not need a special Crystal build.
(There is an optional research mode that can use extra compiler data for more precise GC. It is off unless you turn it on, and almost nobody needs it. Details: docs/STACK_MAPS.md.)
Why a second option alongside Boehm?
Because a language's garbage collector is its most intimate runtime component. You shouldn't have to trust that to a C library you can't touch.
Boehm is a 30-year-old, battle-tested, broad-platform C library. gcry is Crystal-native, shard-delivered, and yours to debug, change, and fork.
Both use the same contract (conservative, non-moving mark-sweep). Both reopen the same GC module. The difference: one you can read and understand, the other you can't.
Performance — numbers don't lie
% of Boehm is the only score that matters. Same host, same load, same wrk. Absolute req/s is host noise; the ratio is truth. Prefer /json (alloc-heavy). Full methodology: docs/PERF.md.
Linux
| Workload | gcry vs Boehm (v0.18.0)* |
|---|---|
Kemal /json throughput |
~87% (carry v0.16) (~95% with GCRY_KEEP_CHUNKS=1, escape) |
Kemal /json post-GC RSS |
~0.80x (carry v0.16) |
Kemal / throughput |
~82% (carry v0.16) |
Fat app /api/v1/ throughput |
~90–96% |
Fat app /api/v1/ RSS |
~1–1.6x / ~0.92x (GCRY_TIGHT_GROW=1) / ~3.43x (v0.17 i3 cut) |
*Kemal: carry v0.16 bench/log/linux/2026-08-01-093130/ (median-of-3, scrub on; / from slash-recut/). Quiet tip / post-tag 9950X smokes ~80% /json @ ~0.74× — headline stays the v0.16 cut. Fat app: finalizer + Linux retain=0 — i3 ~96% @ ~1.63× (…/2026-08-04-acik-i3-retain0-med3/); 9950X band ~1.0–1.8× (post-tag ~102% @ ~1.76×, …/2026-08-05-091820/). Opt-in GCRY_TIGHT_GROW=1 → acik ~103% @ ~0.92× (Kemal thr soft; not default) — PERF.md, ACIKTURKIYE.md. Parallel opt-in (EC>1 + TLAB off + lazy): ~79% /json — not the default. Stack maps dormant — not this release’s win.
macOS (Apple Silicon)
| Workload | gcry vs Boehm (tip)* |
|---|---|
Kemal /json throughput |
~84% |
Kemal /json post-GC RSS |
~1.01x |
Kemal / throughput |
~91% |
Fat app /api/v1/ throughput |
~90% |
Fat app /api/v1/ RSS |
~0.63x |
*Kemal: bench/log/macos/2026-08-04-172842/ (median-of-3, scrub on). Fat app: …/2026-08-04-acik-stackmap/ tip base — PERF-macos.md, ACIKTURKIYE-macos.md. Tagged v0.17 carry was Kemal ~84% @ ~0.93× / acik ~71% @ ~18×.
Detailed tables: PERF.md · PERF-macos.md · ACIKTURKIYE.md
Linux tip fat-app RSS is ~1–1.6x Boehm after finalizer + retain=0 (i3 headline ~1.63x; residual is mapped freelist). Opt-in GCRY_TIGHT_GROW=1 brings acik to ~0.92x. The v0.17 i3 cut was ~3.43x. Darwin tip fat-app is ~0.63x (was ~18x at v0.17). Stack maps remain research-only for precise roots — product path is tip without PRECISE_STACK.
What the default heuristics cost
Every number above is measured with gcry's root-completeness heuristics armed — base-pointer-only ambient roots, the static-root type_id gate, 256 KiB STW stack lags. Each can decline to mark a pointer that is genuinely live, so those numbers price a collector that is allowed to guess. (Parked-fiber scrub was in this list through v0.18; it is opt-in since tip — nothing measured kept its default alive.) GCRY_SOUND=1 turns the whole class off:
GCRY_SOUND=1 ./your-app
Kemal /json (i3, 9 rounds × 30 s) |
% of Boehm | RSS × |
|---|---|---|
| tuned (process defaults) | 81.8% | 0.75× |
sound roots (GCRY_SOUND=1) |
83.0% | 0.76× |
| sound + fully conservative bodies | 83.6% | 0.74× |
RSS is flat across all three — that much reproduces across two sessions. The throughput column did not, and the reason turned out to be the harness: WSL2 steps CLOCK_REALTIME backwards ~1.6 s every ~32 s, and wrk derives its duration from that clock, so a pass containing a step reports ~19% high. Which config gets hit is random, so it biased rather than merely widened — that is how sound came out ahead of tuned despite doing strictly more work.
That was one of four biases in the harness — the others were blocked execution (config order confounded with time, worth ~2–3%) and a fixed config order within each round (whichever ran first came out ~2% slow). All four were bias, not variance, so no run count ever helped. Fixed: monotonic timing, round-robin interleaving, order rotated each round.
With the confounds out (bench/log/linux/2026-08-06-140037-sound-profile/, 9 rounds × 30 s, paired):
| Config | vs tuned | rounds won | σ |
|---|---|---|---|
GCRY_DISABLE_SCRUB_FIBERS=1 |
+1.29% (retracted) | 8/9 | 3.2 |
GCRY_SOUND=1 |
+0.82% | 8/9 | 1.7 |
GCRY_DISABLE_BLACKLIST=1 |
+0.73% | 7/9 | 1.2 |
The whole class is throughput-neutral on this workload — under ~1% either way, not distinguishable from zero.
The scrub_fibers row was once read as the exception, the one knob with a real signal. That is retracted: a second session on the same host and harness measured −1.22% — sign flipped, significance gone. The arithmetic says why and says no run count would have helped. roots + scrub + stacks is 223 µs of each of 131 collections per 20 s, i.e. 0.146% of wall time, and the knob moves ~9% of that — ~0.013%. Both readings are ~100× the largest effect the mechanism can produce. Throughput cannot resolve this knob on this workload, in either direction.
What settled it was the per-collection trace plus the fact that nothing else supported the default: the fat-app RSS it was turned on for does not reproduce, Kemal RSS is flat, and the wipe writes into another fiber's stack below an estimated SP. It is opt-in on tip (GCRY_SCRUB_FIBERS=1), and turning it back on costs 11.2% more root work and 5.9% more pause for no measured retention — PERF.md § "Tip default-path re-cut".
Pause cost is resolved, measured per collection off the GC trace:
| Cut | tuned | GCRY_SOUND=1 |
|---|---|---|
Kemal /json, EC1 |
398 µs | 398 µs (+0.1%) |
Kemal /json, EC4 |
3.60 ms | 16.39 ms (+356%) |
acik /api/v1/, EC1, heap ~72 MiB |
10.7 ms | 18.2 ms (+70%) |
Both tuned figures moved this session, and downward: the low-water skip used to apply only when lag = 0, so the default was faulting in a fixed 256 KiB window per parked fiber that nothing had ever written. It now starts at max(stack_top − lag, low_water) — Kemal EC4 pause 8.06 → 3.60 ms, RSS flat. The fat-app row read 17 ms → 213 ms two sessions ago, then briefly had GCRY_SOUND=1 ahead of the default; the skip on the default path reversed that back. SOUND-DEFAULTS.md
In all three the whole cost is the two STW lag knobs — the other five heuristics are within ±6%.
The EC4 arrow is a fix, not a re-measurement. lag = 0 was scanning each parked fiber's entire 8 MiB of reserved stack, 0.05% of which has ever been written; the scan now starts at the stack's low-water mark. That is not a precision trade — a page with neither the present nor the swapped bit in /proc/self/pagemap has never been faulted, so both ranges see identical words. EC4 pause 147 ms → 13 ms in the same run, RSS unchanged. Method, per-knob decomposition, known limits of the label, and the fat-app cut: docs/SOUND-DEFAULTS.md.
Pause distribution (Kemal /json, Linux)
Illustrative histogram from an earlier cut (not the v0.16.0 median session). Prefer Gcry.pause_stats / /gc-stats on your host.
p50: 2.1 ms ████████████████████████████████▌
p90: 4.8 ms ████████████████████████████████████████████
p99: 9.3 ms ███████████████████████████████████████████████████▌
max: 48.0 ms ████████████████████████████████████████████████████████████████
HDR histogram built in via Gcry.pause_stats — no external tools needed. Prometheus /metrics exposes pause percentiles as gauges.
Feature set
| Feature | Description |
|---|---|
| Conservative mark-sweep | Safe for today's Crystal ABI; scans for pointer-shaped words |
| Stop-the-world | Linux signals / Darwin Mach suspend; HDR histogram via Gcry.pause_stats |
| Non-moving | Stable addresses — no compaction surprises |
| Fiber roots | Stacks + parked fibers; STW SP clamp on other threads |
| Layout-precise scan | Builtins + opt-in — fewer false keeps where registered |
| Empty-chunk release | On by default — Kemal post-GC RSS ~0.80× Boehm (Linux v0.16.0) |
| macOS reclaim | mach_vm punch-hole at host page size (16 KiB on Apple Silicon) |
| Observability | Gcry.metrics, prometheus_text, Observability.json_stats |
| Fork | pthread_atfork reinit (default); see POLICY |
Scope (honest)
gcry is production-curious on Linux and macOS process GC at parallelism 1. Windows is coming.
| Today | Later / elsewhere |
|---|---|
| Linux + macOS process GC (Crystal >= 1.21) | Windows process GC |
| Default ExecutionContext, parallelism 1 (PERF headline) | Parallel supported opt-in: EC>1 + TLAB off + lazy (~79% /json); TLAB-on still experimental |
| Kemal-class thr/RSS near Boehm | Ultra-dense conservative-live apps may keep more RSS until stack maps |
LibC.fork + atfork reinit |
Process.fork under ExecutionContext (Crystal forbids it anyway) |
Roadmap
Phase 1 DONE Conservative mark-sweep, STW, Linux + macOS ✓
Phase 2 NOW Stack maps, barriers, Windows, -Dgc_gcry ○
Phase 3 NEXT Performance parity, parallel mark, nursery def. ◐
Phase 4 GOAL Crystal's default GC △
Tuning (quick reference)
Defaults tuned for process GC. Change after you measure:
| Variable | Effect |
|---|---|
GCRY_SOUND=1 |
Turn off every root-completeness heuristic (RSS-neutral; thr cost unresolved; large pause cost where the root scan is big — EC4 or a big heap) |
GCRY_KEEP_CHUNKS=1 |
Keep empty chunks -> ~95% /json thr, ~3x RSS |
GCRY_THRESHOLD |
Bytes before auto-major (default 32 MiB) |
GCRY_AUTO_LAYOUTS=1 |
Whole-program precise layouts (~-7pp thr) |
GCRY_NURSERY=1 |
Opt-in nursery (off by default for process) |
GCRY_PARALLEL_MARK=N |
Experimental parallel mark workers (default 1) |
GCRY_STRESS=1 |
Collect every N allocs (debug) |
Full list: docs/HARDENING.md. Pauses: Gcry.pause_stats.
Docs
| Doc | What |
|---|---|
| DESIGN.md | Architecture & design decisions |
| ROADMAP.md | Public roadmap to becoming Crystal's default GC |
| docs/PERF.md | Linux performance numbers |
| docs/PERF-macos.md | macOS performance numbers |
| docs/COMPARISON.md | gcry vs Boehm head-to-head |
| docs/INTEGRATION.md | Crystal GC wiring |
| docs/HARDENING.md | All env knobs |
| docs/SOUND-DEFAULTS.md | GCRY_SOUND=1 — what gcry costs with no root heuristics |
| docs/STACK_MAPS.md | Compiler stack maps (research; default off) |
| docs/API.md | Public API + /metrics |
| docs/POLICY.md | OOM, fork, signals |
| CHANGELOG.md | Version history |
Contributing
- Fork -> branch -> commit -> push -> PR
- Collector hot paths: no managed-heap allocation
- Prefer small modules (
heap,mark,sweep,roots) - Stuck? good first issues
Development
make spec # unit specs under Boehm
make samples # -Dgc_none samples -> bin/
make bench # library-heap churn
make bench-kemal-wrk # Kemal + wrk on / and /json
make format-check
License
MIT — see LICENSE.
Contributors
Serdar Dogruyol — creator and maintainer
Tried it? Star it. Loved it? Share it. Hated it? Open an issue.
gcry is yours — use it, break it, fix it, fork it. That's the point.
gcry
- 69
- 2
- 0
- 0
- 1
- about 3 hours ago
- July 23, 2026
MIT License
Thu, 13 Aug 2026 07:21:37 GMT