Aperture Institute GLaDOS — an operating system in Rust, with a language model in the kernel
GLaDOS / Wiki / The compiler

The compiler, and the differ that gates it

There is a code generator in this tree that compiles Aiksi to x86-64 and calls it. Nothing routes through it. It is reached only from a harness whose job is to disagree with it, and the harness was written first, on purpose.

Somewhere to run from

Every page in this kernel is present, writable and executable. There is no NX constant in the paging code and the processor's no-execute enable is never set, so getting somewhere to put generated instructions is an allocation with 4096-byte alignment and no page-table work at all.

That is exactly why the rest is careful. When the hard part is free, the remaining parts are the ones that bite.

Serialisation. Nothing in this tree serialised before this: no wbinvd, no clflush, no mfence, no cpuid used as a barrier anywhere. CPUID does both halves of what is needed. The processor drops what it prefetched, and because the inline assembly declares neither nomem nor readonly, the compiler cannot sink the stores that filled the buffer past it.

The ABI is declared once. unsafe extern "sysv64" fn(u64) -> u64, so no call site spells the convention out. On this target extern "C" is Microsoft x64, not System V, and the context-switch code records what it cost to get that wrong once already. The self-test's stub is mov rax, rdi, add rax, rax, ret, called with 21 — because a stub taking no arguments passes under either convention and proves nothing. Both agree on a bare return in rax. Only the argument tells them apart.

A fault reporter that stops lying

The old reporter printed rip - IMAGE_BASE whenever the base was known, with no check that rip was inside the image. A wild jump therefore produced a number indistinguishable from a real relative address, which a disassembly then resolves confidently to an unrelated function. The image size was read from the firmware at boot, printed, and never stored.

It is stored now, and code::locate is a pure function over the five inputs — instruction pointer, image base, image size, and what the registry of generated buffers says — with all five of its outcomes asserted at boot. Heap-resident generated code is named by tag and offset. An address in neither the image nor the registry is reported as being in neither.

None of this prevents anything. Every interrupt vector but the breakpoint is fatal here, so a bad jump is still a halted machine. What the registry buys is that the one diagnostic which survives says something true.

Verified by faulting on purpose. fault code emits a null dereference into an executable buffer, arms it, and jumps in. The buffer is deliberately leaked rather than dropped, because the reporter reads the registry from inside the handler and dropping it would unregister the range on the way out:

*** EXCEPTION 0x0e  #PF page fault ***
  rip   0x0000000002bf2003   cs  0x0008
  in generated code fa17000000000003 at +0x3

The buffer was at 0x2bf2000, so +0x3 is exact.

Getting that report at all meant fixing something much older: no fault this kernel ever took had produced a readable one. Printing writes the console first and the serial port second, and painting from inside an interrupt gate takes a general protection fault here — so the first line of every report died in the console before serial was reached, and what a person saw was a machine that simply went quiet. The report is emitted twice now, whole, serial before console, rather than interleaved a line at a time, which was the first attempt and still truncated after one line. Serial is a port write and cannot block or fault. A flag makes a fault while reporting print one line and halt rather than recurse, which it did, as an unbroken column of the same exception.

What the compiler actually accepts

One function of integer arithmetic, if, while and return. No builtins, no strings, no records, no imports, no calls.

Anything outside that slice is refused: compile answers nothing and the interpreter remains the only thing that ran the program. Five claims check that refusing actually happens, because a generator that quietly compiled a string return would be answering a question nobody asked.

The step count is the hard part

Twenty-one functions run three ways — interpreted, prepared, and compiled — and all three must agree on the value, the cost and the error text, over 64 rounds.

The cases that earn their place are the short-circuit pair, where whether the right-hand side's ticks happen at all is decided by a runtime value, and the runaway, which has to hit the budget at the same step rather than merely also stopping. Failure text is compared too: division by zero is a status code in compiled output that has to become the interpreter's own words, because a compiler with perfect arithmetic and the wrong error string passes any test that only reads answers.

The harness caught its first mismatch immediately, on fn f(): int { return 7 }, and it was the harness's fault rather than the compiler's. Observation runs the top level before invoking, so the interpreter had already charged one tick for executing the declaration. Compiled code never runs a top level, so it owes exactly that tick — and because the case guarantees the top level is a single statement, the number is one and not an estimate. The budget it is given is short by one for the same reason.

The tick rule is written down where the counter lives, because anything executing this language by another route has to match it exactly: once on entering a statement, once on entering an expression, one extra per loop iteration, and nothing else. A builtin costs one step however much work it does. The budget is a safety bound, so a program that got more room by being run a different way would be a runaway that one path stops and another does not.

The differ, and the canary that must fail

The gate runs one program two ways and requires them to agree on value, step count and error text, bit for bit with no tolerance. That is the same argument the SMP code makes about a split matrix-vector product: any difference at all is a bug, and a tolerance hides the one worth finding.

