Skip to content

Benchmarks

Performance in cljgo is a gated feature, not a marketing claim: perf budgets run inside plain go test ./... and a regression is treated like a conformance failure. Every number on this page was measured, not quoted — and the rows cljgo loses are published alongside the ones it wins.

Measurement context for everything below: Apple M5 Pro, go1.26.3, cljgo @HEAD (post-ADR-0067), re-measured 2026-07-23. hello.clj = (println "hi"). Every competing runtime was installed and measured on the same machine — no normalization, no numbers copied from other projects’ websites.

Metric cljgo Reproduce
Tool binary 12.7 MB stripped go build -trimpath -ldflags="-s -w" ./cmd/cljgo
Compiled binary, hello 5.3 MB cljgo build hello.clj (strips by default)
Compiled startup, hello 5.0 ms (was 28.9 ms pre-AOT-core) hyperfine -N ./hello
Peak RSS, hello 14.7 MB /usr/bin/time -l ./hello
Interpreter boot 31.7 ms · 44.5 MB · 733k allocs go test -bench BenchmarkBoot -benchmem ./pkg/eval/
Emitted vs handwritten Go ~5× (target ~10×, reached and passed 2026-07-23) go test -run TestFactorialPerfBudget ./pkg/emit/
clojure-test-suite 238 / 242 (98.3%) cljgo suite

A compiled binary starts in ~5 ms because it links the compiled core, never the interpreter (ADR 0046) — the interpreter’s ~32 ms boot is the REPL/dev path only.

Head-to-head: let-go’s own suite, unmodified

Section titled “Head-to-head: let-go’s own suite, unmodified”

let-go is the closest comparable — Clojure on Go, but a bytecode VM rather than AOT-to-Go-source. cljgo ran let-go’s own benchmark suite with let-go’s published methodology (hyperfine, 3 warmup / 10 runs). All 7 files run on cljgo with no edits. Wall-clock mean of 10 runs, startup included — the honest mode. Best per row in bold.

The field is mixed execution modes, so both cljgo legs are shown and compared like-for-like: interpretedcljgo run and joker are tree-walkers; compiledcljgo (native AOT binary, what you ship), babashka (GraalVM native image), let-go (bytecode VM), Clojure JVM (JIT).

Benchmark cljgo run (interp) cljgo (AOT) let-go babashka joker (interp) clojure JVM
startup 38.5 ms 5.0 ms 5.0 ms 9.7 ms 6.7 ms 289.2 ms
tak 11.47 s 34.6 ms 1.33 s 1.14 s 457.9 ms
fib 8.79 s 24.7 ms 1.25 s 1.14 s 419.1 ms
loop-recur 469.3 ms 5.4 ms 36.6 ms 37.8 ms 437.8 ms 397.5 ms
persistent-map 48.5 ms 9.4 ms 12.9 ms 13.0 ms 30.5 ms 385.2 ms
map-filter 39.9 ms 5.1 ms 4.8 ms 10.0 ms 8.4 ms 311.5 ms
transducers 89.0 ms 16.4 ms 25.4 ms 13.0 ms 315.9 ms
reduce 61.4 ms 26.0 ms 22.8 ms 20.0 ms 1.46 s 302.5 ms
runtime size 12.7 MB 13 MB 67.9 MB 27.4 MB 385.0 MB

Versions: cljgo @HEAD (post-ADR-0067) · let-go v1.11.1 (tag, built from source with the same toolchain and flags) · babashka v1.12.218 · joker v1.9.0 · Clojure CLI 1.12.5.1645 on OpenJDK 26.0.1. joker has no transducers and is skipped on fib/tak (~13× slower there). Runtime size is the stripped binary for cljgo/let-go, the installed binary for babashka/joker, and JDK + clojure.jar (381.0 + 4.0 MB) for the JVM; a compiled cljgo program is 5.3 MB. Not measured (and honestly flagged as such): gloat (no installable package path) and go-joker (needs a source clone + codegen) — let-go’s published M1 Pro data puts gloat at 12.7× let-go on fib, 5.4× on reduce. Reproduce the whole table: bash benchmark/run.sh — methodology in benchmark/README.md.

The good. The AOT leg wins every recursion and data-structure row outright: tak 34.6 ms and fib 24.7 ms (13× and 17× faster than the JVM — pure int64 recursion is exactly what the ADR 0067 numeric-inference pass lifts to raw typed Go: func fib(n int64) int64, direct recursion, zero boxing), loop-recur 5.4 ms, persistent-map 9.4 ms. startup is a dead heat with let-go at 5.0 ms, and map-filter is 0.3 ms behind — a tie.

The bad. transducers (16.4 vs babashka’s 13.0 ms) and reduce (26.0 vs babashka’s 20.0, let-go’s 22.8) remain behind the two purpose-built cores — closer than before, but honestly lost. The residual is per-element Apply2 dispatch on the reducing fn plus LongChunk.Nth boxing; ADR 0067’s follow-ups and an unboxed internal-reduce are the named path. And the interpreter leg (cljgo run) is a tree-walker: it loses everywhere except against joker. That is what cljgo build is for.

Throughput is a fair fight; memory and size are not. Same program ((reduce + (range 1000))), max resident set via /usr/bin/time -l:

Runtime Static binary / install Max RSS
cljgo 5.3 MB static binary 14 MB
joker 27.4 MB 27 MB
babashka 67.9 MB 28 MB
JVM Clojure JVM + classpath 102 MB

That is ~7× less memory than JVM Clojure, measured — and it’s the JVM’s best case: a hello that exits. On the same program cljgo also finished in 9 ms total wall-clock vs JVM Clojure’s 298 ms, boot included. (A CI-gated peak-RSS budget is on the roadmap so “low memory” becomes an enforced promise, not a slide.)

The 2026-07-23 campaign (ADRs 0063–0067)

Section titled “The 2026-07-23 campaign (ADRs 0063–0067)”

Five decisions moved emitted code from “correct but boxed” to competitive: chunk-aware map/filter/count/keep, the IFn2 2-arg reduce seam, direct-call emission for known local fns, the sealed-core dirty flag, int64 numeric inference, plus <=/>= unboxed compares and a startup clawback. Same machine, morning → evening:

Benchmark before after speedup
fib 975.4 ms 24.7 ms 39×
tak 858.5 ms 34.6 ms 25×
loop-recur 52.1 ms 5.4 ms 9.6×
reduce 60.8 ms 26.0 ms 2.3×
startup 6.5 ms 5.0 ms after a mid-day 9.5 ms peak

The emitted-vs-handwritten-Go factorial gate measures ~5× — it was ~35× before this campaign and ~168× under naive emission, and the ~10× target of design/00 §1.4 is passed for the first time.

Two budgets run inside plain go test ./..., host-relative because a CI runner is not your laptop (override with CLJGO_BOOT_BUDGET / CLJGO_PERF_RATIO_MAX):

  • Interpreter bootTestBootUnderBudget, 250 ms locally (pkg/eval/boot_test.go). Boot got 8.9× faster in v0.2.0 (211 ms → 23.7 ms, by killing a stack-scraping goroutine-ID lookup that burned 73% of boot CPU); it is 31.7 ms today, grown with the core it loads.
  • Emitted vs handwritten GoTestFactorialPerfBudget, 15× ceiling (pkg/emit/perf_test.go). The 15× gate is a regression guard, not the target; measured ~5×.

Compiled-binary startup is also CI-gated (TestBootStartupBudget) since the 2026-07-23 clawback, so the 5.0 ms cannot silently drift again.

Questions or feedback on this page? Comment below with your GitHub account — comments are public and live in the project's GitHub Discussions.