|
| 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 | +``` |
0 commit comments