From ef24215bdedc5754b0a07ae057726aeb7f5855bd Mon Sep 17 00:00:00 2001 From: jvoisin Date: Tue, 2 Jun 2026 23:45:17 +0200 Subject: [PATCH] perf(response): pool on-the-fly compression writers For every dynamic response above the 1 KiB compression threshold the builder previously did one of: brotli.NewWriterV2(b.w, brotli.DefaultCompression) gzip.NewWriter(b.w) flate.NewWriter(b.w, -1) Each constructor allocates the encoder's working set from scratch: brotli's sliding window + hash tables (~3.5 MiB at quality 6), gzip's CRC32 + deflate state (~800 KiB), flate's hash chains (~800 KiB). On a busy server that's per-request churn the GC then has to clean up. All three writer types expose Reset(dst io.Writer), which rebinds the destination without touching the internal buffers. So put each behind a sync.Pool and Get/Reset/Put around the existing Write+Close pair. The constructor signatures didn't change; only Pool.Get + Reset are new. Note that brotli.NewWriterV2 (kept from before this change) returns *matchfinder.Writer, not *brotli.Writer, as V2 is the pure-Go encoder built on top of github.com/andybalholm/brotli/matchfinder, where the actual Writer type lives. Hence the matchfinder import. On a local artificial benchmarks of a 130 KiB HTML-like payload consisting of 250 entry-list items, on a single-core it improves performances by around 10%, and for multicore under GC pressure, ns/op is reduces by ~80% and B/op by ~99%. --- internal/http/response/builder.go | 40 ++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/internal/http/response/builder.go b/internal/http/response/builder.go index 55b0796b..d3240810 100644 --- a/internal/http/response/builder.go +++ b/internal/http/response/builder.go @@ -13,13 +13,38 @@ import ( "mime" "net/http" "strings" + "sync" "time" "github.com/andybalholm/brotli" + "github.com/andybalholm/brotli/matchfinder" ) const compressionThreshold = 1024 +// Compression writers are pooled so each request reuses their internal +// state (brotli sliding window + hash tables, flate dictionary, etc.) +// instead of allocating it from scratch. Reset(dst) rebinds the +// destination without re-allocating the buffers. +var ( + brotliWriterPool = sync.Pool{ + New: func() any { + return brotli.NewWriterV2(io.Discard, brotli.DefaultCompression) + }, + } + gzipWriterPool = sync.Pool{ + New: func() any { + return gzip.NewWriter(io.Discard) + }, + } + flateWriterPool = sync.Pool{ + New: func() any { + w, _ := flate.NewWriter(io.Discard, flate.DefaultCompression) + return w + }, + } +) + // Builder generates HTTP responses. type Builder struct { w http.ResponseWriter @@ -146,25 +171,34 @@ func (b *Builder) compress(data []byte) { b.headers.Set("Content-Encoding", "br") b.writeHeaders() - brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression) + brotliWriter := brotliWriterPool.Get().(*matchfinder.Writer) + brotliWriter.Reset(b.w) brotliWriter.Write(data) brotliWriter.Close() + brotliWriter.Reset(io.Discard) + brotliWriterPool.Put(brotliWriter) return case "gzip": b.headers.Set("Content-Encoding", "gzip") b.writeHeaders() - gzipWriter := gzip.NewWriter(b.w) + gzipWriter := gzipWriterPool.Get().(*gzip.Writer) + gzipWriter.Reset(b.w) gzipWriter.Write(data) gzipWriter.Close() + gzipWriter.Reset(io.Discard) + gzipWriterPool.Put(gzipWriter) return case "deflate": b.headers.Set("Content-Encoding", "deflate") b.writeHeaders() - flateWriter, _ := flate.NewWriter(b.w, -1) + flateWriter := flateWriterPool.Get().(*flate.Writer) + flateWriter.Reset(b.w) flateWriter.Write(data) flateWriter.Close() + flateWriter.Reset(io.Discard) + flateWriterPool.Put(flateWriter) return } }