Repository: numba/numba

Issue:
CUDA: Copying zero-size device array to zero-size host array can fail if strides differ
The following:

```python
from numba import cuda
import numpy as np

shape = (0,)
dtype = 'f4'
order = 'C'

ary = np.arange(np.product(shape), dtype=dtype)
ary = np.ndarray(shape, dtype=ary.dtype, buffer=ary.data, order=order)
x = cuda.to_device(ary)

hx = np.empty_like(ary)
x.copy_to_host(hx)
```

when run with NumPy 1.23 results in:

```
$ python repro.py 
Traceback (most recent call last):
  File "/home/gmarkall/numbadev/issues/distributed-serialize/repro.py", line 13, in <module>
    x.copy_to_host(hx)
  File "/home/gmarkall/numbadev/numba/numba/cuda/cudadrv/devices.py", line 232, in _require_cuda_context
    return fn(*args, **kws)
  File "/home/gmarkall/numbadev/numba/numba/cuda/cudadrv/devicearray.py", line 273, in copy_to_host
    check_array_compatibility(self, ary)
  File "/home/gmarkall/numbadev/numba/numba/cuda/cudadrv/devicearray.py", line 899, in check_array_compatibility
    raise ValueError('incompatible strides: %s vs. %s' %
ValueError: incompatible strides: (4,) vs. (0,)
```

One workaround for this is:

```diff
diff --git a/numba/cuda/cudadrv/devicearray.py b/numba/cuda/cudadrv/devicearray.py
index 651f06bf6..dd8878cab 100644
--- a/numba/cuda/cudadrv/devicearray.py
+++ b/numba/cuda/cudadrv/devicearray.py
@@ -895,6 +895,6 @@ def check_array_compatibility(ary1, ary2):
     if ary1sq.shape != ary2sq.shape:
         raise ValueError('incompatible shape: %s vs. %s' %
                          (ary1.shape, ary2.shape))
-    if ary1sq.strides != ary2sq.strides:
+    if (ary1sq.strides != ary2sq.strides) and ary1.size:
         raise ValueError('incompatible strides: %s vs. %s' %
                          (ary1.strides, ary2.strides))
```

which is probably a reasonable workaround, given that the strides don't matter for zero-sized arrays.

One question is whether `numpy.empty_like()` should be copying the strides of its argument. If I add:

```python
print(f"{ary.shape}, {ary.strides}")
print(f"{x.shape}, {x.strides}")
print(f"{hx.shape}, {hx.strides}")
```

to the above example, I get:

```
(0,), (4,)
(0,), (4,)
(0,), (0,)
```

Maybe this should come out as `(0,), (4,)` on all lines (i.e. the strides of `hx` should be `(4,)` too).


Discussion:

Resolving patch:
diff --git a/numba/cuda/cudadrv/devicearray.py b/numba/cuda/cudadrv/devicearray.py
--- a/numba/cuda/cudadrv/devicearray.py
+++ b/numba/cuda/cudadrv/devicearray.py
@@ -895,6 +895,8 @@ def check_array_compatibility(ary1, ary2):
     if ary1sq.shape != ary2sq.shape:
         raise ValueError('incompatible shape: %s vs. %s' %
                          (ary1.shape, ary2.shape))
-    if ary1sq.strides != ary2sq.strides:
+    # We check strides only if the size is nonzero, because strides are
+    # irrelevant (and can differ) for zero-length copies.
+    if ary1.size and ary1sq.strides != ary2sq.strides:
         raise ValueError('incompatible strides: %s vs. %s' %
                          (ary1.strides, ary2.strides))
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -375,7 +375,7 @@ def check_file_at_path(path2file):
 install_requires = [
     'llvmlite >={},<{}'.format(min_llvmlite_version, max_llvmlite_version),
     'numpy >={},<{}'.format(min_numpy_run_version, max_numpy_run_version),
-    'setuptools<60',
+    'setuptools',
     'importlib_metadata; python_version < "3.9"',
 ]
 


Test patch:
diff --git a/numba/cuda/tests/cudadrv/test_cuda_ndarray.py b/numba/cuda/tests/cudadrv/test_cuda_ndarray.py
--- a/numba/cuda/tests/cudadrv/test_cuda_ndarray.py
+++ b/numba/cuda/tests/cudadrv/test_cuda_ndarray.py
@@ -420,6 +420,41 @@ def test_bug6697(self):
         got = np.asarray(dary)
         self.assertEqual(got.dtype, dary.dtype)
 
+    @skip_on_cudasim('DeviceNDArray class not present in simulator')
+    def test_issue_8477(self):
+        # Ensure that we can copy a zero-length device array to a zero-length
+        # host array when the strides of the device and host arrays differ -
+        # this should be possible because the strides are irrelevant when the
+        # length is zero. For more info see
+        # https://github.com/numba/numba/issues/8477.
+
+        # Create a device array with shape (0,) and strides (8,)
+        dev_array = devicearray.DeviceNDArray(shape=(0,), strides=(8,),
+                                              dtype=np.int8)
+
+        # Create a host array with shape (0,) and strides (0,)
+        host_array = np.ndarray(shape=(0,), strides=(0,), dtype=np.int8)
+
+        # Sanity check for this test - ensure our destination has the strides
+        # we expect, because strides can be ignored in some cases by the
+        # ndarray constructor - checking here ensures that we haven't failed to
+        # account for unexpected behaviour across different versions of NumPy
+        self.assertEqual(host_array.strides, (0,))
+
+        # Ensure that the copy succeeds in both directions
+        dev_array.copy_to_host(host_array)
+        dev_array.copy_to_device(host_array)
+
+        # Ensure that a device-to-device copy also succeeds when the strides
+        # differ - one way of doing this is to copy the host array across and
+        # use that for copies in both directions.
+        dev_array_from_host = cuda.to_device(host_array)
+        self.assertEqual(dev_array_from_host.shape, (0,))
+        self.assertEqual(dev_array_from_host.strides, (0,))
+
+        dev_array.copy_to_device(dev_array_from_host)
+        dev_array_from_host.copy_to_device(dev_array)
+
 
 class TestRecarray(CUDATestCase):
     def test_recarray(self):
diff --git a/numba/testing/main.py b/numba/testing/main.py
--- a/numba/testing/main.py
+++ b/numba/testing/main.py
@@ -207,7 +207,8 @@ def git_diff_str(x):
         parser.add_argument('-g', '--gitdiff', dest='gitdiff', type=git_diff_str,
                             default=False, nargs='?',
                             help=('Run tests from changes made against '
-                                  'origin/main as identified by `git diff`. '
+                                  'origin/release0.56 as identified by'
+                                  '`git diff`. '
                                   'If set to "ancestor", the diff compares '
                                   'against the common ancestor.'))
         return parser
@@ -399,9 +400,9 @@ def _choose_gitdiff_tests(tests, *, use_common_ancestor=False):
     path = os.path.join('numba', 'tests')
     if use_common_ancestor:
         print(f"Git diff by common ancestor")
-        target = 'origin/main...HEAD'
+        target = 'origin/release0.56...HEAD'
     else:
-        target = 'origin/main..HEAD'
+        target = 'origin/release0.56..HEAD'
     gdiff_paths = repo.git.diff(target, path, name_only=True).split()
     # normalise the paths as they are unix style from repo.git.diff
     gdiff_paths = [os.path.normpath(x) for x in gdiff_paths]