Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion src/orng/backends/cupy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
36 changes: 14 additions & 22 deletions tests/test_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -312,34 +311,27 @@ 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,
size=(2, 3),
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,
size=(3,),
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
5 changes: 4 additions & 1 deletion tests/test_functional_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
Loading