boids_kaigen: A Million Fish on std/store — Native, WASM, and What the Numbers Actually Cover
boids_kaigen is a new benchmark arm in tests/benchmarks/003_ecs_reactive:
Kaigen’s port of Unity’s ECS Boids sample, canonicalized as a std/store pass
ladder, oracle-identical to a straight-line Zig twin at every scale from 10k
to their 1M ceiling — and the same emitted program runs on wasm32-wasi under
the same V8 a browser tab would give it.
The occasion was an engine marketing post claiming their ECS ran this sim on the web at ~2ms where Unity took ~9. The useful part of that claim isn’t the ratio — it’s that the workload is a different shape than the one we already oracle-test, and the demo page runs the algorithm in browser WASM with its own timing HUD on the page. The honest part is what it’s measured against, and what it isn’t.
The shape, and why it’s a different probe
The existing boids arm walks Unity DOTS’ own spatial structure: an exact
32³ grid, cell-average representatives, positions clamped to the cube.
Kaigen’s engine does something coarser and, for a store, more interesting:
- A flat 8192-bucket spatial hash — quantized position, multiply-mixed, mod 8192. Distinct cells collide; the bucket is an approximation, not a partition.
- Bounded accumulation — each bucket caps at 256 entries.
- First-arrival semantics — nearest target/obstacle resolves from the first boid that landed in the bucket, not the cell average.
- Their insert is a multithreaded atomic append; both arms here canonicalize
it as sequential insertion in boid order — the aggregate sums are
bit-identical, and the caps guard
count < 256exactly like theirslot < MAX_PER_BUCKET.
The hash itself is ours (spatial_hash_3f is engine-private), and the engine
is closed beta — so this is a port of the published algorithm, not their
binary. The oracle is the same one the whole board uses: cross-arm sink
equality against a straight-line Zig twin.
What the store does with it
The Koru arm is a stage ladder over std/store + std/grid — each pass is
one sweep, and the chain is the frame:
tor kboid-frame {}
kboid-frame = kframe-actors() |> kgrid-clear() |> khash() |> kscatter() |> kmerge()
|> ksteer-align() |> ksteer-sep() |> ksteer-tgt() |> ksteer-norm()
|> ksteer-avoid() |> ksteer-head() |> ksteer-move() The hash pass is one write — the flat hash needs no per-axis clamp:
tor khash {}
khash = std/store:query(flock)
! query b |> std/store:stored { b.cell: @mod(@as(i64, @intFromFloat(b.px / 8.0)) * 73856093 + @as(i64, @intFromFloat(b.py / 8.0)) * 19349663 + @as(i64, @intFromFloat(b.pz / 8.0)) * 83492791, 8192) } And the merge sweep writes iv/nf (the per-bucket reciprocal and count)
once per occupied bucket instead of respelling 1.0/n per boid per
component — a hoist the grid’s written-order semantics makes free:
tor kmerge {}
kmerge = std/grid:sweep(kcells)
! sweep c when c.count > 0 |> std/grid:stored { c.ti: if (((fr.t1x - c.f0x) * (fr.t1x - c.f0x) + (fr.t1y - c.f0y) * (fr.t1y - c.f0y) + (fr.t1z - c.f0z) * (fr.t1z - c.f0z)) < ((fr.t0x - c.f0x) * (fr.t0x - c.f0x) + (fr.t0y - c.f0y) * (fr.t0y - c.f0y) + (fr.t0z - c.f0z) * (fr.t0z - c.f0z))) 1 else 0, c.od: @sqrt((fr.obx - c.f0x) * (fr.obx - c.f0x) + (fr.oby - c.f0y) * (fr.oby - c.f0y) + (fr.obz - c.f0z) * (fr.obz - c.f0z)), c.iv: 1.0 / @as(f32, @floatFromInt(@min(c.count, 256))), c.nf: @as(f32, @floatFromInt(@min(c.count, 256))) } boids_kaigen_fused chains the seven steer blocks inside one query body —
the boids_fused mechanism — so both flavors of the store organization are
measured.
The numbers, with the scope written on them
One machine (M2 Pro laptop), min of 3 runs per cell, identical sinks everywhere. ms per frame, sim pipeline only:
| entities | koru native (ladder) | koru native (fused) | koru wasm simd128 | zig twin |
|---|---|---|---|---|
| 10k | 0.19 | 0.18 | 0.26 | 0.26 |
| 100k | 1.81 | 1.73 | 2.34 | 2.22 |
| 1M | 18.6 | 20.5 | 23.4 | 19.4 |
Sinks: 79562107 / 795075014 / 7950655747 — every arm, every scale,
bit-identical, including the WASM builds.
And the Kaigen demo itself, ?profile=st (single-threaded wasm, the regime
our canonicalization actually models), read off its own HUD on the same
laptop: ~1.8ms at 10k, ~9ms at 100k — but their ECS: timer brackets ecs_flush, which includes the transform pass and draw-batch writes on top
of the sim. Ours doesn’t. So “koru’s sim alone runs inside their ECS window,
several times faster at equal entity count” is the supported sentence; a
cleaner headline is not.
Three caveats that don’t fit in the table:
- Min of 3, not a warmed-up median. One cold machine, one process; treat the ratios as directional until a dedicated-hardware pass lands.
- Their ~2ms tweet number was
mt— multithreaded WASM at an unstated entity count.std/storehas no parallel sweep; the honest comparison isst, and that’s what the table holds. - Different hash, sequential insert. The algorithm is canonicalized, not byte-identical to their binary.
The taxonomy objection, because it’s correct
Read that table and a reviewer who knows ECS internals objects on the first
line: std/store is not an ECS. There are no archetypes, no runtime-decided
entity↔component affinity, no query matching over archetype sets, no
migration cost when a component is added or dropped. It’s a columnar store
with a declared schema and polymorphic striping — the frame is a pass ladder
over columns, full stop.
They’d be right, and it doesn’t weaken the table — because the table isn’t an ECS bake-off. It’s a workload: same hash, same weights, same operation order, same output bits on every arm. What it shows is narrower and, we’d argue, more interesting than ECS parity: the ECS machinery isn’t load-bearing for this workload. A flat store with a spatial hash does the job the ECS does in Kaigen’s build, in less time than their measured ECS window — because the tax this workload charges is bulk iteration over a fixed schema in one deterministic order, and none of that needs archetypes.
Where an ECS pays its real tax is lifecycle churn — spawn, despawn, component
add/remove mid-flight — and the board has arms for exactly that (spawn, despawn, add_remove); that’s a separate question this post doesn’t fold
into the table. The claim here is about the sim: a million fish, one thread,
no ECS required.
What the WASM column actually is
zig build-exe output_emitted.zig -target wasm32-wasi -mcpu=generic+simd128 -O ReleaseFast -lc — the emitted program builds for wasm unchanged and runs
under node:wasi. That’s the same V8 execution tier a browser tab would give
it. simd128 takes the 1M frame from 29.2ms to 23.4ms on the ladder — the real
wasm lever (the fused arm sits scalar-heavy either way). No threads, no GPU,
no render — positions in a store, checksums out.
The JS lane paid for itself differently
--lang=js emitted the same program — and node refused it twice, on real
emitter bugs: Zig’s *%/+%/-% wrapping operators leaked verbatim into
the output, and for|js spliced range bounds raw, so an inline if-expression
landed inside a JavaScript for header. Both fixes live in the toolchain
(Math.imul/| 0 lowering, a js_lower filter routing template splices
through the expression lowerer), plus a std/time JS facet that didn’t
exist. The pins:
Under node the board is bit-identical on every integer scenario — spawn,
fanout, combat_world, all of them — and drifts on floats: JS numbers are
doubles, the store’s f32 columns are f32, and the lowerer isn’t type-directed
yet. boids_kaigen at 100k lands ~0.04% off the native sink and runs ~36ms a
frame. So the JS lane is a correctness surface today; the float gap is named,
not hidden.
The fish tank we could not borrow — so we dumped the store instead
Kaigen’s visualization is their closed engine’s, and the fish/shark assets
are Unity Companion License (“Unity-dependent projects” only). The honest
alternative turned out to be better: boids_kaigen_dump (scenario 19, never
a timed row) runs the same ladder and streams every boid’s position and the
moving actors to stdout each frame. This clip renders that stream verbatim —
16,000 rows a frame, each drawn as a heading-oriented pair, a slow turntable
over the unclamped world, the obstacle inside its true 30-unit aversion
radius. The sink of this exact run (131038907) matches the Zig twin’s at
the same
parameters. The clip is also what caught two honest gaps in our
canonicalization — Kaigen drives its actors from smooth animation clips and
spawns schools on a shell; our first pass teleported actors on a sawtooth and
laid fish on a flat lattice. The clip’s first cut looked wrong, and it was
right to: the store now spawns on a shell and the actors orbit, the way boids_spawn and the clips actually do.
The question this actually raises
std/store is single-threaded today, and this workload is the first one on
the board that makes “should it go parallel” a precise question instead of a
vague one. Most of the ladder is embarrassingly parallel — khash, kmerge, and all seven steer passes are independent per row or per cell. The
one stage that isn’t is kscatter: the flat hash’s first-arrival semantics
make bucket order observable, and the sink oracle can see it. Kaigen’s SPMD
lanes append atomically and accept racy arrival order — fine when MAX_PER_BUCKET caps the damage, fatal for us: a racy scatter drifts the
sink run-to-run and blinds the oracle this whole benchmark strategy rests on.
So the primitive worth building isn’t “parallel sweeps” — half of that exists:
store-backed kernel:self ops already auto-parallelize disjoint rows under
exactly this contract (a shared pool, a flops-scaled floor, and a pinned
exact-sum oracle in 390_113), and store-mode pairwise is deliberately
serial for the same reason kscatter is. What’s missing is the ordered write
— per-lane buffers merged in lane order, or a count-then-place two-pass — the
“deterministic-reduction contract” the kernel code already names as the
blocker. When that lands, boids_kaigen is already the test that proves the
oracle survived it.
And the table says it’s not urgent: 100k runs 1.7ms on one thread — real-time; the 1M case at 18.6ms is the stress point that misses a 60fps frame budget. Parallelism here buys the ceiling, not the floor.
What this doesn’t do
No rendering claim is being made — the video draws dots, not Unity’s scene.
And no structural claim either: std/store is not an archetype ECS, and the
multi-ECS rows in run.sh stay internal references rather than published
numbers — “not apples-to-apples” is a fair read of any such row. The other
boundary: std/store is single-threaded today, and Kaigen’s headline number
was multithreaded. What these runs establish is the floor — one thread,
honest algorithm, three targets, identical answers — plus a sharper
conclusion: this workload’s cost is iteration, not ECS machinery.