Skip to content

Speed up matching among many parameterized routes - #2943

Open
ericproulx wants to merge 1 commit into
masterfrom
perf/route-buckets
Open

ericproulx wants to merge 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 off its own pattern, since its capture groups are numbered for the full union.

Benchmarks

Each scenario in its own process, 3 rounds alternating which build runs first, both builds on one Gemfile.lock, Ruby 4.0.6, median against master:

Request No JIT YJIT
Last of 200 /resourceN/:id routes 14,792 → 97,312 i/s (6.58×) 16,371 → 182,095 i/s (11.12×)
Middle of 200 /resourceN/:id routes 5.32× 8.82×
REST API, 100 varied resources: GET /<resource>/:id 6.65× 10.51×
REST API: GET /<resource>/:id/comments 6.52× 10.29×
REST API: GET /<resource> (answered by the static table) −0.2% −0.6%
REST API: 404 1.94× 1.97×
One route (no buckets built) −1.4% −2.9%
Building the REST API's router −11.5% builds/s −11.3% builds/s
Building the 200-route API's router −6.3% builds/s −6.0% builds/s

The 404 still walks the greedy neighbour union, which this does not touch. Building a router takes longer because each bucket compiles a union of its own; a bucket whose alternatives another method already compiled, as HEAD mirroring GET, reuses that union.

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.
  • 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).
  • Full RSpec suite passes locally (2,880 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 5 times, most recently from 0806c10 to 71bbd34 Compare September 15, 2026 22:01
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
matched through a bucket reads its params off its own pattern, since its
capture groups are numbered for the full union.

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

1 participant