Skip to content

Build regular CPython wheels against the Python 3.11 stable ABI - #96

Open
Dev-iL wants to merge 33 commits into
pycompression:developfrom
Dev-iL:2611/stable-abi
Open

Dev-iL wants to merge 33 commits into
pycompression:developfrom
Dev-iL:2611/stable-abi

Conversation

@Dev-iL

@Dev-iL Dev-iL commented Nov 4, 2026 •

Copy link
Copy Markdown

Every new CPython release currently needs a new set of version-specific wheels. This change builds regular CPython wheels once, as cp311-abi3 against the Python 3.11 stable ABI, as requested in #95 and prescribed in xopen#182 and python-isal#240. Free-threaded CPython and PyPy keep version-specific full-API wheels.

Result: compression, CRC32, bounded decompression, and gzip reads run at baseline speed. Unbounded decompression is slower, because the Limited API has no _PyBytes_Resize() and the result must be copied once from a native buffer into its bytes object. At 16 MiB + 137 bytes of output those calls take up to 19% longer, where 6 of the 12 cases are beyond the control's variation, and the worst case, raw deflate decompression of 1 MiB + 137 bytes of zeros, takes 2.744 times as long. Those calls also briefly hold one extra output-sized allocation.

Benchmark

The baseline is commit 39cec01, unchanged. The benchmark times installed wheels of the baseline and this candidate, both built with CPython 3.11.15, GCC 15.2.0, and the same static zlib-ng 2.3.3 archive. Each of nine randomized rounds times the baseline, a second baseline process as a control, and the candidate on identical inputs, measuring thread CPU time on CPU 1 of a Linux x86_64 Intel Core i7-3770 without AVX2. The 128 cases combine four inputs (FASTQ, standard-library Python source, seeded random bytes, and zeros), four output sizes (1 KiB, 64 KiB, 1 MiB + 137 bytes, and 16 MiB + 137 bytes), and eight operations. A case's ratio is the median over the rounds of candidate time divided by baseline time, and a ratio above 1 is slower. The control deviates from 1 by 3.830% at the 95th percentile (inclusive method) and by 9.626% at most, and a case counts as slower or faster only beyond that maximum. Desktop processes were running during timing; no build, test, or other benchmark was.

Operation Call timed Median ratio (16 cases) Range of case ratios
Level-1 compression compress(data, 1) 1.000× 0.948-1.050×
zlib decompression decompress(data) 1.085× 0.915-2.270×
Raw deflate decompression decompress(data, wbits=-15) 1.049× 0.936-2.744×
Streaming decompression decompressobj().decompress(data) and flush() 1.071× 0.925-2.246×
Bounded decompression _ZlibDecompressor(wbits=-15).decompress(data, 65536) 1.009× 0.991-1.094×
gzip read gzip_ng.open(...).read(131072) 1.004× 0.979-1.039×
gzip readinto readinto() into a 131072-byte bytearray 0.999× 0.966-1.053×
CRC32 crc32(data) 1.004× 0.983-1.047×

No case of compression, bounded decompression, the gzip reads, or CRC32 differs from the baseline by more than the control maximum. The benchmark report has the method, per-case results, raw samples, build hashes, and the reproduction command. Running python benchmark_scripts/benchmark_abi3.py --summarize benchmark_scripts/abi3_results/results.json --library zlib_ng regenerates the numbers above. Links to files in this branch resolve once it is pushed.

Worst-case regressions

Of the 48 unbounded decoder cases, 19 are slower beyond the control maximum:

Input and output size zlib Raw deflate Streaming
Zeros, 1 MiB + 137 bytes 2.270× 2.744× 2.246×
Random, 1 MiB + 137 bytes 1.512× 1.787× 1.530×
Random, 64 KiB 1.214× 1.252× 1.172×
Zeros, 16 MiB + 137 bytes 1.152× 1.190× 1.162×
Random, 16 MiB + 137 bytes 1.151× 1.157× 1.144×
Python source, 1 MiB + 137 bytes 1.135× 1.133× 1.124×
Zeros, 1 KiB 1.135× inside the control inside the control

The zeros 1 KiB zlib case had a control ratio of its own of 0.943, so part of its distance may be noise. No case is faster beyond the control maximum; the nearest, FASTQ at 1 MiB + 137 bytes through the three unbounded decoders at 0.915-0.936 times the baseline time, were not profiled.

