Skip to content

perf(text): precompute glyph band transform and reslot run edits in place - #520

Open
ryantrem wants to merge 9 commits into
masterfrom
perf/text-data-run-edit-fast-paths
Open

perf(text): precompute glyph band transform and reslot run edits in place#520
ryantrem wants to merge 9 commits into
masterfrom
perf/text-data-run-edit-fast-paths

Conversation

@ryantrem

@ryantrem ryantrem commented Aug 1, 2026

Copy link
Copy Markdown
Member

Motivation

Editing text in a TextData was O(total run count) per edit, not O(1). In an Excel-like benchmark (30×28 grid = 840 cells, each cell its own GlyphRun in one TextData), a single-cell edit got slower as the grid grew — quadratic over a full sweep. Two independent causes: unconditional _runs.indexOf() scans, and glyph-count changes falling through to a remove + add path.

Changes

glyph-storage.ts

AtlasSlot now caches the glyph's font-unit bounds (xMin/yMin/xMax/yMax) and the precomputed band-space transform (bandScaleX/Y, bandOffsetX/Y), filled in once by packAppendGlyph. These replace vBandCount/hBandCount.

The per-instance packer was re-deriving these (a Map lookup + 2 divides + 2 multiplies) for every glyph of every edit, even though they're glyph-invariant. Slots are append-only and never moved, so the snapshot can't go stale.

text-data.ts

  • packGlyphAtSlot reads the precomputed fields and drops the curveSet.curves.get() lookup entirely — the atlas-slot lookup already implies validity, since both maps are written together and never pruned. One Map lookup instead of two, no arithmetic.
  • resolveRunIndex (new) — a numeric ref is the index, so it costs nothing; only object refs need the indexOf. Replaces 4 unconditional _runs.indexOf scans. This is the main O(n) → O(1) fix.
  • writeRunToSlots no longer allocates a liveSlots array per call. It returns the caller's slots when every glyph lands (the overwhelmingly common case), materializing a copy only on the first atlas miss.
  • applyReplaceRun now reslots in place: when the curve set matches and the new run is non-empty, a glyph-count change frees + reallocates within the same group instead of remove + add. No _runs splice, no index scan. Adds a prev === newRun fast path for the in-place-mutation pattern apps actually use. Empty replacements still take the remove path so an emptied group can be retired.
  • allocateSlots / applyReset use new Array(n).fill(-1). Load-bearing, not cosmetic: a bare new Array(n) is HOLEY_SMI_ELEMENTS in V8, and this array is now handed straight to a run record, so the holeyness leaks into shiftSlotsAtOrAfter's hot per-slot loop and roughly halves its throughput. Verified with node --allow-natives-syntax + %HasHoleyElements. Comments added so nobody "simplifies" it back.

Tests

  • tests/lite/unit/text-data-run-edits.test.ts (new, 20 tests) — the first test coverage replaceRun has ever had. Covers replaceRun basics, atlas-miss slot positions, in-place reslot grow/shrink/same-size, empty replacement, same-reference mutation, and a 40-cycle slot-partition invariant.
  • tests/lite/unit/text-glyph-storage.test.ts (+1) — tripwire that re-registering a glyph id cannot change its packed geometry, guarding the new snapshot approach.

Measured results

Rendering output is unchanged — the same values are written to each instance, just sourced from the atlas slot instead of recomputed per instance.

Per-edit cost by run count (localized sweep vs stock 1.11.0, speedup at 210 / 840 / 3,360 / 13,440 runs):

stage 210 840 3,360 13,440
indexOf fix alone 1.06x 1.15x 1.52x 2.98x
+ glyph-constant caching 1.29x 1.42x 1.83x 3.55x
+ liveSlots/record reuse 1.77x 1.91x 2.23x 4.19x

Glyph-count-change edits (the reslot fix), per-edit µs at 210 / 420 / 840 / 1680 runs:

210 420 840 1680
before 0.762 1.976 3.131 5.536
after 0.738 0.583 0.554 0.589

Linear → flat (O(n) → O(1)), 9.4x at 1680. Full-sweep grow case: 4.835 ms → 0.52 ms.

Node CPU micro-benchmark (660 runs × 18 glyphs, median ms):

bench before after
addRun (full doc) 4.92 4.83
replaceRun same-group same-size 0.56 0.35 (−37%)
replaceRun cross-group 6.15 5.68 (−8%)
replaceRun same-group resize 5.46 5.09 (−7%)

End-to-end (LineLayoutPad, Babylon outline, ~11.9k glyphs, drawGlyphRun phase, n=120, alternating rounds):

scenario before after
bold flip (remove+add) 9.95 ms 9.55 ms
colour flip (same-size replaceRun) 1.50 ms 1.30 ms (−13%)

Methodology caveat: V8 JIT tiering dominates these microbenchmarks (identical code, 1st invocation 2.70 ms vs 2nd 0.73 ms). All figures come from interleaved harnesses with 40–60 warmup cycles, medians, and two agreeing runs.

Cost

AtlasSlot grows 40 → 108 bytes (measured via CDP heap profiling). That's ≈ +2.2% per glyph overall, and that's an upper bound — the GlyphCurves wrapper, bounds object, curve array and two Map entries weren't counted. Bundle impact is ~130 raw bytes on each of the three text scenes, all still under their ceilings (no ceilings changed):

scene rawKB ceiling
180 27.5 → 27.7 30
181 43.9 → 44.0 46
275 41.9 → 42.0 42.5

Notes for reviewers

  1. Slot assignment order changes. The reslot path frees then reallocates, and freeSlots is LIFO, so glyphs can legitimately land in different slots than before with identical contents. A positional/bitwise instance-buffer diff against old builds will show differences that are not bugs. Correctness was confirmed with a live-instance multiset comparison (identical across all phases) plus visual checks. Flagging it because a positional comparison is the obvious thing to reach for.

  2. Visual parity verified. Babylon outline rendering is pixel-identical between published @babylonjs/lite@1.11.0, master, and this branch.

  3. Known pre-existing defect this interacts with (not fixed here). RunRecord.slots stores only live slots. Glyphs with no outline (e.g. space) never enter the atlas, so for any run containing a space newRun.glyphs.length === rec.slots.length is permanently false — the equal-count in-place fast path is effectively dead code for real text, and such runs always take the reslot branch (correct, but a needless free/realloc). Follow-up: store allocated slots (misses kept but dead-marked), or track an allocated count on RunRecord. This caps the real-world benefit.

  4. Remaining follow-up opportunities. markDirty uses a single union interval, so a one-character edit dirties 2,346 of 6,721 instances — the biggest remaining win in this path. shiftSlotsAtOrAfter is also still O(all glyphs) per growGroup (44–67% of self time in profiles). Both deliberately out of scope.

Validation

  • pnpm run lint (ESLint + tsc --noEmit across all 7 projects) — clean
  • npx vitest run --project unit1307/1307 passed (178 files)
  • pnpm build:bundle-scenes — regenerated per-scene manifests committed
  • pnpm test:parity449 passed, 7 skipped, 2 failed

The 2 parity failures are pre-existing on origin/master, verified by re-running both specs on a pristine detached origin/master checkout and getting identical MAD values:

scene MAD limit on master?
116 — Shadow Depth Materials 0.289 0.01 same failure — and scene-config.json already documents it as flaky (skipParityOnCI: true, BJS live-reference capture unreliable)
265 — EnvironmentTest (IBL) 0.781 0.5 same failure, identical MAD

Neither scene uses the text path. No golden references and no ceilings were modified. (Several specs force-recapture their goldens live on every run; that churn was reverted rather than committed.)

pnpm test:perf was not run, per the agent guardrail in GUIDANCE.md.

ryantrem and others added 3 commits July 31, 2026 18:22
…lace

Speeds up TextData run editing by removing per-instance work from the glyph
packing path and by keeping same-curve-set replaceRun edits inside their
existing draw group.

glyph-storage.ts
- AtlasSlot now caches the glyph's font-unit bounds (xMin/yMin/xMax/yMax) and
  the precomputed band-space transform (bandScaleX/Y, bandOffsetX/Y), filled in
  once by packAppendGlyph. Slots are append-only and never moved, so these can
  never go stale. Replaces the vBandCount/hBandCount fields.

text-data.ts
- packGlyphAtSlot drops the curveSet.curves.get() lookup and the per-instance
  band math, reading everything glyph-invariant straight off the atlas slot.
- writeRunToSlots no longer allocates a liveSlots array in the common case
  where every glyph lands; it returns the caller's slots array and only
  materializes a copy once a glyph actually misses the atlas.
- applyReplaceRun stays in-group for any same-curve-set replacement, including
  changed glyph counts (which now free + reallocate slots instead of routing
  through remove + add). Empty replacements still take the remove path so an
  emptied group can be retired.
- New resolveRunIndex helper avoids the O(run count) _runs.indexOf() scan when
  the caller already passed a numeric run index.
- allocateSlots and applyReset build their slot arrays with .fill(-1). This is
  load-bearing, not cosmetic: writeRunToSlots now hands these arrays straight to
  a run record, so a bare new Array(n) would leave them HOLEY_SMI_ELEMENTS in V8
  and that holeyness leaks into shiftSlotsAtOrAfter's hot per-slot loop, roughly
  halving its throughput.

Measured (Node CPU micro-benchmark, 660 runs x 18 glyphs, median ms):
  addRun (full doc)                    4.92 -> 4.83
  replaceRun same-group same-size      0.56 -> 0.35  (-37%)
  replaceRun cross-group               6.15 -> 5.68  (-8%)
  replaceRun same-group resize         5.46 -> 5.09  (-7%)

Measured (LineLayoutPad, Babylon outline, ~11.9k glyphs, drawGlyphRun phase):
  bold flip (remove+add)               9.95 ms -> 9.55 ms
  colour flip (same-size replaceRun)   1.50 ms -> 1.30 ms  (-13%)

Rendering output is unchanged - the same values are written to each instance,
just sourced from the atlas slot instead of recomputed per instance.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d54a24a7-1149-4edd-be89-61315a3d69bb
AtlasSlot gains 8 precomputed fields, adding ~130 raw bytes to each of the three text scenes. All still well under their maxRawKB ceilings (27.7/30, 44.0/46, 42.0/42.5).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d54a24a7-1149-4edd-be89-61315a3d69bb
Copilot AI review requested due to automatic review settings August 1, 2026 01:50
@bjsplat

bjsplat commented Aug 1, 2026

Copy link
Copy Markdown

Lite Playground - Static Site

Open deployed site

Build 20260801.5 - merge @ 275e820

Copilot AI 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.

Pull request overview

Optimizes TextData run editing and per-glyph packing by removing per-edit O(runCount) scans and per-glyph recomputation that were invariant per glyph, improving scaling for many-run documents (e.g., spreadsheet-like layouts) while keeping rendering output unchanged.

Changes:

  • Cached glyph-invariant bounds and band-space transform in AtlasSlot, and updated packGlyphAtSlot to consume the snapshot (fewer lookups/arithmetic per glyph).
  • Made replaceRun/removeRun edits avoid unnecessary _runs.indexOf() scans via resolveRunIndex, and reslotted same-group glyph-count edits in place instead of remove+add.
  • Added focused unit coverage for replaceRun/atlas-miss behaviors and a regression test for glyph re-registration geometry stability.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/babylon-lite/src/text/glyph-storage.ts Extends AtlasSlot to snapshot glyph bounds + precomputed band transform at pack time.
packages/babylon-lite/src/text/text-data.ts Uses slot snapshot in packing; reduces edit complexity by avoiding index scans and reslotting in place; avoids per-call live-slots allocations on common path.
tests/lite/unit/text-glyph-storage.test.ts Adds regression test ensuring re-registering an existing glyph id cannot change packed geometry.
tests/lite/unit/text-data-run-edits.test.ts New unit suite covering replaceRun behaviors, atlas misses, and slot bookkeeping invariants.
lab/public/bundle/manifest/scene180.json Updates tracked per-scene bundle metrics after runtime byte changes.
lab/public/bundle/manifest/scene181.json Updates tracked per-scene bundle metrics after runtime byte changes.
lab/public/bundle/manifest/scene275.json Updates tracked per-scene bundle metrics after runtime byte changes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@bjsplat

bjsplat commented Aug 1, 2026

Copy link
Copy Markdown

Lab - Static Site

Open deployed site

Build 20260801.5 - merge @ 275e820

@ryantrem
ryantrem enabled auto-merge (squash) August 1, 2026 05:10
ryantrem and others added 2 commits August 1, 2026 20:29
…edit-fast-paths

# Conflicts:
#	lab/public/bundle/manifest/scene181.json
#	lab/public/bundle/manifest/scene275.json
scene181 and scene275 rawBytes shift by -18 each after merging master's
shader/overlay changes (#519, #521). Other manifests were rebuild churn
(JSON number formatting and gzip rounding jitter) and are left untouched.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d54a24a7-1149-4edd-be89-61315a3d69bb
Copilot AI review requested due to automatic review settings August 2, 2026 03:43
@bjsplat

bjsplat commented Aug 2, 2026

Copy link
Copy Markdown

Lite Playground - Static Site

Open deployed site

Build 20260802.1 - merge @ c3be834

Copilot AI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

@bjsplat

bjsplat commented Aug 2, 2026

Copy link
Copy Markdown

Lab - Static Site

Open deployed site

Build 20260802.1 - merge @ c3be834

@deltakosh deltakosh 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.

Found one draw-order regression in the new in-place reslot path.

Comment thread packages/babylon-lite/src/text/text-data.ts
ryantrem and others added 2 commits August 3, 2026 09:13
…edit-fast-paths

# Conflicts:
#	lab/public/bundle/manifest/scene181.json
#	lab/public/bundle/manifest/scene275.json
Instances are drawn in slot order, so a run's glyphs must sit on ascending
slots. The resize path freed the run's whole slot block to the group free
list and re-allocated it, and since freeSlots pushes in glyph order while
allocateSlots pops LIFO, the run came back reversed: growing [0, 1] to three
glyphs yielded [1, 0, 2]. Overlapping glyphs with distinct per-glyph colors
or alpha then composited in the wrong order.

Resize the run's own slot list in place instead -- trim the tail when
shrinking, append newly allocated slots when growing -- so surviving slots
keep their order. Slots reclaimed from the free list can still land below
the ones the run already holds, so the appended range is checked and the
list sorted only when that happens; growth off the group tail skips the
sort entirely.

Done in place rather than with slice/concat so the run's slot array is
never reallocated, preserving its PACKED_SMI elements kind (verified with
%HasHoleyElements across length-truncation, push, and sort).

Adds regression coverage asserting ascending slot order after grow, shrink,
and 40 rounds of mixed resizes.

Reported-by: @deltakosh

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d54a24a7-1149-4edd-be89-61315a3d69bb
Copilot AI review requested due to automatic review settings August 3, 2026 16:34

Copilot AI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (2)

packages/babylon-lite/src/text/text-data.ts:667

  • applyReplaceRun relies on rec.slots already being in ascending order when deciding whether to sort after appending extra slots. However allocateSlots() can hand out reused slots in arbitrary/LIFO order (via group.freeSlots.pop()), so an existing run can already have a non-monotonic rec.slots. In that case, the current check (prevSlot = slots[prevSlotCount - 1]) can miss the disorder and leave slots non-ascending, contradicting the comment above about slot-order affecting compositing.
                const extra = allocateSlots(data, group, newRun.glyphs.length - prevSlotCount);
                // Slots appended past the group's tail already sort after the ones this run holds;
                // only slots reclaimed from the free list can land below them.
                let sorted = true;
                let prevSlot = prevSlotCount > 0 ? slots[prevSlotCount - 1]! : -1;

lab/public/bundle/manifest/scene181.json:3

  • PR description's bundle impact table reports much smaller movement for scene181 (43.9 → 44.0 rawKB, ~+130 bytes), but this committed manifest shows 43.9 → 44.3 rawKB and rawBytes 44961 → 45318 (+357). Please reconcile the numbers (update the PR description or regenerate manifests from the deterministic build output) so reviewers can accurately judge bundle impact vs ceilings.
  "rawKB": 44.3,
  "gzipKB": 16.9,

@bjsplat

bjsplat commented Aug 3, 2026

Copy link
Copy Markdown

Lite Playground - Static Site

Open deployed site

Build 20260803.14 - merge @ fc6c1fb

@bjsplat

bjsplat commented Aug 3, 2026

Copy link
Copy Markdown

Lab - Static Site

Open deployed site

Build 20260803.14 - merge @ fc6c1fb

ryantrem and others added 2 commits August 3, 2026 12:06
…rder

The previous fix kept resized runs ascending by resizing the run's slot list in
place. That covered the resize path but left the same reversal reachable through
`applyAddRun`, and it cost 233 raw bytes.

Move the guarantee into `allocateSlots` instead, so every caller gets ascending
slots: track order while the free list is being drained and sort only when a
reclaimed block actually came back reversed. `sort` runs a comparator call per
element even on already-ordered input, so an unconditional sort measurably hurt
the paths that allocate straight off the group tail -- cross-group replaceRun
went from -4.9% to +1.5% against master. Guarding it recovers that fully.

Nothing refills the free list mid-drain, so the loop breaks out at the first
miss instead of calling `popFreeSlot` on an empty list for every remaining
glyph, and the extension writes the tail of `out` directly rather than
rescanning for -1 placeholders.

Interleaved A/B against master (660 runs x 18 glyphs, median):

  addRun full doc         -16.2%
  replaceRun same-size    -22.9%
  replaceRun resize        -2.7%
  replaceRun cross-group   -4.7%

Bundle cost of the ordering fix drops from 233 to 69 raw bytes over the
unoptimised branch (scene180 +207, scene181 +193), with no change in gzip.

Adds coverage for the add path reusing freed slots, which reproduces [3, 2]
without this change.

Reported-by: @deltakosh

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d54a24a7-1149-4edd-be89-61315a3d69bb
Copilot AI review requested due to automatic review settings August 3, 2026 19:10
@bjsplat

bjsplat commented Aug 3, 2026

Copy link
Copy Markdown

Lite Playground - Static Site

Open deployed site

Build 20260803.18 - merge @ a801a19

Copilot AI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

@bjsplat

bjsplat commented Aug 3, 2026

Copy link
Copy Markdown

Lab - Static Site

Open deployed site

Build 20260803.18 - merge @ a801a19

@ryantrem
ryantrem requested a review from deltakosh August 3, 2026 20:39
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.

4 participants