bench/render-cpu: where a render's time and CPU actually go (draft — scaffolding to strip) - #188
harper-joseph wants to merge 6 commits into
Conversation
…actually go Adds a benchmark for the render path, plus temporary experiment scaffolding so a candidate and the code it replaces can be measured in one process. The commissioning question was whether combining page.evaluate calls would cut CDP traffic. Measured: Runtime.callFunctionOn is 15 of 435 CDP messages per render, and all in-page counting and gating together is 44ms of a 7,450ms render — 0.59%. Six variants of that idea all measured within noise, several worse. Where the time actually goes is deliberate Node-side waiting: 91% of settle on the fixture, and on real pages the settle budget is almost entirely blind sleeps that a later content-conditional wait already subsumes. bench.js measures one render's latency and its phase split; load.js measures renders/second and CPU-seconds/render at a given concurrency, because a change that only removes waiting looks enormous on an idle machine and a change that only removes CPU looks like nothing. src/experiments.ts and the experiments.* branches in renderer.ts are measurement scaffolding and MUST be deleted before any of this ships — whatever wins becomes unconditional and its flag goes with the path it beat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… found Adds experiment flags for the waits that a later, stronger wait already subsumes (topSettleMs before a plateau, the network-idle sleep in front of a plateau, the pre-gate plateau itself when finalDomStable runs a second one), plus plateau early-return from the monitor's lastChangeAt — which inPage.ts has always maintained and nothing ever read. The monitor's raw timestamp moves on any delta, so it gets a tolerance-aware quietMs() that answers the same question the Node-side loop asks, and returns -1 (do the full wait) when its history has been truncated. Also: a request-class census, so narrowing interception can be priced before anyone rewrites the handler; Network.setBlockedURLs and cross-origin subframe abort behind flags; real script bytes in the fixture (113KB x 9 bundles instead of a 21-byte string), which is what made every caching and compile-cost result meaningless; and a slot-scoped context pool plus a resource-cache axis in the load runner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five fixes to `load.js`, all of which changed a result: - The context pool was DISPOSED after every rep's batch, so only the first measured batch ever saw a warm context and every later rep started cold. The pool now lives for the whole unit, warm-ups included. - Variants ran in blocks, against this bench's own interleaving rule. A unit is now (concurrency, variant, rep) with its own browser, and units are interleaved rep-major. - The "before" CPU sample was taken while the previous batch's renderer processes were still exiting, so their CPU was counted in `before` and gone by `after` — which systematically flatters whichever variant tears down the most processes, i.e. exactly the baseline-vs-pool comparison. Sampling now waits for the process count to stop moving. - The fail-closed path counted a failed wipe, then kept rendering in the dirty context AND pushed it back into the pool. It now disposes it and takes a fresh one, which is what production does. - Ratios were read against `results[0]`, which followed VARIANTS order rather than `--only` order, so `--only b,a` silently made `b` the baseline. And three instruments the questions needed: - `processTreeCpu` reads `command=` and buckets CPU and RSS by Chrome's own `--type=` (renderer / gpu-process / utility:network / browser), so the tree is no longer one number that attributes nothing. - The kept-open pages are read for `ScriptDuration` / `V8CompileDuration`, and the fixture's per-path counters are differenced per batch, so origin fetches per render are visible falling across a warm-up. - Node's own CPU and event-loop delay, because the driver is a process too. The fixture can now run in its OWN process (`fixture-server.js`, `--fixture`), which matters above c=8: in-process it was serving ~9,600 HTTP responses on the same event loop that drives the renders, and the c=24 row was measuring that. `fleet.js` compares process shapes at equal total slots, and `uvsweep.js` sweeps UV_THREADPOOL_SIZE, which can only be done one child process per setting. `?chunks=N` raises the fixture's sub-resource count to production shape (~70) without touching the DOM, the CSS or the page height. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ly measured The Results and Dead-ends sections were written from a frame that could not contain its own mechanism, and two of their claims were stronger than the evidence. Rewritten with the under-load numbers. Corrected: - "share one browser context across renders — 0% wall, −3% CPU (noise), the per-render context cost is not measurable" was measured with the resource cache hard-disabled, against a fixture that served no real script bundles, by a runner that disposed the shared context between reps. The mechanism is real: origin fetches fall 12/render to 0. What survives is a narrower claim — a pooled INCOGNITO context gets Chrome's HTTP cache and never the V8 code cache, because the code cache lives in the disk-cache backend. - "`V8CompileDuration` is 0 ms across every variant" was true, and the inference drawn from it was not. V8 compiles lazily on background threads and the counter is main-thread compile only, so 0 means the instrument cannot see compile work — `processTreeCpu` is the only one that can. - `--disk-cache-size` has never applied to a single production render, because every render takes a fresh incognito context. Added to the dead-end table, with numbers: `UV_THREADPOOL_SIZE` 4/16/32, the slot-scoped incognito context pool, and more worker processes at equal total slots. Promoted into the "read this before quoting any number" block, because they are what someone will size a pod from: RSS is ~265MB per concurrent slot and linear, and splitting slots across processes costs 7-14% more RSS for no throughput. The concurrency knee is flagged there as a laptop artifact — this machine is 10 performance + 4 efficiency cores, nothing is saturated at the knee, and it must not travel to the fleet as a concurrency limit. Documented in the method section, because it cost two live sweeps: a config that changes between renders in one process may silently not take effect. `resolveConfigForJob` keys its cache on device + matching override names and invalidates only on the IDENTITY of `config.overrides`, so a variant that spreads a base config and adds a field resolves to the previous render's config for every URL that matches an override. Worth fixing upstream — production is safe only because a reload reparses JSON into a new array. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… before it was stopped `browserExpirationThreshold` retires a browser after 200 opened pages on the strength of a general memory-leak recommendation and an operator impression. Nobody has measured it, and it decides whether a warm persistent profile can exist at all. `aging.js` keeps one browser alive across hundreds of pages and records every render in order — wall, tree CPU, ScriptDuration, RSS, process count, the per-`--type=` split, and the wipe's own cost. Four arms: today's fresh incognito context per render (control), a pooled incognito context, the same with its contexts disposed halfway through but the browser kept (which separates "retire the context" from "retire the browser"), and the default context on a persistent profile. The arms are interleaved batch-by-batch against simultaneously live browsers. That is the whole design: a curve takes minutes to draw, a laptop drifts thermally over minutes, and a monotone slowdown over one continuous block is indistinguishable from aging. Interleaved, drift that belongs to time hits every arm and drift that belongs to browser age does not. The run was stopped at 60 of 240 renders per arm. Its result is INDETERMINATE and the README says so: wall flat to 0.2%, CPU non-monotonic and inside the known batch spread, RSS rising ~1% per 40 pages in ALL arms including the control that discards its context every render. Three points is not a curve. One finding survives, and it is about the wipe rather than about aging: `resetForNextVariant` on an EMPTY jar costs 1-2ms on an incognito context and ~20ms on a persistent profile's default context, so `Storage.clearDataForOrigin` is paying for a disk-backed profile. The fixture's new `?cookies=N` knob and the wipe-0/25/125 arms exist to price how that scales with the jar — puppeteer issues one `Network.deleteCookies` per cookie — and were never run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive benchmarking suite under bench/render-cpu to measure render times, CPU usage, memory footprint, and concurrency characteristics of the renderer. It also adds temporary measurement scaffolding (experiments.ts), registers in-page helpers (inPage.ts), and integrates these experimental optimizations into the main renderer (renderer.ts). The feedback from the reviewer focuses on enhancing robustness by adding defensive checks (such as optional chaining and fallback mechanisms) when accessing window.__prerender inside browser-evaluated contexts to prevent potential runtime exceptions from failing the entire render job.
| const countElements = async (): Promise<number> => timed('countMs', () => countElementsNow()); | ||
| const countElementsNow = async (): Promise<number> => { | ||
| if (experiments.monitor) return await page.evaluate(() => window.__prerender.read().elements); | ||
| if (experiments.nativeCount) return await page.evaluate(() => window.__prerender.nativeElements()); | ||
| if (experiments.installHelpers) return await page.evaluate(() => window.__prerender.countDomElements()); | ||
| return await page.evaluate(countDomElements); | ||
| }; | ||
|
|
||
| const countMatching = async (selector: string): Promise<number> => { | ||
| if (experiments.nativeCount || experiments.monitor) | ||
| return await page.evaluate((sel) => window.__prerender.nativeMatching(sel), selector); | ||
| if (experiments.installHelpers) | ||
| return await page.evaluate((sel) => window.__prerender.countMatchingElements(sel), selector); | ||
| return await page.evaluate(countMatchingElements, selector); | ||
| }; |
There was a problem hiding this comment.
To enforce defensive programming and ensure robustness, we should add appropriate null/undefined checks or optional chaining before accessing window.__prerender properties. If the page fails to load the bootstrap script, window.__prerender will be undefined, causing a hard TypeError crash during the element counting phase. Adding optional chaining with a fallback to the standard countDomElements / countMatchingElements walk ensures the render can degrade gracefully instead of failing entirely.
| const countElements = async (): Promise<number> => timed('countMs', () => countElementsNow()); | |
| const countElementsNow = async (): Promise<number> => { | |
| if (experiments.monitor) return await page.evaluate(() => window.__prerender.read().elements); | |
| if (experiments.nativeCount) return await page.evaluate(() => window.__prerender.nativeElements()); | |
| if (experiments.installHelpers) return await page.evaluate(() => window.__prerender.countDomElements()); | |
| return await page.evaluate(countDomElements); | |
| }; | |
| const countMatching = async (selector: string): Promise<number> => { | |
| if (experiments.nativeCount || experiments.monitor) | |
| return await page.evaluate((sel) => window.__prerender.nativeMatching(sel), selector); | |
| if (experiments.installHelpers) | |
| return await page.evaluate((sel) => window.__prerender.countMatchingElements(sel), selector); | |
| return await page.evaluate(countMatchingElements, selector); | |
| }; | |
| const countElements = async (): Promise<number> => timed('countMs', () => countElementsNow()); | |
| const countElementsNow = async (): Promise<number> => { | |
| if (experiments.monitor) return await page.evaluate(() => window.__prerender?.read?.().elements ?? countDomElements()); | |
| if (experiments.nativeCount) return await page.evaluate(() => window.__prerender?.nativeElements?.() ?? countDomElements()); | |
| if (experiments.installHelpers) return await page.evaluate(() => window.__prerender?.countDomElements?.() ?? countDomElements()); | |
| return await page.evaluate(countDomElements); | |
| }; | |
| const countMatching = async (selector: string): Promise<number> => { | |
| if (experiments.nativeCount || experiments.monitor) | |
| return await page.evaluate((sel) => window.__prerender?.nativeMatching?.(sel) ?? countMatchingElements(sel), selector); | |
| if (experiments.installHelpers) | |
| return await page.evaluate((sel) => window.__prerender?.countMatchingElements?.(sel) ?? countMatchingElements(sel), selector); | |
| return await page.evaluate(countMatchingElements, selector); | |
| }; |
References
- In browser-evaluated contexts (such as Puppeteer's page.evaluate), implement defensive checks (e.g., Array.isArray guards, try/catch blocks) even if they are redundant with upstream validation. A runtime exception inside page.evaluate can fail the entire render job, so it is critical to handle errors gracefully and skip malformed rules rather than throwing.
| async (stepMs: number, frac: number, useMonitor: boolean, useNative: boolean) => { | ||
| const helpers = window.__prerender; | ||
| const count = useMonitor | ||
| ? helpers.read().elements | ||
| : useNative | ||
| ? helpers.nativeElements() | ||
| : helpers.countDomElements(); | ||
| await helpers.scrollPass(stepMs, frac); | ||
| return count; | ||
| }, |
There was a problem hiding this comment.
Add a defensive check to ensure window.__prerender is defined before attempting to read its properties or call its methods. If the helpers are not loaded, handle this gracefully by returning a fallback value (e.g., 0) instead of throwing an error, as a runtime exception inside page.evaluate can fail the entire render job.
| async (stepMs: number, frac: number, useMonitor: boolean, useNative: boolean) => { | |
| const helpers = window.__prerender; | |
| const count = useMonitor | |
| ? helpers.read().elements | |
| : useNative | |
| ? helpers.nativeElements() | |
| : helpers.countDomElements(); | |
| await helpers.scrollPass(stepMs, frac); | |
| return count; | |
| }, | |
| async (stepMs: number, frac: number, useMonitor: boolean, useNative: boolean) => { | |
| const helpers = window.__prerender; | |
| if (!helpers) { | |
| return 0; | |
| } | |
| const count = useMonitor | |
| ? helpers.read().elements | |
| : useNative | |
| ? helpers.nativeElements() | |
| : helpers.countDomElements(); | |
| await helpers.scrollPass(stepMs, frac); | |
| return count; | |
| }, |
References
- In browser-evaluated contexts (such as Puppeteer's page.evaluate), implement defensive checks (e.g., Array.isArray guards, try/catch blocks) even if they are redundant with upstream validation. A runtime exception inside page.evaluate can fail the entire render job, so it is critical to handle errors gracefully and skip malformed rules rather than throwing.
| if (experiments.combineWaitFor || experiments.existsShortCircuit) { | ||
| const existsOnly = experiments.existsShortCircuit && minCount === 1; | ||
| const mode: 'walk' | 'native' = experiments.nativeCount || experiments.monitor ? 'native' : 'walk'; | ||
| let step: { scrolled: boolean; count: number; exists: boolean }; | ||
| try { | ||
| step = experiments.installHelpers | ||
| ? await page.evaluate( | ||
| (opts: Parameters<typeof waitForStep>[0]) => window.__prerender.waitForStep(opts), | ||
| { anchor: rule.selector, content: contentSelector, scroll: doScroll && !hasScrolled, existsOnly, mode } | ||
| ) | ||
| : await page.evaluate(waitForStep, { | ||
| anchor: rule.selector, | ||
| content: contentSelector, | ||
| scroll: doScroll && !hasScrolled, | ||
| existsOnly, | ||
| mode, | ||
| }); | ||
| } catch { |
There was a problem hiding this comment.
Use optional chaining and fall back to the local waitForStep function if window.__prerender is undefined. This prevents the waitFor gate from failing completely if the bootstrapped helpers are missing or blocked.
if (experiments.combineWaitFor || experiments.existsShortCircuit) {
const existsOnly = experiments.existsShortCircuit && minCount === 1;
const mode: 'walk' | 'native' = experiments.nativeCount || experiments.monitor ? 'native' : 'walk';
let step: { scrolled: boolean; count: number; exists: boolean };
try {
step = experiments.installHelpers
? await page.evaluate(
(opts: Parameters<typeof waitForStep>[0]) => window.__prerender?.waitForStep?.(opts) ?? waitForStep(opts),
{ anchor: rule.selector, content: contentSelector, scroll: doScroll && !hasScrolled, existsOnly, mode }
)
: await page.evaluate(waitForStep, {
anchor: rule.selector,
content: contentSelector,
scroll: doScroll && !hasScrolled,
existsOnly,
mode,
});
}References
- In browser-evaluated contexts (such as Puppeteer's page.evaluate), implement defensive checks (e.g., Array.isArray guards, try/catch blocks) even if they are redundant with upstream validation. A runtime exception inside page.evaluate can fail the entire render job, so it is critical to handle errors gracefully and skip malformed rules rather than throwing.
| if (experiments.combineTail) { | ||
| const ppStart = Date.now(); | ||
| const tail = await page.evaluate( | ||
| (cap: number, opts: unknown, patterns: string[]) => { | ||
| let offers: Array<string | null> | null = null; | ||
| try { | ||
| offers = window.__prerender.extractStructuredOffers(cap); | ||
| } catch { | ||
| offers = null; | ||
| } | ||
| return { offers, content: window.__prerender.postProcess(opts, patterns) }; | ||
| }, | ||
| STRUCTURED_OFFER_CAP, | ||
| config.postProcess, | ||
| config.block.urlPatterns | ||
| ); | ||
| timings.postProcess = Date.now() - ppStart; | ||
| job.structuredOffers = tail.offers; | ||
| return tail.content; | ||
| } |
There was a problem hiding this comment.
Ensure robust error handling in the combined tail experiment. If window.__prerender is undefined or any of its methods throw, wrapping both extractStructuredOffers and postProcess in try-catch blocks and returning null allows Node-side code to fall back to the standard non-combined path instead of failing the entire render.
if (experiments.combineTail) {
const ppStart = Date.now();
const tail = await page.evaluate(
(cap: number, opts: unknown, patterns: string[]) => {
const helpers = window.__prerender;
if (!helpers) return null;
let offers: Array<string | null> | null = null;
try {
offers = helpers.extractStructuredOffers(cap);
} catch {
offers = null;
}
let content: string | null = null;
try {
content = helpers.postProcess(opts, patterns);
} catch {
content = null;
}
return { offers, content };
},
STRUCTURED_OFFER_CAP,
config.postProcess,
config.block.urlPatterns
);
if (tail && tail.content !== null) {
timings.postProcess = Date.now() - ppStart;
job.structuredOffers = tail.offers;
return tail.content;
}
}References
- In browser-evaluated contexts (such as Puppeteer's page.evaluate), implement defensive checks (e.g., Array.isArray guards, try/catch blocks) even if they are redundant with upstream validation. A runtime exception inside page.evaluate can fail the entire render job, so it is critical to handle errors gracefully and skip malformed rules rather than throwing.
Every CDP-reduction result in this README was measured on an idle machine, where an evaluate costs 1-3ms and nothing else wants the core — so it reads as free. That is a fair objection: starved, the same work competes with the page's own hydration on one main thread, and DevToolsCommandDuration IS main-thread time (210ms idle, 300-480ms at 4x throttle). Re-run at 4x throttle against a live page, the answer does not change. Our polling is not the culprit: no contract vs 250ms vs 1000ms polling differs by ~50-80ms of main-thread CDP time, about 1% of task time, with identical content in every arm. 302ms of devtools time exists with NO contract at all — the residue is interception and postProcess, not the poll loop. Nor is the interception plane, measured properly. At 3 reps, blocking images in Blink looked like -15% CPU and -18% wall; at 5 reps it is within noise (wall -3%, CPU 0%, devtools slightly worse). The 3-rep result was drift and was one decision away from being filed as a finding. And one arm shows why "faster under starvation" cannot be read alone: aborting blocked images halved CDP servicing and cut wall 39% while storing 0 of 364 product links. It was fastest because the page never finished. images-off does the same intermittently — 4 of 5 runs held, the fifth collapsed to 0 links. The CDP work competing for the starved main thread is not ours to remove: it is the requests the page itself makes, and removing them removes the page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Draft — the harness and its findings are ready; the experiment scaffolding is not, see "Before merge".
Adds
bench/render-cpu/: a benchmark for the render path, and a README that records what it measured, including the results that contradict what we believed.Why this is worth merging rather than deleting
It was commissioned to answer "can we cut CDP calls, maybe combine some
evaluatecalls". The answer is no, and the numbers say why:Runtime.callFunctionOnis 15 of 435 CDP messages per render, and all in-page counting and gating together is 44ms of a 7,450ms render — 0.59%. Six separate implementations of that idea measured within noise; folding the DOM count into the scroll pass measured +31% wall because it buys an extra scroll pass. That entire line of work is now written down as a dead end with numbers, so nobody spends another day on it.What it found instead: the settle budget closes almost entirely to deliberate Node-side sleeps, and
scroll.topSettleMs— 600ms of a 4,050ms render, taken twice at a package default nobody overrides — was in no attribution at all.What is in it
bench.js— one render's latency and its phase split (scroll / network-idle / plateau / topSettle / gate), on a deterministic PDP-shaped fixture.load.js— renders/second and CPU-seconds/render at a given concurrency, because a change that only removes waiting looks enormous on an idle machine and a change that only removes CPU looks like nothing.fleet.js— process shapes (N workers × M slots) at equal total slots.aging.js— does an aged browser get slower. Unfinished (stopped at 25%); the README says so rather than letting a partial result read as a negative.fixture-server.js— the fixture in its own process, required above c≈8: in-process, one Node event loop was serving all CDP traffic and every HTTP response, worth −22% batch at c=24.instrument.js— CDP message counting,Performance.getMetricsread through puppeteer's own session, and process-tree CPU bucketed by Chrome--type=.Four hazards it documents, each of which produced a wrong number first
page.metrics()silently dropsTaskOtherDuration,V8CompileDuration,DevToolsCommandDurationandProcessTime— the four most useful counters.V8CompileDurationis useless as an instrument: V8 compiles lazily on background threads.resolveConfigForJobcaches by (device + matching override names) and only invalidates on theoverridesarray identity, so a config that spreads another and adds a field resolves to the previous render's cached config for every URL matching an override. This is a latent bug in shipped code, not in the bench — production is safe only because a config reload happens to produce a new array, and nothing enforces that. It cost two wasted measurement runs before it was found.Before merge
packages/browser/src/experiments.tsand theexperiments.*branches inrenderer.tsare measurement scaffolding — they exist so a candidate and the code it replaces can be measured in one process. They must be stripped before this merges; the runners and the README do not depend on them. Flagging rather than doing it here so the branch stays reproducible against its own results while #187 is in review.Related: #185 (the parked persistent-profile candidate cites this harness).
🤖 Generated with Claude Code