Memory and the copy

On stable ABI builds, compress(), decompress(), and compressobj() output, and decompressobj().decompress() and _ZlibDecompressor.decompress() without max_length, grow their output in a buffer managed with PyMem_Malloc() and PyMem_Realloc() and copy it into the result. Both exist while the copy is made, so peak memory rises by one output-sized allocation over the baseline's in-place resize. This follows from the code (finish_output()); it was not benchmarked.

Calls that allocate their whole limit at once return a filled bytes object directly: _ZlibDecompressor.decompress(data, max_length) with max_length up to 16 MiB, the gzip_ng file reader behind read(size) and readinto(), and decompressobj().decompress(data, max_length) with max_length up to 16 KiB. test_filled_bounded_decompressor_output_is_not_copied and test_filled_gzip_reader_chunk_is_not_copied check this with tracemalloc.

These bounded calls still copy in three situations:

  • Output shorter than the allocation, such as a _ZlibDecompressor.decompress(data, max_length) call that returns less than max_length, is copied while the full allocation is still held. Peak memory is then the limit plus the output, where the original code shrank the allocation in place and needed only the limit.
  • _ZlibDecompressor.decompress(data, max_length) with max_length above 16 MiB starts from a 16 MiB buffer, as the original code did, grows it natively, and copies the result.
  • decompressobj().decompress(data, max_length) with max_length above 16 KiB starts from a 16 KiB buffer, grows it natively, and copies, so a call that fills its limit briefly holds about twice the limit.

The initial allocations are deliberately not raised to the limit, which would change memory use for callers that pass large limits and receive short results. The benchmark does not time these cases.

Repair check

The first version of this branch (6488941) was later repaired: the fixes under "Fixed on the way", plus 99b8c49 (review fix of the new preset-dictionary support), 9e2dc69 (codec allocator on full-API builds), d7b8e06 (_ParallelCompress docstring), cdf4f03 (restored test-static jobs), a557497 (Windows ARM64 wheel tests), and 7c5af43 (release-wheel audit). Timing the first candidate against the final one (936ab8f) with the same protocol gives operation medians of 0.993-1.005 and case ratios of 0.874-1.065, against a control 95th percentile of 4.262% and maximum of 8.183%. Only zeros 1 KiB raw deflate decompression, at 0.874 with a control ratio of 0.953, lies beyond the control maximum, so there is no measurable change. Both new runs are noisier than the first candidate's own comparison (2.884% and 5.314%); interpretation: machine state, not code.

Relationship to the maintainer's design

The design is to use the 3.11 Limited API, replace _PyBytes_Resize() with buffers managed by PyMem_Malloc() and PyMem_Resize(), create the result with PyBytes_FromStringAndSize() at the end, and accept the performance cost. This branch follows it, and the measured cost is above. Refinements and deviations:

  • Filled bounded output is returned directly instead of copied, for the calls listed under "Memory and the copy".
  • End-of-stream stop: output growth stops when zlib-ng reports the end of the stream, even if the buffer is exactly full. test_stream_end_at_full_output_does_not_grow shows the saved growth (about 16 KiB of traced peak for a 16384-byte stream). It applies to full-API builds too. In the prototype study below, it changes no timing measurably over the 24 large decoder cases (median 1.010 without it), except zeros 1 MiB + 137 bytes through decompressobj(), which is faster without the stop (0.886), unexplained. At 64 KiB, an exact buffer boundary, the original code without it takes 1.385-1.821 times as long for random input and stays inside the control for the other inputs.
  • Codec state allocator: zlib-ng calls its allocation callbacks without an attached thread state, where PyMem_Malloc() is not allowed. PyMem_RawMalloc() is allowed but joins the stable ABI only in 3.13, so Limited-API builds use malloc() and free(), and tracemalloc does not see codec state there. Full-API builds keep PyMem_RawMalloc() and PyMem_RawFree().
  • Free-threaded CPython keeps the full API, because a cp311 stable ABI wheel cannot load there; its locks, thread-state release, and no-GIL declarations are unchanged. The abi3t stable ABI of PEP 803 (Python 3.15 and later) could cover it later.
  • PyPy keeps its existing static types to preserve existing behavior. Its 3.11.17 headers do define Py_TPFLAGS_IMMUTABLETYPE and Py_TPFLAGS_DISALLOW_INSTANTIATION; heap types are used on CPython only.
  • CPython types are heap types from specifications; compressobj() and decompressobj() take their types from module state with traverse and clear hooks. PyObject_New() and FASTCALL are kept. The process-global zlib_ng.error is unchanged, so this adds no subinterpreter isolation.
  • requires-python rises from >=3.9 to >=3.11, the support floor already stated in the 1.1.0 changelog.

