Skip to content

bench/render-cpu: where a render's time and CPU actually go (draft — scaffolding to strip) - #188

Draft
harper-joseph wants to merge 6 commits into
mainfrom
perf/fleet-bench
Draft

harper-joseph wants to merge 6 commits into
mainfrom
perf/fleet-bench

Conversation

@harper-joseph

Copy link
Copy Markdown
Contributor

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 evaluate calls". The answer is no, and the numbers say why: 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 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.getMetrics read 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

  1. page.metrics() silently drops TaskOtherDuration, V8CompileDuration, DevToolsCommandDuration and ProcessTime — the four most useful counters.
  2. Process-tree CPU must be sampled while pages are still open, or a render whose context has exited takes its accounting with it.
  3. V8CompileDuration is useless as an instrument: V8 compiles lazily on background threads.
  4. resolveConfigForJob caches by (device + matching override names) and only invalidates on the overrides array 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.ts and the experiments.* branches in renderer.ts are 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

harper-joseph and others added 5 commits September 18, 2026 15:21
…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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +536 to +550
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);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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
  1. 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.

Comment on lines +633 to +642
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;
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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
  1. 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.

Comment on lines +757 to +774
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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. 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.

Comment on lines +923 to +942
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. 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>
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