Skip to content

gh-160310: Decompress a single stream directly in _decompress_all() - #161737

Open
maikschulze wants to merge 1 commit into
python:mainfrom
maikschulze:decompress-all-fastpath
Open

maikschulze wants to merge 1 commit into
python:mainfrom
maikschulze:decompress-all-fastpath

Conversation

@maikschulze

Copy link
Copy Markdown
Contributor

Follow-up to gh-160310 (#160311): with that fix, one-shot bz2, lzma and zstd decompress() of a single stream also goes through the memoryviews and lists that keep the many-stream case linear. This adds a fast path for the single stream: _decompress_all() decompresses the data with the first decompressor directly and returns its result if that was the only stream. Only data with more streams takes the loop, starting after the first one, so the many-stream case stays linear, and small inputs get most of their previous speed back (decompress() throughput, MB/s):

MB/s zstd, 1 KiB zstd, 4 KiB lzma, 1 KiB bz2, 1 KiB gzip, 1 KiB (control) gzip, 4 KiB (control)
macOS before 621 1,384 232 168 520 1,511
main 497 1,223 208 158 520 1,500
this PR 585 1,356 229 168 512 1,500
Linux before 692 1,540 249 172 459 1,122
main 539 1,365 223 163 451 1,104
this PR 661 1,557 240 170 449 1,107
Windows before 515 1,256 63 57 389 1,205
main 385 1,040 59 55 381 1,180
this PR 485 1,215 62 57 379 1,177

gzip has its own loop, which gh-160310 changed too and this PR leaves alone: main and this PR run the same gzip code, so their difference (up to 2%) is the noise of the measurement. bz2 and lzma gain less, as their decoders take most of the call; for larger inputs, the difference is lost in the noise.

macOS 26.7.1 on an Apple M4 Pro, Linux arm64 in Docker on the same machine (Debian trixie, GCC 14), Windows 11 on a Ryzen 9 7950X (Visual Studio 17.11); PGO builds of main, with the script below, the best of 7 alternating runs per state. Each platform uses one build for all three states: main runs with the build's own standard library; for the other two, PYTHONPATH points to a copy of gzip.py, bz2.py, lzma.py and the compression package as of e80962a's parent (c3fe3c3b85, "before" in the tables) or as of this PR. On Windows, where the bz2 and lzma decompressors alone ran at different speeds from one process to the next, the three states take turns in one process instead, with the second script below.

Where the time goes

The decompressor's share of each decompress() call:

Share zstd, 1 KiB zstd, 4 KiB lzma, 1 KiB bz2, 1 KiB gzip, 1 KiB (control) gzip, 4 KiB (control)
macOS before 93% 95% 96% 98% 70% 72%
main 75% 84% 87% 93% 71% 74%
this PR 89% 94% 95% 99% 70% 74%
Linux before 93% 95% 97% 99% 75% 84%
main 71% 81% 87% 94% 74% 82%
this PR 87% 92% 94% 97% 73% 82%
Windows before 89% 94% 99% 99% 68% 74%
main 67% 78% 93% 96% 66% 73%
this PR 85% 91% 97% 99% 66% 73%

The decompressor alone is created and used once per call, as decompress() does; gzip's is zlib.decompressobj(wbits=31). The rest of the call is Python around it. What zstd and lzma don't get back comes from passing their keyword arguments through _decompress_all(**decomp_args); bz2 passes none and is back where it was.

With many streams, the fast path hands over to the same loop as main after the first stream, so gh-160310's own reproducer is as fast as on main:

gh-160310's reproducer
Seconds per call Members before main this PR
macOS gzip, 10,000 0.0506 0.0077 0.0075
gzip, 20,000 0.1676 0.0148 0.0149
gzip, 40,000 0.6245 0.0303 0.0300
bz2, 10,000 0.0186 0.0053 0.0056
bz2, 20,000 0.0506 0.0108 0.0112
bz2, 40,000 0.1639 0.0218 0.0212
lzma, 10,000 0.0309 0.0080 0.0083
lzma, 20,000 0.1014 0.0166 0.0168
lzma, 40,000 0.3456 0.0331 0.0335
zstd, 10,000 0.0196 0.0055 0.0053
zstd, 20,000 0.0664 0.0107 0.0107
zstd, 40,000 0.2050 0.0214 0.0208
Linux gzip, 10,000 0.0685 0.0083 0.0074
gzip, 20,000 0.2307 0.0165 0.0150
gzip, 40,000 0.8446 0.0321 0.0309
bz2, 10,000 0.0211 0.0045 0.0043
bz2, 20,000 0.0564 0.0090 0.0087
bz2, 40,000 0.2072 0.0176 0.0178
lzma, 10,000 0.0321 0.0081 0.0081
lzma, 20,000 0.1136 0.0160 0.0162
lzma, 40,000 0.4287 0.0317 0.0322
zstd, 10,000 0.0197 0.0048 0.0048
zstd, 20,000 0.0731 0.0093 0.0096
zstd, 40,000 0.2582 0.0196 0.0195
Windows gzip, 10,000 0.0840 0.0154 0.0156
gzip, 20,000 0.2621 0.0308 0.0305
gzip, 40,000 1.2770 0.0608 0.0613
bz2, 10,000 0.1137 0.1229 0.1156
bz2, 20,000 0.2561 0.2471 0.2341
bz2, 40,000 0.6119 0.4954 0.4667
lzma, 10,000 0.0334 0.0148 0.0147
lzma, 20,000 0.1207 0.0299 0.0294
lzma, 40,000 1.7080 0.0597 0.0588
zstd, 10,000 0.0201 0.0111 0.0110
zstd, 20,000 0.0641 0.0223 0.0223
zstd, 40,000 0.2466 0.0446 0.0439

Results and exceptions are unchanged, except for two invalid inputs, which raise what the released versions raise again: BufferError instead of TypeError for a non-contiguous memoryview, and for a str, TypeError without "memoryview: " in its message (#160311 mentions this as a side effect). The compression tests pass on macOS, Linux and Windows.

Since gh-160310 is in the 3.14 and 3.15 branches, this might fit the same backports.

cc @gpshead

Script
"""Per-call time of one-shot decompress() for small inputs, against the module's decompressor
alone (created and used once, as decompress() does): the difference is what decompress() adds
in Python. Also prints the decompressor's share of the call and the throughput of decompress().

    python repro_overhead.py
"""
import bz2, gzip, lzma, sys, timeit, zlib
from compression import zstd

text = b"".join(b"%d: some text to compress, " % i for i in range(1000))


def per_call(fn):
    n, _ = timeit.Timer(fn).autorange()
    return min(timeit.repeat(fn, number=n, repeat=9)) / n * 1e6


print(f"Python {sys.version.split()[0]}, best of 9, microseconds per call\n")
print("| Input | `decompress()` | decompressor alone | difference | decompressor's share | `decompress()`, MB/s |")
print("|---|---:|---:|---:|---:|---:|")
for name, compress, decompress, decompressor, sizes in (
    # gzip's decompressor alone: zlib with the gzip header and trailer, checked in C
    ("gzip", gzip.compress, gzip.decompress, lambda: zlib.decompressobj(wbits=31), (1024, 4096)),
    ("bz2", bz2.compress, bz2.decompress, bz2.BZ2Decompressor, (1024,)),
    ("lzma", lzma.compress, lzma.decompress, lzma.LZMADecompressor, (1024,)),
    ("zstd", zstd.compress, zstd.decompress, zstd.ZstdDecompressor, (1024, 4096)),
):
    for size in sizes:
        data = compress(text[:size])
        assert decompress(data) == decompressor().decompress(data) == text[:size]
        a = per_call(lambda: decompress(data))
        b = per_call(lambda: decompressor().decompress(data))
        print(f"| {name}, {size // 1024} KiB | {a:.2f} | {b:.2f} | {a - b:+.2f} | {b / a:.0%} | {size / a:,.0f} |")
Second script: all three states in one process (Windows)
"""One-shot decompress() before gh-160310, on main and with the fix, all three in one process:
the per-call time and what it adds over the decompressor alone, for the inputs of
repro_overhead.py. In each pass the states take turns, in rotating order; best of PASSES
passes, each best of 9. One process for all three takes out differences between processes: on
Windows, the bz2 and lzma decompressors alone ran up to 20% slower in some processes than in
others, which variants_overhead.py (one process per state) can't separate from the change.

    python inprocess_overhead.py PARENT_LIB FIX_STREAMS_PY [PASSES [MAIN_LIB]]

Run it without PYTHONPATH, so that the plain imports are main's; if the build's own files
aren't main's, MAIN_LIB holds main's gzip.py, bz2.py, lzma.py and compression/. PARENT_LIB holds the gzip.py,
bz2.py, lzma.py and compression/ of c3fe3c3b85 (the parent of e80962a4bd, gh-160310's fix), as
used for PYTHONPATH with variants_overhead.py; FIX_STREAMS_PY is compression/_common/_streams.py
with the fix. The parent's modules, and copies of main's bz2, lzma and zstd modules that use the
fixed _streams, are loaded under other names. gzip doesn't use _streams._decompress_all(), so
main and main + fix run the same gzip.decompress(). PASSES=0 only checks the setup.
"""
import bz2, gzip, importlib.util, lzma, os, sys, timeit, zlib
import _zstd
from compression import zstd

parent_lib, fix_path = sys.argv[1], sys.argv[2]
passes = int(sys.argv[3]) if len(sys.argv) > 3 else 7
main_lib = sys.argv[4] if len(sys.argv) > 4 else None
assert not os.environ.get("PYTHONPATH"), "run without PYTHONPATH"


def load(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


streams_fix = load("_streams_fix", fix_path)
assert "first" in streams_fix._decompress_all.__code__.co_varnames, f"no fast path in {fix_path}"
if main_lib:
    streams_main = load("_streams_main",
                        os.path.join(main_lib, "compression", "_common", "_streams.py"))
else:
    from compression._common import _streams as streams_main
assert "first" not in streams_main._decompress_all.__code__.co_varnames, \
    "main's _streams already has a fast path: pass main's files as MAIN_LIB"


def with_streams(name, path, streams):
    module = load(name, path)
    module._streams = streams
    return module.decompress


def with_fix(module):
    return with_streams(module.__name__.replace(".", "_") + "_fix", module.__file__, streams_fix)


STATES = {
    "parent": {
        "gzip": load("gzip_parent", os.path.join(parent_lib, "gzip.py")).decompress,
        "bz2": load("bz2_parent", os.path.join(parent_lib, "bz2.py")).decompress,
        "lzma": load("lzma_parent", os.path.join(parent_lib, "lzma.py")).decompress,
        "zstd": load("zstd_parent",
                     os.path.join(parent_lib, "compression", "zstd", "__init__.py")).decompress,
    },
    "main": {"gzip": gzip.decompress, "bz2": bz2.decompress, "lzma": lzma.decompress,
             "zstd": zstd.decompress},
    "main + fix": {"gzip": gzip.decompress, "bz2": with_fix(bz2), "lzma": with_fix(lzma),
                   "zstd": with_fix(zstd)},
}
if main_lib:
    files = {"bz2": os.path.join(main_lib, "bz2.py"), "lzma": os.path.join(main_lib, "lzma.py"),
             "zstd": os.path.join(main_lib, "compression", "zstd", "__init__.py")}
    main_gzip = load("gzip_main", os.path.join(main_lib, "gzip.py")).decompress
    for state, suffix, streams in (("main", "_main", streams_main),
                                   ("main + fix", "_fix", streams_fix)):
        STATES[state] = {"gzip": main_gzip, **{n: with_streams(n + suffix, f, streams)
                                               for n, f in files.items()}}
# Loading compression.zstd registers its parameter enums with _zstd; give main's back.
_zstd.set_parameter_types(zstd.CompressionParameter, zstd.DecompressionParameter)
for name in ("bz2", "lzma", "zstd"):
    assert "_decompress_all" not in STATES["parent"][name].__code__.co_names, name
    assert "_decompress_all" in STATES["main"][name].__code__.co_names, name
    assert STATES["main"][name].__globals__["_streams"] is streams_main, name
    assert STATES["main + fix"][name].__globals__["_streams"] is streams_fix, name
assert STATES["parent"]["gzip"].__code__ is not STATES["main"]["gzip"].__code__

text = b"".join(b"%d: some text to compress, " % i for i in range(1000))
INPUTS = []
for name, compress, decompressor, sizes in (
    # gzip's decompressor alone: zlib with the gzip header and trailer, checked in C
    ("gzip", gzip.compress, lambda: zlib.decompressobj(wbits=31), (1024, 4096)),
    ("bz2", bz2.compress, bz2.BZ2Decompressor, (1024,)),
    ("lzma", lzma.compress, lzma.LZMADecompressor, (1024,)),
    ("zstd", zstd.compress, zstd.ZstdDecompressor, (1024, 4096)),
):
    for size in sizes:
        data = compress(text[:size])
        assert decompressor().decompress(data) == text[:size]
        for state in STATES:
            assert STATES[state][name](data) == text[:size], (name, state)
        INPUTS.append((f"{name}, {size // 1024} KiB", name, data, decompressor))


def per_call(fn):
    n, _ = timeit.Timer(fn).autorange()
    return min(timeit.repeat(fn, number=n, repeat=9)) / n * 1e6


names = list(STATES)
best = {}
for p in range(passes):
    order = names[p % 3:] + names[:p % 3]
    for row, name, data, decompressor in INPUTS:
        for state in order:
            decompress = STATES[state][name]
            a = per_call(lambda: decompress(data))
            b = per_call(lambda: decompressor().decompress(data))
            old = best.get((row, state), (float("inf"), float("inf")))
            best[row, state] = (min(old[0], a), min(old[1], b))
    print(f"pass {p + 1} of {passes} done", file=sys.stderr, flush=True)

print(f"# In one process: Python {sys.version.split()[0]}, {sys.platform}; parent = {parent_lib}, "
      f"fix = {fix_path}")
for title, value in (("decompress(), us per call", lambda a, b: f"{a:.2f}"),
                     ("added over the decompressor alone, us per call", lambda a, b: f"{a - b:+.2f}"),
                     ("the decompressor's share of the call", lambda a, b: f"{b / a:.0%}")):
    print(f"\n{title} (best of {passes} passes in one process, states taking turns, each best of 9)\n")
    print("| Input | " + " | ".join(names) + " |")
    print("|---|" + "---:|" * len(names))
    for row, *_ in INPUTS:
        if (row, names[0]) in best:
            print(f"| {row} | " + " | ".join(value(*best[row, s]) for s in names) + " |")

I used an AI assistant to investigate this and draft the patch; I reviewed and tested the changes myself.

…ll()

Since pythongh-160310, every one-shot bz2, lzma and zstd decompress() call wraps
the data in memoryviews and collects the output in lists, also for the
common case of a single stream, which costs about 0.4 us per call (a
quarter of a zstd call on 1 KiB).  Decompress the data with the first
decompressor directly, and only take the loop for data that holds more
than one stream.

This also restores the exceptions of the released versions for
non-contiguous buffers and non-buffer arguments.
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.

1 participant