Validation

  • One cp311 stable ABI wheel, built with Py_LIMITED_API=0x030B0000, passes abi3audit --strict with no violations and a computed baseline of 3.11. The same wheel installs on CPython 3.11.15, 3.12.14, 3.13.12, 3.14.4, 3.15.0a8 (alpha), and 3.15.0; smoke checks against standard-library oracles pass on all six. On each, tests/test_stable_abi.py gives 52 passed and 10 skipped (no _testcapi), or 60 passed and 2 skipped on 3.14.4. The full suite was not run on the other five versions.
  • Full test suite on the installed wheels, each including the standard-library compliance tests:
    • CPython 3.14.4 with PYTHONDEVMODE=1: 14,625 passed and 4 skipped (compliance alone: 138 passed and 2 skipped).
    • Free-threaded CPython 3.13.11 (uv build) with PYTHON_GIL unset, the GIL staying off after import: 14,619 passed and 10 skipped. Eight skips need _testcapi, which that build lacks, one is a 4 GiB bigmem test, and one needs sys.gettotalrefcount.
    • PyPy 3.11.17 (PyPy 8.0.1): 14,597 passed and 32 skipped.
  • test_full_api_codec_state_uses_the_raw_python_allocator skips on the stable ABI wheel by design, because codec state uses malloc() there.
  • flake8, mypy (17 files), actionlint, git diff --check, strict Sphinx, and twine check pass.
  • New tests cover type lifetimes and ownership, immutable and factory-only types, output boundaries and direct return, allocation-failure cleanup and the release of output after failed or invalid decodes, module initialization failure, and that importing on a free-threaded build leaves the GIL disabled.
  • CI is configured to build and audit one cp311-abi3 wheel on Linux, macOS Intel, macOS ARM, Windows x64, and Windows ARM64, and to test that same wheel on CPython 3.11-3.15; the release job audits the wheels it uploads. Windows 3.14t builds from source to exercise its free-threading macro.

Limits: timings and tests come from one Linux x86_64 machine. Not run:

  • No CI has run for this branch.
  • The macOS, Windows, and ARM64 CI jobs (including the new windows-11-arm jobs and Windows 3.14t), the release job's abi3audit step, and the AddressSanitizer and ThreadSanitizer jobs.

Fixed on the way

Each fix is in its own commit. Unless noted, the problem predates the migration (present in 39cec01):

  • c820004: crc32_combine(), _ParallelCompress(), _GzipReader.seek(), and _GzipReader.read() raised SystemError instead of TypeError for bad arguments, because PyArg_Parse* results were compared with < 0.
  • a7dca65, 0bb3996: _GzipReader and _ParallelCompress carried isal_zlib type names.
  • d94cb5c: _ZlibDecompressor lost unconsumed input beyond 4 GiB when it hit its output limit.
  • 8f6408f: decompressobj().decompress() lost unread input beyond the first 4 GiB of one call when the output limit or the end of the stream was reached.
  • f603f0c: _ZlibDecompressor raised SystemError for a stream that needs a preset dictionary, whether or not zdict was given. 99b8c49 is a review fix of this new support: with max_length=0 the applied dictionary was reported as an error and the input discarded. That defect never existed outside this branch.
  • 936ab8f: _GzipReader.readinto() wrote into read-only buffers and crashed on a read-only mmap; it now raises TypeError.
  • 1954f7d: _GzipReader turned any error from looking up read into an unrelated TypeError.
  • 7c540b9: module initialization ignored failures and leaked the module and the gzip module on error exits. One part came with the migration: a pending exception between the two type creations, introduced in 6488941.
  • e692065: the PyPy Py_UNREACHABLE fallback redefined PyPy's own macro.
  • e2ccb50, 758e880, 925524a: the sanitizer job and the gzip_ng.open docstring named python-isal and ISA-L, and the license package-data entry named a file that does not exist.
  • cd6aedb, partly pre-existing: the cibuildwheel skips for cp38 on macOS ARM64 and Windows ARM64 were already stale under the baseline's >=3.9, while the cp39 and cp310 skips became stale with this branch's >=3.11. The same commit adds tests/test_stable_abi.py to the cibuildwheel tests.

Debt left

  • CI paths-ignore skips the whole workflow, including the docs build, for pull requests that only touch docs/ or .rst files. That is the existing CI-cost policy, left as is.
  • Importing zlib_ng in a subinterpreter replaces the process-wide zlib_ng.error class. That is existing module policy and out of scope here.
  • Free-threaded stable ABI wheels (abi3t) are a follow-up.
  • The bounded-call copies listed under "Memory and the copy" are by design.
  • No CI has run for this branch: the remote jobs (macOS, Windows, and ARM64, including the new windows-11-arm jobs and the release abi3audit step) and the ASan and TSan jobs still need to run.

Earlier attempts and considered approaches

Alternative output strategies: prototype benchmark and reasons for rejecting them

A separate prototype study, run against the original code, compared four ways of growing unbounded output. It is not the final-wheel comparison above, and its ratios must not be compared with it: it used prototype binaries, a different reference, and a Python source input repeated to size with a 29,521-byte period, shorter than deflate's 32 KiB window. Every variant adds the end-of-stream stop to the original code and is timed over nine repeats against the original code with the stop alone (baseline-eof). The decoders are decompress(), _ZlibDecompressor(wbits=-15).decompress() without a limit, and decompressobj().decompress(). The control (eof-control) has an inclusive 95th percentile of 8.195% and a maximum of 8.739% over all 72 cases. Large means 1 MiB + 137 and 16 MiB + 137 bytes; small means 1 KiB and 64 KiB.

Strategy (variant ID) Large decoder cases (24): median Range Small decoder cases (24): median Range
Native buffer with a final copy, selected (buffers-eof) 1.181× 0.888-1.911× 1.016× 0.821-1.519×
Joined segments (segmented-eof) 0.998× 0.392-1.651× 1.107× 0.978-1.723×
Eager BytesIO (bytesio-eof) 1.373× 1.049-1.880× 1.703× 1.006-4.752×
Delayed BytesIO, created on first growth (bytesio-hybrid-eof) 1.368× 1.139-1.921× 1.057× 0.887-2.009×

The original code without the stop (baseline) has a median of 1.010 over the large cases, inside the control.

  • Joined segments are faster than the native buffer on large outputs (a median of 0.998 against 1.181, a gap wider than the control, and faster in 16 of 24 cases) and slower on small ones (1.107 against 1.016). On large-output speed, the evidence does not favour the native buffer. The repeated Python input cuts both ways: it gives segments their slowest case (1.651) and some of their largest wins (0.878-0.980 against 1.506-1.623 at 1 MiB + 137 bytes). Without that input, segments lead by more, with a median of 0.964 against 1.155 and faster in 13 of 18 cases. The native buffer was kept because it is the maintainer's prescribed design and needs less machinery (no segment list and no join pass); that this is simpler to maintain is a judgment, not a measurement. Joined segments would be a defensible alternative for a later change.
  • Delayed BytesIO beats the native buffer at 1 MiB + 137 bytes in 9 of 12 decoder cases but loses all 12 at 16 MiB + 137 bytes. Eager BytesIO reaches 4.752 times the reference on small outputs. Both add Python-level calls and writable buffer exports, so neither was adopted.

The per-case results, per-decoder medians, raw repeats, and study settings are committed; the prototype harness, binaries, and inputs are not.

Checklist

  • Pull request details were added to CHANGELOG.rst
  • Documentation was updated (if needed)

🤖 Generated with Claude Code

@Dev-iL
Dev-iL marked this pull request as draft November 5, 2026 04:54
Dev-iL and others added 24 commits November 5, 2026 08:00
A finite output limit that is allocated at once and filled completely is
returned without a second, output-sized copy. Measure the traced peak for
_ZlibDecompressor.decompress(max_length=len(data)) and for one full
_GzipReader.read() chunk. Both checks fail when the bounded allocation is
replaced by native scratch storage plus a final copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PyArg_ParseTuple() and PyArg_ParseTupleAndKeywords() return 0 on
failure, not a negative value. crc32_combine(), _ParallelCompress(),
_GzipReader.seek() and _GzipReader.read() compared the result with < 0,
so a bad argument continued with a pending TypeError and surfaced as
SystemError (or used an unset seek offset). Test the result with !, as
every other parser call in the module does.

This predates the stable ABI migration; it is present in 39cec01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both types were copied from python-isal with their isal_zlib type names,
so their __module__, repr and error messages pointed at a module that
does not exist here. Use the zlib_ng prefix the other types use, in both
the PyPy static types and the heap-type specs, and point the
_ParallelCompress developer-error message at this project's issue
tracker. A test locks every type the module exports, and the factory
types, to the zlib_ng package.

This predates the stable ABI migration; it is present in 39cec01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rename in the previous commit kept the line's trailing space, which
git diff --check reports against the comparison baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decompress_buf() feeds input larger than 4 GiB to zlib-ng in 32-bit
chunks. When the output limit was reached while a chunk was being
processed, only the inner loop ended. The outer loop then arranged the
next chunk, overwriting zst.avail_in while bytes of the current chunk
were still unconsumed, so those bytes were lost from the decompressor's
pending input and unused_data. Leave both loops on the output limit, and
on a decoder error so no further input is fed, before adding zst.avail_in
back to avail_in_real.

The other decoder loops already leave both loops (Decompress.decompress
and Decompress.flush jump to save; _GzipReader recomputes avail_in from
its cursor), and the remaining loops have no output limit.

One-off: needs >4 GiB. A single decompress() of a 1 MiB zlib stream
followed by zeros, 2**32 + 2**20 bytes in total, with max_length=65536,
then draining to EOF, leaves 1044760 bytes of unused_data before this
change and the expected 4296011476 after it (about 12 GiB peak RSS), so
it is not added to the test suite.

This predates the stable ABI migration; it is present in 39cec01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PyObject_HasAttrString() reports any lookup failure as a missing
attribute. A file object whose read lookup raised MemoryError (or any
other error) was then treated as a bytes-like object and failed with an
unrelated TypeError. Look the attribute up before allocating the reader,
treat only AttributeError as "not a file", and propagate anything else.
PyObject_GetOptionalAttrString() would do the same but needs the 3.13
Limited API.

This predates the stable ABI migration; it is present in 39cec01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PyInit_zlib_ng() left failures unreported or leaked:

- Results of PyModule_AddObject(), PyModule_AddIntMacro(),
  PyUnicode_FromString() for the version strings and
  PyUnstable_Module_SetGIL() were ignored, so an allocation failure
  there returned the module with an exception still set (SystemError
  in the importer).
- Exits after the module was created did not release it, and the gzip
  module reference was never released.
- The second create_extension_type() call ran with the exception of a
  failed first call still pending.

Use one error exit that releases the module and the gzip module, check
each addition, and add the integer constants from a table. The global
error and BadGzipFile objects are created and owned as before. A test
imports the module again under _testcapi.set_nomemory() at every
allocation index and requires a reported error. CPython itself can fail
heap-type creation without an exception; the test tolerates only that
form of SystemError. Before this change it fails with "returned a result
with an exception set".

The unchecked additions, the leaks and the unreleased gzip module
predate the stable ABI migration (present in 39cec01); the pending
exception between the two type creations came with it (6488941).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PyPy 3.11 defines Py_UNREACHABLE in pymacro.h, so the unconditional
fallback definition for PyPy produced a macro redefinition warning.
Define the fallback only when the headers lack it.

This predates the stable ABI migration; it is present in 39cec01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The migration switched zlib-ng's allocation callbacks to malloc/free on
every build because PyMem_RawMalloc() joins the stable ABI only in 3.13.
Full-API builds (free-threaded CPython and PyPy) lost the baseline
PyMem_RawMalloc/PyMem_RawFree pair with it, and so the tracemalloc
visibility and the PyMem allocator hooks that tests and embedders use.

Select malloc/free only for Py_LIMITED_API builds below 3.13 and keep
PyMem_RawMalloc/PyMem_RawFree everywhere else. Both are valid without an
attached thread state, which zlib-ng requires. The overflow guard and
the one-byte minimum for zero-sized requests stay for both.

