Skip to content

Plan 007: Profile the editor render loop's per-frame allocation cost, then apply identity fast-paths only if the numbers justify it #90

Description

@git-chad

Plan 007: Profile the editor render loop's per-frame allocation cost, then apply identity fast-paths only if the numbers justify it

Executor instructions: Follow this plan step by step. Run every
verification command and confirm the expected result before moving to the
next step. If anything in the "STOP conditions" section occurs, stop and
report — do not improvise. When done, update the status row for this plan
in plans/README.md — unless a reviewer dispatched you and told you they
maintain the index.

This plan is profile-gated. Step 4 (the structural fix) runs ONLY if
Step 1's measurements clear the threshold defined there. "Measured it,
numbers are too small, removed instrumentation, recorded results" is a
successful, complete outcome of this plan — not a failure.

Drift check (run first): git diff --stat 84c1d09..HEAD -- src/hooks/use-editor-renderer.ts src/renderer/create-webgpu-renderer.ts src/renderer/contracts.ts src/renderer/pipeline-manager.ts src/lib/editor/parameter-schema.ts
If any in-scope file changed since this plan was written, compare the
"Current state" excerpts against the live code before proceeding; on a
mismatch, treat it as a STOP condition.

Status

  • Priority: P3
  • Effort: M
  • Risk: MED (change-detection fast-paths can cause stale rendering if the identity assumptions are wrong — the manual checks in step 5 exist for exactly that)
  • Depends on: plans/001-test-baseline-pure-logic.md
  • Category: perf
  • Planned at: commit 84c1d09, 2026-06-10

Why this matters

Every animation frame, the editor rebuilds its entire renderer frame and recomputes per-layer change-detection signatures — even when nothing on screen is changing. The allocation sites are confirmed by reading the code (all run per frame, per visible layer where noted): two Maps and per-layer object spreads in buildRendererFrame; a [...frame.layers].reverse() array copy; and a createLayerSignature string build per layer that internally does Object.entries(...).sort((a, b) => a.localeCompare(b)).map(...).join(...) over every parameter of every layer. With ~10 layers at 60fps that is thousands of short-lived arrays/strings per second plus repeated localeCompare calls (notably slower than < comparison), purely to answer "did anything change?".

What is NOT confirmed is whether this costs enough to matter — GC of short-lived objects is cheap in modern engines, and no profile exists. Optimizing change detection blind is how stale-canvas bugs get introduced. So this plan measures first, applies one unconditionally-safe micro-fix, and gates the structural fix on the measured numbers.

Current state

The per-frame call chain (all verified at 84c1d09):

  1. src/hooks/use-editor-renderer.ts:144 — inside the requestAnimationFrame loop (self-rescheduling at lines 166–173), every frame calls:

    const frame = buildRendererFrame({
      assets: assetState.assets,
      clockTime,
      delta,
      layers: layerState.layers,
      ...
    })
    ...
    renderer.render(frame)
  2. src/renderer/contracts.ts:79buildRendererFrame allocates per frame: new Map(input.assets.map(...)), a full evaluateTimelineForLayers(...) pass, new Map(evaluatedLayers.map(...)), and per visible layer a fresh params object and a fresh layer object:

    // contracts.ts:95-122 (abridged)
    const params = evaluation
      ? { ...getCachedClone(layer.params), ...evaluation.params }
      : getCachedClone(layer.params)
    return {
      asset: layer.assetId ? (assetById.get(layer.assetId) ?? null) : null,
      layer: { ...layer, hue: ..., opacity: ..., saturation: ..., visible: ... },
      params,
    }

    Note the existing memoization exemplar in the same file — paramsCloneCache, a WeakMap<LayerParameterValues, LayerParameterValues> at contracts.ts:65-77. The fix in step 4 follows this exact pattern. Also note evaluateTimelineForLayers already early-returns [] when tracks.length === 0 (src/lib/editor/timeline/evaluate.ts:217-219), so a static composition with no tracks skips evaluation — but still pays the spreads.

  3. src/renderer/create-webgpu-renderer.ts:29renderFrame copies the layer array every frame: pipeline.syncLayers([...frame.layers].reverse()).

  4. src/renderer/pipeline-manager.ts:206syncLayers computes createLayerSignature(renderableLayer) for every layer every frame; the signature (lines ~85–106) is a ~17-element array joined with "|", ending in parameterValuesSignature(layer.params).

  5. src/lib/editor/parameter-schema.ts:70-75:

    export function parameterValuesSignature(values: LayerParameterValues): string {
      return Object.entries(values)
        .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
        .map(([key, value]) => `${key}:${valueSignature(value)}`)
        .join("|")
    }

    Callers of parameterValuesSignature/valueSignature (verify with grep -rn "parameterValuesSignature\|valueSignature" src/ | grep -v __tests__): only pipeline-manager.ts and the definition. Signatures are ephemeral — compared against the previous frame's value in layerSignatures: Map<string, string> (pipeline-manager.ts:119,217), never persisted. A change in signature format is therefore safe as long as it remains deterministic and injective on the same inputs.

Why signatures exist: when a signature differs from last frame, applyLayerState pushes the new state into the pass (pipeline-manager.ts:217-225). During timeline playback with animated params, signatures legitimately change every frame. The waste is the static case: nothing animating, no edits, yet full rebuild + recompute every frame.

Key identity assumption for step 4 (must be verified in step 3): zustand stores update immutably, so EditorLayer objects in layerState.layers are reference-stable between edits. If a store mutates layer objects in place anywhere, the WeakMap approach is unsound — that's a STOP condition.

Repo conventions: double quotes, no semicolons, sorted object keys (Biome), strict TS (noUncheckedIndexedAccess, exactOptionalPropertyTypes).

Commands you will need

Purpose Command Expected on success
Install bun install --frozen-lockfile exit 0
Build runtime pkg bun run build:runtime exit 0
Dev server bun dev editor at localhost:3000
Tests bun test all pass
Typecheck bun run typecheck:tsc exit 0
Lint bun run lint exit 0

Suggested executor toolkit

  • If Chrome DevTools MCP tools (mcp__chrome-devtools__performance_start_trace / performance_stop_trace) are available in your environment, use them in step 1 to capture a trace alongside the in-code timers; otherwise the in-code timers alone are sufficient.

Scope

In scope:

  • src/renderer/create-webgpu-renderer.ts (temporary instrumentation around syncLayers; step-4 reverse-copy removal)
  • src/hooks/use-editor-renderer.ts (temporary instrumentation around buildRendererFrame — MUST be fully reverted before done)
  • src/lib/editor/parameter-schema.ts (step 2 comparator swap)
  • src/lib/editor/__tests__/parameter-schema.test.ts (extend)
  • src/renderer/contracts.ts (step 4 only: per-layer entry memoization)
  • src/renderer/pipeline-manager.ts (step 4 only: reference-equality fast-path)
  • plans/007-artifacts/profile.md (create — the measurement record)
  • plans/README.md (status row)

