Skip to content

impr: Less work on the shader generation happy path - #3231

Closed
iwoplaza wants to merge 1 commit into
software-mansion:mainfrom
iwoplaza:impr/faster-shader-generation
Closed

iwoplaza wants to merge 1 commit into
software-mansion:mainfrom
iwoplaza:impr/faster-shader-generation

Conversation

@iwoplaza

Copy link
Copy Markdown
Collaborator

Shader generation runs during an app's first frame, on code the JS engine hasn't optimized yet, so every bit of unneeded work there delays the first frame. These changes remove work the generator did even when nothing goes wrong, without changing the generated WGSL.

  • Reserved words: each namespace copied ~500 reserved WGSL words into its own Set. isIdentifierTaken now checks the shared sets directly.
  • Error messages: invariant messages that stringify AST nodes (e.g. on every numeric literal) are passed as functions, so they're only built when the check fails.
  • Externals: each external is coerced to a snippet once per function instead of on every reference. Function externals aren't copied when there are no arguments or outputs to merge in.
  • Conversion ranks: cached per pair of schemas in a WeakMap (schemas are immutable once created; the invariant is documented next to the cache).
  • Small helpers: stitch uses plain loops instead of filter/map/join. The var/let placeholders in a function body are filled with one fixed regex instead of one built per function. Memoized items are matched against slot values without copying the slot map into an array (and the duplicated check became one method).
  • Type guards and proxies: the hottest guards (isDecorated, isLooseDecorated, isPtr) test the cheap type property first, and texture views reuse their value proxy.

tgpu.resolve of the shaders of four docs examples in Node 22: median of interleaved runs in fresh processes, main and this branch built side by side. "First" is the first resolve in a process; "later" is the median of the following 29.

Example First: main → this PR Later: main → this PR
boids 23.1 → 19.6 ms (−15%) 2.72 → 2.02 ms (−26%)
stable-fluid 39.7 → 38.0 ms (−4%) 11.18 → 9.54 ms (−15%)
slime-mold-3d 53.5 → 42.1 ms (−21%) 12.78 → 10.80 ms (−15%)
jelly-slider 74.6 → 62.2 ms (−17%) 21.50 → 16.54 ms (−23%)

16 rounds per example, 30 for stable-fluid. Two identical builds measured the same way differ by ~1.5%. The generated WGSL is identical to main's for all 22 shader modules.

@iwoplaza
iwoplaza force-pushed the impr/faster-shader-generation branch 2 times, most recently from aa79f1e to 8aacd28 Compare October 20, 2026 17:20
Shader generation runs during an app's first frame, on code the JS engine hasn't optimized yet, so every bit of unneeded work there delays the first frame. These changes remove work the generator did even when nothing goes wrong, without changing the generated WGSL.

- **Reserved words:** each namespace copied ~500 reserved WGSL words into its own Set. `isIdentifierTaken` now checks the shared sets directly.
- **Error messages:** `invariant` messages that stringify AST nodes (e.g. on every numeric literal) are passed as functions, so they're only built when the check fails.
- **Externals:** each external is coerced to a snippet once per function instead of on every reference. Function externals aren't copied when there are no arguments or outputs to merge in.
- **Conversion ranks:** cached per pair of schemas in a `WeakMap` (schemas are immutable once created; the invariant is documented next to the cache).
- **Small helpers:** `stitch` uses plain loops instead of `filter`/`map`/`join`. The `var`/`let` placeholders in a function body are filled with one fixed regex instead of one built per function. Memoized items are matched against slot values without copying the slot map into an array (and the duplicated check became one method).
- **Type guards and proxies:** the hottest guards (`isDecorated`, `isLooseDecorated`, `isPtr`) test the cheap `type` property first, and texture views reuse their value proxy.

`tgpu.resolve` of the shaders of four docs examples in Node 22: median of interleaved runs in fresh processes, `main` and this branch built side by side. "First" is the first resolve in a process; "later" is the median of the following 29.

| Example | First: main → this PR | Later: main → this PR |
|---|---|---|
| boids | 23.1 → 19.6 ms (−15%) | 2.72 → 2.02 ms (−26%) |
| stable-fluid | 39.7 → 38.0 ms (−4%) | 11.18 → 9.54 ms (−15%) |
| slime-mold-3d | 53.5 → 42.1 ms (−21%) | 12.78 → 10.80 ms (−15%) |
| jelly-slider | 74.6 → 62.2 ms (−17%) | 21.50 → 16.54 ms (−23%) |

