Skip to content

Speed up matching among many parameterized routes - #2943

Merged
ericproulx merged 1 commit into
masterfrom
perf/route-buckets
Sep 17, 2026
Merged

ericproulx merged 1 commit into
masterfrom
perf/route-buckets

Conversation

@ericproulx

@ericproulx ericproulx commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A request the static table cannot answer is matched against its method's whole union, and on an API with many routes that match is most of the request. Every route's pattern is tried in turn, and each expands every literal character into an alternation with its percent-encodings (\/(?:r|%72)(?:e|%65)…). For the last of 200 /resourceN/:id routes, without a JIT, the match is 43–53 µs of a ~68 µs request, while a one-route union matches in ~0.5 µs.

A route that spells out a literal at some path segment, with only literals and plain params ahead of it, can only match paths carrying that literal there. Router#compile! now picks, per method, the segment position that splits the routes best (Grape::Router::RouteBuckets) and builds one union per literal there. Each holds the routes that literal names plus every route the segment cannot tell apart, in registration order. A request is matched against the union for its own segment, so the first route to match is still the one the full union would pick.

The buckets ride in the per-method entry #transaction already reads ([union, routes, buckets]), so a method without them pays no extra lookup. A bucket whose alternatives another method already compiled — the HEAD routes mirroring GET's — reuses that union instead of compiling it again.

What keeps it exact

  • Segments ahead of the key must be a literal or a lone :param without a requirement, since a requirement can let a param span a /. :version qualifies as long as no declared version holds a /. Optional groups, splats and {} captures leave a route ungrouped.
  • The key holds no ., +, space or pattern syntax, and is ASCII: PATH_INFO arrives binary, so a non-ASCII literal would never equal the path's segment. A request's key is its segment cut at the first ., so /resource7.json still reaches /resource7.
  • A last-segment key only counts for an anchored route, since an unanchored route's /?*path can run on into the segment.
  • Ungrouped routes join every bucket, in registration order, and a segment no route names is matched against them alone.
  • The full union still handles a path holding a % (a literal may match its percent-encoding) or a newline (the patterns end in \Z), and every method with fewer than eight routes or with no segment that halves them.
  • A route matched through a bucket reads its params out of the bucket's match. A route's captures are numbered for the full union, so each bucket renumbers them for its own union when it is built; a route whose captures a union match cannot reproduce is matched by Mustermann instead.

Benchmarks

Against master (66f875c7), which already includes #2920. Each scenario in its own process, median of 3 rounds with the order of the two builds swapped between rounds, Ruby 4.0.6:

Request No JIT YJIT
Last of 200 /resourceN/:id routes 14,363 → 113,960 i/s (7.93×) 16,203 → 221,214 i/s (13.65×)
Middle of 200 /resourceN/:id routes 6.20× 11.3×
REST API, 50 resources × 4 routes: GET /resN/:id 5.85× 9.53×
REST API: GET /resN/:id/items/:item_id 5.59× 8.66×
REST API: GET /resN (answered by the static table) +1.0% −1.9%
One route (no buckets built) −0.4% −1.0%

A request answered through a bucket allocates one object more than on master: the segment its bucket is looked up by.

Building an API takes longer, since each bucket compiles a union of its own; a bucket whose alternatives another method already compiled, as HEAD mirroring GET, reuses that union. On the CRUD API below, builds per second change by −9.9%, −9.1%, −6.6% and −5.7% at 10, 100, 250 and 500 resources without YJIT, and by −13.6%, −6.6%, −4.3% and −9.8% with it. The build at 250 resources with YJIT had a round go the other way (+11.9%), so that figure is the least certain.

Together with #2920

A miss used to walk two unions: its method's, then the neighbour union #2920 narrows. Each PR removes one of the two, so a miss gets both savings. CRUD-shaped API with varied resource names (GET and POST on each collection, GET, PUT and DELETE on each member, prefix + path version + format :json), against master before #2920 (4a3aa6b7), median of 3 rounds with the order of the four builds rotated:

No JIT, 100 resources #2920 alone This PR alone Both
GET 404 +96.5% +92.9% 54.5×
POST 404 +39.3% +41.5% 2.45×
POST 405 +32.2% +28.0% +95.1%
GET, middle route +0.4% 5.95× 6.19×
OPTIONS +0.7% −0.2% +0.6%
Full API instance build (builds/s) −5.2% −8.8% −13.9%
YJIT, 100 resources #2920 alone This PR alone Both
GET 404 +98.3% +92.2% 95.0×
POST 404 +40.8% +41.1% 2.45×
POST 405 +33.0% +35.0% 2.03×
GET, middle route −1.9% 9.93× 9.98×
OPTIONS −3.7% +0.9% −1.9%
Full API instance build (builds/s) −9.2% −10.9% −15.1%

