Feature or enhancement (performance)
Proposal
compression.zstd decompression always builds its output through the growing
output buffer (initial 32 KiB, then progressively larger growth steps, finished
with a resize to the actual size), even though most zstd frames record the
exact decompressed size in the frame header: the one-shot compression APIs
(zstd.compress(), ZSTD_compress2()) write it by default.
Dealing with the growing buffer causes unnecessary work
For frames that record the size, ZstdDecompressor (and thereby
zstd.decompress()) can read it via ZSTD_getFrameContentSize() and
allocate the output buffer exactly once.
The exactly-filled buffer is then returned without any resize or copy.
The patched code is safe
This is only a sizing hint: decompression does not rely on it, so a
hand-crafted header recording a wrong size gives exactly the same
results/exceptions as today (the buffer grows further if needed, or is
shrunk to the actual size on finish).
Two guards keep hand-crafted headers from requesting large spurious
allocations:
- the recorded size is only trusted up to a fixed limit (1 GiB) — larger
outputs just keep using the growing buffer;
- a zstd block cannot expand to more than 128 KiB from less than 4 bytes of
input, so recorded sizes claiming more than a 32768x expansion of the
available input are ignored as implausible.
The patched code is usually faster
Benchmark setup
Measured with pyperf (mean +- standard
deviation over many worker processes). The corpus is deterministic and cached
on disk, so every worker process decompresses byte-identical input: a 16 MiB
blob of random 16-byte words drawn from a 200-word dictionary, sliced or
repeated to the requested size. The full script is at the bottom of this post.
|
Linux/x86-64 |
macOS/arm64 |
| CPU |
AMD Ryzen 5 8500GE |
Apple M3 Pro |
| OS / libc |
Debian 13, glibc 2.41 |
macOS 15.7.9 |
| compiler |
gcc 14.2 |
Apple clang 17.0 |
| libzstd |
1.5.7 |
1.5.7 |
| conditions |
idle, performance governor, pinned to one core |
cannot be quiesced or pinned |
| pyperf worker processes |
20 (default) |
10 |
Both builds are plain ./configure -q (no PGO, no LTO, OPT = -DNDEBUG -g -O3 -Wall), differing only in the patch. Note that essentially all of the time is
spent inside libzstd, a shared library that CPython's own build options do not
affect.
compression.zstd's default compression level is 3, so the tables below use
level 3; levels 1 and 10 were measured too and change little.
Constant decompressed size benchmarks
Linux/x86-64, level 3:
| decompressed size |
main |
patched |
speedup |
| 64 KiB |
34.1 µs +- 576 ns |
32.2 µs +- 683 ns |
+5.6% |
| 256 KiB |
252 µs +- 4.05 µs |
123 µs +- 3.71 µs |
+51.2% |
| 1 MiB |
1.01 ms +- 7.22 µs |
516 µs +- 5.27 µs |
+49.2% |
| 2 MiB |
3.56 ms +- 35.2 µs |
1.04 ms +- 9.28 µs |
+70.7% |
| 3 MiB |
2.97 ms +- 38.4 µs |
1.58 ms +- 27.1 µs |
+47.0% |
| 4 MiB |
3.99 ms +- 67.8 µs |
2.11 ms +- 39.9 µs |
+47.2% |
| 5 MiB |
5.03 ms +- 85.7 µs |
2.64 ms +- 35.3 µs |
+47.6% |
| 6 MiB |
6.10 ms +- 73.3 µs |
3.17 ms +- 66.9 µs |
+48.1% |
| 8 MiB |
8.54 ms +- 147 µs |
4.22 ms +- 76.6 µs |
+50.6% |
| 12 MiB |
12.9 ms +- 201 µs |
6.33 ms +- 48.5 µs |
+50.8% |
| 16 MiB |
17.2 ms +- 320 µs |
8.51 ms +- 178 µs |
+50.6% |
| 32 MiB |
33.9 ms +- 627 µs |
33.7 ms +- 654 µs |
+0.6% (*) |
| 64 MiB |
68.2 ms +- 576 µs |
68.2 ms +- 997 µs |
−0.1% (*) |
| 128 MiB |
138 ms +- 1.86 ms |
138 ms +- 1.59 ms |
+0.1% (*) |
| 256 MiB |
278 ms +- 4.56 ms |
277 ms +- 2.61 ms |
+0.5% (*) |
macOS/arm64, level 3:
| decompressed size |
main |
patched |
speedup |
| 64 KiB |
33.3 µs +- 1.61 µs |
32.8 µs +- 142 ns |
+1.7% (*) |
| 256 KiB |
132 µs +- 550 ns |
121 µs +- 9.32 µs |
+8.5% |
| 1 MiB |
563 µs +- 29.3 µs |
519 µs +- 27.4 µs |
+7.7% (*) |
| 2 MiB |
1.16 ms +- 83.9 µs |
1.09 ms +- 73.8 µs |
+6.7% (*) |
| 4 MiB |
2.33 ms +- 176 µs |
2.22 ms +- 100 µs |
+4.7% (*) |
| 8 MiB |
5.22 ms +- 227 µs |
4.20 ms +- 230 µs |
+19.5% |
| 16 MiB |
9.69 ms +- 622 µs |
8.55 ms +- 26.2 µs |
+11.8% |
| 32 MiB |
19.7 ms +- 1.32 ms |
18.3 ms +- 145 µs |
+7.0% (*) |
| 128 MiB |
79.3 ms +- 3.58 ms |
76.8 ms +- 4.23 ms |
+3.1% (*) |
(*) marks differences smaller than the combined standard deviations.
Speedup by compression level, Linux/x86-64 — the level makes little
difference:
| decompressed size |
level 1 |
level 3 |
level 10 |
| 64 KiB |
+5.6% |
+5.6% |
+6.5% |
| 256 KiB |
+51.2% |
+51.2% |
+54.7% |
| 1 MiB |
+48.9% |
+49.2% |
+49.4% |
| 2 MiB |
+50.6% |
+70.7% |
+72.2% |
| 3 MiB |
+48.2% |
+47.0% |
+48.8% |
| 4 MiB |
+48.1% |
+47.2% |
+49.5% |
| 5 MiB |
+48.6% |
+47.6% |
+49.0% |
| 6 MiB |
+48.6% |
+48.1% |
+50.2% |
| 8 MiB |
+51.1% |
+50.6% |
+52.5% |
| 12 MiB |
+51.1% |
+50.8% |
+52.3% |
| 16 MiB |
+51.4% |
+50.6% |
+52.1% |
| 32–256 MiB |
+0.8…+1.6% (*) |
−0.1…+0.6% (*) |
+0.4…+1.2% (*) |
Varying decompressed size benchmarks
A workload closer to real chunk-oriented use: 1000 frames whose decompressed
sizes are drawn from a normal distribution in log2(size), centred on 2 MiB and
spanning 512 KiB to 8 MiB at +-3 sigma (2269 MiB of plaintext in total). One
iteration decompresses all 1000 frames.
| level |
|
main |
patched |
speedup |
| 1 |
Linux |
1.29 s +- 20.6 ms |
1.14 s +- 5.78 ms |
+12.2% |
| 3 |
Linux |
1.44 s +- 9.42 ms |
1.28 s +- 9.55 ms |
+10.9% |
| 10 |
Linux |
1.27 s +- 9.07 ms |
1.11 s +- 23.7 ms |
+12.7% |
| 1 |
macOS |
1.21 s +- 81.0 ms |
1.23 s +- 68.7 ms |
−1.5% (*) |
| 3 |
macOS |
1.32 s +- 83.4 ms |
1.27 s +- 81.1 ms |
+3.5% (*) |
| 10 |
macOS |
1.21 s +- 128 ms |
1.04 s +- 81.6 ms |
+14.2% (*) |
All three macOS rows are within one standard deviation, so that machine cannot
resolve this workload — its standard deviations are 5–10% of the mean, against
0.5–2% on the pinned, idle Linux machine. The Linux rows are consistent at
+11–13%.
Because the sizes vary from call to call, this workload gives the allocator
much less opportunity to reuse a buffer of exactly the right size, so it is the
more conservative measurement of the two.
What the numbers mean
Avoiding the growth-path reallocations and the final shrink is only part of it.
A full-size output buffer also lets libzstd decompress directly into it:
ZSTD_decompressStream can skip its internal window buffer, and the copies
that go with it, when the remaining output buffer is large enough for the whole
frame. The growing buffer never permits that.
Two things stand out in the tables above.
The gains are much larger on glibc than on macOS. The patched build performs
almost identically on both machines (8 MiB: 4.22 ms on Linux, 4.20 ms on macOS)
— it is the unpatched build that is far slower on Linux (8.54 ms vs 5.22 ms).
The reason is glibc's dynamic mmap threshold: the growing buffer's
reallocations produce a succession of differently sized large blocks, and once
those exceed the threshold each one is mmap'd and munmap'd again per call, so
every call re-faults and re-zeroes the whole output. perf attributes 43–46% of
the unpatched build's samples to the kernel fault paths (do_anonymous_page,
kernel_init_pages) from 6 MiB upwards, against 0.2–0.3% for the patched build;
measured with getrusage(), the unpatched build takes 1712/4099/8195 minor
faults per call at 6/16/32 MiB where the patched build takes none. The patched
build asks for the same exact size on every call, so its allocation is simply
recycled.
A corollary worth stating explicitly: the unpatched path's cost depends on the
process's malloc history, because anything else that frees a large block
raises that threshold. A process that compresses before it decompresses (as a
self-contained benchmark naturally does) is measurably faster in the unpatched
build than a process that only ever decompresses — which is the real workload
here, since data usually arrives already compressed. At 1 MiB on Linux, level 1,
the unpatched build takes 952 µs when the frame is simply read from a file, but
532 µs in a process that compressed it first, and 513 µs if
MALLOC_MMAP_THRESHOLD_ is raised by hand; the patched build takes 485–496 µs
in all three cases. Earlier revisions of this post quoted the primed figures,
which understated the difference. The benchmark script below decompresses frames
read from disk.
The unpatched path is not monotonic in output size. On Linux at level 3 it
needs 3.56 ms for 2 MiB but only 2.97 ms for 3 MiB, reproducibly, because 2 MiB
lands just past a capacity step and pays a growth reallocation plus a large
final shrink. The patched build is linear across the same range (1.04, 1.58,
2.11 ms for 2, 3, 4 MiB).
At 32 MiB and above both builds exceed glibc's mmap threshold ceiling
(DEFAULT_MMAP_THRESHOLD_MAX), so both fault in the whole output on every call;
that cost dominates and the remaining difference falls within the noise.
Raising the thresholds so that neither build returns memory to the kernel
(MALLOC_MMAP_THRESHOLD_/MALLOC_TRIM_THRESHOLD_ = 512 MiB) removes that
effect and leaves the allocator-independent part of the win: at 32 MiB,
19.46 ms vs 15.52 ms (+20%).
Benchmark script
#!/usr/bin/env python3
"""Benchmark compression.zstd decompression (CPython gh-155460).
Measures ``zstd.decompress()`` with pyperf, on two workloads:
* one frame of a fixed decompressed size, for a range of sizes;
* a chunk-oriented workload: 1000 frames whose decompressed sizes are drawn
from a normal distribution in log2(size), centred on 2 MiB and spanning
512 KiB to 8 MiB at +-3 sigma (the endpoints are geometrically symmetric
around the target). One iteration decompresses all 1000 frames.
The corpus is deterministic (fixed seeds) and cached on disk next to this
script, so every pyperf worker process decompresses byte-identical input:
a 16 MiB blob of random 16-byte words drawn from a 200-word dictionary,
which compresses about 9:1 at level 1.
Run it once per interpreter, then compare:
./python bench_zstd_decompress.py --level 3 -o main.json
../other/python bench_zstd_decompress.py --level 3 -o patched.json
./python bench_zstd_decompress.py --compare main.json patched.json
The last command prints a table of mean +- standard deviation for both
runs. ``python -m pyperf compare_to main.json patched.json --table`` works
too and adds pyperf's significance test.
Requires pyperf (``pip install pyperf``) for both interpreters.
"""
import math
import os
import random
import struct
import sys
from compression import zstd
CACHE_DIR = os.path.dirname(os.path.abspath(__file__))
BLOB_SEED = 42
CHUNK_SEED = 1234
NCHUNKS = 1000
MIN_CHUNK, TARGET_CHUNK, MAX_CHUNK = 512 << 10, 2 << 20, 8 << 20
SIZES = {
"64K": 1 << 16, "256K": 1 << 18, "1M": 1 << 20, "2M": 2 << 20,
"3M": 3 << 20, "4M": 4 << 20, "5M": 5 << 20, "6M": 6 << 20,
"7M": 7 << 20, "8M": 8 << 20, "9M": 9 << 20, "10M": 10 << 20,
"11M": 11 << 20, "12M": 12 << 20, "13M": 13 << 20, "14M": 14 << 20,
"15M": 15 << 20, "16M": 16 << 20, "32M": 32 << 20, "64M": 64 << 20,
"128M": 128 << 20, "256M": 256 << 20,
}
DEFAULT_SIZES = "64K,256K,1M,2M,3M,4M,5M,6M,8M,12M,16M,32M,64M,128M,256M"
def get_blob():
"""16 MiB of moderately compressible data (cached on disk)."""
path = os.path.join(CACHE_DIR, "zstd_bench_blob.bin")
if not os.path.exists(path):
rng = random.Random(BLOB_SEED)
words = [bytes(rng.randrange(256) for _ in range(16))
for _ in range(200)]
blob = b"".join(rng.choice(words) for _ in range(1 << 20))
_write_atomic(path, blob)
with open(path, "rb") as fp:
return fp.read()
def _write_atomic(path, data):
with open(path + ".tmp", "wb") as fp:
fp.write(data)
os.replace(path + ".tmp", path)
def get_frame(label, level):
"""One frame of the given decompressed size (cached on disk).
Cached so that a high compression level is not paid once per pyperf
worker process, and so that all workers use byte-identical input.
"""
path = os.path.join(CACHE_DIR, f"zstd_bench_frame_{label}_l{level}.bin")
if not os.path.exists(path):
size = SIZES[label]
blob = get_blob()
data = (blob * (size // len(blob) + 1))[:size]
frame = zstd.compress(data, level)
assert zstd.decompress(frame) == data, "round-trip failed"
del data, blob
_write_atomic(path, frame)
with open(path, "rb") as fp:
return fp.read()
def chunk_sizes():
"""1000 chunk sizes: normal in log2(size), 512 KiB..8 MiB, mode 2 MiB."""
lo, mu, hi = (math.log2(MIN_CHUNK), math.log2(TARGET_CHUNK),
math.log2(MAX_CHUNK))
sigma = (hi - mu) / 3.0
rng = random.Random(CHUNK_SEED)
sizes, offsets = [], []
while len(sizes) < NCHUNKS:
x = rng.gauss(mu, sigma)
if lo <= x <= hi:
sizes.append(int(2 ** x))
offsets.append(rng.randrange(1 << 24))
return sizes, offsets
def get_chunk_frames(level):
"""The 1000 compressed chunks (cached on disk, one file per level)."""
path = os.path.join(CACHE_DIR, f"zstd_bench_chunks_l{level}.bin")
if not os.path.exists(path):
pool = get_blob() * 2
sizes, offsets = chunk_sizes()
buf = []
for size, off in zip(sizes, offsets):
frame = zstd.compress(pool[off:off + size], level)
buf.append(struct.pack("<I", len(frame)))
buf.append(frame)
_write_atomic(path, b"".join(buf))
frames = []
with open(path, "rb") as fp:
while header := fp.read(4):
frames.append(fp.read(struct.unpack("<I", header)[0]))
return frames
def decompress_all(frames):
for frame in frames:
out = zstd.decompress(frame)
del out
def human_time(seconds):
for unit, scale in (("s", 1), ("ms", 1e-3), ("us", 1e-6), ("ns", 1e-9)):
if seconds >= scale or unit == "ns":
return f"{seconds / scale:.3g} {unit}"
def compare(path_a, path_b):
"""Print a human-readable mean +- stdev comparison of two pyperf runs."""
import pyperf
def load(path):
suite = pyperf.BenchmarkSuite.load(path)
return {bench.get_name(): bench for bench in suite}
a, b = load(path_a), load(path_b)
names = [n for n in a if n in b]
if not names:
sys.exit(f"no benchmark names in common between {path_a} and {path_b}")
label_a, label_b = os.path.basename(path_a), os.path.basename(path_b)
width = max(len(n) for n in names)
col = max(len(label_a), len(label_b), 22)
print(f"{'benchmark':<{width}} {label_a:>{col}} {label_b:>{col}} "
f"{'speedup':>9}")
print("-" * (width + 2 * col + 17))
for name in names:
mean_a, sd_a = a[name].mean(), a[name].stdev()
mean_b, sd_b = b[name].mean(), b[name].stdev()
cell_a = f"{human_time(mean_a)} +- {human_time(sd_a)}"
cell_b = f"{human_time(mean_b)} +- {human_time(sd_b)}"
speedup = (mean_a - mean_b) / mean_a * 100.0
# flag differences smaller than the combined spread as inconclusive
flag = " ~" if abs(mean_a - mean_b) < (sd_a + sd_b) else ""
print(f"{name:<{width}} {cell_a:>{col}} {cell_b:>{col}} "
f"{speedup:+8.1f}%{flag}")
print("\n'~' marks differences smaller than the combined standard "
"deviations.\nFor pyperf's significance test, use: "
f"python -m pyperf compare_to {path_a} {path_b} --table")
def add_cmdline_args(cmd, args):
# pyperf spawns worker processes; custom arguments must be forwarded
# explicitly, otherwise the workers silently use the defaults.
cmd.extend(("--level", str(args.level)))
cmd.extend(("--sizes", args.sizes))
if args.skip_chunks:
cmd.append("--skip-chunks")
def main():
if "--compare" in sys.argv:
pos = sys.argv.index("--compare")
try:
return compare(sys.argv[pos + 1], sys.argv[pos + 2])
except IndexError:
sys.exit("--compare needs two pyperf JSON files")
import pyperf
runner = pyperf.Runner(add_cmdline_args=add_cmdline_args)
runner.argparser.add_argument(
"--level", type=int, default=zstd.COMPRESSION_LEVEL_DEFAULT,
help="zstd compression level used to build the corpus "
f"(default: {zstd.COMPRESSION_LEVEL_DEFAULT}, the "
"compression.zstd default)")
runner.argparser.add_argument(
"--sizes", default=DEFAULT_SIZES,
help=f"comma-separated decompressed sizes (default: {DEFAULT_SIZES})")
runner.argparser.add_argument(
"--skip-chunks", action="store_true",
help="skip the 1000-chunk mixed-size workload")
runner.argparser.add_argument(
"--compare", nargs=2, metavar=("A.json", "B.json"),
help="print a mean +- stdev comparison of two result files and exit")
args = runner.parse_args()
runner.metadata["zstd_version"] = zstd.zstd_version
runner.metadata["zstd_level"] = str(args.level)
for label in args.sizes.split(","):
label = label.strip()
if label not in SIZES:
sys.exit(f"unknown size {label!r}, pick from {', '.join(SIZES)}")
frame = get_frame(label, args.level)
runner.bench_func(f"decompress {label} (level {args.level})",
zstd.decompress, frame)
if not args.skip_chunks:
frames = get_chunk_frames(args.level)
runner.bench_func(
f"decompress {len(frames)} chunks (level {args.level})",
decompress_all, frames)
if __name__ == "__main__":
main()
Run it once per interpreter, then compare:
$ ./python bench_zstd_decompress.py --level 3 -o main.json
$ ../patched/python bench_zstd_decompress.py --level 3 -o patched.json
$ ./python bench_zstd_decompress.py --compare main.json patched.json
python -m pyperf compare_to main.json patched.json --table works too.
Prior art
python-zstandard's decompress() allocates the output buffer from
the frame's recorded content size, and pyzstd (from which this module
descends) did the same before the stdlib port.
Real-world motivation
Chunk-oriented workloads such as borgbackup decompress millions of
~64 KiB–8 MiB frames on restore; the output-buffer overhead is ~6%
of total extraction CPU there.
Linked PRs
Feature or enhancement (performance)
Proposal
compression.zstddecompression always builds its output through the growingoutput buffer (initial 32 KiB, then progressively larger growth steps, finished
with a resize to the actual size), even though most zstd frames record the
exact decompressed size in the frame header: the one-shot compression APIs
(
zstd.compress(),ZSTD_compress2()) write it by default.Dealing with the growing buffer causes unnecessary work
For frames that record the size,
ZstdDecompressor(and therebyzstd.decompress()) can read it viaZSTD_getFrameContentSize()andallocate the output buffer exactly once.
The exactly-filled buffer is then returned without any resize or copy.
The patched code is safe
This is only a sizing hint: decompression does not rely on it, so a
hand-crafted header recording a wrong size gives exactly the same
results/exceptions as today (the buffer grows further if needed, or is
shrunk to the actual size on finish).
Two guards keep hand-crafted headers from requesting large spurious
allocations:
outputs just keep using the growing buffer;
input, so recorded sizes claiming more than a 32768x expansion of the
available input are ignored as implausible.
The patched code is usually faster
Benchmark setup
Measured with pyperf (mean +- standard
deviation over many worker processes). The corpus is deterministic and cached
on disk, so every worker process decompresses byte-identical input: a 16 MiB
blob of random 16-byte words drawn from a 200-word dictionary, sliced or
repeated to the requested size. The full script is at the bottom of this post.
Both builds are plain
./configure -q(no PGO, no LTO,OPT = -DNDEBUG -g -O3 -Wall), differing only in the patch. Note that essentially all of the time isspent inside libzstd, a shared library that CPython's own build options do not
affect.
compression.zstd's default compression level is 3, so the tables below uselevel 3; levels 1 and 10 were measured too and change little.
Constant decompressed size benchmarks
Linux/x86-64, level 3:
macOS/arm64, level 3:
(*) marks differences smaller than the combined standard deviations.
Speedup by compression level, Linux/x86-64 — the level makes little
difference:
Varying decompressed size benchmarks
A workload closer to real chunk-oriented use: 1000 frames whose decompressed
sizes are drawn from a normal distribution in log2(size), centred on 2 MiB and
spanning 512 KiB to 8 MiB at +-3 sigma (2269 MiB of plaintext in total). One
iteration decompresses all 1000 frames.
All three macOS rows are within one standard deviation, so that machine cannot
resolve this workload — its standard deviations are 5–10% of the mean, against
0.5–2% on the pinned, idle Linux machine. The Linux rows are consistent at
+11–13%.
Because the sizes vary from call to call, this workload gives the allocator
much less opportunity to reuse a buffer of exactly the right size, so it is the
more conservative measurement of the two.
What the numbers mean
Avoiding the growth-path reallocations and the final shrink is only part of it.
A full-size output buffer also lets libzstd decompress directly into it:
ZSTD_decompressStreamcan skip its internal window buffer, and the copiesthat go with it, when the remaining output buffer is large enough for the whole
frame. The growing buffer never permits that.
Two things stand out in the tables above.
The gains are much larger on glibc than on macOS. The patched build performs
almost identically on both machines (8 MiB: 4.22 ms on Linux, 4.20 ms on macOS)
— it is the unpatched build that is far slower on Linux (8.54 ms vs 5.22 ms).
The reason is glibc's dynamic mmap threshold: the growing buffer's
reallocations produce a succession of differently sized large blocks, and once
those exceed the threshold each one is mmap'd and munmap'd again per call, so
every call re-faults and re-zeroes the whole output.
perfattributes 43–46% ofthe unpatched build's samples to the kernel fault paths (
do_anonymous_page,kernel_init_pages) from 6 MiB upwards, against 0.2–0.3% for the patched build;measured with
getrusage(), the unpatched build takes 1712/4099/8195 minorfaults per call at 6/16/32 MiB where the patched build takes none. The patched
build asks for the same exact size on every call, so its allocation is simply
recycled.
A corollary worth stating explicitly: the unpatched path's cost depends on the
process's malloc history, because anything else that frees a large block
raises that threshold. A process that compresses before it decompresses (as a
self-contained benchmark naturally does) is measurably faster in the unpatched
build than a process that only ever decompresses — which is the real workload
here, since data usually arrives already compressed. At 1 MiB on Linux, level 1,
the unpatched build takes 952 µs when the frame is simply read from a file, but
532 µs in a process that compressed it first, and 513 µs if
MALLOC_MMAP_THRESHOLD_is raised by hand; the patched build takes 485–496 µsin all three cases. Earlier revisions of this post quoted the primed figures,
which understated the difference. The benchmark script below decompresses frames
read from disk.
The unpatched path is not monotonic in output size. On Linux at level 3 it
needs 3.56 ms for 2 MiB but only 2.97 ms for 3 MiB, reproducibly, because 2 MiB
lands just past a capacity step and pays a growth reallocation plus a large
final shrink. The patched build is linear across the same range (1.04, 1.58,
2.11 ms for 2, 3, 4 MiB).
At 32 MiB and above both builds exceed glibc's mmap threshold ceiling
(
DEFAULT_MMAP_THRESHOLD_MAX), so both fault in the whole output on every call;that cost dominates and the remaining difference falls within the noise.
Raising the thresholds so that neither build returns memory to the kernel
(
MALLOC_MMAP_THRESHOLD_/MALLOC_TRIM_THRESHOLD_= 512 MiB) removes thateffect and leaves the allocator-independent part of the win: at 32 MiB,
19.46 ms vs 15.52 ms (+20%).
Benchmark script
Run it once per interpreter, then compare:
python -m pyperf compare_to main.json patched.json --tableworks too.Prior art
python-zstandard'sdecompress()allocates the output buffer fromthe frame's recorded content size, and pyzstd (from which this module
descends) did the same before the stdlib port.
Real-world motivation
Chunk-oriented workloads such as borgbackup decompress millions of
~64 KiB–8 MiB frames on restore; the output-buffer overhead is ~6%
of total extraction CPU there.
Linked PRs