Skip to content

Pre-allocate the output buffer in compression.zstd decompression from the frame's recorded content size #155460

Description

@ThomasWaldmann

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    extension-modulesC modules in the Modules dirperformancePerformance or resource usagetype-featureA feature request or enhancement

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions