Repository navigation
INP at 10000²: scene repaint, fill and copy/save off the main thread, bench INP rows - #15
Merged
Merged
Conversation
…ons with real input
… on the GPU at a time At 10000² with 5k objects selected, each arrow press repainted ~360 tiles: ~135 ms of GPU work through one shared scratch, and presses stacked it (INP 864 ms). Composing runs of 16 tiles on a pool of scratches halves the GPU time, and a WebGL2 fence after each big repaint holds the next one (and background slices) until the GPU has done it, so held keys merge into one repaint per round. Nudge INP 864 -> 88 ms; 5k rebuild (rotate) frame 150 -> 50 ms.
getImageData of the whole picture took ~240 ms of the click at 10000². The page now sends createImageBitmap(composite()) and the worker reads it on an OffscreenCanvas. The worker still accepts a buffer, for a page from an older build. Fill click INP at 10000² 312 -> 32 ms.
…orker createImageBitmap(#image) leaves GPU work queued that only a frame with a changed canvas sends off, so the click's frame waited until the fill landed (~310 ms at 10000²). kickFrame() (view.js) makes that change with a transparent 1 px draw on the preview. The worker reads the bitmap back in 2 MP strips cut on the GPU (one 400 MB readback held the GPU process for ~100 ms), and the exact-colour shortcut reads the clicked pixel through a 1 px canvas, not #image itself. Held big repaints now wait apart from new small ones, so a stroke never waits for the fence. CLAUDE.md: the scratch pool, the fence and the fill snapshot.
… the page; CLAUDE.md: the snapshot and readback traps
…ead back in strips toBlob() on #image read the whole 10000² canvas back on the main thread inside the keydown (~180 ms, plus a 46 ms encode setup), and save with objects did it again for the base canvas. encodeBlob() (libs.js) takes a createImageBitmap snapshot, kicks a frame, and transfers it to a worker per call, which reads it back in 2 MP strips into a CPU canvas and encodes there; toBlob() on the source if the worker fails. Posting the bitmap untransferred serializes it, which is the same readback on the main thread. Bench at 10000² (3 alternated runs, medians): copy INP 152-160 -> 32, task 306 -> 9 ms; save INP 152-160 -> 32, task 606 -> 51 ms; longMax ~155 -> 0. libs.js: a 3000x2400 soft-alpha PNG encoded in the worker equals toBlob's bytes (a 1-row strip shift fails it), and saving with the worker blocked takes the built-in path.
…'s kickFrame() After the fill's snapshot of #image, the compositor holds the next frame until a canvas changes again. Only a second or later fill in a page shows it, and only when the click also changes something on screen, so this row runs one fill untimed, changes the colour, and times a second click that toggles an outline on #status on pointerdown (this row only: on every row the toggle released held frames and hid the regression instead). Measured at 10000² (1 run each, headless Chromium, software GPU): kickFrame() removed: inp 632, 608, 208 ms -> inp200 1 kickFrame() present: inp 128, 136 ms -> inp200 0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013pqKTN4jC7Z9ptpugs1h3g
…he scene's base PNG encodes while the picture's does Save at 10000² with objects now finishes in ~560 ms (was ~600 before the worker, ~860 with the two encodes in a row). libs.js tests the blocked-worker fallback at 3 MP, past the small size.
A ⌘C press is two interactions (the Meta keydown, then the c keydown), and both wait for the frame that c's handler holds up. Their 8 ms-rounded durations tie, the sort kept the Meta one first, and its main part (~0) was reported: 0.4 ms for the 10000² copy and save on 18d061e, whose keydown ran 177-208 ms. Now 179 / 136 there, and ~0-1 at the tip, where the encode is in a worker.
pathanin
marked this pull request as ready for review
October 18, 2026 10:54
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.
Brings the worst interactions at 10000×10000 under 200 ms, and adds benchmark rows that measure INP with real input.
Changes
bench: INP per row (
19f2174,cd452dc): Event Timing for each row (ints,inp,inpMain,inp200,longMax), andinp …rows that run heavy actions with real mouse and keyboard input.--onlycan now pick a single inp row.Scene repaint (
b48d27f): tiles are composed on a pool of scratch canvases, and a WebGL2 fence allows one big repaint on the GPU at a time. Arrow nudge with 5k objects selected: INP 864 → 88 ms.Fill (
e5b12bb,f788fd4,18d061e):ImageBitmapsnapshot, and the worker reads it back in 2 MP strips cut on the GPU. Fill click INP: 312 → 32 ms.kickFrame()stops the compositor from holding the frame after the snapshot.bench: second fill click (with damage) (
9115da5): a row that guardskickFrame(). It runs one fill untimed, then times a second click that also changes something on screen (it toggles an outline on#statuson pointerdown). Only that combination shows the held frame. Putting the toggle on every row hid the regression instead, so only this row has it.Copy and save (
b2a41a2,75dca2c):toBlob()read the whole 10000² canvas back on the main thread inside the keydown (~180 ms), and a save with objects did it again for the base.encodeBlob()(libs.js) now transfers anImageBitmapsnapshot toblob-worker.js, which reads it back in strips and encodes it there. Canvases up to 2 MP stay ontoBlob(), and the base's PNG encodes in parallel with the picture's. At 10000² (3 alternated runs, M4): copy INP 152–160 → 32 ms, save 152–160 → 32 ms; main-thread time 305 → 9 ms (copy) and 607 → 51 ms (save). Copy now takes ~65 ms longer to reach the clipboard, and save finishes ~40 ms sooner. libs.js checks that the worker's PNG is byte-identical totoBlob()'s (a 1-row strip shift fails it) and checks the fallback.bench: inpMain (
a6dd485): it read ~0.4 ms on copy and save while the keydown handler ran 177–208 ms. It now takes the interactions presented in the worst one's frame.Checking the new row
10000², one run each, headless Chromium on a software GPU (cloud container):
kickFrame()removedkickFrame()presentThe 208 ms run sits just over the 200 ms line, so this should be confirmed on real hardware (M4, installed Chrome) before relying on it.
Tests
On the merged tip (
09d6a82, M4, installed Chrome): unit 80, smoke 293, history 86, preview 40, filledge 13, objects 185, alpha 105, libs 68, pwa 16, all passing.Against master (alternated runs), the old 10000² copy/save gaps were noise; no regression.