At 250 resources the two together answer a GET 404 136× faster than neither without YJIT, and 243× with it: every path there has a GET route, so no neighbour is left to try, and every route has a bucket key, so an unknown segment reaches no union at all.

Test plan

  • A differential check routed 33,599 generated paths through each bucket and through the full union and compared the matched route and its params: no differences. The APIs cover shadowing /:anything routes, an explicit HEAD route ahead of GET's and POST routes lined up with GET's, dotted and + literals, splats, optional groups, a requirement spanning a /, an unanchored route, several path versions, header versioning and a mount; the paths add %, +, newline, .json, trailing-slash and case variants.
  • After rebasing onto Look for a 405 neighbour only on paths the method has no route on #2920: routing answers — status and body, for GET, HEAD and POST, on binary and UTF-8 paths — for 69,324 requests over four APIs are identical to master's.
  • spec/grape/router/route_buckets_spec.rb: 9 behaviour examples, passing on this branch and on master without it. Eight of ten rule mutations make one fail, including sharing a union between methods by route position alone; the two that survive are the ASCII rule (no legal request reaches a non-ASCII literal route) and the newline gate (a spec would pin \Z accepting a trailing newline).
  • Three examples added since, for 12 in all: a later segment spelling another route's key, GET / on an API narrowed by its first segment, and a param followed by .json. The suite also fails if a bucket's captures are not renumbered for its union.
  • Full RSpec suite passes locally (2,890 examples); RuboCop clean on the changed files.
  • CI green.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@github-actions

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx force-pushed the perf/route-buckets branch 9 times, most recently from d808af0 to ec89e15 Compare September 16, 2026 09:44
@dblock

dblock commented Sep 17, 2026

Copy link
Copy Markdown
Member

Is there a more generic bucket algorithm, thinking of LSH (locality sensitive hashing)?

@ericproulx

Copy link
Copy Markdown
Contributor Author

LSH trades exactness for speed — a route missing from the candidate set wouldn't show up as a miss, because a lower-priority route in the same set could still match and answer, so it can't preserve first-match precedence. The generic exact counterpart is a segment trie: this PR is its single-level case (one literal position per method, with every route that position can't tell apart kept in each bucket). A multi-level version would also cover APIs mixing prefixes, like a mounted /things/:id next to top-level routes, which currently fall back to the full union. It needs the same soundness rules at every level (param requirements spanning /, versions, unanchored routes), so I'd keep it as a follow-up rather than grow this PR.

@ericproulx
ericproulx force-pushed the perf/route-buckets branch 4 times, most recently from 894044c to 02a53a3 Compare September 17, 2026 13:10
A request the static table cannot answer is matched against its method's
whole union: every route's pattern, each expanding every literal character
into an alternation with its percent-encodings. On an API with 200
parameterized routes that match is most of the request -- 43-53 µs of
~68 µs without a JIT -- and it grows with every route registered ahead of
the one that matches.

A route that spells out a literal at some path segment, with only literals
and plain params ahead of it, can only match paths carrying that literal
there. Router#compile! now picks, per method, the segment position that
splits the routes best and builds one union per literal there. Each holds
the routes that literal names plus every route the segment cannot tell
apart, in registration order, so matching a request against the union for
its own segment picks the route the full union would have picked.

Paths holding a '%' (a literal may match its percent-encoding) or a newline
(the patterns end in \Z) still go through the full union, as do methods
with fewer than eight routes or with no segment that halves them.

A route sits at a different capture group in each bucket than it does in
the router's union, so a bucket carries its routes' captures renumbered for
its own union -- every capture is a fixed offset from the route's own
group, so one number shifts the whole map -- and hands them over with the
match. Reading the captures out of the match already in hand, rather than
having Mustermann match the path a second time, costs 0.28 µs and 5 objects
where the second match cost 1.48 µs and 10: on the 200-route API, +15.5%
without a JIT and +24.2% with YJIT, at 29 -> 22 allocations per request. A
route whose captures a union match cannot reproduce is matched by
Mustermann as before.

The buckets ride in the per-method entry #transaction already reads, so a
method without them pays no extra lookup. A bucket whose alternatives
another method already compiled -- the HEAD routes mirroring GET's --
reuses that union instead of compiling it again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ericproulx
ericproulx merged commit d3f47a8 into master Sep 17, 2026
70 checks passed
@ericproulx
ericproulx deleted the perf/route-buckets branch September 17, 2026 15:01
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