The ECS Benchmark: Naive Koru at Parity with Hand-Tuned C

· 13 min read

Koru has no ECS library, and this post is not announcing one. It has a store: one general surface for mutable application state, built around a single idea — subscriptions are compiled into the write path, so the compiler knows every observer of every field before the program runs.

That shape turns out to be an ECS’s shape. An ECS is columns of component data, queries over them, and systems that sweep those queries once a frame; a store is columns, sweeps, and the writes those sweeps perform. Nobody designed the store to be an entity-component-system. An ECS is simply the most demanding mainstream consumer of exactly the surface a store already is, which makes it a good adversary rather than a target — a body of borrowed, well-optimised code that exercises the store the way a real workload would and was written by people with no interest in flattering it.

Where the fit is good, and where it is not

The interesting part is a bet that has not been called yet: there are no archetypes. A component is a column with a presence bit, not a type whose presence moves a row between tables. Adding or removing one is a single integer write and the row never moves, where an archetype engine pays an O(C) migration twice. That is not a faster implementation of the same operation — it is a different operation.

Nothing prevents Koru from growing archetypes. They are not refused on principle and there is no rule in the language that forbids them; they are simply not built, because no workload has yet forced the question. Calling that a settled ruling would be reading a decision into an absence.

And the honest version of the open question is more interesting than the ruling would be, because an archetype is itself a layout decision. It groups entities by which columns they have so that a query sweeps contiguous memory instead of testing a bit per row. That is the same job the layout work further down this post is circling from the other side — the store design’s line is that “layout is the closure of the queries”, and if a compiler can cluster columns by how they are actually accessed, it may arrive at what archetypes buy without the migration cost that pays for them. May. Nobody has shown it, and the argument that it is unnecessary is not the same as the argument that it works.

The uncomfortable evidence sits in this very harness. archetype_churn_world is an anchor built precisely to stress the thing this bet skips: entities moving between Idle, Seeking, Attacking, Stunned and Dead with real queries over changing component sets. It is Bevy-only. Koru has no arm for it. The one scenario designed to test the ruling hardest is one we do not run, and until we do, the no-archetypes position is a hypothesis with a good result on adjacent workloads rather than a demonstrated one.

The bad parts are all visible in the results file rather than routed around:

  • sparse scans. The baseline walks a 10%-dense index array. Koru has no index verb, so the filter is a when guard and the sweep still visits every row — which is why Koru is the slowest of the three there.
  • fanout cannot pick its victim by index. The baseline damages health[(frame*131 + i*17) % len]. A row is named by handle, never by position, so that access has no spelling; the port keeps the event count and the per-event work identical and damages every tenth row instead.
  • combat_world has no Koru entry at all. Its spatial buckets each hold a list that grows to however many enemies land in that cell, and neither a store row nor a grid cell has a variable-length column.
  • Capacity is a compile-time literal. The world is sized for the harness’s 100 000 entities rather than allocated per run, and asking for more is refused rather than grown.

Eleven of the twelve portable scenarios run, plus the Bevy-only archetype anchor that makes thirteen. So two of thirteen have no Koru entry, and between them they cover a variable-length collection and archetype churn — which is a fair description of what this design has not answered yet rather than a rounding error.

The workload

tests/benchmarks/003_ecs_reactive borrows its workloads rather than inventing them. The flocking scenario is a direct port of Unity DOTS’ own BoidSystem.cs sample — same algorithm, same constants, same cell radius — raced by four independent implementations that all print the checksum 592303452, which is the only evidence any of them did the same work.

Against the code people actually write:

armboids
koru_store — a naive port91.5 ms
zig_striped — idiomatic hand-written Zig217 ms
legion — Rust ECS, default cost model234 ms
bevy_ecs315 ms

That gap is not the interesting part, because a fast number against ordinary code only means the ordinary code left something on the table. The question worth asking is what happens against code where somebody did pay attention. So: the same frame in C, static arrays, no bounds checks, per-component ternaries — every vectoriser precondition cleared deliberately, by someone who knew they were there.

