From 8d65bac2b688e80b79797d521d391f9d4455175d Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 28 May 2026 10:55:06 +0200 Subject: [PATCH 01/17] Added some instructions to the ci trigger --- .github/workflows/gpu_ci_trigger.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/gpu_ci_trigger.yml b/.github/workflows/gpu_ci_trigger.yml index 9ed33d7..74ac41f 100644 --- a/.github/workflows/gpu_ci_trigger.yml +++ b/.github/workflows/gpu_ci_trigger.yml @@ -1,3 +1,22 @@ +# SETUP INSTRUCTIONS: +# ------------------ +# This workflow synchronizes the code to GitLab via SSH to trigger GPU-enabled CI. +# +# 1. GENERATE SSH KEY PAIR (on your local machine): +# ssh-keygen -t ed25519 -f ~/.ssh/gitlab_sync_key -N "" -C "github-to-gitlab-sync" +# +# 2. CONFIGURE GITLAB (The Target): +# - Go to GitLab project > Settings > Repository > Deploy keys. +# - Add the content of '~/.ssh/gitlab_sync_key.pub'. +# - IMPORTANT: Check "Allow write access to this repository". +# +# 3. CONFIGURE GITHUB (The Source): +# - Go to GitHub repo > Settings > Secrets and variables > Actions. +# - Add a new Repository Secret: +# - Name: GITLAB_SSH_PRIVATE_KEY +# - Value: Paste the entire content of '~/.ssh/gitlab_sync_key'. +# + name: Sync to GitLab and Run GPU CI on: From 241a6481e78c4f7f24821661a4847325aa712ccb Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 28 May 2026 10:55:28 +0200 Subject: [PATCH 02/17] Added more tests --- tests/unit/test_benchmarks.py | 79 +++++++++++++++++ tests/unit/test_features.py | 153 +++++++++++++++++++++++++++++++++ tests/unit/test_integration.py | 79 +++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 tests/unit/test_benchmarks.py create mode 100644 tests/unit/test_features.py create mode 100644 tests/unit/test_integration.py diff --git a/tests/unit/test_benchmarks.py b/tests/unit/test_benchmarks.py new file mode 100644 index 0000000..3e8de19 --- /dev/null +++ b/tests/unit/test_benchmarks.py @@ -0,0 +1,79 @@ +import numpy as np +import pytest +import cunumpy as xp +import time + +def has_cupy(): + try: + import cupy + import cupy.cuda + return cupy.cuda.is_available() + except ImportError: + return False + +@pytest.mark.skipif(not has_cupy(), reason="CuPy/GPU not available") +def test_benchmark_matmul(): + """Benchmark matrix multiplication to show CuPy performance gain.""" + size = 2000 + + # --- Benchmark NumPy --- + with xp.use_backend("numpy"): + a_np = xp.random.rand(size, size).astype(xp.float32) + b_np = xp.random.rand(size, size).astype(xp.float32) + + start_np = time.perf_counter() + c_np = a_np @ b_np + # No sync needed for NumPy as it is synchronous + end_np = time.perf_counter() + t_np = end_np - start_np + + # --- Benchmark CuPy --- + with xp.use_backend("cupy"): + a_cp = xp.random.rand(size, size).astype(xp.float32) + b_cp = xp.random.rand(size, size).astype(xp.float32) + + # Warm up + _ = a_cp @ b_cp + xp.synchronize() + + start_cp = time.perf_counter() + c_cp = a_cp @ b_cp + xp.synchronize() # CRITICAL for benchmarking GPU + end_cp = time.perf_counter() + t_cp = end_cp - start_cp + + print(f"\n[Benchmark] Size: {size}x{size}") + print(f"NumPy time: {t_np:.4f}s") + print(f"CuPy time: {t_cp:.4f}s") + print(f"Speedup: {t_np/t_cp:.2f}x") + + # On a real GPU (A100/A30), CuPy should be significantly faster + # We use a conservative threshold of 1.5x for the test to pass on various hardware + assert t_cp < t_np, f"CuPy ({t_cp:.4f}s) was not faster than NumPy ({t_np:.4f}s)" + +@pytest.mark.skipif(not has_cupy(), reason="CuPy/GPU not available") +def test_benchmark_fft(): + """Benchmark FFT performance.""" + size = 2**22 # ~4 million elements + + with xp.use_backend("numpy"): + data_np = xp.random.rand(size).astype(xp.complex64) + start = time.perf_counter() + _ = xp.fft.fft(data_np) + t_np = time.perf_counter() - start + + with xp.use_backend("cupy"): + data_cp = xp.random.rand(size).astype(xp.complex64) + # Warm up + _ = xp.fft.fft(data_cp) + xp.synchronize() + + start = time.perf_counter() + _ = xp.fft.fft(data_cp) + xp.synchronize() + t_cp = time.perf_counter() - start + + print(f"\n[Benchmark] FFT Size: {size}") + print(f"NumPy time: {t_np:.4f}s") + print(f"CuPy time: {t_cp:.4f}s") + assert t_cp < t_np diff --git a/tests/unit/test_features.py b/tests/unit/test_features.py new file mode 100644 index 0000000..bd85591 --- /dev/null +++ b/tests/unit/test_features.py @@ -0,0 +1,153 @@ +import numpy as np +import pytest +import cunumpy as xp + +def has_cupy(): + try: + import cupy + return True + except ImportError: + return False + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_matrix_multiplication(backend): + if backend == "cupy" and not has_cupy(): + pytest.skip("CuPy not installed") + + with xp.use_backend(backend): + # Test basic @ operator and matmul + a = xp.array([[1, 2], [3, 4]], dtype=float) + b = xp.array([[5, 6], [7, 8]], dtype=float) + c = a @ b + + expected = np.array([[19, 22], [43, 50]]) + assert xp.array_equal(xp.to_numpy(c), expected) + + # Test linalg.norm + norm = xp.linalg.norm(a) + assert np.isclose(float(norm), np.linalg.norm([[1, 2], [3, 4]])) + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_reductions_and_axes(backend): + if backend == "cupy" and not has_cupy(): + pytest.skip("CuPy not installed") + + with xp.use_backend(backend): + a = xp.array([[1, 10, 100], [2, 20, 200]], dtype=float) + + assert xp.sum(a) == 333 + assert xp.array_equal(xp.to_numpy(xp.max(a, axis=0)), [2, 20, 200]) + assert xp.array_equal(xp.to_numpy(xp.min(a, axis=1)), [1, 2]) + assert xp.mean(a) == 333 / 6 + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_complex_elementwise(backend): + if backend == "cupy" and not has_cupy(): + pytest.skip("CuPy not installed") + + with xp.use_backend(backend): + a = xp.array([-1, 0, 1], dtype=float) + + # Exp and Log + exp_a = xp.exp(a) + assert np.allclose(xp.to_numpy(exp_a), np.exp([-1, 0, 1])) + + # Trig + b = xp.array([0, xp.pi/2], dtype=float) + assert np.allclose(xp.to_numpy(xp.cos(b)), [1, 0], atol=1e-7) + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_broadcasting_logic(backend): + if backend == "cupy" and not has_cupy(): + pytest.skip("CuPy not installed") + + with xp.use_backend(backend): + # 3D + 1D broadcasting + a = xp.ones((2, 3, 4)) + b = xp.arange(4) + c = a * b + + assert c.shape == (2, 3, 4) + assert xp.array_equal(xp.to_numpy(c[0, 0]), [0, 1, 2, 3]) + assert xp.array_equal(xp.to_numpy(c[1, 2]), [0, 1, 2, 3]) + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_fft_parity(backend): + if backend == "cupy" and not has_cupy(): + pytest.skip("CuPy not installed") + + with xp.use_backend(backend): + # Create a signal with two frequencies + t = xp.linspace(0, 1, 128) + sig = xp.sin(2 * xp.pi * 5 * t) + 0.5 * xp.sin(2 * xp.pi * 20 * t) + + freqs = xp.fft.fft(sig) + inv = xp.fft.ifft(freqs) + + # ifft(fft(x)) == x + assert np.allclose(xp.to_numpy(inv.real), xp.to_numpy(sig)) + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_realistic_normalization_workflow(backend): + """Workflow: Load data -> Compute Stats -> Normalize -> Mask Outliers.""" + if backend == "cupy" and not has_cupy(): + pytest.skip("CuPy not installed") + + with xp.use_backend(backend): + # 1. Create dummy data with clear outliers + data = xp.array([1.0, 2.0, 3.0, 4.0, 100.0, -100.0]) + + # 2. Normalize + mean = xp.mean(data) + std = xp.std(data) + norm_data = (data - mean) / std + + # 3. Mask outliers (abs > 1.0 in this specific small set) + mask = xp.abs(norm_data) < 1.0 + clean_data = data[mask] + + # Verify: -100 and 100 should be gone + res = xp.to_numpy(xp.sort(clean_data)) + assert np.array_equal(res, [1.0, 2.0, 3.0, 4.0]) + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_stacking_and_concatenation(backend): + if backend == "cupy" and not has_cupy(): + pytest.skip("CuPy not installed") + + with xp.use_backend(backend): + a = xp.array([1, 2, 3]) + b = xp.array([4, 5, 6]) + + res_cat = xp.concatenate([a, b]) + assert xp.array_equal(xp.to_numpy(res_cat), [1, 2, 3, 4, 5, 6]) + + res_stack = xp.stack([a, b]) + assert res_stack.shape == (2, 3) + assert xp.array_equal(xp.to_numpy(res_stack[1]), [4, 5, 6]) + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_advanced_indexing(backend): + if backend == "cupy" and not has_cupy(): + pytest.skip("CuPy not installed") + + with xp.use_backend(backend): + a = xp.arange(10).reshape(2, 5) + + # Pick specific elements: (0,1) and (1,3) + rows = xp.array([0, 1]) + cols = xp.array([1, 3]) + + indexed = a[rows, cols] + assert xp.array_equal(xp.to_numpy(indexed), [1, 8]) + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_random_generation(backend): + if backend == "cupy" and not has_cupy(): + pytest.skip("CuPy not installed") + + with xp.use_backend(backend): + # Test reproducibility if we were to add seed (checking existing proxy) + a = xp.random.normal(0, 1, size=(100, 100)) + assert a.shape == (100, 100) + assert xp.abs(xp.mean(a)) < 0.5 # Basic statistical sanity diff --git a/tests/unit/test_integration.py b/tests/unit/test_integration.py new file mode 100644 index 0000000..1baa595 --- /dev/null +++ b/tests/unit/test_integration.py @@ -0,0 +1,79 @@ +import numpy as np +import pytest +import cunumpy as xp + +def has_cupy(): + try: + import cupy + return True + except ImportError: + return False + +def test_data_movement_chain(): + """Test CPU -> GPU -> CPU multi-hop movement.""" + if not has_cupy(): + pytest.skip("CuPy not installed") + + # 1. Start on CPU + data_orig = np.random.rand(100, 100).astype(np.float32) + + # 2. Move to GPU + data_gpu = xp.to_cupy(data_orig) + assert xp.is_gpu(data_gpu) + + # 3. Do operation on GPU + with xp.use_backend("cupy"): + res_gpu = xp.sin(data_gpu) ** 2 + xp.cos(data_gpu) ** 2 + + # 4. Move back to CPU + res_cpu = xp.to_numpy(res_gpu) + assert isinstance(res_cpu, np.ndarray) + assert np.allclose(res_cpu, 1.0) + +def test_synchronize_logic(): + """Verify synchronize can be called and handles errors gracefully.""" + # This is more of a smoke test to ensure the path doesn't crash + xp.synchronize() + + if has_cupy(): + import cupy as cp + with xp.use_backend("cupy"): + a = xp.random.rand(100) + xp.synchronize() + assert xp.is_gpu(a) + +def test_fft_interop(): + """Test FFT between backends.""" + if not has_cupy(): + pytest.skip("CuPy not installed") + + # Create signal on CPU + sig_cpu = np.random.rand(1024).astype(np.complex128) + + # Move to GPU and transform + sig_gpu = xp.to_cupy(sig_cpu) + freq_gpu = xp.fft.fft(sig_gpu) + + # Move frequencies to CPU and transform back + freq_cpu = xp.to_numpy(freq_gpu) + sig_reconstructed = np.fft.ifft(freq_cpu) + + assert np.allclose(sig_cpu, sig_reconstructed) + +def test_mixed_backend_errors(): + """Verify that mixing backends in operations raises errors (standard NumPy/CuPy behavior).""" + if not has_cupy(): + pytest.skip("CuPy not installed") + + a_cpu = np.array([1, 2, 3]) + a_gpu = xp.to_cupy(a_cpu) + + # This should fail because you can't add CPU and GPU arrays directly + with pytest.raises(Exception): + _ = a_cpu + a_gpu + + # But to_cunumpy should fix it + a_gpu_fixed = xp.to_cunumpy(a_cpu) + with xp.use_backend("cupy"): + res = a_gpu + a_gpu_fixed + assert xp.is_gpu(res) From 6e48cb7b592887c190e761a3f6e156999743a96c Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 28 May 2026 11:18:02 +0200 Subject: [PATCH 03/17] Fixed tests --- tests/unit/test_features.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_features.py b/tests/unit/test_features.py index bd85591..0754fa8 100644 --- a/tests/unit/test_features.py +++ b/tests/unit/test_features.py @@ -36,8 +36,8 @@ def test_reductions_and_axes(backend): a = xp.array([[1, 10, 100], [2, 20, 200]], dtype=float) assert xp.sum(a) == 333 - assert xp.array_equal(xp.to_numpy(xp.max(a, axis=0)), [2, 20, 200]) - assert xp.array_equal(xp.to_numpy(xp.min(a, axis=1)), [1, 2]) + assert np.array_equal(xp.to_numpy(xp.max(a, axis=0)), [2, 20, 200]) + assert np.array_equal(xp.to_numpy(xp.min(a, axis=1)), [1, 2]) assert xp.mean(a) == 333 / 6 @pytest.mark.parametrize("backend", ["numpy", "cupy"]) @@ -68,8 +68,8 @@ def test_broadcasting_logic(backend): c = a * b assert c.shape == (2, 3, 4) - assert xp.array_equal(xp.to_numpy(c[0, 0]), [0, 1, 2, 3]) - assert xp.array_equal(xp.to_numpy(c[1, 2]), [0, 1, 2, 3]) + assert np.array_equal(xp.to_numpy(c[0, 0]), [0, 1, 2, 3]) + assert np.array_equal(xp.to_numpy(c[1, 2]), [0, 1, 2, 3]) @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_fft_parity(backend): @@ -120,11 +120,11 @@ def test_stacking_and_concatenation(backend): b = xp.array([4, 5, 6]) res_cat = xp.concatenate([a, b]) - assert xp.array_equal(xp.to_numpy(res_cat), [1, 2, 3, 4, 5, 6]) + assert np.array_equal(xp.to_numpy(res_cat), [1, 2, 3, 4, 5, 6]) res_stack = xp.stack([a, b]) assert res_stack.shape == (2, 3) - assert xp.array_equal(xp.to_numpy(res_stack[1]), [4, 5, 6]) + assert np.array_equal(xp.to_numpy(res_stack[1]), [4, 5, 6]) @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_advanced_indexing(backend): @@ -139,7 +139,7 @@ def test_advanced_indexing(backend): cols = xp.array([1, 3]) indexed = a[rows, cols] - assert xp.array_equal(xp.to_numpy(indexed), [1, 8]) + assert np.array_equal(xp.to_numpy(indexed), [1, 8]) @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_random_generation(backend): From ca6746f7e0df96864eeb9ef479c05bf0d58affc0 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 28 May 2026 11:18:17 +0200 Subject: [PATCH 04/17] formatting --- tests/unit/test_benchmarks.py | 26 ++++++++------ tests/unit/test_features.py | 64 ++++++++++++++++++++-------------- tests/unit/test_integration.py | 32 ++++++++++------- 3 files changed, 74 insertions(+), 48 deletions(-) diff --git a/tests/unit/test_benchmarks.py b/tests/unit/test_benchmarks.py index 3e8de19..6d4aa6a 100644 --- a/tests/unit/test_benchmarks.py +++ b/tests/unit/test_benchmarks.py @@ -1,26 +1,31 @@ +import time + import numpy as np import pytest + import cunumpy as xp -import time + def has_cupy(): try: import cupy import cupy.cuda + return cupy.cuda.is_available() except ImportError: return False + @pytest.mark.skipif(not has_cupy(), reason="CuPy/GPU not available") def test_benchmark_matmul(): """Benchmark matrix multiplication to show CuPy performance gain.""" size = 2000 - + # --- Benchmark NumPy --- with xp.use_backend("numpy"): a_np = xp.random.rand(size, size).astype(xp.float32) b_np = xp.random.rand(size, size).astype(xp.float32) - + start_np = time.perf_counter() c_np = a_np @ b_np # No sync needed for NumPy as it is synchronous @@ -31,14 +36,14 @@ def test_benchmark_matmul(): with xp.use_backend("cupy"): a_cp = xp.random.rand(size, size).astype(xp.float32) b_cp = xp.random.rand(size, size).astype(xp.float32) - + # Warm up _ = a_cp @ b_cp xp.synchronize() - + start_cp = time.perf_counter() c_cp = a_cp @ b_cp - xp.synchronize() # CRITICAL for benchmarking GPU + xp.synchronize() # CRITICAL for benchmarking GPU end_cp = time.perf_counter() t_cp = end_cp - start_cp @@ -46,16 +51,17 @@ def test_benchmark_matmul(): print(f"NumPy time: {t_np:.4f}s") print(f"CuPy time: {t_cp:.4f}s") print(f"Speedup: {t_np/t_cp:.2f}x") - + # On a real GPU (A100/A30), CuPy should be significantly faster # We use a conservative threshold of 1.5x for the test to pass on various hardware assert t_cp < t_np, f"CuPy ({t_cp:.4f}s) was not faster than NumPy ({t_np:.4f}s)" + @pytest.mark.skipif(not has_cupy(), reason="CuPy/GPU not available") def test_benchmark_fft(): """Benchmark FFT performance.""" - size = 2**22 # ~4 million elements - + size = 2**22 # ~4 million elements + with xp.use_backend("numpy"): data_np = xp.random.rand(size).astype(xp.complex64) start = time.perf_counter() @@ -67,7 +73,7 @@ def test_benchmark_fft(): # Warm up _ = xp.fft.fft(data_cp) xp.synchronize() - + start = time.perf_counter() _ = xp.fft.fft(data_cp) xp.synchronize() diff --git a/tests/unit/test_features.py b/tests/unit/test_features.py index 0754fa8..73b32f1 100644 --- a/tests/unit/test_features.py +++ b/tests/unit/test_features.py @@ -1,153 +1,165 @@ import numpy as np import pytest + import cunumpy as xp + def has_cupy(): try: import cupy + return True except ImportError: return False + @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_matrix_multiplication(backend): if backend == "cupy" and not has_cupy(): pytest.skip("CuPy not installed") - + with xp.use_backend(backend): # Test basic @ operator and matmul a = xp.array([[1, 2], [3, 4]], dtype=float) b = xp.array([[5, 6], [7, 8]], dtype=float) c = a @ b - + expected = np.array([[19, 22], [43, 50]]) assert xp.array_equal(xp.to_numpy(c), expected) - + # Test linalg.norm norm = xp.linalg.norm(a) assert np.isclose(float(norm), np.linalg.norm([[1, 2], [3, 4]])) + @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_reductions_and_axes(backend): if backend == "cupy" and not has_cupy(): pytest.skip("CuPy not installed") - + with xp.use_backend(backend): a = xp.array([[1, 10, 100], [2, 20, 200]], dtype=float) - + assert xp.sum(a) == 333 assert np.array_equal(xp.to_numpy(xp.max(a, axis=0)), [2, 20, 200]) assert np.array_equal(xp.to_numpy(xp.min(a, axis=1)), [1, 2]) assert xp.mean(a) == 333 / 6 + @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_complex_elementwise(backend): if backend == "cupy" and not has_cupy(): pytest.skip("CuPy not installed") - + with xp.use_backend(backend): a = xp.array([-1, 0, 1], dtype=float) - + # Exp and Log exp_a = xp.exp(a) assert np.allclose(xp.to_numpy(exp_a), np.exp([-1, 0, 1])) - + # Trig - b = xp.array([0, xp.pi/2], dtype=float) + b = xp.array([0, xp.pi / 2], dtype=float) assert np.allclose(xp.to_numpy(xp.cos(b)), [1, 0], atol=1e-7) + @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_broadcasting_logic(backend): if backend == "cupy" and not has_cupy(): pytest.skip("CuPy not installed") - + with xp.use_backend(backend): # 3D + 1D broadcasting a = xp.ones((2, 3, 4)) b = xp.arange(4) c = a * b - + assert c.shape == (2, 3, 4) assert np.array_equal(xp.to_numpy(c[0, 0]), [0, 1, 2, 3]) assert np.array_equal(xp.to_numpy(c[1, 2]), [0, 1, 2, 3]) + @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_fft_parity(backend): if backend == "cupy" and not has_cupy(): pytest.skip("CuPy not installed") - + with xp.use_backend(backend): # Create a signal with two frequencies t = xp.linspace(0, 1, 128) sig = xp.sin(2 * xp.pi * 5 * t) + 0.5 * xp.sin(2 * xp.pi * 20 * t) - + freqs = xp.fft.fft(sig) inv = xp.fft.ifft(freqs) - + # ifft(fft(x)) == x assert np.allclose(xp.to_numpy(inv.real), xp.to_numpy(sig)) + @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_realistic_normalization_workflow(backend): """Workflow: Load data -> Compute Stats -> Normalize -> Mask Outliers.""" if backend == "cupy" and not has_cupy(): pytest.skip("CuPy not installed") - + with xp.use_backend(backend): # 1. Create dummy data with clear outliers data = xp.array([1.0, 2.0, 3.0, 4.0, 100.0, -100.0]) - + # 2. Normalize mean = xp.mean(data) std = xp.std(data) norm_data = (data - mean) / std - + # 3. Mask outliers (abs > 1.0 in this specific small set) mask = xp.abs(norm_data) < 1.0 clean_data = data[mask] - + # Verify: -100 and 100 should be gone res = xp.to_numpy(xp.sort(clean_data)) assert np.array_equal(res, [1.0, 2.0, 3.0, 4.0]) + @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_stacking_and_concatenation(backend): if backend == "cupy" and not has_cupy(): pytest.skip("CuPy not installed") - + with xp.use_backend(backend): a = xp.array([1, 2, 3]) b = xp.array([4, 5, 6]) - + res_cat = xp.concatenate([a, b]) assert np.array_equal(xp.to_numpy(res_cat), [1, 2, 3, 4, 5, 6]) - + res_stack = xp.stack([a, b]) assert res_stack.shape == (2, 3) assert np.array_equal(xp.to_numpy(res_stack[1]), [4, 5, 6]) + @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_advanced_indexing(backend): if backend == "cupy" and not has_cupy(): pytest.skip("CuPy not installed") - + with xp.use_backend(backend): a = xp.arange(10).reshape(2, 5) - + # Pick specific elements: (0,1) and (1,3) rows = xp.array([0, 1]) cols = xp.array([1, 3]) - + indexed = a[rows, cols] assert np.array_equal(xp.to_numpy(indexed), [1, 8]) + @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_random_generation(backend): if backend == "cupy" and not has_cupy(): pytest.skip("CuPy not installed") - + with xp.use_backend(backend): # Test reproducibility if we were to add seed (checking existing proxy) a = xp.random.normal(0, 1, size=(100, 100)) assert a.shape == (100, 100) - assert xp.abs(xp.mean(a)) < 0.5 # Basic statistical sanity + assert xp.abs(xp.mean(a)) < 0.5 # Basic statistical sanity diff --git a/tests/unit/test_integration.py b/tests/unit/test_integration.py index 1baa595..14a3f57 100644 --- a/tests/unit/test_integration.py +++ b/tests/unit/test_integration.py @@ -1,77 +1,85 @@ import numpy as np import pytest + import cunumpy as xp + def has_cupy(): try: import cupy + return True except ImportError: return False + def test_data_movement_chain(): """Test CPU -> GPU -> CPU multi-hop movement.""" if not has_cupy(): pytest.skip("CuPy not installed") - + # 1. Start on CPU data_orig = np.random.rand(100, 100).astype(np.float32) - + # 2. Move to GPU data_gpu = xp.to_cupy(data_orig) assert xp.is_gpu(data_gpu) - + # 3. Do operation on GPU with xp.use_backend("cupy"): res_gpu = xp.sin(data_gpu) ** 2 + xp.cos(data_gpu) ** 2 - + # 4. Move back to CPU res_cpu = xp.to_numpy(res_gpu) assert isinstance(res_cpu, np.ndarray) assert np.allclose(res_cpu, 1.0) + def test_synchronize_logic(): """Verify synchronize can be called and handles errors gracefully.""" # This is more of a smoke test to ensure the path doesn't crash xp.synchronize() - + if has_cupy(): import cupy as cp + with xp.use_backend("cupy"): a = xp.random.rand(100) xp.synchronize() assert xp.is_gpu(a) + def test_fft_interop(): """Test FFT between backends.""" if not has_cupy(): pytest.skip("CuPy not installed") - + # Create signal on CPU sig_cpu = np.random.rand(1024).astype(np.complex128) - + # Move to GPU and transform sig_gpu = xp.to_cupy(sig_cpu) freq_gpu = xp.fft.fft(sig_gpu) - + # Move frequencies to CPU and transform back freq_cpu = xp.to_numpy(freq_gpu) sig_reconstructed = np.fft.ifft(freq_cpu) - + assert np.allclose(sig_cpu, sig_reconstructed) + def test_mixed_backend_errors(): """Verify that mixing backends in operations raises errors (standard NumPy/CuPy behavior).""" if not has_cupy(): pytest.skip("CuPy not installed") - + a_cpu = np.array([1, 2, 3]) a_gpu = xp.to_cupy(a_cpu) - + # This should fail because you can't add CPU and GPU arrays directly with pytest.raises(Exception): _ = a_cpu + a_gpu - + # But to_cunumpy should fix it a_gpu_fixed = xp.to_cunumpy(a_cpu) with xp.use_backend("cupy"): From c7307093e2aba77d94732e5ea8f17af385e4648e Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 28 May 2026 11:52:16 +0200 Subject: [PATCH 05/17] fix tests --- src/cunumpy/__init__.py | 2 ++ src/cunumpy/xp.py | 20 +++++++++++++++ tests/unit/test_benchmarks.py | 18 +++++--------- tests/unit/test_cupy.py | 24 ++++++++---------- tests/unit/test_features.py | 45 ++++++++++++++-------------------- tests/unit/test_integration.py | 23 ++++++----------- 6 files changed, 63 insertions(+), 69 deletions(-) diff --git a/src/cunumpy/__init__.py b/src/cunumpy/__init__.py index c6592bd..8fc3d8f 100644 --- a/src/cunumpy/__init__.py +++ b/src/cunumpy/__init__.py @@ -2,6 +2,7 @@ from . import xp from .xp import ( get_backend, + has_cupy, is_cpu, is_gpu, set_backend, @@ -14,6 +15,7 @@ __all__ = [ "xp", + "has_cupy", "to_numpy", "to_cupy", "to_cunumpy", diff --git a/src/cunumpy/xp.py b/src/cunumpy/xp.py index 36d3b9c..b3a6519 100644 --- a/src/cunumpy/xp.py +++ b/src/cunumpy/xp.py @@ -92,6 +92,26 @@ def set_backend(backend: BackendType) -> None: array_backend._xp = array_backend._load_backend(backend) +def has_cupy() -> bool: + """Check if CuPy is available and functional.""" + try: + import cupy as cp + + # Check if a GPU is available + if not cp.is_available(): + return False + + # Verify that essential libraries are loadable by performing a small operation. + # This prevents failures in environments where CuPy is installed but CUDA + # libraries (like libcublas or libcufft) are missing. + a = cp.array([1.0], dtype=cp.float32) + _ = a @ a + + return True + except (ImportError, Exception): + return False + + def _cupy_backend() -> bool: """Check if the active global backend is CuPy.""" return array_backend.backend == "cupy" diff --git a/tests/unit/test_benchmarks.py b/tests/unit/test_benchmarks.py index 6d4aa6a..5667954 100644 --- a/tests/unit/test_benchmarks.py +++ b/tests/unit/test_benchmarks.py @@ -6,17 +6,9 @@ import cunumpy as xp -def has_cupy(): - try: - import cupy - import cupy.cuda - - return cupy.cuda.is_available() - except ImportError: - return False - - -@pytest.mark.skipif(not has_cupy(), reason="CuPy/GPU not available") +@pytest.mark.skipif( + not xp.has_cupy(), reason="CuPy/GPU not available or not functional" +) def test_benchmark_matmul(): """Benchmark matrix multiplication to show CuPy performance gain.""" size = 2000 @@ -57,7 +49,9 @@ def test_benchmark_matmul(): assert t_cp < t_np, f"CuPy ({t_cp:.4f}s) was not faster than NumPy ({t_np:.4f}s)" -@pytest.mark.skipif(not has_cupy(), reason="CuPy/GPU not available") +@pytest.mark.skipif( + not xp.has_cupy(), reason="CuPy/GPU not available or not functional" +) def test_benchmark_fft(): """Benchmark FFT performance.""" size = 2**22 # ~4 million elements diff --git a/tests/unit/test_cupy.py b/tests/unit/test_cupy.py index 935a27a..04a100d 100644 --- a/tests/unit/test_cupy.py +++ b/tests/unit/test_cupy.py @@ -5,10 +5,10 @@ def test_to_cupy_available(): - try: - import cupy as cp - except ImportError: - pytest.skip("CuPy not installed") + if not xp.has_cupy(): + pytest.skip("CuPy not installed or not functional") + + import cupy as cp with xp.use_backend("cupy"): arr = np.array([1, 2, 3]) @@ -17,12 +17,8 @@ def test_to_cupy_available(): def test_to_cupy_not_available(): - try: - import cupy - - pytest.skip("CuPy is installed, cannot test missing cupy error") - except ImportError: - pass + if xp.has_cupy(): + pytest.skip("CuPy is installed and functional, cannot test missing cupy error") with xp.use_backend("cupy"): arr = np.array([1, 2, 3]) @@ -42,10 +38,10 @@ def test_synchronize(): def test_xp_array_cupy(): - try: - import cupy as cp - except ImportError: - pytest.skip("CuPy not installed") + if not xp.has_cupy(): + pytest.skip("CuPy not installed or not functional") + + import cupy as cp with xp.use_backend("cupy"): arr = xp.array([1, 2]) diff --git a/tests/unit/test_features.py b/tests/unit/test_features.py index 73b32f1..c920bc6 100644 --- a/tests/unit/test_features.py +++ b/tests/unit/test_features.py @@ -4,19 +4,10 @@ import cunumpy as xp -def has_cupy(): - try: - import cupy - - return True - except ImportError: - return False - - @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_matrix_multiplication(backend): - if backend == "cupy" and not has_cupy(): - pytest.skip("CuPy not installed") + if backend == "cupy" and not xp.has_cupy(): + pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): # Test basic @ operator and matmul @@ -34,8 +25,8 @@ def test_matrix_multiplication(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_reductions_and_axes(backend): - if backend == "cupy" and not has_cupy(): - pytest.skip("CuPy not installed") + if backend == "cupy" and not xp.has_cupy(): + pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): a = xp.array([[1, 10, 100], [2, 20, 200]], dtype=float) @@ -48,8 +39,8 @@ def test_reductions_and_axes(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_complex_elementwise(backend): - if backend == "cupy" and not has_cupy(): - pytest.skip("CuPy not installed") + if backend == "cupy" and not xp.has_cupy(): + pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): a = xp.array([-1, 0, 1], dtype=float) @@ -65,8 +56,8 @@ def test_complex_elementwise(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_broadcasting_logic(backend): - if backend == "cupy" and not has_cupy(): - pytest.skip("CuPy not installed") + if backend == "cupy" and not xp.has_cupy(): + pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): # 3D + 1D broadcasting @@ -81,8 +72,8 @@ def test_broadcasting_logic(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_fft_parity(backend): - if backend == "cupy" and not has_cupy(): - pytest.skip("CuPy not installed") + if backend == "cupy" and not xp.has_cupy(): + pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): # Create a signal with two frequencies @@ -99,8 +90,8 @@ def test_fft_parity(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_realistic_normalization_workflow(backend): """Workflow: Load data -> Compute Stats -> Normalize -> Mask Outliers.""" - if backend == "cupy" and not has_cupy(): - pytest.skip("CuPy not installed") + if backend == "cupy" and not xp.has_cupy(): + pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): # 1. Create dummy data with clear outliers @@ -122,8 +113,8 @@ def test_realistic_normalization_workflow(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_stacking_and_concatenation(backend): - if backend == "cupy" and not has_cupy(): - pytest.skip("CuPy not installed") + if backend == "cupy" and not xp.has_cupy(): + pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): a = xp.array([1, 2, 3]) @@ -139,8 +130,8 @@ def test_stacking_and_concatenation(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_advanced_indexing(backend): - if backend == "cupy" and not has_cupy(): - pytest.skip("CuPy not installed") + if backend == "cupy" and not xp.has_cupy(): + pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): a = xp.arange(10).reshape(2, 5) @@ -155,8 +146,8 @@ def test_advanced_indexing(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_random_generation(backend): - if backend == "cupy" and not has_cupy(): - pytest.skip("CuPy not installed") + if backend == "cupy" and not xp.has_cupy(): + pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): # Test reproducibility if we were to add seed (checking existing proxy) diff --git a/tests/unit/test_integration.py b/tests/unit/test_integration.py index 14a3f57..f3138d3 100644 --- a/tests/unit/test_integration.py +++ b/tests/unit/test_integration.py @@ -4,19 +4,10 @@ import cunumpy as xp -def has_cupy(): - try: - import cupy - - return True - except ImportError: - return False - - def test_data_movement_chain(): """Test CPU -> GPU -> CPU multi-hop movement.""" - if not has_cupy(): - pytest.skip("CuPy not installed") + if not xp.has_cupy(): + pytest.skip("CuPy not installed or not functional") # 1. Start on CPU data_orig = np.random.rand(100, 100).astype(np.float32) @@ -40,7 +31,7 @@ def test_synchronize_logic(): # This is more of a smoke test to ensure the path doesn't crash xp.synchronize() - if has_cupy(): + if xp.has_cupy(): import cupy as cp with xp.use_backend("cupy"): @@ -51,8 +42,8 @@ def test_synchronize_logic(): def test_fft_interop(): """Test FFT between backends.""" - if not has_cupy(): - pytest.skip("CuPy not installed") + if not xp.has_cupy(): + pytest.skip("CuPy not installed or not functional") # Create signal on CPU sig_cpu = np.random.rand(1024).astype(np.complex128) @@ -70,8 +61,8 @@ def test_fft_interop(): def test_mixed_backend_errors(): """Verify that mixing backends in operations raises errors (standard NumPy/CuPy behavior).""" - if not has_cupy(): - pytest.skip("CuPy not installed") + if not xp.has_cupy(): + pytest.skip("CuPy not installed or not functional") a_cpu = np.array([1, 2, 3]) a_gpu = xp.to_cupy(a_cpu) From f5ecc7a8cb69fbb41595f6307a2d6910727bba11 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 28 May 2026 11:57:50 +0200 Subject: [PATCH 06/17] load fft --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b76c09b..b3100e3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,6 +15,7 @@ gpu_tests: before_script: - module load python-waterboa/2025.06 - module load nvhpcsdk/26 + - module load fftw-serial/3.3.10 script: - echo "--- CUDA Sanity Check ---" - nvidia-smi From a59f36c1324f21c96e45cd94a64b9add1d4efb39 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 28 May 2026 12:03:43 +0200 Subject: [PATCH 07/17] temp: added has cupy cache --- src/cunumpy/xp.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/cunumpy/xp.py b/src/cunumpy/xp.py index b3a6519..6201f27 100644 --- a/src/cunumpy/xp.py +++ b/src/cunumpy/xp.py @@ -92,13 +92,21 @@ def set_backend(backend: BackendType) -> None: array_backend._xp = array_backend._load_backend(backend) +_HAS_CUPY_CACHE = None + + def has_cupy() -> bool: """Check if CuPy is available and functional.""" + global _HAS_CUPY_CACHE + if _HAS_CUPY_CACHE is not None: + return _HAS_CUPY_CACHE + try: import cupy as cp # Check if a GPU is available if not cp.is_available(): + _HAS_CUPY_CACHE = False return False # Verify that essential libraries are loadable by performing a small operation. @@ -107,8 +115,10 @@ def has_cupy() -> bool: a = cp.array([1.0], dtype=cp.float32) _ = a @ a + _HAS_CUPY_CACHE = True return True except (ImportError, Exception): + _HAS_CUPY_CACHE = False return False @@ -143,12 +153,12 @@ def to_numpy(array: Any) -> np.ndarray: def to_cupy(array: Any) -> Any: """Convert an array to a CuPy array.""" - try: - import cupy as cp + if not has_cupy(): + raise ImportError("CuPy is not available or not functional.") + + import cupy as cp - return cp.asarray(array) - except ImportError: - raise ImportError("CuPy is not available.") + return cp.asarray(array) def to_cunumpy(array: Any) -> Any: From 834bbc74e77de549770b0da23de36bbd32be9de4 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 28 May 2026 12:34:59 +0200 Subject: [PATCH 08/17] More robust cupy import --- src/cunumpy/xp.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cunumpy/xp.py b/src/cunumpy/xp.py index 6201f27..e5adeb2 100644 --- a/src/cunumpy/xp.py +++ b/src/cunumpy/xp.py @@ -27,13 +27,13 @@ def __init__( def _load_backend(self, backend: BackendType, verbose: bool = False) -> ModuleType: if backend == "cupy": - try: + if has_cupy(): import cupy as cp return cp - except ImportError: + else: if verbose: - print("CuPy not available.") + print("CuPy not available or not functional.") return np import numpy as np_mod @@ -163,7 +163,7 @@ def to_cupy(array: Any) -> Any: def to_cunumpy(array: Any) -> Any: """Convert an array to the currently active backend.""" - if array_backend.backend == "cupy": + if array_backend.backend == "cupy" and has_cupy(): return to_cupy(array) return to_numpy(array) From 903467c060edc1370a699cb0cad854ea53783d1a Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 28 May 2026 13:30:32 +0200 Subject: [PATCH 09/17] Added has_cupy() method --- src/cunumpy/xp.py | 60 +++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/cunumpy/xp.py b/src/cunumpy/xp.py index e5adeb2..7da64e5 100644 --- a/src/cunumpy/xp.py +++ b/src/cunumpy/xp.py @@ -8,6 +8,36 @@ BackendType = Literal["numpy", "cupy"] +_HAS_CUPY_CACHE = None + + +def has_cupy() -> bool: + """Check if CuPy is available and functional.""" + global _HAS_CUPY_CACHE + if _HAS_CUPY_CACHE is not None: + return _HAS_CUPY_CACHE + + try: + import cupy as cp + + # Check if a GPU is available + if not cp.is_available(): + _HAS_CUPY_CACHE = False + return False + + # Verify that essential libraries are loadable by performing a small operation. + # This prevents failures in environments where CuPy is installed but CUDA + # libraries (like libcublas or libcufft) are missing. + a = cp.array([1.0], dtype=cp.float32) + _ = a @ a + + _HAS_CUPY_CACHE = True + return True + except (ImportError, Exception): + _HAS_CUPY_CACHE = False + return False + + class ArrayBackend: def __init__( self, @@ -92,36 +122,6 @@ def set_backend(backend: BackendType) -> None: array_backend._xp = array_backend._load_backend(backend) -_HAS_CUPY_CACHE = None - - -def has_cupy() -> bool: - """Check if CuPy is available and functional.""" - global _HAS_CUPY_CACHE - if _HAS_CUPY_CACHE is not None: - return _HAS_CUPY_CACHE - - try: - import cupy as cp - - # Check if a GPU is available - if not cp.is_available(): - _HAS_CUPY_CACHE = False - return False - - # Verify that essential libraries are loadable by performing a small operation. - # This prevents failures in environments where CuPy is installed but CUDA - # libraries (like libcublas or libcufft) are missing. - a = cp.array([1.0], dtype=cp.float32) - _ = a @ a - - _HAS_CUPY_CACHE = True - return True - except (ImportError, Exception): - _HAS_CUPY_CACHE = False - return False - - def _cupy_backend() -> bool: """Check if the active global backend is CuPy.""" return array_backend.backend == "cupy" From 851a0b2b5f12864be32cc9b8a319bf2073055cd5 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 28 May 2026 13:40:01 +0200 Subject: [PATCH 10/17] Run pytest -xvs --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b3100e3..62e9599 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -38,4 +38,4 @@ gpu_tests: - export PATH="$HOME/.local/bin:$PATH" - export ARRAY_BACKEND=cupy - - python3 -m pytest tests/unit/ + - pytest -xvs . From ec04a25856fbd2b82d67d32d49edc1d6e94820f5 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 28 May 2026 13:43:42 +0200 Subject: [PATCH 11/17] Renamed has_cupy to cupy_available --- src/cunumpy/__init__.py | 4 ++-- src/cunumpy/__init__.pyi | 1 + src/cunumpy/xp.py | 22 +++++++++++----------- tests/unit/test_benchmarks.py | 5 +++-- tests/unit/test_cupy.py | 6 +++--- tests/unit/test_features.py | 18 +++++++++--------- tests/unit/test_integration.py | 8 ++++---- 7 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/cunumpy/__init__.py b/src/cunumpy/__init__.py index 8fc3d8f..383f32a 100644 --- a/src/cunumpy/__init__.py +++ b/src/cunumpy/__init__.py @@ -1,8 +1,8 @@ # cunumpy/__init__.py from . import xp from .xp import ( + cupy_available, get_backend, - has_cupy, is_cpu, is_gpu, set_backend, @@ -15,7 +15,7 @@ __all__ = [ "xp", - "has_cupy", + "cupy_available", "to_numpy", "to_cupy", "to_cunumpy", diff --git a/src/cunumpy/__init__.pyi b/src/cunumpy/__init__.pyi index 2a4fb33..8f51ee6 100644 --- a/src/cunumpy/__init__.pyi +++ b/src/cunumpy/__init__.pyi @@ -12,6 +12,7 @@ from . import xp def to_numpy(array: Any) -> np.ndarray: ... def to_cupy(array: Any) -> Any: ... def to_cunumpy(array: Any) -> Any: ... +def cupy_available() -> bool: ... def get_backend(array: Any) -> str: ... def is_gpu(array: Any) -> bool: ... def is_cpu(array: Any) -> bool: ... diff --git a/src/cunumpy/xp.py b/src/cunumpy/xp.py index 7da64e5..92137ff 100644 --- a/src/cunumpy/xp.py +++ b/src/cunumpy/xp.py @@ -8,21 +8,21 @@ BackendType = Literal["numpy", "cupy"] -_HAS_CUPY_CACHE = None +_CUPY_AVAILABLE_CACHE = None -def has_cupy() -> bool: +def cupy_available() -> bool: """Check if CuPy is available and functional.""" - global _HAS_CUPY_CACHE - if _HAS_CUPY_CACHE is not None: - return _HAS_CUPY_CACHE + global _CUPY_AVAILABLE_CACHE + if _CUPY_AVAILABLE_CACHE is not None: + return _CUPY_AVAILABLE_CACHE try: import cupy as cp # Check if a GPU is available if not cp.is_available(): - _HAS_CUPY_CACHE = False + _CUPY_AVAILABLE_CACHE = False return False # Verify that essential libraries are loadable by performing a small operation. @@ -31,10 +31,10 @@ def has_cupy() -> bool: a = cp.array([1.0], dtype=cp.float32) _ = a @ a - _HAS_CUPY_CACHE = True + _CUPY_AVAILABLE_CACHE = True return True except (ImportError, Exception): - _HAS_CUPY_CACHE = False + _CUPY_AVAILABLE_CACHE = False return False @@ -57,7 +57,7 @@ def __init__( def _load_backend(self, backend: BackendType, verbose: bool = False) -> ModuleType: if backend == "cupy": - if has_cupy(): + if cupy_available(): import cupy as cp return cp @@ -153,7 +153,7 @@ def to_numpy(array: Any) -> np.ndarray: def to_cupy(array: Any) -> Any: """Convert an array to a CuPy array.""" - if not has_cupy(): + if not cupy_available(): raise ImportError("CuPy is not available or not functional.") import cupy as cp @@ -163,7 +163,7 @@ def to_cupy(array: Any) -> Any: def to_cunumpy(array: Any) -> Any: """Convert an array to the currently active backend.""" - if array_backend.backend == "cupy" and has_cupy(): + if array_backend.backend == "cupy" and cupy_available(): return to_cupy(array) return to_numpy(array) diff --git a/tests/unit/test_benchmarks.py b/tests/unit/test_benchmarks.py index 5667954..f9a9e35 100644 --- a/tests/unit/test_benchmarks.py +++ b/tests/unit/test_benchmarks.py @@ -7,7 +7,7 @@ @pytest.mark.skipif( - not xp.has_cupy(), reason="CuPy/GPU not available or not functional" + not xp.cupy_available(), reason="CuPy/GPU not available or not functional" ) def test_benchmark_matmul(): """Benchmark matrix multiplication to show CuPy performance gain.""" @@ -50,7 +50,7 @@ def test_benchmark_matmul(): @pytest.mark.skipif( - not xp.has_cupy(), reason="CuPy/GPU not available or not functional" + not xp.cupy_available(), reason="CuPy/GPU not available or not functional" ) def test_benchmark_fft(): """Benchmark FFT performance.""" @@ -76,4 +76,5 @@ def test_benchmark_fft(): print(f"\n[Benchmark] FFT Size: {size}") print(f"NumPy time: {t_np:.4f}s") print(f"CuPy time: {t_cp:.4f}s") + print(f"Speedup: {t_np/t_cp:.2f}x") assert t_cp < t_np diff --git a/tests/unit/test_cupy.py b/tests/unit/test_cupy.py index 04a100d..a8e804d 100644 --- a/tests/unit/test_cupy.py +++ b/tests/unit/test_cupy.py @@ -5,7 +5,7 @@ def test_to_cupy_available(): - if not xp.has_cupy(): + if not xp.cupy_available(): pytest.skip("CuPy not installed or not functional") import cupy as cp @@ -17,7 +17,7 @@ def test_to_cupy_available(): def test_to_cupy_not_available(): - if xp.has_cupy(): + if xp.cupy_available(): pytest.skip("CuPy is installed and functional, cannot test missing cupy error") with xp.use_backend("cupy"): @@ -38,7 +38,7 @@ def test_synchronize(): def test_xp_array_cupy(): - if not xp.has_cupy(): + if not xp.cupy_available(): pytest.skip("CuPy not installed or not functional") import cupy as cp diff --git a/tests/unit/test_features.py b/tests/unit/test_features.py index c920bc6..1e0aced 100644 --- a/tests/unit/test_features.py +++ b/tests/unit/test_features.py @@ -6,7 +6,7 @@ @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_matrix_multiplication(backend): - if backend == "cupy" and not xp.has_cupy(): + if backend == "cupy" and not xp.cupy_available(): pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): @@ -25,7 +25,7 @@ def test_matrix_multiplication(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_reductions_and_axes(backend): - if backend == "cupy" and not xp.has_cupy(): + if backend == "cupy" and not xp.cupy_available(): pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): @@ -39,7 +39,7 @@ def test_reductions_and_axes(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_complex_elementwise(backend): - if backend == "cupy" and not xp.has_cupy(): + if backend == "cupy" and not xp.cupy_available(): pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): @@ -56,7 +56,7 @@ def test_complex_elementwise(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_broadcasting_logic(backend): - if backend == "cupy" and not xp.has_cupy(): + if backend == "cupy" and not xp.cupy_available(): pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): @@ -72,7 +72,7 @@ def test_broadcasting_logic(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_fft_parity(backend): - if backend == "cupy" and not xp.has_cupy(): + if backend == "cupy" and not xp.cupy_available(): pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): @@ -90,7 +90,7 @@ def test_fft_parity(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_realistic_normalization_workflow(backend): """Workflow: Load data -> Compute Stats -> Normalize -> Mask Outliers.""" - if backend == "cupy" and not xp.has_cupy(): + if backend == "cupy" and not xp.cupy_available(): pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): @@ -113,7 +113,7 @@ def test_realistic_normalization_workflow(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_stacking_and_concatenation(backend): - if backend == "cupy" and not xp.has_cupy(): + if backend == "cupy" and not xp.cupy_available(): pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): @@ -130,7 +130,7 @@ def test_stacking_and_concatenation(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_advanced_indexing(backend): - if backend == "cupy" and not xp.has_cupy(): + if backend == "cupy" and not xp.cupy_available(): pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): @@ -146,7 +146,7 @@ def test_advanced_indexing(backend): @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_random_generation(backend): - if backend == "cupy" and not xp.has_cupy(): + if backend == "cupy" and not xp.cupy_available(): pytest.skip("CuPy not installed or not functional") with xp.use_backend(backend): diff --git a/tests/unit/test_integration.py b/tests/unit/test_integration.py index f3138d3..d13764f 100644 --- a/tests/unit/test_integration.py +++ b/tests/unit/test_integration.py @@ -6,7 +6,7 @@ def test_data_movement_chain(): """Test CPU -> GPU -> CPU multi-hop movement.""" - if not xp.has_cupy(): + if not xp.cupy_available(): pytest.skip("CuPy not installed or not functional") # 1. Start on CPU @@ -31,7 +31,7 @@ def test_synchronize_logic(): # This is more of a smoke test to ensure the path doesn't crash xp.synchronize() - if xp.has_cupy(): + if xp.cupy_available(): import cupy as cp with xp.use_backend("cupy"): @@ -42,7 +42,7 @@ def test_synchronize_logic(): def test_fft_interop(): """Test FFT between backends.""" - if not xp.has_cupy(): + if not xp.cupy_available(): pytest.skip("CuPy not installed or not functional") # Create signal on CPU @@ -61,7 +61,7 @@ def test_fft_interop(): def test_mixed_backend_errors(): """Verify that mixing backends in operations raises errors (standard NumPy/CuPy behavior).""" - if not xp.has_cupy(): + if not xp.cupy_available(): pytest.skip("CuPy not installed or not functional") a_cpu = np.array([1, 2, 3]) From 64ffb33c6c520addcf85832f769c572d5c697a8f Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 28 May 2026 13:45:19 +0200 Subject: [PATCH 12/17] skip matmul test to check if cupy is available --- src/cunumpy/xp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cunumpy/xp.py b/src/cunumpy/xp.py index 92137ff..adbfc51 100644 --- a/src/cunumpy/xp.py +++ b/src/cunumpy/xp.py @@ -28,8 +28,8 @@ def cupy_available() -> bool: # Verify that essential libraries are loadable by performing a small operation. # This prevents failures in environments where CuPy is installed but CUDA # libraries (like libcublas or libcufft) are missing. - a = cp.array([1.0], dtype=cp.float32) - _ = a @ a + # a = cp.array([1.0], dtype=cp.float32) + # _ = a @ a _CUPY_AVAILABLE_CACHE = True return True From 97e3b1efdcc3dea20cbaeb20b896aa21a8110541 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 28 May 2026 14:28:58 +0200 Subject: [PATCH 13/17] Install nvidia-cublas-cu12 --- .gitlab-ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 62e9599..79d4881 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -32,10 +32,15 @@ gpu_tests: # The MPCDF image likely has a specific python environment. # We install our dependencies into the user directory or a virtualenv. - python3 -m pip install --user cupy-cuda12x + - python3 -m pip install --user nvidia-cublas-cu12 nvidia-cufft-cu12 nvidia-curand-cu12 nvidia-cusolver-cu12 nvidia-cusparse-cu12 - python3 -m pip install --user -e . # Add the user bin to PATH for pytest - export PATH="$HOME/.local/bin:$PATH" + + # Try to find libcublas and other libraries in the HPC environment + - export LD_LIBRARY_PATH=$(find /mpcdf/soft /opt/nvidia -name libcublas.so.12 -exec dirname {} \; 2>/dev/null | head -n 1):$LD_LIBRARY_PATH + - export ARRAY_BACKEND=cupy - pytest -xvs . From 54f5baaba5e3248ab0c702aa80c671ac2442469b Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 28 May 2026 14:31:40 +0200 Subject: [PATCH 14/17] Remove the matmul test to check for _CUPY_AVAILABLE_CACHE --- src/cunumpy/xp.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/cunumpy/xp.py b/src/cunumpy/xp.py index adbfc51..8e92e9a 100644 --- a/src/cunumpy/xp.py +++ b/src/cunumpy/xp.py @@ -21,18 +21,8 @@ def cupy_available() -> bool: import cupy as cp # Check if a GPU is available - if not cp.is_available(): - _CUPY_AVAILABLE_CACHE = False - return False - - # Verify that essential libraries are loadable by performing a small operation. - # This prevents failures in environments where CuPy is installed but CUDA - # libraries (like libcublas or libcufft) are missing. - # a = cp.array([1.0], dtype=cp.float32) - # _ = a @ a - - _CUPY_AVAILABLE_CACHE = True - return True + _CUPY_AVAILABLE_CACHE = cp.is_available() + return _CUPY_AVAILABLE_CACHE except (ImportError, Exception): _CUPY_AVAILABLE_CACHE = False return False From 30350d0d95dbf2565fd23b04e3df0c2603a4d2b8 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 28 May 2026 15:23:14 +0200 Subject: [PATCH 15/17] Run gitlab ci using the CLI --- .github/workflows/gpu_ci_trigger.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/gpu_ci_trigger.yml b/.github/workflows/gpu_ci_trigger.yml index 74ac41f..cc249d6 100644 --- a/.github/workflows/gpu_ci_trigger.yml +++ b/.github/workflows/gpu_ci_trigger.yml @@ -12,9 +12,11 @@ # # 3. CONFIGURE GITHUB (The Source): # - Go to GitHub repo > Settings > Secrets and variables > Actions. -# - Add a new Repository Secret: +# - Add new Repository Secrets: # - Name: GITLAB_SSH_PRIVATE_KEY -# - Value: Paste the entire content of '~/.ssh/gitlab_sync_key'. +# Value: Paste the entire content of '~/.ssh/gitlab_sync_key'. +# - Name: GITLAB_TOKEN +# Value: Your GitLab Personal Access Token (with 'api' and 'read_repository' scopes). # name: Sync to GitLab and Run GPU CI @@ -41,6 +43,7 @@ jobs: ssh-private-key: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }} - name: Push to GitLab via SSH & Provide Link + id: push run: | # 1. Setup SSH known hosts mkdir -p ~/.ssh @@ -52,6 +55,7 @@ jobs: else TARGET_BRANCH="${{ github.ref_name }}" fi + echo "TARGET_BRANCH=$TARGET_BRANCH" >> $GITHUB_ENV # 3. Add GitLab SSH remote git remote add gitlab git@gitlab.mpcdf.mpg.de:maxlin/cunumpy.git @@ -60,9 +64,17 @@ jobs: git push -f gitlab HEAD:refs/heads/$TARGET_BRANCH # 5. Provide the direct link - # We construct the URL manually since the push triggers the pipeline automatically PIPELINE_URL="https://gitlab.mpcdf.mpg.de/maxlin/cunumpy/-/pipelines?ref=$TARGET_BRANCH" echo "::notice::GitLab GPU CI Pipeline started automatically via Push!" echo "::notice::View Pipeline: $PIPELINE_URL" + - name: Wait for GitLab Pipeline + uses: docker://gitlab/glab:latest + env: + GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} + GITLAB_HOST: gitlab.mpcdf.mpg.de + with: + entrypoint: glab + args: ci status --wait --branch ${{ env.TARGET_BRANCH }} --repo maxlin/cunumpy + From 0572cc4d13df357cf184cc101a67b4b8ad3c123e Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 28 May 2026 15:43:29 +0200 Subject: [PATCH 16/17] Replaced glab --wait with --live --- .github/workflows/gpu_ci_trigger.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/gpu_ci_trigger.yml b/.github/workflows/gpu_ci_trigger.yml index cc249d6..e40d19d 100644 --- a/.github/workflows/gpu_ci_trigger.yml +++ b/.github/workflows/gpu_ci_trigger.yml @@ -76,5 +76,4 @@ jobs: GITLAB_HOST: gitlab.mpcdf.mpg.de with: entrypoint: glab - args: ci status --wait --branch ${{ env.TARGET_BRANCH }} --repo maxlin/cunumpy - + args: ci status --live --branch ${{ env.TARGET_BRANCH }} --repo maxlin/cunumpy From e78cef7ac24bb83d8acc336d9b8adc3d22757074 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 28 May 2026 15:54:59 +0200 Subject: [PATCH 17/17] Added gpu-test prefix --- .github/workflows/gpu_ci_trigger.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gpu_ci_trigger.yml b/.github/workflows/gpu_ci_trigger.yml index e40d19d..4418074 100644 --- a/.github/workflows/gpu_ci_trigger.yml +++ b/.github/workflows/gpu_ci_trigger.yml @@ -51,9 +51,11 @@ jobs: # 2. Determine target branch if [ "${{ github.event_name }}" == "pull_request" ]; then - TARGET_BRANCH="pr-${{ github.event.number }}" + TARGET_BRANCH="gpu-test-pr-${{ github.event.number }}" else - TARGET_BRANCH="${{ github.ref_name }}" + SOURCE_REF="${{ github.ref_name }}" + SAFE_REF="${SOURCE_REF//\//-}" + TARGET_BRANCH="gpu-test-${SAFE_REF}" fi echo "TARGET_BRANCH=$TARGET_BRANCH" >> $GITHUB_ENV