Visitar URL original
perf(core, engine): reduce allocations when writing shader module uniforms by athomct · Pull Request #3421 · visgl/luma.gl · GitHub
Skip to content

perf(core, engine): reduce allocations when writing shader module uniforms - #3421

Open
athomct wants to merge 6 commits into
visgl:masterfrom
chesapeake-tech:perf/engine-write-changed-uniforms
Open

athomct wants to merge 6 commits into
visgl:masterfrom
chesapeake-tech:perf/engine-write-changed-uniforms

Conversation

@athomct

@athomct athomct commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Background

When an application draws many small models per frame, the per-draw CPU cost of uniform handling adds up. Model.draw() calls updateShaderInputs(), which passes the uniforms of every shader module to the model's UniformStore at every draw, and deck.gl calls ShaderInputs.setProps() for every module of every layer at every draw. A sampling heap profile of 500 deck.gl layers showed that most of the memory allocated per frame came from these two paths. Most of it was temporary arrays and strings that the result does not need.

This replaces the first version of this PR, which added per-module revision counters and wrote only changed modules. As requested in review, this version keeps the existing Model -> UniformStore update path and behavior. It only removes allocations.

Change List

  • UniformStore.setUniforms(), UniformBlock.setUniforms(), ShaderBlockWriter flattening and serialization, and ShaderInputs uniform merging used Object.entries(), which allocates an array per key. These loops now use for...in.
  • ShaderBlockWriter built every leaf path name (lights[0], lights[0].color, ...) again at every flatten. It now keeps the names it has built.
  • UniformStore serialized a changed block with getData(). That flattened the block's values again, although UniformBlock already holds them flattened, and it created four typed-array views of the scratch buffer each time. The new ShaderBlockWriter.getFlatData() serializes values that are already flat. The views are created again only when the scratch buffer changes. getData() is now getFlatData(getFlatUniformValues(values)).
  • When an array of structs was merged, the current array was copied twice. It is now copied once.
  • cloneModuleUniformValue() cloned structs with Object.fromEntries(Object.entries().map()). It now uses a loop.
  • ShaderInputs.getBindingValues() no longer uses Object.values().
  • Docs: getFlatData() in the ShaderBlockLayout page.

What does not change: Model still passes every module to UniformStore. UniformStore still compares each value with its own copy. ShaderInputs.setProps() still makes a new uniforms object for each module it is given, with copied values. So a value changed in place in the object returned by getUniformValues() still reaches the GPU at the next updateShaderInputs().

Measurements

Setup: Chrome with ANGLE on Metal, Apple M1 Max, luma.gl 9.4.0 and deck.gl 9.4.0 (the changed code is the same on master). The page has 500 deck.gl layers of one type, each with 1–2 features, and the view pans every frame. The metric is the CPU time of deck's frame callback (Deck._onRenderFrame). A runtime flag in the same page switches the change off and on, and the two arms alternate. Each value is the median of the paired runs, with the range in brackets. Run A has 5 pairs and run B has 7.

scene run A before (ms) run A after (ms) run B before (ms) run B after (ms)
500 ScatterplotLayers 10.72 (10.64–11.23) 10.15 (10.08–10.39) 10.19 (10.09–10.47) 9.63 (9.38–9.87)
500 ScatterplotLayers + pickObject() each frame 19.22 (18.85–19.58) 18.64 (18.23–18.73) 18.61 (18.35–19.00) 17.96 (17.69–18.19)
500 PathLayers 10.03 (9.91–10.33) 9.39 (9.26–9.81) 9.71 (9.66–10.01) 9.26 (8.99–9.38)
500 PathLayers + pickObject() each frame 20.17 (20.06–20.41) 19.30 (19.07–19.80) 19.29 (19.14–19.37) 18.80 (18.46–19.03)
500 SolidPolygonLayers 17.65 (17.44–17.90) 15.31 (15.23–15.53) 17.45 (16.96–17.80) 15.10 (14.77–15.56)
500 SolidPolygonLayers + pickObject() each frame 31.95 (31.43–32.51) 26.50 (26.32–26.52) 31.19 (30.53–33.32) 25.83 (25.46–26.18)

SolidPolygonLayer gains most because its lighting uniforms are an array of structs. That makes many leaf paths and nested objects per draw. In a sampling heap profile over 60 frames, the bytes allocated fell from 865 MB to 500 MB for the polygon scene and from 523 MB to 327 MB for the scatterplot scene.

The first version of this PR (writing only changed modules) saved more in the polygon scene without picking (17.3 -> 11.9 ms, measured with other fixes applied). It saved almost nothing with picking. This version saves less without picking and more with picking. The two could be combined later if revision tracking is still wanted.

Verification

  • Node 24.14.0, yarn lint: passed. yarn build: passed.
  • yarn bundle-size: @luma.gl/core is 100,994 B minified (budget 101,000 B; master is 100,751 B). The first push of this version was 157 B over the budget, so a second commit merges the two name helpers into one and creates the scratch views in getFlatData() itself. The measurements above were taken before that size change, which does not change behavior.
  • yarn test-node: 4,189 passed, 8 skipped (497 files).
  • Headless Chromium, full npx vitest run --project headless --no-coverage: 381 files, 2,749 passed, 23 skipped.
  • New test Model#updateShaderInputs writes uniform values changed in place (null device): it changes a scalar, and then an array element twice, in the object returned by getUniformValues(), and checks the bytes written to the uniform buffer after each updateShaderInputs(). It also checks that a call with nothing changed writes nothing. The test fails if UniformBlock stores the value without copying it. It would also fail with the first version of this PR.
  • New test ShaderBlockWriter flattens and serializes repeated values consistently: leaf names are built again correctly for different values, getFlatData() gives the same bytes as getData(), and data from an earlier serialization is cleared. The test fails if the name cache ignores the parent name, or if the scratch buffer is not cleared.

CI on the first version

Browser coverage (3/3) failed in Model#updateShaderInputs writes only changed modules with GLSL compilation errors ... WebGL did not provide a shader compiler log. The log shows that the GPU process failed (GPU state invalid after WaitForGetOffsetInRange) just after the WebGPU test Model#draw skips WebGPU render pipelines that failed init. The new test was the next one to compile a WebGL shader, so its compile failed with no log. Aggregate test coverage failed only because that shard failed. That test is removed with the revision tracking. The new Model test uses the null device and does not compile shaders.


Note

Low Risk
Behavior-preserving performance work with expanded tests for flattening, serialization, and own-property iteration; no change to which modules or uniforms are updated each draw.

Overview
Reduces per-frame CPU allocations on the uniform path (UniformStore, ShaderBlockWriter, ShaderInputs, UniformBlock) without changing the existing update semantics.

Serialization: Adds ShaderBlockWriter.getFlatData() for already-flattened values; UniformStore.getUniformBufferData() uses it instead of re-flattening via getData(). Scratch typed-array views are recreated only when the scratch buffer changes. Leaf path strings (lights[0].position, etc.) are cached per writer instance.

Iteration: Hot loops switch from Object.entries() / Object.values() to for...in with Object.hasOwn(), matching prior Object.entries() behavior for own properties only (including struct fields named like Object.prototype members).

ShaderInputs: Struct/array merge and clone use the same own-property iteration; array-of-struct merges copy the current array once.

Docs add getFlatData() to the shader-block-layout API page. New tests cover flat serialization, inherited enumerable properties, and in-place uniform updates through Model#updateShaderInputs.

Reviewed by Cursor Bugbot for commit 62ca83b. Bugbot is set up for automated code reviews on this repo. Configure here.

Model.draw() calls updateShaderInputs(), which passed the uniforms of
every shader module to the model's UniformStore. The store flattened
each module's values and compared them leaf by leaf with the values of
its uniform blocks before finding that most had not changed.
ShaderInputs.setProps(), which deck.gl calls for every module of every
layer at every draw, also copied every value of every module it was
given (typed arrays into new arrays) whether it had changed or not.

ShaderInputs.setProps() now compares each new value with the stored
one and keeps a module's uniforms object when nothing changed. Each
module has a revision that is incremented when its uniforms change, and
getChangedUniformValues() returns the modules whose revision differs
from a revision record kept by the caller. Model keeps one record per
UniformStore, so models that share a ShaderInputs each see every
change, and a new ShaderInputs is written in full.

An object or array that is the stored value itself counts as changed,
since it may have been changed in place. getUniforms() is given a
shallow copy of the previous uniforms, so that fields it sets on it
are seen; a getUniforms() that changes the arrays inside the previous
uniforms in place and returns copies of them would go unseen (no
module in luma.gl or deck.gl does this). Uniform values changed by
writing into the object returned by getUniformValues() are no longer
written by Model; use setProps(). The ShaderInputs docs and the
upgrade guide say so. Material and Computation still write all modules.

