diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8d50278..06b9e39 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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" diff --git a/README.md b/README.md index 9e21837..1f8ff9f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -160,7 +192,7 @@ cdef class MatrixArray: self.length = len(py_matrices) self.matrices = 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 = mem.alloc(1, sizeof(SparseMatrix)) diff --git a/cymem/cymem.pxd b/cymem/cymem.pxd index dbcbd95..d9e6bc2 100644 --- a/cymem/cymem.pxd +++ b/cymem/cymem.pxd @@ -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) ctypedef void (*free_t)(void *p) cdef class PyMalloc: diff --git a/cymem/cymem.pyx b/cymem/cymem.pyx index e2470fb..287656a 100644 --- a/cymem/cymem.pyx +++ b/cymem/cymem.pyx @@ -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 @@ -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 @@ -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: @@ -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, + PyRealloc pyrealloc = Default_Realloc, PyFree pyfree=Default_Free): self.size = 0 self.addresses = {} self.refs = [] self.pymalloc = pymalloc + self.pycalloc = pycalloc + self.pyrealloc = pyrealloc self.pyfree = pyfree def __dealloc__(self): @@ -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[p] = number * elem_size self.size += number * elem_size return p @@ -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 p not in self.addresses: raise ValueError("Pointer %d not found in Pool %s" % (p, self.addresses)) if new_size == 0: raise ValueError("Realloc requires new_size > 0") assert new_size > self.addresses[p] - cdef void* new_ptr = self.alloc(1, new_size) - if new_ptr == NULL: + old_size_val = self.addresses[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[p]) - self.free(p) - self.addresses[new_ptr] = new_size - return new_ptr + # Resize in place + self.addresses.pop(p_old) + self.addresses[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 diff --git a/pyproject.toml b/pyproject.toml index ab36d78..f4f0898 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/setup.py b/setup.py index 1bb51bd..9a2a3fb 100755 --- a/setup.py +++ b/setup.py @@ -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 @@ -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",