Skip to content

perf: faster route lookup (+18.7% geomean, every benchmark scenario faster) - #447

Merged
mcollina merged 3 commits into
mainfrom
perf/faster-lookup
Aug 14, 2026
Merged

mcollina merged 3 commits into
mainfrom
perf/faster-lookup

Conversation

@mcollina

@mcollina mcollina commented Aug 9, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Three optimizations to the lookup hot path, no API changes:

URL sanitizing (lib/url-sanitizer.js) — safeDecodeURI scanned every URL char-by-char in JS on every lookup. Paths of 12+ chars now locate % and the query delimiters (?, #, and ; when useSemicolonDelimiter is enabled) with native String#indexOf and return early when the path portion contains no percent-encoding, which is the common case. Shorter paths keep a char scan, split into a small function V8 reliably inlines into find() plus a percent-decoding slow path that only runs when a % is present. To keep the percent path itself inlineable (V8 counts a callee's already-inlined bytecode against its inlining limit), decodeComponentChar's if-chain became a 768-byte lookup table with an integer-returning variant used on the scan path, and the rare %25 re-encoding moved to its own function.

Static child lookup (lib/node.js) — static children were stored in an object keyed by the child's first character, so every tree level paid a dictionary lookup by single-char string (KeyedLoadIC was ~10% of profile ticks). They are now stored in parallel arrays keyed by charCode with a linear scan over integers.

GET tree fast path (index.js) — trees holds one key per HTTP method, and 35 keys is beyond V8's fast-properties limit, so it is a dictionary-mode object and every trees[method] access goes through a generic IC call (~11% of cycles on short static lookups). A dedicated _treeGET instance field turns the tree load for GET requests — the vast majority of real traffic — into a pointer compare plus a fixed-offset field load. Other methods use trees as before.

Benchmarks

benchmark/bench.js on main vs this branch (Node v24.18.0, Linux x64). To control for CPU frequency drift between runs, both suites were run twice interleaved (main, PR, main, PR) taking the best per scenario. Every scenario is faster, and the short routes that dominate real traffic gain 3-13%:

Scenario main this PR Δ
lookup root "/" route 16.32M 17.66M +8.2%
lookup short static route 9.65M 10.27M +6.4%
lookup long static route 3.32M 4.58M +38.1%
lookup long static route (common prefix) 2.26M 3.73M +64.9%
lookup short parametric route 7.07M 7.26M +2.7%
lookup long parametric route 4.27M 6.35M +48.8%
lookup short parametric route (encoded unoptimized) 4.34M 4.37M +0.5%
lookup short parametric route (encoded optimized) 3.05M 3.07M +0.6%
lookup parametric route with two short params 3.48M 4.81M +38.3%
lookup multi-parametric route with two short params 3.09M 3.19M +2.9%
lookup multi-parametric route with two short regex params 3.39M 3.50M +3.0%
lookup long static + parametric route 1.93M 2.82M +45.9%
lookup short wildcard route 7.89M 8.45M +7.1%
lookup long wildcard route 4.54M 7.37M +62.3%
lookup root route on constrained router 14.43M 16.31M +13.0%
lookup short static unconstraint route 8.77M 9.29M +5.9%
lookup short static versioned route 5.71M 6.30M +10.4%
lookup short static constrained (version & host) route 5.80M 6.35M +9.5%

Geomean: +18.7%. As a noise-free cross-check, perf stat -e instructions on fixed 10M-lookup workloads measures -19% to -33% instructions for the long-route scenarios and parity elsewhere, and perf record shows the IC builtins (KeyedLoadIC, LoadIC) gone from the hot profile.

Testing

  • npm test passes: standard, 521 tests via borp, tsd.
  • Benchmarked with the repo's own suite as above; per-scenario instruction counts (perf stat), cycle profiles (perf record), and V8 --trace-turbo-inlining used to separate real effects from timing noise and to verify inlining behavior.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LKNnSRg1hTJsyUZaJYx3QF

mcollina and others added 2 commits August 9, 2026 09:05
Two optimizations to the lookup hot path, measured at +25.8% geomean
across the benchmark suite (16/18 scenarios faster, up to +73% on
multi-segment routes; the remaining two are at parity):

* URL sanitizing: paths of 12+ chars locate '%' and the query delimiter
  with native String#indexOf instead of a char-by-char JS loop, and
  return early when the path portion contains no percent-encoding.
  Shorter paths keep a char scan that is now small enough for V8 to
  inline into find(), with the percent-decoding slow path split into a
  separate function that only runs when a '%' is present.

* Static child lookup: static children are stored in parallel arrays
  keyed by the charCode of the child's first char, replacing the
  dictionary lookup by single-char string with a linear scan over
  integers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKNnSRg1hTJsyUZaJYx3QF
The charScan/percentScan split left safeDecodeURICharScan's optimized code
(with safeDecodeURIPercentScan and decodeComponentChar inlined) just over
V8's inlining size limit, so find() could no longer inline the sanitizer on
percent-encoded paths and paid a call plus a result allocation per lookup.

Shrink the bytecode that counts against the inlining budget:

* decodeComponentChar's if-chain becomes a 768-byte lookup table, with an
  integer-returning decodeComponentCharCode used on the scan path
* the rare %25 re-encoding string surgery moves to its own function

With this the whole scan chain inlines into find() again and the encoded
lookup benchmarks are back at parity with main by instruction count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKNnSRg1hTJsyUZaJYx3QF
@mcollina mcollina changed the title perf: faster route lookup (+25% geomean on the benchmark suite) perf: faster route lookup (+13.8% geomean on the benchmark suite) Aug 9, 2026
The trees object holds one key per HTTP method, and 35 keys is beyond
V8's fast-properties limit, so it is a dictionary-mode object and every
trees[method] access in find() goes through a generic IC call.

Keep a reference to the GET tree in a dedicated _treeGET instance field:
Router instances stay in fast mode, so for GET requests - the vast
majority of real traffic - the tree load compiles to a pointer compare
plus a fixed-offset field load. Other methods use the trees object as
before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKNnSRg1hTJsyUZaJYx3QF
@mcollina mcollina changed the title perf: faster route lookup (+13.8% geomean on the benchmark suite) perf: faster route lookup (+18.7% geomean, every benchmark scenario faster) Aug 9, 2026

@ivan-tymoshenko ivan-tymoshenko left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

lgtm

@delvedor delvedor left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Awesome!!

@mcollina
mcollina merged commit b3aca1e into main Aug 14, 2026
14 checks passed
@mcollina
mcollina deleted the perf/faster-lookup branch August 14, 2026 21:46
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.

3 participants