Skip to content

compression: add compression-threads option - #7128

Open
ernetas wants to merge 1 commit into
moby:masterfrom
ernetas:zstd-compression-threads
Open

compression: add compression-threads option#7128
ernetas wants to merge 1 commit into
moby:masterfrom
ernetas:zstd-compression-threads

Conversation

@ernetas

@ernetas ernetas commented Sep 7, 2026

Copy link
Copy Markdown

Layers exported as zstd are not compressed in parallel today. zstd.NewWriter is created with klauspost's default concurrency (GOMAXPROCS), but in streaming mode that only pipelines match finding with entropy coding and writing; the blocks themselves are encoded sequentially because they share the match history. A large layer at a high compression level takes minutes while the other cores sit idle.

klauspost/compress (already vendored at v1.19.2) supports real parallel stream compression with WithConcurrentBlocks(true): the input is split into large jobs (4× window size, 16–32MB) that are compressed simultaneously, each non-first job receives an overlap prefix from the previous job, and the output is flushed in order as a valid single-frame stream — the same design as zstd -T<n>.

This PR exposes that as a compression-threads=<n> attribute next to compression-level. It is parsed by the shared compression.ParseAttributes, so the image, OCI, registry cache and local cache exporters all accept it:

  • 0: use all CPUs (GOMAXPROCS), like zstd -T0
  • 1: the sequential encoder
  • n ≥ 2: n parallel jobs
  • unset: unchanged behaviour

Only zstd honours it for now; like compression-level with compression=uncompressed, other types ignore it. Negative or non-integer values are rejected when the attributes are parsed.

Determinism

Job boundaries are fixed by size and each job depends only on its own input and its overlap prefix, so the output does not depend on the number of threads or on how the tar stream is chunked into writes — only on whether parallel mode is used at all. TestZstdCompressThreads asserts this on a 40MB input spanning several jobs: threads=2threads=4threads=0, one write ≡ 4KB writes, and threads=1 ≡ unset.

The option is opt-in and the default output is byte-for-byte unchanged, because enabling parallel jobs changes the produced blob digests.

Numbers

256MB of synthetic text-like data, Apple M1 Pro (10 cores), BenchmarkZstdCompressThreads:

level default threads=1 threads=2 threads=4 threads=8 ratio default → parallel
3 493 MB/s 309 MB/s 593 MB/s 1057 MB/s 1652 MB/s 20.112% → 20.111%
12 83 MB/s 76 MB/s 147 MB/s 282 MB/s 384 MB/s 17.106% → 17.105%

Memory grows with the thread count: each in-flight job buffers 16–32MB of input and up to ~2n jobs can be in flight per layer writer, on top of buildkit already compressing the layers of a chain concurrently. That is why this is opt-in rather than the new default.

Not in this PR

  • gzip/estargz stay as they are; the option is deliberately generic so a parallel gzip implementation could honour it later.
  • Switching the default to parallel. That changes digests and memory use, so it deserves its own discussion.

Layers exported as zstd are not compressed in parallel. The encoder
from klauspost/compress is created with its default concurrency, but
in streaming mode that only pipelines match finding with entropy
coding and writing: the blocks themselves are encoded sequentially
because they share the match history. Compressing a large layer at a
high level can take minutes while the remaining cores sit idle.

The encoder can instead split the input into large independently
compressed jobs (zstd.WithConcurrentBlocks), the same way the zstd
CLI implements -T<n>. Job boundaries are fixed and each job depends
only on its own input plus an overlap prefix taken from the previous
job, so the output is deterministic: it does not depend on the number
of threads or on how the stream is written, only on whether parallel
mode is used at all. The compression ratio is practically unchanged.

Expose this as a compression-threads=<n> attribute next to
compression-level, parsed by the shared compression attribute parser
so the image, OCI, registry cache and local cache exporters all accept
it. 0 uses all CPUs, 1 selects the sequential encoder and larger
values select the number of parallel jobs. Like compression-level for
uncompressed layers, the option is ignored by compression types that
have no use for it; only zstd honours it for now.

The option is opt-in and the default output is unchanged. Enabling
parallel jobs changes the produced blob digests and buffers up to a
few 16-32MB jobs per thread, so the memory for speed trade-off is left
to the user.

Signed-off-by: Ernestas Lukoševičius <ernetas@gmail.com>
@tonistiigi

Copy link
Copy Markdown
Member

I don't see how a user is ever supposed to know what value provides good performance and doesn't OOM the machine at the same time. I support making sure we use all cores, but for that we need controls that allow using X cores to compress Y layers in parallel. If it is X cores for one layer, where all layers still run in parallel, then it is unpredictable.

See also #6841

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants