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)
This commit is contained in:
jvoisin 2026-06-25 00:25:22 +02:00
parent 13a4c3f43c
commit 3e419a5ab1
6 changed files with 66 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,11 @@
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

@ -243,11 +243,11 @@ func (ops *HeaderOps) ApplyTo(hdr http.Header, repl *caddy.Replacer) {
// set
for fieldName, vals := range ops.Set {
fieldName = repl.ReplaceKnown(fieldName, "")
var newVals []string
// use a new slice so we don't overwrite
// the original values in ops.Set
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], "")
}
hdr.Set(fieldName, strings.Join(newVals, ","))
}

View file

@ -0,0 +1,24 @@
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

@ -149,7 +149,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,25 @@
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)
}
}