comptime, typed function pointers and error unions keep a runtime code generator honest;comptime from episode 9, and inline assembly from episode 29 all in your back pocket;Learn Zig Series):Last episode I closed on a promise. We had built a little language runtime that can parse, compile to bytecode, run on a stack VM, hold closures and clean up after itself with a generational collector -- correct, but not fast in the way a real runtime is fast. Every single instruction still crawls through the interpreter's big dispatch loop: fetch the opcode, branch to the right handler, do a few nanoseconds of actual work, jump back to the top, fetch the next opcode. That fetch-and-branch tax is paid on every instruction, and for a hot loop that runs a billion times it dwarfs the real computation. Today we stop paying it. We teach our machine to look at its bytecode, write out the equivalent machine code -- the raw bytes the CPU runs directly -- and then jump straight into that code with no interpreter in the middle. That, in one sentence, is what a JIT does. Here we go!
Three exercises last time, all extending our generational collector. Full code for each.
Exercise 1 -- A promotion counter. Add a promotions field that increments every time a minor collection tenures a young survivor into the old generation, then prove it equals exactly the number of reachable objects after a burst of young garbage:
const std = @import("std");
const ObjKind = enum { int, pair };
const Generation = enum { young, old };
const Obj = struct {
kind: ObjKind,
marked: bool = false,
gen: Generation = .young,
next: ?*Obj = null,
value: i64 = 0,
head: ?*Obj = null,
tail: ?*Obj = null,
};
fn freeList(alloc: std.mem.Allocator, list: ?*Obj) void {
var it = list;
while (it) |o| {
const n = o.next;
alloc.destroy(o);
it = n;
}
}
const GC = struct {
alloc: std.mem.Allocator,
young: ?*Obj = null,
old: ?*Obj = null,
roots: std.ArrayList(*Obj),
old_count: usize = 0,
promotions: usize = 0, // total tenures over the collector's life
fn init(alloc: std.mem.Allocator) GC {
return .{ .alloc = alloc, .roots = .empty };
}
fn deinit(self: *GC) void {
freeList(self.alloc, self.young);
freeList(self.alloc, self.old);
self.roots.deinit(self.alloc);
}
fn newObject(self: *GC, kind: ObjKind) !*Obj {
const o = try self.alloc.create(Obj);
o.* = .{ .kind = kind };
o.next = self.young;
self.young = o;
return o;
}
fn newInt(self: *GC, v: i64) !*Obj {
const o = try self.newObject(.int);
o.value = v;
return o;
}
fn newPair(self: *GC, h: ?*Obj, t: ?*Obj) !*Obj {
const o = try self.newObject(.pair);
o.head = h;
o.tail = t;
return o;
}
fn addRoot(self: *GC, o: *Obj) !void {
try self.roots.append(self.alloc, o);
}
fn markYoung(self: *GC, obj: ?*Obj) void {
const o = obj orelse return;
if (o.gen == .old or o.marked) return;
o.marked = true;
if (o.kind == .pair) {
self.markYoung(o.head);
self.markYoung(o.tail);
}
}
fn minorCollect(self: *GC) void {
for (self.roots.items) |r| self.markYoung(r);
var it = self.young;
while (it) |o| {
const nxt = o.next;
if (o.marked) {
o.marked = false;
o.gen = .old;
o.next = self.old;
self.old = o;
self.old_count += 1;
self.promotions += 1; // count the tenure right here
} else {
self.alloc.destroy(o);
}
it = nxt;
}
self.young = null;
}
};
test "promotions counts exactly the survivors, not the garbage" {
var gc = GC.init(std.testing.allocator);
defer gc.deinit();
const keep = try gc.newPair(try gc.newInt(1), try gc.newInt(2)); // 3 reachable
_ = try gc.newInt(999); // young garbage, rooted nowhere
try gc.addRoot(keep);
gc.minorCollect();
try std.testing.expectEqual(@as(usize, 3), gc.promotions); // 3 survivors
try std.testing.expectEqual(@as(usize, 3), gc.old_count);
}
The counter goes exactly where the tenure happens, so it cannot drift from reality: if a promotion occurs, the counter moves; if it does not, it stays put. The garbage newInt(999) is swept without ever touching promotions.
Exercise 2 -- Age before tenure. Do not promote a young survivor the first time it lives; age it across several minor collections and only tenure it once it crosses a threshold, so medium-lived objects die cheaply in the young generation:
const std = @import("std");
const Obj = struct {
marked: bool = false,
gen: enum { young, old } = .young,
age: u8 = 0,
next: ?*Obj = null,
head: ?*Obj = null,
tail: ?*Obj = null,
};
const tenure_threshold: u8 = 3;
fn freeList(a: std.mem.Allocator, list: ?*Obj) void {
var it = list;
while (it) |o| {
const n = o.next;
a.destroy(o);
it = n;
}
}
const GC = struct {
alloc: std.mem.Allocator,
young: ?*Obj = null,
old: ?*Obj = null,
roots: std.ArrayList(*Obj),
fn init(a: std.mem.Allocator) GC {
return .{ .alloc = a, .roots = .empty };
}
fn deinit(self: *GC) void {
freeList(self.alloc, self.young);
freeList(self.alloc, self.old);
self.roots.deinit(self.alloc);
}
fn newObj(self: *GC) !*Obj {
const o = try self.alloc.create(Obj);
o.* = .{};
o.next = self.young;
self.young = o;
return o;
}
fn addRoot(self: *GC, o: *Obj) !void {
try self.roots.append(self.alloc, o);
}
fn mark(self: *GC, obj: ?*Obj) void {
const o = obj orelse return;
if (o.gen == .old or o.marked) return;
o.marked = true;
self.mark(o.head);
self.mark(o.tail);
}
fn minorCollect(self: *GC) void {
for (self.roots.items) |r| self.mark(r);
var kept_young: ?*Obj = null; // survivors not yet old enough to tenure
var it = self.young;
while (it) |o| {
const nxt = o.next;
if (o.marked) {
o.marked = false;
o.age += 1;
if (o.age >= tenure_threshold) {
o.gen = .old; // finally tenured
o.next = self.old;
self.old = o;
} else {
o.next = kept_young; // stays young, one collection older
kept_young = o;
}
} else {
self.alloc.destroy(o);
}
it = nxt;
}
self.young = kept_young;
}
};
test "an object is tenured only after reaching the age threshold" {
var gc = GC.init(std.testing.allocator);
defer gc.deinit();
const o = try gc.newObj();
try gc.addRoot(o);
gc.minorCollect(); // age 1
try std.testing.expectEqual(@as(u8, 1), o.age);
try std.testing.expect(o.gen == .young);
gc.minorCollect(); // age 2, still young
try std.testing.expect(o.gen == .young);
gc.minorCollect(); // age 3, crosses the threshold
try std.testing.expect(o.gen == .old);
}
The key change is that a surviving-but-too-young object is rebuilt onto a fresh kept_young list in stead of being moved to the old generation. Only when its age reaches the threshold does it graduate. This is the survivor-space idea from the big runtimes in miniature.
Exercise 3 -- A remembered-set stress test. Prove the write barrier scales past a single old-to-young edge: make ten old objects, point each at a fresh young object through the barrier, collect, and confirm all ten young children survived and the remembered set drained:
const std = @import("std");
const Obj = struct {
marked: bool = false,
gen: enum { young, old } = .young,
remembered: bool = false,
next: ?*Obj = null,
head: ?*Obj = null,
tail: ?*Obj = null,
value: i64 = 0,
};
fn freeList(a: std.mem.Allocator, list: ?*Obj) void {
var it = list;
while (it) |o| {
const n = o.next;
a.destroy(o);
it = n;
}
}
const GC = struct {
alloc: std.mem.Allocator,
young: ?*Obj = null,
old: ?*Obj = null,
roots: std.ArrayList(*Obj),
remembered: std.ArrayList(*Obj),
fn init(a: std.mem.Allocator) GC {
return .{ .alloc = a, .roots = .empty, .remembered = .empty };
}
fn deinit(self: *GC) void {
freeList(self.alloc, self.young);
freeList(self.alloc, self.old);
self.roots.deinit(self.alloc);
self.remembered.deinit(self.alloc);
}
fn newObj(self: *GC) !*Obj {
const o = try self.alloc.create(Obj);
o.* = .{};
o.next = self.young;
self.young = o;
return o;
}
fn addRoot(self: *GC, o: *Obj) !void {
try self.roots.append(self.alloc, o);
}
fn writeHead(self: *GC, parent: *Obj, child: *Obj) !void {
parent.head = child;
if (parent.gen == .old and child.gen == .young and !parent.remembered) {
parent.remembered = true;
try self.remembered.append(self.alloc, parent);
}
}
fn markYoung(self: *GC, obj: ?*Obj) void {
const o = obj orelse return;
if (o.gen == .old or o.marked) return;
o.marked = true;
self.markYoung(o.head);
self.markYoung(o.tail);
}
fn minorCollect(self: *GC) void {
for (self.roots.items) |r| self.markYoung(r);
for (self.remembered.items) |old_obj| {
self.markYoung(old_obj.head);
self.markYoung(old_obj.tail);
}
var it = self.young;
while (it) |o| {
const nxt = o.next;
if (o.marked) {
o.marked = false;
o.gen = .old;
o.next = self.old;
self.old = o;
} else {
self.alloc.destroy(o);
}
it = nxt;
}
self.young = null;
for (self.remembered.items) |old_obj| old_obj.remembered = false;
self.remembered.clearRetainingCapacity();
}
};
test "the write barrier scales to many old-to-young edges" {
var gc = GC.init(std.testing.allocator);
defer gc.deinit();
// Ten old objects: allocate, root, then promote them with a minor GC.
var olds: [10]*Obj = undefined;
for (&olds) |*slot| {
const o = try gc.newObj();
try gc.addRoot(o);
slot.* = o;
}
gc.minorCollect();
for (olds) |o| try std.testing.expect(o.gen == .old);
// Point each old object at a brand new young object through the barrier.
for (olds, 0..) |o, i| {
const child = try gc.newObj();
child.value = @intCast(i);
try gc.writeHead(o, child);
}
gc.minorCollect();
// Every young child survived (and was promoted); the remembered set drained.
for (olds, 0..) |o, i| {
try std.testing.expect(o.head.?.gen == .old);
try std.testing.expectEqual(@as(i64, @intCast(i)), o.head.?.value);
}
try std.testing.expectEqual(@as(usize, 0), gc.remembered.items.len);
}
Without the remembered set, the second minor collection would free all ten children, because the trace starts at the roots and stops the moment it hits an old object. The barrier is what makes the ten edges visible to the collector, and the remembered flag keeps each parent in the set at most once. Onward to today's real subject.
Put three ways of running a program side by side. An ahead-of-time (AOT) compiler -- what Zig itself is -- translates your whole source to machine code once, before the program ever runs; you pay the translation cost at build time and the program starts as native code. An interpreter -- what our VM from episode 136 is -- translates nothing; it keeps the program in some easy-to-walk form (bytecode) and re-examines each instruction every time it executes, which is simple and portable but pays that dispatch tax forever. A JIT ("just in time") compiler sits precisely between the two: it starts like an interpreter, but at run time it translates hot pieces of the program into machine code and runs that from then on. The compile happens during execution, "just in time" for the code to be needed -- hence the name.
Why bother, when AOT exists? Because a JIT knows things an AOT compiler cannot. It sees the actual types flowing through a function, the actual branch that is always taken, the actual object shapes -- and it can generate code specialised to this run. That is how a dynamically typed language like JavaScript reaches within a stone's throw of C: V8 watches your code, notices that a function is only ever called with integers, and compiles a fast integer-only version, ready to bail out if that assumption ever breaks. The flip side is real cost and real complexity -- you are shipping a code generator inside your program, and generating machine code at run time is fiddly and platform-specific. Having said that, the core mechanism is surprisingly small, and we can build it today.
Every JIT, from LuaJIT to the JVM, rests on one capability the interpreter never needed: the ability to put bytes into memory and then execute them as instructions. Normal data memory cannot be run -- the CPU (and the operating system) refuse, on purpose, because a program that could execute arbitrary data it just wrote is a security nightmare. So the whole game begins with asking the OS, explicitly, for memory we are allowed to run.
On a Unix system that request is mmap, the same call we met for memory-mapped files back in episode 31 -- only now we ask for an anonymous, private mapping (backed by nothing on disk, just RAM) and, crucially, with the execute permission bit set. We covered instruction encoding in the assembler episodes 59 to 61, so the byte sequences below are old friends. Here is the smallest possible JIT: a function that returns the constant 42:
const std = @import("std");
const posix = std.posix;
fn makeExecutable(code: []const u8) ![]align(std.heap.page_size_min) u8 {
// Ask the OS for read/write/execute anonymous memory (see the W^X note below).
const mem = try posix.mmap(
null, // let the kernel choose the address
code.len, // rounded up to a page internally
.{ .READ = true, .WRITE = true, .EXEC = true },
.{ .TYPE = .PRIVATE, .ANONYMOUS = true },
-1, // no file descriptor: anonymous
0,
);
@memcpy(mem[0..code.len], code);
return mem;
}
test "a hand-assembled function that returns 42" {
// x86-64 System V:
// B8 2A 00 00 00 mov eax, 42 (return value goes in eax/rax)
// C3 ret
const code = [_]u8{ 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
const mem = try makeExecutable(&code);
defer posix.munmap(mem);
// Reinterpret the first byte of that memory AS a function and call it.
const f: *const fn () callconv(.c) i32 = @ptrCast(mem.ptr);
try std.testing.expectEqual(@as(i32, 42), f());
}
Read the magic in the last two lines. @ptrCast takes the pointer to our executable memory and tells the compiler "treat this address as a function of type fn () callconv(.c) i32". Nothing about the bytes changed -- we simply reinterpret them. Then f() does what calling any function pointer does: it jumps the CPU's instruction pointer to that address. The CPU fetches B8 2A 00 00 00, loads 42 into eax, hits C3, and returns -- landing us back in Zig with 42 in hand. The callconv(.c) matters: it tells Zig to use the platform C calling convention (System V on Linux/macOS), which is the convention our hand-written bytes assume -- return value in eax, arguments in edi, esi, and so on. We compiled a function with no compiler. Wowzers.
Hand-typing hex is fine for one instruction and miserable for a hundred. Real JITs build up code in a growable buffer with small helpers, so let us do the same -- an emitter over an ArrayList(u8):
const std = @import("std");
const Emitter = struct {
code: std.ArrayList(u8),
fn init() Emitter {
return .{ .code = .empty };
}
fn deinit(self: *Emitter, a: std.mem.Allocator) void {
self.code.deinit(a);
}
fn byte(self: *Emitter, a: std.mem.Allocator, b: u8) !void {
try self.code.append(a, b);
}
fn bytes(self: *Emitter, a: std.mem.Allocator, bs: []const u8) !void {
try self.code.appendSlice(a, bs);
}
fn imm32(self: *Emitter, a: std.mem.Allocator, v: u32) !void {
// x86-64 is little-endian, and toBytes lays the value out that way.
try self.code.appendSlice(a, &std.mem.toBytes(v));
}
};
test "emit mov eax, imm32 ; ret" {
const a = std.testing.allocator;
var e = Emitter.init();
defer e.deinit(a);
try e.byte(a, 0xB8); // mov eax, imm32
try e.imm32(a, 1337);
try e.byte(a, 0xC3); // ret
try std.testing.expectEqual(@as(usize, 6), e.code.items.len);
try std.testing.expectEqual(@as(u8, 0xB8), e.code.items[0]);
try std.testing.expectEqual(@as(u8, 0xC3), e.code.items[5]);
}
Now the security caveat I skipped over. In makeExecutable I asked for memory that was writable and executable at the same time. That is the easy path and it works on a plain Linux box, but it is exactly the condition every exploit dreams of: if an attacker can steer a write into that page, they can plant their own instructions and have them run. Modern systems enforce W^X ("write xor execute") -- a page may be writable or executable, but never both at once -- and hardened platforms (OpenBSD, macOS with the hardened runtime, SELinux in enforcing mode) will flat-out refuse an RWX mapping. The disciplined pattern is two steps: map the page writable (but not executable), write the code, then flip it to executable (and drop write) before you ever call it. That flip is mprotect:
const std = @import("std");
const posix = std.posix;
const linux = std.os.linux;
// W^X: allocate writable, copy code in, THEN flip to read+execute.
fn finalize(code: []const u8) ![]align(std.heap.page_size_min) u8 {
const mem = try posix.mmap(
null,
code.len,
.{ .READ = true, .WRITE = true }, // writable, NOT executable yet
.{ .TYPE = .PRIVATE, .ANONYMOUS = true },
-1,
0,
);
errdefer posix.munmap(mem);
@memcpy(mem[0..code.len], code);
// Now drop write and add execute. The page is never W and X together.
const rc = linux.mprotect(mem.ptr, mem.len, .{ .READ = true, .EXEC = true });
if (posix.errno(rc) != .SUCCESS) return error.ProtectFailed;
return mem;
}
test "finalize produces callable code without ever being W and X together" {
// B8 07 00 00 00 mov eax, 7 ; C3 ret
const code = [_]u8{ 0xB8, 0x07, 0x00, 0x00, 0x00, 0xC3 };
const mem = try finalize(&code);
defer posix.munmap(mem);
const f: *const fn () callconv(.c) i32 = @ptrCast(mem.ptr);
try std.testing.expectEqual(@as(i32, 7), f());
}
There is a subtlety on some CPUs (notably ARM) that even after mprotect you must flush the instruction cache before the freshly written bytes are guaranteed to run, because the data cache and instruction cache can disagree about what is at that address. On x86-64 the hardware keeps them coherent for you, so we get away without it here -- but a portable JIT calls __builtin___clear_cache (or the equivalent) after writing. Keep that in the back of your mind for the exercises.
Now the real thing. Our VM from episode 136 is a stack machine: push constants, and each arithmetic op pops its operands and pushes a result. The most direct kind of JIT -- a template JIT -- walks that bytecode and, for each op, spits out a fixed little template of machine code that does the same thing on the real CPU stack. No cleverness, no optimisation, just a one-to-one translation. It is the simplest JIT that exists, and it already leaves the interpreter in the dust because there is no dispatch loop left:
const std = @import("std");
const posix = std.posix;
const linux = std.os.linux;
// A tiny stack-machine program, same spirit as our bytecode VM.
const Op = union(enum) { push: i64, add, sub, mul };
const Jit = struct {
code: std.ArrayList(u8),
fn init() Jit {
return .{ .code = .empty };
}
fn deinit(self: *Jit, a: std.mem.Allocator) void {
self.code.deinit(a);
}
fn emit(self: *Jit, a: std.mem.Allocator, bs: []const u8) !void {
try self.code.appendSlice(a, bs);
}
// Translate each op into a template that uses the real x86-64 stack.
fn compile(self: *Jit, a: std.mem.Allocator, program: []const Op) !void {
for (program) |op| switch (op) {
.push => |v| {
try self.emit(a, &[_]u8{ 0x48, 0xB8 }); // mov rax, imm64
try self.emit(a, &std.mem.toBytes(v)); // the 8-byte constant
try self.emit(a, &[_]u8{0x50}); // push rax
},
// pop rcx ; pop rax ; add rax, rcx ; push rax
.add => try self.emit(a, &[_]u8{ 0x59, 0x58, 0x48, 0x01, 0xC8, 0x50 }),
// pop rcx ; pop rax ; sub rax, rcx ; push rax
.sub => try self.emit(a, &[_]u8{ 0x59, 0x58, 0x48, 0x29, 0xC8, 0x50 }),
// pop rcx ; pop rax ; imul rax, rcx ; push rax
.mul => try self.emit(a, &[_]u8{ 0x59, 0x58, 0x48, 0x0F, 0xAF, 0xC1, 0x50 }),
};
try self.emit(a, &[_]u8{ 0x58, 0xC3 }); // pop rax ; ret (result in rax)
}
fn finalize(self: *Jit) !*const fn () callconv(.c) i64 {
const mem = try posix.mmap(
null,
self.code.items.len,
.{ .READ = true, .WRITE = true },
.{ .TYPE = .PRIVATE, .ANONYMOUS = true },
-1,
0,
);
errdefer posix.munmap(mem);
@memcpy(mem[0..self.code.items.len], self.code.items);
const rc = linux.mprotect(mem.ptr, mem.len, .{ .READ = true, .EXEC = true });
if (posix.errno(rc) != .SUCCESS) return error.ProtectFailed;
return @ptrCast(mem.ptr);
}
};
test "jit compiles (2 + 3) * 4 - 1 to machine code" {
const a = std.testing.allocator;
var j = Jit.init();
defer j.deinit(a);
// Reverse-Polish: 2 3 + 4 * 1 - == (2 + 3) * 4 - 1 == 19
const program = [_]Op{
.{ .push = 2 }, .{ .push = 3 }, .add,
.{ .push = 4 }, .mul, .{ .push = 1 },
.sub,
};
try j.compile(a, &program);
const f = try j.finalize();
try std.testing.expectEqual(@as(i64, 19), f());
}
Trace what compile builds. Each push emits mov rax, <constant> then push rax, putting the value on the CPU's own stack. Each binary op pops the top two values into rcx and rax, does the arithmetic in a single native instruction, and pushes the result back. At the end, pop rax ; ret leaves the one remaining value in rax, exactly where the C calling convention expects a return value. The result is a genuine native function -- when you call f(), the CPU runs your arithmetic directly, with zero interpreter overhead. It is not optimised (a real compiler would fold (2+3)*4-1 to the constant 19 at compile time), but it is real machine code, and it is the honest skeleton every template JIT shares.
Writing a JIT in C is a festival of void* casts and mmap return codes you forget to check. Zig tightens several of those screws. Look back at the casts: @ptrCast from a byte pointer to a typed function pointer is explicit and greppable -- you cannot accidentally call data as code, you have to say so. And because the function type is a real Zig type, we can make the emitter generic over the signature it produces, so a mismatch between the bytes and the call site becomes a type you can see:
const std = @import("std");
const posix = std.posix;
const linux = std.os.linux;
// Turn raw bytes into a callable of an arbitrary, comptime-known signature.
fn jitFunction(comptime Fn: type, code: []const u8) !*const Fn {
const mem = try posix.mmap(
null,
code.len,
.{ .READ = true, .WRITE = true },
.{ .TYPE = .PRIVATE, .ANONYMOUS = true },
-1,
0,
);
errdefer posix.munmap(mem);
@memcpy(mem[0..code.len], code);
const rc = linux.mprotect(mem.ptr, mem.len, .{ .READ = true, .EXEC = true });
if (posix.errno(rc) != .SUCCESS) return error.ProtectFailed;
return @ptrCast(@alignCast(mem.ptr));
}
test "a comptime signature turns bytes into a fully typed callable" {
// mov eax, edi ; add eax, esi ; ret -- returns arg0 + arg1
const code = [_]u8{ 0x89, 0xF8, 0x01, 0xF0, 0xC3 };
const add = try jitFunction(fn (i32, i32) callconv(.c) i32, &code);
defer {
// Recover the mapping to free it: the fn pointer IS the base address.
const base: [*]align(std.heap.page_size_min) u8 = @constCast(@ptrCast(@alignCast(add)));
posix.munmap(base[0..code.len]);
}
try std.testing.expectEqual(@as(i32, 9), add(4, 5));
}
Three things earn their keep here. First, comptime Fn: type means the caller states the exact signature, and Zig checks every call against it -- pass the wrong number of arguments and it will not compile, rather than corrupting the stack at run time the way a C void* cast would. Second, mmap and mprotect return Zig errors, so a failed mapping is a ! you must handle, not a null pointer you forget to check and then dereference into a crash. Third, @alignCast makes the alignment assumption explicit -- function code wants proper alignment, and Zig will not let you paper over it silently. The compiler is not writing the machine code for you, but it is standing guard over the dangerous seams where bytes become behaviour.
How do you unit-test a function that did not exist until this microsecond? The same way we tested the collector last episode: with an independent oracle. We already have a perfectly good, obviously-correct way to run a stack program -- a plain interpreter. So we run each program both ways, through the slow-but-trusted interpreter and through the JIT, and assert they agree. If a template has a wrong opcode byte, the two answers diverge and the test screams:
const std = @import("std");
const Op = union(enum) { push: i64, add, sub, mul };
// The trusted oracle: a dead-simple tree-of-stack interpreter.
fn interpret(program: []const Op) i64 {
var stack: [64]i64 = undefined;
var sp: usize = 0;
for (program) |op| switch (op) {
.push => |v| {
stack[sp] = v;
sp += 1;
},
.add => {
sp -= 1;
stack[sp - 1] += stack[sp];
},
.sub => {
sp -= 1;
stack[sp - 1] -= stack[sp];
},
.mul => {
sp -= 1;
stack[sp - 1] *= stack[sp];
},
};
return stack[sp - 1];
}
test "the interpreter oracle agrees with hand-computed values" {
const p1 = [_]Op{ .{ .push = 2 }, .{ .push = 3 }, .add }; // 5
const p2 = [_]Op{ .{ .push = 6 }, .{ .push = 7 }, .mul, .{ .push = 2 }, .sub }; // 40
try std.testing.expectEqual(@as(i64, 5), interpret(&p1));
try std.testing.expectEqual(@as(i64, 40), interpret(&p2));
}
In your own project you would pair this with the JIT from the previous section: generate a batch of random programs, run each through both interpret and the JIT'd function, and assert equality. That is differential testing, and it is the single most effective tool for a code generator, because the two implementations are so different that a bug would have to occur identically in both to slip through -- which essentially never happens. It caught every opcode typo I made while writing this episode, and I made a few. ;-)
I have been quietly assuming x86-64 and Linux this whole time, and it is worth being honest about what that buys and costs. The machine-code bytes are pure x86-64: run this on an Apple Silicon or Raspberry Pi and the CPU will choke on 0x48 0xB8 because AArch64 speaks a completely different instruction set -- a real JIT keeps a code emitter per architecture. The mmap/mprotect dance is POSIX, so it ports to macOS as-is, except that macOS on Apple Silicon additionally requires the MAP_JIT flag and a call to pthread_jit_write_protect_np around the write, part of its hardened W^X enforcement. On Windows the shape is identical but the names change: VirtualAlloc with PAGE_READWRITE, write, then VirtualProtect to PAGE_EXECUTE_READ. Same three moves everywhere -- get memory, write code, make it runnable -- just spelled differently.
And the big runtimes? They are elaborations of exactly this. LuaJIT is a template/tracing hybrid whose speed is legendary, but its heart is still "emit machine code into executable pages and jump to it". V8 (JavaScript) and HotSpot (Java) are tiered: they start by interpreting, profile which functions are hot, JIT those with a fast baseline compiler, and re-JIT the hottest with an optimising compiler that specialises on the types actually observed -- deopting back to the interpreter if an assumption breaks. That tiering is the whole art: compiling everything eagerly wastes time on code that runs once, so a JIT spends its compile budget only where it pays off. PyPy takes the idea to an extreme with a meta-JIT that generates the JIT from the interpreter. All of them, underneath the profiling and the optimising and the deopting, do what we just did: put bytes in an executable page and call them.
Would you write a JIT for the average program? Almost never -- and that is the honest answer. A JIT earns its enormous complexity in exactly one place: a runtime that executes user code hot enough that interpreter overhead dominates, and where you cannot compile that code ahead of time because you do not have it until run time. Language VMs, regex engines with pathological patterns, database query executors, spreadsheet recalculation, shader compilers -- these are the natural homes. For everything else, an AOT compiler like Zig already gives you native speed with none of the runtime code-generation risk, and a plain bytecode interpreter is a hundred times easier to get right. Reach for a JIT when you have measured that the dispatch loop is your bottleneck and the workload is genuinely hot, not before -- the same measure-first discipline from episode 34.
Our template JIT is deliberately the dumbest thing that works: one fixed template per op, everything through the CPU stack, no attempt to keep values in registers or fold constants. That is where the real craft of a code generator begins -- deciding which values live in which registers, laying out control flow with real jumps, and calling back into the runtime (our garbage collector, say) from generated code. There is a whole mountain of that ahead, and we will climb it. For now, sit for a second with what you built: a program that writes another program, in the CPU's own language, while it runs, and then runs it. That is about as close to the metal as software gets.
A division op, and a guard. Add .div to the Op union and the template JIT. The x86-64 signed-divide instruction idiv divides the 128-bit value in rdx:rax by its operand and needs rax sign-extended into rdx first (the cqo instruction, bytes 0x48 0x99), with the operand somewhere other than rax. Emit pop rcx ; pop rax ; cqo ; idiv rcx ; push rax, then write a differential test comparing your JIT against an interpreter extended with division. What happens if the divisor is zero, and where would you add a guard?
A one-argument function. Extend the JIT so the generated function takes a single i64 argument (arriving in rdi per the System V convention) and a special .arg op pushes it onto the stack. Emit push rdi for .arg, compile a program like arg arg mul (which squares its input), finalize to a *const fn (i64) callconv(.c) i64, and test that it squares several values. This is the first step toward JIT-compiling a function that actually takes parameters.
Instruction-cache safety and cleanup. Our finalize never unmaps the code, and on non-x86 targets it skips the instruction-cache flush. Wrap the mapping in a small CompiledFn struct that stores the base pointer and length, exposes the typed function pointer, and has a deinit that calls munmap. Then add a call to flush the instruction cache after mprotect (Zig exposes the builtin as @import("builtin")-gated inline assembly on ARM, or you can call the C builtin via @extern) and write a comment explaining why x86-64 did not need it but AArch64 does.
That is our little machine crossing the line from interpreting programs to compiling them -- raw x86-64 bytes, an executable page, and a jump straight into code that did not exist a moment ago. Bedankt en tot de volgende keer! ;-)