diff --git a/ci/test_cuda_compute_minimal_python.sh b/ci/test_cuda_compute_minimal_python.sh index f0ec1d85760..df3d8f8a5b6 100755 --- a/ci/test_cuda_compute_minimal_python.sh +++ b/ci/test_cuda_compute_minimal_python.sh @@ -44,4 +44,28 @@ if [[ "${py_version}" == "3.14t" ]]; then compute/test_free_threading_stress.py \ compute/test_multi_cc_serialization.py::test_aot_build_result_load_failure_is_shared_and_retryable \ compute/test_multi_cc_serialization.py::test_aot_serialization_waits_for_canonical_first_load + + # Broad thread-safety sweep (pytest-run-parallel): re-run the numba-free + # functional suite with each test executed concurrently across threads + # (barrier-synchronized start), stressing the process-wide build cache, + # single-flight coordination, and the Cython bindings from many threads at + # once. Complements test_free_threading_stress.py above, which targets specific + # shared-object scenarios by hand. -n 0 so the threads share one interpreter. + # + # --parallel-threads=2 matches CuPy's free-threading CI (the closest GPU + # precedent); a small fixed count bounds GPU-memory pressure from concurrent + # kernels and stays reproducible across runners, unlike =auto (the runner's + # logical-core count). + # + # pytest-run-parallel is only used by this sweep, so install it on the 3.14t + # path rather than for every minimal (e.g. non-free-threaded 3.14) run. + python -m pip install pytest-run-parallel + + # Fail fast if the interpreter is not actually GIL-free (wrong build / + # PYTHON_GIL=1): pytest-run-parallel does NOT catch a GIL that is enabled from + # the start -- it would run threads GIL-serialized and pass vacuously. (A GIL + # *re-enabled mid-run* by a non-free-threaded import IS caught by the plugin, + # which is why we do not pass --ignore-gil-enabled.) + python -c "import sys; assert not sys._is_gil_enabled(), 'GIL is enabled; parallel sweep has no signal'" + python -m pytest -n 0 -v --parallel-threads=2 compute/test_no_numba.py fi diff --git a/python/cuda_cccl/tests/compute/conftest.py b/python/cuda_cccl/tests/compute/conftest.py index 57c73ed7de8..40b5a8d581c 100644 --- a/python/cuda_cccl/tests/compute/conftest.py +++ b/python/cuda_cccl/tests/compute/conftest.py @@ -88,14 +88,21 @@ def cuda_stream() -> Generator[Stream, None, None]: @pytest.fixture(scope="function", autouse=True) -def verify_sass(request, monkeypatch): +def verify_sass(request): if request.node.get_closest_marker("no_verify_sass"): return if not check_ldl_stl_in_sass: - print("not checking sass") return + # Pull monkeypatch dynamically rather than as a fixture parameter so this + # autouse fixture does not add monkeypatch to every test's static fixture + # closure. pytest-run-parallel treats monkeypatch as thread-unsafe based on + # that closure, so a parameter here would serialize the entire free-threaded + # parallel sweep -- even though this fixture only patches on the opt-in + # SASS-check path (check_ldl_stl_in_sass, off by default and in CI). + monkeypatch = request.getfixturevalue("monkeypatch") + import cuda.compute._cccl_interop monkeypatch.setattr( @@ -124,8 +131,21 @@ def pytest_collection_modifyitems(config, items): serialization_skip = pytest.mark.skip( reason="serialization not supported on v2 (HostJIT) backend" ) + # Under the pytest-run-parallel sweep (--parallel-threads > 1) skip the + # blanket raise_on_numba_import injection: it monkeypatches + # builtins.__import__, which pytest-run-parallel treats as thread-unsafe and + # would serialize every no_numba test, neutering the sweep. + # + # getoption returns the int 1 from the argparse default but a *string* for + # CLI-passed values ("2", "auto"). Only bypass for an explicit numeric thread + # count > 1 (what CI passes): "auto" is left to inject the guard so a run the + # plugin executes single-threaded (e.g. "auto" resolving to 1 logical CPU) is + # not mistaken for a parallel run. (A bare `> 1` would also TypeError on the + # "2" string.) + parallel_threads = str(config.getoption("parallel_threads", 1)) + running_parallel = parallel_threads.isdigit() and int(parallel_threads) > 1 for item in items: - if item.get_closest_marker("no_numba"): + if item.get_closest_marker("no_numba") and not running_parallel: if "raise_on_numba_import" not in item.fixturenames: item.fixturenames.append("raise_on_numba_import") if USING_V2 and item.get_closest_marker("serialization"): diff --git a/python/cuda_cccl/tests/compute/test_no_numba.py b/python/cuda_cccl/tests/compute/test_no_numba.py index 264cf117aa0..da605d0dab2 100644 --- a/python/cuda_cccl/tests/compute/test_no_numba.py +++ b/python/cuda_cccl/tests/compute/test_no_numba.py @@ -103,7 +103,11 @@ def _raw_negate_i16_op() -> RawOp: return _raw_op(source, "no_numba_negate_i16") -def test_import_numba_raises(): +def test_import_numba_raises(raise_on_numba_import): + # Request the guard explicitly rather than relying on the no_numba + # auto-injection (skipped under the parallel sweep, see conftest + # pytest_collection_modifyitems): this test exercises the guard directly, and + # the monkeypatch it depends on keeps it single-threaded under the sweep. with pytest.raises( ImportError, match="This test is marked 'no_numba' but attempted to import it" ): @@ -205,9 +209,8 @@ def test_binary_search_explicit_opkind_less(search, side): np.testing.assert_array_equal(d_out.copy_to_host(), expected) -def test_segmented_reduce_well_known_plus(monkeypatch): - monkeypatch.setattr(cuda.compute._cccl_interop, "_check_sass", False) - +@pytest.mark.no_verify_sass +def test_segmented_reduce_well_known_plus(): h_input = np.asarray([1, 2, 3, 4, 5, 6, 7, 8], dtype=np.uint32) h_starts = np.asarray([0, 3, 5], dtype=np.int32) h_ends = np.asarray([3, 5, 8], dtype=np.int32) @@ -288,11 +291,8 @@ def test_segmented_sort_keys(): np.testing.assert_array_equal(d_output.copy_to_host(), expected) -def test_unique_by_key_well_known_equal_to(monkeypatch): - cc_major, _ = cuda.compute._cccl_interop.CudaDevice().compute_capability - if cc_major >= 9: - monkeypatch.setattr(cuda.compute._cccl_interop, "_check_sass", False) - +@pytest.mark.no_verify_sass +def test_unique_by_key_well_known_equal_to(): h_keys = np.asarray([1, 1, 2, 2, 2, 3, 4, 4], dtype=np.int16) h_values = np.asarray([10, 11, 20, 21, 22, 30, 40, 41], dtype=np.int8) d_keys = DeviceArray.from_numpy(h_keys)