Koru's JavaScript Target: What a Second Backend Finds in the First
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 }}")p 1 2
p 13 24Nothing 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:
std/store:new(arena, capacity: 64) { hp: i64, kind: i64 }
! inserted { hp }
|> std/store:stored { board.alive: board.alive + 1 }
|> std/store:stored { board.pool: board.pool + hp }
! removed { hp }
|> std/store:stored { board.alive: board.alive - 1 }
|> std/store:stored { board.kills: board.kills + 1 }
|> std/store:stored { board.pool: board.pool - hp }
! updated { old, new }
|> std/store:stored { board.pool: board.pool + new - old }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:
| before | 2.78 s |
| no announcement into an empty room | 0.64 s |
| monomorphised write unit | 0.30 s |
| the same loop, hand-written in JavaScript | 0.29 s |
| the same source, compiled to Zig | 0.07 s |
(tests/benchmarks/005_target_parity — sh 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.