perf(webgpu): memoize the per-quad segment slot lookup - #1608
Conversation
WebGPU submitted quads about 64% slower than WebGL 2 — 10.15ms against 6.19ms
for 20,000 quads — and all of it was one string.
`segmentSlotFor` runs once per quad to resolve which multi-texture slot the
quad batches against, and it built a template-literal key every time, then
hashed that string into the slot table:
const slotKey = `${this.resourceId(record.view)}|${filter}|${wrap}`;
Consecutive quads in a batch almost always share a texture — a sprite sheet, a
font atlas, an emitter's particle image — so the answer was identical for long
runs while the cost was not. At 20,000 quads that is 20,000 string allocations
and 20,000 string-keyed lookups every frame.
The last resolved texture is now remembered and short-circuited. Invalidated
when the slot is evicted, and bypassed when the caller forces a reupload, since
that means the resident record must be re-resolved.
Profiling put the whole gap here rather than in the GPU API. At 20,000 quads
the WebGPU calls themselves accounted for 0.07ms of a 9.8ms draw:
writeBuffer 21 calls 0.05ms 2.1MB
createCommandEncoder 1 call 0.01ms
submit 1 call 0.01ms
createBindGroup 0 calls
createBuffer 0 calls
No bind group or buffer churn, and it uploads less than WebGL does. The cost
was JavaScript in the per-quad path.
20,000 quads 10.15ms -> 5.71ms
5,000 quads 2.33ms -> 1.40ms
That takes WebGPU from noticeably slower than WebGL 2 to marginally faster
(5.71 against 6.14). This is the shared quad batcher, so it lifts sprites, text
and tilemaps too, not just particles.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
The memo added in the previous commit keyed on texture identity alone, which was wrong twice over, and adversarial tests written afterwards caught both. The slot key is `(view, filter, wrap)`. Keying the memo on the texture object meant the same texture drawn `nearest` then `linear` — or `no-repeat` then `repeat` — got the earlier slot back and batched against a sampler with the wrong configuration. Silent, and visible only as a wrong-looking sprite. Worse, the memo short-circuited `getResidentRecord`, which revalidates the record against `texture.getTexture()` and re-uploads when the source changed underneath. A video frame, an animated canvas or a swapped atlas would have kept serving the stale upload. So the residency check now runs on every quad as before, and only the part that was actually expensive is memoized: building a template-literal key and hashing it into the slot table. Keyed on the resolved view rather than the texture, so a re-upload that produces a new view misses. The correction costs almost nothing — 5.71ms to 5.93ms at 20,000 quads, against 10.15ms before any memo and 6.49ms for WebGL 2 on the same run. Tests grow from 6 to 11 and are now about the ways a memo can hand back a slot that is no longer correct: filter change, wrap change, interleaved textures, residency revalidated on every quad, reupload reaching the store, eviction driven through the real table, and interleaved quads sharing slots correctly through addQuad. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
|
Update — the first version of this had two regressions, both caught by writing adversarial tests after the fact. Fixed in 0885416. The slot key is
The residency check now runs on every quad as before, and only the genuinely expensive part is memoized — building the template-literal key and hashing it into the slot table — keyed on the resolved view rather than the texture object, so a re-upload producing a new view misses. The correction costs almost nothing:
Tests grew from 6 to 11, and are now about the ways a memo can hand back a slot that is no longer correct rather than about the memo working when nothing changes: filter change on the same texture, wrap change on the same texture, interleaved textures each keeping their own slot, residency revalidated on every quad (counted, not assumed), The two original regressions are directly covered — reverting either fix fails a test. |
…inned
An independent review found the memo itself correct, and the tests not. Two
mutants — each reintroducing a wrong-texture or wrong-sampler bug of exactly
the character of the two regressions this branch already shipped and fixed —
survived all 105 tests across the eight relevant WebGPU suites:
- deleting `this._memoSlot = resolved` returns the PREVIOUS texture's slot on
any memo hit at slot >= 1. No test performed "resolve X, resolve Y at slot
>= 1, resolve Y again".
- clearing the memo on eviction only for slot 0 passes everything, because
every existing test's memo happened to sit on slot 0 when a reset fired.
And the eviction test was a tautology: its 32-texture overflow loop overwrites
`_memoView` on every miss, so the assertion held even with the eviction clear
deleted.
The common blind spot is that the tests compared slot NUMBERS. A returned slot
is only correct if its entry holds the CALLER's view, so the assertions now
check `segmentEntries[slot].view` — which is what makes both mutants fail.
Also adds the forced-reupload case with a view that actually changes. The mock
store ignores `options` and hands out one immortal view per atlas, so the
existing test only asserted flag plumbing and neither force path existed.
While here: the previous commit message claimed a forced reupload always
yields a new view. It does not — an in-place re-upload before the record is
stamped for the frame keeps the same view, and the memo hit is correct there
precisely because nothing has referenced it yet this frame. The in-code comment
was already accurate; only the commit message overclaimed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
None of those three entries were API changes. Two were consequences of what the release ADDED — a scene that already set one of the six modes now renders it — and the third records that 3D mesh rendering is UNCHANGED, which is the opposite of a change. The two substantive caveats, the per-draw capture and composite cost and the drawMesh fallback, move onto the Added entry they belong to. `### Changed` is for user-facing API changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
`abandonFrame()` finishes the command encoder without submitting, then frees every texture retired during that frame — correct in itself, since the draws referencing them died with the buffer. But the batchers were left untouched, and a quad batcher's `segmentEntries` holds `GPUTextureView`s into exactly those textures, so the next frame could compose a bind group over destroyed resources. Reached by any frame abandoned after a texture was replaced or unloaded mid-frame; a stage switch freeing the previous scene's assets is the ordinary way in. The normal submit path is safe because `flush()` resets the segment first — only the abandon path skips it. Every registered batcher is now reset before the retired textures are freed, which also drops the dead frame's queued vertices, current effect and material, and composed bind groups. Found by an adversarial review of #1608; present on master since the backend landed, not a regression from any current PR. Three tests using the established stub pattern, including one asserting the reset happens BEFORE the destroy. Two mutations caught: removing the reset, and reordering it after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
# Conflicts: # packages/melonjs/CHANGELOG.md
WebGPU submitted quads about 64% slower than WebGL 2 — 10.15 ms against 6.19 ms for 20 000 quads. The entire gap was one string built per quad.
Where it was
segmentSlotForruns once per quad to resolve which multi-texture slot the quad batches against:Consecutive quads in a batch almost always share a texture — a sprite sheet, a font atlas, an emitter's particle image — so the answer was identical for long runs while the cost was not. At 20 000 quads that is 20 000 string allocations and 20 000 string-keyed map lookups every frame.
It was not the GPU API
Worth recording, because the obvious suspects were all innocent. Instrumenting every WebGPU call in the draw path at 20 000 quads:
writeBuffercreateCommandEncodersubmitcreateBindGroupcreateBuffer0.07 ms of a 9.8 ms draw. No bind-group churn, no per-frame allocation, and it uploads less than WebGL (2.1 MB vs 2.6 MB). The cost was plain JavaScript in the per-quad path.
The fix
Remember the last resolved texture and short-circuit. Invalidated when the slot is evicted (the table hands that index to a different texture), and bypassed when the caller forces a
reupload, since that means the resident record must be re-resolved.WebGPU goes from noticeably slower than WebGL 2 to marginally faster (5.71 against 6.14). This is the shared quad batcher, so it lifts sprites, text and tilemaps too — particles are just what surfaced it.
Measured on Apple silicon under headless Chromium with ANGLE/Metal; absolute numbers will differ elsewhere, but the removed work is unconditional.
Tests
6 added to
tests/webgpu_quad_batcher.spec.js: repeated texture resolves to the same slot, distinct textures do not collapse onto one,reuploadbypasses the memo, the memo does not survive a segment reset, an evicted slot is forgotten, andaddQuadstill routes quads to the right slot.Two mutations, each breaking a test: making the memo ignore
reupload, and dropping the eviction invalidation.A third mutation found dead code rather than a gap — an explicit clear in
resetSegmentthat no test could distinguish, becauseslotTable.reset()already evicts every live slot and the eviction callback handles it. Removed rather than left as untestable noise.Full suite 6335 passing / 262 files, root lint 0 errors.
🤖 Generated with Claude Code
https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N