Repository navigation
benchmark: memory (codspeed) #7623
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
eab7570
benchmark: memory (codspeed)
Sheraff 3d31b79
fix codspeed github action
Sheraff 4c6d3af
unified codspeed job
Sheraff 025d4b4
im dumb
Sheraff 537776c
cleaner run command in github action
Sheraff 97d1801
better response stream draining
Sheraff b01f4de
cleanup streaming-peak scenario: we only care about server memory, no…
Sheraff fa3cb8a
use platformatic/flame for local memory bench
Sheraff 9bd39c0
direct pprof calls for cleaner output
Sheraff 3e63fd2
review
Sheraff 073a036
increase iteration count for low benches
Sheraff d8a4e76
flame run splits by benches, like codspeed
Sheraff d1ef03d
ci: apply automated fixes
autofix-ci[bot] ca04026
Merge branch 'main' into bench-codspeed-memory
Sheraff 2ff1a0d
QA
Sheraff 92c2688
build outside of codspeed instrumentation
Sheraff fab18e6
ci: apply automated fixes
autofix-ci[bot] 6a4c1f6
nitpick
Sheraff a30c37e
fix workspace deps
Sheraff ed51981
Merge branch 'main' into bench-codspeed-memory
Sheraff cbfde6d
solid & vue
Sheraff ab7fb18
ci: apply automated fixes
autofix-ci[bot] 8d25db9
simplify dual architecture
Sheraff b15de72
cleanup
Sheraff c922c2a
ci: apply automated fixes
autofix-ci[bot] e4fbeb1
fix vue benchmark
Sheraff File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| # Memory Benchmarks | ||
|
|
||
| Dedicated memory benchmarks for TanStack Router / Start, measured with the | ||
| CodSpeed **memory instrument** (`mode: memory` in | ||
| `.github/workflows/memory-benchmarks.yml`). Two separate benchmarks: | ||
|
|
||
| - `server/` (`@benchmarks/memory-server`) — React Start apps, requests against | ||
| the built server handler (`handler.fetch`), Node environment. | ||
| - `client/` (`@benchmarks/memory-client`) — router-only React apps in jsdom. | ||
|
|
||
| These deliberately do **not** reuse the CPU scenarios in `benchmarks/ssr` and | ||
| `benchmarks/client-nav`: memory benches need their own iteration counts, | ||
| payload sizes, and route shapes, and tuning those must never shift the CPU | ||
| baselines. React-first; each scenario keeps a `react/` level so solid/vue can | ||
| be added later without renames. | ||
|
|
||
| ## Layout | ||
|
|
||
| ```text | ||
| benchmarks/memory/<server|client>/ | ||
| package.json Nx targets: build:react, test:perf:react, test:types | ||
| bench-utils.ts memoryBenchOptions, seeded LCG (+ sequential request loop on the server side) | ||
| vitest.react.config.ts aggregates scenarios/*/react/vite.config.ts | ||
| scenarios/<scenario>/react/ one isolated app per scenario + memory.bench.ts | ||
| ``` | ||
|
|
||
| One app per scenario; apps and bench names are stable once landed (CodSpeed | ||
| continuity). Never grow an existing scenario for a new case — add a scenario. | ||
|
|
||
| ## 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 | ||
| 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. Because of `--no-opt`, | ||
| allocation counts overstate production; 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. | ||
|
|
||
| ## Bench shapes and signals | ||
|
|
||
| - **Churn (leak detector):** N sequential iterations at steady state. If one | ||
| iteration leaks L bytes, peak grows by ~N·L; healthy builds show a flat | ||
| timeline floor independent of N. Tuning check: doubling N must leave peak | ||
| roughly unchanged. | ||
| - **Peak (footprint):** one (or very few) large operations; peak memory | ||
| scaling with the workload is the signal. | ||
|
|
||
| ## Scenarios | ||
|
|
||
| ### Server | ||
|
|
||
| | Scenario | Shape | Guards against | | ||
| | ----------------------- | ----- | --------------------------------------------------------- | | ||
| | `request-churn` | churn | cross-request retention in document SSR (unique URLs) | | ||
| | `server-fn-churn` | churn | retention in the server-function RPC path | | ||
| | `error-paths` | churn | redirect/notFound/error/unmatched paths pinning contexts | | ||
| | `aborted-requests` | churn | dangling streams/listeners after mid-stream client aborts | | ||
| | `peak-large-page` | peak | per-request peak scaling with page size | | ||
| | `streaming-peak` | peak | streaming buffering O(document) instead of O(chunk) | | ||
| | `serialization-payload` | peak | double-buffering / string-copy blowups in dehydration | | ||
|
|
||
| ### Client | ||
|
|
||
| | Scenario | Shape | Guards against | | ||
| | ------------------------- | ----- | -------------------------------------------------------- | | ||
| | `navigation-churn` | churn | per-navigation retention at steady state | | ||
| | `unique-location-churn` | churn | unbounded href/search-keyed caches (never-repeated URLs) | | ||
| | `preload-churn` | churn | preload-cache eviction not releasing memory | | ||
| | `loader-data-retention` | churn | departed routes' loader data staying pinned (gcTime 0) | | ||
| | `mount-unmount` | churn | router instances not collectable after dispose | | ||
| | `interrupted-navigations` | churn | superseded navigations retaining closures/contexts | | ||
|
|
||
| ## Conventions | ||
|
|
||
| - Strictly sequential work: at most one request/navigation in flight; each | ||
| server response is fully consumed before the next request. Pairing a single | ||
| navigation with its render signal via `Promise.all([navigate, rendered])` | ||
| is fine — never overlap distinct work items. | ||
| - Randomness only via the seeded LCG in `bench-utils.ts`; no `Math.random`, | ||
| `Date.now`, or timers — with one documented exception: `streaming-peak`'s | ||
| deferred sections use small `setTimeout` delays, because React schedules | ||
| stream flushes via `setImmediate` and any non-timer deferral wins that race, | ||
| suppressing the Suspense fallbacks the scenario exists to stream. | ||
| - Sanity assertions run once at module load and throw on wrong | ||
| status/markers, so a bench can never silently measure the wrong thing. | ||
| - Server requests follow `benchmarks/ssr` conventions: document GETs send | ||
| `accept: text/html`, server-fn requests send `sec-fetch-site: same-origin` | ||
| with bodies precomputed at module level. | ||
| - Client apps export `mountTestApp` from `app.tsx`; benches import the built | ||
| `dist/app.js`; navigations use `replace: true`; unmount does full teardown | ||
| (React root, `__TSR_ROUTER__`, `history.destroy()`); large loader payloads | ||
| are never rendered into the DOM. | ||
| - `NODE_ENV=production` everywhere (the Nx targets set it). | ||
|
|
||
| ## Run | ||
|
|
||
| ```bash | ||
| pnpm nx run @benchmarks/memory-server:test:perf:react --outputStyle=stream --skipRemoteCache | ||
| pnpm nx run @benchmarks/memory-client:test:perf:react --outputStyle=stream --skipRemoteCache | ||
| pnpm nx run @benchmarks/memory-server:test:types --outputStyle=stream --skipRemoteCache | ||
| pnpm nx run @benchmarks/memory-client:test:types --outputStyle=stream --skipRemoteCache | ||
| ``` | ||
|
|
||
| Real memory measurement, locally (requires the CodSpeed CLI, `codspeed setup` | ||
| once to install the memory executor, and sudo; **uploads results to the | ||
| CodSpeed dashboard** — local runs do not affect PR baselines): | ||
|
|
||
| ```bash | ||
| WITH_INSTRUMENTATION=1 codspeed run --mode memory -- pnpm nx run @benchmarks/memory-server:test:perf:react | ||
| WITH_INSTRUMENTATION=1 codspeed run --mode memory -- pnpm nx run @benchmarks/memory-client:test:perf:react | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| export const memoryBenchOptions = { | ||
| iterations: 1, | ||
| warmupIterations: 1, | ||
| time: 0, | ||
| warmupTime: 0, | ||
| } | ||
|
|
||
| export function createDeterministicRandom(seed: number) { | ||
| let state = seed >>> 0 | ||
|
|
||
| return () => { | ||
| state = (state * 1664525 + 1013904223) >>> 0 | ||
| return state / 0x100000000 | ||
| } | ||
| } | ||
|
|
||
| export function randomSegment(random: () => number) { | ||
| return Math.floor(random() * 1_000_000_000).toString(36) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| { | ||
| "name": "@benchmarks/memory-client", | ||
| "private": true, | ||
| "type": "module", | ||
| "dependencies": { | ||
| "@tanstack/react-router": "workspace:^", | ||
| "@tanstack/router-core": "workspace:^", | ||
| "react": "^19.0.0", | ||
| "react-dom": "^19.0.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@codspeed/vitest-plugin": "^5.5.0", | ||
| "@testing-library/react": "^16.2.0", | ||
| "@vitejs/plugin-react": "^6.0.1", | ||
| "@types/jsdom": "28.0.0", | ||
| "typescript": "^6.0.2", | ||
| "vite": "^8.0.14", | ||
| "vitest": "^4.1.4" | ||
| }, | ||
| "nx": { | ||
| "targets": { | ||
| "build:react": { | ||
| "executor": "nx:noop", | ||
| "cache": false, | ||
| "dependsOn": [ | ||
| { | ||
| "projects": [ | ||
| "@benchmarks/memory-client-navigation-churn-react", | ||
| "@benchmarks/memory-client-unique-location-churn-react", | ||
| "@benchmarks/memory-client-preload-churn-react", | ||
| "@benchmarks/memory-client-loader-data-retention-react", | ||
| "@benchmarks/memory-client-mount-unmount-react", | ||
| "@benchmarks/memory-client-interrupted-navigations-react" | ||
| ], | ||
| "target": "build:client" | ||
| } | ||
| ] | ||
| }, | ||
| "test:perf:react": { | ||
| "executor": "nx:run-commands", | ||
| "cache": false, | ||
| "dependsOn": [ | ||
| "build:react" | ||
| ], | ||
| "options": { | ||
| "command": "NODE_ENV=production vitest bench --config ./vitest.react.config.ts", | ||
| "cwd": "benchmarks/memory/client" | ||
| } | ||
| }, | ||
| "test:types": { | ||
| "executor": "nx:noop", | ||
| "dependsOn": [ | ||
| { | ||
| "projects": [ | ||
| "@benchmarks/memory-client-navigation-churn-react", | ||
| "@benchmarks/memory-client-unique-location-churn-react", | ||
| "@benchmarks/memory-client-preload-churn-react", | ||
| "@benchmarks/memory-client-loader-data-retention-react", | ||
| "@benchmarks/memory-client-mount-unmount-react", | ||
| "@benchmarks/memory-client-interrupted-navigations-react" | ||
| ], | ||
| "target": "test:types:client" | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| } | ||
| } |
51 changes: 51 additions & 0 deletions
51
benchmarks/memory/client/scenarios/interrupted-navigations/react/memory.bench.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import { afterAll, beforeAll, bench, describe } from 'vitest' | ||
| import { | ||
| createDeterministicRandom, | ||
| memoryBenchOptions, | ||
| randomSegment, | ||
| } from '../../../bench-utils' | ||
| import { setup } from './setup' | ||
|
|
||
| const interruptedNavigationIterations = 150 | ||
|
|
||
| const interruptedNavigationPairs = createInterruptedNavigationPairs( | ||
| interruptedNavigationIterations, | ||
| ) | ||
|
|
||
| function createInterruptedNavigationPairs(iterations: number) { | ||
| const random = createDeterministicRandom(13) | ||
|
|
||
| return Array.from({ length: iterations }, (_, index) => ({ | ||
| slowId: `slow-${index}-${randomSegment(random)}`, | ||
| fastId: `fast-${index}-${randomSegment(random)}`, | ||
| })) | ||
| } | ||
|
|
||
| await setup().sanity() | ||
|
|
||
| describe('memory', () => { | ||
| const test = setup() | ||
|
|
||
| /** | ||
| * Plain `vitest bench` never runs suite hooks (beforeAll/afterAll) and only | ||
| * honors tinybench's setup/teardown options; the CodSpeed runner does the | ||
| * exact opposite. Both registrations are load-bearing — exactly one pair | ||
| * runs in any given mode. | ||
| */ | ||
| beforeAll(test.before) | ||
| afterAll(test.after) | ||
|
|
||
| bench( | ||
| 'mem interrupted-navigations (react)', | ||
| async () => { | ||
| for (const pair of interruptedNavigationPairs) { | ||
| await test.interrupt(pair.slowId, pair.fastId) | ||
| } | ||
| }, | ||
| { | ||
| ...memoryBenchOptions, | ||
| setup: test.before, | ||
| teardown: test.after, | ||
| }, | ||
| ) | ||
| }) |
31 changes: 31 additions & 0 deletions
31
benchmarks/memory/client/scenarios/interrupted-navigations/react/project.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| { | ||
| "name": "@benchmarks/memory-client-interrupted-navigations-react", | ||
| "projectType": "application", | ||
| "targets": { | ||
| "build:client": { | ||
| "executor": "nx:run-commands", | ||
| "cache": false, | ||
| "dependsOn": [ | ||
| { | ||
| "projects": ["@tanstack/react-router"], | ||
| "target": "build" | ||
| } | ||
| ], | ||
| "options": { | ||
| "command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts" | ||
| } | ||
| }, | ||
| "test:types:client": { | ||
| "executor": "nx:run-commands", | ||
| "dependsOn": [ | ||
| { | ||
| "projects": ["@tanstack/react-router"], | ||
| "target": "build" | ||
| } | ||
| ], | ||
| "options": { | ||
| "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" | ||
| } | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.