Test scikit-image on free-threaded Python using pytest-run-parallel#7678
Test scikit-image on free-threaded Python using pytest-run-parallel#7678andfoy wants to merge 21 commits intoscikit-image:mainfrom
Conversation
|
Thanks @andfoy! Can you explain a bit of which tests are / aren't thread safe? |
|
@stefanv, the main bulk of "thread-unsafe" tests are the ones that depend on some kind of warning capture logic, given that the warnings module is not thread-safe, those tests are bound to fail under concurrent conditions, see python/cpython#91505. Other tests use the general NumPy random number generator, as opposed to using of the local generator interfaces, e.g., There was a single instance of a module that used global shared state variables and not local ones: Finally, there is a set of C-modules that use the stdlib random generator function calls, which are not thread-safe, those need to be refactored in order to use a local state generator: |
|
Do you know how to address those last two items? I'm happy to get all this in, but would prefer if we don't merge items requiring later TODOs that we don't know how to handle. |
.github/workflows/tests.yml
Outdated
| # TODO: replace with setup-python when there is support | ||
| - uses: deadsnakes/action@6c8b9b82fe0b4344f4b98f2775fcc395df45e494 # v3.1.0 | ||
| # TODO: replace with setup-python when there is support | ||
| - uses: Quansight-Labs/setup-python@b9ab292c751a42bcd2bb465b7fa202ea2c3f5796 # v5.3.1 |
There was a problem hiding this comment.
you might also try with setup-uv
|
Out of curiosity, how much slower does this make the free-threaded CI run? |
|
I significantly reworked the CI over the past few days, so unfortunately you'll have a few merge conflicts. On the bright side, it should now be easier to follow what's happening, and how to adapt the various scripts. |
361487d to
2c96b36
Compare
|
According to this test-run attempt https://github.com/scikit-image/scikit-image/actions/runs/13117156401, the action |
I added it. Should work now. :) |
|
Should remember to remove the deadsnakes one, once this is merged. 😬 |
b434448 to
24114e6
Compare
c583fd6 to
67c9828
Compare
Address failing tests Address failing test Mark test as thread_unsafe Mark test as thred_unsafe Debug via SSH Mark test_rotate as thread_unsafe
cbdfb18 to
935821a
Compare
Schefflera-Arboricola
left a comment
There was a problem hiding this comment.
test_var in test_texture.py::TestLBP needs a @pytest.mark.thread_unsafe.
lagru
left a comment
There was a problem hiding this comment.
Thank you @andfoy for tackling this. I'm happy to get rid of global state and random number generation. I haven't gotten through everything yet.
A general question, how obvious is it that a test isn't thread-safe in case we forget to mark it such?
skimage/_shared/testing.py
Outdated
| testing.assert_allclose(r5, img5) | ||
|
|
||
|
|
||
| FETCH_LOCK = threading.Lock() |
There was a problem hiding this comment.
Nitpick: should probably be named _FETCH_LOCK.
| with expected_warnings(["alpha cannot be safely cast to image dtype"]): | ||
| rgba = gray2rgba(img_u8, alpha) | ||
| assert_equal(rgba[..., :3], gray2rgb(img_u8)) | ||
| if num_parallel_threads == 1: |
There was a problem hiding this comment.
Any specific reason for using this approach here instead of marking the whole test as thread_unsafe? The latter seems like the simpler option which also doesn't require defining a fallback fixture for num_parallel_threads.
There was a problem hiding this comment.
Given that the test is checking for other conditions rather other than the warning, I thought it was a good idea not losing that coverage? I can skip it, however maybe the warning-specific test could be moved to its own separate test?
|
|
||
|
|
||
| @pytest.mark.thread_unsafe | ||
| def test_download_all_with_pooch(): |
There was a problem hiding this comment.
To clarify, I'm not sure why this one isn't thread-safe? I assume our pooch code isn't thread-safe? In that case, we should probably make it so!
There was a problem hiding this comment.
The issue is related to the fact of multiple threads trying to write to the same file, however, in practice it would be sound to delegate downloads to a single thread, thus testing such functionality in parallel would not make much sense. A separate question would be if pooch spawns multiple threads to accelerate downloads, but that's a different question.
| img = np.ones((5, 5), dtype=np.int8) | ||
| img_ii = integral_image(img) | ||
| if isinstance(feature_type, list): | ||
| feature_type = list(feature_type) |
There was a problem hiding this comment.
Nitpick: Make feature_type a tuple from the start in parametrize instead?
There was a problem hiding this comment.
The extra list is used to take a copy of feature_type, which is being mutated with shuffle afterwards
|
|
||
|
|
||
| @pytest.mark.thread_unsafe | ||
| def test_basic(): |
There was a problem hiding this comment.
Clarification: Same here, why isn't this test thread-safe? I don't see a warning or global state here.
There was a problem hiding this comment.
There is an ongoing Cython/CPython issue that is causing sporadic segfaults: cython/cython#6718, this is completely unrelated to the actual scikit-image code.
| if hasattr(threading, 'get_native_id'): | ||
| tmp_dir = tmp_path / str(threading.get_native_id()) | ||
| else: | ||
| tmp_dir = tmp_path / str(threading.get_ident()) | ||
| tmp_dir.mkdir() | ||
| file_path = tmp_dir / "roundtrip.png" |
There was a problem hiding this comment.
I assume tmp_path isn't thread-safe. Could we conditionally override it in conftest.py with a thread-safe version? That way we would avoid future bugs because I would certainly forget that I needed to do this in one year.
There was a problem hiding this comment.
Ensuring a different tmp_path per thread is not possible due to a limitation in pytest-run-parallel: Quansight-Labs/pytest-run-parallel#14
| reason='This test is failing under free-threaded Python', | ||
| ) | ||
| @testing.parametrize("ndim, axis", dim_axis) | ||
| def test_wrap_around(ndim, axis): |
There was a problem hiding this comment.
Why is this necessary now? This test seems to run fine on main on 313t?
There was a problem hiding this comment.
It fails with a numerical error, even when marked as single-threaded:
PYTHON_GIL=0 pytest -x -v --parallel-threads=10 skimage/restoration/tests/test_unwrap.py
============================================================= test session starts ==============================================================
platform linux -- Python 3.13.2, pytest-8.3.3, pluggy-1.5.0 -- /home/andfoy/anaconda3/envs/nogil/bin/python3.13
cachedir: .pytest_cache
hypothesis profile 'default' -> database=DirectoryBasedExampleDatabase(PosixPath('/home/andfoy/Documentos/Quansight/nogil/scikit-image/.hypothesis/examples'))
rootdir: /home/andfoy/Documentos/Quansight/nogil/scikit-image
configfile: pyproject.toml
plugins: localserver-0.9.0.post0, hypothesis-6.118.8, run-parallel-0.2.1.dev0, order-1.3.0
collected 16 items
skimage/restoration/tests/test_unwrap.py::test_unwrap_1d PASSED [ 6%]
skimage/restoration/tests/test_unwrap.py::test_unwrap_2d[False] PASSED [ 12%]
skimage/restoration/tests/test_unwrap.py::test_unwrap_2d[True] PASSED [ 18%]
skimage/restoration/tests/test_unwrap.py::test_unwrap_3d[False] PASSED [ 25%]
skimage/restoration/tests/test_unwrap.py::test_unwrap_3d[True] PASSED [ 31%]
skimage/restoration/tests/test_unwrap.py::test_wrap_around[2-0] FAILED [ 37%]
=================================================================== FAILURES ===================================================================
____________________________________________________________ test_wrap_around[2-0] _____________________________________________________________
ndim = 2, axis = 0
@pytest.mark.thread_unsafe
@skipif(
sys.version_info[:2] == (3, 4),
reason="Doesn't work with python 3.4. See issue #3079",
)
# @skipif(
# bool(sysconfig.get_config_var("Py_GIL_DISABLED")),
# reason='This test is failing under free-threaded Python',
# )
@testing.parametrize("ndim, axis", dim_axis)
def test_wrap_around(ndim, axis):
> check_wrap_around(ndim, axis)
skimage/restoration/tests/test_unwrap.py:145:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
ndim = 2, axis = 0
def check_wrap_around(ndim, axis):
# create a ramp, but with the last pixel along axis equalling the first
elements = 100
ramp = np.linspace(0, 12 * np.pi, elements)
ramp[-1] = ramp[0]
image = ramp.reshape(tuple([elements if n == axis else 1 for n in range(ndim)]))
image_wrapped = np.angle(np.exp(1j * image))
index_first = tuple([0] * ndim)
index_last = tuple([-1 if n == axis else 0 for n in range(ndim)])
# unwrap the image without wrap around
# We do not want warnings about length 1 dimensions
with expected_warnings([r'Image has a length 1 dimension|\A\Z']):
image_unwrap_no_wrap_around = unwrap_phase(image_wrapped, rng=0)
print(
'endpoints without wrap_around:',
image_unwrap_no_wrap_around[index_first],
image_unwrap_no_wrap_around[index_last],
)
# without wrap around, the endpoints of the image should differ
assert_(
abs(
image_unwrap_no_wrap_around[index_first]
- image_unwrap_no_wrap_around[index_last]
)
> np.pi
)
# unwrap the image with wrap around
wrap_around = [n == axis for n in range(ndim)]
# We do not want warnings about length 1 dimensions
with expected_warnings([r'Image has a length 1 dimension.|\A\Z']):
image_unwrap_wrap_around = unwrap_phase(image_wrapped, wrap_around, rng=0)
print(
'endpoints with wrap_around:',
image_unwrap_wrap_around[index_first],
image_unwrap_wrap_around[index_last],
)
# with wrap around, the endpoints of the image should be equal
> assert_almost_equal(
image_unwrap_wrap_around[index_first], image_unwrap_wrap_around[index_last]
)
E AssertionError:
E Arrays are not almost equal to 7 decimals
E ACTUAL: 0.0
E DESIRED: 37.69911184307752
skimage/restoration/tests/test_unwrap.py:126: AssertionError
------------------------------------------------------------- Captured stdout call -------------------------------------------------------------
endpoints without wrap_around: -6.283185307179586 31.41592653589793
endpoints with wrap_around: 0.0 37.69911184307752
=========================================================== short test summary info ============================================================
FAILED skimage/restoration/tests/test_unwrap.py::test_wrap_around[2-0] - AssertionError:
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
========================================================= 1 failed, 5 passed in 0.69s ==========================================================
|
@lagru thanks for the review, let me check again the skip marks I left, maybe they are now fixed with the latest Cython nightlies |
This comment was marked as outdated.
This comment was marked as outdated.
|
Hi all, I'm going to try to resurrect this. @andfoy hope you don't mind. Hopefully more to come from me soon. |
Towards fixing #7464 Currently, running the scikit-image tests fails on the free-threaded build because the GIL is enabled at runtime: ``` _______________________________________________ ERROR collecting build-install/usr/lib/python3.14t/site-packages/skimage/feature/censure.py ________________________________________________ build-install/usr/lib/python3.14t/site-packages/skimage/feature/censure.py:6: in <module> from ..morphology import octagon, star build-install/usr/lib/python3.14t/site-packages/skimage/morphology/__init__.py:28: in <module> from ._skeletonize import medial_axis, skeletonize, thin build-install/usr/lib/python3.14t/site-packages/skimage/morphology/_skeletonize.py:10: in <module> from ._skeletonize_lee_cy import _compute_thin_image E RuntimeWarning: The global interpreter lock (GIL) has been enabled to load module 'skimage.morphology._skeletonize_lee_cy', which has not declared that it can run safely without the GIL. To override this behavior and keep the GIL disabled (at your own risk), run with PYTHON_GIL=0 or -Xgil=0. ``` This is happening because the `cython_args` aren't being passed to the C++ codegen function in the project's meson configuration. I changed the name of the variable to `cython_codegen_args` to make it a little clearer it has nothing to do with `cython_c_args` and `cython_cpp_args`, which are passed to e.g. clang and not Cython. I also made `cython_gen_cpp` use the same arguments as `cython_gen`. `c_undefined_ok` is unused, so I deleted it. Finally, I added a regression test for this in the project's `conftest.py`, following a similar check I added to numpy's `conftest.py` a while back. I also added a 3.14t testing job and deleted the 3.13t job and special testing logic. All of that is unnecessary now. I will follow up after this to make the 3.14t testing job use pytest-run-parallel, superseding #7678.
|
I opened #8065, this should probably be closed in favor of that PR. |
Towards fixing scikit-image#7464 Currently, running the scikit-image tests fails on the free-threaded build because the GIL is enabled at runtime: ``` _______________________________________________ ERROR collecting build-install/usr/lib/python3.14t/site-packages/skimage/feature/censure.py ________________________________________________ build-install/usr/lib/python3.14t/site-packages/skimage/feature/censure.py:6: in <module> from ..morphology import octagon, star build-install/usr/lib/python3.14t/site-packages/skimage/morphology/__init__.py:28: in <module> from ._skeletonize import medial_axis, skeletonize, thin build-install/usr/lib/python3.14t/site-packages/skimage/morphology/_skeletonize.py:10: in <module> from ._skeletonize_lee_cy import _compute_thin_image E RuntimeWarning: The global interpreter lock (GIL) has been enabled to load module 'skimage.morphology._skeletonize_lee_cy', which has not declared that it can run safely without the GIL. To override this behavior and keep the GIL disabled (at your own risk), run with PYTHON_GIL=0 or -Xgil=0. ``` This is happening because the `cython_args` aren't being passed to the C++ codegen function in the project's meson configuration. I changed the name of the variable to `cython_codegen_args` to make it a little clearer it has nothing to do with `cython_c_args` and `cython_cpp_args`, which are passed to e.g. clang and not Cython. I also made `cython_gen_cpp` use the same arguments as `cython_gen`. `c_undefined_ok` is unused, so I deleted it. Finally, I added a regression test for this in the project's `conftest.py`, following a similar check I added to numpy's `conftest.py` a while back. I also added a 3.14t testing job and deleted the 3.13t job and special testing logic. All of that is unnecessary now. I will follow up after this to make the 3.14t testing job use pytest-run-parallel, superseding scikit-image#7678.
|
Work is continued in #8065 |
Description
This PR adds an additional CI job on top of the already-existing 3.13 free threaded one, this new CI pipeline runs the scikit-image testsuite using the pytest-run-parallel plugin, which allows to run individual tests concurrently during a pytest session
Some tests were using the global random number generator, via np.random.seed, which was causing numeric result mismatches when the tests were being run in parallel. Instead, all tests now use np.random.RandomState, which ensures that each thread gets its own independent random generator.
Also, several tests check for warnings, which as 3.13 is not thread-safe, therefore, such tests are marked as thread unsafe and are run in a single thread
Checklist
./doc/examplesfor new featuresRelease note
For maintainers and optionally contributors, please refer to the instructions on how to document this PR for the release notes.