Skip to content

perf(router): walk prefix segments outside the prefix matcher - #233

Merged
dinwwwh merged 3 commits into
mainfrom
claude/project-thread-3j3vmz
Oct 31, 2026
Merged

dinwwwh merged 3 commits into
mainfrom
claude/project-thread-3j3vmz

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 31, 2026

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

What

A prefix matcher compares a literal-only prefix, such as oRPC's /api/v1/planets lazy-router prefixes, with one startsWith. 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:

main this PR
create 5 literal matchers (cold) 623k 575k (-7.7%)
first call of those 5 198k 96k (-51.6%)
create 5 param matchers (cold) 768k 720k (-6.2%)
first call of those 5 493k 504k (+2.2%)
steady, per request: 1 / 10 literal prefixes 1055 / 10034 1055 / 10035
steady, per request: 1 / 10 param prefixes 2167 / 16948 2174 / 16967 (+0.1..0.3%)
rpc / rest cold+3 router share (no prefix matchers) 5.404M / 14.369M 5.422M / 14.379M (+0.33% / +0.06%)

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

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
@dinwwwh dinwwwh self-assigned this Oct 31, 2026
@codecov

codecov Bot commented Oct 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ 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.

  • matchesHead extraction — the loop that walks head segment-by-segment (for params and %-containing paths) is byte-identical to the inline loop it replaces; the closure now falls through to return matchesHead(head, pathname) after the unchanged literal !== undefined && !pathname.includes('%') guard, with the same head/literal captures.
  • 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.ts exercises both paths (literal fast path, params, %, catch-all) plus a non-vacuous property check; the router suite passes 73/73 locally.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@codspeed

codspeed Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 12.88%

2 warnings

⚠️ 1 benchmark is dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ 1 benchmark with a significant change ran on different hardware

Its base and head results were measured on machines with a different CPU, core count or memory, so the change might not come from the code. Run base and head on the same runner type.

Learn more about Macro Runners

See affected benchmarks

❌ 1 regressed benchmark
✅ 28 untouched benchmarks
⏩ 2 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ rou3 64.6 µs 74.1 µs -12.88%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/project-thread-3j3vmz (a93efd3) with main (3bae110)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

dinwwwh commented Oct 31, 2026

Copy link
Copy Markdown
Member Author

CodSpeed Performance Analysis: the one regression comes from when V8 compiles the bench, not from this PR's code.

The flagged benchmark is prefix match, 10 pending lazy routers::rou3, at 64.6 µs → 74.1 µs. That bench only calls rou3's RegExp.test, and this diff doesn't touch rou3 or the bench file. The result came back the same on one re-run.

I replayed CodSpeed's simulation run locally. It runs the real vitest bench for router.bench.ts under callgrind, with CODSPEED_ENV=1 CODSPEED_RUNNER_MODE=simulation and the plugin's V8 flags (--no-opt and the rest), and records one dump per measured call. Instruction counts for the measured call:

bench base (b57a56d) head (a93efd3)
prefix, @openapi-spec/router 196,828 177,008 (-10%)
prefix, rou3 90,331 110,791 (+23%)
RPC match, ours 64,724 63,131
REST match, ours 150,800 150,702

The function profiles of the rou3 call show where the difference comes from:

  • Base: the bench closure runs as Sparkplug baseline code (LoadICBaseline, Call_..._Baseline_Compact).
  • Head: the same closure is still in the Ignition interpreter (GetNamedPropertyHandler, JumpLoopHandler, CallProperty1Handler).

The new matchesHead function changes how much bytecode the earlier benches queue up for Sparkplug. That moves the batch that compiles rou3's bench closure from before its single measured call to after it. CodSpeed runs 7 warm-up calls and then measures one, so the rou3 bench is charged for one interpreted run.

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

@dinwwwh
dinwwwh merged commit 38ce29b into main Oct 31, 2026
11 of 12 checks passed
dinwwwh added a commit that referenced this pull request Nov 2, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants