Skip to content

perf(router): make fewer calls per part when building the tree - #195

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

Summary

A cold start builds the tree in V8's interpreter and baseline code, where each call and each property load goes through a builtin. In the profile of the first request for a method (PATCH in the REST set, 100 routes), loads and calls are most of the time outside split. Two changes in expand's part loop:

  • decode(part) became part.includes('%') ? decode(part) : part, so a part without % (nearly all of them) skips the call.
  • literals = Math.min(literals, index) became if (index < literals) literals = index, which drops a global load, a property load and a call per waiting route and per param.

Stryker showed a gap the second change exposed: with the comparison mutated to true, literals moves to the last param, and a late-added route sharing a param part with the route before it skipped that param. A case in late-adds.test.ts now covers it (/s/{x}/t/{y} then /s/{x}/t/u, added after a request).

Measurements

Callgrind instructions, --single-threaded, hash seeds 1 and 2 (averaged), main vs this PR:

Stage main PR Change
REST, 3 requests 3.892M 3.757M -3.5%
REST, 3 requests on 3 methods 5.697M 5.487M -3.7%
REST, 10 requests 6.615M 6.452M -2.5%
100 real nouns, 10 requests 6.588M 6.426M -2.5%
RPC (statics only), 10 requests 420k 420k same
Router import +6k (+0.4% of the router's share)
  • Size (esbuild --minify, gzip -9): 5068 B to 5076 B minified, 2112 B to 2114 B gzip.
  • Steady state: unchanged, since expand only runs while building the tree.

Checks

  • pnpm run check green; router tests 73/73 with 100% coverage.
  • All fuzzers at 3 seeds: 0 diffs.
  • Stryker: 64 survivors (61 on main). The three new ones are equivalent: index <= literals at both checks (assigning an equal value), and part.includes(""), which only sends every part through decode, which checks for % itself. (An earlier count of 54 here came from a run that shared the CPU with benchmarks, where test timeouts counted as kills.)

🤖 Generated with Claude Code

https://claude.ai/code/session_01VVaw7Sutbw6xMTMyDFeNfw

A cold start builds the tree in V8's interpreter, where each call and each
property load runs a builtin. `expand` now decodes only parts with `%`
instead of calling `decode` for each, and lowers `literals` with a
comparison instead of loading and calling `Math.min`.

A late-add test now covers a route sharing a param part with the route
before it, which needs `literals` to stay at the first param.

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 not alter performance

1 warning

⚠️ 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

✅ 29 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/project-thread-m32ra9 (78440c8) with main (b2a5f7d)

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

  • expand skips decode for %-free parts — part.includes('%') ? decode(part) : part; decode already returns its input unchanged when it holds no %, so the value is identical and only the call frame is saved. Same shape as the existing split branch.
  • literals = Math.min(...) → if (index < literals) literals = index (both the wait and param sites) — equivalent for the finite values literals/index ever hold; drops a global Math load, property load, and call per waiting route/param.
  • New late-add case — /s/{x}/t/{y} then /s/{x}/t/u queued after a request, asserting /s/1/t/u still yields { x: '1' }.

I verified the two edits are behavior-preserving by reading decode (its %-guard makes the ternary a no-op) and the literals value domain. I also mutated the param-branch guard to always assign (if (index >= 0)) and ran late-adds: it fails exactly as the PR describes, with /s/1/t/u returning params: undefined because the over-long shared-prefix walk skips the {x} param — so the added case is non-vacuous and genuinely pins that regression. Reverted; router suite is 73/73 green and the tree is clean.

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

@dinwwwh
dinwwwh merged commit 7a4e0bd into main Oct 29, 2026
11 checks passed
dinwwwh added a commit that referenced this pull request Oct 29, 2026
…201)

<!-- claude-projects-attribution: {"github_login":"dinwwwh"} -->
_Requested by **dinwwwh** · [project
thread](https://claude.ai/code/project/chan_01UcKMz91tMarPV1kWLVgLYE?thread=cmsg_01UcKMz91tMarPV1kWLVgLYE6swgSUM9SyULmBVdRjY2t7)_

## What

`expand` tracks how many of the previous route's parts the next route
can reuse (`literals`). It used to lower that count in two places: once
where a route waits, and once where it adds a param. Both happen only
for a part that starts with `{` once decoded, so the count is now
lowered in one place, right after that check.

That check also catches a part such as `{a.b}`, which starts with `{`
but is literal text. The count now drops for that part too, so the next
route shares fewer parts with this one and parses them again. Matching
is unaffected. The comment on `shared` and `literals` now describes the
rule this way.

## Measurements

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

| | before | after |
|---|---|---|
| min / min+gzip | 5012 / 2092 B | 5001 / 2091 B |
| REST cold+3 match | 3.498M | 3.485M (−0.35%) |
| REST cold+3, one request per method | 5.075M | 5.064M (−0.2%) |
| REST cold+10 | 6.010M | 5.998M (−0.2%) |

`expand` only runs while the tree is being built, so steady-state
matching does not change.

## Checks

- Tests pass with 100% coverage.
- The model fuzzers, including the late-add ones, find 0 diffs.
- Stryker finds 66 survivors, down from 67, and none of them are new.
The `if (true)` mutant of the merged check is killed by the late-add
test added in #195.

---
_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