Skip to content

perf: pool compressor/decompressor contexts in the one-shot helpers - #3

Open
AskAlexSharov wants to merge 1 commit into
mainfrom
perf/pool-oneshot-helpers
Open

perf: pool compressor/decompressor contexts in the one-shot helpers#3
AskAlexSharov wants to merge 1 commit into
mainfrom
perf/pool-oneshot-helpers

Conversation

@AskAlexSharov

@AskAlexSharov AskAlexSharov commented Jul 23, 2026

Copy link
Copy Markdown

Inefficiency

The convenience helpers — GzipCompress, ZlibCompress, GzipDecompress, ZlibDecompress — allocated a fresh libdeflate context on every call and freed it on return:

c, _ := NewCompressor(level) // libdeflate_alloc_compressor: builds match-finder tables
defer c.Close()              // libdeflate_free_compressor

libdeflate_alloc_compressor allocates internal match-finder tables, so for one-shot calls on small payloads that alloc/free pair — not the compression — dominates the cost. (The low-level API already lets callers reuse a context; the README even recommends pooling. This PR brings the same benefit to the convenience API.)

Change

Reuse contexts via bounded, per-level pools (pool.go). The helpers now getCompressor/putCompressor (and the decompressor equivalents) instead of allocating fresh each time.

  • Bounded channel, not sync.Pool: sync.Pool drops its contents during GC. For a C-backed handle that means leaking the C context, since this branch attaches no finalizer to free it. A channel keeps the pooled contexts reachable, and Put frees any surplus beyond the cap — so memory stays bounded and nothing leaks. Each handout is owned exclusively by one caller, so a pooled compressor is never used concurrently (libdeflate contexts are not thread-safe).
  • Lazy init: each pool is created on first use via one sync.Once per level, so a program that only compresses at a single level allocates only that level's pool.

Configurable capacity

// 0 (default) = auto: 2*GOMAXPROCS clamped to [4, 32].
// A positive value is used verbatim.
var PoolCapacity int

The auto value is clamped to a ceiling of 32 so high-core machines don't retain a large number of big (e.g. level-12) contexts idle. An explicit PoolCapacity is honored verbatim. Set it before the first compress/decompress call: it does not resize pools that already exist and is not safe to change concurrently with use.

Benchmark

BenchmarkGzipCompressOneShot, Apple M4 Max, ~1 KB payload, level 6, -count=3:

Benchmark Before After Delta
Serial 8560 ns/op (116 MB/s) 6130 ns/op (162 MB/s) ~28% faster
Parallel 1350 ns/op (733 MB/s) 965 ns/op (1029 MB/s) ~29% faster

Go allocs/op also drop 2 → 1 (the reused wrapper is no longer heap-allocated per call).

Tests

  • TestConvenienceHelpersConcurrent — 32 goroutines round-tripping across levels 1/6/12, run under -race.
  • TestPoolCapacityOverride — the PoolCapacity override path.
  • The serial and parallel benchmarks above.

gofmt, go vet, golangci-lint (v2), and go test -race are all clean.

Scope

Based on main, independent of #2. Both touch libdeflate.go, but in different regions (the bug fixes are in the methods; this PR only rewires the four convenience functions), so they should merge without conflict in either order.

@AskAlexSharov
AskAlexSharov force-pushed the perf/pool-oneshot-helpers branch 3 times, most recently from 1d82c7f to adde006 Compare July 24, 2026 02:57
The convenience helpers (GzipCompress, ZlibCompress, GzipDecompress,
ZlibDecompress) allocated a fresh libdeflate context on every call and
freed it on return. Allocating a compressor builds internal match-finder
tables, so that alloc/free pair — not the compression itself — dominated
the cost of one-shot calls on small payloads.

Reuse contexts via bounded per-level pools. A bounded channel is used
rather than a sync.Pool: sync.Pool drops its contents on GC, which for a
C-backed handle means leaking the C context (no finalizer is attached on
this branch). A channel keeps pooled contexts reachable, and Put frees any
surplus beyond the cap, so memory stays bounded and nothing leaks. Each
handout is exclusively owned by one caller, so a pooled compressor is
never used concurrently (libdeflate contexts are not thread-safe).

Each pool is created lazily (one sync.Once per level). Capacity is the
exported PoolCapacity var: 0 (default) means auto = 2*GOMAXPROCS clamped
to [4, 32] so high-core machines don't retain many large (e.g. level-12)
contexts idle; a positive value is honored verbatim. Set it before first
use.

BenchmarkGzipCompressOneShot (Apple M4 Max, ~1 KB payload, level 6):

                   before        after       delta
  Serial       8560 ns/op    6130 ns/op    ~28% faster  (116 -> 162 MB/s)
  Parallel     1350 ns/op     965 ns/op    ~29% faster  (733 -> 1029 MB/s)

Go allocs/op also drop from 2 to 1 (the reused wrapper is no longer
heap-allocated per call). Adds a concurrent round-trip test across levels
(run under -race), a PoolCapacity override test, and the benchmarks.
@AskAlexSharov
AskAlexSharov force-pushed the perf/pool-oneshot-helpers branch from adde006 to 03a706d Compare July 24, 2026 03:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant