From 3dc962b30138f58f590638ae2b703a22e89c5423 Mon Sep 17 00:00:00 2001 From: Julien Voisin Date: Sat, 30 May 2026 06:52:37 +0200 Subject: [PATCH] perf(internal/gzip): pool *gzip.Writer per middleware instance (#1654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gzip.NewWriterLevel allocates fresh deflate window and hash table buffers (~1.18 MiB) on every request. This commit pools them in a closure-local sync.Pool so each middleware instance reuses its writers. The level is validated once at setup (NewWriterLevel against io.Discard); pooled writers are reset to io.Discard on Put so the pool doesn't pin response writers between requests. Only call site is RenderIndex (lib/http.go), which serves the challenge page, so this directly cuts the per-challenge allocation footprint. I benchmarked the change using the following benchmark, put in the commit message instead of in a file since it's pretty much useless outside of this particular change. ``` package internal import ( "io" "net/http" "net/http/httptest" "testing" ) func BenchmarkGzipMiddleware(b *testing.B) { payload := make([]byte, 4096) for i := range payload { payload[i] = byte(i) } inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(payload) }) h := GzipMiddleware(1, inner) b.ReportAllocs() b.RunParallel(func(pb *testing.PB) { req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Accept-Encoding", "gzip") for pb.Next() { rec := httptest.NewRecorder() h.ServeHTTP(rec, req) io.Copy(io.Discard, rec.Body) } }) } ``` The results are pretty nice: Benchmarks (Linux arm64, count=10, benchstat, vs origin/main): GzipMiddleware-8 sec/op 158.8µs ± 4% -> 5.2µs ± 3% -96.72% (p=0.000) GzipMiddleware-8 B/op 1180.6 KiB -> 1.9 KiB -99.84% (p=0.000) GzipMiddleware-8 allocs/op 32 -> 13 -59.38% (p=0.000) Signed-off-by: jvoisin --- docs/docs/CHANGELOG.md | 1 + internal/gzip.go | 29 ++++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index cf766d4e..645369fd 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix a race in the bbolt store where the asynchronous cleanup scheduled by an expired read could delete a value that had just been refreshed; the delete now only fires when the key still carries the same expired generation it observed. - Marginally increase the performances of requests processing - Marginally improve the performances of PoW validation +- Significantly improve the performances of the gzip middleware ## v1.25.0: Necron diff --git a/internal/gzip.go b/internal/gzip.go index c83a0edf..e9ecdb9d 100644 --- a/internal/gzip.go +++ b/internal/gzip.go @@ -2,11 +2,28 @@ package internal import ( "compress/gzip" + "io" "net/http" "strings" + "sync" ) func GzipMiddleware(level int, next http.Handler) http.Handler { + // Validate the level once at setup; gzip.NewWriterLevel only fails for + // invalid levels and we'd rather panic now than mid-request. + if _, err := gzip.NewWriterLevel(io.Discard, level); err != nil { + panic(err) + } + + // Per-middleware pool of *gzip.Writer. Each entry carries ~40 KiB of + // deflate buffers; reusing them avoids that allocation on every request. + pool := sync.Pool{ + New: func() any { + gz, _ := gzip.NewWriterLevel(io.Discard, level) + return gz + }, + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { next.ServeHTTP(w, r) @@ -14,11 +31,13 @@ func GzipMiddleware(level int, next http.Handler) http.Handler { } w.Header().Set("Content-Encoding", "gzip") - gz, err := gzip.NewWriterLevel(w, level) - if err != nil { - panic(err) - } - defer gz.Close() + gz := pool.Get().(*gzip.Writer) + gz.Reset(w) + defer func() { + gz.Close() + gz.Reset(io.Discard) + pool.Put(gz) + }() grw := gzipResponseWriter{ResponseWriter: w, sink: gz} next.ServeHTTP(grw, r)