diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1213571..612e133 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,3 +35,33 @@ jobs: - name: Test with pytest run: | python -m pytest + + cupy-api: + name: CuPy ${{ matrix.cupy-version }} API (CPU stub build) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + cupy-version: ["12.*", "13.*", "14.*"] + env: + CUPY_INSTALL_USE_STUB: "1" + # Compatibility for CuPy 12; remove when support for 12 is dropped. + READTHEDOCS: "True" + CUPY_NUM_BUILD_JOBS: "2" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: | + pyproject.toml + .github/workflows/tests.yml + - name: Install CuPy without CUDA + run: | + python -m pip install --upgrade pip + python -m pip install --no-binary=cupy -e '.[numpy]' --group test "cupy==${{ matrix.cupy-version }}" + - name: Inspect CuPy and test the NumPy wrapper + run: | + python -c "import cupy; assert callable(cupy.random.Generator.random)" + python -m pytest -q tests/test_backends.py tests/test_functional_backends.py --fake-cupy -k cupy diff --git a/src/orng/backends/cupy.py b/src/orng/backends/cupy.py index 75d1d25..5834021 100644 --- a/src/orng/backends/cupy.py +++ b/src/orng/backends/cupy.py @@ -262,7 +262,7 @@ def choice( else int(cp.prod(cp.asarray(target_size))) ) if probs is None: - indices = gen.permutation(n)[:flat_k] + indices = cp.argsort(gen.random(n, dtype=cp.float64))[:flat_k] else: gumbels = -cp.log(-cp.log(gen.random(n))) keys = cp.log(probs) + gumbels diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..680313c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,84 @@ +import sys +from types import SimpleNamespace + +import numpy as np +import pytest + + +def pytest_addoption(parser): + parser.addoption( + "--fake-cupy", + action="store_true", + help="Run CuPy backend cases with NumPy and the installed CuPy API", + ) + + +@pytest.fixture +def cupy_generator_api(): + """Inspect the installed class without creating any CUDA objects.""" + cp = pytest.importorskip("cupy") + methods = frozenset( + name + for name in dir(cp.random.Generator) + if not name.startswith("_") + and callable(getattr(cp.random.Generator, name)) + ) + return cp.__version__, methods + + +@pytest.fixture +def numpy_cupy(monkeypatch, cupy_generator_api): + """NumPy execution restricted to the installed CuPy Generator API. + + This checks method availability, not CUDA behaviour or every signature. + Bit-generator state is NumPy state, solely for exercising ORNG's plumbing. + """ + version, methods = cupy_generator_api + + class Generator: + def __init__(self, seed=None): + self._rng = np.random.default_rng(seed) + + @property + def bit_generator(self): + return self._rng.bit_generator + + def __getattr__(self, name): + if name not in methods: + raise AttributeError( + f"CuPy {version} Generator has no method {name!r}" + ) + method = getattr(self._rng, name) + if name in {"uniform", "beta"}: + # CuPy accepts dtype here; NumPy does not. + def with_dtype(*args, dtype=np.float64, **kwargs): + return np.asarray(method(*args, **kwargs), dtype=dtype) + + return with_dtype + if name in { + "random", + "standard_normal", + "standard_gamma", + "standard_exponential", + }: + + def with_default_dtype(*args, **kwargs): + if kwargs.get("dtype", np.float64) is None: + kwargs["dtype"] = np.float64 + return method(*args, **kwargs) + + return with_default_dtype + return method + + # Only the generator API is restricted; array operations use NumPy. + namespace = SimpleNamespace( + **{name: getattr(np, name) for name in dir(np) if name != "random"}, + random=SimpleNamespace( + Generator=Generator, + default_rng=Generator, + PCG64=np.random.PCG64, + ), + asnumpy=np.asarray, + ) + monkeypatch.setitem(sys.modules, "cupy", namespace) + return namespace diff --git a/tests/test_backends.py b/tests/test_backends.py index 08d3742..d1dcd3d 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -12,6 +12,9 @@ @pytest.fixture(params=["numpy", "torch", "cupy", "jax"]) def backend_name(request): if request.param == "cupy": + if request.config.getoption("--fake-cupy"): + request.getfixturevalue("numpy_cupy") + return "cupy" cupy = pytest.importorskip("cupy") try: cupy.cuda.runtime.getDeviceCount() @@ -103,10 +106,6 @@ def to_list(arr): if name == "cupy": cp = pytest.importorskip("cupy") - try: - cp.cuda.runtime.getDeviceCount() - except cp.cuda.runtime.CUDARuntimeError as exc: - pytest.skip(f"CuPy runtime unavailable: {exc}") def new_backend(seed): return CuPyBackend(seed=seed, generator=None) @@ -121,7 +120,7 @@ def assert_close(a, b): cp.testing.assert_allclose(a, b) def to_list(arr): - return arr.get().tolist() + return cp.asnumpy(arr).tolist() return { "name": name, @@ -312,17 +311,10 @@ def test_array_rng_choice(rng): assert all(v in [10, 20, 30] for v in x.flatten()) -def test_cupy_choice_with_probabilities_and_no_replacement(): - cp = pytest.importorskip("cupy") - try: - cp.cuda.runtime.getDeviceCount() - except (ImportError, cp.cuda.runtime.CUDARuntimeError) as exc: - pytest.skip(f"CuPy runtime unavailable: {exc}") - - backend = CuPyBackend(seed=123, generator=None) - - values = cp.array([1, 2, 3, 4], dtype=cp.int32) - probs = cp.array([0.7, 0.1, 0.1, 0.1], dtype=cp.float32) +def test_choice_with_probabilities_and_no_replacement(backend_case): + backend = backend_case["new_backend"](123) + values = [1, 2, 3, 4] + probs = [0.7, 0.1, 0.1, 0.1] draws = backend.choice( values, @@ -330,9 +322,10 @@ def test_cupy_choice_with_probabilities_and_no_replacement(): replace=True, probabilities=probs, ) - assert isinstance(draws, cp.ndarray) - assert draws.shape == (2, 3) - assert cp.isin(draws, values).all() + backend_case["assert_array"](draws, shape=(2, 3), dtype=None) + assert all( + value in values for value in backend_case["to_list"](draws.reshape(-1)) + ) without_replacement = backend.choice( values, @@ -340,6 +333,5 @@ def test_cupy_choice_with_probabilities_and_no_replacement(): replace=False, probabilities=None, ) - assert isinstance(without_replacement, cp.ndarray) - assert without_replacement.shape == (3,) - assert cp.unique(without_replacement).size == 3 + backend_case["assert_array"](without_replacement, shape=(3,), dtype=None) + assert len(set(backend_case["to_list"](without_replacement))) == 3 diff --git a/tests/test_functional_backends.py b/tests/test_functional_backends.py index 48fc697..ae1b217 100644 --- a/tests/test_functional_backends.py +++ b/tests/test_functional_backends.py @@ -30,7 +30,10 @@ def _check_backend_available(name: str) -> None: @pytest.fixture(params=["numpy", "torch", "jax", "cupy"]) def functional_backend_case(request): name = request.param - _check_backend_available(name) + if name == "cupy" and request.config.getoption("--fake-cupy"): + request.getfixturevalue("numpy_cupy") + else: + _check_backend_available(name) backend = create_functional_backend(name) if name == "numpy":