Skip to content

Resolve a '%'-free path against a union without the encodings - #2919

Closed
ericproulx wants to merge 1 commit into
masterfrom
perf/plain-union-fast-path
Closed

ericproulx wants to merge 1 commit into
masterfrom
perf/plain-union-fast-path

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

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 compiles to \/(?: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 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: false

The 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 — one gsub and one Regexp.new for 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: false drops 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 %XX branches keeps it.

What the rewrite is allowed to touch

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;
  • 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+. 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%.

Full API instance build before after
50 routes 7.3 ms 8.1 ms
200 routes 29.5 ms 32.3 ms
500 routes 77.4 ms 85.1 ms
1000 routes 160.1 ms 173.5 ms

Throughput

Ruby 4.0.6 without YJIT, format :json and a two-version path prefix.

before after
50 routes, last route 43.3k i/s 67.9k i/s +57%
200 routes, last route 15.5k i/s 31.7k i/s +104%
500 routes, last route 6.9k i/s 15.5k i/s +126%
500 routes, path matching nothing 5.5k i/s 433.0k i/s 78x
500 routes, percent-encoded path 6.73k i/s 6.71k i/s −0.2%

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 and requirements regexps: 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

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
ericproulx force-pushed the perf/plain-union-fast-path branch from cbc2669 to 999bb73 Compare September 9, 2026 07:13
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx

Copy link
Copy Markdown
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.

@ericproulx ericproulx closed this Sep 10, 2026
@ericproulx
ericproulx deleted the perf/plain-union-fast-path 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.

1 participant