Skip to content

Commit 9699a34

Browse files
authored
Merge pull request #54 from ngoldbaum/ft-staging
Support the free-threaded build
2 parents 465ad7d + 5cded50 commit 9699a34

File tree

9 files changed

+182
-48
lines changed

9 files changed

+182
-48
lines changed

.github/workflows/tests.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,16 @@ jobs:
2020
strategy:
2121
fail-fast: false
2222
matrix:
23-
os: [ubuntu-latest, windows-latest, macos-13]
24-
python_version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
23+
os: [ubuntu-latest, windows-latest, macos-15-intel]
24+
python_version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"]
2525
runs-on: ${{ matrix.os }}
2626

2727
steps:
2828
- name: Check out repo
2929
uses: actions/checkout@v3
3030

3131
- name: Configure Python version
32-
uses: actions/setup-python@v4
32+
uses: actions/setup-python@v6
3333
with:
3434
python-version: ${{ matrix.python_version }}
3535
architecture: x64

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ Simple but high performance Cython hash table mapping pre-randomized keys to
66
`void*` values. Inspired by
77
[Jeff Preshing](http://preshing.com/20130107/this-hash-table-is-faster-than-a-judy-array/).
88

9+
All Python APIs provded by the `BloomFilter` and `PreshMap` classes are
10+
thread-safe on both the GIL-enabled build and the free-threaded build of Python
11+
3.14 and newer. If you use the C API or the `PreshCounter` class, you must
12+
provide external synchronization if you use the data structures by this library
13+
in a multithreaded environment.
14+
915
[![tests](https://github.com/explosion/preshed/actions/workflows/tests.yml/badge.svg)](https://github.com/explosion/preshed/actions/workflows/tests.yml)
1016
[![pypi Version](https://img.shields.io/pypi/v/preshed.svg?style=flat-square&logo=pypi&logoColor=white)](https://pypi.python.org/pypi/preshed)
1117
[![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)

preshed/bloom.pxd

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ cdef struct BloomStruct:
1313
cdef class BloomFilter:
1414
cdef Pool mem
1515
cdef BloomStruct* c_bloom
16+
# Thread-unsafe variant of __contains__
1617
cdef inline bint contains(self, key_t item) nogil
1718

19+
# Low-level thread-unsafe C API.
20+
# If you use this API and expose it to Python, you must provide external
21+
# synchronization (e.g. with a lock or critical section).
1822

1923
cdef void bloom_init(Pool mem, BloomStruct* bloom, key_t hcount, key_t length, uint32_t seed) except *
2024

21-
cdef void bloom_add(BloomStruct* bloom, key_t item) nogil
22-
2325
cdef bint bloom_contains(const BloomStruct* bloom, key_t item) nogil
2426

2527
cdef void bloom_add(BloomStruct* bloom, key_t item) nogil

preshed/bloom.pyx

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ from murmurhash.mrmr cimport hash128_x86
55
import math
66
from array import array
77

8+
cimport cython
9+
10+
from libcpp.vector cimport vector
11+
812
try:
913
import copy_reg
1014
except ImportError:
@@ -37,48 +41,56 @@ cdef class BloomFilter:
3741
return cls(*params)
3842

3943
def add(self, key_t item):
40-
bloom_add(self.c_bloom, item)
44+
with cython.critical_section(self):
45+
bloom_add(self.c_bloom, item)
4146

42-
def __contains__(self, item):
43-
return bloom_contains(self.c_bloom, item)
47+
def __contains__(self, key_t item):
48+
with cython.critical_section(self):
49+
return bloom_contains(self.c_bloom, item)
4450

51+
# Requires external synchronization (e.g. a critical section)
4552
cdef inline bint contains(self, key_t item) nogil:
4653
return bloom_contains(self.c_bloom, item)
4754

4855
def to_bytes(self):
49-
return bloom_to_bytes(self.c_bloom)
56+
with cython.critical_section(self):
57+
return bloom_to_bytes(self.c_bloom)
5058

5159
def from_bytes(self, bytes byte_string):
52-
bloom_from_bytes(self.mem, self.c_bloom, byte_string)
53-
return self
60+
with cython.critical_section(self):
61+
bloom_from_bytes(self.mem, self.c_bloom, byte_string)
62+
return self
63+
64+
def _roundtrip(self):
65+
# Purely for testing, since this operation can't be done atomically
66+
# without holding a critical section the entire time.
67+
# Entering the same critical section recursively doesn't release it.
68+
# (see cpython commit 180d417)
69+
with cython.critical_section(self):
70+
self.from_bytes(self.to_bytes())
5471

5572

5673
cdef bytes bloom_to_bytes(const BloomStruct* bloom):
57-
py = array("L")
58-
py.append(bloom.hcount)
59-
py.append(bloom.length)
60-
py.append(bloom.seed)
74+
# local scratch buffer
75+
cdef vector[key_t] ret = vector[key_t]()
76+
ret.push_back(bloom.hcount)
77+
ret.push_back(bloom.length)
78+
ret.push_back(<key_t>bloom.seed)
6179
for i in range(bloom.length // sizeof(key_t)):
62-
py.append(bloom.bitfield[i])
63-
if hasattr(py, "tobytes"):
64-
return py.tobytes()
65-
else:
66-
# Python 2 :(
67-
return py.tostring()
80+
ret.push_back(bloom.bitfield[i])
81+
# copy data in the scratch buffer into a new bytes object
82+
return (<char *>ret.data())[:3*sizeof(key_t) + bloom.length]
6883

6984

7085
cdef void bloom_from_bytes(Pool mem, BloomStruct* bloom, bytes data):
71-
py = array("L")
72-
if hasattr(py, "frombytes"):
73-
py.frombytes(data)
74-
else:
75-
py.fromstring(data)
76-
bloom.hcount = py[0]
77-
bloom.length = py[1]
78-
bloom.seed = py[2]
86+
cdef char* c_data = data;
87+
cdef key_t* i_data = <key_t*>c_data;
88+
bloom.hcount = i_data[0]
89+
bloom.length = i_data[1]
90+
bloom.seed = <uint32_t>i_data[2]
7991
bloom.bitfield = <key_t*>mem.alloc(bloom.length // sizeof(key_t), sizeof(key_t))
8092
for i in range(bloom.length // sizeof(key_t)):
81-
bloom.bitfield[i] = py[3+i]
93+
bloom.bitfield[i] = i_data[3+i]
8294

8395

8496
cdef void bloom_init(Pool mem, BloomStruct* bloom, key_t hcount, key_t length, uint32_t seed) except *:

preshed/maps.pxd

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ from libc.stdint cimport uint64_t
22
from cymem.cymem cimport Pool
33

44

5+
# Low-level thread-unsafe C API.
6+
# If you use this API and expose it to Python, you must provide external
7+
# synchronization (e.g. with a lock or critical section).
8+
59
ctypedef uint64_t key_t
610

711

@@ -24,7 +28,6 @@ cdef struct MapStruct:
2428
bint is_empty_key_set
2529
bint is_del_key_set
2630

27-
2831
cdef void* map_bulk_get(const MapStruct* map_, const key_t* keys, void** values,
2932
int n) nogil
3033

@@ -46,10 +49,11 @@ cdef class PreshMap:
4649
cdef MapStruct* c_map
4750
cdef Pool mem
4851

52+
# these methods are thread-unsafe and require external synchronization
4953
cdef inline void* get(self, key_t key) nogil
5054
cdef void set(self, key_t key, void* value) except *
5155

52-
56+
# note: this class is thread-unsafe without external synchronization
5357
cdef class PreshMapArray:
5458
cdef Pool mem
5559
cdef MapStruct* maps

preshed/maps.pyx

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,24 @@ cdef class PreshMap:
3535

3636
property capacity:
3737
def __get__(self):
38-
return self.c_map.length
38+
cdef key_t length
39+
with cython.critical_section(self):
40+
# This might be atomic on some architectures
41+
# but not everywhere, so needs a lock
42+
length = self.c_map.length
43+
return length
3944

4045
def items(self):
4146
cdef key_t key
4247
cdef void* value
4348
cdef int i = 0
44-
while map_iter(self.c_map, &i, &key, &value):
45-
yield key, <size_t>value
49+
while True:
50+
with cython.critical_section(self):
51+
it = map_iter(self.c_map, &i, &key, &value)
52+
if it:
53+
yield key, <size_t>value
54+
else:
55+
break
4656

4757
def keys(self):
4858
for key, _ in self.items():
@@ -53,37 +63,51 @@ cdef class PreshMap:
5363
yield value
5464

5565
def pop(self, key_t key, default=None):
56-
cdef Result result = map_get_unless_missing(self.c_map, key)
57-
map_clear(self.c_map, key)
66+
cdef Result result
67+
with cython.critical_section(self):
68+
result = map_get_unless_missing(self.c_map, key)
69+
map_clear(self.c_map, key)
5870
if result.found:
5971
return <size_t>result.value
6072
else:
6173
return default
6274

6375
def __getitem__(self, key_t key):
64-
cdef Result result = map_get_unless_missing(self.c_map, key)
76+
cdef Result result
77+
with cython.critical_section(self):
78+
result = map_get_unless_missing(self.c_map, key)
6579
if result.found:
6680
return <size_t>result.value
6781
else:
6882
return None
6983

7084
def __setitem__(self, key_t key, size_t value):
71-
map_set(self.mem, self.c_map, key, <void*>value)
85+
with cython.critical_section(self):
86+
map_set(self.mem, self.c_map, key, <void*>value)
7287

7388
def __delitem__(self, key_t key):
74-
map_clear(self.c_map, key)
89+
with cython.critical_section(self):
90+
map_clear(self.c_map, key)
7591

7692
def __len__(self):
77-
return self.c_map.filled
93+
cdef key_t filled
94+
with cython.critical_section(self):
95+
# This might be atomic on some architectures
96+
# but not everywhere, so needs a lock
97+
filled = self.c_map.filled
98+
return filled
7899

79100
def __contains__(self, key_t key):
80-
cdef Result result = map_get_unless_missing(self.c_map, key)
101+
cdef Result result
102+
with cython.critical_section(self):
103+
result = map_get_unless_missing(self.c_map, key)
81104
return True if result.found else False
82105

83106
def __iter__(self):
84107
for key in self.keys():
85108
yield key
86109

110+
# thread-unsafe low-level API
87111
cdef inline void* get(self, key_t key) nogil:
88112
return map_get(self.c_map, key)
89113

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import threading
2+
import sys
3+
from concurrent.futures import ThreadPoolExecutor
4+
5+
from preshed.bloom import BloomFilter
6+
from preshed.maps import PreshMap
7+
8+
9+
def run_threaded(chunks, closure):
10+
orig_interval = sys.getswitchinterval()
11+
sys.setswitchinterval(.0000001)
12+
n_threads = len(chunks)
13+
with ThreadPoolExecutor(max_workers=n_threads) as tpe:
14+
futures = []
15+
b = threading.Barrier(n_threads)
16+
for i, chunk in enumerate(chunks):
17+
futures.append(tpe.submit(closure, b, chunk))
18+
[f.result() for f in futures]
19+
sys.setswitchinterval(orig_interval)
20+
21+
22+
def test_multithreaded_bloom_sharing():
23+
bf = BloomFilter(size=2**16)
24+
n_threads = 8
25+
vals = list(range(0, 10000, 10))
26+
n_vals = len(vals)
27+
chunk_size = n_vals//n_threads
28+
assert chunk_size * n_threads == n_vals
29+
chunks = []
30+
for i in range(0, n_vals, chunk_size):
31+
chunks.append(vals[i: i + chunk_size])
32+
33+
def worker(b, chunk):
34+
b.wait()
35+
for ii in chunk:
36+
# exercises __contains__, add, and to_bytes
37+
# all are supposed to be thread-safe
38+
assert ii not in bf
39+
bf.add(ii)
40+
assert ii in bf
41+
bf._roundtrip()
42+
43+
run_threaded(chunks, worker)
44+
45+
46+
def test_multithreaded_map_sharing():
47+
h = PreshMap()
48+
n_threads = 8
49+
keys = list(range(0, 10000, 10))
50+
vals = list(range(1, 10000, 10))
51+
n_vals = len(vals)
52+
chunk_size = n_vals//n_threads
53+
assert chunk_size * n_threads == n_vals
54+
chunks = []
55+
for i in range(0, n_vals, chunk_size):
56+
chunks.append(zip(keys[i: i + chunk_size], vals[i: i + chunk_size]))
57+
assert len(chunks) == n_threads
58+
59+
def worker(b, chunk):
60+
b.wait()
61+
for k, v in chunk:
62+
# __getitem__
63+
assert h[k] is None
64+
# __setitem__
65+
h[k] = v
66+
# __getitem__ again
67+
assert h[k] == v
68+
# items()
69+
for (kk, vv) in h.items():
70+
# None if another thread removed it
71+
assert h[kk] in (vv, None)
72+
# pop
73+
assert h.pop(k) == v
74+
assert h[k] is None
75+
# __delitem__
76+
h[k] = v
77+
assert h[k] == v
78+
del h[k]
79+
assert h[k] is None
80+
h[k] = v
81+
82+
run_threaded(chunks, worker)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[build-system]
22
requires = [
33
"setuptools",
4-
"cython>=0.28",
4+
"cython>=3.1",
55
"cymem>=2.0.2,<2.1.0",
66
"murmurhash>=0.28.0,<1.1.0",
77
]

setup.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,12 @@ def setup_package():
9999
version=about["__version__"],
100100
url=about["__uri__"],
101101
license=about["__license__"],
102-
ext_modules=cythonize(ext_modules, language_level=2),
103-
python_requires=">=3.6,<3.14",
102+
ext_modules=cythonize(
103+
ext_modules,
104+
language_level=2,
105+
compiler_directives={"freethreading_compatible": True},
106+
),
107+
python_requires=">=3.9,<3.15",
104108
install_requires=["cymem>=2.0.2,<2.1.0", "murmurhash>=0.28.0,<1.1.0"],
105109
classifiers=[
106110
"Environment :: Console",
@@ -111,13 +115,13 @@ def setup_package():
111115
"Operating System :: MacOS :: MacOS X",
112116
"Operating System :: Microsoft :: Windows",
113117
"Programming Language :: Cython",
114-
"Programming Language :: Python :: 3.6",
115-
"Programming Language :: Python :: 3.7",
116-
"Programming Language :: Python :: 3.8",
117118
"Programming Language :: Python :: 3.9",
118119
"Programming Language :: Python :: 3.10",
119120
"Programming Language :: Python :: 3.11",
120121
"Programming Language :: Python :: 3.12",
122+
"Programming Language :: Python :: 3.13",
123+
"Programming Language :: Python :: 3.14",
124+
"Programming Language :: Python :: Free Threading :: 2 - Beta",
121125
"Topic :: Scientific/Engineering",
122126
],
123127
cmdclass={"build_ext": build_ext_subclass},

0 commit comments

Comments
 (0)