perf: create-10k −30% (tbody remount) + D1 $ELEMENTS signal; interleaved benchmark CI; D2–D4 adjudicated - #136
Conversation
Unlinking an effect from the signal graph is pure bookkeeping. Running 1,000 of them synchronously inside React's unmount commit (e.g. clearing a large list) blocks presentation of the frame that removes the rows. tracked() now queues the alien-effect cleanup in a shared disposal queue flushed via rAF+setTimeout after the next paint. Cleanup always runs, so subscriber lists cannot leak; a spurious effect firing before the flush only calls forceUpdate on an unmounted component, a no-op. Measured (15-run medians, 4x CPU throttle): clear-rows script -4-6%, with small wins on partial update, remove, and replace. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gik3P8QuDQKMPe9Z7SZcEc
Two benchmark-app changes targeting clear-rows and create/select script time: (1) clear() bumps an epoch used as the tbody key, so React detaches the old tbody with a single removeChild instead of 1,000 per-row removals during the deletion commit; (2) selection state moves onto the row item (item.selected), replacing the per-row useComputed firewall - each Row subscribes only to its own item's signal and selecting writes exactly two signals. Measured (15-run script medians vs adjacent identical-code baseline): clear -9.5%, create-1k -10.5%, select -42%, partial update -7.7%. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gik3P8QuDQKMPe9Z7SZcEc
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gik3P8QuDQKMPe9Z7SZcEc
…reate-10k Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gik3P8QuDQKMPe9Z7SZcEc
…p same-row select, throw-safe disposal flush; expand optimization plan to six design candidates Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gik3P8QuDQKMPe9Z7SZcEc
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gik3P8QuDQKMPe9Z7SZcEc
… non-Error throwables Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gik3P8QuDQKMPe9Z7SZcEc
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #136 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 64 65 +1
Lines 2217 2240 +23
Branches 524 529 +5
=========================================
+ Hits 2217 2240 +23 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…parent-mode For; D0 attribution findings; browser-path override for kernel browser tests
…nt (will be re-applied)
…easurement (will be re-applied)" This reverts commit f15425c.
…browser-mode configs; D1 verdict in plan doc
…h snapshot refresh + emptiness transitions); GC-aliasing retrospective addenda
…ng tests; failed-approach note + plan verdict
Adds .github/workflows/benchmark.yml, which builds the kernel at the PR head and at the base branch and runs 10 interleaved pairs via a new perf-ab.ts orchestrator, then posts a sticky PR comment with paired median deltas, win counts and an exact sign test. Interleaving (with the order flipped each pair) is what makes this usable on shared CI runners: machine drift hits both arms equally and cancels within a pair, where a block of A followed by a block of B would confound it with the code change. The job skips itself when both kernel builds come out identical, so PRs that do not touch the kernel do not burn ~40 minutes measuring noise. perf-ab.ts only orchestrates: all timing, warmup counts and CPU throttling still come from the existing src/perf.test.ts. Krause weights moved to a shared perf-weights.ts so perf-compare.ts cannot drift from it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…wsers on install The benchmark app imported /css/bootstrap/dist/css/bootstrap.min.css, a path that is gitignored — so fresh clones and CI had no file and the vite build failed (this is what killed the first Benchmark workflow run). bootstrap@3.3.6 is now a devDependency and currentStyle.css imports it from node_modules via postcss-import. The submission checklist is unaffected: currentStyle.css is not among the files copied to the benchmark repo, which serves its own /css tree. Also approves playwright's postinstall in pnpm-workspace.yaml (pnpm 10 blocks dependency build scripts by default), so browsers download on pnpm install instead of every browser-mode suite silently failing to launch, and syncs the lockfile-resolved playwright 1.61.1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In a container job, the github.workspace expression expands to the host runner path (/home/runner/work/...) while run steps execute inside the container where the workspace is mounted at /__w/... — so perf-ab.ts finished all 10 interleaved pairs and then failed writing the report to a directory that does not exist in the container. The GITHUB_WORKSPACE env var evaluates inside the container, matching how the later summary/comment steps already read the files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
📊 Interleaved benchmark: PR vs base (main)🟢 5 benchmark(s) look faster, none slower. 10 interleaved pairs. Negative Δ = faster on this PR. Bold + coloured rows are the ones where a two-sided sign test clears p ≤ 0.05.
How to read thisEach arm is a complete prebuilt app bundle (kernel + app), so this measures the full difference between the two builds — app-side and kernel-side changes alike. The two builds alternate within one time window, with the order flipped each pair, so machine drift hits both arms equally and the paired delta cancels it. CI runners are shared and noisy, so absolute milliseconds here are meaningless — do not compare them against numbers from your laptop or against a previous run of this job. The paired delta and the win count are the signal. With 10 pairs a sign test needs roughly 9/10 wins to clear p ≤ 0.05, so this job is good at catching large regressions and weak at resolving effects under a few percent. A borderline result means "run it properly", not "no effect". GC aliasing. A change that removes allocations can shift when V8's scavenge lands relative to the measured window and show up as a reproducible regression on churn-heavy benchmarks with both script and paint time inflating together. That fingerprint is an artifact, not added work. To check, re-run the suspect benchmark with a forced collection before tracing: ( Measured head |
…ed-GC protocol: still rejected, explanation corrected 12 interleaved pairs on quiet local hardware: the original rejection reason (partial update +17.3%) did not reproduce (-2.7%, 8/12) — the GC-aliasing suspicion was right. But create-10k regressed +13.8% (0/12, p<0.001) free-running, and an 8-pair forced-GC interleave showed the variant actually 5% FASTER (7/8 pairs) — pure GC scheduling, charged to the variant under free-running timing, which is what the official benchmark measures. Verdict unchanged, mechanism corrected in the note. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tracked(Component, { refDisposal: true }) disposes the component's reactive effect via a React 19 ref cleanup instead of the default per-instance useEffect. The wrapper injects a stable trackedRef prop the component attaches to its root element; detach flags and queues the unlink through the deferred disposal queue (StrictMode re-attach cancels it). Dev builds warn and dispose if the ref is never attached. Default behavior unchanged; js-krauset's Row opts in. Committed for the record before measurement-based revert — see the following commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ote + plan verdict 12 full-suite interleaved pairs showed nothing significant; a focused 16-pair interleave on clear rows and replace all rows — the two benchmarks the change mechanically targets — came back flat (clear +0.7% 6/15 p=0.607, replace -0.2% 8/15 p=1.0). The per-row passive effect's remaining cost is below measurement resolution even at 10k rows; the deferred disposal queue had already removed the expensive part. Implementation preserved in the previous commit for future reference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hted total -19.9% Profile-driven (owner direction: profile the app, don't design from the plan doc). perf:analyze at HEAD showed getHostSibling at 135ms / 15.8% of create-10k vs 1.3ms on replace-1k — O(n^2): each placement of an all-new child into the already-mounted tbody scans forward through not-yet-mounted sibling fibers. clearEpoch is now tbodyEpoch and run() bumps it too, so wholesale creates mount rows via the appendAllChildren fast path. add() still appends into the live tbody (keyed semantics verified, 10/10 dist tests). 12 interleaved app-dist pairs: create-10k 348.6 -> 242.1ms (-30.4%, 12/12), replace -3.3% (p=0.012), create-1k -2.2% (p=0.039), everything else flat. Kernel profiles also showed no supergrain functions in top self-time and GC <=3%, undermining D4's premise — recorded in the plan doc. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nel absent from self-time, GC <=3%) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR is a performance-focused update to Supergrain’s kernel/react integration and the js-krauset benchmark app, and it adds drift-resistant (interleaved) benchmark automation in CI to continuously validate performance changes.
Changes:
- Add a coarse array-level
$ELEMENTSsignal and use it to make parent-modeForswap detection O(1) to subscribe/teardown while preserving behavior. - Improve
js-krausetcreate-10k performance by forcing a<tbody>remount onrun()(avoiding React’sgetHostSiblingO(n²) path), plus related selection/state wiring. - Introduce interleaved A/B benchmarking (
perf-ab.ts) and a GitHub Actions workflow to run it per-PR, plus Playwright/pnpm setup tweaks for fresh clones.
Reviewed changes
Copilot reviewed 39 out of 46 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| vitest.playwright.ts | Centralizes Vitest Playwright provider creation with optional CHROMIUM_EXECUTABLE_PATH. |
| vitest.config.ts | Switches browser provider wiring to playwrightProvider() across browser projects. |
| pnpm-workspace.yaml | Allows Playwright’s postinstall via onlyBuiltDependencies. |
| pnpm-lock.yaml | Updates lockfile to include new deps (notably bootstrap). |
| packages/silo/vitest.config.ts | Uses shared Playwright provider. |
| packages/queries/vitest.browser.config.ts | Uses shared Playwright provider. |
| packages/kernel/vitest.browser.config.ts | Uses shared Playwright provider. |
| packages/kernel/vitest.bench.config.ts | Uses shared Playwright provider for browser benches. |
| packages/kernel/tests/react/for-component-magic.test.tsx | Adds swap/emptiness-transition regression coverage for parent-mode For. |
| packages/kernel/tests/core/disposal-queue.test.ts | Adds node-environment tests for deferred disposal queue behavior. |
| packages/kernel/src/write.ts | Bumps $ELEMENTS on in-place array element replacement (when subscribed). |
| packages/kernel/src/react/tracked.ts | Defers tracked() cleanup via scheduleDisposal to move unlinking off the unmount critical path. |
| packages/kernel/src/react/for.ts | Replaces per-index subscriptions with trackArrayElements(raw) in parent-mode swap effect. |
| packages/kernel/src/react/disposal-queue.ts | Introduces deferred disposal queue implementation. |
| packages/kernel/src/internal.ts | Exposes $ELEMENTS and trackArrayElements from the internal entrypoint. |
| packages/kernel/src/core.ts | Defines $ELEMENTS and trackArrayElements() helper. |
| packages/js-krauset/vitest.config.ts | Uses shared Playwright provider. |
| packages/js-krauset/src/perf.test.ts | Supports CHROMIUM_EXECUTABLE_PATH when launching Chromium for perf runs. |
| packages/js-krauset/src/main.tsx | Remounts <tbody> on run()/clear() and moves selection to per-item state. |
| packages/js-krauset/src/dist.test.ts | Supports CHROMIUM_EXECUTABLE_PATH when launching Chromium for dist validation. |
| packages/js-krauset/perf-weights.ts | Centralizes Krause benchmark weights for reuse. |
| packages/js-krauset/perf-compare.ts | Reuses shared weights instead of duplicating the map. |
| packages/js-krauset/perf-ab.ts | Adds interleaved A/B runner + reporting for paired deltas and sign test p-values. |
| packages/js-krauset/package.json | Adds perf:ab script and introduces npm bootstrap dependency. |
| packages/js-krauset/css/currentStyle.css | Switches bootstrap CSS import from a gitignored path to node_modules resolution. |
| packages/js-krauset/css/bootstrap/package.json | Removes vendored bootstrap metadata. |
| packages/js-krauset/css/bootstrap/LICENSE | Removes vendored bootstrap license file (now sourced via npm package). |
| packages/js-krauset-react-hooks/vitest.config.ts | Uses shared Playwright provider. |
| packages/js-krauset-main/vitest.config.ts | Uses shared Playwright provider. |
| packages/husk/vitest.browser.config.ts | Uses shared Playwright provider. |
| packages/doc-tests/vitest.config.ts | Uses shared Playwright provider (keeps as any casting). |
| packages/devtools/vitest.browser.config.ts | Uses shared Playwright provider. |
| OPTIMIZATION-AGENT.md | Documents interleaved A/B (perf:ab) workflow + GC-aliasing guidance. |
| notes/performance/elements-signal-plan.md | Adds detailed plan + results narrative for D0–D5 and P1. |
| notes/failed-approaches/tracked-ref-disposal.md | Documents D3 attempt and why it was reverted. |
| notes/failed-approaches/lazy-version-signal-and-weakmap-skip.md | Documents lazy-version/weakmap-skip retest and GC-aliasing explanation. |
| notes/failed-approaches/for-swap-effect-deps.md | Documents D2 attempt and why it was obsoleted by D1. |
| notes/failed-approaches/for-passive-swap-effect.md | Documents passive swap-effect attempt and why it failed. |
| CLAUDE.md | Adds perf:ab workflow documentation. |
| .gitignore | Ignores new benchmark output artifacts and local Claude settings. |
| .github/workflows/benchmark.yml | Adds PR benchmark workflow running interleaved A/B and posting a sticky PR comment. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…timestamp, and run link The comment is edited in place so it never bumps in the PR timeline, and without a stamp a fresh result is indistinguishable from a stale one — which made a successfully-updated comment read as 'the benchmark didn't run'. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…el-only The kernel-only comparison was blind to app-side changes: both arms ran the PR's app, so P1's tbody-remount win (create-10k -30%) never appeared in the PR comment while the kernel-only delta read ~-2.6% — technically correct, wildly misleading for 'how much faster is this PR'. perf-ab.ts gains --mode app (swap prebuilt js-krauset/dist bundles, no rebuild between runs; kernel mode stays the default for local kernel experiments). The workflow now builds the complete app at head and at base (providing the gitignored bootstrap copy when the base predates the npm dependency) and interleaves the two bundles. Smoke-tested locally: app mode reproduces the P1 magnitude (-30.6% create-10k, single pair). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ics of CHROMIUM_EXECUTABLE_PATH; scheduleDisposal's paint guarantee) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ofiling hooks); kept Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… A/B tooling Removes the one coverage ignore this branch introduced and closes the smaller findings from the review of #136. disposal-queue: `/* c8 ignore */` hid the branch that actually ships — the requestAnimationFrame path — leaving only the node fallback tested. Both branches are now covered for real: tests/core keeps driving the fallback with fake timers, and a new browser suite under tests/react exercises the rAF callback, its nested macrotask, and the backstop timer. The file reports 100% with zero skipped lines. While covering it, fixed the stale-backstop interaction: a round arms both an rAF chain and a ~100ms backstop, and the loser used to fire later and clear `scheduled` even though its queue was empty — stranding the timers a subsequent round had armed and making the next scheduleDisposal arm a third set. flush() now returns before touching `scheduled` when there is nothing to drain. Every disposer still runs; this only stops the redundant re-arming. Also adds an integration test that tracked()'s teardown really does unlink the component's effect from the signal graph after unmount — the queue was tested in isolation, its caller was not. changeset: the branch changes published behavior (tracked() defers effect teardown past paint; parent-mode For swaps track one array-level signal instead of one per index) but carried no changeset, so merging would have bumped nothing — and D5 needs a kernel publish. benchmark.yml: pass `runs` and `base_ref` through `env:` instead of pasting `${{ }}` into shell source. Both need write access to influence today, so this is closing the class, not a live hole. perf-ab.ts: restore packages/kernel/dist (or js-krauset/dist) on exit. Swapping arms overwrote a real build output and left whichever arm ran last silently standing in as your dist. Also rejects a flag whose value is missing or flag-shaped rather than substituting the default and benchmarking the wrong thing. perf.test.ts: assert the select benchmark's warmup left a different row selected. Re-selecting the current row is a no-op (as it was under the old store.selected model too), so timing a click on an already-selected row would report ~0ms for doing nothing. .gitignore: stop ignoring .claude/settings.json. settings.local.json is already ignored and is where machine-specific hook paths belong; ignoring the shared file blocks committing project-wide settings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAbdFjd8xbca2ptYvP5BZX
Review fixes for #136: remove the coverage ignore, add a changeset, harden the A/B tooling
… unreachable state), rename kernel-diff step id to bundle-diff (it gates on the app bundle now) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
….local.json split in .gitignore; perf-ab restore is silent on clean exit, loud on interruption Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Started as the
notes/performance/elements-signal-plan.mdexecution (D0–D5); grew into a full profile-driven optimization pass plus benchmark infrastructure. Every accepted change was measured with interleaved A/B on quiet hardware; every rejection has a failed-approach note.Headline: create-10k is down ~−30% total on top of D1's −15–21% script, and benchmarks now run in CI on every PR commit.
Accepted
D1 —
$ELEMENTSarray-level signal (kernel)For's swap effect, replacing 10,000 per-index subscriptions on create-10k.core.ts:$ELEMENTS+trackArrayElements();write.ts: bump only if a subscriber created it (no write-pathgetNode);for.ts: one subscription, O(1) teardown.Deferred effect teardown in
tracked()(kernel — changes published behavior)react/disposal-queue.tsqueues disposers and flushes them in the first macrotask after the next frame.tracked()that is aforceUpdateon an unmounted component, which React ignores. Consumers who dispose effects themselves and need teardown at exactly the unmount commit must guard their own effect body.P1 — tbody remount on
run()(app, profile-driven)getHostSiblingat 135ms / 15.8% of create-10k vs 1.3ms at 1k — O(n²) sibling scans when placing all-new rows into the already-mounted tbody.clearEpoch→tbodyEpoch, bumped onrun()too: fresh tbody mounts rows via theappendAllChildrenfast path.Per-row selection state (app)
selected: number | nullread through a per-rowuseComputedto aselectedflag on the row itself, plus a module-levelselectedRowpointer. Selecting now writes exactly two signals (deselect old, select new) instead of re-evaluating a derived value per row.setPropertyskips unchanged writes).Benchmark CI (
.github/workflows/benchmark.yml+perf-ab.ts)pnpm perf:abruns the same tool locally.Fresh-clone fixes: bootstrap is a real npm dep (was a gitignored file that broke CI builds); playwright browsers install via pnpm
onlyBuiltDependencies(pnpm 10 was silently blocking the postinstall).Rejected (each with a failed-approach note)
tracked(C, { refDisposal: true }): complete opt-in implementation (StrictMode-safe, dev leak guard, 5 tests) preserved atc53e82b, then reverted: flat at 12 full pairs and at 16 focused pairs on the two benchmarks it targets. The per-row passive effect is below measurement resolution even at 10k rows.Methodology (OPTIMIZATION-AGENT.md)
Remaining
D5 — refresh the official js-framework-benchmark submission (needs kernel publish + the
add-supergrainbranch of commoncurriculum/js-framework-benchmark). The published 413.7ms create-10k predates PR #135, D1, and P1.Review follow-ups are in #137, which targets this branch: removes the
c8 ignoreoverdisposal-queue.ts's requestAnimationFrame path (now covered by a real browser test), adds the missing changeset — without one this merges without a version bump and D5 cannot proceed — and hardensperf-ab.ts/benchmark.yml.🤖 Generated with Claude Code