Skip to content

Commit 8c5d214

Browse files
authored
fix: ensure we do not segfault when using threads and nogil (#486)
* fix: ensure we do not segfault when using threads with nogil * style: pre-commit * doc: clarify changelog * prod: run testing on slim images * fix: this does notwork
1 parent 0b852f9 commit 8c5d214

File tree

5 files changed

+252
-36
lines changed

5 files changed

+252
-36
lines changed

.github/workflows/tests-external-cfitsio.yml

Lines changed: 36 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,19 @@ jobs:
3030
os: [macos-latest, ubuntu-latest]
3131
config:
3232
# 3.44 is the last version that did not support uint64
33-
- { pyver: "3.9", npver: "1", cftsver: "3440", doslow: ""}
33+
- { pyver: "3.9", npver: "1", cftsver: "3440", doslow: "", ft: "", cf: ""}
3434
# python 3 string writing fails on all 3.* builds
3535
# so test first 4.* release
36-
- { pyver: "3.9", npver: "1", cftsver: "-4.0.0", doslow: ""}
36+
- { pyver: "3.9", npver: "1", cftsver: "-4.0.0", doslow: "", ft: "", cf: ""}
3737
# 4.1.0 is the first version for which tests pass for uint64
38-
- { pyver: "3.9", npver: "1", cftsver: "-4.1.0", doslow: ""}
38+
- { pyver: "3.9", npver: "1", cftsver: "-4.1.0", doslow: "", ft: "", cf: ""}
3939
# 4.4.0 is first version for which tests pass for compressed
4040
# binary tables that exceed 2**32 bytes
41-
- { pyver: "3.9", npver: "1", cftsver: "-4.4.0", doslow: "--slow"}
42-
- { pyver: "3.9", npver: "1", cftsver: "latest", doslow: "--slow"}
43-
- { pyver: "3.14", npver: "2", cftsver: "latest", doslow: "--slow"}
41+
- { pyver: "3.9", npver: "1", cftsver: "-4.4.0", doslow: "--slow", ft: "", cf: ""}
42+
- { pyver: "3.9", npver: "1", cftsver: "latest", doslow: "--slow", ft: "", cf: ""}
43+
- { pyver: "3.14", npver: "2", cftsver: "latest", doslow: "--slow", ft: "python-gil", cf: "--enable-reentrant"}
44+
- { pyver: "3.14", npver: "2", cftsver: "latest", doslow: "--slow", ft: "python-freethreading", cf: ""}
45+
- { pyver: "3.14", npver: "2", cftsver: "latest", doslow: "--slow", ft: "python-freethreading", cf: "--enable-reentrant"}
4446

4547
runs-on: ${{ matrix.os }}
4648
env:
@@ -74,33 +76,46 @@ jobs:
7476
create-args: >-
7577
python=${{ matrix.config.pyver }}
7678
numpy=${{ matrix.config.npver }}
79+
${{ matrix.config.ft }}
7780
78-
- name: build external cfitsio
81+
- name: set parallel testing flags
7982
run: |
80-
export CFLAGS="${CFLAGS} ${TEST_CFLAGS}"
81-
82-
mkdir -p cfitsio-external-build
83-
cd cfitsio-external-build
84-
rm -rf *
83+
if [[ "${{ matrix.config.ft }}" == "python-freethreading" ]]; then
84+
echo "PRP_FLAGS=--parallel-threads 4 --iterations 4 --ignore-gil-enabled" >> ${GITHUB_ENV}
85+
fi
8586
87+
- name: set cfitsio config flags and version
88+
run: |
8689
if [[ "${{ matrix.config.cftsver }}" == "latest" ]]; then
8790
cftsver=${LATEST_CFITSIO_VER}
8891
else
8992
cftsver="${{ matrix.config.cftsver }}"
9093
fi
9194
95+
echo "CFITSIO_VER=${cftsver}" >> ${GITHUB_ENV}
96+
9297
if [[ "${{ matrix.config.cftsver }}" == *3* ]]; then
93-
config_flags=""
98+
config_flags="${{ matrix.config.cf }}"
9499
else
95-
config_flags="--without-fortran --disable-shared"
100+
config_flags="--without-fortran --disable-shared ${{ matrix.config.cf }}"
96101
fi
97102
98-
cfitsio_name=cfitsio${cftsver}
103+
echo "CFITSIO_CONFIG_FLAGS=${config_flags}" >> ${GITHUB_ENV}
104+
105+
- name: build external cfitsio
106+
run: |
107+
export CFLAGS="${CFLAGS} ${TEST_CFLAGS} -fPIC"
108+
109+
mkdir -p cfitsio-external-build
110+
cd cfitsio-external-build
111+
rm -rf *
112+
113+
cfitsio_name=cfitsio${CFITSIO_VER}
99114
wget https://heasarc.gsfc.nasa.gov/FTP/software/fitsio/c/${cfitsio_name}.tar.gz
100115
cfitsio_dir=`tar -tzf ${cfitsio_name}.tar.gz | sed -n "1,1p" | cut -f1 -d"/"`
101116
tar -xzvf ${cfitsio_name}.tar.gz
102117
cd ${cfitsio_dir}
103-
CFLAGS="-fPIC" ./configure --prefix=$HOME/cfitsio-static-install ${config_flags}
118+
./configure --prefix=$HOME/cfitsio-static-install ${CFITSIO_CONFIG_FLAGS}
104119
make install -j 4
105120
cd ..
106121
cd ..
@@ -121,7 +136,7 @@ jobs:
121136
python -c "import fitsio; assert fitsio.cfitsio_has_curl_support()"
122137
fi
123138
124-
pytest -vv fitsio
139+
pytest -vv ${PRP_FLAGS} fitsio
125140
126141
- name: install bzip2 on linux
127142
if: matrix.os == 'ubuntu-latest'
@@ -130,30 +145,18 @@ jobs:
130145
131146
- name: build external cfitsio w/ bzip2
132147
run: |
133-
export CFLAGS="${CFLAGS} ${TEST_CFLAGS}"
148+
export CFLAGS="${CFLAGS} ${TEST_CFLAGS} -fPIC"
134149
135150
mkdir -p cfitsio-external-build
136151
cd cfitsio-external-build
137152
rm -rf *
138153
139-
if [[ "${{ matrix.config.cftsver }}" == "latest" ]]; then
140-
cftsver=${LATEST_CFITSIO_VER}
141-
else
142-
cftsver="${{ matrix.config.cftsver }}"
143-
fi
144-
145-
if [[ "${{ matrix.config.cftsver }}" == *3440* ]]; then
146-
config_flags="--with-bzip2"
147-
else
148-
config_flags="--without-fortran --disable-shared --with-bzip2"
149-
fi
150-
151-
cfitsio_name=cfitsio${cftsver}
154+
cfitsio_name=cfitsio${CFITSIO_VER}
152155
wget https://heasarc.gsfc.nasa.gov/FTP/software/fitsio/c/${cfitsio_name}.tar.gz
153156
cfitsio_dir=`tar -tzf ${cfitsio_name}.tar.gz | sed -n "1,1p" | cut -f1 -d"/"`
154157
tar -xzvf ${cfitsio_name}.tar.gz
155158
cd ${cfitsio_dir}
156-
CFLAGS="-fPIC" ./configure --prefix=$HOME/cfitsio-static-install ${config_flags}
159+
./configure --prefix=$HOME/cfitsio-static-install ${CFITSIO_CONFIG_FLAGS} --with-bzip2
157160
make install -j 4
158161
cd ..
159162
cd ..
@@ -177,4 +180,4 @@ jobs:
177180
python -c "import fitsio; assert fitsio.cfitsio_has_curl_support()"
178181
fi
179182
180-
pytest -vv ${{ matrix.config.doslow }} fitsio
183+
pytest -vv ${{ matrix.config.doslow }} ${PRP_FLAGS} fitsio

.github/workflows/tests-pypi.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
strategy:
2323
fail-fast: false
2424
matrix:
25-
os: [macos-latest, ubuntu-22.04]
25+
os: [macos-latest, ubuntu-latest]
2626
pyver: ["3.9", "3.14"]
2727
runs-on: ${{ matrix.os }}
2828

CHANGES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Changes
1515
- Marked some tests as slow to speed up testing. Pass `--slow` to
1616
pytest to run them.
1717
- Added python 3.14 to the CI config.
18-
- Added testing against free threaded builds of python.
18+
- Added support and testing against free threaded builds of python.
1919

2020
Bug Fixes
2121

fitsio/fitsio_pywrap.c

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5433,7 +5433,9 @@ init_fitsio_wrap(void)
54335433

54345434
#if PY_MAJOR_VERSION >= 3
54355435
#ifdef Py_GIL_DISABLED
5436-
PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
5436+
if (fits_is_reentrant() != 0) {
5437+
PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
5438+
}
54375439
#endif
54385440
#endif
54395441

fitsio/tests/test_threading.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
from concurrent.futures import ThreadPoolExecutor, as_completed
2+
import os
3+
import tempfile
4+
import time
5+
6+
import numpy as np
7+
import fitsio
8+
9+
import pytest
10+
11+
SIZE = 5000
12+
DATA = np.zeros((SIZE, SIZE), dtype='f8')
13+
DATA[:] = -1
14+
15+
16+
def create_file(fname):
17+
with fitsio.FITS(fname, 'rw') as fits:
18+
fits.write_image(DATA)
19+
fits[0].write_checksum()
20+
fits.read_raw()
21+
22+
23+
def read_file(fname):
24+
with fitsio.FITS(fname, 'r') as fits:
25+
fits[0].verify_checksum()
26+
fits[0].read()
27+
28+
29+
def test_threading_works():
30+
"""
31+
Test a basic image write, data and a header, then reading back in to
32+
check the values
33+
"""
34+
nt = 32
35+
36+
with tempfile.TemporaryDirectory() as tmpdir:
37+
filenames = [
38+
os.path.join(tmpdir, "fname%d.fits" % i) for i in range(nt)
39+
]
40+
41+
def _create_file(i):
42+
fname = filenames[i]
43+
data = np.zeros((32, 32), dtype='f8')
44+
data[:] = i
45+
with fitsio.FITS(fname, 'rw', clobber=True) as fits:
46+
fits.write_image(data)
47+
fits[0].write_checksum()
48+
49+
def _read_file(i):
50+
fname = filenames[i]
51+
with fitsio.FITS(fname, 'r') as fits:
52+
fits[0].verify_checksum()
53+
assert (fits[0].read() == i).all()
54+
55+
with ThreadPoolExecutor(max_workers=nt) as pool:
56+
for _ in pool.map(_create_file, range(nt)):
57+
pass
58+
for _ in pool.map(_read_file, range(nt)):
59+
pass
60+
61+
62+
@pytest.mark.slow
63+
@pytest.mark.xfail(reason="Threading performance might be flaky!")
64+
@pytest.mark.parametrize(
65+
"write_only,read_only",
66+
[
67+
(False, True),
68+
(True, False),
69+
(False, False),
70+
],
71+
)
72+
@pytest.mark.parametrize("klass", [ThreadPoolExecutor])
73+
def test_threading_timing(klass, write_only, read_only):
74+
"""
75+
Test a basic image write, data and a header, then reading back in to
76+
check the values
77+
"""
78+
nt = 2
79+
fac = 2
80+
81+
if read_only:
82+
print("\nread only", flush=True)
83+
elif write_only:
84+
print("\nwrite only", flush=True)
85+
else:
86+
print("\nread and write", flush=True)
87+
88+
with tempfile.TemporaryDirectory() as tmpdir:
89+
filenames = [
90+
os.path.join(tmpdir, "fname%d.fits" % i) for i in range(nt * fac)
91+
]
92+
93+
def _remove_files():
94+
for fname in filenames:
95+
try:
96+
os.remove(fname)
97+
except Exception:
98+
pass
99+
100+
if read_only:
101+
for fname in filenames:
102+
create_file(fname)
103+
104+
t0 = time.time()
105+
if not read_only:
106+
create_file(filenames[0])
107+
if not write_only:
108+
read_file(filenames[0])
109+
t0_one = time.time() - t0
110+
print("one file time:", t0_one, flush=True)
111+
if not read_only:
112+
_remove_files()
113+
114+
t0 = time.time()
115+
with klass(max_workers=nt) as pool:
116+
if not read_only:
117+
futs = [
118+
pool.submit(create_file, filenames[i])
119+
for i in range(nt * fac)
120+
]
121+
for fut in as_completed(futs):
122+
fut.result()
123+
if not write_only:
124+
futs = [
125+
pool.submit(read_file, filenames[i])
126+
for i in range(nt * fac)
127+
]
128+
for fut in as_completed(futs):
129+
fut.result()
130+
t0_threads = time.time() - t0
131+
print(
132+
"parallel time / one file time",
133+
t0_threads / t0_one,
134+
"(perfect is %d)" % fac,
135+
flush=True,
136+
)
137+
if not read_only:
138+
_remove_files()
139+
140+
t0 = time.time()
141+
if not read_only:
142+
for fname in filenames:
143+
create_file(fname)
144+
if not write_only:
145+
for fname in filenames:
146+
read_file(fname)
147+
t0_serial = time.time() - t0
148+
print(
149+
"serial time / one file time:",
150+
t0_serial / t0_one,
151+
f"(should be about {nt * fac})",
152+
flush=True,
153+
)
154+
155+
if read_only:
156+
assert t0_threads < t0_serial, (
157+
"Threading should be faster than serial! ( %f < %f)"
158+
% (t0_threads, t0_serial)
159+
)
160+
161+
162+
@pytest.mark.slow
163+
@pytest.mark.xfail(reason="Threading performance might be flaky!")
164+
def test_threading_read_one_file():
165+
nt = 4
166+
167+
with tempfile.TemporaryDirectory() as tmpdir:
168+
fname = os.path.join(tmpdir, "fname.fits")
169+
with fitsio.FITS(fname, 'rw', clobber=True) as fits:
170+
fits.write_image(np.concatenate([DATA]), compress="GZIP", qlevel=0)
171+
fits[0].write_checksum()
172+
173+
def _read_file(fname):
174+
with fitsio.FITS(fname, 'r') as fits:
175+
fits[0].verify_checksum()
176+
assert (fits[1].read() == -1).all()
177+
return True
178+
179+
t0 = time.time()
180+
_read_file(fname)
181+
t0_one = time.time() - t0
182+
print("\none file time:", t0_one, flush=True)
183+
184+
t0 = time.time()
185+
with ThreadPoolExecutor(max_workers=nt) as pool:
186+
futs = [pool.submit(_read_file, fname) for _ in range(nt)]
187+
188+
assert all([fut.result() for fut in as_completed(futs)])
189+
t0_threads = time.time() - t0
190+
print(
191+
"parallel time / one file time",
192+
t0_threads / t0_one,
193+
"(perfect is 1)",
194+
flush=True,
195+
)
196+
197+
t0 = time.time()
198+
for _ in range(nt):
199+
_read_file(fname)
200+
t0_serial = time.time() - t0
201+
print(
202+
"serial time / one file time:",
203+
t0_serial / t0_one,
204+
f"(should be about {nt})",
205+
flush=True,
206+
)
207+
208+
assert t0_threads < t0_serial, (
209+
"Threading should be faster than serial! ( %f < %f)"
210+
% (t0_threads, t0_serial)
211+
)

0 commit comments

Comments
 (0)