Skip to content

impr: Smaller, faster to load function metadata (v3) - #3246

Draft
iwoplaza wants to merge 1 commit into
impr/metadata-v3-runtimefrom
impr/metadata-v3
Draft

iwoplaza wants to merge 1 commit into
impr/metadata-v3-runtimefrom
impr/metadata-v3

Conversation

@iwoplaza

@iwoplaza iwoplaza commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Every 'use gpu' function carries metadata (AST, externals, name) that the WGSL generator needs. In v2 the plugin registered it with an IIFE per function, and stored the AST as an object literal:

(/*#__PURE__*/($ => (globalThis.__TYPEGPU_META__ ??= new WeakMap()).set($.f = ((a, b) => {…}), {
  v: 2, name: "cross",
  ast: {"params":[{"type":"i","name":"a"},{"type":"i","name":"b"}],"body":[0,[[10,[1,"a","*",[5,"2"]]]]]},
  externals: {}
}) && $.f)({}))

v3 declares a helper once per module, and keeps the AST in a JSON string:

const __tgpu_meta = (f, ...m) => ((globalThis.__TYPEGPU_META__ ??= new WeakMap()).set(f, m), f);
(/*#__PURE__*/__tgpu_meta((a, b) => {…}, '[["a","b"],[[10,[1,"a","*",2]]]]', {}, "cross"))

Changes

  • Shared helper: one declaration per module (after the hashbang and directive prologue) replaces the IIFE wrapper on every function. A minifier inlines it into modules that register only one function.
  • JSON string: engines only scan string literals when loading a script. typegpu parses the AST the first time the function is used (impr: Read v3 function metadata #3245).
  • Slim fields: params are plain strings, numeric literals are plain numbers (impr: Allow numeric literals as bare numbers in the AST #3244), there's no version field, and name is omitted when the function has no name.
  • No parentheses around the function: v2 wrapped it ($.f = (…)). Minifiers keep those parentheses, because V8 takes them as a hint to compile the function eagerly at load. Most 'use gpu' functions never run on the CPU, so that was wasted work.
  • Externals are unchanged, one thunk per key. Since feat: Expand property access in externals #2425 they can be property paths (ext.config.zero), and evaluating them all at once could throw where the original code wouldn't.
  • Already transformed code: the plugin recognizes both v2 and v3 output and doesn't transform it again (e.g. prebuilt libraries). In v3 it identifies the helper by what the callee is bound to, because minifiers rename or inline it.
  • Babel and unplugin both emit v3. Docs (explaining-the-magic.mdx) are updated.

Needs a typegpu version that reads v3 (#3245), so the two have to be released together.

Measurements

Four docs examples with the most 'use gpu' functions, as standalone Vite 8 apps, each built into one minified chunk (the lab is in #3248). Compared against this PR's parent (#3245).

Bundle size (bytes, minified / gzip / brotli):

App Before After Change
monocular-light-injection (127 fns) 524,945 / 125,471 / 104,508 505,710 / 124,788 / 104,119 −19,235 / −683 / −389
selfie-segmentation (43 fns) 366,069 / 96,479 / 80,879 359,922 / 96,240 / 80,688 −6,147 / −239 / −191
os-awards (121 fns) 476,677 / 125,278 / 104,398 459,476 / 124,605 / 104,049 −17,201 / −673 / −349
jelly-slider (103 fns) 445,482 / 114,906 / 96,101 429,710 / 114,127 / 95,538 −15,772 / −779 / −563

The compressed gain is small. Metadata is 6–16% of these gzipped bundles, but gzip already removes most of the repetitive syntax. What's left is information: identifiers make up about 40% of the metadata's gzip cost, and tree structure most of the rest. Builds with unstable_obfuscate + unstable_minify save 213–553 B gzip.

Load time: importing each whole production bundle in a fresh Node 24 process. Without WebGPU the apps stop at tgpu.init(), which runs after all module-level code, so every function's metadata has been registered by then. Median of 31 interleaved runs. Two identical builds measured the same way differ by up to 2%.

App Before After
monocular-light-injection 24.80 ms 22.13 ms (−11%)
selfie-segmentation 13.38 ms 11.87 ms (−11%)
os-awards 16.85 ms 15.34 ms (−9%)
jelly-slider 15.44 ms 13.63 ms (−12%)

Metadata alone, including first use: each app's metadata and JS implementations as a standalone script, in v2 and in v3. A fresh Node process compiles and runs it, then reads every function's metadata the way getFunctionMetadata does (v3 parses the JSON here). Median of 21 runs.

App v2 v3 Of which reading Heap before first use
monocular-light-injection 6.98 ms 4.28 ms (−39%) 1.58 → 2.25 ms 1,617 → 569 KiB
selfie-segmentation 1.95 ms 1.40 ms (−28%) 0.53 → 0.73 ms 505 → 224 KiB
os-awards 4.39 ms 2.99 ms (−32%) 1.31 → 1.68 ms 1,082 → 432 KiB
jelly-slider 4.08 ms 2.71 ms (−34%) 1.12 → 1.49 ms 1,037 → 389 KiB

Removing the parentheses accounts for 0.2–1.2 ms of that (v2 without them: 5.83, 1.73, 4.00, 3.62 ms).

The generated WGSL is unchanged. All docs example tests pass. In Chromium, every shader module of the four apps is byte-identical to main's (91 modules in monocular-light-injection).

Alternatives

Each candidate was produced by re-encoding the metadata in the built bundles, then measuring the resulting bundle. Gzip change vs v2 for monocular-light-injection / jelly-slider, and the share of the metadata's gzip cost (20.4 / 13.0 kB) it removes:

Format Gzip Share
Shared helper only −281 / −270 B 1–2%
+ slim fields −744 / −788 B 4–6%
+ plain numbers −977 / −970 B 5–7%
+ AST as a JSON string (this PR) −928 / −938 B 5–7%
Numbers kept as [5, 0.5] nodes −753 / −812 B 4–6%
Externals referenced by index −909 / −793 B 4–6%
Prefix encoding (no brackets or quotes) −3,023 / −2,639 B 15–20%
  • The JSON string costs ~50 B gzip more than the plain object literal, but loads 30–40% faster and keeps less in memory until first use.
  • The prefix encoding compresses best, but needs a custom decoder of ~0.76 kB gzip, which cancels most of the gain for smaller apps (selfie-segmentation would save ~0.1 kB net). Its first read is 1.7–2× slower than v2's. It could replace the JSON string later without changing anything else in the format.

Each module declares a `__tgpu_meta` helper once, and every 'use gpu' function
is registered with `__tgpu_meta(fn, json, externals, name?)`:
- the AST is a JSON string (params as plain strings, bare numeric literals)
- the function isn't wrapped in parentheses, so V8 compiles it lazily
- already transformed code is recognized in both the v2 and v3 shapes
@iwoplaza
iwoplaza added this pull request to stack #3249 October 21, 2026 10:12
@iwoplaza iwoplaza changed the title impr: Emit v3 function metadata impr: Smaller, faster to load function metadata (v3) Oct 21, 2026
@github-actions

Copy link
Copy Markdown

pkg.pr.new

packages
Ready to be installed by your favorite package manager ⬇️

https://pkg.pr.new/software-mansion/TypeGPU/tinyest@96db22146cd8ec9daa37757241219cddc64e0388
https://pkg.pr.new/software-mansion/TypeGPU/typegpu@96db22146cd8ec9daa37757241219cddc64e0388
https://pkg.pr.new/software-mansion/TypeGPU/unplugin-typegpu@96db22146cd8ec9daa37757241219cddc64e0388

benchmark
view benchmark

commit
view commit

@github-actions

Copy link
Copy Markdown

Resolution Time Benchmark

---
config:
  themeVariables:
    xyChart:
      plotColorPalette: "#E63946, #3B82F6, #059669"
---
xychart
  title "Random Branching (🔴 PR | 🔵 main | 🟢 release)"
  x-axis "max depth" [1, 2, 3, 4, 5, 6, 7, 8]
  y-axis "time (ms)"
  line [0.91, 1.90, 4.24, 7.14, 7.55, 12.01, 24.06, 24.21]
  line [0.86, 1.81, 4.13, 6.52, 8.05, 12.91, 25.61, 24.96]
  line [0.94, 1.87, 4.11, 6.49, 6.86, 11.84, 24.51, 25.87]
Loading
---
config:
  themeVariables:
    xyChart:
      plotColorPalette: "#E63946, #3B82F6, #059669"
---
xychart
  title "Linear Recursion (🔴 PR | 🔵 main | 🟢 release)"
  x-axis "max depth" [1, 2, 3, 4, 5, 6, 7, 8]
  y-axis "time (ms)"
  line [0.30, 0.50, 0.75, 0.90, 1.12, 1.19, 1.40, 1.65]
  line [0.34, 0.52, 0.66, 0.75, 1.03, 1.11, 1.31, 1.51]
  line [0.34, 0.53, 0.70, 0.86, 1.08, 1.16, 1.38, 1.52]
Loading
---
config:
  themeVariables:
    xyChart:
      plotColorPalette: "#E63946, #3B82F6, #059669"
---
xychart
  title "Full Tree (🔴 PR | 🔵 main | 🟢 release)"
  x-axis "max depth" [1, 2, 3, 4, 5, 6, 7, 8]
  y-axis "time (ms)"
  line [0.91, 2.08, 3.95, 7.00, 12.75, 26.97, 56.83, 114.95]
  line [0.95, 2.25, 3.92, 6.85, 12.35, 25.68, 55.96, 112.79]
  line [0.88, 2.11, 4.28, 6.93, 12.01, 26.11, 54.07, 110.49]
Loading

@github-actions

Copy link
Copy Markdown

Bundle size comparison (import * as ... in PR vs import * as ... in target):

🟢 Decreased (max -23.94%) ➖ Unchanged 🔴 Increased ❔ Unknown
1 351 0 0

import * as ... in PR vs import * as ... in target (did bundle size increase?):

Test tsdown
STATIC_useGpuFunction.ts 197 B ($${\color{green}-23.9\%}$$)

If you wish to run a comparison for other, slower bundlers, run the 'Tree-shake test' from the GitHub Actions menu.

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.

1 participant