Repository navigation
abi3 compatibility - #672
Merged
Merged
Conversation
evertlammerts
enabled auto-merge
October 15, 2026 14:28
evertlammerts
added a commit
that referenced
this pull request
Oct 16, 2026
Stacked on #672. That PR made the bindings compile under the limited API everywhere except the GIL check. This one removes that last obstacle, switches the release wheels to the stable ABI, and makes CI test the promise. After it, each platform ships two wheels instead of four: one for 3.11 and one tagged `cp312-abi3` that serves 3.12, 3.13, 3.14 and later releases without a new duckdb build. The GIL check is the one thing the stable ABI cannot express: there is no function in it that answers "does this thread hold the GIL". We asked that question in three places. The Arrow stream callbacks and `DuckDBPyResult::Close` release the GIL only when the thread holds it, because ending a query waits for its tasks and a task inside a Python UDF needs the GIL to finish, while pyarrow calls `get_schema` without the GIL. And 49 debug asserts plus three runtime diagnostics checked the GIL state outright. For the two release sites I replaced the check with a scope guard, `PyUtil::GilReleaseIfHeld`. From 3.12 it calls `PyThreadState_Swap(nullptr)`, which is in the stable ABI and does exactly what we need: it detaches the thread's state and releases the GIL when the thread holds it, and returns null and does nothing otherwise; the destructor swaps the state back. I probed that on 3.12, 3.13 and 3.14. On 3.11 the same call only swaps a process-wide pointer and is unsafe, so there the guard keeps today's check, which the 3.11 wheel can use because it is not abi3. The asserts go through two macros that are empty in stable-ABI builds, and the three runtime diagnostics (the two `GilAssert` calls and the pandas scan's "GIL was already held" error) became debug asserts: they guarded against our own mistakes, and only full-API builds can check them. The build switch is small. `wheel.py-api = "cp312"` makes scikit-build-core tag the wheel abi3 on 3.12 and later and hand CMake the `Development.SABIModule` component; on 3.11 the same setting yields a version-specific wheel. CMake passes that component to `find_package(Python)` and, under the same condition nanobind applies in linked mode (CPython 3.12 or later, not free-threaded), builds `nanobind-static-abi3` instead of `nanobind-static` and passes `STABLE_ABI` to the module. The choice is made once and used for both the module and our object libraries, because nanobind puts `Py_LIMITED_API` on its abi3 library as a public define and the two sides must compile against the same Python headers. Editable builds clear `py-api` through an override, so local builds keep the full API and the debug asserts. CI changes shape. The seed jobs build cp312, the abi3 wheel, on every platform (it was cp314); the fan-out builds only cp311. The build action runs `abi3audit --strict` on every cp312 wheel, which checks the extension's imported symbols against the stable ABI list, something compiling with `Py_LIMITED_API` does not prove for symbols pulled in through inline functions or dependencies. A new job downloads each cp312 wheel and runs the suite on 3.13 and 3.14; cibuildwheel tests a wheel only on the interpreter that built it, so this job is what tests the promise. It skips the universal2 wheel, as `CIBW_TEST_SKIP` already does, because its slices are the arm64 and x86_64 wheels, tested as those. In PR runs it tests on Linux only, like the sanity job; the Windows and macOS legs run in full packaging runs. Two things the review caught that are worth knowing: uv refuses to install a lockfile and a wheel in one command, and `uv export` needs the project's version, which the build backend takes from the DuckDB submodule and the git history. The test job therefore installs in two steps and checks out with the submodule and full history. Verification on this machine (Linux aarch64): the limited-API syntax check over all 44 binding sources reports zero errors. The fast suite passes on a full-API debug build (6128 tests). A cp312 wheel built here is tagged `cp312-abi3`, passes `abi3audit --strict`, has `Py_LIMITED_API` in every one of its 44 binding compile commands and all 11 nanobind units, and passes the fast suite installed into fresh 3.12, 3.13 and 3.14 environments with the locked test dependencies, 5996 tests each. A 3.11 build is tagged `cp311-cp311`. The editable build has no `Py_LIMITED_API` in its compile commands, and its GIL-specific tests (the subinterpreter cases, the stream deadlock tests, fsspec) pass. What I could not test here and leave to CI: the Windows and macOS legs of the new test job, and `abi3audit` on the universal2 wheel.
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.
If we have one abi3 wheel per platform for CPython 3.12 and up, instead of one wheel per Python version, a new CPython release can use it without a new duckdb release. Now that the bindings are on nanobind, which supports the limited API from 3.12, the remaining obstacle is our own code. I checked every binding source against the Python 3.12 headers with
Py_LIMITED_API=0x030C0000: 41 of 43 files failed, from five places. This PR removes all of them except the GIL check, which comes in its own PR. The build is unchanged; the wheels still use the full API. Behaviour stays the same except where noted below.The five places and what replaced them:
The
pyutil.hpphelpers usedPyTuple_SET_ITEM,PyByteArray_GET_SIZEandPyMemoryView_GET_BUFFER. They now usePyTuple_SetItemwith its result checked,PyByteArray_Size, andPyObject_GetBufferwith a release guard. A memoryview that skips bytes, likememoryview(b"abcdef")[::2], used to be stored as the wrong bytes (abcinstead oface); it now raisesBufferError, since the buffer request asks for one contiguous run. Contiguous views, slices and casts convert as before.The object column string scan read CPython's internal string layout, and decoded non-ASCII strings codepoint by codepoint. It now makes one
PyUnicode_AsUTF8AndSizecall per value and points DuckDB's string at the result without copying: for ASCII that is the string's own buffer, for anything else the UTF-8 copy CPython caches on the string object for its lifetime. That cache is the memory trade: a frame with many non-ASCII strings grows by their UTF-8 size after the first scan. A lone surrogate used to trip an INTERNAL error in the UTF-8 encoder that invalidated the database; it now raisesInvalidInputExceptionand the connection stays usable. The scan holds the GIL for each chunk of 2048 rows, like every other object column scan already did; the old path read the string internals without it.The datetime C API has no stable ABI counterpart at all. Fields of Python
datetime,date,timeandtimedeltavalues are now read as attributes, through interned names, and the object is held for the whole conversion because an attribute read runs Python code. Since a subclass's property overrides now apply, the fields are validated before they reach the engine:Date::FromDatealready rejects an invalid date, and times go throughTime::IsValidTimeand raiseInvalidInputException. In the other direction, DuckDB values become Python objects by calling thedatetime,dateandtimeclasses with the fields packed into the 10, 4 or 6 bytes their pickle state uses, the form__reduce__produces. The classes accept that single argument without parsing keyword arguments, which is roughly twice as fast as calling them with the integer fields. Every CPython from 2.7 to the main branch and the pure-Python datetime module accept it, but it is undocumented, and it does no range checking of its own, so we check: years outside 1 to 9999 and times outside Python's range fall back to the string, as they did before. No other stable-ABI library I found uses this form; nanobind, PyO3 and through it polars and cryptography call the classes with integers.The pyarrow
RecordBatchReadercheck (which makes a second scan of a reader raise instead of returning nothing) readtp_mroandtp_name. It now compares against pyarrow's real class withPyType_IsSubtypewhenever pyarrow is insys.modules. When it is not, it falls back to the type's own data: an extension type'sPyType_GetNameand__module__come fromtp_name, which a Python class cannot forge, and the bases walk usesPy_tp_bases, which a metaclass cannot replace, visiting each ancestor once. The three existing properties stay pinned: it works with pyarrow out ofsys.modules, a Python class named and homed like the reader is not it, and a metaclass that replaces__mro__does not confuse it. The new Appender code readtp_namefor an error message; it usesnb::type_name.Performance was measured with the benchmark suite under Callgrind as CI runs it, two sweeps per side, against the merge base, plus wall clock where Callgrind proved unreliable. Strings: the mixed ASCII and non-ASCII scan takes 46% fewer instructions, the ASCII-only scan 17% more. Values into DuckDB through
values(): temporal values +1 to +2%, binary unchanged. Tuple building throughPyTuple_SetItem: +3 to +4% on plain integer fetches. The one real regression is building Python temporal objects, where the stable ABI has nothing as cheap as the C API: per 1M values fetched, TIMESTAMP 365 ms against 267 ms, DATE 313 ms against 198 ms, TIME 318 ms against 185 ms (the integer-field form would have been 496, 386 and 364 ms). Parallel scans of object string columns no longer scale past 4 threads, because the conversion now happens under the GIL: on 2M mixed strings, 1 thread 44 ms against 109 ms before, 8 threads 31 ms against 18 ms, 16 threads 32 ms against 13 ms.Known limitations, both pre-existing: a
str()of a non-string object in a scanned column runs Python code, and if that code removes an earlier string from the frame, the pointer the scan already took dangles; and the loop converting a Python list crashes if Python code shrinks the list mid-conversion. The temporal attribute reads join the callbacks that could do either. A TIME from Arrow with 100 hours or more fails in the engine's two-digit formatting with an internal error, before and after.