Skip to content

Commit 854ac95

Browse files
authored
benchmark: memory (codspeed) (#7623)
1 parent 689c5ab commit 854ac95

527 files changed

Lines changed: 18270 additions & 38 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/client-nav-benchmarks.yml

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ jobs:
3636
benchmark:
3737
- client-nav
3838
- ssr
39+
- memory-server
40+
- memory-client
41+
include:
42+
- benchmark: client-nav
43+
mode: simulation
44+
- benchmark: ssr
45+
mode: simulation
46+
- benchmark: memory-server
47+
mode: memory
48+
- benchmark: memory-client
49+
mode: memory
3950
runs-on: ubuntu-latest
4051
steps:
4152
- name: Checkout
@@ -46,8 +57,19 @@ jobs:
4657
- name: Setup Tools
4758
uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main
4859

60+
# Run the build outside of the CodSpeed action to avoid being slowed by intrumentation overhead.
61+
# (and then run the task itself with --excludeTaskDependencies)
62+
- name: Prepare ${{ matrix.benchmark }}:${{ matrix.framework }} benchmark
63+
run: >-
64+
pnpm nx run
65+
@benchmarks/${{ matrix.benchmark }}:build:${{ matrix.framework }}
66+
4967
- name: Run ${{ matrix.benchmark }}:${{ matrix.framework }} CodSpeed benchmark
5068
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0
5169
with:
52-
mode: simulation
53-
run: WITH_INSTRUMENTATION=1 pnpm nx run @benchmarks/${{ matrix.benchmark }}:test:perf:${{ matrix.framework }}
70+
mode: ${{ matrix.mode }}
71+
run: >-
72+
WITH_INSTRUMENTATION=1
73+
pnpm nx run
74+
@benchmarks/${{ matrix.benchmark }}:test:perf:${{ matrix.framework }}
75+
--excludeTaskDependencies

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,6 @@ vite.config.ts.timestamp_*
9090

9191
# eslint-plugin-start perf fixtures
9292
/e2e/eslint-plugin-start/src/perf/generated
93+
94+
# local memory flame profiles
95+
benchmarks/memory/**/.profiles/

benchmarks/memory/README.md

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# Memory Benchmarks
2+
3+
Dedicated memory benchmarks for TanStack Router / Start, measured with the
4+
CodSpeed **memory instrument** (`mode: memory` in
5+
`.github/workflows/client-nav-benchmarks.yml`). Two separate benchmarks:
6+
7+
- `server/` (`@benchmarks/memory-server`) — React/Solid/Vue Start apps, requests against
8+
the built server handler (`handler.fetch`), Node environment.
9+
- `client/` (`@benchmarks/memory-client`) — router-only React/Solid/Vue apps in jsdom.
10+
11+
These deliberately do **not** reuse the CPU scenarios in `benchmarks/ssr` and
12+
`benchmarks/client-nav`: memory benches need their own iteration counts,
13+
payload sizes, and route shapes, and tuning those must never shift the CPU
14+
baselines. Each scenario keeps a framework level (`react/`, `solid/`, `vue/`)
15+
so framework ports can be added without renames.
16+
17+
## Layout
18+
19+
```text
20+
benchmarks/memory/<server|client>/
21+
package.json Nx targets: build:<framework>, test:perf:<framework>, test:flame:<framework>, test:types
22+
bench-utils.ts memoryBenchOptions, seeded LCG (+ sequential request loop on the server side)
23+
vitest.<framework>.config.ts aggregates scenarios/*/<framework>/vite.config.ts
24+
scenarios/<scenario>/<framework>/
25+
one isolated app per scenario + setup.ts + memory.bench.ts + memory.flame.ts
26+
```
27+
28+
One app per scenario; apps and bench names are stable once landed (CodSpeed
29+
continuity). Never grow an existing scenario for a new case — add a scenario.
30+
`setup.ts` imports the built app and exports the concrete workload;
31+
`memory.bench.ts` registers `bench(...)` directly, and `memory.flame.ts` runs the
32+
same workload through the Flame profiler.
33+
34+
## How the memory instrument executes a bench
35+
36+
- The bench function is warmed up, then **measured exactly once**, starting
37+
after a forced GC. Under plain `vitest bench` the suites only smoke-test:
38+
timing output is meaningless; real numbers come from CodSpeed.
39+
- Under CodSpeed the bench fn runs several warmup invocations plus the
40+
measured one **on the same mount**, so bench fns must be idempotent and
41+
module-level counters/LCGs are used where ids must never repeat across
42+
invocations.
43+
- Plain `vitest bench` never runs suite hooks (`beforeAll`/`afterAll`) and
44+
only honors tinybench's `setup`/`teardown` options; the CodSpeed runner
45+
does the exact opposite. Client benches therefore register **both** — in
46+
any given mode exactly one pair runs.
47+
- The process runs with V8 determinism flags (predictable GC schedule,
48+
`--no-opt`). Never call `global.gc()` manually. Because of `--no-opt`,
49+
allocation counts overstate production; numbers are for regression
50+
tracking, not absolute claims.
51+
- Keep each bench under **~1.5M allocations** (instrument overhead grows past
52+
2M); this is the main constraint when tuning iteration counts.
53+
54+
## Bench shapes and signals
55+
56+
- **Churn (leak detector):** N sequential iterations at steady state. If one
57+
iteration leaks L bytes, peak grows by ~N·L; healthy builds show a flat
58+
timeline floor independent of N. Tuning check: doubling N must leave peak
59+
roughly unchanged.
60+
- **Peak (footprint):** one (or very few) large operations; peak memory
61+
scaling with the workload is the signal.
62+
63+
## Scenarios
64+
65+
### Server
66+
67+
| Scenario | Shape | Guards against |
68+
| ----------------------- | ----- | --------------------------------------------------------- |
69+
| `request-churn` | churn | cross-request retention in document SSR (unique URLs) |
70+
| `server-fn-churn` | churn | retention in the server-function RPC path |
71+
| `error-paths` | churn | redirect/notFound/error/unmatched paths pinning contexts |
72+
| `aborted-requests` | churn | dangling streams/listeners after mid-stream client aborts |
73+
| `peak-large-page` | peak | per-request peak scaling with page size |
74+
| `streaming-peak` | peak | streaming buffering O(document) instead of O(chunk) |
75+
| `serialization-payload` | peak | double-buffering / string-copy blowups in dehydration |
76+
77+
### Client
78+
79+
| Scenario | Shape | Guards against |
80+
| ------------------------- | ----- | -------------------------------------------------------- |
81+
| `navigation-churn` | churn | per-navigation retention at steady state |
82+
| `unique-location-churn` | churn | unbounded href/search-keyed caches (never-repeated URLs) |
83+
| `preload-churn` | churn | preload-cache eviction not releasing memory |
84+
| `loader-data-retention` | churn | departed routes' loader data staying pinned (gcTime 0) |
85+
| `mount-unmount` | churn | router instances not collectable after dispose |
86+
| `interrupted-navigations` | churn | superseded navigations retaining closures/contexts |
87+
88+
## Conventions
89+
90+
- Strictly sequential work: at most one request/navigation in flight; each
91+
server response is fully consumed before the next request. Pairing a single
92+
navigation with its render signal via `Promise.all([navigate, rendered])`
93+
is fine — never overlap distinct work items.
94+
- Randomness only via the seeded LCG in `bench-utils.ts`; no `Math.random`,
95+
`Date.now`, or timers — with one documented exception: `streaming-peak`'s
96+
deferred sections use small `setTimeout` delays so deferred stream chunks are
97+
observable across framework renderers.
98+
- Sanity assertions run once at module load and throw on wrong
99+
status/markers, so a bench can never silently measure the wrong thing.
100+
- Server requests follow `benchmarks/ssr` conventions: document GETs send
101+
`accept: text/html`, server-fn requests send `sec-fetch-site: same-origin`
102+
with bodies precomputed at module level.
103+
- Client apps export `mountTestApp` from `app.tsx`; benches import the built
104+
`dist/app.js`; navigations use `replace: true`; unmount does full teardown
105+
(framework root, `__TSR_ROUTER__`, `history.destroy()`); large loader payloads
106+
are never rendered into the DOM.
107+
- `NODE_ENV=production` everywhere (the Nx targets set it).
108+
109+
## Run
110+
111+
Smoke-test the CodSpeed/Vitest benchmark entrypoints and typecheck the
112+
scenarios:
113+
114+
```bash
115+
pnpm nx run @benchmarks/memory-server:test:perf:react --outputStyle=stream --skipRemoteCache
116+
pnpm nx run @benchmarks/memory-server:test:perf:solid --outputStyle=stream --skipRemoteCache
117+
pnpm nx run @benchmarks/memory-server:test:perf:vue --outputStyle=stream --skipRemoteCache
118+
pnpm nx run @benchmarks/memory-client:test:perf:react --outputStyle=stream --skipRemoteCache
119+
pnpm nx run @benchmarks/memory-client:test:perf:solid --outputStyle=stream --skipRemoteCache
120+
pnpm nx run @benchmarks/memory-client:test:perf:vue --outputStyle=stream --skipRemoteCache
121+
pnpm nx run @benchmarks/memory-server:test:types --outputStyle=stream --skipRemoteCache
122+
pnpm nx run @benchmarks/memory-client:test:types --outputStyle=stream --skipRemoteCache
123+
```
124+
125+
Local attribution profiling, without CodSpeed CLI/login/sudo/upload, uses
126+
`@datadog/pprof` heap sampling and `@platformatic/flame` only to render the
127+
captured pprof files as HTML/Markdown. These targets rebuild the scenarios with
128+
`--sourcemap true` so the generated profile reports can point back to source;
129+
the normal CodSpeed benchmark builds are unchanged. Local aggregate scripts run
130+
with `--parallel=1`, and scenario `test:flame` targets opt out of Nx parallelism
131+
so profiling workloads do not overlap and bias each other. The Vitest aggregate
132+
configs also set `fileParallelism: false` so benchmark files run sequentially
133+
inside `test:perf:react`.
134+
135+
```bash
136+
pnpm benchmark:memory:server:flame
137+
pnpm benchmark:memory:client:flame
138+
pnpm benchmark:memory:server:flame:solid
139+
pnpm benchmark:memory:client:flame:solid
140+
pnpm benchmark:memory:server:flame:vue
141+
pnpm benchmark:memory:client:flame:vue
142+
```
143+
144+
To profile one scenario, run its `test:flame` target directly:
145+
146+
```bash
147+
pnpm nx run @benchmarks/memory-server-request-churn-react:test:flame --outputStyle=stream --skipRemoteCache
148+
pnpm nx run @benchmarks/memory-client-navigation-churn-react:test:flame --outputStyle=stream --skipRemoteCache
149+
```
150+
151+
Flame writes reports under the scenario's ignored `.profiles/<timestamp>/`
152+
directory, including `heap-profile-*.html` and `heap-profile-*.md`. The
153+
`memory.flame.ts` entrypoints run the same workload shape as `memory.bench.ts`
154+
but manually start profiling after sanity/setup work and stop it after the
155+
measured workload. Treat these profiles as diagnostic heap-sampling attribution;
156+
they are not CodSpeed memory metrics such as peak memory, allocated bytes, or
157+
allocation counts. The heap sampler is stopped before profile conversion and
158+
Flame report generation, so Flame/pprof report-generation work should not appear
159+
as part of the captured workload. Flame runs do not force GC before profiling;
160+
doing so would perturb the workload and still would not make heap sampling
161+
equivalent to CodSpeed memory metrics.
162+
163+
Clean local Flame profile output with:
164+
165+
```bash
166+
pnpm --filter @benchmarks/memory-server clean:profiles
167+
pnpm --filter @benchmarks/memory-client clean:profiles
168+
```
169+
170+
Client memory benches are useful for regression tracking of router/React/jsdom
171+
integration behavior, especially retained route/cache data. They are not pure
172+
browser-memory measurements, and local Flame attribution can include jsdom,
173+
React DOM, and profiler shutdown frames.
174+
175+
Real memory measurement, locally (requires the CodSpeed CLI, `codspeed setup`
176+
once to install the memory executor, and sudo; **uploads results to the
177+
CodSpeed dashboard** — local runs do not affect PR baselines):
178+
179+
```bash
180+
WITH_INSTRUMENTATION=1 codspeed run --mode memory -- pnpm nx run @benchmarks/memory-server:test:perf:react
181+
WITH_INSTRUMENTATION=1 codspeed run --mode memory -- pnpm nx run @benchmarks/memory-server:test:perf:solid
182+
WITH_INSTRUMENTATION=1 codspeed run --mode memory -- pnpm nx run @benchmarks/memory-server:test:perf:vue
183+
WITH_INSTRUMENTATION=1 codspeed run --mode memory -- pnpm nx run @benchmarks/memory-client:test:perf:react
184+
WITH_INSTRUMENTATION=1 codspeed run --mode memory -- pnpm nx run @benchmarks/memory-client:test:perf:solid
185+
WITH_INSTRUMENTATION=1 codspeed run --mode memory -- pnpm nx run @benchmarks/memory-client:test:perf:vue
186+
```
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export const memoryBenchOptions = {
2+
iterations: 1,
3+
warmupIterations: 1,
4+
time: 0,
5+
warmupTime: 0,
6+
throws: true,
7+
}
8+
9+
export function createDeterministicRandom(seed: number) {
10+
let state = seed >>> 0
11+
12+
return () => {
13+
state = (state * 1664525 + 1013904223) >>> 0
14+
return state / 0x100000000
15+
}
16+
}
17+
18+
export function randomSegment(random: () => number) {
19+
return Math.floor(random() * 1_000_000_000).toString(36)
20+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export interface ClientMemoryWorkload {
2+
name: string
3+
before?: () => Promise<void> | void
4+
run: () => Promise<void> | void
5+
sanity: () => Promise<void> | void
6+
after?: () => Promise<void> | void
7+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { profileFlameWorkload } from '../flame-control.ts'
2+
import { window } from './jsdom.ts'
3+
import type { ClientMemoryWorkload } from './benchmark.ts'
4+
5+
export async function runClientFlameBenchmark(workload: ClientMemoryWorkload) {
6+
try {
7+
await workload.sanity()
8+
await workload.before?.()
9+
await profileFlameWorkload(workload.run, workload.name)
10+
} finally {
11+
await workload.after?.()
12+
window.close()
13+
}
14+
}

benchmarks/memory/client/jsdom.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { JSDOM } from 'jsdom'
2+
3+
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
4+
url: 'http://localhost/',
5+
})
6+
7+
const { window } = dom
8+
9+
function setGlobal(name: string, value: unknown) {
10+
Object.defineProperty(globalThis, name, {
11+
value,
12+
configurable: true,
13+
writable: true,
14+
})
15+
}
16+
17+
setGlobal('window', window)
18+
setGlobal('document', window.document)
19+
setGlobal('self', window)
20+
setGlobal('navigator', window.navigator)
21+
setGlobal('location', window.location)
22+
setGlobal('history', window.history)
23+
setGlobal('HTMLElement', window.HTMLElement)
24+
setGlobal('Element', window.Element)
25+
setGlobal('SVGElement', window.SVGElement)
26+
setGlobal('DocumentFragment', window.DocumentFragment)
27+
setGlobal('Node', window.Node)
28+
setGlobal('MouseEvent', window.MouseEvent)
29+
setGlobal('MutationObserver', window.MutationObserver)
30+
setGlobal('sessionStorage', window.sessionStorage)
31+
setGlobal('localStorage', window.localStorage)
32+
setGlobal('getComputedStyle', window.getComputedStyle.bind(window))
33+
34+
setGlobal(
35+
'requestAnimationFrame',
36+
window.requestAnimationFrame?.bind(window) ??
37+
((callback: (time: number) => void) =>
38+
setTimeout(() => callback(performance.now()), 16)),
39+
)
40+
41+
setGlobal(
42+
'cancelAnimationFrame',
43+
window.cancelAnimationFrame?.bind(window) ??
44+
((handle: number) => clearTimeout(handle)),
45+
)
46+
47+
const scrollTo = () => {}
48+
window.scrollTo = scrollTo
49+
setGlobal('scrollTo', scrollTo)
50+
51+
export { window }
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
export type Framework = 'react' | 'solid' | 'vue'
2+
3+
export type MountedApp = {
4+
router: unknown
5+
unmount: () => void
6+
}
7+
8+
export type MountTestApp = (container: HTMLDivElement) => MountedApp
9+
10+
const frameworkNames = {
11+
react: 'React',
12+
solid: 'Solid',
13+
vue: 'Vue',
14+
} satisfies Record<Framework, string>
15+
16+
export function noop() {}
17+
18+
export function warnClientMemoryDevMode(framework: Framework) {
19+
if (process.env.NODE_ENV !== 'production') {
20+
console.warn(
21+
`memory client benchmark is running without NODE_ENV=production; ${frameworkNames[framework]} dev overhead will dominate results.`,
22+
)
23+
}
24+
}
25+
26+
export function createBenchContainer() {
27+
const container = document.createElement('div')
28+
document.body.append(container)
29+
30+
return container
31+
}
32+
33+
export function removeBenchContainer(container: HTMLDivElement | undefined) {
34+
container?.remove()
35+
}
36+
37+
export function nextAnimationFrame() {
38+
return new Promise<void>((resolve) => {
39+
requestAnimationFrame(() => resolve())
40+
})
41+
}
42+
43+
export async function drainMicrotasks() {
44+
await Promise.resolve()
45+
await Promise.resolve()
46+
}

0 commit comments

Comments
 (0)