Chrome with ANGLE on Metal, deck.gl 9.4, CPU time of deck's frame with
the view changing every frame; change switched off and on in the same
page, median of 5 paired runs:

  500 ScatterplotLayers     9.7 ->  9.0 ms
  500 PathLayers            9.4 ->  8.5 ms
  500 SolidPolygonLayers   17.3 -> 11.9 ms

A vector basemap of ~510 draws per frame: 2.4-2.8 ms of 26-28 ms per
frame (13 paired runs, all positive); 0.9 ms of 15 ms on a lighter
style.
@ibgreen

ibgreen commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

I'd like a smaller first step here that reduces allocations while preserving the existing Model -> UniformStore update path and uniform mutation behavior.

This PR adds public per-module revision counters, per-model consumed-revision records, and recursive equality logic alongside the existing uniform change detection in UniformStore. That is a substantial increase in state and API surface for a performance change.

The behavior change is also concerning: mutating values returned by getUniformValues() and then calling updateShaderInputs() previously reached the GPU, but now can silently leave uniforms stale. The shallow copy passed to getUniforms() also does not isolate nested arrays or objects, so mutation of a nested previous value followed by returning a copy can evade revision detection. Documenting these cases in the upgrade guide does not remove the compatibility cost.

Could we split out an allocation-focused optimization that retains the existing updates and mutation behavior, and benchmark that independently? We can then evaluate revision tracking separately if the remaining gain warrants the extra state and a changed mutation contract.

Revert 3cafbab, which added per-module uniform revisions to
ShaderInputs, per-model consumed-revision records to Model, and value
equality checks in ShaderInputs.setProps(). Model.updateShaderInputs()
again passes the uniforms of every module to its UniformStore, so
values changed in place in the object returned by getUniformValues()
reach the GPU again.
…forms

Model.draw() calls updateShaderInputs(), which passes the uniforms of
every shader module to the model's UniformStore at every draw, and
deck.gl calls ShaderInputs.setProps() for every module of every layer
at every draw. Both paths allocated more than they needed to:

- Object.entries() in UniformStore.setUniforms(),
  UniformBlock.setUniforms(), ShaderBlockWriter flattening and
  serialization, and ShaderInputs uniform merging allocated an array
  per key. These loops now use for...in.
- ShaderBlockWriter built every leaf path name ("lights[0]",
  "lights[0].color") again at every flatten. It now keeps the names it
  has built.
- UniformStore serialized a changed block with getData(), which
  flattened the block's values again although UniformBlock holds them
  flattened, and created four typed-array views of the scratch buffer
  each time. The new ShaderBlockWriter.getFlatData() serializes flat
  values, and the views are created again only when the scratch buffer
  changes.
- Merging an array of structs copied the current array twice.
- cloneModuleUniformValue() used Object.fromEntries(Object.entries()
  .map()) for structs.

The update path and its results do not change: Model still passes
every module to UniformStore, which still compares the values against
its own copy, so values changed in place in the object returned by
getUniformValues() are still written. A new test checks this.

Chrome with ANGLE on Metal, luma.gl 9.4 and deck.gl 9.4, CPU time of
deck's frame with the view changing every frame; change switched off
and on in the same page, median of 5 paired runs:

  500 ScatterplotLayers              10.7 -> 10.2 ms
  500 PathLayers                     10.0 ->  9.4 ms
  500 SolidPolygonLayers             17.7 -> 15.3 ms
  500 SolidPolygonLayers + picking   32.0 -> 26.5 ms
@athomct athomct changed the title perf(engine): write only changed shader module uniforms at each draw perf(core, engine): reduce allocations when writing shader module uniforms Oct 12, 2026
@athomct

athomct commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

@ibgreen Thanks, done. I reverted the revision tracking (7add6b9) and merged master. In its place is an allocation-only change (d5b29b7):

  • Removed: the per-module revision counters, the per-model consumed-revision records and the equality checks in setProps().
  • Model.updateShaderInputs() still passes every module to UniformStore, which still compares each value with its own copy. A value changed in place in the object returned by getUniformValues() reaches the GPU again. A new null-device test checks this: it changes a scalar, and then an array element twice, and checks the bytes written to the uniform buffer.
  • The allocations removed: Object.entries() in the UniformStore, UniformBlock, ShaderBlockWriter and ShaderInputs merge loops; leaf path names (lights[0].color) built again at every flatten; flattening of the already flat UniformBlock values again before serialization (new ShaderBlockWriter.getFlatData()); four typed-array views made for each serialization; and a double copy when an array of structs is merged.

