mirror of
https://github.com/hanzoai/react.git
synced 2026-09-24 17:02:27 +00:00
[Fixture] flight-ssr-bench: drain immediates between iterations to fix false memory leak (#36968)
Claude found this while running the Flight/Fizz bench. --- While measuring memory in this fixture, the Flight server appeared to leak ~140KB per render: `heapUsed` grew steadily across hundreds of renders even with forced GC, and roughly 3x worse when a Flight client consumed the stream in the same process. Fizz-only runs were flat, so it looked like a real Flight leak. On a closer look, the benchmark loop seems to be lying. React schedules one `setImmediate` per request, but every render in this fixture completes entirely in promises and `nextTick` callbacks, so a tight benchmark loop never gives the event loop a chance to run immediates at all. They quietly pile up, and each pending callback keeps its already-finished request alive. Heap snapshots show that every "leaked" request was held by a pending immediate, and nothing else. Adding a single `await setImmediate` per iteration makes memory completely flat over 300 renders, with or without a same-process Flight client. A real server yields to the event loop on every request, so this can't happen outside a synthetic loop. The fix is to yield to the event loop between benchmark iterations, outside the timed window, so latency numbers are unaffected. Each variant now also reports how much heap it retains after the run settles. That number was meaningless before — the pile-up made variants with bigger per-request graphs look like they used more memory when they didn't. After the fix, a full `yarn bench:bare` run retains +0.3–0.6MB total per variant after 1000 iterations (JIT/warmup noise), instead of drifting by hundreds of MB. --------- Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
committed by
GitHub
co-authored by
Hanzo Dev
parent
9277edc43a
commit
2a784bedd2
@@ -54,6 +54,8 @@ A dashboard with ~25 components (16 client components), rendering:
|
||||
|
||||
## Output
|
||||
|
||||
Each variant reports render latency stats, GC pauses, and (when run with `--expose-gc`, which the `yarn bench*` scripts do) the heap retained after the run settles.
|
||||
|
||||
The overhead tables show two comparisons:
|
||||
|
||||
1. **Flight overhead** -- Flight+Fizz vs Fizz-only (how much RSC adds)
|
||||
|
||||
@@ -93,12 +93,31 @@ function renderFlightFizzEdge(renderRSCEdge, AppComponent, itemCount) {
|
||||
|
||||
const canGC = typeof globalThis.gc === 'function';
|
||||
|
||||
// Yield to the event loop's check phase between renders. React schedules a
|
||||
// setImmediate per request, but a fully in-process render completes entirely
|
||||
// in microtasks/nextTicks, so a tight benchmark loop never lets those
|
||||
// immediates run. They then pile up in Node's immediate queue, each one
|
||||
// retaining its (otherwise finished) request graph, which inflates any memory
|
||||
// measurement. A real server yields to the event loop on every request, so
|
||||
// draining between iterations is both correct and more representative.
|
||||
function tick() {
|
||||
return new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
|
||||
async function settledHeapUsed() {
|
||||
await tick();
|
||||
if (canGC) globalThis.gc();
|
||||
return process.memoryUsage().heapUsed;
|
||||
}
|
||||
|
||||
async function runBenchmark(name, fn, iterations, warmup) {
|
||||
if (canGC) globalThis.gc();
|
||||
const heapBefore = await settledHeapUsed();
|
||||
|
||||
// Warmup
|
||||
for (let i = 0; i < warmup; i++) {
|
||||
await fn();
|
||||
await tick();
|
||||
}
|
||||
|
||||
// Collect GC pauses during timed iterations.
|
||||
@@ -112,14 +131,17 @@ async function runBenchmark(name, fn, iterations, warmup) {
|
||||
});
|
||||
gcObs.observe({entryTypes: ['gc']});
|
||||
|
||||
// Timed iterations
|
||||
// Timed iterations. The tick between iterations is excluded from the
|
||||
// timed window.
|
||||
const times = [];
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const start = performance.now();
|
||||
await fn();
|
||||
times.push(performance.now() - start);
|
||||
await tick();
|
||||
}
|
||||
gcObs.disconnect();
|
||||
const heapAfter = await settledHeapUsed();
|
||||
|
||||
// Trim top/bottom 5% to remove outliers
|
||||
const sorted = [...times].sort((a, b) => a - b);
|
||||
@@ -146,6 +168,8 @@ async function runBenchmark(name, fn, iterations, warmup) {
|
||||
iterations,
|
||||
gcCount,
|
||||
gcTotalMs,
|
||||
heapBefore,
|
||||
heapAfter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,13 +187,28 @@ function printResult(result) {
|
||||
result.gcTotalMs.toFixed(1),
|
||||
(result.gcTotalMs / result.iterations).toFixed(2)
|
||||
);
|
||||
printHeap(result);
|
||||
}
|
||||
|
||||
function printHeap(result) {
|
||||
if (!canGC) return;
|
||||
const mb = b => (b / 1048576).toFixed(1);
|
||||
const delta = result.heapAfter - result.heapBefore;
|
||||
console.log(
|
||||
' Heap: %s MB retained after run (%s%s MB vs before)',
|
||||
mb(result.heapAfter),
|
||||
delta >= 0 ? '+' : '',
|
||||
mb(delta)
|
||||
);
|
||||
}
|
||||
|
||||
async function runConcurrent(name, fn, total, concurrency, warmup) {
|
||||
if (canGC) globalThis.gc();
|
||||
const heapBefore = await settledHeapUsed();
|
||||
|
||||
for (let i = 0; i < warmup; i++) {
|
||||
await fn();
|
||||
await tick();
|
||||
}
|
||||
|
||||
let gcCount = 0;
|
||||
@@ -192,21 +231,27 @@ async function runConcurrent(name, fn, total, concurrency, warmup) {
|
||||
while (launched < total && launched - completed < concurrency) {
|
||||
const idx = launched++;
|
||||
const t0 = performance.now();
|
||||
fn().then(() => {
|
||||
latencies[idx] = performance.now() - t0;
|
||||
completed++;
|
||||
if (completed === total) {
|
||||
resolve();
|
||||
} else {
|
||||
launch();
|
||||
}
|
||||
});
|
||||
fn()
|
||||
.then(() => {
|
||||
latencies[idx] = performance.now() - t0;
|
||||
// Drain immediates before freeing the slot (see tick()).
|
||||
return tick();
|
||||
})
|
||||
.then(() => {
|
||||
completed++;
|
||||
if (completed === total) {
|
||||
resolve();
|
||||
} else {
|
||||
launch();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
launch();
|
||||
});
|
||||
const elapsed = performance.now() - start;
|
||||
gcObs.disconnect();
|
||||
const heapAfter = await settledHeapUsed();
|
||||
|
||||
const sorted = [...latencies].sort((a, b) => a - b);
|
||||
const mean = sorted.reduce((s, t) => s + t, 0) / sorted.length;
|
||||
@@ -221,6 +266,8 @@ async function runConcurrent(name, fn, total, concurrency, warmup) {
|
||||
concurrency,
|
||||
gcCount,
|
||||
gcTotalMs,
|
||||
heapBefore,
|
||||
heapAfter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,6 +282,7 @@ function printConcurrentResult(result) {
|
||||
result.gcTotalMs.toFixed(1),
|
||||
(result.gcTotalMs / result.total).toFixed(2)
|
||||
);
|
||||
printHeap(result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -307,6 +355,7 @@ async function profileRun(name, fn, warmup, iterations, outputPath) {
|
||||
// Warmup (unprofiled)
|
||||
for (let i = 0; i < warmup; i++) {
|
||||
await fn();
|
||||
await tick();
|
||||
}
|
||||
|
||||
// Collect GC pauses during the profiled run.
|
||||
@@ -324,6 +373,7 @@ async function profileRun(name, fn, warmup, iterations, outputPath) {
|
||||
const session = await startProfiler();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
await fn();
|
||||
await tick();
|
||||
}
|
||||
const profile = await stopProfiler(session, outputPath);
|
||||
gcObs.disconnect();
|
||||
|
||||
Reference in New Issue
Block a user