Repository navigation
Conversation
Use heap types and public C APIs, retain full-API free-threaded/PyPy builds, and avoid copies for filled bounded output. Include installed-wheel benchmarks, mitigation experiments, ownership checks and shared-wheel CI coverage.
Dev-iL
marked this pull request as draft
November 5, 2026 04:54
The report's control variation, per-operation medians and ranges, the 16 MiB + 137 table and the slowest/fastest cases were produced by uncommitted post-processing. Add --summarize RESULTS to print them from a committed --output file, using the inclusive p95 the report quotes, and print the same summary at the end of a run. The worker code path is unchanged. Reject a --cpu that is not in the process affinity mask up front instead of failing inside every worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- factory_only_new was only installed on PyPy, and PyPy 3.11 already
refuses to call a static type that has Py_TPFLAGS_DISALLOW_INSTANTIATION
("cannot create 'Compress' instances"; checked by building without the
tp_new slot). The non-PyPy slot arrays held PyPy-only entries that never
compile.
- CPython 3.11 and PyPy 3.11 both define Py_TPFLAGS_DISALLOW_INSTANTIATION,
so its fallback definition is unused. Reword the comment about why
Compress and Decompress need the flag.
- RetVal is NULL at every jump to the error labels, so clearing it there
does nothing. output_buffer_finish() always leaves the buffer empty, so
clearing it afterwards does nothing either, and the early returns run
before the buffer is used.
- Drop casts in Py_INCREF() and blank lines the port added.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This predates the stable ABI port. PyObject_HasAttrString() treats every error as a missing attribute, so a MemoryError or an error raised by a read property made _GzipReader() fall back to the buffer protocol and raise "a bytes-like object is required" instead. Look the attribute up and only treat AttributeError as missing. PyObject_HasAttrStringWithError() is not in the 3.11 Limited API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This predates the stable ABI port: when PyList_New() failed, readall() returned without releasing the 128 KiB first chunk. The allocation failure test now also covers _GzipReader.read() and readall(), runs 80 failure points, and checks with tracemalloc that a second pass over all of them keeps no memory. On the previous code the readall() case kept 131293 bytes; every case now keeps under 3 KiB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This predates the stable ABI port. A negative size passed the upper bound check and was converted to a huge uint32_t, so _ParallelCompress(-1, 1) raised MemoryError instead of ValueError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This predates the stable ABI port. Input is passed to ISA-L in chunks of at most UINT32_MAX bytes. - IgzipDecompressor: when the output limit was reached, the -2 break only left the inner loop. The outer loop then gave ISA-L the next chunk in place of the unread rest of the current one, which was lost. - isal_zlib.decompressobj(): save_unconsumed_input() decided whether input was left from zst.avail_in, which only covers the current chunk. When a chunk ran out as the output limit or the end of the stream was reached, the later chunks were not saved to unconsumed_tail or unused_data. One-off check, not a test: a real case needs more than 4 GiB of input plus copies of it. Instead, arrange_input_buffer() was changed in a scratch build to give at most 7 bytes per chunk. Decoding a stream followed by 200 bytes of extra data, with max_length 1, 100 and 4096, lost input in all six cases before this change (eof never reached) and returned the data and unused_data correctly after it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This predates the stable ABI port. PyPy 3.11 defines Py_UNREACHABLE in pymacro.h, so every PyPy build warned that it was redefined. The PyPy build of isal_zlibmodule.c now has no warnings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The step was already named "Set up Python 3.10" while installing 3.11 before the stable ABI port (2498167). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This predates the stable ABI port. IGzipFile.__init__ checks compresslevel before it calls gzip.GzipFile.__init__, so a rejected level left an object without mode and fileobj. gzip.GzipFile.__del__ (present in the checked 3.11.15, 3.12.14, 3.13.12 and 3.14.4) then reported "'IGzipFile' object has no attribute 'mode'" through sys.unraisablehook. Set both attributes first, as gzip.GzipFile.__init__ does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- read(n) that fills its allocation must return it without a second copy. With the equality check in shrink_bytes() removed in a scratch build, the peak was 524839 bytes for a 262144-byte read and the test failed; the limit is 1.5 times the read size. - readall() at and around its 128 KiB chunk size, which picks between returning the first chunk and joining a list of chunks. - The existing filled-buffer test shares the tracemalloc helper. A peak test for readall() cannot tell whether chunks are copied: the final join holds the whole list plus the joined copy, which is more than any extra chunk copy. readall() is covered by the boundary test above and by the allocation failure test instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The five heap-type deallocators repeated the same PyPy-conditional code to free an object and release its type, and three places spelled out Py_SETREF by hand because it is not in the Limited API. Move both into free_instance() and replace_reference() in isal_shared.h, as in python-zlib-ng. replace_reference() keeps the order: store the new value, then release the old one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
read() and readall() kept their own shrink_bytes() copy of the logic in output_buffer_finish(). Both now read through GzipReader_read_bytes(), which lets a bytes object own the output with the new output_buffer_init_bytes() and finishes it with output_buffer_finish(). A filled read is still returned without a copy, a short one is still copied (Limited API) or resized (full API), and readall() still returns the first chunk directly when it is short and joins a list of chunks otherwise. The pinned tests pass on the Limited API, free-threaded 3.13 and PyPy 3.11 builds; with the direct return in output_buffer_finish() removed in a scratch build, the read(n) test peaked at 525068 bytes for a 262144-byte read and failed. Also describe in the OutputBuffer comment when a bytes object owns the output: on Limited API builds only when the first allocation equals a finite limit, and shorter results are still copied. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The port replaced tp_name, which is not in the Limited API, with %R of the type, so the message ended in "not <class 'bytes'>" instead of "not bytes" as before and as in io. Use PyType_GetName(), which the 3.11 Limited API and PyPy 3.11 provide. Clear the buffer error first so the name lookup does not run with an exception set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Invalid input (truncated stream, bad checksum, truncated gzip) must release the output that had grown past 256 KiB, for decompress(), igzip_lib.decompress(), decompressobj(), IgzipDecompressor and _GzipReader read()/readall(). With PyMem_Free() removed from output_buffer_clear() in a scratch build, the four Limited API PyMem cases failed (as did all seven older allocation failure cases); with output_buffer_clear() removed from GzipReader_read_bytes(), the two _GzipReader cases kept 2622319 and 1311234 bytes and failed. - A stream that ends exactly at the first 16 KiB output allocation must not grow it. Without the end-of-stream exit in the decompressobj() and IgzipDecompressor loops (the baseline loop), the peak was 49353 bytes instead of 32969 on the Limited API build, and 32921 instead of 16537 on free-threaded 3.13; both fail the limits. - Deleting instances releases their reference to the heap type (CPython with the GIL only). Without Py_DECREF(type) in free_instance(), all five cases failed. - Exported and factory-made types are named after the module that exports them. Igzip_lib.IgzipDecompressor and IsalError keep their "igzip_lib." names. Naming _ParallelCompress "zlib_ng." in a scratch build failed the isal_zlib case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test-abi3 job audits a wheel built with "python -m build", but the wheels that are uploaded to PyPI come from cibuildwheel in build_wheels. Run abi3audit --strict on each leg's cp311-abi3 wheel there, before it is uploaded as an artifact. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
git diff --check flagged the line that b618b4a added to this comment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rerun the nine-round final-wheel comparison on the repaired candidate, add a repair check of the earlier candidate against this one, record source hashes and the build recipe, and commit the strategy-study data the report quotes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…andidate Lead the report with the result, quote the final-wheel run and the repair check, and count a strategy as faster only beyond its study's control maximum. Name each strategy-study variant, give the evidence for and against it, and correct the PyPy and free-threaded explanations. Name the calls that slow down or avoid the copy in the changelog and README, list the user-visible fixes, and use the "version X.Y.Z-dev" heading the changelog used before. Record the final validation results. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dev-iL
marked this pull request as ready for review
November 5, 2026 09:46
2 tasks done
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.
Regular CPython wheels currently have to be rebuilt and re-released for every new Python version. This PR builds them against the Python 3.11 stable ABI instead (
cp311-abi3), so one wheel serves CPython 3.11 and every later release, as requested in python-isal#240. Free-threaded CPython and PyPy keep their full-API, version-specific wheels.The cost is an extra copy when decompression returns its whole output at once: on the measured machine those calls take up to 23% longer at 16 MiB + 137 bytes of output, where 6 of the 12 cases are beyond the control's variation, and up to 2.160 times as long for 1 MiB + 137 bytes of incompressible raw deflate data. Compression, CRC-32, gzip file reads, and bounded
IgzipDecompressoroutput show no measurable difference apart from three outliers listed below.How output is returned
_PyBytes_Resizeis not in the Limited API (the C API subset that builds against the stable ABI). Output that grows during a call is therefore kept in aPyMembuffer and copied into a bytes object withPyBytes_FromStringAndSizewhen the call finishes, following the design that maintainer rhpvorderman set out in a comment on xopen#182. The benchmark report calls this native output. CPython types become heap types created from specs, and private or layout-dependent APIs are replaced with public ones.These calls avoid the copy, because their output goes straight into a Python-owned object; output shorter than
max_lengthor the requested size is still copied:igzip_lib.IgzipDecompressor().decompress(data, max_length)whenmax_lengthis at most 16 MiB and the output fills it.isal_zlib.decompressobj().decompress(data, max_length)whenmax_lengthis at most 16 KiB and the output fills it.read()andreadinto()onigzip.open()files, which read through_GzipReader.readinto().isal_zlib.decompressobj().decompress(data, max_length)withmax_lengthabove 16 KiB still starts from a 16 KiB allocation, grows it as native output and copies it once, so it holds at least twice the output in memory during the copy, where the original wheel resized one bytes object. The first allocation is deliberately not raised tomax_length, so that a large limit does not allocate memory that a shorter output never uses. The same applies toIgzipDecompressorwithmax_lengthabove 16 MiB. The benchmark below does not time either case.When
IgzipDecompressor().decompress()returns less than amax_lengthof at most 16 MiB, ordecompressobj().decompress()less than amax_lengthof at most 16 KiB, the copy is made while themax_length-sized allocation is still held. Peak memory is then the limit plus the output, where the original wheel shrank the allocation in place and needed only the limit: for 12 MiB of output under a 16 MiB limit, 28 MiB against 16 MiB. The random 1 KiB bounded case below shows the time cost of such a copy for a small output.Final-wheel benchmark
The baseline is commit 2498167, unchanged. The benchmark times installed wheels over nine randomized paired rounds of 128 cases. Each round times the original baseline, the baseline again as a control, and the candidate on identical inputs, with iteration counts calibrated on the baseline and then frozen. Both wheels use CPython 3.11.15, GCC 15.2.0 with
-O3 -DNDEBUG, and ISA-L 2.32.1. The worker runs pinned to CPU 1 of a Linux x86_64 Intel i7-3770 (no AVX2). The inputs are four corpora (the FASTQ fixture, stdlib Python source, seeded random bytes, and zeros) cut to 1 KiB, 64 KiB, 1 MiB + 137, and 16 MiB + 137 bytes. Each ratio is the median of nine paired ratios of candidate CPU time to baseline CPU time; above 1 means slower. The control deviates from 1 by 4.345% at p95 (inclusive percentile) and 9.116% at most, and a case counts as slower or faster only beyond that maximum. No build, test, or other benchmark ran during the timings; desktop processes did.isal_zlib.compress(data, 1)isal_zlib.decompress()igzip_lib.decompress(), raw deflatedecompressobj(), decompress and flushIgzipDecompressor, 64 KiBmax_lengthigzip.open().read(131072)igzip.open().readinto(), 128 KiBisal_zlib.crc32()Worst cases: random 1 MiB + 137 raw decoding at 2.160× (
isal_zlib.decompress()1.504×,decompressobj()1.519× for the same input) and zeros 1 MiB + 137 raw decoding at 1.636×. Outside whole-output decoding, three cases exceed the control: random 1 KiB bounded decoding at 1.151× (plausibly the copy of output shorter than its 64 KiB limit, not profiled), zeros 16 MiB + 137 bounded decoding at 1.122× (not copied; the earlier candidate's run measured 0.995×, so run-to-run variation is the likely cause), and Python 1 KiBreadinto()at 1.115× (unexplained). Three FASTQ cases are faster: 1 MiB + 137 streaming at 0.859× anddecompress()at 0.896×, and 1 KiBdecompress()at 0.890×, whose own control was 0.909. Their cause was not profiled.A repair check timed the earlier candidate (65d3eb3) as baseline and control against this one with the same protocol. Per-operation medians range 0.992-1.006, so the repairs since 65d3eb3 show no measurable effect at the operation level. The control was 4.175% at p95 and 6.836% at most; five individual cases exceed that maximum in both directions, four of them at 16 MiB + 137 bytes.
The benchmark report has every summary, outlier, and limit.
cpython311.jsonkeeps every raw run with input hashes, iteration counts, and extension paths;cpython311.csvhas every case;build-metadata.jsonhas the wheel and source hashes and the build recipe;repair-check.jsonhas the repair check.python benchmark_scripts/benchmark_abi3.py --library isal --summarize <file>prints the summaries. These links resolve once the branch is pushed.Relationship to the maintainer's design
PyMemgrowth for output with a finalPyBytes_FromStringAndSize, and measurement of the cost.cp311-abi3floor cannot cover free-threaded builds, so they keep the full API and bytes resizing. PEP 803 ("abi3t", Python 3.15 and later) is a possible follow-up.Py_TPFLAGS_IMMUTABLETYPEandPy_TPFLAGS_DISALLOW_INSTANTIATION, and PyPy honours the latter on these static types.PyObject_New, which the Limited API provides, paired withPyObject_Free. The design comment did not prescribe this detail.CompressandDecompresstype references live for the process. No per-interpreter module isolation is added.Validation
All runtime checks install wheels built from this branch outside the source tree, on Linux x86_64 only;
validation.txthas the details.-DPy_LIMITED_API=0x030B0000.abi3audit --stricton thecp311-abi3wheel: 0 violations, computed baseline 3.11. Requires-Python is>=3.11inpyproject.tomland the sdist and wheel metadata.cp311-abi3wheel on system CPython 3.14.4 withPYTHONDEVMODE=1: 9,832 passed, 11 skipped (platform-, size-, and build-specific tests), including the stdlib compliance tests.test_stable_abi.pygives 54 passed, 10 skipped on each (no_testcapi). The full suite was not run on these versions.cp313-cp313twheel,PYTHON_GILunset: 9,813 passed, 30 skipped (14 need_testcapi, which this build lacks, including the 9 allocation-failure tests). The GIL stays disabled after importing all three C modules.pp311-pypy311_pp80wheel: 9,797 passed, 46 skipped (tests that need_testcapi,tracemalloc, or reference counts).test_stable_abi.py(63 passed, 1 skipped).twine check, andgit diff --checkpass. actionlint's only finding is the unchangedcodecov/codecov-action@v1line listed under Debt left.Fixed on the way
These problems predate the port; each is fixed in its own commit:
_GzipReader.readall()leaked its first chunk whenPyList_New()failed.IgzipDecompressorandisal_zlib.decompressobj()lost unread input beyond the first 4 GiB of one call when the output limit or the end of the stream was reached._ParallelCompress()raisedMemoryErrorinstead ofValueErrorfor a negative buffer size._GzipReader()turned any error from looking upread, such asMemoryError, into "a bytes-like object is required".IGzipFile()rejected a compression level,gzip.GzipFile.__del__reported an unraisableAttributeError.Py_UNREACHABLEwas redefined.Debt left
abi3t, PEP 803) were not attempted; they would be a follow-up.decompressobj().decompress(data, max_length)withmax_lengthabove 16 KiB holds at least twice the output during the copy, by design (see above).abi3auditstep), any Windows, macOS, or ARM runtime check, AddressSanitizer, UndefinedBehaviorSanitizer, and ThreadSanitizer on this candidate, and shellcheck. No CI has run on this branch yet..github/workflows/ci.yml:106still usescodecov/codecov-action@v1, which actionlint reports as too old. The line predates this PR and is unchanged; python-zlib-ng uses@v7. Upgrading needs the Codecov token or tokenless setting checked in live CI, so it is left to the maintainers.Earlier attempts and considered approaches
Output strategies that were measured, and why native output was kept
These are prototype studies with their own harness and a full-API reference that already includes the end-of-stream stop, so the stop cannot hide the copy cost. Their ratios are not comparable with the final-wheel table: their Python corpus came from repeated package sources, their files record neither the interpreter nor input hashes (the versions below come from the report committed at 65d3eb3), and the bounded study, the free-threaded probe, and the 64 MiB memory test use exact power-of-two sizes. Each ran nine randomized paired rounds. Two strategies differ measurably in a case only when their ratios differ by more than the study's control maximum. The table covers the 24 large decoding cases of the irregular-size study (four corpora; 1 MiB + 137 and 16 MiB + 137 bytes; three decoders); across all 52 cases its control was 5.32% at p95 and 6.32% at most. Ranges are across case medians.
abi3-eof)abi3-chunks)abi3-bytesio)abi3-hybrid)decompress()andigzip_lib.decompress()only, gave 1.210× (0.846-2.002×) against 1.403× (1.177-1.868×): native output was faster beyond the control in all 8 cases at 16 MiB + 137, while at 1 MiB + 137 delayed BytesIO had the lower median in 6 of the 8 (1 beyond the control, against 2 for native output). At exactly 64 MiB, BytesIO grows RSS by about one output size against two for native output, but at 16 MiB + 137 it grows RSS as much as native output, likely because of zero-filled spare capacity (interpretation). BytesIO was rejected for its higher median and worse 16 MiB + 137 results, and because it calls intoio.BytesIOfrom C.abi3-fast, despite the ID): for one call that fills a 16 MiB limit it measured 1.517× (FASTQ), 8.162× (Python), 1.915× (random), and 5.134× (zeros), and 2.369× for random at 1 MiB. Returning the bytes object directly (abi3-bounded) measured 1.000×, 0.971×, 0.989×, 0.994×, and 0.973× for those cases and 0.948-1.052× across all 24. The traced peak for a 16 MiB output fell from 33,641,953 to 16,864,737 bytes, the study's full-API baseline figure. The direct return was adopted.PyType_GenericAllocfor every instance: an early prototype that used it, zeroing the whole instance including the ISA-L state, appeared slower on small calls. This was not measured in a committed study;PyObject_Newwas kept.Data:
mitigation-irregular.json,mitigation-irregular-summary.json,mitigation-315-summary.json,mitigation-memory.json,mitigation-bounded.json,mitigation-bounded-memory.json, andfree-threaded-probe.json. The report's "Output strategy studies" section lists the controls of each study and every variant ID.Checklist
🤖 Generated with Claude Code