From 0e9fae6904f35101b80962717a4e372c0d95be83 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 27 Oct 2025 14:49:50 -0600 Subject: [PATCH 01/21] add testing on 3.13t, 3.14, and 3.14t --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5fd07c5..483c90d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-13] - python_version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python_version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.13t", "3.14", "3.14t"] runs-on: ${{ matrix.os }} steps: @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@v3 - name: Configure Python version - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python_version }} architecture: x64 From b3e180f3cae9703d59d8c590fe33819680b85d45 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 27 Oct 2025 14:51:52 -0600 Subject: [PATCH 02/21] temporarily enable testing on forks --- .github/workflows/tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 483c90d..287a1f6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,8 @@ env: jobs: tests: name: Test - if: github.repository_owner == 'explosion' + # TODO: uncomment before upstream PR + # if: github.repository_owner == 'explosion' strategy: fail-fast: false matrix: From 38ca36d69b9bac872474d5bcb64fd135aa78f711 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 27 Oct 2025 14:56:20 -0600 Subject: [PATCH 03/21] set PYTHON_GIL=0 for free-threaded test environments --- .github/workflows/tests.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 287a1f6..9fea628 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -72,6 +72,12 @@ jobs: run: | python -m pip install -U -r requirements.txt + - name: Force-disable GIL on free-threaded builds + if: endsWith(matrix.python_version, 't') + shell: bash + run: | + echo "PYTHON_GIL=0" >> $GITHUB_ENV + - name: Run tests shell: bash run: | From add923cb67ba3e821dcbbd89d4dfcb8f683ac4cb Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 27 Oct 2025 14:57:07 -0600 Subject: [PATCH 04/21] relax python_requires upper limit --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index de0a2ad..283d6b7 100755 --- a/setup.py +++ b/setup.py @@ -100,7 +100,7 @@ def setup_package(): url=about["__uri__"], license=about["__license__"], ext_modules=cythonize(ext_modules, language_level=2), - python_requires=">=3.6,<3.14", + python_requires=">=3.6,<3.15", install_requires=["cymem>=2.0.2,<2.1.0", "murmurhash>=0.28.0,<1.1.0"], classifiers=[ "Environment :: Console", From 0a2870b2069b4df75535a648cf98c500bade2bb3 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Tue, 28 Oct 2025 13:58:54 -0600 Subject: [PATCH 05/21] add TODO comment --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9fea628..68fff8b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -72,6 +72,7 @@ jobs: run: | python -m pip install -U -r requirements.txt + # TODO: delete when Cython extensions declare themselves compatible - name: Force-disable GIL on free-threaded builds if: endsWith(matrix.python_version, 't') shell: bash From b19d2ce3987445c574061862dae9aabc459cb299 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Wed, 29 Oct 2025 14:15:30 -0600 Subject: [PATCH 06/21] depend on cymem free-threading fork and murmurhash master branch --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f83923b..bf8be7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,8 +2,9 @@ requires = [ "setuptools", "cython>=0.28", - "cymem>=2.0.2,<2.1.0", - "murmurhash>=0.28.0,<1.1.0", + # TODO: update for cymem free-thraded support release before making upstream PR + "cymem @ git+https://github.com/lysnikolaou/cymem.git@free-threading", + "murmurhash @ git+https://github.com/explosion/murmurhash.git@master", ] build-backend = "setuptools.build_meta" From e56aa082ee02087b3c530846ead31b9c727f5ff0 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 7 Nov 2025 13:42:45 -0700 Subject: [PATCH 07/21] Implement bloom serialization with a C++ vector --- preshed/bloom.pyx | 48 +++++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/preshed/bloom.pyx b/preshed/bloom.pyx index 54455a3..3b1dac6 100644 --- a/preshed/bloom.pyx +++ b/preshed/bloom.pyx @@ -5,6 +5,10 @@ from murmurhash.mrmr cimport hash128_x86 import math from array import array +cimport cython + +from libcpp.vector cimport vector + try: import copy_reg except ImportError: @@ -39,46 +43,46 @@ cdef class BloomFilter: def add(self, key_t item): bloom_add(self.c_bloom, item) - def __contains__(self, item): + def __contains__(self, key_t item): return bloom_contains(self.c_bloom, item) cdef inline bint contains(self, key_t item) nogil: return bloom_contains(self.c_bloom, item) def to_bytes(self): - return bloom_to_bytes(self.c_bloom) + cdef char* c_data + cdef key_t bloom_length + # lives until the data are copied to the Python bytes object + cdef vector[key_t] ret = vector[key_t]() + c_data = bloom_to_bytes(self.c_bloom, ret) + bloom_length = self.c_bloom.length + return c_data[:3*sizeof(key_t) + bloom_length] def from_bytes(self, bytes byte_string): + # I don't think it's possible to make this thread-safe? + # mem.alloc acquires a critical section on mem.addresses bloom_from_bytes(self.mem, self.c_bloom, byte_string) return self -cdef bytes bloom_to_bytes(const BloomStruct* bloom): - py = array("L") - py.append(bloom.hcount) - py.append(bloom.length) - py.append(bloom.seed) +cdef char* bloom_to_bytes(const BloomStruct* bloom, vector[key_t]& ret): + ret.push_back(bloom.hcount) + ret.push_back(bloom.length) + ret.push_back(bloom.seed) for i in range(bloom.length // sizeof(key_t)): - py.append(bloom.bitfield[i]) - if hasattr(py, "tobytes"): - return py.tobytes() - else: - # Python 2 :( - return py.tostring() + ret.push_back(bloom.bitfield[i]) + return ret.data() cdef void bloom_from_bytes(Pool mem, BloomStruct* bloom, bytes data): - py = array("L") - if hasattr(py, "frombytes"): - py.frombytes(data) - else: - py.fromstring(data) - bloom.hcount = py[0] - bloom.length = py[1] - bloom.seed = py[2] + cdef char* c_data = data; + cdef key_t* i_data = c_data; + bloom.hcount = i_data[0] + bloom.length = i_data[1] + bloom.seed = i_data[2] bloom.bitfield = mem.alloc(bloom.length // sizeof(key_t), sizeof(key_t)) for i in range(bloom.length // sizeof(key_t)): - bloom.bitfield[i] = py[3+i] + bloom.bitfield[i] = i_data[3+i] cdef void bloom_init(Pool mem, BloomStruct* bloom, key_t hcount, key_t length, uint32_t seed) except *: From 158b6c78d8d192e61c383ab7da063091587050c8 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 7 Nov 2025 13:47:52 -0700 Subject: [PATCH 08/21] apply critical sections in BloomFilter --- preshed/bloom.pxd | 2 -- preshed/bloom.pyx | 11 +++++++---- pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/preshed/bloom.pxd b/preshed/bloom.pxd index 6396715..ca53090 100644 --- a/preshed/bloom.pxd +++ b/preshed/bloom.pxd @@ -18,8 +18,6 @@ cdef class BloomFilter: cdef void bloom_init(Pool mem, BloomStruct* bloom, key_t hcount, key_t length, uint32_t seed) except * -cdef void bloom_add(BloomStruct* bloom, key_t item) nogil - cdef bint bloom_contains(const BloomStruct* bloom, key_t item) nogil cdef void bloom_add(BloomStruct* bloom, key_t item) nogil diff --git a/preshed/bloom.pyx b/preshed/bloom.pyx index 3b1dac6..ecf15c0 100644 --- a/preshed/bloom.pyx +++ b/preshed/bloom.pyx @@ -41,10 +41,12 @@ cdef class BloomFilter: return cls(*params) def add(self, key_t item): - bloom_add(self.c_bloom, item) + with cython.critical_section(self): + bloom_add(self.c_bloom, item) def __contains__(self, key_t item): - return bloom_contains(self.c_bloom, item) + with cython.critical_section(self): + return bloom_contains(self.c_bloom, item) cdef inline bint contains(self, key_t item) nogil: return bloom_contains(self.c_bloom, item) @@ -54,8 +56,9 @@ cdef class BloomFilter: cdef key_t bloom_length # lives until the data are copied to the Python bytes object cdef vector[key_t] ret = vector[key_t]() - c_data = bloom_to_bytes(self.c_bloom, ret) - bloom_length = self.c_bloom.length + with cython.critical_section(self): + c_data = bloom_to_bytes(self.c_bloom, ret) + bloom_length = self.c_bloom.length return c_data[:3*sizeof(key_t) + bloom_length] def from_bytes(self, bytes byte_string): diff --git a/pyproject.toml b/pyproject.toml index bf8be7e..e798a0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [build-system] requires = [ "setuptools", - "cython>=0.28", + "cython>=3.1", # TODO: update for cymem free-thraded support release before making upstream PR "cymem @ git+https://github.com/lysnikolaou/cymem.git@free-threading", "murmurhash @ git+https://github.com/explosion/murmurhash.git@master", From e4b0e9722f59139f5d3d3b3feec9a37c58516b39 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 7 Nov 2025 14:46:33 -0700 Subject: [PATCH 09/21] add a multithreaded bloom filter test --- preshed/tests/test_bloom.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/preshed/tests/test_bloom.py b/preshed/tests/test_bloom.py index 1d27cb4..4c38e72 100644 --- a/preshed/tests/test_bloom.py +++ b/preshed/tests/test_bloom.py @@ -1,7 +1,9 @@ from __future__ import division import pytest import pickle +import threading +from concurrent.futures import ThreadPoolExecutor from preshed.bloom import BloomFilter def test_contains(): @@ -54,3 +56,37 @@ def test_bloom_pickle(): bf2 = pickle.loads(data) for ii in range(0,1000,20): assert ii in bf2 + + +def test_multithreaded_sharing(): + bf = BloomFilter(size=2**16) + n_threads = 8 + vals = list(range(0, 10000, 10)) + n_vals = len(vals) + chunk_size = n_vals//n_threads + assert chunk_size * n_threads == n_vals + chunks = [] + for i in range(0, n_vals, chunk_size): + chunks.append(vals[i: i + chunk_size]) + + b = threading.Barrier(n_threads) + + def worker(chunk): + b.wait() + for ii in chunk: + # exercises __contains__, add, and to_bytes + # all are supposed to be thread-safe + assert ii not in bf + bf.add(ii) + assert ii in bf + if ii % 100 == 0: + # every tenth iteration + bf.to_bytes() + + tpe = ThreadPoolExecutor(max_workers=n_threads) + + futures = [] + for i, chunk in enumerate(chunks): + futures.append(tpe.submit(worker, chunk)) + + [f.result() for f in futures] From bc418fb60e74f2c099d93b266e160255c7310ae7 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Tue, 11 Nov 2025 14:10:30 -0700 Subject: [PATCH 10/21] drop 3.13t, use macos-latest-intel --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 68fff8b..4df38cf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,8 +21,8 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-13] - python_version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.13t", "3.14", "3.14t"] + os: [ubuntu-latest, windows-latest, macos-15-intel] + python_version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"] runs-on: ${{ matrix.os }} steps: From 3b577fbcb94cf7e84e43223a9efd5220ba871daf Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Tue, 11 Nov 2025 14:27:32 -0700 Subject: [PATCH 11/21] Bloom filter thread safety pass (#3) * tweak comments * Simplify critical sections * avoid resource leak --- preshed/bloom.pyx | 28 +++++++++++++++------------- preshed/tests/test_bloom.py | 16 ++++++---------- pyproject.toml | 5 ++++- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/preshed/bloom.pyx b/preshed/bloom.pyx index ecf15c0..e703d1f 100644 --- a/preshed/bloom.pyx +++ b/preshed/bloom.pyx @@ -52,29 +52,31 @@ cdef class BloomFilter: return bloom_contains(self.c_bloom, item) def to_bytes(self): - cdef char* c_data - cdef key_t bloom_length - # lives until the data are copied to the Python bytes object - cdef vector[key_t] ret = vector[key_t]() with cython.critical_section(self): - c_data = bloom_to_bytes(self.c_bloom, ret) - bloom_length = self.c_bloom.length - return c_data[:3*sizeof(key_t) + bloom_length] + return bloom_to_bytes(self.c_bloom) def from_bytes(self, bytes byte_string): - # I don't think it's possible to make this thread-safe? - # mem.alloc acquires a critical section on mem.addresses - bloom_from_bytes(self.mem, self.c_bloom, byte_string) - return self + with cython.critical_section(self): + bloom_from_bytes(self.mem, self.c_bloom, byte_string) + return self + + def _roundtrip(self): + # Purely for testing, since this operation can't be done atomically + # without holding a critical section the entire time. + # Entering the same critical section recursively doesn't release it. + # (see cpython commit 180d417) + with cython.critical_section(self): + self.from_bytes(self.to_bytes()) -cdef char* bloom_to_bytes(const BloomStruct* bloom, vector[key_t]& ret): +cdef bytes bloom_to_bytes(const BloomStruct* bloom): + cdef vector[key_t] ret = vector[key_t]() ret.push_back(bloom.hcount) ret.push_back(bloom.length) ret.push_back(bloom.seed) for i in range(bloom.length // sizeof(key_t)): ret.push_back(bloom.bitfield[i]) - return ret.data() + return (ret.data())[:3*sizeof(key_t) + bloom.length] cdef void bloom_from_bytes(Pool mem, BloomStruct* bloom, bytes data): diff --git a/preshed/tests/test_bloom.py b/preshed/tests/test_bloom.py index 4c38e72..94503e2 100644 --- a/preshed/tests/test_bloom.py +++ b/preshed/tests/test_bloom.py @@ -79,14 +79,10 @@ def worker(chunk): assert ii not in bf bf.add(ii) assert ii in bf - if ii % 100 == 0: - # every tenth iteration - bf.to_bytes() + bf._roundtrip() - tpe = ThreadPoolExecutor(max_workers=n_threads) - - futures = [] - for i, chunk in enumerate(chunks): - futures.append(tpe.submit(worker, chunk)) - - [f.result() for f in futures] + with ThreadPoolExecutor(max_workers=n_threads) as tpe: + futures = [] + for i, chunk in enumerate(chunks): + futures.append(tpe.submit(worker, chunk)) + [f.result() for f in futures] diff --git a/pyproject.toml b/pyproject.toml index e798a0a..af58091 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,8 +2,11 @@ requires = [ "setuptools", "cython>=3.1", - # TODO: update for cymem free-thraded support release before making upstream PR + # needed to get a thread-safe version of cymem + # TODO: move to explosion/cymem when the PR is merged "cymem @ git+https://github.com/lysnikolaou/cymem.git@free-threading", + # needed because murmurhash free-threaded support hasn't been released + # TODO: delete when murmurhash does a release "murmurhash @ git+https://github.com/explosion/murmurhash.git@master", ] build-backend = "setuptools.build_meta" From ff9c6af039d244eee15eddd01aedf83492faf6e1 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Tue, 11 Nov 2025 14:37:29 -0700 Subject: [PATCH 12/21] create a new file for multithreaded tests --- preshed/tests/test_bloom.py | 32 ---------------------- preshed/tests/test_multithreaded.py | 42 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 32 deletions(-) create mode 100644 preshed/tests/test_multithreaded.py diff --git a/preshed/tests/test_bloom.py b/preshed/tests/test_bloom.py index 94503e2..1d27cb4 100644 --- a/preshed/tests/test_bloom.py +++ b/preshed/tests/test_bloom.py @@ -1,9 +1,7 @@ from __future__ import division import pytest import pickle -import threading -from concurrent.futures import ThreadPoolExecutor from preshed.bloom import BloomFilter def test_contains(): @@ -56,33 +54,3 @@ def test_bloom_pickle(): bf2 = pickle.loads(data) for ii in range(0,1000,20): assert ii in bf2 - - -def test_multithreaded_sharing(): - bf = BloomFilter(size=2**16) - n_threads = 8 - vals = list(range(0, 10000, 10)) - n_vals = len(vals) - chunk_size = n_vals//n_threads - assert chunk_size * n_threads == n_vals - chunks = [] - for i in range(0, n_vals, chunk_size): - chunks.append(vals[i: i + chunk_size]) - - b = threading.Barrier(n_threads) - - def worker(chunk): - b.wait() - for ii in chunk: - # exercises __contains__, add, and to_bytes - # all are supposed to be thread-safe - assert ii not in bf - bf.add(ii) - assert ii in bf - bf._roundtrip() - - with ThreadPoolExecutor(max_workers=n_threads) as tpe: - futures = [] - for i, chunk in enumerate(chunks): - futures.append(tpe.submit(worker, chunk)) - [f.result() for f in futures] diff --git a/preshed/tests/test_multithreaded.py b/preshed/tests/test_multithreaded.py new file mode 100644 index 0000000..ba8dddf --- /dev/null +++ b/preshed/tests/test_multithreaded.py @@ -0,0 +1,42 @@ +import threading +import sys +from concurrent.futures import ThreadPoolExecutor + +from preshed.bloom import BloomFilter + + +def run_threaded(chunks, closure): + orig_interval = sys.getswitchinterval() + sys.setswitchinterval(.0000001) + n_threads = len(chunks) + with ThreadPoolExecutor(max_workers=n_threads) as tpe: + futures = [] + b = threading.Barrier(n_threads) + for i, chunk in enumerate(chunks): + futures.append(tpe.submit(closure, b, chunk)) + [f.result() for f in futures] + sys.setswitchinterval(orig_interval) + + +def test_multithreaded_bloom_sharing(): + bf = BloomFilter(size=2**16) + n_threads = 8 + vals = list(range(0, 10000, 10)) + n_vals = len(vals) + chunk_size = n_vals//n_threads + assert chunk_size * n_threads == n_vals + chunks = [] + for i in range(0, n_vals, chunk_size): + chunks.append(vals[i: i + chunk_size]) + + def worker(b, chunk): + b.wait() + for ii in chunk: + # exercises __contains__, add, and to_bytes + # all are supposed to be thread-safe + assert ii not in bf + bf.add(ii) + assert ii in bf + bf._roundtrip() + + run_threaded(chunks, worker) From 330116c180e3f9e942dbde722160af917a13add3 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Tue, 11 Nov 2025 15:25:25 -0700 Subject: [PATCH 13/21] thread safety pass for preshed.map --- preshed/maps.pyx | 39 ++++++++++++++++++++-------- preshed/tests/test_multithreaded.py | 40 +++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/preshed/maps.pyx b/preshed/maps.pyx index 0dcebc5..7cb063b 100644 --- a/preshed/maps.pyx +++ b/preshed/maps.pyx @@ -35,14 +35,22 @@ cdef class PreshMap: property capacity: def __get__(self): - return self.c_map.length + cdef key_t length + with cython.critical_section(self): + length = self.c_map.length + return length def items(self): cdef key_t key cdef void* value cdef int i = 0 - while map_iter(self.c_map, &i, &key, &value): - yield key, value + while True: + with cython.critical_section(self): + it = map_iter(self.c_map, &i, &key, &value) + if it: + yield key, value + else: + break def keys(self): for key, _ in self.items(): @@ -53,31 +61,42 @@ cdef class PreshMap: yield value def pop(self, key_t key, default=None): - cdef Result result = map_get_unless_missing(self.c_map, key) - map_clear(self.c_map, key) + cdef Result result + with cython.critical_section(self): + result = map_get_unless_missing(self.c_map, key) + map_clear(self.c_map, key) if result.found: return result.value else: return default def __getitem__(self, key_t key): - cdef Result result = map_get_unless_missing(self.c_map, key) + cdef Result result + with cython.critical_section(self): + result = map_get_unless_missing(self.c_map, key) if result.found: return result.value else: return None def __setitem__(self, key_t key, size_t value): - map_set(self.mem, self.c_map, key, value) + with cython.critical_section(self): + map_set(self.mem, self.c_map, key, value) def __delitem__(self, key_t key): - map_clear(self.c_map, key) + with cython.critical_section(self): + map_clear(self.c_map, key) def __len__(self): - return self.c_map.filled + cdef key_t filled + with cython.critical_section(self): + filled = self.c_map.filled + return filled def __contains__(self, key_t key): - cdef Result result = map_get_unless_missing(self.c_map, key) + cdef Result result + with cython.critical_section(self): + result = map_get_unless_missing(self.c_map, key) return True if result.found else False def __iter__(self): diff --git a/preshed/tests/test_multithreaded.py b/preshed/tests/test_multithreaded.py index ba8dddf..5d1c4e4 100644 --- a/preshed/tests/test_multithreaded.py +++ b/preshed/tests/test_multithreaded.py @@ -3,6 +3,7 @@ from concurrent.futures import ThreadPoolExecutor from preshed.bloom import BloomFilter +from preshed.maps import PreshMap def run_threaded(chunks, closure): @@ -40,3 +41,42 @@ def worker(b, chunk): bf._roundtrip() run_threaded(chunks, worker) + + +def test_multithreaded_map_sharing(): + h = PreshMap() + n_threads = 8 + keys = list(range(0, 10000, 10)) + vals = list(range(1, 10000, 10)) + n_vals = len(vals) + chunk_size = n_vals//n_threads + assert chunk_size * n_threads == n_vals + chunks = [] + for i in range(0, n_vals, chunk_size): + chunks.append(zip(keys[i: i + chunk_size], vals[i: i + chunk_size])) + assert len(chunks) == n_threads + + def worker(b, chunk): + b.wait() + for k, v in chunk: + # __getitem__ + assert h[k] is None + # __setitem__ + h[k] = v + # __getitem__ again + assert h[k] == v + # items() + for (kk, vv) in h.items(): + # None if another thread removed it + assert h[kk] in (vv, None) + # pop + assert h.pop(k) == v + assert h[k] is None + # __delitem__ + h[k] = v + assert h[k] == v + del h[k] + assert h[k] is None + h[k] = v + + run_threaded(chunks, worker) From 4c7158e017ed4234619517d439ef1493f54af3bb Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Thu, 13 Nov 2025 09:25:55 -0700 Subject: [PATCH 14/21] move depedendencies to upstream cymem and murmurhash to use new releases --- pyproject.toml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index af58091..95dc1bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,12 +2,8 @@ requires = [ "setuptools", "cython>=3.1", - # needed to get a thread-safe version of cymem - # TODO: move to explosion/cymem when the PR is merged - "cymem @ git+https://github.com/lysnikolaou/cymem.git@free-threading", - # needed because murmurhash free-threaded support hasn't been released - # TODO: delete when murmurhash does a release - "murmurhash @ git+https://github.com/explosion/murmurhash.git@master", + "cymem>=2.0.2,<2.1.0", + "murmurhash>=0.28.0,<1.1.0", ] build-backend = "setuptools.build_meta" From 6e28c7ccf8d855981670d183a4c04cc56ecafafa Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Thu, 13 Nov 2025 11:53:02 -0700 Subject: [PATCH 15/21] mark extensions as compatible with the free-threaded build --- .github/workflows/tests.yml | 10 +--------- setup.py | 6 +++++- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4df38cf..6e046ed 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,8 +16,7 @@ env: jobs: tests: name: Test - # TODO: uncomment before upstream PR - # if: github.repository_owner == 'explosion' + if: github.repository_owner == 'explosion' strategy: fail-fast: false matrix: @@ -72,13 +71,6 @@ jobs: run: | python -m pip install -U -r requirements.txt - # TODO: delete when Cython extensions declare themselves compatible - - name: Force-disable GIL on free-threaded builds - if: endsWith(matrix.python_version, 't') - shell: bash - run: | - echo "PYTHON_GIL=0" >> $GITHUB_ENV - - name: Run tests shell: bash run: | diff --git a/setup.py b/setup.py index 283d6b7..840d1c0 100755 --- a/setup.py +++ b/setup.py @@ -99,7 +99,11 @@ 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=2, + compiler_directives={"freethreading_compatible": True}, + ), python_requires=">=3.6,<3.15", install_requires=["cymem>=2.0.2,<2.1.0", "murmurhash>=0.28.0,<1.1.0"], classifiers=[ From 1964bcaae17e3d1c786f20fbad3cba99d7bc1864 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Thu, 13 Nov 2025 12:00:23 -0700 Subject: [PATCH 16/21] update trove classifiers --- setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 840d1c0..6e61f9c 100755 --- a/setup.py +++ b/setup.py @@ -115,13 +115,13 @@ def setup_package(): "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Programming Language :: Cython", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Free Threading :: 2 - Beta", "Topic :: Scientific/Engineering", ], cmdclass={"build_ext": build_ext_subclass}, From b0a3cde1acca1a5bd5c1d3419184296d549aa20c Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 14 Nov 2025 08:23:09 -0700 Subject: [PATCH 17/21] add comments --- preshed/bloom.pyx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/preshed/bloom.pyx b/preshed/bloom.pyx index e703d1f..737e7e0 100644 --- a/preshed/bloom.pyx +++ b/preshed/bloom.pyx @@ -70,12 +70,14 @@ cdef class BloomFilter: cdef bytes bloom_to_bytes(const BloomStruct* bloom): + # local scratch buffer cdef vector[key_t] ret = vector[key_t]() ret.push_back(bloom.hcount) ret.push_back(bloom.length) ret.push_back(bloom.seed) for i in range(bloom.length // sizeof(key_t)): ret.push_back(bloom.bitfield[i]) + # copy data in the scratch buffer into a new bytes object return (ret.data())[:3*sizeof(key_t) + bloom.length] From fcbea6293ee4bc4735586b561288ef6129c43670 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 17 Nov 2025 09:58:56 -0700 Subject: [PATCH 18/21] Apply suggestions from code review Co-authored-by: Guido Imperiale --- preshed/bloom.pxd | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/preshed/bloom.pxd b/preshed/bloom.pxd index ca53090..4771912 100644 --- a/preshed/bloom.pxd +++ b/preshed/bloom.pxd @@ -13,6 +13,7 @@ cdef struct BloomStruct: cdef class BloomFilter: cdef Pool mem cdef BloomStruct* c_bloom + # Thread-unsafe variant of __contains__ cdef inline bint contains(self, key_t item) nogil diff --git a/setup.py b/setup.py index 6e61f9c..5921f17 100755 --- a/setup.py +++ b/setup.py @@ -104,7 +104,7 @@ def setup_package(): language_level=2, compiler_directives={"freethreading_compatible": True}, ), - python_requires=">=3.6,<3.15", + python_requires=">=3.9,<3.15", install_requires=["cymem>=2.0.2,<2.1.0", "murmurhash>=0.28.0,<1.1.0"], classifiers=[ "Environment :: Console", From 9a2e34b727039bbcea084fa3ac8c25fd3c4e9f7d Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 17 Nov 2025 10:14:39 -0700 Subject: [PATCH 19/21] add comments explaining thread safety guarantees --- preshed/bloom.pxd | 3 +++ preshed/bloom.pyx | 1 + preshed/maps.pxd | 8 ++++++-- preshed/maps.pyx | 5 +++++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/preshed/bloom.pxd b/preshed/bloom.pxd index 4771912..f3f178a 100644 --- a/preshed/bloom.pxd +++ b/preshed/bloom.pxd @@ -16,6 +16,9 @@ cdef class BloomFilter: # Thread-unsafe variant of __contains__ cdef inline bint contains(self, key_t item) nogil +# Low-level thread-unsafe C API. +# If you use this API and expose it to Python, you must provide external +# synchronization (e.g. with a lock or critical section). cdef void bloom_init(Pool mem, BloomStruct* bloom, key_t hcount, key_t length, uint32_t seed) except * diff --git a/preshed/bloom.pyx b/preshed/bloom.pyx index 737e7e0..9b9ff4e 100644 --- a/preshed/bloom.pyx +++ b/preshed/bloom.pyx @@ -48,6 +48,7 @@ cdef class BloomFilter: with cython.critical_section(self): return bloom_contains(self.c_bloom, item) + # Requires external synchronization (e.g. a critical section) cdef inline bint contains(self, key_t item) nogil: return bloom_contains(self.c_bloom, item) diff --git a/preshed/maps.pxd b/preshed/maps.pxd index 291d11c..dc6bd4b 100644 --- a/preshed/maps.pxd +++ b/preshed/maps.pxd @@ -2,6 +2,10 @@ from libc.stdint cimport uint64_t from cymem.cymem cimport Pool +# Low-level thread-unsafe C API. +# If you use this API and expose it to Python, you must provide external +# synchronization (e.g. with a lock or critical section). + ctypedef uint64_t key_t @@ -24,7 +28,6 @@ cdef struct MapStruct: bint is_empty_key_set bint is_del_key_set - cdef void* map_bulk_get(const MapStruct* map_, const key_t* keys, void** values, int n) nogil @@ -46,10 +49,11 @@ cdef class PreshMap: cdef MapStruct* c_map cdef Pool mem + # these methods are thread-unsafe and require external synchronization cdef inline void* get(self, key_t key) nogil cdef void set(self, key_t key, void* value) except * - +# note: this class is thread-unsafe without external synchronization cdef class PreshMapArray: cdef Pool mem cdef MapStruct* maps diff --git a/preshed/maps.pyx b/preshed/maps.pyx index 7cb063b..4c0d09b 100644 --- a/preshed/maps.pyx +++ b/preshed/maps.pyx @@ -37,6 +37,8 @@ cdef class PreshMap: def __get__(self): cdef key_t length with cython.critical_section(self): + # This might be atomic on some architectures + # but not everywhere, so needs a lock length = self.c_map.length return length @@ -90,6 +92,8 @@ cdef class PreshMap: def __len__(self): cdef key_t filled with cython.critical_section(self): + # This might be atomic on some architectures + # but not everywhere, so needs a lock filled = self.c_map.filled return filled @@ -103,6 +107,7 @@ cdef class PreshMap: for key in self.keys(): yield key + # thread-unsafe low-level API cdef inline void* get(self, key_t key) nogil: return map_get(self.c_map, key) From bd3732f27699d4e6a38ff25838ec17a157f9ac38 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 17 Nov 2025 10:17:15 -0700 Subject: [PATCH 20/21] Describe thread safety guarantees in the readme --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index b1091ea..51b6fcc 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ Simple but high performance Cython hash table mapping pre-randomized keys to `void*` values. Inspired by [Jeff Preshing](http://preshing.com/20130107/this-hash-table-is-faster-than-a-judy-array/). +All Python APIs provded by the `BloomFilter` and `PreshedMap` classes are +thread-safe on both the GIL-enabled build and the free-threaded build of Python +3.14 and newer. If you use the C API, you must provide external syncrhonization +if you use the data structures by this library in a multithreaded environment. + [![tests](https://github.com/explosion/preshed/actions/workflows/tests.yml/badge.svg)](https://github.com/explosion/preshed/actions/workflows/tests.yml) [![pypi Version](https://img.shields.io/pypi/v/preshed.svg?style=flat-square&logo=pypi&logoColor=white)](https://pypi.python.org/pypi/preshed) [![conda Version](https://img.shields.io/conda/vn/conda-forge/preshed.svg?style=flat-square&logo=conda-forge&logoColor=white)](https://anaconda.org/conda-forge/preshed) From 5cded5088bb421a5fc3cdc140fe5c828b91186ed Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 17 Nov 2025 10:32:20 -0700 Subject: [PATCH 21/21] spelling and mention PreshCounter --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 51b6fcc..5852a6e 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ Simple but high performance Cython hash table mapping pre-randomized keys to `void*` values. Inspired by [Jeff Preshing](http://preshing.com/20130107/this-hash-table-is-faster-than-a-judy-array/). -All Python APIs provded by the `BloomFilter` and `PreshedMap` classes are +All Python APIs provded by the `BloomFilter` and `PreshMap` classes are thread-safe on both the GIL-enabled build and the free-threaded build of Python -3.14 and newer. If you use the C API, you must provide external syncrhonization -if you use the data structures by this library in a multithreaded environment. +3.14 and newer. If you use the C API or the `PreshCounter` class, you must +provide external synchronization if you use the data structures by this library +in a multithreaded environment. [![tests](https://github.com/explosion/preshed/actions/workflows/tests.yml/badge.svg)](https://github.com/explosion/preshed/actions/workflows/tests.yml) [![pypi Version](https://img.shields.io/pypi/v/preshed.svg?style=flat-square&logo=pypi&logoColor=white)](https://pypi.python.org/pypi/preshed)