Python's zstd threads sit idle above level 3
Python 3.14 ships zstd in the standard library as compression.zstd, and it exposes zstd's multithreading through one option, CompressionParameter.nb_workers. On a 16 MB input, eight workers made level 1 compress 4.2ร faster. At level 3 they gave 1.7ร. At level 9 and above they did nothing at all. Setting two more parameters, job_size and, at the top levels, overlap_log, turned that into a 3.4ร speedup at level 9 and up to 5ร at level 19, for a ratio loss of 1 to 4%.
The test
The input is every .py file in the CPython 3.14 standard library, concatenated in sorted order: 16.3 MB of real source code. Each configuration is a one-shot compress(data, options=โฆ) call, timed five times. I report the median of three such rounds, each run in a fresh process. The setup is CPython 3.14.8 with its bundled zstd 1.5.7, in a Linux aarch64 container with 10 cores. The machine had other work on it, so treat differences under about 10% as noise.
| level | 0 workers (s) | 2 | 4 | 8 | speedup at 8 |
|---|---|---|---|---|---|
| 1 | 0.021 | 0.011 | 0.007 | 0.005 | 4.2ร |
| 3 | 0.029 | 0.017 | 0.017 | 0.018 | 1.7ร |
| 9 | 0.130 | 0.132 | 0.134 | 0.137 | 1.0ร |
| 15 | 1.233 | 1.328 | 1.217 | 1.386 | 0.9ร |
| 19 | 2.838 | 2.761 | 3.064 | 2.829 | 1.0ร |
Level 3 stops at two workers, and from level 9 up the extra threads might as well not exist. Setting nb_workers=1 isn't free either: it moves compression onto a background thread, which was 2 to 5% slower than 0 at the fast levels and changed the output slightly.
Why: one big job
zstd's multithreaded mode doesn't split the input across workers evenly. It cuts the input into jobs and hands each job to a worker. With job_size left at 0, the job size scales with the compression window, which grows with the level. The numbers fit jobs of about four windows: 2 MB at level 1 gives eight jobs for eight workers, 8 MB at level 3 gives two jobs, and 16 MB or more from level 9 up puts the whole input into a single job. A 16 MB file is simply too small to split at the default job size. A much larger file would be split, so this mostly affects inputs in the tens of megabytes and below.
Setting the job size yourself fixes level 9:
| level 9, 8 workers | time (s) | ratio | vs single-threaded |
|---|---|---|---|
| 0 workers (baseline) | 0.131 | 5.066 | โ |
| job_size default | 0.133 | 5.064 | 1.0ร |
| job_size 4 MB | 0.052 | 5.038 | 2.5ร, โ0.6% ratio |
| job_size 2 MB | 0.039 | 5.019 | 3.4ร, โ0.9% ratio |
| job_size 1 MB | 0.046 | 5.007 | 2.8ร, โ1.2% ratio |
Level 19 needs the overlap too
At level 19, job_size did nothing. With 8 MB, 4 MB, 2 MB and 1 MB jobs the output was the same size to the byte, and the time stayed around 2.5 s. The reason is the overlap: each job is primed with the tail of the data before it, so matches can cross job boundaries. At the strongest levels that overlap defaults to the whole window, 8 MB here, and zstd won't make a job smaller than its overlap. overlap_log sets the overlap as a fraction of the window: 9 is the whole window, and each step down halves it.
| level 19, 8 workers, 2 MB jobs | time (s) | ratio | vs single-threaded |
|---|---|---|---|
| 0 workers (baseline) | 2.838 | 5.845 | โ |
| overlap_log 9 (full window) | 2.501 | 5.844 | 1.1ร |
| overlap_log 7 | 0.829 | 5.717 | 3.4ร, โ2.2% ratio |
| overlap_log 6 | 0.619 | 5.646 | 4.6ร, โ3.4% ratio |
| overlap_log 5 | 0.532 | 5.604 | 5.3ร, โ4.1% ratio |
| overlap_log 1 | 0.452 | 5.441 | 6.3ร, โ6.9% ratio |
The useful comparison is with level 15 on one thread: 1.23 s for a ratio of 5.31. Level 19 with 2 MB jobs and overlap_log=6 takes half that time and still compresses 6% better. Below overlap_log 5 the time barely improves and the ratio keeps falling. Lowering overlap_log without setting job_size changed nothing, because the default job is still bigger than the input.
Takeaway
Setting nb_workers on its own only reliably helped at level 1. For anything stronger on inputs under a few tens of megabytes, set the job size as well:
from compression.zstd import compress, CompressionParameter as P
opts = {P.compression_level: 19, P.nb_workers: 8,
P.job_size: 2 << 20, P.overlap_log: 6}
out = compress(data, options=opts)
Every setting here round-tripped through decompress correctly, and the output is an ordinary zstd frame that any decoder reads. Method: one input, one machine, one zstd version. Code with a different mix of repetition, or a much larger file, will land in different places, but checking whether your threads are working at all takes one timed run.