Skip to content

Test scikit-image on free-threaded Python using pytest-run-parallel#7678

Closed
andfoy wants to merge 21 commits intoscikit-image:mainfrom
andfoy:use_pytest_run_parallel
Closed

Test scikit-image on free-threaded Python using pytest-run-parallel#7678
andfoy wants to merge 21 commits intoscikit-image:mainfrom
andfoy:use_pytest_run_parallel

Conversation

@andfoy
Copy link
Copy Markdown
Contributor

@andfoy andfoy commented Jan 28, 2025

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

Release note

For maintainers and optionally contributors, please refer to the instructions on how to document this PR for the release notes.

...

@stefanv
Copy link
Copy Markdown
Member

stefanv commented Jan 28, 2025

Thanks @andfoy! Can you explain a bit of which tests are / aren't thread safe?

@andfoy
Copy link
Copy Markdown
Contributor Author

andfoy commented Jan 29, 2025

@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., RandomState or default_rng, those tests were "fixed" by replacing the global instance by a local one.

There was a single instance of a module that used global shared state variables and not local ones:

_curvop = threading.local()

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:

@stefanv stefanv added the 🤖 type: Infrastructure CI, packaging, tools and automation label Jan 30, 2025
@stefanv
Copy link
Copy Markdown
Member

stefanv commented Jan 30, 2025

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.

# 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you might also try with setup-uv

@ngoldbaum
Copy link
Copy Markdown
Contributor

Out of curiosity, how much slower does this make the free-threaded CI run?

@stefanv
Copy link
Copy Markdown
Member

stefanv commented Jan 31, 2025

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.

@andfoy andfoy force-pushed the use_pytest_run_parallel branch from 361487d to 2c96b36 Compare February 3, 2025 15:39
@andfoy
Copy link
Copy Markdown
Contributor Author

andfoy commented Feb 3, 2025

According to this test-run attempt https://github.com/scikit-image/scikit-image/actions/runs/13117156401, the action astral-sh/setup-uv needs to be registered somewhere in the project settings

@lagru
Copy link
Copy Markdown
Member

lagru commented Feb 3, 2025

astral-sh/setup-uv needs to be registered somewhere in the project settings

I added it. Should work now. :)

@lagru
Copy link
Copy Markdown
Member

lagru commented Feb 3, 2025

Should remember to remove the deadsnakes one, once this is merged. 😬

@andfoy andfoy force-pushed the use_pytest_run_parallel branch from b434448 to 24114e6 Compare February 4, 2025 19:15
@andfoy andfoy force-pushed the use_pytest_run_parallel branch 2 times, most recently from c583fd6 to 67c9828 Compare February 11, 2025 23:30
@andfoy andfoy force-pushed the use_pytest_run_parallel branch from cbdfb18 to 935821a Compare March 5, 2025 17:20
Copy link
Copy Markdown
Contributor

@Schefflera-Arboricola Schefflera-Arboricola left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lagru lagru self-requested a review March 6, 2025 09:23
Copy link
Copy Markdown
Member

@lagru lagru left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

testing.assert_allclose(r5, img5)


FETCH_LOCK = threading.Lock()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

@andfoy andfoy Mar 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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():
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Copy link
Copy Markdown
Contributor Author

@andfoy andfoy Mar 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: Make feature_type a tuple from the start in parametrize instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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():
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarification: Same here, why isn't this test thread-safe? I don't see a warning or global state here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +69 to +74
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"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this necessary now? This test seems to run fine on main on 313t?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ==========================================================
 

@andfoy
Copy link
Copy Markdown
Contributor Author

andfoy commented Mar 10, 2025

@lagru thanks for the review, let me check again the skip marks I left, maybe they are now fixed with the latest Cython nightlies

@github-actions

This comment was marked as outdated.

@github-actions github-actions bot added the 😴 Dormant No recent activity label Feb 13, 2026
@lagru lagru removed the 😴 Dormant No recent activity label Feb 13, 2026
@ngoldbaum
Copy link
Copy Markdown
Contributor

Hi all, I'm going to try to resurrect this.

@andfoy hope you don't mind.

Hopefully more to come from me soon.

stefanv pushed a commit that referenced this pull request Feb 25, 2026
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.
@ngoldbaum
Copy link
Copy Markdown
Contributor

I opened #8065, this should probably be closed in favor of that PR.

matusvalo pushed a commit to matusvalo/scikit-image that referenced this pull request Mar 4, 2026
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.
@stefanv
Copy link
Copy Markdown
Member

stefanv commented Mar 6, 2026

Work is continued in #8065

@stefanv stefanv closed this Mar 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🤖 type: Infrastructure CI, packaging, tools and automation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants