Skip to content

abi3 compatibility - #672

Merged
evertlammerts merged 1 commit into
v2.0-cyanopterafrom
abi3-wheels
Oct 15, 2026
Merged

evertlammerts merged 1 commit into
v2.0-cyanopterafrom
abi3-wheels

Conversation

@evertlammerts

Copy link
Copy Markdown
Member

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.hpp helpers used PyTuple_SET_ITEM, PyByteArray_GET_SIZE and PyMemoryView_GET_BUFFER. They now use PyTuple_SetItem with its result checked, PyByteArray_Size, and PyObject_GetBuffer with a release guard. A memoryview that skips bytes, like memoryview(b"abcdef")[::2], used to be stored as the wrong bytes (abc instead of ace); it now raises BufferError, 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_AsUTF8AndSize call 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 raises InvalidInputException and 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, time and timedelta values 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::FromDate already rejects an invalid date, and times go through Time::IsValidTime and raise InvalidInputException. In the other direction, DuckDB values become Python objects by calling the datetime, date and time classes 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 RecordBatchReader check (which makes a second scan of a reader raise instead of returning nothing) read tp_mro and tp_name. It now compares against pyarrow's real class with PyType_IsSubtype whenever pyarrow is in sys.modules. When it is not, it falls back to the type's own data: an extension type's PyType_GetName and __module__ come from tp_name, which a Python class cannot forge, and the bases walk uses Py_tp_bases, which a metaclass cannot replace, visiting each ancestor once. The three existing properties stay pinned: it works with pyarrow out of sys.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 read tp_name for an error message; it uses nb::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 through PyTuple_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.

@evertlammerts
evertlammerts added this pull request to the merge queue Oct 15, 2026
Merged via the queue into v2.0-cyanoptera with commit 4ae3048 Oct 15, 2026
17 checks passed
@evertlammerts
evertlammerts deleted the abi3-wheels branch October 15, 2026 18:49
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.
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.

1 participant