Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python_version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
python_version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.13t"]
include:
- os: windows-2019
python_version: "3.6"
Expand Down
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,38 @@ pip install -U pip setuptools wheel
pip install cymem
```

### Free threading

`cymem` has support for being built and run under free-threaded CPython.
Currently `Pool` is not thread safe when used from multiple threads at once;
**please avoid sharing a single** `Pool` instance between threads.

Also remember to declare `freethreading_compatible` for the example case below:

``` python
from setuptools import setup
from Cython.Build import cythonize

from Cython.Compiler.Version import version as cython_version
from packaging.version import Version


compiler_directives = dict()
compiler_tenv = dict()

if Version(cython_version) >= Version("3.1.0"):
compiler_directives["freethreading_compatible"] = True
compiler_tenv["CYTHON_FREE_THREADING"] = True
else:
compiler_tenv["CYTHON_FREE_THREADING"] = False

setup(
ext_modules = cythonize("*.pyx", language_level=3,
compiler_directives=compiler_directives,
compile_time_env=compiler_tenv)
)
```

## Example Use Case: An array of structs

Let's say we want a sequence of sparse matrices. We need fast access, and a
Expand Down Expand Up @@ -160,7 +192,7 @@ cdef class MatrixArray:
self.length = len(py_matrices)
self.matrices = <SparseMatrix**>self.mem.alloc(self.length, sizeof(SparseMatrix*))
for i, py_matrix in enumerate(py_matrices):
self.matrices[i] = sparse_matrix_init(self.mem, py_matrix)
self.matrices[i] = sparse_matrix_init_cymem(self.mem, py_matrix)

cdef SparseMatrix* sparse_matrix_init_cymem(Pool mem, list py_matrix) except NULL:
sm = <SparseMatrix*>mem.alloc(1, sizeof(SparseMatrix))
Expand Down
2 changes: 2 additions & 0 deletions cymem/cymem.pxd
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
ctypedef void* (*malloc_t)(size_t n)
ctypedef void* (*calloc_t)(size_t n)
ctypedef void* (*realloc_t)(size_t n)
Copy link
Contributor

@rgommers rgommers May 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're adding to the public API, you should explain in the PR description why. Also, calloc method is added to the implementation but not here.

ctypedef void (*free_t)(void *p)

cdef class PyMalloc:
Expand Down
50 changes: 42 additions & 8 deletions cymem/cymem.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import warnings

WARN_ZERO_ALLOC = False

cdef bint CYTHON_FREE_THREADING

IF CYTHON_FREE_THREADING:
from libcpp.mutex cimport once_flag, call_once
cdef once_flag _lazy_import_lock

cdef class PyMalloc:
cdef void _set(self, malloc_t malloc):
self.malloc = malloc
Expand All @@ -17,6 +23,24 @@ cdef PyMalloc WrapMalloc(malloc_t malloc):
o._set(malloc)
return o

cdef class PyCalloc:
cdef void _set(self, calloc_t calloc):
self.calloc = calloc

cdef PyCalloc WrapCalloc(calloc_t calloc):
cdef PyCalloc o = PyCalloc()
o._set(calloc)
return o

cdef class PyRealloc:
cdef void _set(self, realloc_t realloc):
self.realloc = realloc

cdef PyRealloc WrapRealloc(realloc_t realloc):
cdef PyRealloc o = PyRealloc()
o._set(realloc)
return o

cdef class PyFree:
cdef void _set(self, free_t free):
self.free = free
Expand All @@ -27,6 +51,8 @@ cdef PyFree WrapFree(free_t free):
return o

Default_Malloc = WrapMalloc(PyMem_Malloc)
Default_Calloc = WrapCalloc(PyMem_Calloc)
Default_Realloc = WrapRealloc(PyMem_Realloc)
Default_Free = WrapFree(PyMem_Free)

cdef class Pool:
Expand All @@ -43,15 +69,21 @@ cdef class Pool:
size (size_t): The current size (in bytes) allocated by the pool.
addresses (dict): The currently allocated addresses and their sizes. Read-only.
pymalloc (PyMalloc): The allocator to use (default uses PyMem_Malloc).
pycalloc (PyCalloc): The allocator to use (default uses PyMem_Calloc).
pyrealloc (PyRealloc): The allocator to use (default uses PyMem_Realloc).
pyfree (PyFree): The free to use (default uses PyMem_Free).
"""

def __cinit__(self, PyMalloc pymalloc=Default_Malloc,
PyCalloc pycalloc = Default_Calloc,
Copy link
Member

@honnibal honnibal May 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is backwards incompatible. A user who was instantiated the class with something like mem = Pool(pymalloc=Default_Malloc, pyfree=Default_Free) would suffer segfaults.

When adding new arguments to a function with defaults, the new arguments need to be placed after the current ones.

PyRealloc pyrealloc = Default_Realloc,
PyFree pyfree=Default_Free):
self.size = 0
self.addresses = {}
self.refs = []
self.pymalloc = pymalloc
self.pycalloc = pycalloc
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately this will be more disruptive than a reasonable person would expect.

We build against cymem across our dependency tree (we cimport cymem), and in order to add a member, you need to add a cdef in the .pxd.

If a public class changes size in Cython, it's no longer binary-compatible. This means we'll need to make this a major version increment, and we'd need to do updates across our dependency chain to use the new version.

self.pyrealloc = pyrealloc
self.pyfree = pyfree

def __dealloc__(self):
Expand All @@ -69,10 +101,9 @@ cdef class Pool:
"""
if WARN_ZERO_ALLOC and (number == 0 or elem_size == 0):
warnings.warn("Allocating zero bytes")
cdef void* p = self.pymalloc.malloc(number * elem_size)
cdef void* p = self.pycalloc.calloc(number * elem_size)
if p == NULL:
raise MemoryError("Error assigning %d bytes" % (number * elem_size))
memset(p, 0, number * elem_size)
self.addresses[<size_t>p] = number * elem_size
self.size += number * elem_size
return p
Expand All @@ -84,18 +115,21 @@ cdef class Pool:

If p is not in the Pool or new_size is 0, a MemoryError is raised.
"""
cdef void* p_new
if <size_t>p not in self.addresses:
raise ValueError("Pointer %d not found in Pool %s" % (<size_t>p, self.addresses))
if new_size == 0:
raise ValueError("Realloc requires new_size > 0")
assert new_size > self.addresses[<size_t>p]
cdef void* new_ptr = self.alloc(1, new_size)
if new_ptr == NULL:
old_size_val = self.addresses[<size_t>p]
p_new = self.pyrealloc.realloc(p, new_size)
if p_new == NULL:
raise MemoryError("Error reallocating to %d bytes" % new_size)
memcpy(new_ptr, p, self.addresses[<size_t>p])
self.free(p)
self.addresses[<size_t>new_ptr] = new_size
return new_ptr
# Resize in place
self.addresses.pop(<size_t>p_old)
self.addresses[<size_t>p_new] = new_size
self.size = (self.size - old_size_val) + new_size
return p_new

cdef void free(self, void* p) except *:
"""Frees the memory block pointed to by p, which must have been returned
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ build-backend = "setuptools.build_meta"

[tool.cibuildwheel]
build = "*"
skip = "pp* cp36* cp37* cp38*"
skip = "pp* cp36* cp37* cp38* *t*_i686"
test-skip = ""
free-threaded-support = false
enable = ["cpython-freethreading"]

archs = ["native"]

Expand Down
14 changes: 13 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,21 @@
from setuptools.command.build_ext import build_ext
from sysconfig import get_path
from Cython.Build import cythonize
from Cython.Compiler.Version import version as cython_version
from packaging.version import Version


PACKAGES = find_packages()
MOD_NAMES = ["cymem.cymem"]

compiler_directives = dict()
compiler_tenv = dict()

if Version(cython_version) >= Version("3.1.0"):
compiler_directives["freethreading_compatible"] = True
compiler_tenv["CYTHON_FREE_THREADING"] = True
else:
compiler_tenv["CYTHON_FREE_THREADING"] = False

# By subclassing build_extensions we have the actual compiler that will be used which is really known only after finalize_options
# http://stackoverflow.com/questions/724664/python-distutils-how-to-get-a-compiler-that-is-going-to-be-used
Expand Down Expand Up @@ -100,7 +110,9 @@ def setup_package():
version=about["__version__"],
url=about["__uri__"],
license=about["__license__"],
ext_modules=cythonize(ext_modules, language_level=2),
ext_modules=cythonize(ext_modules, language_level=3,
compiler_directives=compiler_directives,
compile_time_env=compiler_tenv),
setup_requires=["cython>=0.25"],
classifiers=[
"Environment :: Console",
Expand Down