perf(geometry): flatten the layout grid, and compile the label stage's inner loop - #77
Merged
Merged
Conversation
…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.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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
dataarray,strideper 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.
labelPenaltyand the grid it queries are compiled.wasm/label-penalty.tsin AssemblyScript,npm run wasm, output committed — the arrangementbuild-glyphs.mjsalready uses.Results
Node, via
tests/harness.js: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:
buildLayoutContextruns inside a frame and can await nothing, soinstantiateStreamingoff a URL is unavailable to it. Chrome refuses synchronous compilation past 4KB on the main thread; this is 3,440 bytes andscripts/build-wasm.mjswarns when a change crosses the line. That limit is the browser's, though, so every way it can fail — the size limit, a CSP forbiddingwasm-eval, noWebAssemblyat all — ends innull, 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.jsuses thesetLabelKernelseam 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.hypotbecamesqrt(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.labelPenaltyis called from inside the pass that filled them, never from a context handed back. Inrelayoutthe reset lands after the dirty scan, which reads the previous pass's JS label grid.1951tests pass (4 new),npm run buildclean. Bundle grows 4.5 KB on 1.2 MB.wasm/*.wasmis gitignored;js/wasm/label-penalty-bytes.jsis the committed artifact.