Skip to content

INP at 10000²: scene repaint, fill and copy/save off the main thread, bench INP rows - #15

Merged
pathanin merged 11 commits into
masterfrom
perf/inp-bench-toggle-nmt3fo
Oct 18, 2026
Merged

pathanin merged 11 commits into
masterfrom
perf/inp-bench-toggle-nmt3fo

Conversation

@pathanin

@pathanin pathanin commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

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), and inp … rows that run heavy actions with real mouse and keyboard input. --only can 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):

    • The page sends an ImageBitmap snapshot, 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.
    • The worker still accepts a buffer, so a page from an older build keeps working.
    • CLAUDE.md now documents the snapshot and readback traps.
  • bench: second fill click (with damage) (9115da5): a row that guards kickFrame(). It runs one fill untimed, then times a second click that also changes something on screen (it toggles an outline on #status on 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 an ImageBitmap snapshot to blob-worker.js, which reads it back in strips and encodes it there. Canvases up to 2 MP stay on toBlob(), 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 to toBlob()'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):

inp (ms) inp200
kickFrame() removed 632, 608, 208 1 each run
kickFrame() present 128, 136 0

The 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.

pathanin and others added 11 commits October 18, 2026 04:18
… 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 pathanin changed the title INP at 10000²: scratch-tile pool + GPU fence, fill readback in the worker, bench INP rows INP at 10000²: scene repaint, fill and copy/save off the main thread, bench INP rows Oct 18, 2026
@pathanin
pathanin marked this pull request as ready for review October 18, 2026 10:54
@pathanin
pathanin merged commit 36bf4c3 into master Oct 18, 2026
3 checks passed
@pathanin
pathanin deleted the perf/inp-bench-toggle-nmt3fo branch October 18, 2026 10:59
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.

1 participant