Skip to content

Router: match common patterns without regexp, and attach route values with one allocation - #188

Open
Mohith26 wants to merge 1 commit into
monzo:masterfrom
Mohith26:perf/router-match-without-regexp
Open

Mohith26 wants to merge 1 commit into
monzo:masterfrom
Mohith26:perf/router-match-without-regexp

Conversation

@Mohith26

@Mohith26 Mohith26 commented Nov 4, 2026

Copy link
Copy Markdown

Router.lookup runs a regexp for every candidate route (in reverse registration order), and Router.Params runs it again with FindStringSubmatch. Since most services register patterns like /things/:id or /things/:id/*rest, this adds up on every request.

This PR keeps the regexp as the definition of what a pattern means, but skips it when it is not needed:

  • newRouteMatcher builds a small matcher at Register time for patterns made of literal text, :params followed by / or the end, and an optional trailing *residual. In that subset there is only one way a path can match, so a single left-to-right scan gives the same answer and the same captures as the regexp. Anything else (/:id.json, /*r/:p, * in the middle, invalid UTF-8 or U+FFFD in the pattern) still uses the regexp, so behaviour is unchanged.
  • Serve attached the router, pattern and method with three context.WithValue calls, which is five allocations including the boxed strings. A single routedContext now answers all three keys and delegates everything else (values, Done, Err, Deadline) to the parent, so cancellation still propagates.

Testing:

  • TestRouteMatcherAgreesWithRegexp runs ~1,300 pattern/path pairs (literals, params, residuals, newlines, unicode, invalid UTF-8) and checks the matcher agrees with the compiled regexp on both the match and the captured params.
  • FuzzRouteMatcherAgreesWithRegexp does the same with fuzzed inputs; I ran it for 150s (about 11.7M executions) with no differences.
  • TestRouterMixedMatchersPrecedence interleaves fast-path and regexp routes to check last-registered-wins still holds, and TestRoutedContext covers the context values, shadowing, cancellation and the nil-parent panic.
  • go vet ./..., go test ./... and go test -race on the router tests pass.

Benchmarks (go test -run '^$' -bench BenchmarkRouter -benchtime 200ms, old and new runs interleaved, benchstat medians, Apple Silicon arm64). I also added BenchmarkRouterManyRoutes, a 40-route table shaped like a typical service. The timings are from a busy shared machine so they are noisy; the allocation counts are exact.

benchmark before after allocs
Lookup GET /index.html 368 ns 78 ns 1 -> 1
Lookup GET /1/2 (two params) 320 ns 172 ns 4 -> 2
Lookup GET /404 231 ns 100 ns 1 -> 1
Serve GET /index.html 540 ns 185 ns 6 -> 2
Serve GET /1/2 277 ns 147 ns 6 -> 2
ManyRoutes Serve, last-registered route 397 ns 152 ns 6 -> 2
ManyRoutes Params, first-registered route 825 ns 349 ns 3 -> 2
ManyRoutes Params, last-registered route 557 ns 152 ns 4 -> 2

Across all router benchmarks the geomean is -38% time and -46% allocations. The not-found path is unchanged since its cost is building the terror.

I used an AI assistant while writing this; I reviewed the change and ran everything above locally.

… with one allocation

Most registered patterns are literal text, :params followed by '/', and an
optional trailing *residual. For these there is only one way a path can
match, so a left-to-right scan gives exactly the same result and captures
as the compiled regexp. Patterns outside that subset keep using the regexp.

Serve also attached the router, pattern and method with three
context.WithValue calls (five allocations including the boxed strings).
A single routedContext answers all three keys from one allocation.

Equivalence with the regexp is checked by a table test and a fuzz test.
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