Skip to content

perf(core): codegen bindColumns runners — beat bitECS, no post-growth penalty#82

Merged
andymai merged 1 commit into
mainfrom
perf/codegen-bindcolumns
Jun 8, 2026
Merged

perf(core): codegen bindColumns runners — beat bitECS, no post-growth penalty#82
andymai merged 1 commit into
mainfrom
perf/codegen-bindcolumns

Conversation

@andymai
Copy link
Copy Markdown
Owner

@andymai andymai commented Jun 8, 2026

What

bindColumns (the pinned-columns SoA fast path) now codegens its per-archetype runners, so it beats bitECS robustly — not just on a single pre-sized archetype.

The problem

The interpreted approach invoked ONE user factory per archetype. The moment it produced a 2nd runner — a 2nd matched archetype, or a re-invoke after column growth — V8 saw the factory making multiple closures and disabled specialization for all of them: ~1.5 ns/entity, which loses to bitECS (~1.4). It only won on a single pre-sized archetype (hence the old "pre-size before binding" caveat).

The fix

Each archetype's runner is recompiled into a distinct function — new Function('return (' + factory.toString() + ')')() — so its runner is the singleton of its own freshly-minted factory → specialized → ~1.0 ns/entity (~0.7× bitECS), through growth and across archetypes, no pre-sizing.

ns/entity
codegen (steady + after re-bind) 0.97
interpreted, 2nd runner (multi-arch / post-growth) 1.5 — loses to bitECS
bitECS 0.4 1.4

Safety — codegen can never change results

  • eval probed once; under strict CSP / locked sandbox → interpreted fallback (the runs-everywhere promise holds).
  • the factory must be self-contained; per-frame inputs flow through the runner's new ctx arg (hoisted out of the loop).
  • a pre-flight runs a recompiled runner + the interpreted runner over an identical 1-row scratch clone and compares (Object.is, so NaN writes match); codegen is used only on a match — a miscompile / illegal closure / any divergence → interpreted. Codegen is faithful by construction (recompile of the same source), so the only divergences are row-invariant closures, caught at row 0.
  • Security: generated source is the caller's own factory.toString(), never an interpolated external string.

Breaking (pre-launch)

