diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 8d33d33..24edc51 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -31,7 +31,9 @@ jobs: fail-fast: false matrix: os: [ ubuntu-22.04, macos-14, windows-2022 ] - python-version: [ "3.11", "3.12", "3.13", "3.14" ] + # 3.14t is the free-threaded build. No 3.13t: NumPy ships no cp313t + # wheels from 2.5 on, so it would compile NumPy from source. + python-version: [ "3.11", "3.12", "3.13", "3.14", "3.14t" ] env: PYTHON_VERSION: ${{ matrix.python-version }} @@ -60,6 +62,7 @@ jobs: - name: Build wheels uses: pypa/cibuildwheel@v3.3.0 env: + # cp314t needs no CIBW_ENABLE; cibuildwheel gates only cp313t. CIBW_BUILD: "cp3${{ env.PYTHON_SUBVERSION }}-*" CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux*" CIBW_ARCHS_MACOS: "x86_64 universal2" @@ -69,12 +72,13 @@ jobs: CIBW_ENVIRONMENT_WINDOWS: "CMAKE_TOOLCHAIN_FILE=C:/vcpkg/scripts/buildsystems/vcpkg.cmake" # Bundle openblas.dll into the wheel (auditwheel/delocate handle this on Linux/macOS) CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: "delvewheel repair -w {dest_dir} {wheel} --add-path C:/vcpkg/installed/x64-windows/bin" - CIBW_TEST_COMMAND: python -c "import sparsediffpy" + # On free-threaded builds, assert the import did not re-enable the GIL. + CIBW_TEST_COMMAND: python -c "import sys, sysconfig, sparsediffpy; ft = bool(sysconfig.get_config_var('Py_GIL_DISABLED')); assert not ft or not sys._is_gil_enabled(), 'GIL re-enabled on import'; print('freethreaded_build=', ft)" - name: Check wheels shell: bash run: | - pip install --upgrade twine - twine check wheelhouse/* + python -m pip install --upgrade twine + python -m twine check wheelhouse/* - uses: actions/upload-artifact@v4 with: diff --git a/README.md b/README.md index f515061..3a02ca6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,25 @@ pip install sparsediffpy from sparsediffpy import _sparsediffengine ``` +## Free-threaded Python + +Wheels are published for free-threaded CPython 3.14 (`cp314t`) as well as the +default builds. There is no `cp313t` wheel because NumPy ships none from 2.5 +onwards, so that build would have to compile NumPy from source. + +The extension declares that it does not need the GIL, so importing it leaves +free threading enabled. Distinct problems can be built and evaluated +concurrently from different threads: the engine keeps no mutable global state, +every problem and expression owns its own buffers, and all inputs and outputs +are copied at the boundary. This assumes a default build, since +`SP_TRACK_MEMORY` adds global allocation counters that are not thread-safe. + +A single problem or expression capsule is **not** thread-safe. Do not call into +the same problem from two threads at once, and do not share expression capsules +between problems that are evaluated concurrently. Note that `expr::refcount` is +a plain `int`, so breaking this rule corrupts the heap rather than merely +interleaving calls, as it would under the GIL. + ## License Apache License 2.0 diff --git a/SparseDiffEngine b/SparseDiffEngine index 4172c5e..d8cb9b8 160000 --- a/SparseDiffEngine +++ b/SparseDiffEngine @@ -1 +1 @@ -Subproject commit 4172c5ece836bc8d8ebd3fa59b89e019874ea681 +Subproject commit d8cb9b88453dae339c74755f7c3082dafaccbecc diff --git a/pyproject.toml b/pyproject.toml index 677defb..379eca4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,8 @@ file = "README.md" content-type = "text/markdown" [tool.scikit-build] -cmake.version = ">=3.15" +# 3.30.3+ so FindPython3 detects the free-threaded ABI on all platforms. +cmake.version = ">=3.30.3" cmake.build-type = "Release" wheel.packages = ["sparsediffpy"] # Persistent build dir: avoids scikit-build-core's TemporaryDirectory, whose cleanup diff --git a/sparsediffpy/_bindings/bindings.c b/sparsediffpy/_bindings/bindings.c index 7eaac62..4011d1c 100644 --- a/sparsediffpy/_bindings/bindings.c +++ b/sparsediffpy/_bindings/bindings.c @@ -187,5 +187,15 @@ static struct PyModuleDef sparsediffpy_module = { PyMODINIT_FUNC PyInit__sparsediffengine(void) { if (ensure_numpy() < 0) return NULL; - return PyModule_Create(&sparsediffpy_module); + PyObject *module = PyModule_Create(&sparsediffpy_module); + if (!module) return NULL; +#ifdef Py_GIL_DISABLED + /* Requires an engine built without SP_TRACK_MEMORY (the default), which + leaves no mutable global state, so distinct problems are independent. + A single problem or expression capsule is still single-threaded: + expr::refcount is a plain int, so sharing one across threads corrupts + the heap. See README.md. */ + PyUnstable_Module_SetGIL(module, Py_MOD_GIL_NOT_USED); +#endif + return module; }