diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b67fd7cc..007faf2f 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 ``python -m isal.igzip`` replacing the input file with empty output and exiting with status 0 when the output file is the input file, as in ``-f -o data data``. It exits with an error now. 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("