The runner gains a ctx arg: factory: (views, meta) => (ctx) => void, run(ctx). Deps flow through ctx (which codegen can capture) instead of an outer closure (which it can't). Zero-arg runners still typecheck (void ctx).

Review

Independent adversarial review: no critical/major. Confirmed no silent-corruption path — the 1-row pre-flight worry is a misframing (codegen ≡ interpreted by construction; a user's own stride bug reproduces identically, never diverges). Fixed its 4 minors: Object.is for NaN, dead-param cleanup, stale README/_perf-tables prose.

Verification

pnpm test164 files / 1252 green (new codegen-pinned.test: CSP-availability, self-contained-codegen-through-growth, closure-fallback, pre-flight equality, multi-archetype; the property suite now drives a self-contained factory so codegen is its primary correctness gate — byte-identical to .each under random spawn/despawn/grow). typecheck:tests · typecheck:extras · docs:check.

First of a 4-PR program to close the bitECS gaps (codegen .each + tracked-write, bundle budget) + a CI bench regression lane. Bench numbers held (load-gated; bindColumns 1.02 ns/e, no regression).

… penalty

bindColumns is the SoA fast path, but the interpreted approach invoked
ONE user factory per archetype: the moment it produced a second runner
(a 2nd matched archetype, or a re-invoke after column growth) V8 saw the
factory making multiple closures and disabled specialization for all of
them — ~1.5 ns/entity, which LOSES to bitECS (~1.4). It only won on a
single pre-sized archetype.

Each archetype's runner is now CODEGEN'D: the factory is re-evaluated
into a DISTINCT function via new Function('return (' + factory.toString()
+ ')')(), so its runner is the singleton of its own freshly-minted
factory → specialized → ~1.0 ns/entity (~0.7x bitECS), through growth and
across archetypes, with NO pre-sizing. (Spike: 0.97 steady, 0.97 after
re-bind vs the interpreted 1.5.)

Safety — codegen can never change results:
- eval availability probed once; under strict CSP / a locked sandbox the
  interpreted factory call is used (the runs-everywhere promise holds).
- the factory must be SELF-CONTAINED (close over nothing — the recompiled
  copy sees only globals); per-frame inputs arrive via the runner's new
  ctx argument, hoisted out of the loop.
- a PRE-FLIGHT runs a recompiled runner and the interpreted runner over an
  identical 1-row scratch clone and compares (Object.is, so identical NaN
  writes match); codegen is used only on a match. A miscompile, an illegal
  outer-scope closure (ReferenceError), or any divergence → interpreted.
  Codegen is faithful by construction (a recompile of the same source), so
  the only divergences are row-invariant closures — caught at row 0.

Security: the generated source is the caller's OWN factory.toString(),
never an interpolated external string — no injection surface.

Breaking (pre-launch): the runner gains a ctx arg —
factory: (views, meta) => (ctx) => void, run(ctx). Deps flow through ctx
instead of an outer closure (which codegen can't capture). Zero-arg
runners still typecheck (void ctx).

Adversarial review confirmed no silent-corruption path (the 1-row
pre-flight worry is a misframing — codegen ≡ interpreted by construction;
a user's own stride bug reproduces identically, never diverges). Fixed
its findings: Object.is for NaN, dead-param cleanup, stale README/docs
prose. The property suite now drives a self-contained factory so the
codegen path is its primary correctness gate (byte-identical to .each
under random spawn/despawn/grow).
@andymai andymai enabled auto-merge (squash) June 8, 2026 08:40
@andymai andymai merged commit ec50962 into main Jun 8, 2026
8 checks passed
@andymai andymai deleted the perf/codegen-bindcolumns branch June 8, 2026 08:42
@release-kun release-kun Bot mentioned this pull request Jun 8, 2026
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Jun 8, 2026

Greptile Summary

This PR introduces a codegen layer for bindColumns that recompiles the user's factory into a distinct new Function per archetype (and on storage growth), keeping each archetype's runner a V8 singleton and eliminating the post-growth specialisation penalty. The Ctx generic is added to carry per-frame inputs through the runner argument instead of an outer closure, and a 1-row pre-flight equality check ensures the codegen path is gated on proven equivalence with the interpreted fallback.

  • packages/core/src/query/codegen.ts (new): CSP probe, recompile, PROBE_CTX, and buildPinnedRunner / preflightMatches; falls back to the interpreted runner on any throw or mismatch.
  • packages/core/src/query/live-query.ts: bindColumns signature gains Ctx; makeBinding and reinvoke delegate to buildPinnedRunner; meta.count live-getter contract is unchanged.
  • Tests, bench, docs: property suite now drives a self-contained factory (primary codegen gate); five new unit tests pin the safety contract; all stale "pre-size before binding" caveats removed.

Confidence Score: 4/5

Safe to merge; the codegen mechanism is guarded at every exit — CSP probe, pre-flight equality check, and try/catch fallback — so no code path can silently produce wrong results.

The core logic is sound: meta.count remains a live getter (unchanged), the pre-flight correctly tests structural closure equivalence against scratch data, and the fallback chain is airtight. The two findings are about documentation accuracy — the 'invokes the runner once' NOTE fires twice, and the eagerly-created interpreted runner with real views is not mentioned in the public contract. Neither affects runtime correctness.

packages/core/src/query/codegen.ts — the two NOTE-level doc inaccuracies are in this file.

Important Files Changed

Filename Overview
packages/core/src/query/codegen.ts New codegen module: probes CSP, recompiles the user factory per archetype for V8 specialisation, and validates via a 1-row pre-flight equality check. Logic is correct; two minor doc-accuracy issues noted.
packages/core/src/query/live-query.ts Threads Ctx through bindColumns and replaces direct factory(views, meta) with buildPinnedRunner; meta.count live getter confirmed unchanged. reinvoke correctly uses a fresh recompile on growth.
packages/schema/src/index.ts Public bindColumns signature updated on both Query and LooseQuery interfaces to add Ctx generic defaulting to void; backwards-compatible for zero-arg runners.
packages/core/test/codegen-pinned.test.ts New tests covering CSP-availability, self-contained codegen through growth, outer-closure fallback, pre-flight equality invariant, and multi-archetype integration — solid coverage of the safety contract.
packages/core/test/bind-columns.property.test.ts Factory made self-contained (DT moved inside, destructuring deferred to body) so the property suite now gates codegen correctness under random spawn/despawn/grow.
bench/iterate.ts Benchmark factory updated to be self-contained: DT arrives via ctx.dt, pre-allocated ctx object prevents per-frame allocation.
scripts/bench-report.mjs Stale pre-size before binding caveat removed from the generated bench-report prose; replaced with accurate codegen description.
website/guide/performance.md Documentation rewritten to reflect codegen path, self-contained factory requirement, and removal of the pre-size before you bind tip.
website/guide/_perf-tables.md Performance table description updated to match the new codegen-backed bindColumns behaviour.
README.md Headline description updated: edge ahead / pre-sizing replaced with beats bitECS, no pre-sizing required.

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/core/src/query/codegen.ts:76-84
**Interpreted runner always eagerly created even on the codegen path**

`factory(views, meta)` (line 76) is called with the real production views unconditionally, creating an interpreted runner even when codegen will succeed and the runner will be discarded. On the happy codegen path the factory is therefore called twice with real data (once for `interpreted`, once for the recompiled copy on line 83) plus twice more with scratch arrays inside `preflightMatches`. For a factory that is a pure closure factory this is harmless, but a factory with construction-time side effects (e.g. an initialisation log, a registration call) will fire those effects even when the codegen runner is ultimately used. The public-facing comments describe runner-invocation side effects but do not document that the factory-creation code also runs on a discard path.

### Issue 2 of 2
packages/core/src/query/codegen.ts:91-98
**Pre-flight NOTE says "invokes the user's runner once" but it fires twice**

The NOTE on line 95 reads "this INVOKES the user's runner once over the scratch," but `preflightMatches` actually invokes it twice — once for the recompiled version (`scratchA`, line 110) and once for the interpreted version (`scratchB`, line 111). A runner with observable side effects (the exact scenario the NOTE is warning about) would see those effects fire on both runs. The wording should say "twice" (or "once per version") to avoid a subtle expectation mismatch for users debugging unexpected pre-flight behaviour.

Reviews (1): Last reviewed commit: "perf(core): codegen bindColumns runners ..." | Re-trigger Greptile

Comment on lines +76 to +84
const interpreted = factory(views, meta)
if (!CODEGEN_AVAILABLE) return interpreted
try {
// The real runner is the singleton of its OWN recompiled factory (specialized). The pre-flight
// validates a SEPARATELY-recompiled runner over scratch — same source, so faithful ⇒ the real
// runner is faithful too. (Reusing one recompiled factory for both would make it produce two
// closures and forfeit specialization — the very penalty codegen exists to avoid.)
const codegen = recompile(factory)(views, meta)
if (preflightMatches(factory, views, strides)) return codegen
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Interpreted runner always eagerly created even on the codegen path

factory(views, meta) (line 76) is called with the real production views unconditionally, creating an interpreted runner even when codegen will succeed and the runner will be discarded. On the happy codegen path the factory is therefore called twice with real data (once for interpreted, once for the recompiled copy on line 83) plus twice more with scratch arrays inside preflightMatches. For a factory that is a pure closure factory this is harmless, but a factory with construction-time side effects (e.g. an initialisation log, a registration call) will fire those effects even when the codegen runner is ultimately used. The public-facing comments describe runner-invocation side effects but do not document that the factory-creation code also runs on a discard path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/query/codegen.ts
Line: 76-84

Comment:
**Interpreted runner always eagerly created even on the codegen path**

`factory(views, meta)` (line 76) is called with the real production views unconditionally, creating an interpreted runner even when codegen will succeed and the runner will be discarded. On the happy codegen path the factory is therefore called twice with real data (once for `interpreted`, once for the recompiled copy on line 83) plus twice more with scratch arrays inside `preflightMatches`. For a factory that is a pure closure factory this is harmless, but a factory with construction-time side effects (e.g. an initialisation log, a registration call) will fire those effects even when the codegen runner is ultimately used. The public-facing comments describe runner-invocation side effects but do not document that the factory-creation code also runs on a discard path.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Comment on lines +91 to +98
/**
* Run a recompiled runner and a fresh interpreted runner over IDENTICAL 1-row scratch clones of the
* columns with the probe ctx, and compare. Equal ⇒ a recompile of this factory is faithful (and the
* real runner, recompiled from the same source, is therefore faithful too). The real columns are
* never touched. NOTE: this INVOKES the user's runner once over the scratch — a runner with effects
* beyond its views (a global write, a ctx method call) fires/throws here; the contract is a pure SoA
* loop reading values off ctx. A throw (e.g. an illegal outer-scope closure → ReferenceError) counts
* as a mismatch and falls back to interpreted.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Pre-flight NOTE says "invokes the user's runner once" but it fires twice

The NOTE on line 95 reads "this INVOKES the user's runner once over the scratch," but preflightMatches actually invokes it twice — once for the recompiled version (scratchA, line 110) and once for the interpreted version (scratchB, line 111). A runner with observable side effects (the exact scenario the NOTE is warning about) would see those effects fire on both runs. The wording should say "twice" (or "once per version") to avoid a subtle expectation mismatch for users debugging unexpected pre-flight behaviour.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/query/codegen.ts
Line: 91-98

Comment:
**Pre-flight NOTE says "invokes the user's runner once" but it fires twice**

The NOTE on line 95 reads "this INVOKES the user's runner once over the scratch," but `preflightMatches` actually invokes it twice — once for the recompiled version (`scratchA`, line 110) and once for the interpreted version (`scratchB`, line 111). A runner with observable side effects (the exact scenario the NOTE is warning about) would see those effects fire on both runs. The wording should say "twice" (or "once per version") to avoid a subtle expectation mismatch for users debugging unexpected pre-flight behaviour.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant