Skip to content

[INFO] Limited API and freethreading Python 3.15 #7399

Open

Description

@da-woods

It is anticipated that Python 3.15 will support a version of the Limited API that means the same extension will be importable in both regular and free-threading Python. Some of the details are still to be decided but Cython aims to support this.

To let you start testing this early I've pushed a freethreading-limited-api-preview branch to this repo. This branch is intended to be fairly short-lived and will be deleted once it's all merge into the repo. The branch will probably not be kept rigorously up-to-date with other Cython bug fixes

It's a merge of the following PRs and branches:

Be aware that it is missing some some bits of internal thread safety which would be provided by critical sections and PyMutexes since these have not yet been added to the Limited API.

To use

Define the following CFLAGS when you build:

-DPy_LIMITED_API=0x030F0000 -DCYTHON_OPAQUE_OBJECTS=1 -D_Py_OPAQUE_PYOBJECT

Build with as recent alpha of Python 3.15 as you can.

Set up your build system normally so that the modules are named abi3.so (see https://cython.readthedocs.io/en/latest/src/userguide/limited_api.html#building-with-the-limited-api for some hints)

Known Limitations

Anything involving an extern type (cdef extern class or similar) is unlikely to work.

Performance is about 13% slower compare with the Py_LIMITED_API=0x030E0000 - essentially every access to an attribute of a cdef class goes through a painful indirect lookup.

Some thread-safety guards missing right now.

Activity

  1. da-woods commented on Dec 12, 2025

    @da-woods
    CollaboratorAuthor

    One detail that I've subsequently spotted is that it breaks our memoryview ABI compatibility. That's going to need either some workarounds or some checks.

  2. da-woods commented on Dec 13, 2025

    @da-woods
    CollaboratorAuthor

    The other outstanding issue is access to cdef classes without the GIL. I've currently implemented a very hacky workaround, but there's no guarantee it'll keep working long-term and Python developers are sceptical of it.

  3. mgorny commented on Jan 5, 2026

    @mgorny
    Contributor

    I've noticed that the generated C file has:

      #if CYTHON_COMPILING_IN_CPYTHON_FREETHREADING
      {Py_mod_gil, Py_MOD_GIL_NOT_USED},
      #endif

    rather than:

      #if CYTHON_COMPILING_IN_CPYTHON_FREETHREADING || CYTHON_COMPILING_IN_LIMITED_API_FREETHREADING
      {Py_mod_gil, Py_MOD_GIL_NOT_USED},
      #endif

    Is this intentional?

  4. da-woods commented on Jan 5, 2026

    @da-woods
    CollaboratorAuthor

    @mgorny thanks for testing.

    I think I deliberately didn't add it in #7382 because I don't think it should be in Cython master until we have access to PyMutex and critical section.

    However, I think it should be available on this "preview" branch because hopefully everyone is sensible enough not know it's for testing and not for shipping. I'll push a commit to fix that shortly.

  5. mgorny commented on Jan 5, 2026

    @mgorny
    Contributor

    Yeah, I agree. Thanks!

  6. da-woods commented on Jan 18, 2026

    @da-woods
    CollaboratorAuthor

    I've pushed a mid-sized update to this.

    • fixes a few bugs with vtab access
    • more importantly, guards a hack related to attribute lookup without the GIL. The Python core-devs weren't hugely happy about the approach (this is fine... it's the natural equilibrium for Python core-devs to be unhappy at hacks in Cython) I'd taken because it may not be long-term stable. I've now at least got a lower-performance fallback ready if it the underlying assumption ends up changing.

    I'll probably push another update later this week to try to clean up some memoryview stuff, time permitting. That'll be more code quality rather than any change in what works.

  7. ngoldbaum commented on Jan 22, 2026

    @ngoldbaum
    Contributor

    Over in #7476 (comment), @da-woods pointed out that defining wrappers for PyObject subtypes like NumPy does in its Cython headers will need some work on the Cython side.

    NumPy currently does something like this:

    cdef extern from "numpy/arrayscalars.h":
    
        # abstract types
        ctypedef class numpy.generic [object PyObject]:
            pass
    

    Maybe NumPy's cython headers will need some updates?

  8. da-woods commented on Feb 1, 2026

    @da-woods
    CollaboratorAuthor

    I've pushed some improvements to the handling of extern types (e.g. numpy's numpy.ufuncobject). You can now specify:

    ctypedef class numpy.generic [object PyObject, opaque_pyobject False]:  # or true
    

    to explicitly specify whether the extern type opaque or not. It defaults to False so there's no need to specify it for existing code.
    This means that non-opaque Cython modules can use opaque extern types (at least partly).

    Opaque Cython modules don't check the size of non-opaque extern types. Largely because it will definitely be a compile error if you try to access their attributes, so it's "relatively" safe.

    I imagine some of this will end up changing, so don't rely too much on it in this exact form. But hopefully it lets people get a bit further with the Numpy headers.

  9. ngoldbaum commented on Feb 23, 2026

    @ngoldbaum
    Contributor

    @da-woods I'm planning to add a section to PEP 803 that quotes ecosystem library maintainers to comment on why PEP 803 will be useful. Can you comment a bit about why it'll be beneficial for Cython users? I can then excerpt that for the PEP.

    No worries if you're not comfortable doing that.

  10. da-woods commented on Feb 23, 2026

    @da-woods
    CollaboratorAuthor

    @ngoldbaum I don't mind being quoted but.... I don't fully understand the appeal of the Stable ABI.

    Cython is doesn't have huge problems with the number of wheels we distribute because ultimately it works fine as pure-Python. We do distribute wheels for a few of the smaller platforms as Stable ABI wheels but that's more "dogfooding" than because we actually need to. So I'm adding this in anticipation that other people will find it useful rather than because I will.

    I do remain slightly concerned that the performance trade-offs for this will turn out to be too much for many Cython users (it's possible that the trade-off may be different for other binding tools). That's not a huge disaster since we're not getting rid of the regular compilation mode so people are free to pick their own personal trade-offs.

    But none of that makes a particularly useful quote...

  11. ngoldbaum commented on Feb 24, 2026

    @ngoldbaum
    Contributor

    I think it was useful! I opened python/peps#4831.

  12. scoder commented on Feb 25, 2026

    @scoder
    Contributor

    I do remain slightly concerned that the performance trade-offs for this will turn out to be too much for many Cython users

    Our benchmarks report our Limited API code (the L- columns) as visibly slower than our optimised CPython code, e.g.

    https://github.com/cython/cython/actions/runs/22303110980

    Most of the big-gap ones are micro-benchmarks, but from the rest, especially object intensive benchmarks like "richards" suffer by factors. They are often slower than the non-compiled pure Python code (the column right of it). The proposed Stable ABI changes are likely to make this relation even worse, and thus completely useless for anything performance relevant. [EDIT: I was referring to code where the performance of the C-API code matters, not one-shot long running native data processing where it does not.]

    I can see the "external library wrapping" use case still being a candidate for the Stable ABI, even with the new worse performance. There are certainly cases where people say "I need the functionality, regardless of the speed". PyQT comes to my mind as a reasonable example, also audio/video processing libraries etc. that just need a "configure and run". There are certainly cases like this. But in many such cases, running a subprocess will be hugely easier and faster than connecting to a library through the Limited API.

    I can't say whether it's really worth building and supporting a whole second API construct just for this use case. Performance matters for a lot of users in the Python ecosystem, and this isn't just about some "~10% YMMV". Anything for which the wrapping performance matters will probably try hard to avoid the Stable ABI at all cost.

  13. rgommers commented on Feb 25, 2026

    @rgommers
    Contributor

    The proposed Stable ABI changes are likely to make this relation even worse, and thus completely useless for anything performance relevant.

    I am not sure this take is correct in general. It seems to be true when one is using lots of short, micro-second level operations. When working with larger array data (quite common for numerical/scientific libraries), the differences quickly seem to converge to <=10%. See for example benchmarks for PyWavelets (which uses a lot of Cython code, and some C code) in PyWavelets/pywt#828. Scikit-learn (which is even more heavy on Cython usage) benchmarks are in the works, I'd expect roughly similar outcomes.

    My expectation is that for the most popular platforms, well-maintained scientific libraries are likely to continue to take the wheel building pain to avoid any performance regressions, but for niche platforms it'll be either "no wheels" or "stable ABI wheels". And for less well-maintained libraries, the tradeoffs are leaning even more towards the stable ABI being useful.

  14. 20 remaining items

  15. scoder commented on Jun 25, 2026

    @scoder
    Contributor

    It would be really nice to see this integrated before 3.3.0 final

    We're not planning to integrate this for 3.3, see #7753 (comment)

    It's a large pack of changes in various corners and I do not want to risk further extending the time that it'll take us to get the pile of new features in 3.3 to our users.

    I just read up on the main parts of PEP-803, which seems to be the one that got the final blessing. It left me with the impression that independent ABIs for GIL- and noGIL-Python, and a temporal limit of the stability guarantees, seem the best path forward. As we see with abi3, we've come to a point where we can't hold up the stability guarantees – and that's fine, isn't it? Design a new replacement ABI, rebuild the packages, move on. Once a project has gone through the entire hassle to adopt one Stable ABI, the next ABI adaptation will probably be mostly a rebuild, with some fixes. Projects could still ship cross-Pyversion wheels for several years, thus reducing the total number of wheels considerably, and eventual EOL-driven reduction should then keep it mostly constant over time. We'd have abi3 for Py<=3.14(non-t), abi3 and abi1t for Py3.15+, and abi4 and abi2t eventually for, say, Py3.19 or Py4.3.

    The main reason why I favour independent ABIs for GIL- and noGIL-Python targets is that I found it worth adapting the code independently for the two, e.g. considering locks vs. GIL-safety, so targeting both with a single wheel seems counter-intuitive to me, at least in most cases. Why enter a critical section when the code is guarded by the GIL? There's probably enough difference between both targets to merit independently optimised wheels, and so separate ABIs are unlikely to hurt much.

  16. encukou commented on Jun 25, 2026

    @encukou
    Contributor

    independent ABIs for GIL- and noGIL-Python [...] seem the best path forward

    Sure. The new noGIL one happens to be compatible with the GIL, though. (Perhaps not surprising: for one, the GIL can be re-enabled; also, if the ABI should ideally be compatible with “any” future implementation, making it incompatible with the known past implementations would be rather questionable.)

    a temporal limit of the stability guarantees seem the best path forward

    That would be nice, if we could predict when the next feature needs to break the guarantees.
    Instead, CPython's ABI for the new feature got a new name.

    Why enter a critical section when the code is guarded by the GIL? There's probably enough difference between both targets to merit independently optimised wheels

    In a GIL interpreter, critical sections are no-op functions in the stable ABI, and nothing (empty #defines) in version-specific ABI.

    So, no difference until you care about the cost of a C function call, at which point you don't want the stable ABI anyway.

  17. da-woods commented on Jun 25, 2026

    @da-woods
    CollaboratorAuthor

    I broadly agree that if we want to do 3.3 soon then there's too many charges here. It does essentially rewrite every attribute access to cdef classes so there's a lot to go wrong.

    I don't think the "independent ABIs" is meaningfully going to happen (or at least I was told that there was no chance of a free-threading ABI with non-opaque objects, which would be the main performance improvement).

    As Petr (just) said, not omitting critical sections at compile time shouldn't cost a lot.

  18. nitzmahone commented on Jun 25, 2026

    @nitzmahone

    Definitely understand (and appreciate) the cautious approach!

    IIUC abi3t always == abi3_abi3t, so the talk of separate paths above confused me (or I'm misinformed). Just to clarify: at least for 3.3 as-currently-planned, abi3t is not available- only abi3 limited API builds will be available- free-threading support continues to require version-specific builds, yeah?

  19. da-woods commented on Jun 25, 2026

    @da-woods
    CollaboratorAuthor

    Just to clarify: at least for 3.3 as-currently-planned, abi3t is not available- only abi3 limited API builds will be available- free-threading support continues to require version-specific builds, yeah?

    Yes. That's right.

  20. scoder commented on Jun 25, 2026

    @scoder
    Contributor

    a temporal limit of the stability guarantees seem the best path forward

    That would be nice, if we could predict when the next feature needs to break the guarantees.

    I didn't mean to base it on prediction. When there is a good reason to change the ABI, then a decision can be taken in which (next or future) release to make that change. That release would then be the end of the current Stable ABI and the start of a new one, pretty similar to what is now happening with PEP-803. It would be a tradeoff decision between the cost of package maintenance/distribution and the cost of CPython development / language benefits / whatever the reason for the ABI change will be. Just another tradeoff, like so many others. I'm not convinced that making the object struct opaque is necessary, not right now, possibly not even in the future. It seems a premature pessimisation to me.

  21. encukou commented on Jun 26, 2026

    @encukou
    Contributor

    When there is a good reason to change the ABI, then a decision can be taken in which (next or future) release to make that change. That release would then be the end of the current Stable ABI and the start of a new one, pretty similar to what is now happening with PEP-803.

    That's the general plan on CPython side too -- with specifics left to be decided based on the necessary change.

    I'm not convinced that making the object struct opaque is necessary, not right now, possibly not even in the future.

    Others saw non-opaque PyObject as the biggest problem of abi3. It's not likely that any new stable ABI will go back to it.

  22. scoder commented on Jun 26, 2026

    @scoder
    Contributor

    I'm not convinced that making the object struct opaque is necessary, not right now, possibly not even in the future.

    Others saw non-opaque PyObject as the biggest problem of abi3.

    I had skimmed through that issue, and sure, making the object struct opaque is one of the options. Whether it's a good one depends on which side of the ABI you stand on.

    The issue's problem statement specifically refers to implementing immortal object in CPython in Py3.11/12. If a new Stable ABI4 based on new, optimised object struct layouts had been defined at that point, it could already have served for four release series since then, probably even beyond Py3.15(non-t). Some 4-6 years is a reasonable time frame for a stability guarantee that most maintainers and distributors could probably live with.

    Opaque object structs are really a political decision, not a technical necessity.

  23. dalcinl commented on Aug 22, 2026

    @dalcinl
    Member

    @scoder I'm not sure whether this is the best place to report, but looks like the following is a trivial code generation bug?

    In file included from /usr/include/python3.15/Python.h:82,
                     from src/mpi4py/MPI.c:19:
    /usr/include/python3.15/slots.h:54:58: error: braced-group within expression allowed only inside a function
       54 |     {(NAME), PySlot_INTPTR | PySlot_STATIC, {0}, {(void*)(VALUE)}}
          |                                                          ^
    src/mpi4py/MPI.c:273395:5: note: in expansion of macro ‘PySlot_PTR_STATIC’
    273395 |     PySlot_PTR_STATIC(Py_mod_state_free, {cleanup_func}),
           |     ^~~~~~~~~~~~~~~~~
    

    The following patch fixes the error above, but...

    I'm using Python 3.150.rc1, so I guess your branch is currently outdated.
    diff --git a/Cython/Compiler/ModuleNode.py b/Cython/Compiler/ModuleNode.py
    index 6fe3ff5c3..ede2ec496 100644
    --- a/Cython/Compiler/ModuleNode.py
    +++ b/Cython/Compiler/ModuleNode.py
    @@ -3858,7 +3858,7 @@ class ModuleNode(Nodes.Node, Nodes.BlockNode):
             code.putln(f"  PySlot_PTR_STATIC(Py_mod_state_traverse, {Naming.module_cname}_traverse),")
             code.putln(f"  PySlot_PTR_STATIC(Py_mod_state_clear, {Naming.module_cname}_clear),")
             if Options.generate_cleanup_code:
    -            code.putln("  PySlot_PTR_STATIC(Py_mod_state_free, {cleanup_func}),")
    +            code.putln("  PySlot_PTR_STATIC(Py_mod_state_free, cleanup_func),")
             code.putln("#endif")
             code.putln(f"PySlot_PTR_STATIC(Py_mod_slots, {Naming.pymoduledef_slots_cname}),")
             code.putln("PySlot_PTR_STATIC(Py_slot_end, 0)")

    ... but a new error pops up:

    In file included from /usr/include/python3.15/Python.h:82,
                     from src/mpi4py/MPI.c:19:
    src/mpi4py/MPI.c:273395:42: error: ‘cleanup_func’ undeclared here (not in a function)
    273395 |     PySlot_PTR_STATIC(Py_mod_state_free, cleanup_func),
           |                                          ^~~~~~~~~~~~
    /usr/include/python3.15/slots.h:54:59: note: in definition of macro ‘PySlot_PTR_STATIC’
       54 |     {(NAME), PySlot_INTPTR | PySlot_STATIC, {0}, {(void*)(VALUE)}}
          |                                                           ^~~~~
    error: command '/usr/lib64/mpich/bin/mpicc' failed with exit code 1
    
  24. da-woods commented on Aug 22, 2026

    @da-woods
    CollaboratorAuthor

    That looks like it's missing the f to make it an fstring. I'll push a fix in a bit. I don't think cleanup_func is used much so obviously isn't well-tested

  25. da-woods commented on Aug 22, 2026

    @da-woods
    CollaboratorAuthor

    It's worth bearing in mind that it doesn't look to mix hugely well with module state (#7905) - access to cdef class attributes won't work outside the GIL. That probably won't be fixed soon (because the work needs to happen on the module-state side rather than the freethreading limited API side). So what you're testing may be disappointing.

    Either way, the immediate fix is pushed.

  26. dalcinl commented on Aug 23, 2026

    @dalcinl
    Member

    @da-woods After your fix, my first try with my project (mpi4py) was a complete success. Too good to be true? I'll double check everything.

    However, there is a minor issue with the code generated with this branch. If you have Cython code using the special __weakref__ attribute to support weak references, and then you compile WITHOUT Py_LIMITED_API/Py_TARGET_ABI3T (in other words, with CYTHON_OPAQUE_OBJECTS turn off), then C code dealing with the access of __weakref__ in parent/child classes is broken.

    Here you have a minimal reproducer:

    ctypedef api class Parent [
        type   Parent_Type,
        object ParentObject,
    ]:
        cdef object __weakref__
    
    ctypedef api class Child(Parent) [
        type   Child_Type,
        object ChildObject,
    ]:
        pass

    And this is the error attempting to compile that code with cythonize.py

    [dalcinl@localhost cython]$ python --version
    Python 3.14.7
    [dalcinl@localhost cython]$ ./cythonize.py -b repro.pyx
    Compiling /home/dalcinl/Devel/REPOS/cython/repro.pyx because it changed.
    [1/1] Cythonizing /home/dalcinl/Devel/REPOS/cython/repro.pyx
    In file included from /home/dalcinl/Devel/REPOS/cython/repro.c:41:
    /home/dalcinl/Devel/REPOS/cython/repro.c: In function ‘__Pyx_modinit_Exttype_ChildObject’:
    /home/dalcinl/Devel/REPOS/cython/repro.c:4586:131: error: ‘struct ChildObject’ has no member named ‘__weakref__’
     4586 |   if (__pyx_mstate->__pyx_ptype_5repro_Child->tp_weaklistoffset == 0) __pyx_mstate->__pyx_ptype_5repro_Child->tp_weaklistoffset = offsetof(struct ChildObject, __weakref__);
          |                                                                                                                                   ^~~~~~~~
    error: Command '['gcc', '-fno-strict-overflow', '-Wsign-compare', '-DDYNAMIC_ANNOTATIONS_ENABLED=1', '-DNDEBUG', '-fcf-protection', '-fexceptions', '-fcf-protection', '-fexceptions', '-fcf-protection', '-fexceptions', '-O3', '-fPIC', '-I/usr/include/python3.14', '-c', '/home/dalcinl/Devel/REPOS/cython/repro.c', '-o', 'build/temp.linux-x86_64-cpython-314/home/dalcinl/Devel/REPOS/cython/repro.o']' returned non-zero exit status 1.

    Looks like a simple scope lookup issue? offsetoff should use the parent class that defines __weakref__.
    EDIT: No, it is not that simple. The entry.cname no longer include the __pyx_base.prefix, that's why the currently generated C code fails.

  27. added a commit that references this issue on Aug 27, 2026
  28. da-woods commented on Aug 27, 2026

    @da-woods
    CollaboratorAuthor

    However, there is a minor issue with the code generated with this branch. If you have Cython code using the special __weakref__ attribute [...]

    Fixed

  29. dalcinl commented on Aug 28, 2026

    @dalcinl
    Member

    @da-woods Thanks! Now I can confirm that everything is working OK with mpi4py. The ABI3T build passes all tests with both the GIL-enabled or the free-threaded interpreter. The generated C code builds fine with non-ABI3 from Python 3.10 to 3.15.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions