Not a Joke

I have a long and somewhat embarrassing history with that goes back to the years when it was fading from academia and LISP machines were giving way to and DECstations (a transition that should be familiar to anyone who read the UNIX-HATERS Handbook), so I got properly hooked on when it came out.

But had the huge disadvantage of being tied to the 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 ever since. On a or an ARM SBC, starting a 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 binary that understands most of 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 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 :

go-joker notebook with Mandelbrot rendering
The Go-Joker notebook rendering Mandelbrot through the WASM-backed imaging path.

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 (did you know that had one audited externally in Europe? Ask me how I know) and my limited time working on internals–plus a lot of reading about the 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 relative madness, eventually got me to a tiered execution engine:

The final thing, in a very rough sketch
Go-Joker’s tiered execution pipeline, including WASM, typed IR, boxed IR and tree-walker fallbacks.

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 , 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 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 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- runtime) could JIT-compile 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 process.

This was huge fun: the arithmetic benchmarks went from 12ms (IR) to 0.24ms (WASM), giving what used to be a interpreter pretty much / 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, output, escape analysis and allocation profiles.

By the end, I had some pretty nice results:

benchmark comparison
Current CLBG and micro-benchmark results in milliseconds per operation; lower is better.

Why This Matters (for me, at least)

The original goal wasn’t really to build a fast (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 , 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 , stored in the database alongside everything else, and executed at speeds that range from “competitive with ” to “competitive with JIT-compiled ”, depending on the workload.

I’m not doing anything with gi right now, but the above is close enough to the 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 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 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, for numeric leaves, the fallback chain) came from knowing how the and 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 in , but it was a lot of fun.