On a full-API build a new test sees the deflate state of compressobj()
in tracemalloc (more than 100 KiB); with malloc it sees 304 bytes. It
skips on stable ABI builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The heap-type slots for _ParallelCompress had no Py_tp_doc, so on
CPython its __doc__ became None while the baseline static type and the
PyPy type keep it. Share one docstring between both definitions and test
the docstrings of every directly constructible type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fail one Python allocation at a time, from the first allocation until
20 calls in a row succeed, for decompress(), compressobj() compress and
flush, decompressobj() decompress and flush, _ZlibDecompressor,
_GzipReader.read() and _GzipReader.readall(). Every attempt must raise
MemoryError (or zlib_ng.error with Z_MEM_ERROR, zlib's mapping of a
failed state allocation) or return output that the standard library
decodes to the original data, and a clean call afterwards must succeed.

The sweep fails for decompress, compressobj and decompressobj when the
Limited API output allocation stops setting MemoryError, and for
_GzipReader.readall when its first allocation failure is swallowed.
zlib-ng's own state allocations use malloc on stable ABI builds and are
outside the reach of set_nomemory there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
compressobj() and decompressobj() take their types from module state,
so deleting the module attributes must not free them. Run that in a
fresh process with only a weak reference held by the test. It fails
when module state holds a borrowed reference that it neither traverses
nor clears.

Exercise _GzipReader.readinto() directly with a bytearray and a
memoryview, for a buffer that is filled exactly and for a short final
read. It fails when readinto() leaves one byte of the buffer unused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decompress() and decompressobj().decompress() start with a 16 KiB
output allocation. A stream that ends exactly at that size must leave
the decode loop on Z_STREAM_END rather than grow the buffer once more.
The output is the same either way, so compare traced peaks for a
16383-byte and a 16384-byte stream instead: they differ by a few bytes,
and by about 16 KiB when the loop ignores Z_STREAM_END (stable ABI
32968 -> 49305, full API 58697 -> 75033).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The package data entry named zlib-ng/LICENSE, but the vendored file is
zlib-ng/LICENSE.md, so the pattern matched nothing. The license still
reached wheels only because MANIFEST.in grafts the zlib-ng directory and
include-package-data copies it. Name the real file so the license does
not depend on that.

This predates the stable ABI migration; it is present in 39cec01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sanitizer step was copied from python-isal and still said it
installs python-isal and passes CFLAGS to ISA-L. It installs this
package, and setup.py passes CFLAGS to the zlib-ng build.

This predates the stable ABI migration; it is present in 39cec01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CIBW_SKIP still excluded cp38, cp39 and cp310 builds for macOS arm64
and Windows arm64. requires-python >= 3.11 already keeps cibuildwheel
from building any of them (cp38 was outside the baseline's >= 3.9 as
well), so keep only the 32-bit skips.

