Skip to content

Speed up static route matching - #2940

Merged
ericproulx merged 1 commit into
masterfrom
perf/static-route-matching
Sep 13, 2026
Merged

ericproulx merged 1 commit into
masterfrom
perf/static-route-matching

Conversation

@ericproulx

@ericproulx ericproulx commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Why

Every direct route request currently runs through the method’s compiled Regexp.union and then scans the registered routes to find the capture group that matched. For APIs with many routes, a request for a literal path therefore pays work proportional to route registration order even though its result is known once the router is compiled.

What changes

Grape::Router#compile! now builds a frozen per-method lookup table for paths a route spells out in full. At request time, an exact lookup can skip both the union match and the route scan.

  • A table key is considered only when the route origin is already normalized and contains no route-pattern syntax or percent escape.
  • Path-versioned routes contribute one key for each declared version.
  • Each candidate key is resolved during compilation through the same union and registration-order scan used at request time. This preserves the route that would normally win, including an earlier parameterized route shadowing a later literal route.
  • Cached path captures are accepted only when all values are strings. They are frozen in the compiled table and duplicated for each request, preserving the request-local mutable values endpoint code expects.
  • Routes with parameters, splats, optional/pattern syntax, percent escapes, non-string captures, or a path that normalizes differently continue through the existing matching path.

Keeping other requests as fast as before

The table lookup is extra work for every request it cannot answer, so the matching path was restructured to pay for it:

  • A method none of whose routes spells out a path in full stores no table, so its requests go straight to the union without a lookup that is bound to miss.
  • The union is stored paired with the routes it was built from, and both #transaction and #match? read the pair through one lookup. They used to read @optimized_map and @map separately, so a request that misses the table does no more Hash lookups than it did before this change.
  • A request the table answers runs exactly the code it did in the first version of this PR.

Benchmarks

Same process, Ruby 4.0.6, no JIT, 7 rounds with the variants' order rotated, median change against master:

Request Routes First version Now
GET /api/v1/hello/42 /hello/:id −1.40% (slower in 7/7 rounds) −0.15%
GET /api/v1/hello/42 /hello, /hello/:id +0.07% −0.11%
GET /api/v1/hello /hello +15.38% +14.54%
GET /api/v1/hello /hello, /hello/:id +15.29% +15.83%

Differences under ~1% are within this harness's noise: the literal-path rows run identical code in both versions and still measured −1.0% and +0.3%. On the last of 200 literal routes, the lookup takes a request from ~17,700 to ~157,800 i/s without a JIT.

Behaviour preserved

  • Route precedence remains unchanged.
  • Cascading routes still rotate through later candidates after an initial route declines.
  • Path version captures are available to the matched endpoint.
  • Unknown methods, ANY routes, auto-OPTIONS, greedy-neighbour matching, and all non-static paths retain the existing flow.

Coverage

The router specs exercise the behavior-sensitive cases:

  • A literal path shadowed by an earlier /:id route still selects that earlier route and receives fresh :id values on successive requests.
  • Every declared path version of a literal route matches and exposes the correct :version capture.

Validation

  • bundle exec rubocop lib/grape/router.rb spec/grape/router_spec.rb
  • bundle exec rspec — 2,864 examples, 0 failures
  • bundle exec rake was attempted; its global RuboCop scan is blocked locally by pre-existing ignored benchmark artifacts under tmp/ with unrelated offenses.

@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx force-pushed the perf/static-route-matching branch from 99470ec to 2821214 Compare September 12, 2026 21:16

@dblock dblock left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am not convinced these optimizations are worth the complexity of code, but since it's AI-generated I can live with it.

@ericproulx

Copy link
Copy Markdown
Contributor Author

I am not convinced these optimizations are worth the complexity of code, but since it's AI-generated I can live with it.

#2941

@ericproulx
ericproulx force-pushed the perf/static-route-matching branch from 2821214 to 5c1449e Compare September 13, 2026 08:45
@ericproulx
ericproulx force-pushed the perf/static-route-matching branch from 5c1449e to 28da1fa Compare September 13, 2026 17:13
@ericproulx
ericproulx added this pull request to stack #2944 September 13, 2026 19:11
@ericproulx
ericproulx merged commit ae2559b into master Sep 13, 2026
103 checks passed
@ericproulx
ericproulx deleted the perf/static-route-matching branch September 15, 2026 21:49
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