Speed up matching among many parameterized routes - #2943
Open
ericproulx wants to merge 1 commit into
Open
ericproulx wants to merge 1 commit into
ericproulx wants to merge 1 commit into
Conversation
ericproulx
force-pushed
the
perf/route-buckets
branch
from
September 13, 2026 18:43
1f52d7a to
f396d62
Compare
Danger ReportNo issues found. |
Danger ReportNo issues found. |
ericproulx
force-pushed
the
perf/route-buckets
branch
5 times, most recently
from
September 15, 2026 22:01
0806c10 to
71bbd34
Compare
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>
ericproulx
force-pushed
the
perf/route-buckets
branch
from
September 15, 2026 22:14
71bbd34 to
ec4ba50
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/:idroutes, 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
#transactionalready 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
:paramwithout a requirement, since a requirement can let a param span a/.:versionqualifies as long as no declared version holds a/. Optional groups, splats and{}captures leave a route ungrouped..,+, space or pattern syntax, and is ASCII:PATH_INFOarrives 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.jsonstill reaches/resource7./?*pathcan run on into the segment.%(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.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:/resourceN/:idroutes/resourceN/:idroutesGET /<resource>/:idGET /<resource>/:id/commentsGET /<resource>(answered by the static table)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
/:anythingroutes, 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\Zaccepting a trailing newline).🤖 Generated with Claude Code