I have a long and somewhat embarrassing history with LISP that goes back to the years when it was fading from academia and LISP machines were giving way to Macs and DECstations (a transition that should be familiar to anyone who read the UNIX-HATERS Handbook), so I got properly hooked on Clojure when it came out.
But Clojure had the huge disadvantage of being tied to the Java virtual machine. That dependency was also one of its superpowers, but I always saw it as its biggest flaw.
Despite that, I used it in production for a few years and have been mourning the fact that you can’t have it without the JVM ever since. On a Raspberry Pi or an ARM SBC, starting a JVM just to run a REPL feels like bringing a shipping container to a picnic.
Joker was the escape hatch I’d been looking for–a standalone Go binary that understands most of Clojure’s syntax and needs no external runtime.
I started using it for scripting and linting years ago, and when I began thinking about building gi (my own lightweight coding agent), embedding Joker as the extension language was the obvious choice.
There was just a tiny little problem: it was slow. Not “a bit slow”–it was orders of magnitude slower than Python on anything involving loops, arithmetic or recursion. Fine for linting, but useless for anything else.
I forked it and spent two very intense days making it fast over a bank holiday. I’ve written about go-joker before, including the spiffy notebook interface it ships with:
But I recently realised that I never really put together all of my notes from last April, and it’s long overdue to write a proper post about it, so here it is.
The Approach
My deep, dark past poking at the JVM (did you know that HP had one audited externally in Europe? Ask me how I know) and my limited time working on .NET internals–plus a lot of reading about the JVM’s tiered compilation–all told me the same thing: the path from “slow interpreter” to “fast interpreter” follows a fairly predictable arc. First you identify the hot paths, then you lower them to a simpler representation, then you specialise that representation for the common types. If you’re lucky, you can go further and compile to native code for the innermost loops.
This isn’t something I’ve done often (not for a few decades, really), but I used to discuss it with one or two compiler nerds I worked with ages ago–we had long, weird phone calls about gcc, of all things–so I had an idea of how to do it.
The trick was getting a coding agent to do most of the mechanical work while I steered the architecture.
I had gpt-5.5 implement each layer while I provided the design constraints, which initially boiled down to:
- flat bytecode
- register-based execution
- no heap allocation for primitives
- a tree-walker fallback for anything weird
A few hours of thumbing through ancient books, interspersed with liberal swearing and infected by WASM’s relative madness, eventually got me to a tiered execution engine:
Each tier handles what it can and drops to the next for anything more complex. Early on, execution would start in the tree-walker and be promoted as the interpreter recognised patterns it could optimise, and the implementation grew progressively more intricate from there.
And since I needed something to compile, I went out and grabbed The Computer Language Benchmarks Game, which has a range of computational scenarios that resist trivial optimisation (to a degree), along with ready-made comparisons.
Phase 1: IR Bytecode
The first step, heavily inspired by .NET, was compiling hot loops and functions to flat bytecode–an intermediate representation with fixed-size opcodes, a value stack and no allocation for integer/float operations.
This alone got mandelbrot from 450ms down to about 40ms.
The key insight (which I stole from the JIT literature) was that most Clojure loops are either purely numeric or purely structural–they rarely mix–so you can have a typed path that handles Int/Double without boxing and a boxed path for everything else.
And my old JVM tricks also paid off: stripping Int and Double down to single-field structs (8 bytes, stack-allocable) cut allocations by half across the board. That is the kind of change an LLM won’t suggest unless you ask very specifically, because it breaks the type hierarchy in ways that it “feels” are wrong until you measure.
Phase 2: WASM Compilation
The realisation that pure numeric loops could go further came when I noticed that wazero (a pure-Go WebAssembly runtime) could JIT-compile WASM to native code–with zero cgo, another requirement of mine.
If the tree-walker detected a loop that was purely integer/float arithmetic, we could emit WASM bytecode for it, hand it to wazero and get native-speed execution (well, almost) without leaving the Go process.
This was huge fun: the arithmetic benchmarks went from 12ms (IR) to 0.24ms (WASM), giving what used to be a Clojure interpreter pretty much Bun/JavaScriptCore speed. It’s limited–it only handles cases where every value is a known numeric type and there are no collection operations–but when it applies, it’s great.
Phase 3: Polishing
The rest was just grinding out the hotspots.
Per-instance function compilation caches (irGetFnProg), capture-slot optimisation for closures (captureSlotSet), a StringCursor native type for zero-allocation string iteration (because, well, it was getting embarrassing to append stuff to strings…), transient vectors for non-escaping loop mutations and tail-call rewriting at parse time–I had to ask piclaw to check the ordering, but this was all done by systematically going through the benchmarks.
Given my fondness for profiling, I wanted this thing to be self-diagnosing, so I asked gpt-5.5 to add a runtime introspection namespace (joker.runtime) so scripts can inspect their own IR, WASM output, escape analysis and allocation profiles.
By the end, I had some pretty nice results:
- Mandelbrot: ~0.095ms on Joker’s best path (~68× faster than Python)
- N-body: ~0.006ms (~133× faster than Python)
- Joker’s best-path suite wins 12 of the 15 benchmarks; Go and JavaScript engines dominate the other three
- It beats Python and Goja (Go’s JavaScript engine) on all 15
Why This Matters (for me, at least)
The original goal wasn’t really to build a fast Clojure (well, not this fast, at least), but as usual I wandered off big time. Eventually I had to get back to what I wanted in the first place: an extension language for gi that:
- compiles into the binary (no external runtime)
- starts instantly (no JVM, no Node.js)
- is fast enough for real work (not just config parsing)
- has a REPL for interactive debugging
- can introspect its own execution
I now have all five.
Scripts and extensions for gi can be written in Clojure, stored in the SQLite database alongside everything else, and executed at speeds that range from “competitive with Python” to “competitive with JIT-compiled JavaScript”, depending on the workload.
I’m not doing anything with gi right now, but the above is close enough to the LISP machine dream that I still use go-joker quite frequently.
The AI Angle
Two days–that’s how long this took, from “Joker is too slow” to “Joker beats Python on Mandelbrot.”
I could not have done this in two days without AI–the mechanical work of implementing 30+ IR opcodes, writing typed dispatch paths, plumbing WASM emission, and generating benchmark harnesses would have taken weeks by hand.
But (and this is the interesting bit for me) I also could not have done it with AI alone–the architectural decisions (tiered execution, typed vs. boxed split, WASM for numeric leaves, the fallback chain) came from knowing how the JVM and .NET CLR work internally, remembering that I had a copy of Smith & Nair and the wazero source (kudos), and spending years thinking about what makes interpreters fast and (let’s face it) taking a few shortcuts.
All in all, I think this ratio of thinking to execution (and, by the way, go-joker comes with a massive battery of tests I would never have thought of writing) is what I want to get out of most of my projects.
It’s never going to be as popular as the Bun rewrite in Rust, but it was a lot of fun.