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%.
This commit is contained in:
jvoisin
2026-06-02 23:45:17 +02:00
committed by fguillot
parent 56d0b31cb6
commit ef24215bde
+37 -3
View File
@@ -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
}
}