It was written before the code generator on purpose. The model code makes the objection twice — two implementations that are supposed to agree do not stay agreeing — and a harness written afterwards is a harness shaped by whatever the second implementation happens to do. The second route today is the prepare-and-adopt path against plain interpretation, which is a real pair rather than a placeholder, and is the one every routing decision now depends on. A compiled route is a third route and every case applies to it unchanged.

The canary is what makes it a harness rather than a formality. A suite that has never reported a difference is indistinguishable from one that compares nothing, which is exactly how a one-shot check elsewhere in this tree passed over a deadlock. So the suite runs two programs differing by one unused declaration: same answer, one more top-level statement, step counts one apart. It fails if that is not caught. That is the difference a comparison looking only at answers waves through, and precisely the one a code generator with nearly-right ticks produces.

Eleven cases, sixty-four rounds, including the three failure modes and a loop for the tick most easily got wrong. Two limits are printed rather than left to be assumed. Stored cores and seeded tools are compared too, but all three seeded tools have computing top levels, so the prepared route declines them and the stored half of the corpus contributes nothing on a fresh machine — the line reads 0 agreed, 3 declined. And console output is not compared at all, the same blind spot the skill judge records: a program of print statements answers nothing however it behaved.

What a step costs, and why the compiler is not the answer

Before any of this, every step budget in the tree was a number chosen by comparison to another number and nobody had ever timed one. Best of nine, on real measurement:

One step13.7 ns, about 37 cycles at 2.67 GHz
Interpreter construction2,132 ns
Vote budget, 20k274 us
Draw budget, 200k2 ms
Step budget, 20M274 ms

So the budgets are sane as safety bounds, and the genuinely hot path — the vote on every routing decision — spends 20 steps against a ceiling of 20,000, a tenth of a percent of what it is allowed.

Breaking one vote into its parts contradicted a confident prediction written into the plan that asked for the measurement:

Build interpreter1,757 ns31%
Run the top level623 ns11%
Call the function3,244 ns57%
Total5,658 ns

The walk looks dominant and is not. Twenty steps at 13.7 ns is about 275 ns of dispatch, which is 8% of that call and 5% of the whole vote. The rest is work no code generator removes: allocating a fresh string, scanning it, and cloning an argument string plus a 23-element list into the frame. Against that, the 2,380 ns of setup paid per decision is eight times larger than the entire tree-walk — setup for a program whose whole body is one assignment and three conditionals.

That is why the optimisation work came before any compiler, and why a compiler for this tree cannot be justified as an optimisation of this path. The plan's own text said so in advance: unless the measurement showed the tree-walk itself was the cost, the compiler was not the answer. It showed exactly that.

Three wins, all of them allocation

The kernel record shapes. Interpreter construction copied all eight into the program's own table every time: 49 string allocations and eight tree inserts to reproduce immutable kernel data, identical in every interpreter that has ever existed. Lookup consults two tables now, which is safe only because a program was already forbidden from redeclaring a kernel record — a guard that was tidiness when both lived in one map and is load-bearing now that they are two.

The function itself. A call deep-copied the whole statement tree and every expression tree under it, on a structure that is immutable from the moment it was declared. Functions are refcounted now, so a call is a refcount bump. Not atomically refcounted: one interpreter per call chain, nothing crossing a task, the same single-core assumption the rest of the kernel's interior mutability rests on. It is a real trade — one allocation added per declaration to remove about nineteen per call — and the vote is the worst case for it, a seven-statement function called once, and it still wins, because the saving scales with the body and the cost does not.

The declaration. The vote ran the core's top level on every routing decision to register a function that had been the same function since it was parsed. A declarative top level runs once now and a fresh interpreter is seeded from the result. Two conditions make that a saving rather than a semantic change, and both are in the code as the reason. A declaration's only effect is to register itself, so running it once and copying is indistinguishable from running it again — but an assignment, a call that could read the clock, or an import that lexes another file would all be frozen at preparation time, so those programs keep re-arming. And the prepared form carries the top level's step count, because a prepared run that skipped those ticks would answer identically and report itself cheaper, which is two paths through one program disagreeing about a number the judges read.

Interpreter construction2,132 ns → 22 ns
Build interpreter, in a vote1,757 ns → 42 ns
Running the top level813 ns → ~75 ns
One vote, cumulative418 → 146 step-equivalents, 2.9x

None of it from compiling anything. Both predictions the plan made — that the tree-walk was the cost, and that construction was near-certainly the fix — were wrong in the same direction.

How big the noise is

The first version of this benchmark timed one vote per sample, best of nine, and could not resolve its own subject: a change worth about a tenth of a vote read as an 18% regression, on a boot whose step loop happened to run 25% faster than the boot it was compared against. Each sample runs 200 votes now.

That fixes per-sample noise and not the boot-to-boot kind, so the benchmark carries a control. Interpreter construction cannot be touched by anything about how a program is stored or called, so a difference in that line between two builds is measurement error and nothing else. Across the pair of builds that judged the refcounting change it read 16%, which is what "within noise" is allowed to mean here — a number instead of an adjective.