A compiler built for Wax.

Fast enough for a tight edit and run loop, serious enough to carry a program from source code to optimized machine code.

The compiler has to come with us

Most languages can assume their compiler is installed somewhere on the developer's machine. Wax does something weirder.

Open a recording five years from now and the latest compiler is unlikely to just work with it. We need the exact compiler that built the program, along with the debugger and runtime that understood it. That toolset needs to be small enough to archive forever, cheap enough to fetch on demand, and portable enough to load directly in a browser.

That rules out the obvious path.

LLVM is a wonderful piece of technology, but it is enormous. A toolchain measured in hundreds of megabytes makes perfect sense as an installed native dependency. It does not make sense when we want to keep every historical version and pull the right one into a browser whenever somebody opens an old recording.

Size was only half the problem. Wax needs exact control over memory layout, object identity, function calls, code loading, and deterministic behavior across every target. Those decisions run all the way from the language into the runtime. Wax needs to own them itself.

So Wax bit the bullet and built its own compiler stack. Wax owns the language, WaxIR, optimizer, runtime, and compact native and WebAssembly generators. It can also produce portable C when letting the platform compiler finish the job makes more sense. Every route starts from the same definition of Wax.

The result is a complete Wax compiler that compresses to around 3 MB as WebAssembly. The introspection toolset, which also contains the debugger and runtime, is around 2 MB on top of that. A recording either embeds this directly, or carries the exact cryptographic identity of its toolset, so the Inspector can fetch it later, verify every byte, and know it is using the right machinery.

That is a lot to own, but it lets every piece of the stack uncompromisingly optimized for Wax's needs.

Compile it twice

Waiting for a compiler sucks. Even a few seconds is enough to break your train of thought when you do it hundreds of times a day.

The problem is, good optimization takes time. The compiler has to study the program, try things, throw some of them away, and keep working until the code is better. We want that work in a finished build. We just do not want to sit there staring at it every time we change a line.

When Wax is running inside a host that can replace code, it compiles the program twice.

The first build is called Instant. It does much less optimizer work and tries to get the entire program running as quickly as possible. This is still a real compiled program. There is no interpreter hiding underneath it, no bytecode, and no profiler waiting to discover which functions are important.

As soon as the Instant build starts running, Wax compiles the program again in the background with the optimizer turned all the way up to eleven. When that build is ready, Wax loads the new code and redirects the running program into it. The heap stays exactly where it was, every object keeps its identity, and the application does not restart. One moment it is running the Instant build. The next moment it is running the optimized one.

It looks a bit like a JIT from the outside, but Wax never compiles one hot method at a time. Both builds are complete programs. The optimized build is also cached, so the next run can start with it immediately if nothing relevant changed.

Of course, compiling twice is only useful if the first build is actually fast. The compiler processes files and function bodies in parallel, and it remembers enough between builds to avoid starting over. Change one declaration and Wax rebuilds the things that depend on it. Everything else stays done.

Closed world

Wax makes a deliberate trade: code cannot quietly appear inside an existing build. Before it generates code, the compiler knows every module, type, function, generic instance, object layout, host function, and API in the program. You can always compile a new build, but you cannot change one after the compiler has finished reasoning about it.

That lets the compiler go much further than checking whether the types line up. It can work out what every function can actually do.

A function might look harmless on its own and then call a helper, which calls another helper, which changes some shared state five calls later. Wax follows that entire chain. It works out what state the original function can read or change, whether it allocates, whether it reaches the host, which errors can come back, and whether a temporary reference can escape. Wax calls this analysis BoundEffects.

Now suppose the host calls an API that only inspects the program. BoundEffects can prove that the call does not change anything that survives it, even when the inspection goes through a pile of helpers. The call and its arguments do not need to go into the recording at all. If we need the answer later, we can just run the inspection again.

The same proof makes parallel work much less scary. The compiler can look through everything a worker might call and reject hidden access to shared state, allocation, or host functions. The runtime checks that the actual memory ranges do not overlap before the workers start. Wax can then spread the work across several cores or run it serially without changing the result.

It is also what keeps a reference into temporary scratch memory from escaping through some innocent looking helper five calls away. That makes views into temporary memory safe and cheap without giving them their own allocations.

The compiler is pretty conservative about all of this. If it cannot complete the proof, it tells you. Loudly.

How determinism works →

Performance

The Wax memory model →