Koru's JavaScript Target: What a Second Backend Finds in the First

· 7 min read

Koru has a second backend. The same source that compiles to Zig now compiles to JavaScript, and std/store — the reactive table at the centre of the language — runs on node: container stores, guarded sweeps, standing rules, lifecycle interceptors, [tree] traversal, owned string columns.

This is an entity integration step. It is the whole program, and it produces that output on both targets.

import std/io
import std/store

std/store:new(ents, capacity: 4) { px: i64, vx: i64, py: i64, vy: i64 }

std/store:insert(ents) { px: 0, vx: 1, py: 0, vy: 2 }
std/store:insert(ents) { px: 10, vx: 3, py: 20, vy: 4 }

std/store:query(ents)
! query e
    |> std/store:stored { e.px: e.px + e.vx, e.py: e.py + e.vy }

std/store:query(ents)
! query r |> std/io:print.ln("p {{ r.px:d }} {{ r.py:d }}")
Output
p 1 2
p 13 24

Nothing in that program is written for a target. There is no conditional compilation, no host block, no shim. query opens a sweep, stored writes the row, and which backend renders it is a flag.

The port is not the interesting part

The interesting part is what the second backend said about the first.

Zig’s optimiser is very good, and for as long as Koru had exactly one backend, that was indistinguishable from Koru emitting good code. A cost LLVM deletes and a cost the compiler never emits look identical from the outside — the binary is the same either way, and nobody has a reason to ask which it was.

JavaScript has no such optimiser. Every dead thing the compiler emits, V8 runs.

The first finding was the store’s own announcement path. Writing a column announces it, so standing rules can react. When no rule observes the store, that announcement reads the column back, allocates a branch object to carry it, tests its tag against every column, and throws the result away — per write, per row. LLVM had been erasing that chain since the store was written. On node it was 77% of total runtime.

The store knows its observers at compile time. It now declines to announce into an empty room, and the same program got 4.3× faster — while the Zig side did not move a millisecond, because it had never paid.

A name JavaScript cannot bind

The second finding was smaller and more embarrassing. Koru’s own store vocabulary names the before-and-after of a write old and new:

new is a JavaScript keyword. The destructure emitted const new = … and node refused the entire program.

Refusing the Koru would have been the wrong fix — nothing about that program is wrong, and a host’s keyword list is not a language design input. So the binding is renamed instead, and only the binding: const new$ = __koru_input.new;. The property key keeps Koru’s spelling, because a reserved word is a perfectly legal key in JavaScript and because the transforms that build those objects write them as raw host text that never passes through the emitter. Rename the key and the reader silently stops matching the writer.

Monomorphising the write

A stored block names its columns statically, which means the set of columns a write touches is known at compile time. The store was not using that: one write unit served every block, taking a bitmask and testing each bit at runtime.

Those tests were the last of the gap. The store now emits one write unit per column set actually written — no mask, no dead slots, unconditional stores. A set the scan cannot see keeps the general unit, so the optimisation declines rather than misfires.

On a 4096-entity, 50 000-frame integration benchmark, one source compiled both ways with identical checksums:

before2.78 s
no announcement into an empty room0.64 s
monomorphised write unit0.30 s
the same loop, hand-written in JavaScript0.29 s
the same source, compiled to Zig0.07 s

(tests/benchmarks/005_target_paritysh run.sh re-derives the last three rows. All three arms print a checksum and the runner fails non-zero if they disagree, because a benchmark whose arms compute different things measures nothing and does it quietly.)

The emitted JavaScript is no longer distinguishable from the loop a person would write by hand. What separates it from Zig is JavaScript.

Worth naming: the store’s layout was never the problem. The emitted cell is four flat arrays, exactly what the hand-written control uses. Both wins were in the language’s own lowering, and neither was in the JavaScript emitter.

Where it stands

At the last full scan, 635 of 755 measurable tests pass on the JavaScript target, and std/store sits at 120 of 131 — from zero two days ago. The remainder is a tail rather than a wall: a handful of fixtures carrying their own Zig proc bodies, two grid writes, one @import with no JavaScript lowering, one question parked on a ruling.

The thing to take from this is not the second backend. It is that a compiler with one backend cannot tell the difference between code it does not emit and code its host deletes — and it has no reason to care until the day it does. Porting was the audit.

There is a smaller version of the same point. The emitted JavaScript is a text file. Four candidate optimisations were priced by editing it and re-running node, before a line of compiler code was written, and three of them measured exactly nothing — the sweep body V8 already inlines, the call V8 already elides once the callee is empty, the arithmetic that was never the cost. Each was plausible enough to schedule. Asking the same four questions of the Zig arm needs a disassembler and a rebuild each time.

A backend you can read is a backend that tells you things.