boids_kaigen: A Million Fish on std/store — Native, WASM, and What the Numbers Actually Cover

· 6 min read

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 < 256 exactly like their slot < 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:

entitieskoru native (ladder)koru native (fused)koru wasm simd128zig twin
10k0.190.180.260.26
100k1.811.732.342.22
1M18.620.523.419.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/store has no parallel sweep; the honest comparison is st, 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.

boids_kaigen_dump · 16,000 entities × 180 frames · positions verbatim from std/store — sink identical to the Zig twin

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.