Resolve a '%'-free path against a union without the encodings - #2919
Closed
ericproulx wants to merge 1 commit into
Closed
ericproulx wants to merge 1 commit into
ericproulx wants to merge 1 commit into
Conversation
Every route pattern is compiled with Mustermann's `uri_decode: true`, which expands each literal character of a path into an alternation with its percent-encodings -- `/resource7` becomes `\/(?:r|%72)(?:e|%65)(?:s|%73)(?:o|%6F|%6f)...` -- so that `/a%2Eb` still reaches a route declared as `/a.b`. The router's per-method union is the disjunction of those patterns, so every request pays for the expansion: it roughly doubles the compiled source, and it hides the literal prefix each branch starts with, which is what would otherwise let Onigmo reject a non-matching path without walking the alternatives. On a 500-route API, failing to match cost 180us. An input holding no '%' cannot take any of those alternatives, so for it the union without them accepts exactly the same strings, by the same path through the regexp, capturing the same substrings. `compile!` now derives that stand-in per union and `transaction`, `#match?` and `#greedy_match?` read it whenever the path has no '%'. The stand-in is cut out of the union's source rather than recompiled from each pattern with `uri_decode: false`. That is 20x cheaper -- one gsub and one `Regexp.new` for the whole union against a fresh Mustermann parse per route -- and it is the more faithful transformation: `uri_decode: false` also drops the `\+` branch that lets a plus match a literal space, which would make `/with+space` (no '%' anywhere, so resolved here) miss a route the decode-aware union matches. Only alternations are rewritten, and only their `%XX` alternatives are dropped, so a `requirements` Regexp inserted into the pattern verbatim survives whatever it holds. Two shapes are stepped over rather than rewritten: a character class, where `(?:` and `|` are literal characters and dropping them would shrink the set a route matches, and an escape, so an escaped bracket cannot open a class. The group itself is only unwrapped around a lone character -- a quantifier binds to the group, so `(?:ab|%41)+` must not become `ab+` -- which is every path literal, and the bare form is what the prefix optimisation needs. GET routes are mirrored for HEAD and the greedy neighbours mirror both, so the same source is usually compiled several times over; the stand-in is built once per distinct source and shared. Without that, and with the rewrite memoised per distinct expansion rather than recomputed per match, boot for 500 routes grew 84% -- with both, 10%. Measured on Ruby 4.0.6 without YJIT, `format :json` and a two-version path prefix, hitting the last route of the union: 43.3k -> 67.9k i/s at 50 routes (+57%), 15.5k -> 31.7k at 200 (+104%), 6.9k -> 15.5k at 500 (+126%). A path that matches nothing: 5.5k -> 433.0k i/s (78x). A path carrying a percent-encoding still goes through the decode-aware union and is unchanged (-0.2%), as is the first route of a union and a single-route API, which pay only the `String#include?`. Boot, full API instance build: 7.3 -> 8.1 ms at 50 routes, 29.5 -> 32.3 at 200, 77.4 -> 85.1 at 500, 160.1 -> 173.5 at 1000. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ericproulx
force-pushed
the
perf/plain-union-fast-path
branch
from
September 9, 2026 07:13
cbc2669 to
999bb73
Compare
Danger ReportNo issues found. |
Contributor
Author
|
Closing: going with #2920 instead, which halves the cost of a 404 without rewriting the compiled union. The no-match figure in the description here was also inflated by a benchmark whose routes all shared one literal; with varied route names the gain per union walk was ~4x rather than 78x. |
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.
Every route pattern is compiled with Mustermann's
uri_decode: true, which expands each literal character of a path into an alternation with its percent-encodings —/resource7compiles to\/(?:r|%72)(?:e|%65)(?:s|%73)(?:o|%6F|%6f)…— so that/a%2Ebstill reaches a route declared as/a.b.The router's per-method union is the disjunction of those patterns, so every request pays for the expansion. It roughly doubles the compiled source, and it hides the literal prefix each branch starts with, which is what would otherwise let Onigmo reject a non-matching path without walking the alternatives. On a 500-route API, failing to match cost 180 µs.
An input holding no
%cannot take any of those alternatives, so for it the union without them accepts exactly the same strings, by the same path through the regexp, capturing the same substrings.compile!now derives that stand-in per union, and#transaction,#match?and#greedy_match?read it whenever the path has no%.Why source surgery rather than
uri_decode: falseThe stand-in is cut out of the union's compiled source rather than recompiled from each pattern with
uri_decode: false. That is 20x cheaper — onegsuband oneRegexp.newfor the whole union, against a fresh Mustermann parse per route (13 ms vs 169 ms per 500 routes).It is also the more faithful transformation.
uri_decode: falsedrops the\+branch that lets a plus match a literal space, so/with+space— which carries no%anywhere and is therefore resolved by the stand-in — would miss a route the decode-aware union matches. Removing only the%XXbranches keeps it.What the rewrite is allowed to touch
Only alternations are rewritten, and only their
%XXalternatives are dropped, so arequirementsRegexp inserted into the pattern verbatim survives whatever it holds. Two shapes are stepped over rather than rewritten:(?:and|are literal characters and dropping them would shrink the set a route matches;The group itself is only unwrapped around a lone character — a quantifier binds to the group, so
(?:ab|%41)+must not becomeab+. Every path literal is a single character, and the bare form is what the prefix optimisation needs.Boot
GET routes are mirrored for HEAD and the greedy neighbours mirror both, so the same source is usually compiled several times over; the stand-in is built once per distinct source and shared. Without that, and with the rewrite memoised per distinct expansion rather than recomputed per match, boot for 500 routes grew 84% — with both, 10%.
Throughput
Ruby 4.0.6 without YJIT,
format :jsonand a two-version path prefix.A path carrying a percent-encoding still goes through the decode-aware union and is unchanged, as is the first route of a union and a single-route API — those pay only the
String#include?.Beyond the specs, the two unions were checked for agreement over 2,223
%-free inputs against routes covering dots, unicode, splats, optionals, escaped bars, literal spaces, plus signs andrequirementsregexps: same match, same captures, same group contents throughout.One cost worth naming: an extra compiled union per distinct source is held for the life of the router.
🤖 Generated with Claude Code