Skip to content
58 changes: 43 additions & 15 deletions benchmarks/memory/server/bench-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@ export type RunSequentialRequestLoopOptions =
iterations?: number
buildRequest: (random: () => number, index: number) => Request
validateResponse?: (response: Response, request: Request) => void
// For peak-shape scenarios only. Their signal is the footprint of a
// single request, but whether V8 collects iteration i's garbage before
// iteration i+1 allocates its payload is not reproducible run to run —
// the measured peak flips by a whole payload depending on GC timing.
// Forcing a collection between iterations pins the GC points so peak
// deterministically measures max(single-request footprint). Churn
// scenarios must never set this: their signal is accumulation across
// iterations, which a forced GC would mask.
// Whether V8 collects iteration i's garbage before iteration i+1
// allocates its payload is not reproducible run to run — the measured
// peak flips by a whole payload depending on GC timing, which shifts
// with runner hardware. Forcing a collection between iterations pins
// the GC points so peak deterministically measures the largest
// single-iteration footprint plus any reachable accumulation.
// Accumulation signals stay visible: leaked or cached objects are
// still referenced, so a forced collection cannot reclaim them — it
// only removes floating garbage, whose collection timing is the
// dominant cross-run noise source.
pinGcBetweenIterations?: boolean
}

Expand Down Expand Up @@ -102,13 +104,39 @@ export async function runSequentialRequestLoop(
await drainResponse(response)

if (pinGcBetweenIterations) {
// Two 0ms timer hops first, so trailing renderer/stream teardown
// scheduled for the next turns runs before the collection — otherwise
// its garbage survives into the next iteration's measurements.
await new Promise<void>((resolve) => setTimeout(resolve, 0))
await new Promise<void>((resolve) => setTimeout(resolve, 0))
// Present under CodSpeed (--expose-gc); absent in plain smoke runs.
;(globalThis as { gc?: () => void }).gc?.()
await settleAndPinGc()
}
}
}

// Renderer/stream teardown spans several event-loop turns (React and Vue
// need more than the two turns the original barrier allowed for), and a
// too-short settle window leaks a whole payload of garbage past the
// collection point, flipping the measured peak bimodally between runs.
// Every step here is a fixed count on purpose: an adaptive exit (collect
// until the post-GC heap size stops moving) makes the collection points
// land at data-dependent turns, which re-introduces exactly the run-to-run
// variance this barrier exists to remove — heap-size readings never fully
// stabilize under the instrumented CI environment, so the exit point (and
// with it every subsequent GC point) shifted between identical runs.
const settleTurnsBeforeGc = 16

// Settle trailing renderer/stream teardown with a fixed number of 0ms timer
// hops (one full event-loop turn each, microtasks flushing between hops),
// then pin a collection point. The second collection one turn later picks
// up whatever the first collection's finalizers released. gc() is present
// under CodSpeed (--expose-gc); in plain smoke runs there is nothing to
// pin, so only the settle hops run.
export async function settleAndPinGc() {
for (let turn = 0; turn < settleTurnsBeforeGc; turn++) {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}

const gc = (globalThis as { gc?: () => void }).gc

if (gc) {
gc()
await new Promise<void>((resolve) => setTimeout(resolve, 0))
gc()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server aborted-requests (react)',
watch: false,
environment: 'node',
Expand Down
17 changes: 7 additions & 10 deletions benchmarks/memory/server/scenarios/aborted-requests/shared.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { settleAndPinGc } from '#memory-server/bench-utils'
import type { StartRequestHandler } from '#memory-server/bench-utils'

export type { StartRequestHandler }
Expand All @@ -12,7 +13,7 @@ type AbortedRequestMode = {
cancelMode: AbortedRequestCancelMode
}

const abortedRequestIterations = 100
const abortedRequestIterations = 40
let abortedRequestCounter = 0
const eagerMarker = 'data-bench="aborted-requests-eager"'
const alphaFallbackMarker = 'data-bench="aborted-requests-alpha-fallback"'
Expand Down Expand Up @@ -183,16 +184,12 @@ async function cancelReader(
await reader.cancel()
}

const cancellationDrainTicks = 8

// Fixed-count settlement barrier: each 0ms timer hop runs one full event-loop
// turn (microtasks flush between hops), so post-abort renderer teardown
// finishes within a deterministic number of turns instead of bleeding a
// load-dependent amount of work into later iterations of the measured run.
// Post-abort settlement barrier. Settles renderer teardown across a fixed
// number of event-loop turns, then (under CodSpeed) pins a collection point
// after every aborted request, so the measured peak cannot drift with how
// much floating garbage the previous iterations happened to leave behind.
async function drainCancellation() {
for (let tick = 0; tick < cancellationDrainTicks; tick++) {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
await settleAndPinGc()
}

async function assertAbortedRequestsSanity(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server aborted-requests (solid)',
watch: false,
environment: 'node',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server aborted-requests (vue)',
watch: false,
environment: 'node',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server error-paths (react)',
watch: false,
environment: 'node',
Expand Down
6 changes: 5 additions & 1 deletion benchmarks/memory/server/scenarios/error-paths/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type { StartRequestHandler }

type Framework = 'react' | 'solid' | 'vue'

const errorPathsIterations = 50
const errorPathsIterations = 40
const redirectSeed = 0xdecafbad
const notFoundSeed = 0xdecafb0d
const errorSeed = 0xdecafbed
Expand Down Expand Up @@ -142,6 +142,7 @@ export function createWorkloadGroup(
iterations: errorPathsIterations,
buildRequest: buildRedirectRequest,
validateResponse: validateRedirectResponse,
pinGcBetweenIterations: true,
})

const runNotFound = () =>
Expand All @@ -150,6 +151,7 @@ export function createWorkloadGroup(
iterations: errorPathsIterations,
buildRequest: buildNotFoundRequest,
validateResponse: validateNotFoundResponse,
pinGcBetweenIterations: true,
})

const runError = () =>
Expand All @@ -158,6 +160,7 @@ export function createWorkloadGroup(
iterations: errorPathsIterations,
buildRequest: buildErrorRequest,
validateResponse: validateErrorResponse,
pinGcBetweenIterations: true,
})

const runUnmatched = () =>
Expand All @@ -166,6 +169,7 @@ export function createWorkloadGroup(
iterations: errorPathsIterations,
buildRequest: buildUnmatchedRequest,
validateResponse: validateNotFoundResponse,
pinGcBetweenIterations: true,
})

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server error-paths (solid)',
watch: false,
environment: 'node',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server error-paths (vue)',
watch: false,
environment: 'node',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server peak-large-page (react)',
watch: false,
environment: 'node',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server peak-large-page (solid)',
watch: false,
environment: 'node',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server peak-large-page (vue)',
watch: false,
environment: 'node',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server request-churn (react)',
watch: false,
environment: 'node',
Expand Down
3 changes: 2 additions & 1 deletion benchmarks/memory/server/scenarios/request-churn/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export type { StartRequestHandler }
type Framework = 'react' | 'solid' | 'vue'

const benchmarkSeed = 0xdecafbad
const requestChurnIterations = 200
const requestChurnIterations = 40
const itemPageMarker = 'data-bench="request-churn-item"'
// Module-level so CodSpeed warmups and measurement never replay URLs.
const benchmarkRandom = createDeterministicRandom(benchmarkSeed)
Expand Down Expand Up @@ -68,6 +68,7 @@ export function createWorkloadGroup(
iterations: requestChurnIterations,
buildRequest: buildItemRequest,
validateResponse: validateItemResponse,
pinGcBetweenIterations: true,
})

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server request-churn (solid)',
watch: false,
environment: 'node',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server request-churn (vue)',
watch: false,
environment: 'node',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server serialization-payload (react)',
watch: false,
environment: 'node',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type { StartRequestHandler }
type Framework = 'react' | 'solid' | 'vue'

const benchmarkSeed = 0x51eaa11
const serializationPayloadIterations = 20
const serializationPayloadIterations = 12
const payloadPageMarker = 'data-bench="serialization-payload"'

const requestInit = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server serialization-payload (solid)',
watch: false,
environment: 'node',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server serialization-payload (vue)',
watch: false,
environment: 'node',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server server-fn-churn (react)',
watch: false,
environment: 'node',
Expand Down
3 changes: 2 additions & 1 deletion benchmarks/memory/server/scenarios/server-fn-churn/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type SerovalNode =
const benchmarkSeed = 0xdecafbad
const payloadSeed = 0x51f0cafe
const fixtureCount = 16
const serverFnChurnIterations = 150
const serverFnChurnIterations = 40
const origin = 'http://localhost'
const tssContentTypeFramed = 'application/x-tss-framed'
const acceptHeader = `${tssContentTypeFramed}, application/x-ndjson, application/json`
Expand Down Expand Up @@ -206,6 +206,7 @@ export async function createWorkloadGroup(
runSequentialRequestLoop(handler, {
seed: benchmarkSeed,
iterations: serverFnChurnIterations,
pinGcBetweenIterations: true,
buildRequest: (_random, index) => {
const fixtureIndex = Math.floor(index / 2) % fixtureCount

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export default defineConfig({
minify: false,
},
test: {
// Keep lazily-compiled code alive for the whole run: the pinned
// collections age code fast enough for V8 to flush unused bytecode,
// and the mid-measurement recompile injects a multi-MB allocation
// burst at a run-dependent time.
execArgv: ['--no-flush-bytecode'],
name: '@benchmarks/memory-server server-fn-churn (solid)',
watch: false,
environment: 'node',
Expand Down
Loading
Loading