Repository navigation
perf(http): cut six per-request allocations from the trie router - #4165
Conversation
Three changes on the trie matcher's path, none of which touch the default
mux path or any public signature.
The composed middleware chain is now memoised per route. composeMiddleware
calls every middleware CONSTRUCTOR and each returns a fresh closure, so
running it per request allocated one closure per middleware on every
request -- five in a default GoFr app, which an allocation profile put at
about 13% of all objects allocated while serving. The chain depends only on
the route and the handler, both fixed once the router is built.
The path-parameter map is stored only when it holds something. gorilla/mux
allocates a Vars map on every successful match (route.go:101), even for a
route with no parameters, and storing it cost a context node and a shallow
Request copy per request to carry nothing.
trieNode.collect returns its slice instead of taking a *[]*routeEntry
out-parameter, which stopped the slice header escaping to the heap and lets
the caller's stack buffer stay on the stack.
Measured with BenchmarkRequestPath/trie/static/routes=100, the same
benchmark run against this branch and against its parent:
before 1072 B/op 14 allocs/op ~570 ns/op
after 536 B/op 8 allocs/op ~264 ns/op
A fourth change, in the propagation carrier: headerCarrier.Values went through
http.Header.Values, which canonicalises whatever key it is handed. "baggage" is
not already canonical, so that allocated the canonical string on every request --
for the one header the W3C propagators always ask about. It now uses the same
canonical-key table Get does. The comment saying baggage was absent from that
table was simply wrong; the table has always contained it.
TestTrieRequestAllocationsDoNotRegress counts the allocations so a later change
cannot give them back silently: every other test here checks behaviour, which
all of this preserves by design.
Behaviour changes, both confined to the trie path. mux.Vars(r) now returns
nil rather than an empty non-nil map for a route with no path parameters:
every read stays correct -- indexing yields the zero value, len is 0, and
ranging does nothing, which is all Request.PathParam and handlers do with
it -- but an explicit nil check would see a different answer than under the
mux matcher. And middleware registered after a route's first request is not
in that route's cached chain; GoFr registers all middleware before Run, and
the trie index already assumed the same.
082be26 to
f57ad3c
Compare
PiyushSingh-ZS
left a comment
There was a problem hiding this comment.
All four optimizations look sound to me, and the numbers are worth having. Most of what follows is small; one comment actively contradicts the code beneath it and should be fixed before merge.
Verified on the branch
serveMatchedis reached only fromserveTrie.ServeHTTPbranches onrou.useTrieand otherwise delegates straight torou.Router.ServeHTTP, so the default mux path genuinely does not see any of this. That is the claim the whole risk assessment rests on and it holds.mux.Varshas exactly one non-test consumer in the framework —request.go(pathParams: mux.Vars(r)) — andpathParamsis only ever read (r.pathParams[key]), never written. So a nil map is safe there; there is no nil-map-write waiting to happen.canonicalPropagationKeysdoes containheaderBaggage. The old comment claiming otherwise was wrong, as the PR says, andhttp.Header.Valueswould indeed have canonicalized"baggage"on every request.- The
own sync.Mapguard is the subtle part of the chain cache and I think it is correct. A route underPathPrefix(...).Subrouter()can arrive with a handler mux wrapped for this request whilematch.Routestill points at the inner route, so keying the cache on the route alone would pin the first wrapper forever. Restricting the cache to routes GoFr registered itself is the right fence, andLoadOrStorerather thanStoreis the right primitive.
The allocation guard's doc comment says the opposite of its code
// The ceiling is a ceiling, not the measurement: it sits above the figure the
// commit quotes so ordinary variation in the runtime or a dependency does not
// fail the build, while a regression of the size these changes made does.
func TestTrieRequestAllocationsDoNotRegress(t *testing.T) {
// Exact, not a ceiling. With headroom this caught only the largest of the three
// savings here: reverting the chain memoization costs five allocations and was
// caught, but the empty-Vars skip costs two and the collect signature one, and
// both slipped under a tolerance wide enough to absorb toolchain drift.
const wantAllocs = 7
...
if got != wantAllocs {The godoc paragraph describes a tolerance; the inline comment and the code implement an exact match. The inline reasoning is the better of the two — a tolerance wide enough to absorb toolchain drift really would hide the 2-alloc and 1-alloc savings — so I would just delete the godoc paragraph. As it stands, the next person to hit this after a Go upgrade will read the godoc first, conclude the assertion is wrong, and loosen it.
Also worth a word: the guard pins 7 while the PR table quotes 8 allocs/op. Different fixtures (the guard uses five middlewares and a nopWriter; the benchmark uses three and a real chain), which is fine — but stating that in the comment stops it reading as an inconsistency.
mux.Vars(r) returning nil deserves an explicit maintainer sign-off
The analysis is right — indexing, len and range all behave identically, only an explicit != nil differs — and it is documented in docs/advanced-guide/routing-performance/page.md. Being opt-in behind GOFR_ROUTER softens it a lot.
My only concern is the shape of the failure: a user flips an env variable described as a performance toggle and gets a silent semantic change in any handler doing if mux.Vars(r) != nil. That is a hard one to debug because nothing in the symptom points at the router. I do not think it needs to block — just that it should be a decision someone made out loud rather than something that rides in on a perf PR.
Middleware registration ordering is now load-bearing
Middleware registered after a route's first request is not in that route's cached chain.
True, and GoFr does register everything before Run. But that moves from a convention to a silent correctness dependency: register a middleware late and it simply does not run for already-served routes, with no error anywhere. A cheap log.Error (or panic in dev) from Use/UseMiddleware when chains is already non-empty would turn that into a loud failure for the cost of one atomic read.
Conflict with #4167
Both PRs edit the route registration inside setupGraphQL in pkg/gofr/gofr.go:
- this one:
NewRoute().Methods(...).Path("/graphql")->router.Add(http.MethodPost, "/graphql", ...) - #4167:
if a.graphqlManager != nil->if a.graphQLActive()
Whichever lands second needs a manual merge, and this one's change is functionally significant — Add calls markOwned, so /graphql becomes chain-cache eligible. Worth flagging to whoever sequences them.
Umang01-hash
left a comment
There was a problem hiding this comment.
Verified independently at head 2aa101bf (the router files are unchanged from my review; the head just merged in newer development) — builds, go vet, golangci-lint --new-from-rev (0 issues), and go test -race ./pkg/gofr/http/... all green; E2E in both mux and trie modes confirms params, param-free routes, 404, and that the memoized chain still runs the full Tracer→Logging chain (trace/span IDs present in logs). Chain-cache is race-safe: rou.mws's last write (WS middleware) precedes ListenAndServe and the lazy trie build, and the own + MatchErr guards keep subrouter/non-owned routes uncached. Alloc guard pins 7 exactly and goes red on reverting any of the three cuts. All opt-in behind GOFR_ROUTER=trie, so default deployments are untouched.
+1 to @PiyushSingh-ZS's review — agree all four optimizations are sound. Two of his non-blocking points are worth doing:
- Middleware-registration order is now load-bearing; a
log.Error/dev-panic fromUse/UseMiddlewarewhenchainsis already populated would turn a silent "middleware doesn't run" into a loud failure. - Conflict with #4167 — both edit the
/graphqlregistration insetupGraphQL; this PR's switch torouter.Addmakes/graphqlchain-cache-eligible, so whoever merges second needs a manual reconcile.
And the alloc-guard godoc paragraph should be dropped (it contradicts the exact-match code beneath it). None block merge. LGTM.
…rving Review follow-ups on #4165. Memoizing the chain per route makes registration order load-bearing in trie mode: a middleware registered after a route has served never appears in that route's cached chain and simply does not run for it, with nothing in the symptom pointing at the router. Use now reports that. It is an error rather than a panic because the router may already be serving, and it is trie-only -- mux composes per request, so a late registration takes effect there and a warning would just train users to ignore the message. The cost is one atomic read per Use call. Also drops the allocation guard's godoc paragraph, which described a tolerance while the code beneath it asserted an exact count. The inline comment already explains why exact is the right choice: a tolerance wide enough to absorb toolchain drift hides the 2-allocation and 1-allocation savings. Left as it was, the next person to hit this after a Go upgrade would read the godoc first and loosen the assertion. The replacement paragraph also states why the guard pins 7 while the commit table quotes 8 -- different fixtures, five middlewares and a nopWriter here against three and a real writer in the benchmark. The routing-performance page gains the registration-order caveat alongside the existing mux.Vars note.
Unrelated to this PR: a golang.org/x/net go.mod hash the workspace picked up while the tests were run locally.
|
Thanks both. Everything actionable is done at The contradicting godoc paragraph is gone. You're right that it was the dangerous half: the next person to hit this after a Go upgrade reads the godoc first, concludes the assertion is wrong, and loosens it. The replacement paragraph also states why the guard pins 7 while the table quotes 8 — different fixtures (five middlewares and a Late middleware registration is now loud. Both of you asked for this and it was the right call: Two deliberate limits:
Cost is one The routing-performance page gains the registration-order caveat alongside the existing
Conflict with #4167 noted. #4167 has since been rebased and the |
Umang01-hash
left a comment
There was a problem hiding this comment.
Re-reviewed at 5078e339 — all of @PiyushSingh-ZS's feedback is resolved:
- Late-registration is now loud (
reportLateRegistration+UseLogger): trie-only, error-not-panic, andcached.Store(true)sits on the cache-miss path so there's no new per-request cost (alloc guard still pins 7). Two revert-red tests confirm it fires once on a late registration and stays silent on the startup path and in mux mode. - Alloc-guard godoc no longer contradicts the code — the 'ceiling' paragraph is replaced with the 7-vs-8 fixture explanation.
mux.Vars(r)nil behaviour is documented.
Gates green at head: build, go vet, golangci-lint --new-from-rev (0 issues), go test -race ./pkg/gofr/http/..., plus the late-reg/chain-cache/alloc/empty-vars tests. No false positives from framework callers, and the rou.mws append-during-serving race is pre-existing (unchanged) and only reachable via the exact late-registration path this now reports.
One item is inherently cross-PR: #4167 still edits the same /graphql registration line (it keeps NewRoute(), this uses router.Add, which makes /graphql chain-cache-eligible) — whoever merges second needs a manual reconcile. LGTM.
Description:
Four changes on the trie matcher's path. None touch the default mux path or any public signature.
BenchmarkRequestPath/trie/static/routes=100:1. Memoize the composed middleware chain per route.
composeMiddlewarecalls every middleware constructor, each returning a fresh closure — so running it per request allocated one closure per middleware, per request. Five in a default app; an allocation profile put it at ~13% of all objects allocated while serving. The chain depends only on route + handler, both fixed once the router is built.2. Store the path-parameter map only when it holds something. gorilla/mux allocates a Vars map on every successful match (
route.go:101), even for a route with no parameters — costing a context node and a shallowRequestcopy per request to carry nothing.3.
trieNode.collectreturns its slice instead of taking a*[]*routeEntryout-parameter, which stopped the slice header escaping to the heap.4.
headerCarrier.Valuesno longer canonicalizes. It went throughhttp.Header.Values, which canonicalizes whatever key it gets."baggage"is not already canonical, so that allocated the canonical string on every request — for the one header the W3C propagators always ask about. It now uses the same canonical-key tableGetdoes.Breaking Changes (if applicable):
GOFR_ROUTER), neither affecting the default mux path:mux.Vars(r)returnsnilrather than an empty non-nil map when a route has no path parameters. Every read stays correct — indexing yields the zero value,lenis 0, ranging does nothing, which is allRequest.PathParamand handlers do with it. An explicit!= nilcheck would see a different answer than under the mux matcher.Middleware registered after a route's first request is not in that route's cached chain. GoFr registers all middleware before
Run, and the trie index already assumed the same.Additional Information:
TestTrieRequestAllocationsDoNotRegresscounts allocations so a later change cannot give them back silently. It uses five real middleware closures — a guard built on a no-op middleware cannot fail, so it would have been worthless.//go:build !race: the race detector adds allocations, so an exact count cannot hold under it.docs/advanced-guide/routing-performance/page.mddocuments the trie router's behaviour.Checklist:
goimportandgolangci-lint.