Store Views, Measured: The Guard That Removes Work

· 5 min read

The previous post on store views made a claim from the emitted shape: “no tag column, no discrimination cost.” This post is the receipt. When the claim is measured, it turns out to be too modest — the discrimination cost is not zero, it is negative. Narrowing a view query by kind does not add a filter to the work; it removes the other members’ work entirely.

Three layouts, one workload

The benchmark is deliberately plain: sweep every entity, sum the shared str leaf. Three arms, one logical workload — 100 000 rows, 60 000 players and 40 000 enemies, 1000 frames, ReleaseFast, Apple M2 Pro. The sinks are the oracle: every arm computes the same total, bit for bit, so a divergence would mean a wrong layout, not a slow one.

armms
two lone stores, swept separately9.3
the same stores through one view9.2
the view narrowed to Player (when e is Player)5.6

Two readings, both load-bearing:

  • The polymorphism is free. The view query and the two separate sweeps are the same time to the noise. A view is not a slower way to sweep two stores — it is the two sweeps.
  • The guard is cheaper than the sweep. Narrowing to players costs less than the full query. A filter that makes a query faster is the surprising row in the table.

Why: the guard removes the loop

A view compiles to one loop per member. The is-guard resolves to a constant per loop — the loop is the kind:

std/store:view(Entities) {
    Player
    Enemy
}

std/store:query(Entities)
! query e when e is Player |> std/io:print.ln("player {{ e.str:d }} {{ e.mana:d }}")

The Player loop emits const kind = 1; if (!(kind == 1)) continue; — a constant and a compare that fold to nothing. The Enemy loop emits const kind = 2; if (!(kind == 2)) continue; — a constant false, an empty body, and the optimizer removes the loop wholesale. The narrowing query runs the Player loop and nothing else: 60 000 real row-processings beat 100 000, which is exactly the 5.6 vs 9.2 ms.

Pick two of three — the loop never exists

With a three-member view, the emitter takes this further. The guard proves its member set at compile time — the union of the positively named members — and skips the unproven member’s loop at emit time:

std/store:query(Entities)
! query e when e is Player or e is Enemy |> std/io:print.ln("entity {{ e.str:d }}")

The emitted sweep contains two loops — Player.len, then Enemy.len — and no Boss loop at all. Not skipped, not filtered: absent. The surviving guards fold to constants and the query is literally the two chosen sweeps. The guard is a proof the compiler discharges by not generating code.

Negation runs the same machine in reverse

e is not Boss proves no member (every other member can match), so all three loops are emitted — but the excluded loop’s guard is a constant the optimizer sees through:

std/store:query(Entities)
! query e when e is not Boss |> std/io:print.ln("entity {{ e.str:d }}")

Player: if (!(1 != 3)) continue; — never. Enemy: if (!(2 != 3)) continue; — never. Boss: if (!(3 != 3)) continue; — always, an empty body, eliminated. The same negative cost, applied to the complement.

The contrast: kind as data

The same narrowing, spelled the way a monolithic store forces it — one store, a stored kind column, a real per-row guard — costs about (26.4 ms vs 9.2 ms). Every row pays a tag load and a data-dependent branch, and the branch defeats vectorization of the sum. Filtering cannot remove work it has to examine.

And the instinctive fix — make the tag smaller — measured worse, not better: a 1-byte tag instead of an 8-byte integer was 1.6× slower (43.5 ms), not faster, on the same workload. This is the counterintuitive row that makes the mechanism visible: the slowness was never the tag’s width. It is that the kind is data instead of proof. The view keeps the discrimination in the declaration, where the compiler can discharge it by not generating code; a stored tag puts it in every row, where every query must pay it.

The ruling this data backs

The pooled set — a physical fold where shared leaves meet in one extent — is pinned as aspirational, red by design until std/store:kind and the pool land. The measurement says the synthesized kind must resolve to the per-loop constant the view has: a stored tag, at any width, inherits the ~3×. “The kind vocabulary is never stored” is not a taste; it is the difference between a query that removes work and one that pays per row.

Honest boundaries

One machine (Apple M2 Pro), one workload shape (a read-mostly column sum), ReleaseFast. The physical fold’s win — one extent, better locality on wide rows and many kinds — is unmeasured, not refuted; this benchmark cannot show it. The claim here is the mechanism: in Koru, the way to make a query fast is to let the compiler prove which partition it wants, and the way to make that provable is to partition at declaration instead of tagging at runtime.

One footnote, because it is the honest shape of a benchmark: the first version of this one refused to compile. A view sweep whose body writes to another store emitted the shared-leaf read against the view’s name — a store that does not exist — because the write path took the column-read lowering instead of threading the projection. It was a real compiler bug the benchmark was never looking for, now fixed and pinned. The number this post reports exists because the machine refused to produce a false one first.