Skip to content

refactor(router): build a method's tree in match, without a helper - #198

Merged
dinwwwh merged 1 commit into
mainfrom
claude/project-thread-m32ra9
Oct 29, 2026
Merged

dinwwwh merged 1 commit into
mainfrom
claude/project-thread-m32ra9

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 29, 2026

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

What

build was a two-line function that only match called. Its two lines now sit in match, and its doc comment moves along with them.

Why

V8 spends about 15k instructions on each top-level function: once at import, and again the first time it compiles the function. I measured this with callgrind on 40 tiny functions, each called once, against one function called 40 times. Folding build in drops one function from every import, and one compile from the first request that builds a tree. The cost is about 70 bytes more for match to compile on any app's first request.

Measurements

Instruction counts from callgrind, node --single-threaded, averaged over hash seeds 1 and 2. The route sets are oRPC-style: 250 static RPC routes and 703 REST routes.

before after
min / min+gzip 5060 / 2108 B 5043 / 2100 B
import 2.177M 2.167M (−10k)
RPC 3 requests (static only) 327k 344k (+17k)
REST cold+3, GET 3.534M 3.502M (−0.9%)
REST cold+3, one request per method 5.115M 5.083M (−0.6%)
REST cold+10 6.074M 6.037M (−0.6%)
  • REST: the router's whole cold+3 share (import, add and 3 requests) drops by about 0.5%.
  • RPC (static only): it rises by about 0.2% (+7k), because match compiles slightly more code.
  • Steady state: unchanged in every case (static, param, params2, catch-all, encoded, mixes).

Checks

  • Tests pass with 100% coverage.
  • The model fuzzers, including the late-add ones, find 0 diffs.
  • Stryker finds 68 survivors, the same as on main.

Generated by Claude Code

`build` was a two-line function called only from `match`. Each function
costs V8 about 15k instructions to set up at import and again when it is
first compiled, so folding it in makes the module smaller and the first
request that builds a tree cheaper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7Sutbw6xMTMyDFeNfw
@dinwwwh dinwwwh self-assigned this Oct 29, 2026
@codecov

codecov Bot commented Oct 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Oct 29, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 22.34%

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

⚠️ Base and head ran on different runner types

Some benchmarks with significant performance changes were compared across different runner types, so the changes might not come from the code. Run base and head on the same runner type, or use CodSpeed Macro Runners for more stable results.

Learn more about Macro Runners

Open the report in CodSpeed to investigate

❌ 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
❌ @openapi-spec/router 76 µs 97.8 µs -22.34%

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-m32ra9 (9fdf14a) with main (a2b7d67)

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

@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

  • Inline build into match — the module-private two-line build(routes) helper (its sole caller was match) is folded into the routes.pending.length > 0 branch, with its doc comment carried over. The inlined expand(routes.root, routes.pending, 1, routes.unfiltered > 0) + routes.pending = [] are statement-for-statement identical to the removed body, so matching behavior is unchanged.

Verified: build( has no remaining references, expand is resolved in the same module, and the router suite passes (6 files / 73 tests).

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

dinwwwh commented Oct 29, 2026

Copy link
Copy Markdown
Member Author

CodSpeed's −22% on "match, 250 RPC-style procedures" comes from when V8 compiles match, not from slower matching. The router does the same work per request as before.

CodSpeed runs with --no-opt and measures one call after a few warm-up calls. match now has a few more bytes of bytecode, so Sparkplug's batch compile of it happens one call later: in the 8th call instead of the 7th. I replayed this under callgrind with CodSpeed's V8 flags, using the bench's routes and requests:

warm-up calls before the measured one main this PR
6 306k (compile lands here) 53k
7 48k 314k (compile lands here)
8 27.9k 46k
9, 12, 30 27.9k 27.9k

From the 9th call on, both versions take 27.9k instructions per call. With TurboFan on, the steady state is also unchanged: rpc 1589 vs 1589 and static 1644 vs 1645 instructions per request. This is the same Sparkplug shift as on #167 and #161. I'm merging on that basis.


Generated by Claude Code

@dinwwwh
dinwwwh merged commit cd598d9 into main Oct 29, 2026
10 of 11 checks passed
dinwwwh added a commit that referenced this pull request Oct 29, 2026
<!-- claude-projects-attribution: {"github_login":"dinwwwh"} -->
_Requested by **dinwwwh** · [project
thread](https://claude.ai/code/project/chan_01UcKMz91tMarPV1kWLVgLYE?thread=cmsg_01UcKMz91tMarPV1kWLVgLYE6swgSUM9SyULmBVdRjY2t7)_

## What

`matchesSegment` was a one-line function used in two places:
`matchesCatchAll`, for the segments after a catch-all param, and the
closure that `createPrefixMatcher` returns. Both now do the comparison
in place, with a comment that `undefined` stands for a param, which
needs a non-empty segment.

## Why

Each top-level function costs V8 about 15k instructions to set up at
import, and about as much again to compile when it is first called (see
#198). This removes one function from every import. It also removes one
compile from the first request that reaches a catch-all, or a prefix
with a param.

## Measurements

Instruction counts from callgrind, `node --single-threaded`, averaged
over hash seeds 1 and 2:

| | before | after |
|---|---|---|
| min / min+gzip | 5043 / 2100 B | 5053 / 2098 B |
| import | 2.167M | 2.164M |
| REST cold+10 (includes a catch-all request) | 6.041M | 6.017M (−0.4%)
|
| steady, catch-all / tail | 4985 / 5256 | 4979 / 5249 |
| steady, prefix matcher with a param / with `%` | 2453 / 4082 | 2422 /
4052 (−1%) |

Cold+3 on the REST set reaches no catch-all, so it changes only by the
import saving.

## Checks

- Tests pass with 100% coverage.
- The model fuzzers, including the late-add and prefix ones, find 0
diffs.
- Stryker finds 68 survivors, the same as on main. None are on the new
comparisons.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01VVaw7Sutbw6xMTMyDFeNfw)_

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