Out of scope:

  • packages/shader-lab-react/** — the package has parallel copies of pipeline-manager.ts/contracts.ts; mirroring is a decision for plan 006's consolidation design, not this plan.
  • Zustand stores and src/lib/editor/timeline/evaluate.ts internals.
  • Any change to what triggers applyLayerState — only to how cheaply "nothing changed" is detected.
  • React-side re-render optimization (separate finding, needs React Profiler evidence).

Git workflow

  • Branch: advisor/007-render-loop-allocations
  • Commit per step; style: perf: replace localeCompare in parameter signatures, perf: skip frame rebuild for unchanged layers
  • App-only change: no changeset required.
  • Do NOT push or open a PR unless the operator instructed it.

Steps

Step 1: Instrument and measure

Add temporary timing (clearly marked // TEMP instrumentation — plan 007, remove before merge):

  • In src/hooks/use-editor-renderer.ts, around the buildRendererFrame call: accumulate elapsed performance.now() time and a frame count.
  • In src/renderer/create-webgpu-renderer.ts renderFrame, around pipeline.syncLayers(...) (including the [...frame.layers].reverse()): same.
  • Once per second, console.log median-ish stats: { buildMs: <avg per frame>, syncMs: <avg per frame>, frames }, then reset accumulators.

Measure two scenarios in bun dev (Chrome), each observed for ≥ 30 seconds, and record the numbers in plans/007-artifacts/profile.md:

  • Scenario A (static): a composition with ~10 layers of mixed types (e.g. gradient, halftone, ascii, crt, dithering — duplicate types as needed), no timeline tracks, playback paused, no pointer interaction.
  • Scenario B (animating): same composition with 3+ animated parameter tracks, timeline playing.

If DevTools tracing is available, also capture a 10 s Performance trace of Scenario A and note GC events and any long frames (> 16.7 ms) attributable to scripting rather than GPU.

Verify: plans/007-artifacts/profile.md exists with per-scenario buildMs/syncMs averages and the machine/browser noted.

Step 2: Unconditional micro-fix — drop localeCompare

In src/lib/editor/parameter-schema.ts parameterValuesSignature, replace the comparator:

.sort(([leftKey], [rightKey]) =>
  leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0
)

Parameter keys are plain ASCII identifiers; lexicographic ordering is just as deterministic and avoids ICU collation per comparison. Extend src/lib/editor/__tests__/parameter-schema.test.ts: equal inputs → equal signatures; two values differing in one param → different signatures; key order in the input object does not affect the signature.

Verify: bun test parameter-schema → all pass. bun run typecheck:tsc && bun run lint → exit 0.

Step 3: Gate decision

Proceed to step 4 only if, in Scenario A (static), buildMs + syncMs ≥ 0.3 ms per frame on the test machine, or the DevTools trace showed GC-attributable long frames during Scenario A. Otherwise: remove all step-1 instrumentation, write the conclusion in profile.md ("static-frame overhead X ms — below threshold; structural fix not justified"), skip to step 5, and mark the plan DONE with that outcome.

Verify: the decision and its numbers are recorded in profile.md.

Step 4: Identity fast-paths (conditional)

First verify the identity assumption: read src/store/layer-store.ts update actions and confirm layers are replaced immutably (look for in-place mutation of layer objects; spread/map-replacement patterns are what you expect to find). If any action mutates a layer object in place, STOP.

Then, modeled on the existing paramsCloneCache WeakMap (contracts.ts:65-77):

  1. buildRendererFrame entry memo (src/renderer/contracts.ts): cache the built { asset, layer, params } entry in a WeakMap<EditorLayer, { asset: EditorAsset | null; entry: RenderableLayerPass }>. Reuse the cached entry when (a) the EditorLayer reference is unchanged, (b) the resolved asset is the same reference, and (c) the layer has no timeline evaluation this frame (evaluatedById.get(layer.id) is undefined). Animated or just-edited layers fall through to the existing build path. Result: in a static scene, frame.layers entries become reference-stable across frames.
  2. syncLayers fast-path (src/renderer/pipeline-manager.ts): keep a Map<string, RenderableLayerPass> of last frame's entry per layer id; when the incoming entry is reference-equal, skip createLayerSignature and the signature comparison entirely for that layer (the existing layerSignatures map stays as the slow-path mechanism). Clear this map wherever layerSignatures is cleared/deleted (lines 196, 411).
  3. Drop the array copy (src/renderer/create-webgpu-renderer.ts:29): iterate in reverse inside syncLayers or build a reversed array once in buildRendererFrame — pick the smaller diff; do not mutate frame.layers in place.

Re-run both Scenario A and B measurements; append before/after numbers to profile.md. Then remove all step-1 instrumentation.

Verify: Scenario A buildMs + syncMs reduced vs step 1 (numbers in profile.md); grep -rn "TEMP instrumentation" src/ → no matches.

Step 5: Behavioral regression checks (manual, in bun dev)

These guard the classic fast-path failure mode — stale canvas:

  • Edit a parameter via the properties sidebar (slider drag) → canvas updates live, every drag tick.
  • Toggle a layer's visibility off/on → canvas reflects it immediately.
  • Reorder layers → composite order changes immediately.
  • Undo (history) a parameter change → canvas reverts immediately.
  • Play timeline with animated params → animation unchanged from before (Scenario B).
  • Import a .lab project → renders correctly.

Verify: all six checks pass; bun test && bun run typecheck:tsc && bun run lint → exit 0.

Test plan

  • Step 2's signature tests in src/lib/editor/__tests__/parameter-schema.test.ts (model after the plan-001 tests there).
  • The fast-path logic depends on DOM/WebGPU and is covered by the manual matrix in step 5, recorded in the PR description with the before/after frame numbers from profile.md.

Done criteria

  • plans/007-artifacts/profile.md exists with Scenario A and B numbers, the gate decision, and (if step 4 ran) before/after comparison
  • parameterValuesSignature no longer calls localeCompare (grep -n "localeCompare" src/lib/editor/parameter-schema.ts → no matches)
  • grep -rn "TEMP instrumentation" src/ → no matches (instrumentation fully removed)
  • If step 4 ran: grep -n "WeakMap" src/renderer/contracts.ts shows the new entry memo, and the step-5 manual matrix is recorded in PR notes
  • bun test, bun run typecheck:tsc, bun run lint all exit 0
  • No files outside the in-scope list modified (git status)
  • plans/README.md status row updated (including the "below threshold, stopped after step 2" outcome if that's what happened)

STOP conditions

Stop and report back (do not improvise) if:

  • Any layer-store action mutates an EditorLayer object in place (breaks the WeakMap identity assumption — report the action name and line).
  • parameterValuesSignature or valueSignature turns out to have callers beyond pipeline-manager.ts and tests (signature format would then be load-bearing elsewhere).
  • After implementing step 4, any step-5 check shows a stale canvas and the cause isn't found within one focused fix attempt — revert step 4 (keep steps 1–2 results) and report.
  • The use-editor-renderer.ts render loop differs structurally from the excerpt (drift).

Maintenance notes

  • The package tree (packages/shader-lab-react/src/renderer/) has parallel copies of pipeline-manager.ts and contracts.ts-equivalent logic; whether to mirror these fast-paths is a plan-006 consolidation-design question — flag it there rather than hand-porting.
  • Anyone adding a new field to EditorLayer that affects rendering must ensure it flows through createLayerSignature (slow path) — the fast path is reference-based and unaffected, but the slow path remains the correctness backstop.
  • If the React-side re-render finding (zustand selectors) is ever investigated, reuse Scenario A/B from profile.md as the benchmark fixtures.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions