diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 86f7a54..1e91db3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,6 +3,8 @@ variables: PIP_DISABLE_PIP_VERSION_CHECK: "1" CUNUMPY_BACKEND: "cupy" CUNUMPY_REQUIRE_CUDA: "1" + # empty: the newest CuPy; gpu_tests_min_cupy pins the oldest supported one + CUPY_PIN: "" stages: - test @@ -19,7 +21,7 @@ stages: - module load fftw-serial/3.3.10 - nvidia-smi # Keep NVRTC, CUDA libraries and headers on the same toolkit generation. - - python3 -m pip install --user "cupy-cuda13x[ctk]" "cuda-toolkit==13.2.*" + - python3 -m pip install --user "cupy-cuda13x[ctk]${CUPY_PIN}" "cuda-toolkit==13.2.*" - python3 -m pip install --user -e ".[test]" - export PATH="$HOME/.local/bin:$PATH" - python3 -c "import cupy; cupy.show_config()" @@ -28,6 +30,18 @@ gpu_tests: extends: .gpu_job script: - python3 -m pytest -q tests + - python3 testing/cupyx_names.py + +# The oldest supported CuPy (cunumpy.MIN_CUPY_VERSION): the whole suite, and the +# fake cupyx.scipy must list exactly its names (_fake_cupyx.NAMES). +gpu_tests_min_cupy: + extends: .gpu_job + variables: + CUPY_PIN: "==14.0.0" + script: + - python3 -c "import cupy, cunumpy; assert cupy.__version__ == cunumpy.MIN_CUPY_VERSION, cupy.__version__" + - python3 -m pytest -q tests + - python3 testing/cupyx_names.py --exact gpu_sanitizers: extends: .gpu_job diff --git a/CHANGELOG.md b/CHANGELOG.md index 652f241..bff2a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Supported CuPy versions: 14.0 or newer (`xp.MIN_CUPY_VERSION`, + `xp.cupy_version_supported`); an older CuPy warns when the CuPy backend is + first checked, and `backend_info()` reports the minimum. The GPU CI runs the + whole suite on CuPy 14.0.0 too (`gpu_tests_min_cupy`). - MIT license (`LICENSE.txt`, referenced by `pyproject.toml`). - `xp.optimize.newton`: Newton's method or the secant method on many independent scalar equations at once, with the steps of @@ -16,7 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The fake CuPy has `cupyx.scipy`: SciPy behind the CuPy rules (host arrays rejected, fake device arrays and proxy objects returned), so `xp.scipy` code runs its CuPy path without a GPU. Every `cupyx.scipy` subpackage has only - the names CuPy v14.2.0 provides, so e.g. `special.jv` fails as on a GPU. + the names the oldest supported CuPy (14.0.0) provides, so e.g. `special.jv` + or `sparse.csr_array` fails as on a GPU. `testing/cupyx_names.py` and + `tests/unit/test_cupyx_names.py` check these names against the installed + CuPy in the GPU CI. `cupyx.empty_pinned`/`zeros_pinned` return NumPy arrays. - `xp.optimize.fsolve`, `root`, `minimize` and `xp.integrate.quad`, `odeint` (`cupyx.scipy` has neither): SciPy on the host with SciPy's arguments, the diff --git a/docs/source/guides/solvers.md b/docs/source/guides/solvers.md index ee2d948..ee013fe 100644 --- a/docs/source/guides/solvers.md +++ b/docs/source/guides/solvers.md @@ -61,7 +61,9 @@ def periodic_poisson(rho, length): `xp.scipy.special` on both backends. * On the fake CuPy (tests without a GPU), `xp.scipy` runs SciPy with the CuPy rules: host arrays are rejected, results are fake device arrays, and each - subpackage has only the names CuPy has. + subpackage has only the names of the oldest supported CuPy + (`xp.MIN_CUPY_VERSION`), so code that passes there runs on every supported + CuPy. ## Splines and interpolation diff --git a/docs/source/installation.md b/docs/source/installation.md index 6eb5b94..fdb0a7a 100644 --- a/docs/source/installation.md +++ b/docs/source/installation.md @@ -20,8 +20,15 @@ example: ```bash python -m pip install cupy-cuda12x # CUDA 12.x +python -m pip install cupy-cuda13x # CUDA 13.x ``` +CuNumpy supports CuPy 14.0 or newer (`xp.MIN_CUPY_VERSION`); the GPU CI tests +14.0 and the newest release. CuPy 13 lacks parts of `cupyx.scipy` that +CuNumpy relies on (`interpolate.UnivariateSpline`, `NdBSpline`, +`CubicSpline`, ...): selecting the CuPy backend with an older CuPy warns. +`xp.backend_info()` shows the installed and the minimum version. + On HPC clusters, load the site's CUDA module first and check the CuPy installation guide for the matching package name. Then verify that CuNumpy can use it: diff --git a/docs/source/reference/top-level.md b/docs/source/reference/top-level.md index 52b6248..2df44ea 100644 --- a/docs/source/reference/top-level.md +++ b/docs/source/reference/top-level.md @@ -12,4 +12,5 @@ The NumPy/CuPy namespace (see the [API overview](../api.md)) plus backend select ```{eval-rst} .. autodata:: cunumpy.scipy +.. autodata:: cunumpy.MIN_CUPY_VERSION ``` diff --git a/src/cunumpy/__init__.py b/src/cunumpy/__init__.py index 1d5247b..cfa7e67 100644 --- a/src/cunumpy/__init__.py +++ b/src/cunumpy/__init__.py @@ -33,10 +33,12 @@ from cunumpy._scipy_backend import scipy from cunumpy._staging import to_host_async from cunumpy.xp import ( + MIN_CUPY_VERSION, as_device_array, assert_same_backend, backend_info, cupy_available, + cupy_version_supported, default_float_dtype, get_array_backend, get_array_module, @@ -96,6 +98,7 @@ def require_version(minimum: str) -> None: __all__ = [ + "MIN_CUPY_VERSION", "__version__", "algorithms", "arguments", @@ -105,6 +108,7 @@ def require_version(minimum: str) -> None: "cuda", "cupy_available", "cupy_backend", + "cupy_version_supported", "default_float_dtype", "evaluate_on_host", "get_array_backend", diff --git a/src/cunumpy/__init__.pyi b/src/cunumpy/__init__.pyi index 9e23036..e239928 100644 --- a/src/cunumpy/__init__.pyi +++ b/src/cunumpy/__init__.pyi @@ -28,6 +28,10 @@ def to_cupy(array: Any) -> Any: ... def to_cunumpy(array: Any) -> Any: ... def to_host_async(array: Any) -> HostCopy: ... def cupy_available() -> bool: ... +def cupy_version_supported(cupy_version: str) -> bool: ... + +MIN_CUPY_VERSION: str + def backend_info() -> dict[str, Any]: ... def get_array_module(array: Any) -> Any: ... def get_backend() -> str: ... diff --git a/src/cunumpy/_fake_cupyx.py b/src/cunumpy/_fake_cupyx.py index 900b0ef..0a85e11 100644 --- a/src/cunumpy/_fake_cupyx.py +++ b/src/cunumpy/_fake_cupyx.py @@ -9,10 +9,11 @@ in CuPy; * array results are fake device arrays, and objects (a fitted spline, a factorization) are proxies whose methods follow the same rules; -* each subpackage has only the names CuPy provides (:data:`NAMES`), so code - using a SciPy name that CuPy lacks (e.g. ``RectBivariateSpline``, - ``special.jv``, ``linalg.solve_circulant``) fails here as it would on a - GPU. A CuPy name that the installed SciPy lacks is missing too. +* each subpackage has only the names the oldest supported CuPy provides + (:data:`NAMES`, CuPy :data:`NAMES_VERSION`), so code using a SciPy name + that CuPy lacks (e.g. ``RectBivariateSpline``, ``special.jv``, + ``linalg.solve_circulant``) fails here as it would on a GPU. A CuPy name + that the installed SciPy lacks is missing too. ``cupyx.empty_pinned`` and ``cupyx.zeros_pinned`` return NumPy arrays, like CuPy's (which are backed by pinned memory). @@ -48,14 +49,22 @@ def _names(text: str) -> frozenset[str]: return frozenset(text.split()) -#: The public names of each ``cupyx.scipy`` subpackage (CuPy v14.2.0). +#: The CuPy release whose ``cupyx.scipy`` names :data:`NAMES` lists: the oldest +#: supported one (``cunumpy.xp.MIN_CUPY_VERSION``), so code that runs on the +#: fake runs on every supported CuPy. Newer CuPy releases have more names. +NAMES_VERSION = "14.0.0" + +#: The public names of each ``cupyx.scipy`` subpackage in CuPy +#: :data:`NAMES_VERSION`. Check them against an installed CuPy with +#: ``python testing/cupyx_names.py`` (the GPU CI does, in +#: ``tests/unit/test_cupyx_names.py``). NAMES = { "fft": _names( """ - dct dctn dst dstn fft fft2 fftfreq fftn fftshift fht get_fft_plan hfft - hfft2 hfftn idct idctn idst idstn ifft ifft2 ifftn ifftshift ifht ihfft - ihfft2 ihfftn irfft irfft2 irfftn next_fast_len rfft rfft2 rfftfreq - rfftn + dct dctn dst dstn fft fft2 fftfreq fftn fftshift fht + get_fft_plan hfft hfft2 hfftn idct idctn idst idstn ifft ifft2 + ifftn ifftshift ifht ihfft ihfft2 ihfftn irfft irfft2 irfftn + next_fast_len rfft rfft2 rfftfreq rfftn """, ), "fftpack": _names( @@ -67,75 +76,80 @@ def _names(text: str) -> frozenset[str]: """ Akima1DInterpolator BPoly BSpline BarycentricInterpolator CloughTocher2DInterpolator CubicHermiteSpline CubicSpline - InterpolatedUnivariateSpline KroghInterpolator LSQUnivariateSpline - LinearNDInterpolator NdBSpline NdPPoly NearestNDInterpolator PPoly - PchipInterpolator RBFInterpolator RegularGridInterpolator - UnivariateSpline barycentric_interpolate interp1d interpn - krogh_interpolate make_interp_spline make_lsq_spline pchip - pchip_interpolate splantider splder + InterpolatedUnivariateSpline KroghInterpolator + LSQUnivariateSpline LinearNDInterpolator NdBSpline NdPPoly + NearestNDInterpolator PPoly PchipInterpolator RBFInterpolator + RegularGridInterpolator UnivariateSpline barycentric_interpolate + interp1d interpn krogh_interpolate make_interp_spline + make_lsq_spline pchip pchip_interpolate splantider splder """, ), "linalg": _names( """ - bandwidth block_diag circulant companion convolution_matrix dft expm - fiedler fiedler_companion hadamard hankel helmert hilbert khatri_rao - kron leslie lu lu_factor lu_solve solve_triangular toeplitz + bandwidth block_diag circulant companion convolution_matrix dft + expm fiedler fiedler_companion hadamard hankel helmert hilbert + khatri_rao kron leslie lu lu_factor lu_solve solve_triangular + toeplitz """, ), "ndimage": _names( """ affine_transform binary_closing binary_dilation binary_erosion - binary_fill_holes binary_hit_or_miss binary_opening binary_propagation - black_tophat center_of_mass convolve convolve1d correlate correlate1d - distance_transform_edt extrema find_objects fourier_ellipsoid - fourier_gaussian fourier_shift fourier_uniform gaussian_filter - gaussian_filter1d gaussian_gradient_magnitude gaussian_laplace + binary_fill_holes binary_hit_or_miss binary_opening + binary_propagation black_tophat center_of_mass convolve + convolve1d correlate correlate1d distance_transform_edt extrema + find_objects fourier_ellipsoid fourier_gaussian fourier_shift + fourier_uniform gaussian_filter gaussian_filter1d + gaussian_gradient_magnitude gaussian_laplace generate_binary_structure generic_filter generic_filter1d - generic_gradient_magnitude generic_laplace grey_closing grey_dilation - grey_erosion grey_opening histogram iterate_structure label - labeled_comprehension laplace map_coordinates maximum maximum_filter - maximum_filter1d maximum_position mean median median_filter minimum - minimum_filter minimum_filter1d minimum_position morphological_gradient - morphological_laplace percentile_filter prewitt rank_filter rotate shift - sobel spline_filter spline_filter1d standard_deviation sum sum_labels - uniform_filter uniform_filter1d value_indices variance white_tophat zoom + generic_gradient_magnitude generic_laplace grey_closing + grey_dilation grey_erosion grey_opening histogram + iterate_structure label labeled_comprehension laplace + map_coordinates maximum maximum_filter maximum_filter1d + maximum_position mean median median_filter minimum + minimum_filter minimum_filter1d minimum_position + morphological_gradient morphological_laplace percentile_filter + prewitt rank_filter rotate shift sobel spline_filter + spline_filter1d standard_deviation sum sum_labels uniform_filter + uniform_filter1d value_indices variance white_tophat zoom """, ), "signal": _names( """ - BadCoefficients CZT StateSpace TransferFunction ZerosPolesGain ZoomFFT - abcd_normalize argrelextrema argrelmax argrelmin band_stop_obj bilinear - bilinear_zpk bode buttap butter buttord cheb1ap cheb1ord cheb2ap - cheb2ord cheby1 cheby2 check_COLA check_NOLA chirp choose_conv_method - coherence cont2discrete convolve convolve2d correlate correlate2d - correlation_lags csd cspline1d cspline1d_eval cspline2d cwt czt - czt_points dbode decimate deconvolve detrend dfreqresp dimpulse dlsim - dlti dstep ellip ellipap ellipord fftconvolve filtfilt find_peaks - findfreqs firls firwin firwin2 freqresp freqs freqs_zpk freqz freqz_sos - freqz_zpk gammatone gauss_spline gausspulse get_window group_delay - hilbert hilbert2 iircomb iirdesign iirfilter iirnotch iirpeak impulse - invres invresz istft kaiser_atten kaiser_beta kaiserord lfilter - lfilter_zi lfiltic lombscargle lp2bp lp2bp_zpk lp2bs lp2bs_zpk lp2hp - lp2hp_zpk lp2lp lp2lp_zpk lsim lti max_len_seq medfilt medfilt2d - minimum_phase morlet morlet2 normalize oaconvolve order_filter - peak_prominences peak_widths periodogram place_poles qmf qspline1d - qspline1d_eval qspline2d resample resample_poly residue residuez ricker - savgol_coeffs savgol_filter sawtooth sepfir2d sos2tf sos2zpk sosfilt - sosfilt_zi sosfiltfilt sosfreqz spectrogram spline_filter square ss2tf - ss2zpk step stft sweep_poly symiirorder1 symiirorder2 tf2sos tf2ss - tf2zpk unique_roots unit_impulse upfirdn vectorstrength welch wiener - zoom_fft zpk2sos zpk2ss zpk2tf + BadCoefficients CZT StateSpace TransferFunction ZerosPolesGain + ZoomFFT abcd_normalize argrelextrema argrelmax argrelmin + band_stop_obj bilinear bilinear_zpk bode buttap butter buttord + cheb1ap cheb1ord cheb2ap cheb2ord cheby1 cheby2 check_COLA + check_NOLA chirp choose_conv_method coherence cont2discrete + convolve convolve2d correlate correlate2d correlation_lags csd + cspline1d cspline1d_eval cspline2d cwt czt czt_points dbode + decimate deconvolve detrend dfreqresp dimpulse dlsim dlti dstep + ellip ellipap ellipord fftconvolve filtfilt find_peaks findfreqs + firls firwin firwin2 freqresp freqs freqs_zpk freqz freqz_sos + freqz_zpk gammatone gauss_spline gausspulse get_window + group_delay hilbert hilbert2 iircomb iirdesign iirfilter + iirnotch iirpeak impulse invres invresz istft kaiser_atten + kaiser_beta kaiserord lfilter lfilter_zi lfiltic lombscargle + lp2bp lp2bp_zpk lp2bs lp2bs_zpk lp2hp lp2hp_zpk lp2lp lp2lp_zpk + lsim lti max_len_seq medfilt medfilt2d minimum_phase morlet + morlet2 normalize oaconvolve order_filter peak_prominences + peak_widths periodogram place_poles qmf qspline1d qspline1d_eval + qspline2d resample resample_poly residue residuez ricker + savgol_coeffs savgol_filter sawtooth sepfir2d sos2tf sos2zpk + sosfilt sosfilt_zi sosfiltfilt sosfreqz spectrogram + spline_filter square ss2tf ss2zpk step stft sweep_poly + symiirorder1 symiirorder2 tf2sos tf2ss tf2zpk unique_roots + unit_impulse upfirdn vectorstrength welch wiener zoom_fft + zpk2sos zpk2ss zpk2tf """, ), "sparse": _names( """ - SparseEfficiencyWarning SparseWarning block_array block_diag bmat - coo_array coo_matrix csc_array csc_matrix csr_array csr_matrix dia_array - dia_matrix diags diags_array eye eye_array find get_index_dtype hstack - identity issparse isspmatrix isspmatrix_coo isspmatrix_csc - isspmatrix_csr isspmatrix_dia kron kronsum matrix_transpose permute_dims - rand random random_array safely_cast_index_arrays sparray spdiags - spmatrix swapaxes tril triu vstack + SparseEfficiencyWarning SparseWarning bmat coo_matrix csc_matrix + csr_matrix dia_matrix diags eye find hstack identity issparse + isspmatrix isspmatrix_coo isspmatrix_csc isspmatrix_csr + isspmatrix_dia kron kronsum rand random spdiags spmatrix tril + triu vstack """, ), "sparse.csgraph": _names( @@ -145,8 +159,8 @@ def _names(text: str) -> frozenset[str]: ), "sparse.linalg": _names( """ - LinearOperator SuperLU aslinearoperator bicgstab cg cgs eigsh factorized - gmres lobpcg lsmr lsqr matrix_power minres norm spilu splu spsolve + LinearOperator SuperLU aslinearoperator cg cgs eigsh factorized + gmres lobpcg lsmr lsqr minres norm spilu splu spsolve spsolve_triangular svds """, ), @@ -157,17 +171,19 @@ def _names(text: str) -> frozenset[str]: ), "special": _names( """ - bdtr bdtrc bdtri beta betainc betaincinv betaln binom boxcox boxcox1p - btdtr btdtri cbrt chdtr chdtrc chdtri cosdg cosm1 cotdg digamma - ellipeinc ellipj ellipk ellipkinc ellipkm1 entr erf erfc erfcinv erfcx - erfinv exp1 exp10 exp2 expi expit expm1 expn exprel fdtr fdtrc fdtri - gamma gammainc gammaincc gammainccinv gammaincinv gammaln gammasgn gdtr - gdtrc huber i0 i0e i1 i1e inv_boxcox inv_boxcox1p j0 j1 k0 k0e k1 k1e - kl_div lambertw log1p log_expit log_ndtr log_softmax loggamma logit - logsumexp lpmv multigammaln nbdtr nbdtrc nbdtri ndtr ndtri pdtr pdtrc - pdtri poch polygamma pseudo_huber psi radian rel_entr rgamma round - shichi sici sinc sindg softmax sph_harm sph_harm_y spherical_yn tandg - wright_bessel xlog1py xlogy y0 y1 yn zeta zetac + bdtr bdtrc bdtri beta betainc betaincinv betaln binom boxcox + boxcox1p btdtr btdtri cbrt chdtr chdtrc chdtri cosdg cosm1 cotdg + digamma ellipeinc ellipj ellipk ellipkinc ellipkm1 entr erf erfc + erfcinv erfcx erfinv exp1 exp10 exp2 expi expit expm1 expn + exprel fdtr fdtrc fdtri gamma gammainc gammaincc gammainccinv + gammaincinv gammaln gammasgn gdtr gdtrc huber i0 i0e i1 i1e + inv_boxcox inv_boxcox1p j0 j1 k0 k0e k1 k1e kl_div lambertw + log1p log_expit log_ndtr log_softmax loggamma logit logsumexp + lpmv multigammaln nbdtr nbdtrc nbdtri ndtr ndtri pdtr pdtrc + pdtri poch polygamma pseudo_huber psi radian rel_entr rgamma + round shichi sici sinc sindg softmax sph_harm sph_harm_y + spherical_yn tandg wright_bessel xlog1py xlogy y0 y1 yn zeta + zetac """, ), "stats": _names( @@ -178,6 +194,50 @@ def _names(text: str) -> frozenset[str]: } +def public_names(module: types.ModuleType) -> frozenset[str]: + """The public names of a (real) ``cupyx.scipy`` subpackage, as in :data:`NAMES`. + + Names without a leading underscore, except submodules and the + ``annotations`` of ``from __future__ import annotations``. + """ + return frozenset( + name + for name in dir(module) + if not name.startswith("_") + and name != "annotations" + and not isinstance(getattr(module, name, None), types.ModuleType) + ) + + +def installed_names() -> dict[str, frozenset[str]]: + """:func:`public_names` of each subpackage of the installed (real) ``cupyx.scipy``.""" + return { + path: public_names(importlib.import_module(f"cupyx.scipy.{path}")) + for path in SUBPACKAGES + } + + +def compare_names( + real: dict[str, frozenset[str]], +) -> tuple[dict[str, list[str]], dict[str, list[str]]]: + """Compare :data:`NAMES` with the names of a real ``cupyx.scipy``. + + Returns ``(missing, added)``: per subpackage (only those with a + difference), the names of :data:`NAMES` that `real` lacks, and the names + `real` has beyond :data:`NAMES`. On CuPy :data:`NAMES_VERSION` both are + empty; a newer CuPy may add names, but a name it lacks means the fake + accepts code that fails on that CuPy. + """ + missing, added = {}, {} + for path in SUBPACKAGES: + have = real.get(path, frozenset()) + if NAMES[path] - have: + missing[path] = sorted(NAMES[path] - have) + if have - NAMES[path]: + added[path] = sorted(have - NAMES[path]) + return missing, added + + class _Proxy: """A SciPy object (spline, interpolator, ...) seen as a CuPy object.""" diff --git a/src/cunumpy/xp.py b/src/cunumpy/xp.py index bb22e9a..4ae131b 100644 --- a/src/cunumpy/xp.py +++ b/src/cunumpy/xp.py @@ -10,6 +10,7 @@ import logging import os +import re import sys import warnings from collections.abc import Callable, Generator @@ -46,6 +47,44 @@ _CUPY_AVAILABLE_CACHE = None _CUPY_UNAVAILABLE_REASON: str | None = None +#: The oldest supported CuPy release. Older ones lack parts of ``cupyx.scipy`` +#: that cunumpy documents (``interpolate.UnivariateSpline``, ``NdBSpline``, +#: ``CubicSpline``, ...); the fake ``cupyx.scipy`` lists the names of this +#: release. The GPU CI tests this release and the newest one. +MIN_CUPY_VERSION = "14.0.0" + + +def cupy_version_supported(cupy_version: str) -> bool: + """Return whether a CuPy version is :data:`MIN_CUPY_VERSION` or newer. + + Pre-releases of the minimum itself (``14.0.0rc1``) are older than it. + + Parameters + ---------- + cupy_version : str + A version string, e.g. ``cupy.__version__``. + + Returns + ------- + bool + False also for a string that is not a version. + + Examples + -------- + >>> xp.cupy_version_supported("13.6.0") + False + >>> xp.cupy_version_supported("14.2.0") + True + """ + match = re.match(r"(\d+)\.(\d+)(?:\.(\d+))?(.*)", cupy_version) + if match is None: + return False + release = tuple(int(part or 0) for part in match.groups()[:3]) + minimum = tuple(int(part) for part in MIN_CUPY_VERSION.split(".")) + if release != minimum: + return release > minimum + return re.match(r"\.?(a|b|rc|dev)", match.group(4)) is None + def cupy_available() -> bool: """Return whether CuPy can be imported and reports a usable CUDA device. @@ -71,6 +110,16 @@ def cupy_available() -> bool: try: import cupy as cp + cupy_version = getattr(cp, "__version__", "") + fake = getattr(cp, "__cunumpy_fake__", False) + if cupy_version and not fake and not cupy_version_supported(cupy_version): + warnings.warn( + f"CuPy {cupy_version} is older than the oldest version cunumpy " + f"supports ({MIN_CUPY_VERSION}); parts of xp.scipy (e.g. " + "interpolate.UnivariateSpline) are missing on the GPU", + RuntimeWarning, + stacklevel=2, + ) # Check if a GPU is available _CUPY_AVAILABLE_CACHE = cp.is_available() _CUPY_UNAVAILABLE_REASON = ( @@ -321,7 +370,8 @@ def backend_info() -> dict[str, Any]: ------- dict Keys ``backend``, ``cupy_available``, ``cuda_unavailable_reason``, - ``versions``, ``device`` (None without CUDA) and + ``versions``, ``min_cupy_version`` (:data:`MIN_CUPY_VERSION`), + ``device`` (None without CUDA) and ``cuda_visible_devices``, plus ``cuda_inspection_error`` on failure. Examples @@ -342,6 +392,7 @@ def backend_info() -> dict[str, Any]: "cupy_available": available, "cuda_unavailable_reason": None if available else _CUPY_UNAVAILABLE_REASON, "versions": versions, + "min_cupy_version": MIN_CUPY_VERSION, "device": None, "cuda_visible_devices": os.environ.get("CUDA_VISIBLE_DEVICES"), } diff --git a/testing/cupyx_names.py b/testing/cupyx_names.py new file mode 100644 index 0000000..632e797 --- /dev/null +++ b/testing/cupyx_names.py @@ -0,0 +1,75 @@ +"""Compare the fake CuPy's ``cupyx.scipy`` names with the installed CuPy. + +The fake ``cupyx.scipy`` (``cunumpy._fake_cupyx.NAMES``) lists the names of +the oldest supported CuPy (``NAMES_VERSION``). Run this where the real CuPy +is installed:: + + python testing/cupyx_names.py # report; exit 1 if the fake lists a name CuPy lacks + python testing/cupyx_names.py --exact # also exit 1 if CuPy has names the fake lacks + python testing/cupyx_names.py --print # print NAMES for the installed CuPy + +``--print`` gives the new ``NAMES`` when raising the minimum CuPy version: run +it with that version installed and paste the output into ``_fake_cupyx.py``. +The GPU CI runs the same check in ``tests/unit/test_cupyx_names.py``. +""" + +import argparse +import sys +import textwrap + + +def main() -> int: + """Run the comparison; return the exit code.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--exact", action="store_true", help="fail on names the fake lacks too" + ) + parser.add_argument( + "--print", action="store_true", help="print NAMES for the installed CuPy" + ) + args = parser.parse_args() + + import cupy + + if getattr(cupy, "__cunumpy_fake__", False): + sys.exit("the fake CuPy is active: unset CUNUMPY_FAKE_CUPY") + + from cunumpy._fake_cupyx import ( + NAMES_VERSION, + SUBPACKAGES, + compare_names, + installed_names, + ) + + real = installed_names() + if args.print: + print( + f"#: The public names of each ``cupyx.scipy`` subpackage (CuPy {cupy.__version__})." + ) + print("NAMES = {") + for path in SUBPACKAGES: + body = textwrap.fill( + " ".join(sorted(real[path])), + width=72, + initial_indent=" " * 8, + subsequent_indent=" " * 8, + break_on_hyphens=False, + break_long_words=False, + ) + print(f' "{path}": _names(\n """\n{body}\n """,\n ),') + print("}") + return 0 + + missing, added = compare_names(real) + print(f"installed CuPy {cupy.__version__}; the fake lists CuPy {NAMES_VERSION}") + for path, names in missing.items(): + print(f"cupyx.scipy.{path} lacks names the fake has: {' '.join(names)}") + for path, names in added.items(): + print(f"cupyx.scipy.{path} has names the fake lacks: {' '.join(names)}") + if not missing and not added: + print("the names agree") + return 1 if missing or (args.exact and added) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/test_cupyx_names.py b/tests/unit/test_cupyx_names.py new file mode 100644 index 0000000..f529a40 --- /dev/null +++ b/tests/unit/test_cupyx_names.py @@ -0,0 +1,84 @@ +"""The fake cupyx.scipy names (_fake_cupyx.NAMES) against the oldest supported and the installed CuPy.""" + +import types + +import pytest + +import cunumpy as xp +from cunumpy import _fake_cupyx +from cunumpy._fake_cupyx import NAMES, NAMES_VERSION, compare_names, public_names +from cunumpy.kernel_testing import requires_cupy + + +def test_names_are_those_of_the_oldest_supported_cupy(): + assert NAMES_VERSION == xp.MIN_CUPY_VERSION + assert tuple(NAMES) == _fake_cupyx.SUBPACKAGES + # the reason for the minimum: CuPy 13 has no smoothing splines + assert {"UnivariateSpline", "NdBSpline"} <= NAMES["interpolate"] + + +def test_compare_names(): + real = {path: set(names) for path, names in NAMES.items()} + assert compare_names(real) == ({}, {}) + real["special"] = (real["special"] - {"erf"}) | {"jv"} + missing, added = compare_names(real) + assert missing == {"special": ["erf"]} + assert added == {"special": ["jv"]} + + +def test_public_names_skip_private_names_and_submodules(): + module = types.ModuleType("cupyx.scipy.example") + module.solve = lambda: None + module._helper = lambda: None + module.annotations = object() # from __future__ import annotations + module.linalg = types.ModuleType("cupyx.scipy.example.linalg") + assert public_names(module) == {"solve"} + + +@requires_cupy +def test_installed_cupy_has_every_name_of_the_fake(): + """On a GPU: the fake has no name the installed CuPy lacks; on the oldest supported CuPy, the same names.""" + import cupy + + missing, added = compare_names(_fake_cupyx.installed_names()) + assert not missing, f"CuPy {cupy.__version__} lacks names of the fake: {missing}" + if cupy.__version__ == NAMES_VERSION: + assert not added, f"CuPy {NAMES_VERSION} has names the fake lacks: {added}" + + +@requires_cupy +def test_installed_cupy_is_supported(): + import cupy + + assert xp.cupy_version_supported(cupy.__version__) + + +@pytest.mark.parametrize( + ("version", "supported"), + [ + ("13.6.0", False), + ("14.0.0", True), + ("14.0.0rc1", False), + ("14.2.0", True), + ("15.0.0a1", True), + ], +) +def test_cupy_version_supported(version, supported): + assert xp.cupy_version_supported(version) is supported + + +def test_old_cupy_warns_once(monkeypatch): + import sys + + from cunumpy import xp as backend + + old = types.ModuleType("cupy") + old.__version__ = "13.6.0" + old.is_available = lambda: False + monkeypatch.setitem(sys.modules, "cupy", old) + monkeypatch.setattr(backend, "_CUPY_AVAILABLE_CACHE", None) + with pytest.warns( + RuntimeWarning, match=r"CuPy 13\.6\.0 is older than .* \(14\.0\.0\)" + ): + assert not backend.cupy_available() + assert not backend.cupy_available() # cached: no second warning diff --git a/tests/unit/test_scipy_backend.py b/tests/unit/test_scipy_backend.py index ed928b7..dcc0f82 100644 --- a/tests/unit/test_scipy_backend.py +++ b/tests/unit/test_scipy_backend.py @@ -15,6 +15,10 @@ @pytest.fixture def fake_cupyx(monkeypatch): """A fake `cupyx.scipy` with `special.erf` and `sparse.linalg.cg`, as backend.""" + # hide the real cupyx (imported by earlier tests on a GPU): its other + # subpackages, e.g. cupyx.scipy.ndimage, would stay importable + for name in [n for n in sys.modules if n.split(".")[0] == "cupyx"]: + monkeypatch.delitem(sys.modules, name) modules = {} for name in ("cupyx", "cupyx.scipy", "cupyx.scipy.special", "cupyx.scipy.sparse"): modules[name] = types.ModuleType(name)