hand-tuned -O3 Cboids
C, one record per cell85.3 ms
koru_store — still the naive port91.5 ms
C, one array per field — Koru’s own layout94.9 ms

The naive port lands between the two C builds. Not ahead of C, not behind C — inside the band C itself moves through depending on which clang you invoke and how you shape the loop. That band spans 9%, wider than the distance from Koru to either edge of it, which is why no direction is claimable and why any single-toolchain C number is a point sample rather than a reference.

Nobody made Koru fast on this workload. The port is the obvious transcription of the C# sample; there is no ~proc|zig in the file and nothing in it was tuned. What follows is why that was enough, and the answer turns out to be three accidents that were never about speed.

The two tables, briefly

If you have not seen Koru’s data surfaces before, the whole port is built from two of them and neither takes long.

A store is the mutable table. Declare it with its columns and a capacity, put rows in, sweep it:

std/store:new(items, capacity: 8) { v: i64 }

std/store:insert(items) { v: 10 }
std/store:insert(items) { v: 20 }

std/store:query(items)
! query e |> std/io:print.ln("v {{ e.v:d }}")
Output
v 10
v 20

query opens a sweep and ! query e is the arm that runs once per row, with e bound to the row being visited. No iterator, no index — the sweep is the loop.

A grid is the other kind of table, and it is new. The distinction is one question: can a row move? A store’s take swap-removes, so position is not identity, so a row is named by a generational handle and every access pays brand, bounds and generation. A grid forbids removal — every cell exists from the declaration and none of them relocates — so position is identity and none of that apparatus is needed. No handle, no generation word, no freelist, no len.

That makes it what you reach for when the address is a number you computed, which is exactly what a spatial hash is:

std/grid:new(cells, size: 8) { count: 0[i64] }
std/store:new(acc) { n: 0[i64] }

std/grid:stored { cells[2].count: 5 }
std/grid:stored { cells[6].count: 9 }

std/grid:sweep(cells)
! sweep c when c.count > 0 |> std/store:stored { acc.n: acc.n + c.count }

std/io:print.ln("sum {{ acc.n:d }}")
Output
sum 14

Cells are addressed positionally, written with stored, and swept with a guard — when c.count > 0 filters the six untouched cells instead of summing zeros. The sweep’s body writes into a store, and that bridge is what the whole benchmark leans on: the grid gathers, the store holds the world. std/store:new(acc) { n: 0[i64] } with no capacity is a singleton — one row, nothing to thread.

Grids also come dimensioned — std/grid:new(lights, dimensions: 1000x1000), addressed lights[x, y], linearised row-major — which is the form boids uses for its 32³ cells. That is the entire surface the port needed.

An aside, because reading about a flock is a poor substitute for watching one. The same three rules run on screen in Koru’s raylib binding — raylib/tests/boids.k, 200 boids, cohesion and alignment and separation, drawn at 60fps from a std/grid by a counted for inside the frame arm.

It is not the program benchmarked here and the numbers on this page say nothing about it: the benchmark arm is a faithful port of DOTS’ BoidSystem.cs with DOTS’ own constants, built to be raced; the raylib one is the three rules written to be looked at, with a naive O(N²) neighbour search and no spatial index at all. Different programs, same idea, and only one of them has a checksum.

Its file header is worth more than the picture, though. It carries the two defects that writing it uncovered — a grid write discarding the rest of its chain, and a phantom-typed borrow that could not enter a store sweep — both of which are fixed now and neither of which any test in this repo would have found. Drawing something turns out to be a good way to break a data surface.

A vectoriser checks three things, and all three must pass

A per-element loop goes 4-wide only if it clears every one of these. Each was tested in isolation on the Zig baseline, and none of them is worth anything alone.

Legality. A bounds check that traps is an early exit, and LLVM refuses outright: “Cannot vectorize early exit loop with more than one early exit.” Not “prices it badly” — refuses.

Aliasing. With heap-allocated columns, the runtime alias checks make widening unprofitable. Give the baseline every other fix and leave the columns on the heap, and nothing helps at any body shape.

