Skip to content

Measure hdrgen: native, Node and browsers, and correct the PRD - #261

Merged
adulbrich merged 11 commits into
mainfrom
bench-hdrgen
Aug 7, 2026
Merged

Measure hdrgen: native, Node and browsers, and correct the PRD#261
adulbrich merged 11 commits into
mainfrom
bench-hdrgen

Conversation

@adulbrich

Copy link
Copy Markdown
Collaborator

Adds scripts/bench-hdrgen, a committed instrument outside the test suite, and records what it measured.

Why

PRD.md claimed the WebAssembly build costs "roughly 2x native". Nothing in the repository measured that. The claim then got repeated in discussion as though it were established, which is how an unverified number becomes a design constraint.

Results

Apple Silicon, arm64, machine idle. Median of three, seconds, for the merge alone.

leg 4 8 12 18 frames
native-arm64 5.4 7.0 9.1 13.6
native-x86_64 (Rosetta) 5.4 9.3 12.5 18.4
wasm-node 12.0 15.1 18.6 22.8
wasm-chromium 5.7 7.7 10.3 14.5
wasm-webkit 6.5 8.8 9.5 13.4

In a browser the WebAssembly build runs the merge at native speed. WebKit 13.4s against native arm64's 13.6s; Chromium 14.5s, about 1.07x. The PRD is corrected here: it was wrong, and pessimistically so, for the case the web build actually runs.

Rosetta costs about 1.35x. That matters beyond curiosity: the only native hdrgen installed on the machine is x86_64, so every earlier ad-hoc comparison was against an emulated binary. An informal "native 4.0s vs wasm 4.2s, essentially parity" reading came from exactly that, and was flattering wasm for the wrong reason. Building arm64 from the commit versions.json pins is what makes the native column mean anything.

wasm-node is the odd row at 1.68x native, slower than either browser despite sharing V8 with Chromium. No explanation is offered because none was tested. The suspicion is the harness: the module cannot fetch its own .wasm under Node, so that leg hands over a compiled module through instantiateWasm, which may not tier up like a browser's streaming compile. Flagged rather than explained away.

Design decisions worth reviewing

The browser legs are a bare page, no Next build, no app, no worker, no UI, served by request interception. A number taken through the application cannot separate a slow engine from a slow application, and that separation was the point.

WebKit is measured, not just Chromium, unlike perf.config.ts which deliberately fixes the engine to compare hosts. The desktop build runs WKWebView, so WebKit is what tells "browsers are slow at this" apart from "Chromium is slow at this".

Frames are sampled evenly across the bracket, endpoints included, not taken from the front. A bracket is an exposure sequence, so a prefix is a run of near-identical long exposures: on the first four the pipeline's own filter reported "kept 1 of 4", which would have timed a one-frame merge while the table said four. The orchestrator hands its selection to the browser leg so every leg merges the same files from one source.

A timeout is recorded as a result, not an error, and a crash is distinguished from a hang, because those send an investigation in different directions.

What this does not answer

The deployed site. A full merge is ~14s in a browser, so a deployment that feels far slower is not slow because of the merge; delivery of the wasm payload and the app around it are what remain. e2e-web/tests/perf.bench.ts already takes TARGET_URL and exists to separate those.

🤖 Generated with Claude Code

adulbrich and others added 10 commits August 6, 2026 10:27
The PRD claims the WebAssembly build costs roughly 2x native and nothing here
measures it. What has been observed pulls in two directions: the shipped wasm
merges the 18-frame bracket in 22s headless under Node, matching the ~24s #245
records for the macOS desktop app, while the same bracket in the browser build
under Playwright's Chromium did not finish in fifteen minutes on the same
machine, and CI's chromium job does it inside a six-minute job.

The comparison taken so far was not fair either: /usr/local/bin/hdrgen is an
x86_64 binary on an arm64 machine, so every native timing ran under Rosetta
against a native arm64 JIT. Hence building from the pinned fork commit rather
than measuring only what happens to be installed.

The load-bearing decision is that the browser leg is a bare page calling the
same hdrgen.js on the same bytes, not the app. Only that shape can say whether
the engine is slow or the application around it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six tasks: shared fixtures and reporter, the two native legs including building
arm64 from the pinned fork commit, the wasm-on-node control, the browser legs
in a bare page, an orchestrator, and a run that writes down what it found.

Two decisions the plan makes explicit. The browser legs serve themselves by
request interception rather than a static server, so there is no port and
nothing added to public/ or out/. And the wasm-node leg runs in a child
process, because callMain is synchronous and a ceiling that cannot be enforced
is not a ceiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The benchmark only means anything if each environment runs identical work, so
the bracket, the argument vector and the staged naming live in one module that
every leg has to go through. The reporter refuses to drop runs that did not
finish: dropping them would report the slowest environment as absent rather
than slow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The summarise() sample had a truncated template literal that could not parse
and could not have grouped by frame count. And 'node --test <dir>' fails on
Node 24.15.0 in this environment, reproduced independently outside the repo,
so every step now uses the glob form that works. Three later tasks would have
hit the same wall.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plan's record shape defines three statuses and its summarise() handled two,
folding every error into the timeout count. A benchmark built to find out why a
leg does not finish would then have reported a module that aborted immediately
as one that was merely slow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
summarise folded any non-ok status into "N/M timed out", so a leg that
crashed immediately would be reported as one that merely ran slow. Telling a
hang apart from a crash is most of why this benchmark exists, so the note now
counts "timed out" and "errored" separately and lists whichever occurred.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ampling

Two native binaries, wasm in Node, and wasm in Chromium and WebKit, all merging
the same frames through the same argv so a difference between them is the
environment and nothing else. The browser legs run in a bare page served by
request interception: no Next build, no app, no worker, no UI, because a number
taken through the application cannot tell a slow engine from a slow app.

Frames are now sampled evenly across the bracket with the endpoints included,
not taken from the front. A bracket is an exposure sequence, so a prefix is a
run of near-identical long exposures: on the first four the pipeline's own
filter reported "kept 1 of 4", which would have timed a one-frame merge while
the table said four. The orchestrator hands its selection to the browser leg so
every leg merges the same files from one source, and the selection itself is
pinned to an explicit expected list in the tests.

The wasm-node leg runs in a child process because callMain is synchronous: a
run that hangs blocks the thread that would otherwise time it out, and a
ceiling that cannot be enforced is not a ceiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In a browser the WebAssembly build runs the merge at native speed: WebKit 13.4s
against native arm64's 13.6s at the full bracket, Chromium 14.5s. PRD.md:79's
'roughly 2x native' is wrong, and pessimistically so.

Rosetta costs about 1.35x, which is why the earlier ad-hoc native-versus-wasm
comparison read as parity: the native side was emulated.

wasm-node is the slowest leg and no explanation is offered, because none has
been tested. The suspicion is the harness rather than Node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The table is the output; the JSON is an artefact of one machine on one
afternoon. Writing it to the repository root left it sitting in git status
looking like something to commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The claim was never measured. Merging the 18-frame reference bracket takes
13.4s in WebKit and 14.5s in Chromium against 13.6s for a native arm64 build of
the same commit, so the figure was pessimistic for the browser, which is the
case the web build actually runs.

Keeps the two caveats that make the number honest rather than triumphal: the
native leg reads frames from disk inside the timed region and the WebAssembly
legs do not, and a merge is not pure compute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lumilab Ready Ready Preview Aug 6, 2026 6:25pm

CI cancelled after fifteen minutes with no failed step, which read as
infrastructure and was not. Jest does collect `.mjs` despite its default
testMatch listing only `[jt]s`, so `bench.test.mjs` was being pulled into the
jsdom environment, where `node:test` means nothing. `scripts/` now sits beside
the two end-to-end directories in testPathIgnorePatterns, for the same reason
they are there: it has its own runner.

The rest is lint. The browser leg's evaluate body was one 26-branch block and
is now load, time, classify; a helper in the reporter shadowed a parameter; and
three await-in-loop suppressions sat one line above the await they were meant
to cover, which silently made them apply to nothing.

Verified against every gate CI runs: 61 suites and 406 tests with no bench file
collected, tsc clean at the root and in e2e-web, no lint errors, and the
browser leg still measuring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@adulbrich
adulbrich merged commit cd973a5 into main Aug 7, 2026
12 checks passed
@adulbrich
adulbrich deleted the bench-hdrgen branch August 7, 2026 09:08
adulbrich added a commit that referenced this pull request Aug 7, 2026
* docs: design for measuring hdrgen across native, node and browsers

The PRD claims the WebAssembly build costs roughly 2x native and nothing here
measures it. What has been observed pulls in two directions: the shipped wasm
merges the 18-frame bracket in 22s headless under Node, matching the ~24s #245
records for the macOS desktop app, while the same bracket in the browser build
under Playwright's Chromium did not finish in fifteen minutes on the same
machine, and CI's chromium job does it inside a six-minute job.

The comparison taken so far was not fair either: /usr/local/bin/hdrgen is an
x86_64 binary on an arm64 machine, so every native timing ran under Rosetta
against a native arm64 JIT. Hence building from the pinned fork commit rather
than measuring only what happens to be installed.

The load-bearing decision is that the browser leg is a bare page calling the
same hdrgen.js on the same bytes, not the app. Only that shape can say whether
the engine is slow or the application around it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: implementation plan for the hdrgen benchmark

Six tasks: shared fixtures and reporter, the two native legs including building
arm64 from the pinned fork commit, the wasm-on-node control, the browser legs
in a bare page, an orchestrator, and a run that writes down what it found.

Two decisions the plan makes explicit. The browser legs serve themselves by
request interception rather than a static server, so there is no port and
nothing added to public/ or out/. And the wasm-node leg runs in a child
process, because callMain is synchronous and a ceiling that cannot be enforced
is not a ceiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* bench(hdrgen): one argv builder and one reporter for every leg

The benchmark only means anything if each environment runs identical work, so
the bracket, the argument vector and the staged naming live in one module that
every leg has to go through. The reporter refuses to drop runs that did not
finish: dropping them would report the slowest environment as absent rather
than slow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: repair two defects in the benchmark plan found during Task 1

The summarise() sample had a truncated template literal that could not parse
and could not have grouped by frame count. And 'node --test <dir>' fails on
Node 24.15.0 in this environment, reproduced independently outside the repo,
so every step now uses the glob form that works. Three later tasks would have
hit the same wall.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: let the reporter tell a crash apart from a hang

The plan's record shape defines three statuses and its summarise() handled two,
folding every error into the timeout count. A benchmark built to find out why a
leg does not finish would then have reported a module that aborted immediately
as one that was merely slow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* bench(hdrgen): tell an errored run apart from a timed-out one

summarise folded any non-ok status into "N/M timed out", so a leg that
crashed immediately would be reported as one that merely ran slow. Telling a
hang apart from a crash is most of why this benchmark exists, so the note now
counts "timed out" and "errored" separately and lists whichever occurred.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* bench(hdrgen): the remaining legs, the orchestrator, and even frame sampling

Two native binaries, wasm in Node, and wasm in Chromium and WebKit, all merging
the same frames through the same argv so a difference between them is the
environment and nothing else. The browser legs run in a bare page served by
request interception: no Next build, no app, no worker, no UI, because a number
taken through the application cannot tell a slow engine from a slow app.

Frames are now sampled evenly across the bracket with the endpoints included,
not taken from the front. A bracket is an exposure sequence, so a prefix is a
run of near-identical long exposures: on the first four the pipeline's own
filter reported "kept 1 of 4", which would have timed a one-frame merge while
the table said four. The orchestrator hands its selection to the browser leg so
every leg merges the same files from one source, and the selection itself is
pinned to an explicit expected list in the tests.

The wasm-node leg runs in a child process because callMain is synchronous: a
run that hangs blocks the thread that would otherwise time it out, and a
ceiling that cannot be enforced is not a ceiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record what the hdrgen benchmark measured

In a browser the WebAssembly build runs the merge at native speed: WebKit 13.4s
against native arm64's 13.6s at the full bracket, Chromium 14.5s. PRD.md:79's
'roughly 2x native' is wrong, and pessimistically so.

Rosetta costs about 1.35x, which is why the earlier ad-hoc native-versus-wasm
comparison read as parity: the native side was emulated.

wasm-node is the slowest leg and no explanation is offered, because none has
been tested. The suspicion is the harness rather than Node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* bench(hdrgen): write results beside the run's scratch, not into the repo

The table is the output; the JSON is an artefact of one machine on one
afternoon. Writing it to the repository root left it sitting in git status
looking like something to commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: the WebAssembly build is not 2x native, it is about native

The claim was never measured. Merging the 18-frame reference bracket takes
13.4s in WebKit and 14.5s in Chromium against 13.6s for a native arm64 build of
the same commit, so the figure was pessimistic for the browser, which is the
case the web build actually runs.

Keeps the two caveats that make the number honest rather than triumphal: the
native leg reads frames from disk inside the timed region and the WebAssembly
legs do not, and a merge is not pure compute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(bench): keep the benchmark out of Jest, and satisfy the linter

CI cancelled after fifteen minutes with no failed step, which read as
infrastructure and was not. Jest does collect `.mjs` despite its default
testMatch listing only `[jt]s`, so `bench.test.mjs` was being pulled into the
jsdom environment, where `node:test` means nothing. `scripts/` now sits beside
the two end-to-end directories in testPathIgnorePatterns, for the same reason
they are there: it has its own runner.

The rest is lint. The browser leg's evaluate body was one 26-branch block and
is now load, time, classify; a helper in the reporter shadowed a parameter; and
three await-in-loop suppressions sat one line above the await they were meant
to cover, which silently made them apply to nothing.

Verified against every gate CI runs: 61 suites and 406 tests with no bench file
collected, tsc clean at the root and in e2e-web, no lint errors, and the
browser leg still measuring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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