Skip to content

Measurement tools: main-thread trace + offscreen replay page, left out of builds unless MEASUREMENT_TOOLS - #2126

Merged
pablo-mayrgundter merged 9 commits into
mainfrom
claude/open-205-render-worker-gpu-spike
Nov 10, 2026
Merged

pablo-mayrgundter merged 9 commits into
mainfrom
claude/open-205-render-worker-gpu-spike

Conversation

@pablo-mayrgundter

@pablo-mayrgundter pablo-mayrgundter commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

What and why

This checks in the tools from the open-205 §9.1 render-worker spike, so the next measurement doesn't need a long-lived draft branch. They're left out of normal builds, as the owner asked, and turned on by a build-time switch. Their findings are in design/new/offscreen-rendering.md §5a (merged in #2134); §5b covers how to use them again.

What's included:

  • ?feature=mainThreadTrace (src/utils/mainThreadTrace.js, 16 unit tests). From page start it records long animation frames (split into non-render and render work), long tasks, its own rAF cadence, input delay, load milestones and tab visibility. An overlay offers Mark, Summary, Repaint probe and Download trace.
    • The repaint probe times 60 forced repaints: CPU inside renderer.render, and GPU up to a WebGL2 fence.
    • The trace carries no model name, URL or path. Element ids in long-animation-frame invokers are dropped too.
  • /spike/offscreen/ (tools/spikes/offscreen/), the replay page. It plays a trace's non-render blocks, or a synthetic pump, against a BatchedMesh scene rendered on main or in a worker. Its Many views test compares a context per view with one shared context. The page is noindex.

How they stay out of production:

  • MEASUREMENT_TOOLS is a define in tools/esbuild/vars.prod.js, false by default and inherited by every config.
  • The tracer: src/index.jsx loads it through an import() inside a branch the define folds away, so esbuild drops the module.
    • Verified with build-prod: no tracer strings in docs/index.js, and the bundle is ~10.5 KB smaller. With the switch on, they're back.
    • The build doesn't code-split, so with the switch on the import is inlined, not fetched.
  • The page: tools/esbuild/build.js builds its two bundles and copies its index.html only with the switch.
    • The HTML moved from public/, which every build copies whole, to tools/spikes/offscreen/, beside its source.
    • Verified: no docs/spike/ without the switch; all four files with it.
  • tools/esbuild/defines.test.js pins the default to off.

Including them when needed (the top of tools/spikes/offscreen/README.md):

  • Locally: MEASUREMENT_TOOLS=true yarn build-prod (or … yarn serve).
  • On a deploy preview: a throwaway branch or draft PR whose netlify.toml adds [context.deploy-preview.environment] MEASUREMENT_TOOLS = "true", never merged. Or set the variable for deploy previews in Netlify's settings, then unset it afterwards.

Also changed:

  • .eslintrc.cjs turns no-magic-numbers off for tools/spikes/**, because scene constants read better inline.
  • The CLAUDE.md/AGENTS.md router row and offscreen-rendering.md §5b describe the tools as checked in and gated.

History: this was the draft spike PR, kept open as a toolbox. Its measurements are recorded in #2134 and on #2092 and #2030. The branch has been brought up to date with main.

Tests

  • Unit tests: 16 for mainThreadTrace.js (percentiles, privacy of script attribution including the id drop, LoAF splitting, replay plans, windows, hidden-tab gaps, the repaint probe), plus the defines default.
  • Hook gate passed: eslint, typecheck and jest (6046 passed, 5 skipped).
  • test-ci passed (coverage thresholds included).
  • build-prod was run with the switch off and on, with the outputs checked as above.
  • Browser smoke on a switch-on build (headless Chromium): the overlay installs with all four buttons, the Repaint probe runs and alerts its result, and /spike/offscreen/ loads and completes a Many views run.
  • Review: Sonnet fallback (Codex is out of quota). Nothing blocking.
    • It confirmed the gating: the define is a bare boolean, so the branch folds and the module goes with it.
    • It confirmed yarn serve picks the switch up.
    • It found the async install loses nothing, because the observers are buffered.
    • I took its privacy point: a long-animation-frame invoker like BUTTON#id.onclick now keeps the tag and handler but drops the id (#…), tested.
  • No E2E spec. In a normal build, which is what Playwright tests, there is nothing to exercise.

Reported Conditions

None. These are dev-only measurement tools, absent from normal builds, and they add no failure path to the app.

🤖 Generated with Claude Code

https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH

… page)

Draft for open-205 §9.1. Not for merge as is.

- ?feature=mainThreadTrace (src/utils/mainThreadTrace.js): records long
  animation frames (split into pre-render work and rendering), long tasks,
  rAF cadence, input delay and load milestones from page start. A
  Mark / Summary / Download overlay; the trace carries no model name,
  URL or path.
- /spike/offscreen/ (tools/spikes/offscreen/, built beside
  public/spike/offscreen/index.html): replays a trace's non-render
  main-thread blocks, or the section-5 synthetic pump, while drawing one
  BatchedMesh scene on main or in a worker with fence pacing; reports
  frame intervals, input-to-frame latency and how much the busy work
  stretched. A many-views test measures live-context limits, first-show
  and re-show times for one worker per view against one context with many
  scenes, for the multi-session UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH
@netlify

netlify Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for bldrs-share-prod ready!

Name Link
🔨 Latest commit faf608a
🔍 Latest deploy log https://app.netlify.com/projects/bldrs-share-prod/deploys/6af3a565a80c8a6aba96af02
😎 Deploy Preview https://deploy-preview-2126--bldrs-share-prod.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 47 (no change from production)
Accessibility: 88 (no change from production)
Best Practices: 100 (no change from production)
SEO: 100 (no change from production)
PWA: -
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@pablo-mayrgundter

Copy link
Copy Markdown
Member Author

Here's the results on my MacBook Air M2 16GB OSX Sonoma 14.3.1, Brave 1.99.55, Chromium 157.0.8103.39
spike-test-runs.json
spike-test-run-views.json
main-thread-trace-index.json
main-thread-trace-psb.json
main-thread-trace-psb-ui-mark.json

From the first real-GPU run (M2, Brave):
- One frame in flight capped every run at half the display rate even
  when idle: a frame's fence had not signalled by the next rAF, because
  the browser presents it asynchronously. Default pacing is now
  `fence2` (at most two frames in flight); `fence` stays as an option.
  Completions are tracked per frame, in order.
- The PSB trace showed a 5.8 s frame gap with no main-thread work and
  no input, almost certainly a hidden tab. The trace now records
  hidden/visible, and summaries leave out intervals spanning a hidden
  period (counted as hiddenGaps).
- The replay page gains a "between Marks" window, and results and traces
  record navigator.userAgentData brands, since Brave masks the GPU name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH
Measures what one repaint of the still view costs on the user's GPU, to
decide the hover-highlight overlay (draw only the highlight over a cached
frame). Its "Repaint probe" button forces 60 repaints and times each two
ways: CPU time inside three's render calls, and GPU time from the draw's
return until a WebGL2 fence placed after it signals, polled from a
MessageChannel loop. Triangles and draw calls are summed per frame
because three resets renderer.info on every render call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH
pablo-mayrgundter pushed a commit that referenced this pull request Nov 10, 2026
…s and #2135

Adds §5b describing the measurement tools kept on draft PR #2126 (main-thread
trace, repaint probe, replay page, many-views test), how to rebuild and use
them, and their two gaps (minified attribution; no N-session harness). Links
§9.1's outcome to the measured long-task targets on #2030 and the shared GPU
budget follow-up (#2135). Router row updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH
pablo-mayrgundter added a commit that referenced this pull request Nov 10, 2026
…sion to defer the render worker (#2134)

* Pin conway 1.1630.750 (styled-items prep from record bytes)

conway#750 replaces populateStyledItemsMap's per-item entity walk with a
read of each styled item's target straight from the record bytes, shared
by IFC and AP214. In Node on a 231 MB IFC that pass drops from 1.6-2.6 s
to ~120 ms and the longest synchronous block in a deferred open from
2.0-3.1 s to ~0.6 s (open-205 #2030, target 3).

The only other change since the 1.1626.740 pin is conway#747 (setup
tooling); the wasm bundle is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH

* PLAYBOOK: why a symlinked or partial node_modules fails the single-three check

The worktree section claimed a symlinked node_modules makes
singleThreeInstance.test.js see two copies of three. It sees none:
the test runs `find node_modules ...`, and without -L find does not
descend into a symlink given as its starting point, so the guard
behind the muted "Multiple instances of Three.js" warning fails on
source that is fine. A partly hardlinked copy fails differently (the
deferred-BVH picking tests loaded two copies of three). A full
`cp -al` of a sibling's tree plus its .husky/_ shim passes, at
almost no disk cost, when the dependencies match.

Router row updated to say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH

* Residency: rank OCCUPANCY from the viewer's camera, not a field it lacks

ResidencyControl handed ResidencyController a camera getter reading
`viewer.context.ifcCamera.perspectiveCamera`. `viewer.context` is the
ThreeContext facade, which has no `ifcCamera` (that lives on the legacy
context it wraps), so the getter always returned null and OCCUPANCY fell
back to ranking parts by world-space radius alone, ignoring the eye. It now
reads `context.getCamera()`.

This changes what the at-rest slider keeps at a given percent under the
default metric, and what Auto keeps while moving. The ranking is still taken
when a set is chosen, not re-ranked as the camera orbits; the design doc's
Known limits now says so, and notes that the owner's 25% PSB measurement was
by size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH

* Residency: a disposed viewer's camera reads as none, not a throw

Review follow-up on the OCCUPANCY camera fix. A disposed ThreeContext
throws from getCamera once its legacy context is dropped, and the store can
hold the old viewer until the next setViewer, so a re-rank in that gap would
now throw where the old (always-null) getter did not. Guard it, with a test
that goes red without the guard.

Doc: note that the eye is the camera position, which is right for a
perspective view but not an orthographic one, and rewrap Known limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH

* Motion residency E2E: assert eviction only on frames painted while the camera moved

The drag tests asserted that every frame painted during a drag drew fewer
triangles than the still view. CI (and ~1 in 8 local runs under parallel
load) saw an occasional full-count frame mid-drag. Recording the camera's
last change per frame showed why: under CPU load Playwright's pointer moves
arrive 200+ ms apart, the drag stretches from ~0.4 s to ~4 s, and a pause
that long is rest by MotionResidency's own rule (REST_MS plus REST_FRAMES),
so the full set is drawn again until the next move restarts motion. The
product is right; the test assumed a drag never stalls.

The recorder now notes, per painted frame, how long since the last
camera-controls `update` (the event that starts and extends motion), and
the two "fewer while moving" checks look only at frames inside REST_MS of a
change, where rest is impossible. A full frame there still fails.

Measured locally: 7 of 24 failures before; 48 of 48 passes after (whole spec,
8x per form factor, parallel workers). With motion start broken, all six
cases still go red on their triangle assertions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH

* Motion residency E2E: time a frame from before its draw, not after

Review follow-up. sinceMoveMs was taken after the frame's first render call,
so a slow render counted toward "time since the camera moved", and on a slow
runner every drag frame could exceed REST_MS and trip the "painted while the
camera moved" guard. Read the clock before the draw instead. It is still
after MotionResidency's rest check in frame(), so it never reads less than
the controller's own and a rest frame can't pass as one in motion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH

* offscreen-rendering: real-GPU spike results, and the go/no-go rule

Adds §5a with the owner's M2 measurements from Share#2126: a render worker
against a busy main (R1/R2, 4.3x frame rate, input still main-bound), 20
views (per-view contexts vs one shared), Share's own main thread on PSB
(hover was the largest block, since fixed by #2127-#2129), and the repaint
probe (big models are GPU- and triangle-bound, which residency fixes and a
worker doesn't). Re-opens §9.1 as "whether to start": re-capture Share on
main, replay R1 vs R2, go if render on main still costs visible frames,
otherwise defer to the multi-session UI. Status, §1, §10 and the router row
updated to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH

* offscreen-rendering: record the re-capture on main, and defer the render worker

Adds the 2026-11-10 re-capture to §5a: PSB as a GLB cache hit (two tasks,
1.3 s and 1.0 s, the first being the hit's synchronous hydration) and as a
first load (18 long tasks, input otherwise fine), a Seestrasse trace (main
effectively free), and 8 Seestrasse-sized views (switch costs one frame per
context, two shared). A worker removes the stalls but costs ~54 ms of input
latency in every phase and ~15% of idle frames; splitting the tasks fixes
both. §9.1 is amended to ask first whether a cheaper fix removes a block,
and records the outcome: deferred, split the tasks under #2030, revisit
with the multi-session UI. Status, §1, §10 and the router row updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH

* offscreen-rendering: §5b reusable tools on #2126; link #2030's targets and #2135

Adds §5b describing the measurement tools kept on draft PR #2126 (main-thread
trace, repaint probe, replay page, many-views test), how to rebuild and use
them, and their two gaps (minified attribution; no N-session harness). Links
§9.1's outcome to the measured long-task targets on #2030 and the shared GPU
budget follow-up (#2135). Router row updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH

---------

Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Member Author

Kept open as a toolbox, not for merge. The spike's conclusion is recorded in design/new/offscreen-rendering.md §5a and §9.1 (#2134): the render worker is deferred. This PR's branch holds the measurement tools: ?feature=mainThreadTrace with its repaint probe, and the /spike/offscreen/ replay and many-views page. They're documented in the same doc's §5b, including how to rebuild the preview (merge main, then push) and the two gaps to close before the next use.

The branch was brought up to date with main at 206a7485 on 2026-11-10.


Generated by Claude Code

…rement tools

The spike's tools land on main at their current paths, so the next
measurement doesn't need a long-lived draft branch: `?feature=mainThreadTrace`
(src/utils/mainThreadTrace.js, off by default, ~4 KB gzipped in the bundle;
the build doesn't code-split, so a lazy import would be inlined anyway), its
repaint probe, and the /spike/offscreen/ replay and many-views page
(tools/spikes/offscreen/, public/spike/offscreen/index.html, noindex).
README, the lint and build comments, offscreen-rendering.md §5b and the
router row now describe them as checked in rather than as a draft PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH
…ncludes them

Per the owner: check the tools in, but don't ship them. A build-time switch,
MEASUREMENT_TOOLS (vars.prod.js, default false, inherited by every config),
now gates both:
- the main-thread tracer: index.jsx installs it through an import() inside a
  branch the define folds away, so esbuild drops the module (verified: no
  tracer strings in docs/index.js, ~10.5 KB smaller; present again with the
  switch on);
- the /spike/offscreen/ page: build.js builds its bundles and copies its
  index.html only with the switch. The HTML moves from public/ (copied whole
  into every build) to tools/spikes/offscreen/, beside its source.

The README now opens with how to include them: locally
(MEASUREMENT_TOOLS=true yarn build-prod / serve) and on a deploy preview (a
throwaway branch setting it under [context.deploy-preview.environment]).
offscreen-rendering.md §5b and the router row say the same. A defines test
pins the default to off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH
@pablo-mayrgundter pablo-mayrgundter changed the title [spike, do not merge] open-205 §9.1: real-GPU render-worker measurement (main-thread trace + replay page) Measurement tools: main-thread trace + offscreen replay page, left out of builds unless MEASUREMENT_TOOLS Nov 10, 2026
Review follow-up. A long animation frame names an event-listener invoker by
its element (`BUTTON#some-id.onclick`), so an id rode into the trace. Keep
the tag and handler, replace the id with `#…`; the trace promises no model
names, and an id is page content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nj2dXLdxTaS5V1HZS5gsH
@pablo-mayrgundter
pablo-mayrgundter marked this pull request as ready for review November 10, 2026 23:15
@chatgpt-codex-connector

Copy link
Copy Markdown
Codex Review Summary: ❌ Failed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ❌ Failed 2026-11-10T23:15:15.112389Z
We couldn’t complete this review. Comment @codex review to try again.
faf608a Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@pablo-mayrgundter
pablo-mayrgundter merged commit 5c9f7a0 into main Nov 10, 2026
16 checks passed
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