Speed up matching among many parameterized routes - #2943
Conversation
1f52d7a to
f396d62
Compare
Danger ReportNo issues found. |
Danger ReportNo issues found. |
d808af0 to
ec89e15
Compare
|
Is there a more generic bucket algorithm, thinking of LSH (locality sensitive hashing)? |
|
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 |
894044c to
02a53a3
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 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>
02a53a3 to
5d3e675
Compare
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
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:/resourceN/:idroutes/resourceN/:idroutesGET /resN/:idGET /resN/:id/items/:item_idGET /resN(answered by the static table)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: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
/: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).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.🤖 Generated with Claude Code