Repository navigation
perf(@typegpu/gl): cache linked programs per root - #3257
Kaleidosium wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
🟢 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
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Per-root linked-program cache —
TgpuRootWebGLnow keeps a#programCache: Map<string, WebGLProgram>.createRenderPipelinekeys onvertexGlsl + '\0' + fragmentGlsl, reuses an existing program for identical source pairs, and only callslinkProgram+ caches on a miss. Failed compilation/linking is never cached since the cache write happens afterlinkProgramreturns. - Teardown deletes cached programs —
destroy()iterates the cache, callsgl.deleteProgramon each entry, then clears it before the existing buffer/uniform/texture/sampler cleanup. Repeateddestroy()is a no-op for the cache. - Regression tests —
packages/typegpu-gl/tests/programCache.test.tscovers 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.
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.
There was a problem hiding this comment.
✅ 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.tsnow stamps a monotonic_id, so thetoEqual-based assertions inprogramCache.test.tsdistinguish 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.
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.
There was a problem hiding this comment.
✅ 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 —
linkPrograminpackages/typegpu-gl/src/tgpuRootWebGL.tsnow wraps fragment compilation and program create/attach/link intry/finally, sovert(andfragonce compiled) aredeleteShaderd even when fragment compilation orcreateProgramfails. The earlier revision leaked the already-compiled vertex shader on those paths;compileShaderself-deletes on its own compile failure, so theif (frag)guard avoids a double delete. - Failure-path tests expanded —
packages/typegpu-gl/tests/programCache.test.tsnow 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).
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…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.

Every
createRenderPipelineon aTgpuRootWebGLcompiles and links its shaders, even when an earlier pipeline produced exactly the same GLSL. Successfully linked programs were also never deleted, not even byroot.destroy().WebGLProgramis shared: each pipeline keeps its own VAO and its own uniform, texture and sampler bindings.root.destroy()deletes the cached programs.51 pipelines built from 3 distinct shader pairs:
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,
TgpuRootWebGLdoesn't handle context loss. After a loss, create a new root; reusing one across a restore hands stale programs to new pipelines.