Cost model. Legal and alias-free is still not enough. The vectoriser prices the lane-insert gathers and can simply decline. On the Zig pipeline, flattening the loop body either way tips it over; rustc declines until forced.

Koru clears all three, and here is the honest version of why: not one of the three decisions was made for performance.

Its columns are module-level arrays at static addresses because a store’s capacity is a compile-time literal and a second-class table has nothing to allocate. Its emitted body is per-component selects because a stored block is one write whose right-hand sides all read pre-state, so there is no branch returning an aggregate. And its bounds check can be waived at the declaration because the trap edge was measured and found to cost 47%.

Three unrelated rulings, three bars cleared. That is the whole mechanism.

The write block is one transaction, and that is what shapes the loop

The second bar is the one that comes from a semantics ruling rather than a layout one, so it is worth showing. A stored block is one write. Every right-hand side reads the state before the block, and nothing lands until all of them exist.

std/grid:new(cells, size: 4) { a: 0.0[f64], b: 0.0[f64] }

std/grid:stored { cells[0].a: 3.0, cells[0].b: 7.0 }
std/grid:stored { cells[1].a: 3.0, cells[1].b: 7.0 }

// An exchange: neither entry may see the other's write.
std/grid:stored { cells[0].a: cells[0].b, cells[0].b: cells[0].a }

// A butterfly: both entries read both fields.
std/grid:stored { cells[1].a: cells[1].a + cells[1].b, cells[1].b: cells[1].a - cells[1].b }

std/io:print.ln("swap {{ cells[0].a:f }} {{ cells[0].b:f }} butterfly {{ cells[1].a:f }} {{ cells[1].b:f }}")
Output
swap 7 3 butterfly 10 -4

The exchange is a real swap and the butterfly computes (a+b, a−b) from the original pair — not from a half-updated row. Written the other way, as a sequence, each entry would see its predecessor’s result, and the block would be a dependency chain through memory. That chain is exactly what stops a vectoriser cold.

So the rule that makes a row observable only whole is also the rule that makes the block a column-to-column map, free to reorder across rows. Atomicity bought the vector width.

Layout is a declaration, and it cuts both ways

A grid’s cells are stored one array per field — or one record per cell, if the declaration says so:

std/grid:new(colg, size: 8) { a: 0[i64], b: 0[i64] }
[layout(row)]std/grid:new(rowg, size: 8) { a: 0[i64], b: 0[i64] }
[layout(column)]std/grid:new(explicitg, size: 8) { a: 0[i64], b: 0[i64] }

// ... identical writes, an identical cross-cell read and an identical
// guarded sweep are run over all three ...

std/io:print.ln("column {{ acc.n:d }} row {{ acc.m:d }} explicit {{ acc.e:d }}")
Output
column 117 row 117 explicit 117

The three totals agreeing is the point: the two layouts are observationally identical — same writes, same reads, same sweep result — because layout is a codegen decision and nothing else.

The trade is real and asymmetric, which is the part worth publishing. A scatter touches many fields of one cell at a computed index: row layout pays one cache line where column layout pays one per field, and boids drops from 98.7 ms to 91.5 ms. A sweep touches one field of every cell: column layout reads contiguously and vectorises, row layout strides the whole record and cannot, and the same program goes from 0.04 s to 0.45 s.

Row buys about 7% on a scatter and costs about 11× on a sweep. Both programs are checked in, because a benchmark that only measured the winning direction would turn a trade into a recommendation. column stays the default.

Four claims that died

Every explanation in this post is one that survived an attempt to kill it. These did not, and they are listed because a benchmark’s job is to be believed.

“Our static data model is why we are fast.” Refuted directly. Give the Zig baseline Koru’s exact fixed-extent globals and change nothing else: 221 ms → 221 ms. Necessary, worth zero alone.

“The verbosity is the optimisation.” The steering was 12,983 characters of per-component arithmetic, and the claim was that writing each component out separately is what produces the vectorisable shape. Rewritten with a helper subflow and capture slots — 3,497 characters, 4× smaller — it runs at the same speed and stays vectorised. The emitted shape is load-bearing; the character count was a confound.

“It’s an LLVM version difference.” The same C source vectorises under clang 20 and clang 22 alike. What survives is narrower and still open: LLVM vectorises this loop from clang and declines it from rustc, so the frontends hand it different IR for the same source intent.

“The remaining store-count gap is collectible.” Koru emits 14 vector stores per four boids where clang emits 6, because the steer ends in two chained stored blocks and two chained blocks are two transactions. Nothing observes the intermediate, so the compiler could fuse them. Tested by hand: 2 ms slower. Fusing makes one body fatter, and this workload punishes fat bodies. The variant is kept in the tree so nobody builds that optimisation on the strength of a store count.

What an expert is worth: 78 ms

None of this is a ceiling on Zig, and the strongest way to show that is to go and find the ceiling. A steelman — hand-written Zig with the full optimisation ladder applied, same algorithm, same checksum — runs at 78 ms. That is 1.3× faster than Koru and faster than every C build here.

So expertise is worth about 30% on this workload, and it is worth reporting as the headline of its own section rather than a footnote, because it is evidence for the argument rather than against it.

Look at what the ladder actually consists of. Packing the grid cell into one record: 9 ms. Widening the vectors by hand to 16 lanes: 10 ms — and 8 and 32 are both worse, while 12 is 2.3× worse because a non-power-of-two vector legalises badly. Hoisting per-cell invariants out of a per-boid loop that LLVM cannot see through: 5 ms. Every one of those is a thing you have to know, and two of them are things you have to measure your way to because intuition gets them backwards.

And the road there is worse than the destination suggests. The path to fast Zig runs through a state that measures worse than where you started. Fixing only the control flow in the baseline costs 14 ms, because it buys a vectorisation that never arrives until the aliasing is fixed too — the two are mutually dependent and neither pays alone. A competent programmer tries the obvious half, measures a regression, reverts, and never discovers the other half was worth 2×.

That cliff is the whole point. Koru lands on the far side of it without anyone deciding to, and a naive port that starts where a determined expert finishes minus 30% is a better result than one that merely beat some slow code.

What this does not say

It is not a claim that Koru beats Zig, C, or anything else. The 217 ms row is a real measurement of idiomatic hand-written Zig, and it is also a weak baseline — the steelman above is the same language 2.8× faster. Hand-written Zig reaches ~114 ms from two source changes; forced-vector Rust reaches 93 ms. Everything here is reachable by anyone who knows the three bars are there.

It is also not a DOTS head-to-head. Unity is not installed on the machine that produced these numbers, so boids is a port verified against DOTS’ published source, not a measured race against DOTS.

What is still missing

  • combat_world still has no Koru port. std/grid answered the spatial-index half; what has no spelling is a bucket holding a list that grows to however many enemies land in it. The pieces for an intrusive chain exist and nobody has assembled them.
  • The AoS-versus-SoA question is unfinished, not answered. Boids wants both — its scatter is row-shaped and its resolve sweep is column-shaped — so a per-table flag is a coin-flip between a 7% win and an 11× loss. The real form is clustering co-accessed fields, which is what the store design meant by “layout is the closure of the queries”, and it is not built.
  • Why rustc declines where clang accepts remains open, with target-CPU eliminated as the cause.
  • archetype_churn_world has no Koru arm, and it is the scenario that would actually test the no-archetypes bet. Building it means either showing that presence columns hold up under heavy churn, or discovering the workload that forces archetypes into the language. Both outcomes are worth more than the current silence.

None of this is finished, and the shape of what is missing is the point. Every number in this post moved by deleting a defect, not by adding an optimisation — the two that mattered were a trap edge that blocked the vectoriser and a write path that emitted one call per field. A design whose wins come from removing its own mistakes has not yet found its ceiling, and it has not yet been made to answer the questions it is avoiding: a variable-length collection, archetype churn, and a layout planner that does by inference what the annotation currently does by hand.

The result worth taking from this post is not that the design is right. It is that a naive port lands inside hand-tuned C’s range, on somebody else’s algorithm, with a bit-identical checksum — and that the reasons are three decisions nobody made for speed. That is a good position from which to go find out what breaks it.