Repository navigation
perf(router): walk prefix segments outside the prefix matcher - #233
Conversation
createPrefixMatcher found its literal-only prefix with `every` and then `map` + `join`, two arrow functions a cold start compiles on the first call. Building the text in the loop that already walks the prefix spares them. The text stays in a `const`, so V8 still folds it into the matcher. Creating the first matcher costs 10% fewer instructions, 20 matchers 13% fewer; matching is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7ReSePbVFG3kzDy1FQ
A prefix matcher compares a literal-only prefix with one startsWith, but its closure also held the segment-by-segment walk for other prefixes and for paths with `%`. V8 compiles a function whole on its first call, so a cold start compiled the walk for every app with literal lazy-router prefixes. Moving the walk to `matchesHead` keeps the closure small. For 5 literal prefixes, creating the matchers costs 8% fewer instructions and their first call 52% fewer. Steady matching is unchanged for literal prefixes and up to 0.3% slower for prefixes with params; importing costs about 18k instructions more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7ReSePbVFG3kzDy1FQ
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the segment walk inside createPrefixMatcher's returned closure was extracted verbatim into a module-level matchesHead(head, pathname), called only when the literal startsWith fast path does not apply.
matchesHeadextraction — the loop that walksheadsegment-by-segment (for params and%-containing paths) is byte-identical to the inline loop it replaces; the closure now falls through toreturn matchesHead(head, pathname)after the unchangedliteral !== undefined && !pathname.includes('%')guard, with the samehead/literalcaptures.- Rationale documented — the JSDoc explains the hoist keeps the walk out of the closure so a cold start compiles it only when a param prefix or
%path needs it. - Coverage —
prefix.test.tsexercises both paths (literal fast path, params,%, catch-all) plus a non-vacuous property check; the router suite passes 73/73 locally.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
Merging this PR will degrade performance by 12.88%2 warnings
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
|
CodSpeed Performance Analysis: the one regression comes from when V8 compiles the bench, not from this PR's code. The flagged benchmark is I replayed CodSpeed's simulation run locally. It runs the real
The function profiles of the rou3 call show where the difference comes from:
The new This is the same Sparkplug timing effect described on #198. Nothing in this PR needs fixing for it. Our own prefix bench gets 10% fewer instructions. Generated by Claude Code |
…n't land in CodSpeed's measured call (#345) <!-- claude-projects-attribution: {"github_login":"dinwwwh"} --> _Requested by **dinwwwh** · [project thread](https://claude.ai/code/project/chan_01UcKMz91tMarPV1kWLVgLYE?thread=cmsg_01UcKMz91tMarPV1kWLVgLYEKguiPzeDgT5b6rRXC3tGer)_ ## What The two match benches (250 RPC-style and 703 REST-style procedures) now run their request list 16 times per call, for both this router and rou3. The add bench and the prefix bench are unchanged. This is a bench-only change with no source changes. ## Why In simulation mode, CodSpeed runs each bench function a few times as warmup and then measures one call. The RPC match bench makes only 4 requests per call, about 30k instructions. A one-time V8 event, such as a Sparkplug batch compile, can land in that call and swing the result by up to 10x. This caused the false alarms on #233 (rou3 prefix -12.9%) and #336 (RPC match x2.1). I replayed the bench the way the plugin runs it. The replay used `optimizeFunction`-style warmup calls, then `gc()`, then one call between `InstrumentHooks.start/stopBenchmark`, under callgrind with CodSpeed's analysis V8 flags (`--no-opt --predictable ...`). Instructions in the measured call: | warmup calls | 1 pass, before #336 | 1 pass, main | 16 passes, before #336 | 16 passes, main | | --- | --- | --- | --- | --- | | 5 | 53,596 | 75,341 | 261,803 | 321,344 | | 7 | 324,590 | 32,281 | 261,803 | 321,344 | | 9 | 28,098 | 41,465 | 261,803 | 321,344 | With one pass, the result depends on where the compile lands: in one case 7 warmups read 10x higher than 9. With 16 passes, it is the same for every warmup count. The stable numbers also show something the noise hid. Since #336, this request mix costs about 23% more in the tiers CodSpeed runs, because the `POST /resource49/unknown` miss now splits the path and walks the tree. In TurboFan that is about +450 instructions per miss on a method with only static routes, which #336 accepted. So expect CodSpeed on this PR to show every match bench about 16x slower, because each call does 16x the work. Later PRs then compare against a stable baseline. I tried the same check on the prefix bench, and it is already stable with one pass (142,366 instructions for 5, 7 and 9 warmups). It stays as it is. ## Checks - `vitest bench --run packages/router` runs, and the hooks (oxlint, oxfmt, tsc) pass. --- _Generated by [Claude Code](https://claude.ai/code/session_01VVaw7PzysACDLzaReKDUKE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>

Requested by dinwwwh · project thread
What
A prefix matcher compares a literal-only prefix, such as oRPC's
/api/v1/planetslazy-router prefixes, with onestartsWith. Its closure also held the segment-by-segment walk used for prefixes with params and for paths with%. V8 compiles a function whole on its first call, so every app with literal lazy-router prefixes compiled the walk on its first request, though it never ran.The walk now lives in
matchesHead(head, pathname), and the closure calls it only when the literal comparison doesn't apply. Results are unchanged: an old-vs-new diff over 2M random prefixes and paths found 0 differences, and fuzz-wide's prefix model check found 0 too.Measurements
Node 22, callgrind
--single-threaded --hash-seed=1, instructions, main = b57a56d:The rpc/rest cold cost is the import pre-parsing one more function (about 18k instructions). Apps with literal lazy-router prefixes save about 150k on their cold start.
Size (esbuild
--minify,gzip -9n): 5001 → 5012 B min, 2099 → 2098 B gzip.Generated by Claude Code