From 8c35fb2a383c96678dbb50e48e6ae2d21639adbb Mon Sep 17 00:00:00 2001 From: jvoisin Date: Tue, 30 Jun 2026 17:09:40 +0200 Subject: [PATCH] fileserver: reduce allocations in calculateEtag calculateEtag runs for every static file served (and on conditional If-None-Match revalidations). It used a non-presized strings.Builder plus two strconv.FormatInt calls, each allocating an intermediate string and triggering builder growth, which is about six allocations per call. This commit builds the quoted etag into a fixed 32-byte stack array with strconv.AppendInt instead, so only the final string is heap-allocated. The output format is byte-for-byte identical. Two base-36 int64 values plus the two quote bytes are at most 30 bytes (the worst case is MinInt64, which is `1y2p0ij32e8e8`, so 13 digits plus a minus sign, so two times 14, plus the quotes, for a total of 30 characters), so the buffer never spills. And even if it did, which it doesn't append would simply grow it onto the heap without changing the result. Adds a benchmark, as required by the policy: CalculateEtag: - sec/op 347.6n -> 186.2n (-46.42%) - B/op 112 -> 56 (-50%) - allocs/op 6 -> 2 (-66.67%) It removes some CPU work, but more interestingly four of six per-file allocations on a hot path of a very common handler, lowering per-request garbage and GC pressure. --- .../fileserver/calculate_etag_bench_test.go | 27 +++++++++++++++++++ modules/caddyhttp/fileserver/staticfiles.go | 14 +++++----- 2 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 modules/caddyhttp/fileserver/calculate_etag_bench_test.go diff --git a/modules/caddyhttp/fileserver/calculate_etag_bench_test.go b/modules/caddyhttp/fileserver/calculate_etag_bench_test.go new file mode 100644 index 00000000000..ab6cde79114 --- /dev/null +++ b/modules/caddyhttp/fileserver/calculate_etag_bench_test.go @@ -0,0 +1,27 @@ +package fileserver + +import ( + "io/fs" + "testing" + "time" +) + +type benchFileInfo struct { + size int64 + mtime time.Time +} + +func (fi benchFileInfo) Name() string { return "file.txt" } +func (fi benchFileInfo) Size() int64 { return fi.size } +func (fi benchFileInfo) Mode() fs.FileMode { return 0o644 } +func (fi benchFileInfo) ModTime() time.Time { return fi.mtime } +func (fi benchFileInfo) IsDir() bool { return false } +func (fi benchFileInfo) Sys() any { return nil } + +func BenchmarkCalculateEtag(b *testing.B) { + fi := benchFileInfo{size: 1234567, mtime: time.Unix(1700000000, 123456789)} + b.ReportAllocs() + for b.Loop() { + _ = calculateEtag(fi) + } +} diff --git a/modules/caddyhttp/fileserver/staticfiles.go b/modules/caddyhttp/fileserver/staticfiles.go index 3ca7452e112..7dd87503d9d 100644 --- a/modules/caddyhttp/fileserver/staticfiles.go +++ b/modules/caddyhttp/fileserver/staticfiles.go @@ -765,12 +765,14 @@ func calculateEtag(d os.FileInfo) string { if !usefulModTime(mtime) { return "" } - var sb strings.Builder - sb.WriteRune('"') - sb.WriteString(strconv.FormatInt(mtime.UnixNano(), 36)) - sb.WriteString(strconv.FormatInt(d.Size(), 36)) - sb.WriteRune('"') - return sb.String() + // A quoted etag of two base-36 int64 values is 30 bytes, thus fitting + // comfortably in 32 bytes, on the stack to prevent allocations + var buf [32]byte + b := append(buf[:0], '"') + b = strconv.AppendInt(b, mtime.UnixNano(), 36) + b = strconv.AppendInt(b, d.Size(), 36) + b = append(b, '"') + return string(b) } // Finds the first corresponding etag file for a given file in the file system and return its content