Skip to content

perf(core): guard bulk slab changes in the spatial-grid subscriber - #833

Merged
Aymericr merged 1 commit into
pascalorg:mainfrom
RIRuhAI:ruh/perf-bulk-slab-change-guard
Sep 12, 2026
Merged

Aymericr merged 1 commit into
pascalorg:mainfrom
RIRuhAI:ruh/perf-bulk-slab-change-guard

Conversation

@RIRuhAI

@RIRuhAI RIRuhAI commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

The spatial-grid subscriber runs markNodesOverlappingSlab for every slab a store write adds, removes or reshapes: two full node scans per slab, so a scene load, reload, paste or import is O(slabs × nodes). On a 4,645-slab scene (7,143 nodes) that scan alone blocked the main thread for ~5 s on every load — a real-Chrome CPU profile showed 5.9 s inclusive inside this scan out of a 7 s setScene block, enough for Chrome's "Page Unresponsive" dialog on a loaded machine.

When one write changes 32 or more slabs, this dirties every node any slab could affect once — walls, ceilings, stairs and level-hosted floor-placed kinds, a superset of the per-slab marks — and skips the per-slab scans. setScene marks every node dirty right after set() regardless, so load-time behaviour is unchanged; single-slab edits keep the targeted rules (markSlabChangeDependents, covering-below, deck stairs).

Measurement caveat: the numbers were taken with an equivalent patch applied to the published @pascal-app/core@1.0.0-beta.5 (before #805), not re-taken on this source. Same mechanism. Max main-thread freeze on that scene went 6.4 s → 0.4–0.9 s; time to a ready model 31.7 s → ~21 s. Happy to re-run on a fixture you prefer.

How to test

  1. bun test --cwd packages/core src/hooks/spatial-grid/spatial-grid-sync.test.ts — new bulk slab-change guard block: 31 added interior plates leave a non-overlapping wall clean (per-slab path); 32 mark it (superset path); a 3,000-slab write stays linear (well under 1.5 s, versus seconds unguarded); later single-slab edits stay targeted.
  2. Load a scene with hundreds of slab nodes with ?perf and compare the setScene span before and after.
  3. Full core suite passes (1,490 tests); tsc --build and bun check clean.

Screenshots / screen recording

No visual change. Numbers above; a before/after CPU profile of the scan is available on request.

Checklist

  • I've tested this locally with bun dev — verified through the package test suites, tsc --build and bun check instead; no editor session was run for this change
  • My code follows the existing code style (run bun check to verify)
  • I've updated relevant documentation (if applicable)
  • This PR targets the main branch

🤖 Generated with Claude Code


Note

Medium Risk
Changes when and which nodes are marked dirty in the spatial-grid subscriber; bulk mode trades precise overlap scans for a fixed superset sweep, so correctness depends on that superset matching real slab dependents outside full-scene hydration.

Overview
Fixes main-thread freezes on scene load, paste, and import when thousands of slabs change in one store write. The spatial-grid subscriber used to run two full node scans per added, removed, or reshaped slab; this adds a bulk guard at 32+ slab changes that calls markAllSlabDependents once (walls, ceilings, stairs, level-hosted floor-placed nodes) and skips the per-slab overlap and markSlabChangeDependents paths.

Exports BULK_SLAB_CHANGE_THRESHOLD, countBulkSlabChanges, and markAllSlabDependents. Dirty marks on bulk writes can be a superset of the old per-slab marks (e.g. a non-overlapping wall may be marked at the threshold); single-slab edits still use the targeted rules. Tests cover counting, threshold behavior, a 3,000-slab performance bound, and that later one-slab edits stay precise.

Reviewed by Cursor Bugbot for commit 7997a2f. Bugbot is set up for automated code reviews on this repo. Configure here.

A scene load, reload, paste or import changes tens to thousands of slabs in
one store write. The spatial-grid subscriber ran markNodesOverlappingSlab for
every added, removed or reshaped slab — two full node scans each — so the cost
was O(slabs × nodes).

When a write changes >= 32 slabs, dirty every node any slab could affect once
(walls, ceilings, stairs, level-hosted floor-placed kinds — a superset of the
per-slab marks) and skip the per-slab scans. setScene marks every node dirty
after set() regardless, so load-time behaviour is unchanged; single-slab edits
keep the targeted rules.

Indicative measurement: an equivalent patch applied to the published
@pascal-app/core 1.0.0-beta.5 (which predates pascalorg#805) on a 4,645-slab scene
(7,143 nodes) in Chrome took the main-thread block on load from ~6.4 s to
0.4–0.9 s (real-Chrome CPU profile had 5.9 s inclusive in this scan). The
mechanism here is identical; the numbers were not re-taken on this source.

Tests: threshold edge (31 plates leave a non-overlapping wall clean, 32 mark
it), 3,000-slab write stays linear, later single-slab edits stay targeted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@pascal

pascal Bot commented Sep 10, 2026

Copy link
Copy Markdown

I hit an error while handling your request (Model unavailable on AI Gateway free tier: Free tier users do not have access to this model. Upgrade to paid credits at https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dtop-up for unrestricted…).

Please try again, rephrase, or reach out if it keeps failing.

Error id: 5cafdd00-710c-4d67-8059-613f87865866

@Aymericr Aymericr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The algorithmic guard is sound: the bulk path marks a true superset of every per-slab consumer, while single-slab writes retain the precise overlap/deck/covering rules. I also merged the head onto current main locally and verified the focused spatial-grid suite (24 tests / 64 assertions), the core build, and Biome. The 3,000-slab regression completed in 182 ms here.

@Aymericr
Aymericr merged commit 8e41178 into pascalorg:main Sep 12, 2026
1 check passed
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.

2 participants