Skip to content
Merged
42 changes: 30 additions & 12 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,19 +432,24 @@ unavailable CPU clock fail the run; timing never does. The nightly runs
reconnects x 64 characters, and keeps its run directory as the `bench-browser`
artifact.

The in-memory frame, retained-history, gap ledger, bandwidth allocation, and resume-history paths also run directly in Chromium:
The in-memory frame, retained-history, gap ledger, bandwidth allocation, cached-group
replay, and resume-history paths also run directly in Chromium:

```bash
nix develop --command bun bench/browser/micro.ts --bench frames --out .tmp/frames.json base=/path/to/release current=. aa=.
nix develop --command bun bench/browser/micro.ts --bench drift --out .tmp/drift.json before=/path/to/main current=. aa=.
nix develop --command bun bench/browser/micro.ts --bench ledger --out .tmp/ledger.json before=/path/to/main current=. aa=.
nix develop --command bun bench/browser/micro.ts --bench bandwidth --out .tmp/bandwidth.json before=/path/to/main current=. aa=.
nix develop --command bun bench/browser/micro.ts --bench replay --out .tmp/replay.json before=/path/to/main current=. aa=.
nix develop --command bun bench/browser/micro.ts --bench resume-history --out .tmp/resume-history.json before=/path/to/main current=. aa=.
```

Each variant uses its own workspace source and a persistent page, with one full
Each variant uses its own workspace source and a persistent, cross-origin isolated
page, so `performance.now()` resolves 5 µs rather than 100 µs, with one full
warmup before three alternating measured rounds. `aa` must bundle identically to
`current`. The JSON preserves every CSV sample and the browser version. `frames`
`current`. The JSON preserves every CSV sample and the browser version. Replay
and resume-history also record each variant's main-thread GC count, pause and
reclaimed heap and the rows from one traced run after the timed rounds. `frames`
runs the checkout's existing `js/net/bench/frames.ts`; `drift` uses the same fixture
against every checkout, sweeping retained groups, publishers, and readers. Replay
and group setup stay outside drift timing; each parked read checks its live
Expand All @@ -463,23 +468,36 @@ Its `time_mode` column is 0 for untimed, 1 for millisecond timestamps, and 2
for mixed millisecond/microsecond timestamps, which exercise exact cross-scale
comparison.

`replay` sweeps retained groups (64, 512, 2048) against subscribers (1, 8) for
monotonic, reverse, and shuffled arrivals. `join` rows time each subscriber
joining the cache and replaying every retained group; `append` rows time each of
64 arrivals fanned into every subscriber's unread backlog, landing inside the
backlog for reverse and shuffled orders. `max_us` is the slowest single operation,
the longest the event loop was held. An untimed pass first prints each shape's
`Array.prototype.sort` calls, elements and comparator visits, ahead of the timing
header so the nightly report keeps only timings as series. Every subscriber then
drains, requiring each sequence once, ascending, with its payload. Each shape then
closes its readers and track and advances its own manual clock past cache expiry,
outside timing, so completed shapes cannot accumulate through prune timers.

`resume-history` moves long-lived logical fronts across lite sessions over the
in-memory transport, make-before-break as a session draining after GOAWAY does.
It sweeps fronts, names read per route, and route count, with `stable` names
(the same ones on every route) against `churn` (new names first answered by each
route), each read by info lookup, fetch, or subscription and then closed. Once a
manual clock ages out every cache, full collections from Chromium's exposed `gc`
let weak references count the retired routes' sessions, transports, broadcast
handles, and delivered payload buffers still reachable. These counts are exact,
so any nonzero one fails the run once every sample is written, leaving a retaining
`before` revision's counts in the JSON. A concluded control must release everything,
manual clock ages out every cache, asynchronous major collections from Chromium's
exposed `gc` run without stack heap pointers, so dead stack slots cannot pin a route
even under tracing. Weak references count the retired routes' sessions, transports,
broadcast handles, and delivered payload buffers still reachable. These counts are exact,
so any nonzero one in a timed or traced pass fails the run once every sample is
written, leaving a retaining `before` revision's counts in the JSON. A concluded control must release everything,
or the fixture fails. `size_bytes` is the JS heap the fronts and their serving
route hold, from Chromium's precise memory info; `serve_us` (clamped to the
page's coarse timer) and `read_us` include no setup. Bun's collector scans its
route hold, from Chromium's precise memory info; `serve_us` and
`read_us` include no setup. Bun's collector scans its
stack conservatively, so the fixture refuses to run there.