The wheel tests ran the compliance and gzip suites but not
tests/test_stable_abi.py, which holds the ownership, output-boundary and
allocation-failure checks for the built extension. Run it on Linux,
macOS and Windows; it needs only pytest and skips what an interpreter
cannot run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The migration replaced the ubuntu-latest source-build jobs for CPython
3.11, 3.12, 3.13, 3.14 and 3.15 with the same-wheel abi3 jobs. Those
source builds also run the project's tox configuration (coverage,
PYTHONDEVMODE) on every regular version, so restore them exactly as in
39cec01 alongside the new jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release builds and publishes a cp311-abi3 win_arm64 wheel, but the
same-wheel jobs built and tested abi3 wheels only on Linux, both macOS
runners and Windows x64. Add windows-11-arm to the build and test
matrices, with the arm64 MSVC environment that test-static and the
release job already select for that runner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release job uploads the cp311-abi3 wheels that cibuildwheel built
and repaired, but abi3audit only ran on the separately built test wheels.
Run abi3audit --strict on every cp311-abi3 wheel in dist before the
artifact upload, so a wheel that uses symbols outside the 3.11 stable ABI
is not published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mypy rejected the string arguments that the argument-parsing test passes
on purpose (tox -e lint runs mypy over tests). Pass them through a value
typed as Any, and give _ParallelCompress the level argument its stub
requires. The test still fails with SystemError on 6488941.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The initialization failure test reimported the module under
set_nomemory(). On CPython 3.13 the import system itself segfaults in
_get_module_lock() under that hook, for pure Python modules such as json
as well, and it raises RuntimeError rather than MemoryError for a lock
it cannot allocate. Call _imp.create_dynamic() instead, which runs the
module's initialization again without those locks, and require
MemoryError (or CPython 3.14's exception-less heap-type failure) from
every failed attempt. It passes on 3.13 and 3.14, with and without
PYTHONDEVMODE, and still fails on 6488941's initialization with
"returned a result with an exception set".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
A zlib stream that names a preset dictionary made inflate return
Z_NEED_DICT, and _ZlibDecompressor jumped to its error exit without
setting an exception, so callers got SystemError whether or not they
had passed zdict. Set the dictionary and continue when one was given,
as Decompress.decompress() does, and otherwise report the zlib error.

This predates the stable ABI migration (present in 39cec01; CPython's
own zlib._ZlibDecompressor has the same gap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docstring was copied from python-isal and still credited isa-l. This
predates the stable ABI migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Dev-iL
Dev-iL marked this pull request as ready for review November 5, 2026 06:39
Dev-iL and others added 2 commits November 5, 2026 08:58
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>
…didate

Open the benchmark report with its result, name operations and strategy
variants consistently, list every slowdown beyond the control maximum,
describe which calls copy their output and what that costs in memory,
add the repair check and the small-output cohort of the strategy study,
and correct the PyPy and allocator statements. Replace the pending
validation record with markers for the final counts, and state the
remaining limits.

The changelog takes the project's "version X-dev" heading, names the
calls that pay the copy with the measured cost, and lists the
user-visible fixes made on the way. The installation docs mention the
cost and point to the changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dev-iL and others added 6 commits November 5, 2026 09:33
After _ZlibDecompressor applied a preset dictionary, inflation kept
going with err still set to Z_NEED_DICT. With max_length=0 the output
limit was reached first, and the limit exit reported Z_NEED_DICT as
"Error 2 while decompressing data" and discarded the input. Treat the
applied dictionary as progress so the next call resumes the stream.

Fixes a regression in f603f0c, found in review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This predates the stable ABI migration. save_unconsumed_input() decided
whether input was left from zst.avail_in, which only covers the current
chunk of at most UINT32_MAX bytes. When a chunk ran out exactly as the
output limit or the end of the stream was reached, the following chunks
were not saved to unconsumed_tail or unused_data. Measure what is left
from the end of the caller's buffer instead, as python-isal does. Below
4 GiB both measures agree, so nothing else changes.

d94cb5c fixed the same class in _ZlibDecompressor only.

One-off check, not a test: a real case needs more than 4 GiB of input.
With arrange_input_buffer() capped at 7 bytes per chunk in a scratch
build, decoding a stream followed by 190 bytes of extra data lost
unused_data with max_length=100 before this change and returned it in
every case after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The allocation-failure sweep only checked for crashes, unset exceptions
and wrong output. Run it twice and require that the second pass retains
less than 8 KiB under tracemalloc, and add a test that repeated invalid
input does not keep its partly grown output, as python-isal does.

With PyMem_Free removed from output_clear() in a scratch Limited API
build, 7 of these 11 cases fail; the whole suite passed before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This predates the stable ABI migration. readinto() requested a plain
buffer, so it wrote decompressed data into bytes objects and read-only
memoryviews, and crashed on a read-only mmap. Request a writable buffer
and raise the TypeError that io's readinto() methods raise, as
python-isal does. gzip_ng's BufferedReader always passes a writable
buffer, so normal file reads are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The zdict, decompressobj and readinto fixes changed the extension, so
the 9-round paired timing, the repair check against 6488941 and the
recorded wheels and test counts now describe 936ab8f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rederive every figure in the benchmark report from the re-timed data:
the control spread, the operation table, the 19 cases beyond the
control maximum, the 16 MiB + 137 summary, the absence of faster cases,
and the repair check. Disclose the _ZlibDecompressor copies above a
16 MiB limit and for output shorter than its limit, discount the
repeated Python input in the strategy study in both directions, and
restate the end-of-stream timing evidence with its one outlier.

The changelog adds the decompressobj() input beyond 4 GiB and the
read-only readinto() buffer fixes. build.json loses slashes in its
prose strings and records that no CI has run for this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Move to stable ABI.

1 participant