Measured on its own with the same method as before (500 deck.gl layers, view panning, CPU time of deck's frame, change switched off and on in the same page, 5 and 7 paired runs):

scene before (ms) after (ms)
500 ScatterplotLayers 10.2–10.7 9.6–10.2
500 PathLayers 9.7–10.0 9.3–9.4
500 SolidPolygonLayers 17.5–17.7 15.1–15.3
500 SolidPolygonLayers + picking 31.2–32.0 25.8–26.5

The polygon layer gains most, because its lighting uniforms are an array of structs. The bytes allocated per frame fell by about 40% in the polygon and scatterplot scenes. The PR body has the full table.

The earlier CI failure (Browser coverage (3/3)) was a GPU process failure in the CI runner just after a WebGPU pipeline-error test. The new WebGL test that followed it could not compile its shader. That test is gone, and the new Model test uses the null device.

The previous commit put the minified @luma.gl/core bundle 157 B over
its 101,000 B budget. One helper now builds both the array-element
and struct-field names, and getFlatData() creates the typed-array
views of the scratch buffer itself. The behavior does not change:
100,994 B.
@athomct
athomct marked this pull request as ready for review October 12, 2026 06:24
@greptile-apps

greptile-apps Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; both previous findings are fixed and no new actionable issue was found.

Summary

Reduces temporary allocations when merging and writing shader uniforms without changing which modules are updated.

  • Uniform blocks serialize saved values without flattening them again.
  • Shader inputs build merged uniforms with fewer temporary allocations.

Reviews (2) · Last reviewed commit: "fix(core, engine): keep Object.entries()..." · Reviewed by Greptile

Comment thread modules/engine/src/shader-inputs.ts Resolved
@athomct

athomct commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

Measured in a real application as well: a deck.gl-community BasemapLayer rendering OpenFreeMap Liberty and Positron styles, on luma.gl 10 master (607cfb7) with and without this PR, deck.gl master, Windows, RTX 4090, Chrome 155 (ANGLE D3D11). The metric is mean main-thread CPU time per frame in Deck._onRenderFrame while zooming through z2–z14, median of 6 reps with the build order rotated each rep. A layer census at z14.05 is identical in both builds (475 layers for Liberty, 202 for Positron).

style / view / cache master this PR change
Liberty map cold 22.88 ms 21.99 ms -3.9%
Liberty map warm 16.66 ms 16.00 ms -4.0%
Liberty globe cold 41.93 ms 41.22 ms -1.7%
Liberty globe warm 37.06 ms 36.15 ms -2.5%
Positron map cold 11.41 ms 11.01 ms -3.5%
Positron map warm 9.25 ms 8.92 ms -3.5%
Positron globe cold 36.07 ms 34.14 ms -5.3%
Positron globe warm 30.06 ms 29.54 ms -1.7%

Geometric mean -3.3%, faster in all 8 cases. Changing zoom repeatedly within one style zoom level, where per-frame uniform writes dominate, is 10.9% faster (geometric mean over the four style and view cases); changes across a zoom step are 4.8% faster.

…iform loops

Review of the previous commits found two regressions:

- The leaf-name cache in ShaderBlockWriter was a plain object, so a
  struct field named `constructor`, `toString`, `valueOf` or
  `hasOwnProperty` resolved to an Object.prototype function instead of
  a field path, and the field was never written. The cache now has a
  null prototype.
- for...in also visits inherited enumerable properties, which
  Object.entries() skipped. A partial struct update could then reset a
  saved field from an inherited value. Each loop over caller-supplied
  values now checks Object.hasOwn() before it reads the value:
  ShaderBlockWriter.getFlatUniformValues() (uniforms and struct
  fields), UniformBlock.setUniforms(), UniformStore.setUniforms(), and
  the merge and clone helpers in ShaderInputs. UniformBlock reads each
  value once again.

No loop allocates. To keep @luma.gl/core within its 101,000 B budget,
getFlatData() no longer caches a Uint8Array view; it returns the one
view it already created for each call: 100,995 B.

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.

3 participants