Repository navigation
Conversation
readall() decompressed into chunks of four times the input buffer size and joined them at the end. When the output didn't fit in the first chunk, the join copied all of the output into new memory, so readall() needed about twice the output in memory. Every file opened with igzip.open() got a first chunk of 2 MiB. When the amount of compressed data left is known, for bytes-like input, regular files, and streams that fit in the input buffer, readall() now decompresses into one buffer. When the buffer is full, it grows to the size that the compression ratio so far predicts, plus an eighth, by at least a quarter and to at most eight times its size. Longer streams of unknown length, such as pipes, still use chunks, because on Windows, doubling one buffer for regular files took up to 1.3 times as long as joining chunks. The first buffer is four times the compressed bytes in the input buffer, and at least 16 KiB. readall() first fills an empty input buffer outside a deflate block, so a small file is read to the end and its size is known without fstat(). It doesn't refill the buffer inside a deflate block, because the end of a member needs the bytes just before current_pos.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
igzip.decompress()andIGzipFile.read()with no size, whichjson.load(igzip.open(path))calls, both end up in_GzipReader.readall(). Ondevelop, that method decompresses into chunks of four times the input buffer size and joins them withbytes.join(). When the output doesn't fit in the first chunk, the join copies all of it, soreadall()needs about twice the output in memory. For files, the chunks are 2 MiB, even for a small file.With this PR,
readall()decompresses into onebytesobject when it knows how much compressed data is left. That's the case for bytes-like input, for streams that it has read to the end, and for regular files, wherefstat()andtell()give the amount. When the buffer is full,_PyBytes_Resize()grows it to the size that the compression ratio so far predicts, plus an eighth. Each resize grows the buffer by at least a quarter and to at most eight times its size. The estimate only sizes the allocation, andreadall()doesn't read the size field of the gzip trailer. A stream of unknown size that doesn't fit in the input buffer, such as a pipe, still joins chunks. In my Windows tests with regular files, doubling one buffer took up to 1.3 times as long as joining chunks.The first buffer is four times the compressed bytes in the input buffer, and at least 16 KiB. Before choosing that size,
readall()fills an empty input buffer, so a file that fits in the input buffer is read to the end without a call tofstat(). In my mingw-w64 build, checkingfileno(),fstat(), andtell()added 20 to 35 µs to_GzipReader(f, 512 * 1024).readall()of a 64 KiB file.readall()fills the input buffer only outside a deflate block: at the end of a member,GzipReader_read_into_buffer()movescurrent_posback over the bytes that are still in the bit buffer of ISA-L, so those bytes must stay in the input buffer. Without this check,test_readall_after_read_inside_deflate_blockfails with "CRC check failed".read()with a size,readinto(),readline(), and iteration don't usereadall(). On that path, the only change is a counter of compressed bytes read.readall()now holds the object's lock for the whole call, wheredevelopreleased it between chunks.Results
I measured seven kinds of data (CPython's
Lib/**/*.pyfiles, three kinds of JSON event lines, FASTQ, CSV, and a web server log), compressed withigziplevel 2,gzip -6, and zlib level 9 at eight sizes from 64 KiB to 128 MiB. That's 168 cases each forigzip.decompress()andigzip.open(path).read(), with ratios from 2.9 to 35 to 1. On each machine, I built both versions and timed them in thread CPU time, in fresh processes in a new random order for each case, in 9 rounds locally and 11 rounds on GitHub runners. Timingdevelopagainst itself the same way gives a noise floor of 2 to 6%.The table shows how many times faster the first call in a process is with this PR: the median over the cases in each row, and the range.
decompress(), ratio above 4.2 to 1, 1 to 128 MiBdecompress(), ratio below 4 to 1, 1 to 128 MiBdecompress(), 64 KiBread(), 64 KiBread(), 1 to 2 MiBread(), 3 to 32 MiBread(), 128 MiBThe Linux container is Docker on WSL2 on an AMD Ryzen 7 9800X3D (CPython 3.12.15, one pinned CPU), where a page fault costs about five times as much as on the Ubuntu runner. The runners use CPython 3.12, and the Windows runner uses an MSVC build; a mingw-w64 build on the Ryzen gives row medians within 0.07 of it.
igzip.decompress()changes little below 4 to 1 because, for bytes input, the output then fits in the first chunk ofdevelop, which is four times the compressed size.In warm calls, which repeat the call in the same process,
read()of a 64 KiB file is 1.6 times faster on the Ubuntu runner and 2.0 times faster in the Linux container, because the 2 MiB chunk ofdeveloptakes 18 page faults per call there. In first calls,json.load(igzip.open(path))is 1.01 to 1.04 times faster (geometric mean per platform), because parsing takes most of the time.read(131072),readline(), and line iteration don't change.With 32 MiB of output,
read()needs 1.14 times the output in memory instead of 2.02 times, as traced bytracemalloc, and on every platform its peak RSS grows by about the output size instead of twice that. When later data compresses much worse than earlier data, the buffer overshoots, up to 3.9 times the output in my tests, butreadall()touches only the part it writes, so the peak RSS doesn't grow more. On Windows, the rest still counts toward the commit charge, which I didn't measure.Slower cases
read()of a file with 1 to 2 MiB of output takes 1.05 to 1.4 times as long in 35 of 42 first calls. Those are exactly the cases that need a resize, which copies the output on Windows, whiledevelopwrites these outputs into its 2 MiB first chunk. With 4 MiB of text, which needs two resizes,read()takes 1.07 times as long.read()with 128 MiB of output is no faster, and its peak RSS grows by twice the output, as ondevelop. With the limit of eight times per resize, the buffer grows from 2 to 16 to 128 MiB and then, because the output fills it exactly, to 160 MiB, and that last resize copies 128 MiB. From the code, larger outputs need the same third resize. I didn't measure them.readall()resizes 8 to 17 times. On Linux, whererealloc()moves large blocks withmremap(), it's still 1.2 to 2.1 times faster thandevelop. On Windows, where each resize copies, it takes 1.8 to 2.3 times as long.igzip.decompress()on 64 KiB of log data compressed withgzip -6take 1.6 to 2.1 times as long, with 16 page faults per call wheredeveloptakes none. First calls are 1.2 times faster.read()calls with 128 MiB take 1.2 to 1.3 times as long. Keeping the chunks on PyPy with#ifdef PYPY_VERSIONremoved that regression but also most of the gains, so I left it out.Tests
The new file
tests/test_readall.pyhas 50 tests. They cover bytes and files in five sizes, a stream withoutfileno(),io.BytesIO, the chunk path through a 4 KiB input buffer, members that compress very differently, 300 members of random sizes,readall()afterread()calls of many sizes and inside a deflate block, files whosetell()fails or points past the end, and peak memory. Ondevelop, only the fourtest_peak_memorytests fail.The full test suite passes with CPython 3.12.15 on Linux, with CPython 3.14.8 and AddressSanitizer (leak detection off, no reports), with PyPy 3.11 (without the two compliance test files, which need CPython's
testpackage), on Windows with mingw-w64 and MSVC builds, on macOS arm64, and on free-threaded CPython 3.14t. On 3.14t, a stress test with threads that callread()andreadall()on one shared reader found no errors. With this PR, noreadall()result had another thread's read in the middle of it; ondevelop, 298 did.flake8andmypypass as intox.ini, and GCC with-Wall -Wextragives the same warnings as ondevelop. This PR merges cleanly with each of #287, #288, and #289.Checklist