Skip to content

perf(geometry): flatten the layout grid, and compile the label stage's inner loop - #77

Merged
thethinkmachine merged 1 commit into
mainfrom
perf/label-stage-kernel
Sep 18, 2026
Merged

thethinkmachine merged 1 commit into
mainfrom
perf/label-stage-kernel

Conversation

@thethinkmachine

Copy link
Copy Markdown
Owner

A grid query was the hottest thing in the app — ~31% of the layout pass at 500 states, ahead of every piece of geometry it exists to serve. Almost none of that was searching: it was building a string `${ix},${iy}` per cell per query and hashing it, and allocating a result array per query to spread buckets into.

What changed, in the order it had to happen

1. The grid is flat. An open-addressed table with a linked list per cell, answering into a buffer it reuses. Measured over 20k label-sized queries on 500 nodes:

string key + fresh array + spread (before) 9.12 ms
the same, writing into a reused array 7.26 ms
an integer key, still a Map of arrays 3.96 ms
flat hash + linked list, reused array 2.02 ms

A CSR layout is faster still, but it has to be built from a complete list in one pass — and two of the three grids here are filled during the pass that queries them (edge samples as edges are routed, labels as they are placed). One structure that does both beats a second implementation for the one grid that is static. A callback in place of the result array was tried and is worse (4.6 ms): three call sites pass three different closures, so the call inside the loop goes polymorphic.

2. Each grid carries its items' numbers in a data array, stride per item, and a query answers with indices into it. The inner loops then read f64s out of one contiguous buffer instead of chasing a pointer per item and loading four or five properties off whatever shape the object turned out to have. A group's key becomes an integer, which retires a string compare from the innermost comparison in the pass.

3. labelPenalty and the grid it queries are compiled. wasm/label-penalty.ts in AssemblyScript, npm run wasm, output committed — the arrangement build-glyphs.mjs already uses.

Results

Node, via tests/harness.js:

gain
grid + flattening ~2.0× on the pass 10.0 ms → 5.1 ms at 200 states; 67 → 32 at 1000
the kernel on top 1.09–1.15× 3.46 → 3.00 at 200; 27.3 → 24.5 at 1000; drag frame 1.66 → 1.46

Measured separately because only one of them can be A/B'd in a single process. On the function alone wasm is 1.39× (586ns → 423ns per call) and it is ~45% of the pass.

Worth recording: against well-written typed-array JS the instruction set is worth about forty per cent. The 2.9× that was really available here was a data-layout problem wearing a wasm-shaped hat — which is why step 1 is the larger half, and why it had to come first, since WebAssembly cannot see a JS object. A JS→wasm call costs 6.2ns, so batching candidates into one call per label would buy nothing.

The fallback is not a degraded mode

The kernel is instantiated synchronously: buildLayoutContext runs inside a frame and can await nothing, so instantiateStreaming off a URL is unavailable to it. Chrome refuses synchronous compilation past 4KB on the main thread; this is 3,440 bytes and scripts/build-wasm.mjs warns when a change crosses the line. That limit is the browser's, though, so every way it can fail — the size limit, a CSP forbidding wasm-eval, no WebAssembly at all — ends in null, and the JS implementation stays.

Which makes it the same diagram computed the other way, and that is what must not rot. tests/label-penalty-wasm.test.js uses the setLabelKernel seam to lay the same machines out both ways and compare every label position exactly — on a full pass and across eight frames of a drag, since two kernels that only nearly agreed would come apart over a gesture rather than at the first frame. Math.hypot became sqrt(dx*dx + dy*dy) on both sides so that can be bit-identical.

Notes for review

  • gridGrow's first draft read the arrays it had just replaced. The incremental-layout invariant caught it and nothing else would have.
  • One pass's wasm grids at a time — safe only because labelPenalty is called from inside the pass that filled them, never from a context handed back. In relayout the reset lands after the dirty scan, which reads the previous pass's JS label grid.
  • The kernel owns the sample grid outright and only mirrors nodes and labels; samples are the most numerous thing in a pass, so building that grid on both sides would cost more than it saves.
  • 1951 tests pass (4 new), npm run build clean. Bundle grows 4.5 KB on 1.2 MB.
  • wasm/*.wasm is gitignored; js/wasm/label-penalty-bytes.js is the committed artifact.

…s inner loop

A grid query was the hottest thing in the app — ~31% of the layout pass at 500
states, ahead of every piece of geometry it exists to serve — and almost none of
that was searching. It was building a string `${ix},${iy}` per cell per query and
hashing it, and allocating a result array per query to spread buckets into.

Three changes, in the order they had to happen:

  1. The grid becomes a flat open-addressed table with a linked list per cell,
     answering into a buffer it reuses. A CSR layout is faster still but has to
     be built from a complete list in one pass, and two of the three grids here
     are filled *during* the pass that queries them.

  2. Each grid carries its items' numbers in a `data` array, `stride` per item,
     and a query answers with indices into it. The inner loops then read f64s
     out of one contiguous buffer instead of chasing a pointer per item. A
     group's key becomes an integer, which retires a string compare from the
     innermost comparison in the pass.

  3. labelPenalty and the grid it queries are ported to AssemblyScript
     (wasm/label-penalty.ts, `npm run wasm`, output committed the way
     build-glyphs.mjs already does it).

Measured, Node, tests/harness.js:

  grid + flattening   ~2.0x on the pass   10.0ms -> 5.1ms at 200 states
                                          67    -> 32   at 1000
  the kernel on top   1.09-1.15x          3.46  -> 3.00 at 200
                                          27.3  -> 24.5 at 1000
                                          drag frame 1.66 -> 1.46

Worth recording: against well-written typed-array JS the instruction set is
worth about forty per cent. The 2.9x that was really available here was a
data-layout problem wearing a wasm-shaped hat, which is why step 1 is the larger
half and why it had to come first — WebAssembly cannot see a JS object.

The kernel is instantiated synchronously, because buildLayoutContext runs inside
a frame and can await nothing. Chrome refuses synchronous compilation past 4KB
on the main thread; this is 3,440 bytes and scripts/build-wasm.mjs warns when a
change crosses the line. But that limit is the browser's, so every way it can
fail ends in null and the JS implementation stays as the fallback — not a
degraded mode, the same diagram computed the other way. tests/label-penalty-wasm
lays the same machines out both ways and compares every label position exactly,
on a full pass and across eight frames of a drag; Math.hypot became
sqrt(dx*dx+dy*dy) on both sides so that can be bit-identical.

gridGrow's first draft read the arrays it had just replaced, which the
incremental-layout invariant caught and nothing else would have.
Copilot AI lite review requested due to automatic review settings September 18, 2026 03:24
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@thethinkmachine
thethinkmachine merged commit 54820c3 into main Sep 18, 2026
6 checks passed
@thethinkmachine
thethinkmachine deleted the perf/label-stage-kernel branch September 18, 2026 03:27
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