Skip to content

perf(@typegpu/gl): cache linked programs per root - #3257

Open
Kaleidosium wants to merge 3 commits into
software-mansion:mainfrom
Kaleidosium:cache-webgl-programs
Open

Kaleidosium wants to merge 3 commits into
software-mansion:mainfrom
Kaleidosium:cache-webgl-programs

Conversation

@Kaleidosium

@Kaleidosium Kaleidosium commented Oct 22, 2026 •

Copy link
Copy Markdown

Every createRenderPipeline on a TgpuRootWebGL compiles and links its shaders, even when an earlier pipeline produced exactly the same GLSL. Successfully linked programs were also never deleted, not even by root.destroy().

  • Linked programs are cached per root, keyed on the full vertex + fragment source. Only the WebGLProgram is shared: each pipeline keeps its own VAO and its own uniform, texture and sampler bindings.
  • A failed compile or link isn't cached, so the next attempt tries again.
  • root.destroy() deletes the cached programs.
  • The GL test mocks now give each resource a unique id, so assertions check which program, texture or sampler was used, not just how many calls were made.

51 pipelines built from 3 distinct shader pairs:

main this PR
Programs linked 51 3
Shaders compiled 102 6

In a downstream app on an Apple M4 (Chromium, time from navigation to two frames after ready, medians of 5 fresh-context runs), this went from 837 to 769 ms at DPR 1 and from 2388 to 2312 ms at DPR 3. That's a small local sample, not a general claim.

#3095 moves pipeline construction into another module, so one of the two will need a rebase.

Merge Danger

Door: two-way

The cache can be reverted without data or API migration.

Blast Radius: WebGL

The cache owns programs for the root's lifetime, retaining one entry per unique generated shader pair until destroy(). Applications generating many distinct shader variants will retain those keys until teardown. Binding state remains per pipeline; WebGPU is unchanged. Physical phones, other browser engines and long-duration memory behavior have not been measured.

Like the WebGPU root with device loss, TgpuRootWebGL doesn't handle context loss. After a loss, create a new root; reusing one across a restore hands stale programs to new pipelines.

Copilot AI balanced review requested due to automatic review settings October 22, 2026 10:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The focused change preserves pipeline-local bindings and failure handling, with regression coverage and no identified blocking issues.

No open issues 🎉

What changed in this PR

This PR reduces redundant WebGL shader compilation and linking by caching programs within each TgpuRootWebGL, without changing the public API.

Changes:

  • Reuses linked programs for identical complete vertex/fragment sources while preserving per-pipeline bindings.
  • Deletes cached programs on root teardown; failed compilation or linking attempts are not cached.
  • Adds regression tests for reuse, binding independence, root isolation, failure retries, and repeated teardown.
File Description
packages/​typegpu-gl/​tests/​programCache.test.ts Adds five regression tests for program caching and lifecycle behavior.
packages/​typegpu-gl/​src/​tgpuRootWebGL.ts Adds the root-local program cache and teardown cleanup.

Review effort: Balanced

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Per-root linked-program cache — TgpuRootWebGL now keeps a #programCache: Map<string, WebGLProgram>. createRenderPipeline keys on vertexGlsl + '\0' + fragmentGlsl, reuses an existing program for identical source pairs, and only calls linkProgram + caches on a miss. Failed compilation/linking is never cached since the cache write happens after linkProgram returns.
  • Teardown deletes cached programs — destroy() iterates the cache, calls gl.deleteProgram on each entry, then clears it before the existing buffer/uniform/texture/sampler cleanup. Repeated destroy() is a no-op for the cache.
  • Regression tests — packages/typegpu-gl/tests/programCache.test.ts covers 51 pipelines collapsing to 3 programs / 6 shader compiles with exactly-once deletion, shared programs with independent uniforms/textures/samplers across alternating draws, no cross-root sharing, and retry after both compile and link failures.

Sharing a linked program is sound here: every draw re-uploads uniforms and re-binds textures/samplers, and each pipeline owns its VAO and crossShaderStageState-derived location bindings. The cache key is the exact GLSL that gets compiled, so a hit can only reuse a semantically identical program. Verified locally: pnpm --filter @typegpu/gl exec vitest run → 139 passed, 2 skipped.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Mock programs, textures, samplers and other GL objects were structurally identical, so toEqual-based assertions could not tell one from another. A destroy() that deleted the same program repeatedly, or pipelines sharing one another's texture/sampler bindings, passed the program cache tests. Unique ids make those assertions check identity.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Reviewed the one commit added since the prior pullfrog review — a test-only strengthening of the GL mock utilities.

  • Mock resources carry unique ids — every factory in packages/typegpu-gl/tests/utils/extendedTest.ts now stamps a monotonic _id, so the toEqual-based assertions in programCache.test.ts distinguish one program/texture/sampler from another instead of comparing structurally-identical { _type } objects. No production code changed; confirmed no other GL test deep-compares these mocks against a _type-only expectation.

Verified locally: pnpm --filter @typegpu/gl exec vitest run → 139 passed, 2 skipped.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Release compiled shaders when fragment compilation or resource allocation
fails. Strengthen program cache tests for program selection, binding
independence, texture orientation, failure recovery, and teardown.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Reviewed the one commit added since the prior pullfrog review — it closes a shader leak on pipeline-creation failure and strengthens the failure-path tests.

  • Shaders released on every failure path — linkProgram in packages/typegpu-gl/src/tgpuRootWebGL.ts now wraps fragment compilation and program create/attach/link in try/finally, so vert (and frag once compiled) are deleteShaderd even when fragment compilation or createProgram fails. The earlier revision leaked the already-compiled vertex shader on those paths; compileShader self-deletes on its own compile failure, so the if (frag) guard avoids a double delete.
  • Failure-path tests expanded — packages/typegpu-gl/tests/programCache.test.ts now parametrizes over vertex/fragment compilation failure, vertex/fragment/program allocation failure, and a failed source pair leaving an already-cached program usable; each asserts the failed shaders are released.

Verified locally: pnpm --filter @typegpu/gl exec vitest run → 146 passed, 2 skipped (9 files).

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Kaleidosium added a commit to Kaleidosium/TypeGPU that referenced this pull request Oct 22, 2026
…haring

The test assumed a second pipeline built from the same descriptor links a second program. With a program cache (software-mansion#3257) both pipelines share one, so the lookup failed. Check that every upload targets a location queried on the program bound for that draw, and give the second pipeline a different fragment shader so the per-program check stays meaningful.

This branch has not been deployed

No deployments
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.

2 participants