From aab432e265080ec6a60fe8254dfa9be1664c9d0a Mon Sep 17 00:00:00 2001 From: Parker Loflin Date: Fri, 6 Nov 2026 17:32:31 -0500 Subject: [PATCH] Start one-shot gzip decompression with room for the whole output isal_zlib.decompress and igzip_lib.decompress started with a 16 KiB output buffer and doubled it until the output fit. Peak memory was up to twice the output, and on glibc the growing buffer could page-fault on every page of the output on every call: 2562 faults per call for 10 MiB. A gzip member ends with ISIZE, its size modulo 2**32, so for gzip data the buffer now starts at ISIZE + 1 bytes. The extra byte keeps the loop from doubling a buffer that the output fills exactly. ISIZE is only a hint. With trailing data or more members, or in a corrupt stream, it can be anything, so it is ignored when deflate could not expand the input that much (1032 times at most) and from 32 MiB up. A wrong ISIZE then wastes at most 32 MiB until the call returns, which matters because the debug allocator of PYTHONDEVMODE writes to all of it. Above 32 MiB glibc maps every buffer afresh anyway, so larger outputs gained nothing from the hint. Linux in Docker, Python 3.11 and 3.14, decompressing text with wbits=31, one size per process: 10, 20 and 25 MB are 1.5 times faster (10 MiB: 13.9 -> 9.1 ms on 3.11) with no page faults left, and 25 MB of zeros is 3 times faster. In a process that made other allocations first, 10 MiB took the same time in both builds, while 25 MB was still 1.6 times faster. Up to 4 MiB the time is unchanged. Peak memory falls by up to half: 1 MiB of output peaks at 1 MiB instead of 2 MiB, and 10 MiB at 10 MiB instead of 16 MiB. The output is unchanged. When a small member is followed by a larger one, wbits=31 still returns only the first member but now allocates the size of the last one until it returns: 1 KB followed by 4.7 MB takes about 20 us instead of 5 us. igzip.decompress reads through _GzipReader and is not affected. --- CHANGELOG.rst | 5 +++ src/isal/isal_shared.h | 18 ++++++++++ tests/test_compat.py | 79 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 481b7c5f..961fc649 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,11 @@ Changelog version 1.10.0-dev ------------------ ++ Speed up ``isal_zlib.decompress`` and ``igzip_lib.decompress`` for gzip data + by starting with an output buffer of the size stored in the gzip trailer, up + to 32 MiB, instead of a 16 KiB buffer that doubles as it fills. Peak memory + use drops by up to half, and on Linux outputs of 10 to 32 MiB decompress up + to 1.5 times faster. + Fix the gzip header that ``igzip_threaded.open`` writes when ``threads`` is not 0. It had the XFL and OS bytes swapped, so the ``file`` command reported the output as coming from VM/CMS or a FAT file system. diff --git a/src/isal/isal_shared.h b/src/isal/isal_shared.h index f4f4519b..0d44d5dc 100644 --- a/src/isal/isal_shared.h +++ b/src/isal/isal_shared.h @@ -38,6 +38,8 @@ static PyObject *IsalError; /* Initial buffer size. */ #define DEF_BUF_SIZE (16*1024) #define DEF_MAX_INITIAL_BUF_SIZE (16 * 1024 * 1024) +/* Largest gzip ISIZE used to size one-shot output. */ +#define DEF_MAX_ISIZE_HINT (32 * 1024 * 1024) #define ISAL_BEST_SPEED ISAL_DEF_MIN_LEVEL #define ISAL_BEST_COMPRESSION ISAL_DEF_MAX_LEVEL #define ISAL_DEFAULT_COMPRESSION 2 @@ -408,6 +410,22 @@ igzip_lib_decompress_impl(Py_buffer *data, int flag, } else if (bufsize == 0) { bufsize = 1; } + /* Size the output from ISIZE in the gzip trailer, plus one byte so that a + buffer the output fills exactly is not grown. ISIZE can be wrong + (trailing data, more members, corruption), so it is capped by + deflate's 1032:1 ratio and DEF_MAX_ISIZE_HINT. */ + if ((flag == ISAL_GZIP || flag == ISAL_GZIP_NO_HDR_VER) && + data->len >= 8) { + const uint8_t *trailer = (const uint8_t *)data->buf + data->len - 4; + uint32_t isize = (uint32_t)trailer[0] | + ((uint32_t)trailer[1] << 8) | + ((uint32_t)trailer[2] << 16) | + ((uint32_t)trailer[3] << 24); + if (isize < DEF_MAX_ISIZE_HINT && (Py_ssize_t)isize >= bufsize && + isize / 1032 <= (size_t)data->len) { + bufsize = (Py_ssize_t)isize + 1; + } + } ibuf = (uint8_t *)data->buf; ibuflen = data->len; diff --git a/tests/test_compat.py b/tests/test_compat.py index 3f339851..2812ccb3 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -12,6 +12,7 @@ import gzip import itertools import random +import struct import subprocess import sys import textwrap @@ -24,6 +25,11 @@ import pytest +try: + import tracemalloc +except ImportError: # PyPy + tracemalloc = None # type: ignore + DATA_DIR = Path(__file__).parent / "data" COMPRESSED_FILE = DATA_DIR / "test.fastq.gz" with gzip.open(str(COMPRESSED_FILE), mode="rb") as file_h: @@ -139,6 +145,79 @@ def test_decompress_bufsize(module): module.decompress(compressed, bufsize=-1) +GZIP_DECOMPRESS = [ + pytest.param(isal_zlib, {"wbits": 31}, id="isal_zlib-wbits31"), + pytest.param(isal_zlib, {"wbits": 47}, id="isal_zlib-wbits47"), + pytest.param(igzip_lib, {"flag": igzip_lib.DECOMP_GZIP}, id="igzip_lib"), +] + + +@pytest.mark.parametrize(["module", "kwargs"], GZIP_DECOMPRESS) +@pytest.mark.parametrize("data_size", [0, 1, 16 * 1024 - 1, 16 * 1024, + 16 * 1024 + 1, 1024 * 1024]) +def test_decompress_gzip_bufsize(module, kwargs, data_size): + data = DATA[:data_size] + compressed = gzip.compress(data) + assert module.decompress(compressed, **kwargs) == data + for bufsize in [0, 1, max(data_size - 1, 0), data_size, data_size + 1, + 2 * 1024 * 1024]: + assert module.decompress(compressed, **kwargs, + bufsize=bufsize) == data + + +@pytest.mark.parametrize(["module", "kwargs"], GZIP_DECOMPRESS) +@pytest.mark.parametrize("trailing_data", [ + bytes(4), b"\xff\xff\xff\x01", b"\xff\xff\xff\xff", + gzip.compress(b"another member"), gzip.compress(bytes(1024 * 1024))], + ids=["zeros", "almost-32-MiB", "4-GiB", "small-member", "large-member"]) +def test_decompress_gzip_trailing_data(module, kwargs, trailing_data): + data = DATA[:100_000] + compressed = gzip.compress(data) + trailing_data + assert zlib.decompress(compressed, wbits=31) == data + assert module.decompress(compressed, **kwargs) == data + + +@pytest.mark.parametrize(["module", "kwargs"], GZIP_DECOMPRESS) +@pytest.mark.parametrize("isize", [0, 99_999, 100_001, 2**24, 2**25 - 1, + 2**32 - 1]) +def test_decompress_gzip_wrong_isize(module, kwargs, isize): + compressed = gzip.compress(DATA[:100_000]) + trailer = struct.pack("