Skip to content

perf(textures): bound main-thread image decode concurrency#131

Merged
chiefcll merged 2 commits into
mainfrom
perf/image-decode-concurrency-gate
Jul 17, 2026
Merged

perf(textures): bound main-thread image decode concurrency#131
chiefcll merged 2 commits into
mainfrom
perf/image-decode-concurrency-gate

Conversation

@chiefcll

@chiefcll chiefcll commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What

Adds a ConcurrencyGate (small FIFO semaphore) that caps how many image fetch+decode operations run at once, plus a new imageDecodeConcurrency renderer setting (default 4, 0 disables). The gate is applied only when there is no image worker manager — i.e. numImageWorkers === 0, or workers/createImageBitmap are unsupported.

This is the first main-thread decode-concurrency bound this renderer has ever had. It is not a restoration of prior behavior — see history below.

Why

On devices without image workers (e.g. LG WebOS 3.x, where createImageBitmap can't be polyfilled inside a Worker so numImageWorkers must stay 0), every image fetch+decode runs on the main thread.

loadTexture awaits getTextureData() (fetch + createImageBitmap decode) eagerly and independently per node (each CoreNode fires it via queueMicrotask), with no concurrency ceiling. A scroll that makes many image nodes renderable in a single tick therefore kicks off dozens of main-thread decodes back-to-back, starving the render loop and causing frame jank.

The existing uploadTextureQueue does not help here — it throttles the GPU upload step (processUntil time budget), which sits downstream of the expensive decode. The decode has already happened by the time a texture reaches that queue.

History (why "first ever", not "restored")

Eager per-node decode in loadTexture has been present since the initial release (1.0.x), so decode concurrency on the non-worker path has been unbounded from day one. A prefetchLimit = Math.max(1, numImageWorkers) window existed in processSome from 1.1.1 (#12) to 1.5.3, but it governed upload-queue overlap, not the decode fan-out — by the time the queue drained, loadTexture had already populated textureData, so that window's getTextureData() was a no-op. It was removed in 1.5.4 (#104), which did not change decode concurrency. No released version has ever bounded main-thread decode concurrency; this PR introduces it.

How

  • ConcurrencyGate: acquire()/release() semaphore. A released slot is handed straight to the next FIFO waiter, so the active count never dips below limit while work is pending.
  • Created in CoreTextureManager.initialize() only when imageWorkerManager === null && imageDecodeConcurrency > 0. With workers, the pool already bounds concurrency, so the gate stays null (fully inert).
  • loadTexture() acquire()s before getTextureData() and release()s in a finally. Priority (on-screen) textures bypass the gate so they never wait behind off-screen prefetch decodes. The gate wraps only the decode; the per-frame upload budget is untouched.
  • Plumbed imageDecodeConcurrency through RendererMainSettingsStageCoreTextureManager (TextureManagerSettings).

Usage (WebOS 3.x)

new RendererMain({
  numImageWorkers: 0,        // forced on WebOS 3.x
  imageDecodeConcurrency: 4, // main-thread decode ceiling (this change)
});

Set to 0 to opt out (unbounded — current behavior). Tune 3–6 against on-device scroll jank.

Reviewer notes

  • No behavior change on worker-enabled devices (the default): numImageWorkers defaults to 2, so decodeGate is null and the code path is a no-op.
  • Scope: the gate bounds the createImageBitmap decode (inside getTextureData). On the <img> fallback path (hasCreateImageBitmap === false), the image decodes lazily and the real main-thread cost is the synchronous texImage2D at upload, which the gate does not cover — the per-frame upload budget (processUntil) paces that instead. So the gate is most effective on the createImageBitmap(-polyfill) path, which is the WebOS 3.x case.
  • The gate paces, it does not cancel. Texture cleanup is lazy (memory-pressure driven, not scroll-driven), so a fast scroll through a long list still eventually decodes everything that entered preload bounds — the gate just spreads it out. Pair with preloadBound tuning to cap how many decodes get triggered in the first place.
  • Scope is deliberately narrow — this caps how many decodes overlap, not the cost of each. Compressed textures / right-sizing sources remain the larger levers.
  • Not observable in the browser preview: inert under the dev-server defaults, and it's a main-thread scheduling change rather than a visual one. Verified via unit tests instead.

Tests

  • New src/core/CoreTextureManager.test.ts — 6 tests for ConcurrencyGate: immediate resolution up to limit, blocking past limit, FIFO slot hand-off, release-with-no-waiters, no ceiling below limit, and a burst test asserting peak concurrency never exceeds the limit.
  • Full unit suite green (337 passed); tsc --build clean; lint clean (0 errors).

🤖 Generated with Claude Code

chiefcll and others added 2 commits July 17, 2026 07:33
On devices without image workers (numImageWorkers === 0, e.g. LG WebOS
3.x where createImageBitmap can't be polyfilled inside a Worker), every
image fetch+decode runs on the main thread. Since 1.6.0, getTextureData
runs eagerly and independently per node inside loadTexture, with no
concurrency ceiling — so a scroll that makes many image nodes renderable
in one tick fires dozens of decodes back-to-back and starves the render
loop. The existing upload queue only throttles the GPU upload step, which
is downstream of the expensive decode.

Add a ConcurrencyGate (small FIFO semaphore) that caps how many
fetch+decode operations run at once, applied only when there is no image
worker manager (with workers, the pool already bounds concurrency).
Priority/on-screen textures bypass the gate so they never wait behind
off-screen prefetch. The gate wraps only the decode; the per-frame upload
budget is untouched.

Exposed as the `imageDecodeConcurrency` renderer setting (default 4; 0
disables). Inert on worker-enabled devices.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Note that the concurrency gate bounds the createImageBitmap decode inside
getTextureData; on the <img> fallback the real main-thread cost is the
synchronous texImage2D at upload, which the per-frame upload budget paces
instead. Prevents a future reader from expecting the gate to help that path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@chiefcll
chiefcll merged commit b19b5bd into main Jul 17, 2026
3 checks passed
@chiefcll
chiefcll deleted the perf/image-decode-concurrency-gate branch July 17, 2026 12:58
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