The browser smoke lane preserves one measured round of all five fixtures, and drift,
ledger and bandwidth also run in the nightly JS benchmark leg. Timing is
The browser smoke lane preserves one measured round of all six fixtures, and drift,
ledger, bandwidth and replay also run in the nightly JS benchmark leg. Timing is
observational, with the A/A spread showing the host's noise floor.

## Pattern and container coverage
Expand Down
50 changes: 5 additions & 45 deletions bench/browser/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { Media } from "./page/media.ts";
import type { Dial, Mode, Racing } from "./page/racing.ts";
import type { Delivery, Transport } from "./page/transport.ts";
import { compare, type Row, summarize } from "./summary.ts";
import { collections, GC_CATEGORIES, rendererPid, type TraceEvent, traceEvents } from "./trace.ts";

const { values } = parseArgs({
options: {
Expand Down Expand Up @@ -275,52 +276,13 @@ async function open(variant: string, kind: string, counted = false): Promise<Ope
const cdp = await context.newCDPSession(page);
// Refuse an unavailable CPU clock rather than relabeling wall time as CPU.
await cdp.send("Performance.enable", { timeDomain: "threadTicks" });
return { page, cdp, pid: await rendererPid(page), errors, close: () => context.close() };
return { page, cdp, pid: await pagePid(page), errors, close: () => context.close() };
}

// The renderer process behind a page, from the frame list a trace starts with.
async function rendererPid(page: Page): Promise<number> {
async function pagePid(page: Page): Promise<number> {
await browser.startTracing(page, { categories: ["disabled-by-default-devtools.timeline"] });
const events = traceEvents(await browser.stopTracing());
const url = page.url();
for (const event of events) {
if (event.name !== "TracingStartedInBrowser") continue;
const frames = (event.args?.data?.frames ?? []) as { url: string; processId: number }[];
const frame = frames.find((frame) => frame.url === url);
if (frame) return frame.processId;
}
throw new Error(`no renderer process found for ${url}`);
}

interface TraceEvent {
name: string;
ph: string;
pid: number;
tid: number;
dur?: number;
args?: { data?: { frames?: unknown }; usedHeapSizeBefore?: number; usedHeapSizeAfter?: number };
}

function traceEvents(buffer: Buffer): TraceEvent[] {
const trace = JSON.parse(buffer.toString()) as { traceEvents?: TraceEvent[] } | TraceEvent[];
return Array.isArray(trace) ? trace : (trace.traceEvents ?? []);
}

// GC on one renderer's main thread: total pause, count, and the JS heap each collection reclaimed.
function collections(events: TraceEvent[], pid: number) {
const gcs = events.filter(
(event) => event.pid === pid && event.tid === pid && (event.name === "MinorGC" || event.name === "MajorGC"),
);
const reclaimed = gcs.reduce(
(sum, event) => sum + Math.max(0, (event.args?.usedHeapSizeBefore ?? 0) - (event.args?.usedHeapSizeAfter ?? 0)),
0,
);
return {
count: gcs.length,
minor: gcs.filter((event) => event.name === "MinorGC").length,
pause_ms: gcs.reduce((sum, event) => sum + (event.dur ?? 0), 0) / 1000,
reclaimed_bytes: reclaimed,
};
return rendererPid(traceEvents(await browser.stopTracing()), page.url());
}

async function metrics(opened: Opened) {
Expand Down Expand Up @@ -397,9 +359,7 @@ async function trace(
await run();
return undefined;
}
await browser.startTracing(page, {
categories: ["devtools.timeline", "v8", "disabled-by-default-v8.gc", "disabled-by-default-devtools.timeline"],
});
await browser.startTracing(page, { categories: GC_CATEGORIES });
let buffer: Buffer | undefined;
try {
await run();
Expand Down
77 changes: 57 additions & 20 deletions bench/browser/micro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { resolve } from "node:path";
import { parseArgs } from "node:util";
import { chromium, type Page } from "playwright";
import { collections, GC_CATEGORIES, rendererPid, traceEvents } from "./trace.ts";
import { workspace } from "./workspace.ts";

const { values, positionals } = parseArgs({
Expand All @@ -14,10 +15,16 @@ const { values, positionals } = parseArgs({
},
});
const benchmark = values.bench;
const expectedRows = (
{ frames: 26, drift: 36, ledger: 12, bandwidth: 44, "resume-history": 72 } as Record<string, number>
)[benchmark];
if (expectedRows === undefined) throw new Error("--bench must be frames, drift, ledger, bandwidth, or resume-history");
// The rows each benchmark prints per run.
const ROWS: Record<string, number> = {
frames: 26,
drift: 36,
ledger: 12,
bandwidth: 44,
replay: 72,
"resume-history": 72,
};
if (!Object.hasOwn(ROWS, benchmark)) throw new Error(`--bench must be one of ${Object.keys(ROWS).join(", ")}`);
const rounds = Number(values.rounds);
if (!Number.isSafeInteger(rounds) || rounds < 1) throw new Error("--rounds must be a positive integer");
const roots = new Map<string, string>();
Expand Down Expand Up @@ -48,7 +55,7 @@ for (const [name, root] of roots) {
: undefined,
);
build.onLoad(
{ filter: /\/bench\/(frames|drift|ledger|bandwidth|resume-history)\.ts$/ },
{ filter: /\/bench\/(frames|drift|ledger|bandwidth|replay|resume-history)\.ts$/ },
async ({ path }) => {
const source = await Bun.file(path).text();
if (benchmark !== "frames")
Expand Down Expand Up @@ -83,8 +90,13 @@ const server = Bun.serve({
});
}
if (!scripts.has(name)) return new Response("unknown variant", { status: 404 });
// Cross-origin isolated, so performance.now() resolves 5 us instead of 100 us.
return new Response(`<script type="module" src="/${name}.js"></script>`, {
headers: { "Content-Type": "text/html" },
headers: {
"Content-Type": "text/html",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
});
},
});
Expand All @@ -95,6 +107,18 @@ const browser = await chromium.launch({
args: benchmark === "resume-history" ? ["--js-flags=--expose-gc", "--enable-precise-memory-info"] : [],
});
const samples: { round: number; variant: string; rows: string[] }[] = [];
const traced: { variant: string; rows: string[] }[] = [];
// Main-thread GC across one whole run per variant, from a traced pass after the timed rounds.
const gc: Record<string, ReturnType<typeof collections>> = {};
const save = () =>
Bun.write(
values.out,
JSON.stringify(
{ benchmark, browser: browser.version(), warmup: 1, roots: Object.fromEntries(roots), samples, traced, gc },
null,
2,
),
);
try {
const pages = new Map<string, Page>();
const rows = new Map<string, string[]>();
Expand All @@ -110,34 +134,47 @@ try {
void page.close();
});
await page.goto(`http://127.0.0.1:${server.port}/${name}`);
if (!(await page.evaluate("crossOriginIsolated"))) throw new Error(`${name} page is not cross-origin isolated`);
await page.waitForFunction(() => typeof (globalThis as unknown as { measure: unknown }).measure === "function");
await page.evaluate("globalThis.measure()");
console.log(`warmed ${name}`);
}
// One run of `variant`'s page, requiring every row.
const measure = async (variant: string) => {
rows.set(variant, []);
await pages.get(variant)?.evaluate("globalThis.measure()");
const measured = rows.get(variant) ?? [];
if (measured.length !== ROWS[benchmark]) throw new Error(`incomplete rows: ${variant}`);
return measured;
};
for (let round = 0; round < rounds; round++) {
const names = [...roots.keys()];
if (round % 2) names.reverse();
for (const variant of names) {
rows.set(variant, []);
await pages.get(variant)?.evaluate("globalThis.measure()");
const measured = rows.get(variant);
if (measured?.length !== expectedRows) throw new Error(`incomplete rows: ${variant}`);
samples.push({ round, variant, rows: measured });
await Bun.write(
values.out,
JSON.stringify(
{ benchmark, browser: browser.version(), warmup: 1, roots: Object.fromEntries(roots), samples },
null,
2,
),
);
samples.push({ round, variant, rows: await measure(variant) });
await save();
console.log(`measured ${round} ${variant}`);
}
}
// Traced apart from the timed rounds, so tracing overhead never enters a timing.
for (const [variant, page] of pages) {
if (benchmark !== "replay" && benchmark !== "resume-history") continue;
await browser.startTracing(page, { categories: GC_CATEGORIES });
let buffer: Buffer | undefined;
try {
traced.push({ variant, rows: await measure(variant) });
} finally {
buffer = await browser.stopTracing();
}
const events = traceEvents(buffer);
gc[variant] = collections(events, rendererPid(events, page.url()));
await save();
console.log(`traced ${variant}`);
}
// Reachability is exact here, so a retired route still reachable fails the run, once every
// variant's samples are written for comparison.
if (benchmark === "resume-history") {
const retaining = samples.filter(({ rows }) =>
const retaining = [...samples, ...traced].filter(({ rows }) =>
rows.some((row) =>
row
.split(",")
Expand Down
2 changes: 1 addition & 1 deletion bench/browser/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ bun install --frozen-lockfile

cd "$HERE"
# The in-memory paths share Chromium installation and artifact retention with this lane.
for benchmark in frames drift ledger bandwidth resume-history; do
for benchmark in frames drift ledger bandwidth replay resume-history; do
bun micro.ts --bench "$benchmark" --rounds 1 --out "$HARNESS_RUN/$benchmark.json" \
current="$WORKSPACE" aa="$WORKSPACE"
done
Expand Down
51 changes: 51 additions & 0 deletions bench/browser/trace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/** Chrome trace parsing shared by the browser benchmark runners. */

/** Categories a GC-attributing trace records. */
export const GC_CATEGORIES = [
"devtools.timeline",
"v8",
"disabled-by-default-v8.gc",
"disabled-by-default-devtools.timeline",
];

export interface TraceEvent {
name: string;
ph: string;
pid: number;
tid: number;
dur?: number;
args?: { data?: { frames?: unknown }; usedHeapSizeBefore?: number; usedHeapSizeAfter?: number };
}

export function traceEvents(buffer: Buffer): TraceEvent[] {
const trace = JSON.parse(buffer.toString()) as { traceEvents?: TraceEvent[] } | TraceEvent[];
return Array.isArray(trace) ? trace : (trace.traceEvents ?? []);
}

/** The renderer process showing `url`, from the frame list a trace starts with. */
export function rendererPid(events: TraceEvent[], url: string): number {
for (const event of events) {
if (event.name !== "TracingStartedInBrowser") continue;
const frames = (event.args?.data?.frames ?? []) as { url: string; processId: number }[];
const frame = frames.find((frame) => frame.url === url);
if (frame) return frame.processId;
}
throw new Error(`no renderer process found for ${url}`);
}

/** GC on one renderer's main thread: total pause, count, and the JS heap each collection reclaimed. */
export function collections(events: TraceEvent[], pid: number) {
const gcs = events.filter(
(event) => event.pid === pid && event.tid === pid && (event.name === "MinorGC" || event.name === "MajorGC"),
);
const reclaimed = gcs.reduce(
(sum, event) => sum + Math.max(0, (event.args?.usedHeapSizeBefore ?? 0) - (event.args?.usedHeapSizeAfter ?? 0)),
0,
);
return {
count: gcs.length,
minor: gcs.filter((event) => event.name === "MinorGC").length,
pause_ms: gcs.reduce((sum, event) => sum + (event.dur ?? 0), 0) / 1000,
reclaimed_bytes: reclaimed,
};
}
2 changes: 1 addition & 1 deletion js/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ test-changed list:
coverage package="":
python3 ../sh/js/coverage.py '{{ package }}'

# Run one net benchmark: broadcasts, forward, requests, fills, reader, frames, track, resume, observed, drift, ledger, bandwidth, varint, lite-varint, priority, subscribe-drop, refresh, or compression.
# Run one net benchmark: broadcasts, forward, requests, fills, reader, frames, track, resume, observed, drift, ledger, bandwidth, replay, varint, lite-varint, priority, subscribe-drop, refresh, or compression.
bench name:
bun install --frozen-lockfile
bun net/bench/{{ name }}.ts
Expand Down
Loading