Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 65 additions & 28 deletions benchmarks/memory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,14 @@ so framework ports can be added without renames.
benchmarks/memory/<server|client>/
package.json Nx targets: build:<framework>, test:perf:<framework>, test:flame:<framework>, test:types
bench-utils.ts memoryBenchOptions, seeded LCG (+ sequential request loop on the server side)
isolated-benchmark.ts registers churn benches through the shared child-process controller
vitest.<framework>.config.ts aggregates scenarios/*/<framework>/vite.config.ts
scenarios/<scenario>/<framework>/
one isolated app per scenario + setup.ts + memory.bench.ts + memory.flame.ts

benchmarks/memory/shared/
isolated-process.ts parent-side child lifecycle and IPC protocol
isolated-process-child.ts fresh Node/V8 process that owns and runs one invocation
```

One app per scenario; apps and bench names are stable once landed (CodSpeed
Expand All @@ -39,27 +44,59 @@ same workload through the Flame profiler.

## How the memory instrument executes a bench

- The bench function is warmed up, then **measured exactly once**, starting
after a forced GC. Under plain `vitest bench` the suites only smoke-test:
timing output is meaningless; real numbers come from CodSpeed.
- Under CodSpeed the bench fn runs several warmup invocations plus the
measured one **on the same mount**, so bench fns must be idempotent and
module-level counters/LCGs are used where ids must never repeat across
invocations.
- Plain `vitest bench` never runs suite hooks (`beforeAll`/`afterAll`) and
- The bench function is warmed up, then **measured exactly once**. Under plain
`vitest bench` the suites only smoke-test: timing output is meaningless; real
numbers come from CodSpeed.
- Churn benchmarks give every CodSpeed optimization invocation and the measured
invocation a fresh Node child process. The child imports the same production
build used by the Flame runner, executes the scenario sanity path, and runs
one full-sized warm-up outside the marker. A full loop is intentional: a
fresh process no longer inherits the V8 heap growth and runtime caches that
earlier benchmarks used to warm implicitly, and a token warm-up leaves those
one-time native allocations inside the measured timeline. Client warm-ups
use a disposable app that is torn down before the child creates the measured
app; server warm-ups use IDs that cannot overlap the measured request
sequence.
After the child reports that the workloads are loaded, the parent sends an
unmeasured `prime` command over the same IPC channel used for measurement.
The child settles pending work and forces two collections before
acknowledging it. This primes both IPC directions and the child command
queue before the parent benchmark function tells the child to execute the
real inner loop inside the CodSpeed marker. Teardown and process exit happen
after the marker.
- The fresh child deliberately replays the same seeded workload on every
invocation. Module-level counters still make every item within one inner loop
unique; they no longer carry state from CodSpeed warmups into measurement.
- Churn loops use deliberately large measured iteration counts so the regular
steady-state shape dominates the timeline and a per-iteration leak is
amplified. Their full-sized warm-up loops are unmeasured and use disjoint
inputs, so they establish the same steady state without consuming or hiding
the measured leak signal.
- Peak-footprint benchmarks remain direct: their existing lifecycle and pinned
inter-iteration collection points were already stable and do not benefit from
process isolation.
- Plain `vitest bench` never runs the Vitest suite hooks and
only honors tinybench's `setup`/`teardown` options; the CodSpeed runner
does the exact opposite. Client benches therefore register **both** — in
any given mode exactly one pair runs.
- The process runs with V8 determinism flags (predictable GC schedule,
`--no-opt`). Never call `global.gc()` manually in **churn** scenarios —
their signal is accumulation across iterations, which a forced collection
masks. **Peak** scenarios do the opposite: they set
`pinGcBetweenIterations` on the request loop so a collection runs between
iterations. Their signal is the footprint of a single request, and without
pinned GC points the measured peak flips by a whole payload depending on
whether iteration i's garbage is collected before iteration i+1 allocates.
Because of `--no-opt`, allocation counts overstate production; numbers are
for regression tracking, not absolute claims.
does the exact opposite. Isolated benches therefore register **both** the
suite hooks and Tinybench options; in any given mode exactly one pair runs.
- Isolated children inherit the Vitest worker's V8 flags, including CodSpeed's
memory-analysis configuration and any scenario-specific flags. The controller
then supplies deterministic defaults for flags the worker did not already
set: `--expose-gc`, `--predictable`, `--no-opt`, `--no-flush-bytecode`, and
fixed initial/semi-space sizes. Disabling optimization prevents a workload
from crossing a JIT tier-up threshold inside the marker; retaining bytecode,
pre-sizing the heap, and exercising one full loop before measurement keep
compilation and heap-growth bursts out of the measured peak. The forced
pre-measurement collections remove only unreachable setup, sanity, and
warm-up garbage; reachable caches or leaks survive and remain part of the
measured baseline and subsequent accumulation.
- Server request loops also pin collections between iterations. This removes
floating response/render garbage whose collection timing otherwise shifts the
peak, while retained objects still accumulate because collection cannot
reclaim reachable memory. Peak scenarios additionally verify that the heap
returned to its established floor. CodSpeed's memory-analysis execution can
overstate production allocation counts; numbers are for regression tracking,
not absolute claims.
- Keep each bench under **~1.5M allocations** (instrument overhead grows past
2M); this is the main constraint when tuning iteration counts.

Expand Down Expand Up @@ -167,14 +204,14 @@ pnpm nx run @benchmarks/memory-client-navigation-churn-react:test:flame --output
Flame writes reports under the scenario's ignored `.profiles/<timestamp>/`
directory, including `heap-profile-*.html` and `heap-profile-*.md`. The
`memory.flame.ts` entrypoints run the same workload shape as `memory.bench.ts`
but manually start profiling after sanity/setup work and stop it after the
measured workload. Treat these profiles as diagnostic heap-sampling attribution;
they are not CodSpeed memory metrics such as peak memory, allocated bytes, or
allocation counts. The heap sampler is stopped before profile conversion and
Flame report generation, so Flame/pprof report-generation work should not appear
as part of the captured workload. Flame runs do not force GC before profiling;
doing so would perturb the workload and still would not make heap sampling
equivalent to CodSpeed memory metrics.
but manually start profiling after sanity, warm-up, and setup work and stop it
after the measured workload. Treat these profiles as diagnostic heap-sampling
attribution; they are not CodSpeed memory metrics such as peak memory, allocated
bytes, or allocation counts. The heap sampler is stopped before profile
conversion and Flame report generation, so Flame/pprof report-generation work
should not appear as part of the captured workload. Flame runs do not force GC
before profiling; doing so would perturb the workload and still would not make
heap sampling equivalent to CodSpeed memory metrics.

Clean local Flame profile output with:

Expand Down
21 changes: 21 additions & 0 deletions benchmarks/memory/client/benchmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,26 @@ export interface ClientMemoryWorkload {
before?: () => Promise<void> | void
run: () => Promise<void> | void
sanity: () => Promise<void> | void
warmup?: () => Promise<void> | void
after?: () => Promise<void> | void
}

export async function warmClientMemoryWorkload(workload: ClientMemoryWorkload) {
if (!workload.warmup) {
return
}

if (Boolean(workload.before) !== Boolean(workload.after)) {
throw new Error(
`Client memory workload ${workload.name} must define both before and after when it defines either hook`,
)
}
Comment on lines +10 to +19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate paired lifecycle hooks before the early return.

Line 11 bypasses the before and after pairing check when warmup is absent. A workload with only before can then create measured state without cleanup. Validate the pair before returning.

Proposed fix
 export async function warmClientMemoryWorkload(workload: ClientMemoryWorkload) {
-  if (!workload.warmup) {
-    return
-  }
-
   if (Boolean(workload.before) !== Boolean(workload.after)) {
     throw new Error(
       `Client memory workload ${workload.name} must define both before and after when it defines either hook`,
     )
   }
 
+  if (!workload.warmup) {
+    return
+  }
+
   await workload.before?.()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function warmClientMemoryWorkload(workload: ClientMemoryWorkload) {
if (!workload.warmup) {
return
}
if (Boolean(workload.before) !== Boolean(workload.after)) {
throw new Error(
`Client memory workload ${workload.name} must define both before and after when it defines either hook`,
)
}
export async function warmClientMemoryWorkload(workload: ClientMemoryWorkload) {
if (Boolean(workload.before) !== Boolean(workload.after)) {
throw new Error(
`Client memory workload ${workload.name} must define both before and after when it defines either hook`,
)
}
if (!workload.warmup) {
return
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmarks/memory/client/benchmark.ts` around lines 10 - 19, Move the
before/after pairing validation in warmClientMemoryWorkload before the
!workload.warmup early return, so workloads without warmup still require both
lifecycle hooks or neither. Preserve the existing error and return behavior
after validation.


await workload.before?.()

try {
await workload.warmup()
} finally {
await workload.after?.()
}
}
2 changes: 2 additions & 0 deletions benchmarks/memory/client/flame-runner.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { profileFlameWorkload } from '../flame-control.ts'
import { window } from './jsdom.ts'
import { warmClientMemoryWorkload } from './benchmark.ts'
import type { ClientMemoryWorkload } from './benchmark.ts'

export async function runClientFlameBenchmark(workload: ClientMemoryWorkload) {
try {
await workload.sanity()
await warmClientMemoryWorkload(workload)
await workload.before?.()
await profileFlameWorkload(workload.run, workload.name)
} finally {
Expand Down
41 changes: 41 additions & 0 deletions benchmarks/memory/client/isolated-benchmark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { afterEach, beforeEach, bench, describe } from 'vitest'
import { IsolatedMemoryProcess } from '../shared/isolated-process.ts'
import { memoryBenchOptions } from './bench-utils.ts'
import type { IsolatedMemoryBenchmarkKind } from '../shared/isolated-process.ts'

type RegisterIsolatedClientMemoryBenchmarkOptions = {
name: string
setupUrl: URL
}

const kind = 'client' satisfies IsolatedMemoryBenchmarkKind

export function registerIsolatedClientMemoryBenchmark(
options: RegisterIsolatedClientMemoryBenchmarkOptions,
) {
const isolatedProcess = new IsolatedMemoryProcess({
kind,
setupUrl: options.setupUrl,
workloadNames: [options.name],
})

const run = async () => {
try {
await isolatedProcess.run(0)
} catch (error) {
await isolatedProcess.stop().catch(() => {})
throw error
}
}

describe('memory', () => {
beforeEach(() => isolatedProcess.start())
afterEach(() => isolatedProcess.stop())

bench(options.name, run, {
...memoryBenchOptions,
setup: () => isolatedProcess.start(),
teardown: () => isolatedProcess.stop(),
Comment on lines +31 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

For Vitest 4.1.4 benchmark files, verify whether suite-level beforeEach/afterEach hooks run around bench() tasks and whether Tinybench setup/teardown run for each benchmark cycle.

💡 Result:

In Vitest 4.1.4, the behavior of suite-level hooks and Tinybench setup/teardown in benchmark files is as follows: Suite-Level Hooks (beforeEach/afterEach) Historically, Vitest's suite-level beforeEach and afterEach hooks did not run for bench tasks [1]. While there have been community efforts and pull requests (such as PR #7541) aimed at enabling this functionality, these hooks are generally not standardly supported or guaranteed to execute around benchmark tasks in the same way they do for test blocks [1][2][3]. Benchmark execution in Vitest has historically followed a distinct lifecycle from standard tests [1][3]. Tinybench Setup/Teardown Tinybench, which powers Vitest's benchmarking, explicitly provides setup and teardown options [4][5][6]. These are configured as part of the third argument (options object) passed to the bench function [4][5]. According to Tinybench documentation, these setup and teardown functions are designed to run for each benchmark cycle (task execution) [6][7][8]. They are intended for operations that need to occur immediately before and after the specific code being measured, allowing users to handle stateful setups that should not be included in the timed performance result [3][6]. In summary, you should rely on the explicit setup and teardown options provided in the bench function's configuration object to manage cycle-specific logic, rather than relying on suite-level beforeEach/afterEach hooks [3][4][6]. Top Results: [1][2][3][4][6]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate files:"
fd -a 'isolated-benchmark\.ts$|isolated.*memory.*\.ts$|memory.*\.ts$' . | sed 's#^\./##' | head -100

echo
echo "Target file outline:"
ast-grep outline benchmarks/memory/client/isolated-benchmark.ts --view compact || true

echo
echo "Target file lines:"
cat -n benchmarks/memory/client/isolated-benchmark.ts | sed -n '1,120p'

echo
echo "Search isolated process definitions/usages:"
rg -n "class IsolatedMemoryProcess|IsolatedMemoryProcess|start\(|stop\(|isolatedProcess|bench\\(" -S benchmarks . --glob '*.ts' --glob '!node_modules' | head -200

Repository: TanStack/router

Length of output: 25556


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Vitest version declarations:"
for f in package.json pnpm-lock.yaml yarn.lock package-lock.json; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    if [ "$f" = "package.json" ]; then
      node -e "const p=require('./package.json'); console.log({devDependencies:p.devDependencies?.vitest, dependencies:p.dependencies?.vitest, scripts:p.scripts});" 2>/dev/null || sed -n '/vitest/p' "$f"
    else
      rg -n "vitest(@|:)|version:" "$f" | head -80
    fi
  fi
done

echo
echo "Shared process start/stop implementation:"
cat -n benchmarks/memory/shared/isolated-process.ts | sed -n '140,235p'

echo
echo "Isolated process tests around duplicate start:"
cat -n benchmarks/memory/server/isolated-process.test.ts | sed -n '1,130p'

echo
echo "Memory bench options:"
cat -n benchmarks/memory/client/bench-utils.ts | sed -n '1,120p'

echo
echo "Server isolated benchmark counterpart:"
cat -n benchmarks/memory/server/isolated-benchmark.ts | sed -n '1,70p'

Repository: TanStack/router

Length of output: 10199


Use Tinybench setup/teardown for the isolated lifecycle.

In Vitest 4 benchmark mode, keep the setup and teardown in the bench() options, but remove the suite beforeEach/afterEach hooks or make them no-ops. The suite hooks can start the process before bench() setup/teardown runs, while IsolatedMemoryProcess.start() rejects a second start because a child process already exists.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmarks/memory/client/isolated-benchmark.ts` around lines 31 - 38, Update
the memory benchmark suite around isolatedProcess to remove or neutralize the
describe-level beforeEach and afterEach hooks, leaving lifecycle management
exclusively to the setup and teardown callbacks in bench options. Preserve the
existing isolatedProcess.start() and isolatedProcess.stop() calls in those
Tinybench callbacks.

})
})
}
1 change: 1 addition & 0 deletions benchmarks/memory/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"#memory-client/benchmark": "./benchmark.ts",
"#memory-client/bench-utils": "./bench-utils.ts",
"#memory-client/flame-runner": "./flame-runner.ts",
"#memory-client/isolated-benchmark": "./isolated-benchmark.ts",
"#memory-client/lifecycle": "./lifecycle.ts"
},
"dependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,6 @@
import { afterAll, beforeAll, bench, describe } from 'vitest'
import { memoryBenchOptions } from '#memory-client/bench-utils'
import { workload } from './setup'
import { registerIsolatedClientMemoryBenchmark } from '#memory-client/isolated-benchmark'

await workload.sanity()

describe('memory', () => {
if (workload.before && workload.after) {
beforeAll(workload.before)
afterAll(workload.after)

bench(workload.name, workload.run, {
...memoryBenchOptions,
setup: workload.before,
teardown: workload.after,
})
return
}

bench(workload.name, workload.run, memoryBenchOptions)
registerIsolatedClientMemoryBenchmark({
name: 'mem client interrupted-navigations (react)',
setupUrl: new URL('./setup.ts', import.meta.url),
})
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export default defineConfig({
test: {
name: '@benchmarks/memory-client interrupted-navigations (react)',
watch: false,
environment: 'jsdom',
setupFiles: ['../../../vitest.setup.ts'],
environment: 'node',
},
})
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,17 @@ type InterruptedNavigationRouter = {
) => () => void
}

const interruptedNavigationIterations = 150
const interruptedNavigationIterations = 300
const interruptedNavigationWarmupIterations = interruptedNavigationIterations
const interruptedNavigationPairs = createInterruptedNavigationPairs(
interruptedNavigationIterations,
13,
'',
)
const interruptedNavigationWarmupPairs = createInterruptedNavigationPairs(
interruptedNavigationWarmupIterations,
0x1a2b3c,
'warmup-',
)

const uninitialized = () =>
Expand All @@ -63,12 +71,16 @@ const uninitializedSettlement = () =>
reason: new Error('interrupted-navigations benchmark is not initialized'),
})

function createInterruptedNavigationPairs(iterations: number) {
const random = createDeterministicRandom(13)
function createInterruptedNavigationPairs(
iterations: number,
seed: number,
prefix: string,
) {
const random = createDeterministicRandom(seed)

return Array.from({ length: iterations }, (_, index) => ({
slowId: `slow-${index}-${randomSegment(random)}`,
fastId: `fast-${index}-${randomSegment(random)}`,
slowId: `${prefix}slow-${index}-${randomSegment(random)}`,
fastId: `${prefix}fast-${index}-${randomSegment(random)}`,
}))
}

Expand Down Expand Up @@ -272,15 +284,20 @@ export function createWorkload(
await drainMicrotasks()
}

async function runPairs(
pairs: ReadonlyArray<{ slowId: string; fastId: string }>,
) {
for (const pair of pairs) {
await interrupt(pair.slowId, pair.fastId)
}
}

return {
name: `mem client interrupted-navigations (${framework})`,
before,
interrupt,
async run() {
for (const pair of interruptedNavigationPairs) {
await interrupt(pair.slowId, pair.fastId)
}
},
run: () => runPairs(interruptedNavigationPairs),
warmup: () => runPairs(interruptedNavigationWarmupPairs),
async sanity() {
await before()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,6 @@
import { afterAll, beforeAll, bench, describe } from 'vitest'
import { memoryBenchOptions } from '#memory-client/bench-utils'
import { workload } from './setup'
import { registerIsolatedClientMemoryBenchmark } from '#memory-client/isolated-benchmark'

await workload.sanity()

describe('memory', () => {
if (workload.before && workload.after) {
beforeAll(workload.before)
afterAll(workload.after)

bench(workload.name, workload.run, {
...memoryBenchOptions,
setup: workload.before,
teardown: workload.after,
})
return
}

bench(workload.name, workload.run, memoryBenchOptions)
registerIsolatedClientMemoryBenchmark({
name: 'mem client interrupted-navigations (solid)',
setupUrl: new URL('./setup.ts', import.meta.url),
})
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export default defineConfig({
test: {
name: '@benchmarks/memory-client interrupted-navigations (solid)',
watch: false,
environment: 'jsdom',
setupFiles: ['../../../vitest.setup.ts'],
environment: 'node',
},
})
Original file line number Diff line number Diff line change
@@ -1,21 +1,6 @@
import { afterAll, beforeAll, bench, describe } from 'vitest'
import { memoryBenchOptions } from '#memory-client/bench-utils'
import { workload } from './setup'
import { registerIsolatedClientMemoryBenchmark } from '#memory-client/isolated-benchmark'

await workload.sanity()

describe('memory', () => {
if (workload.before && workload.after) {
beforeAll(workload.before)
afterAll(workload.after)

bench(workload.name, workload.run, {
...memoryBenchOptions,
setup: workload.before,
teardown: workload.after,
})
return
}

bench(workload.name, workload.run, memoryBenchOptions)
registerIsolatedClientMemoryBenchmark({
name: 'mem client interrupted-navigations (vue)',
setupUrl: new URL('./setup.ts', import.meta.url),
})
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export default defineConfig({
test: {
name: '@benchmarks/memory-client interrupted-navigations (vue)',
watch: false,
environment: 'jsdom',
setupFiles: ['../../../vitest.setup.ts'],
environment: 'node',
},
})
Loading
Loading