16 rounds per example, 30 for stable-fluid. Two identical builds measured the same way differ by ~1.5%. The generated WGSL is identical to `main`'s for all 22 shader modules.

- `typegpu` test suite passes (192 files, 2771 tests), along with typecheck, oxlint and oxfmt.
@iwoplaza
iwoplaza force-pushed the impr/faster-shader-generation branch from 8aacd28 to 43021ff Compare October 20, 2026 17:55
@iwoplaza
iwoplaza marked this pull request as ready for review October 20, 2026 17:56
@iwoplaza
iwoplaza requested review from aleksanderkatan, cieplypolar, reczkok and vende11s and a balanced review from Copilot October 20, 2026 17:56

@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

This PR removes avoidable work from the shader-generation hot path without changing emitted WGSL. I traced each behavioral change and ran the affected suites locally: packages/typegpu (195 files / 2921 tests) and @typegpu/gl (134 tests) both pass.

  • Reserved-word sets — namespace.ts now seeds takenGlobalIdentifiers empty; isIdentifierTaken checks bannedTokens/builtins/templateEnumerants directly. Those are the set's only readers (isIdentifierTaken and the reserveIdentifier write), so identifier allocation is unchanged.
  • External snippet caching — FunctionScopeLayer.externalSnippets caches coerceToSnippet(external) once per id. The layer is created fresh in pushFunctionScope, and block scopes are still scanned first, so shadowing is preserved. mergeFunctionExternals now returns base directly when args/out are both undefined; no consumer mutates externalMap (both getSnippetById and replaceExternalsInWgsl only read it), and the invariant is documented on the early return.
  • Conversion-rank cache — autoRankCache/implicitRankCache are keyed by (src, dest) per allowImplicit; every consumer (findBestType, applyActionToSnippet) treats the returned info as read-only, as the JSDoc requires.
  • Hot-loop rewrites — stitch loops, the fixed /#VAR_\d+#/g placeholder regex (which matches the #VAR_${size}# placeholders emitted at the two placeholderForVariable.set sites), and the extracted #slotsHaveValues are all equivalent to the code they replace.
  • Guard reordering — isLooseDecorated/isPtr/isDecorated test .type before isMarkedInternal; both are pure, so the outcome is unchanged for every input.
  • Texture value-proxy reuse — caching #valueProxy per instance only changes proxy identity; naming and resolution stay dynamic (getName(this) / ctx.resolve(this) at resolve time).

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

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.

🟡 Changes recommended

External snippet caching can freeze getter-backed values and change generated shader constants.

1 open issue
What changed in this PR

This PR reduces shader-generation overhead to improve first-frame latency, aiming to preserve generated WGSL.

Changes:

  • Caches conversion ranks, external snippets, and texture proxies.
  • Avoids reserved-word copies and eagerly formatted diagnostics.
  • Streamlines stitching, placeholder replacement, and memoization checks.
File Description
packages/​typegpu/​src/​types.ts Adds per-function external snippet storage.
packages/​typegpu/​src/​tgsl/​wgslGenerator.ts Defers diagnostics and simplifies placeholder replacement.
packages/​typegpu/​src/​tgsl/​conversion.ts Caches conversion ranks by schema pair.
packages/​typegpu/​src/​resolutionCtx.ts Caches externals and streamlines identifier and slot checks.
packages/​typegpu/​src/​data/​wgslTypes.ts Reorders hot type-guard checks.
packages/​typegpu/​src/​data/​dataTypes.ts Reorders the loose-decoration guard.
packages/​typegpu/​src/​core/​texture/​texture.ts Reuses texture-view proxies.
packages/​typegpu/​src/​core/​resolve/​stitch.ts Replaces array pipelines with loops.
packages/​typegpu/​src/​core/​resolve/​namespace.ts Avoids copying reserved identifiers.
packages/​typegpu/​src/​core/​resolve/​externals.ts Skips unnecessary external-map copies.

Review effort: Balanced

Comment on lines +230 to +233
const cached = layer.externalSnippets.get(id);
if (cached) {
return cached;
}
@iwoplaza

Copy link
Copy Markdown
Collaborator Author

Moved to #3240 (branch now lives on the main repo instead of the fork).

@iwoplaza iwoplaza closed this Oct 21, 2026
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