caddyhttp: presize buffers in hot paths to reduce allocations (#7842)
Some checks are pending
Tests / test (./cmd/caddy/caddy, ~1.27.0, macos-latest, 0, 1.27, mac) (push) Waiting to run
Tests / test (./cmd/caddy/caddy, ~1.27.0, ubuntu-latest, 0, 1.27, linux) (push) Waiting to run
Tests / test (./cmd/caddy/caddy.exe, ~1.27.0, windows-latest, True, 1.27, windows) (push) Waiting to run
Tests / test (s390x on IBM Z) (push) Waiting to run
Tests / goreleaser-check (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, aix) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, linux) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, windows) (push) Waiting to run
Lint / lint (push) Waiting to run
Lint / lint-1 (push) Waiting to run
Lint / lint-2 (push) Waiting to run
Lint / govulncheck (push) Waiting to run
Lint / dependency-review (push) Waiting to run
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Waiting to run

* caddyhttp: presize buffers in hot paths to reduce allocations

Right-size three per-request allocations that previously grew from a
nil/empty backing array, causing repeated reallocation:

- CleanPath: pre-Grow the strings.Builder used when slash collapsing is
  disabled, instead of letting it reallocate as runes are appended
- headers: presize the rewritten value slice in HeaderOps.ApplyTo's Set
  loop instead of appending from nil
- reverseproxy: presize the weights slice in WeightedRoundRobinSelection
  .Select

This commit also adds benchmarks exercising each path. Measured with benchstat
with count=20 and GOMAXPROCS=1, with p=0.000:

                                    sec/op                  B/op              allocs/op
CleanPathNoCollapse       1013.4n -> 828.6n  (-18.23%)  120->64  (-46.67%)   4->1  (-75.00%)
HeaderOpsApplyToSet       1262.0n -> 955.8n  (-24.27%)  255->152 (-40.39%)   9->6  (-33.33%)
WeightedRoundRobinSelect   215.8n -> 200.3n  ( -7.20%)  112->96  (-14.29%)   2->2  (N/A)

* caddyhttp: presize strings.Builder in query rewrite and path matcher

Two builders whose output size is bounded by a known input length were
growing from empty, causing repeated reallocation. Seed each with a
capacity hint:

- rewrite.buildQueryString: pre-Grow to len(qs) (runs per request when
  rewrite query manipulation is configured)
- MatchPath.matchPatternWithEscapeSequence: pre-Grow to len(escapedPath)
  (reached only when a path pattern contains % escape sequences)

Adds a benchmark for buildQueryString, measured with benchstat:

BuildQueryString:
	- sec/op  965.2n -> 743.3n  (-22.99%)
	- B/op 248->80  (-67.74%)
	- allocs/op 5->1  (-80.00%)

The builder previously reallocated four times per call. The matcher path
is cold and not separately benchmarked.

* chore: add benchmark headers

---------

Signed-off-by: Julien Voisin <jvoisin@users.noreply.github.com>
This commit is contained in:
Julien Voisin 2026-11-04 21:26:53 +01:00 • committed by GitHub
parent 7145e5eba8
commit c8492563ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 138 additions and 5 deletions

View file

@ -286,6 +286,7 @@ func CleanPath(p string, collapseSlashes bool) string {
// and then remove the remaining temporary characters.
const tmpCh = 0xff
var sb strings.Builder
sb.Grow(len(p) + 1)
for i, ch := range p {
if ch == '/' && i > 0 && p[i-1] == '/' {
sb.WriteByte(tmpCh)

View file

@ -0,0 +1,25 @@
// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package caddyhttp
import "testing"
func BenchmarkCleanPathNoCollapse(b *testing.B) {
const p = "/foo/bar/baz/qux/some/longer/path/segment/here/index.html"
b.ReportAllocs()
for b.Loop() {
_ = CleanPath(p, false)
}
}

View file

@ -259,11 +259,9 @@ func (ops *HeaderOps) ApplyTo(hdr http.Header, repl *caddy.Replacer) {
if expanded := repl.ReplaceKnown(fieldName, ""); expanded != fieldName {
fieldName = http.CanonicalHeaderKey(expanded)
}
var newVals []string
newVals := make([]string, len(vals))
for i := range vals {
// append to new slice so we don't overwrite
// the original values in ops.Set
newVals = append(newVals, repl.ReplaceKnown(vals[i], ""))
newVals[i] = repl.ReplaceKnown(vals[i], "")
}
if fieldName == "Set-Cookie" && len(newVals) > 1 {
hdr.Del(fieldName)

View file

@ -0,0 +1,38 @@
// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package headers
import (
"net/http"
"testing"
"github.com/caddyserver/caddy/v2"
)
func BenchmarkHeaderOpsApplyToSet(b *testing.B) {
ops := &HeaderOps{
Set: http.Header{
"Content-Type": []string{"text/html; charset=utf-8"},
"Cache-Control": []string{"public", "max-age=3600", "immutable"},
"X-Custom-Header": []string{"value-one", "value-two"},
},
}
repl := caddy.NewReplacer()
b.ReportAllocs()
for b.Loop() {
hdr := make(http.Header)
ops.ApplyTo(hdr, repl)
}
}

View file

@ -552,6 +552,7 @@ func (MatchPath) matchPatternWithEscapeSequence(escapedPath, matchPath string) b
// of the path, then switching to the escaped parts where
// the pattern hints to us wherever % is present.
var sb strings.Builder
sb.Grow(len(escapedPath))
// iterate the pattern and escaped path in lock-step;
// increment iPattern every time we consume a char from the pattern,

View file

@ -152,7 +152,7 @@ func (r *WeightedRoundRobinSelection) Select(pool UpstreamPool, _ *http.Request,
return pool[0]
}
var index, totalWeight int
var weights []int
weights := make([]int, 0, len(r.Weights))
for _, w := range r.Weights {
if w > 0 {

View file

@ -0,0 +1,39 @@
// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package reverseproxy
import (
"net/http"
"testing"
)
func BenchmarkWeightedRoundRobinSelect(b *testing.B) {
pool := UpstreamPool{
{Host: new(Host), Dial: "0.0.0.1"},
{Host: new(Host), Dial: "0.0.0.2"},
{Host: new(Host), Dial: "0.0.0.3"},
{Host: new(Host), Dial: "0.0.0.4"},
{Host: new(Host), Dial: "0.0.0.5"},
}
wrrPolicy := &WeightedRoundRobinSelection{
Weights: []int{5, 4, 3, 2, 1},
totalWeight: 15,
}
req, _ := http.NewRequest("GET", "/", nil)
b.ReportAllocs()
for b.Loop() {
_ = wrrPolicy.Select(pool, req, nil)
}
}

View file

@ -374,6 +374,7 @@ func escapePathPreservingSlashes(path string) string {
// duplicate keys rather than replaces.
func buildQueryString(qs string, repl *caddy.Replacer) string {
var sb strings.Builder
sb.Grow(len(qs))
// first component must be key, which is the same
// as if we just wrote a value in previous iteration

View file

@ -0,0 +1,30 @@
// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package rewrite
import (
"testing"
"github.com/caddyserver/caddy/v2"
)
func BenchmarkBuildQueryString(b *testing.B) {
repl := caddy.NewReplacer()
const qs = "foo=bar&baz=qux&search=hello+world&page=2&sort=desc&filter=active&id=12345"
b.ReportAllocs()
for b.Loop() {
_ = buildQueryString(qs, repl)
}
}