Repository: numba/numba

Issue:
Host-to-device copy of broadcast arrays mangles data
- [x] I am using the latest released version of Numba (most recent is visible in
 the change log (https://github.com/numba/numba/blob/master/CHANGE_LOG).
- [x] I have included below a minimal working reproducer (if you are unsure how
 to write one see http://matthewrocklin.com/blog/work/2018/02/28/minimal-bug-reports).

CuPy developers recently discovered a bug in how numba.cuda copies data from numpy broadcast arrays to cuda device arrays: https://github.com/cupy/cupy/pull/1760#discussion_r236622313.

```python
>>> import numpy as np
>>> from numba import cuda
>>> x = np.broadcast_to(np.arange(4), (2, 4))
>>> x
array([[0, 1, 2, 3],
       [0, 1, 2, 3]])
>>> cuda.to_device(x).copy_to_host()
array([[0, 0, 1, 1],
       [0, 0, 1, 1]])
```
I did some digging. The bug occurs in `DeviceNDArrayBase.copy_to_device`:
https://github.com/numba/numba/blob/9c629ba0e9dfc86c74abf3951818d2b36f0a2c37/numba/cuda/cudadrv/devicearray.py#L196-L205
Here, `self` is a `DeviceNDArray` created to hold the data, via `to_device -> auto_device -> from_array_like`, having the same shape, strides, and size as `x`. In particular, the zero-stride dimension that `x` contains by virtue of being a broadcast array is kept in the device array:
```python
>>> x.strides
(0, 8)
>>> cuda.to_device(x).strides
(0, 8)
```
Because of this, the device array is neither C nor F contiguous:
```python
>>> cuda.to_device(x).flags
{'F_CONTIGUOUS': False, 'C_CONTIGUOUS': False}
```
Hence, the statement `'C' if self.flags['C_CONTIGUOUS'] else 'F'` evaluates to `'F'`, and the effect of the lines quoted above are the following:
* Create a contiguous F-ordered copy of `x` on the host. In memory this looks like `0, 0, 1, 1, 2, 2, 3, 3`.
* Copy as much of this as will fit into the device array's data, that is, the first half: `0, 0, 1, 1`.

It seems like `DeviceNDArrayBase.copy_to_device` needs some well thought-out special casing for broadcast arrays. I think it should be enough to just eliminate the zero-stride dimensions and work with the remaining core of the arrays. Something like,
```python
def _inner(ary):
    index = []
    for stride in ary.strides:
        index.append(0 if stride == 0 else slice(None))
    return ary[tuple(index)]
```
and then modifying `devicearray.py` line 198 onwards like this:
```python
    ary = np.array( 
         _inner(ary), 
         order='C' if _inner(self).flags['C_CONTIGUOUS'] else 'F', 
         subok=True, 
         copy=not ary.flags['WRITEABLE'])  # `host_pointer` does not work with readonly arrays
```


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
@@ -181,28 +181,21 @@ def copy_to_device(self, ary, stream=0):
         sentry_contiguous(self)
         stream = self._default_stream(stream)
 
+        self_core, ary_core = array_core(self), array_core(ary)
         if _driver.is_device_memory(ary):
             sentry_contiguous(ary)
-
-            if self.flags['C_CONTIGUOUS'] != ary.flags['C_CONTIGUOUS']:
-                raise ValueError("Can't copy %s-contiguous array to a %s-contiguous array" % (
-                    'C' if ary.flags['C_CONTIGUOUS'] else 'F',
-                    'C' if self.flags['C_CONTIGUOUS'] else 'F',
-                ))
-
-            sz = min(self.alloc_size, ary.alloc_size)
-            _driver.device_to_device(self, ary, sz, stream=stream)
+            check_array_compatibility(self_core, ary_core)
+            _driver.device_to_device(self, ary, self.alloc_size, stream=stream)
         else:
-            # Ensure same contiguous-nous. Only copies (host-side)
-            # if necessary (e.g. it needs to materialize a strided view)
-            ary = np.array(
-                ary,
-                order='C' if self.flags['C_CONTIGUOUS'] else 'F',
+            # Ensure same contiguity. Only makes a host-side copy if necessary
+            # (i.e., in order to materialize a writable strided view)
+            ary_core = np.array(
+                ary_core,
+                order='C' if self_core.flags['C_CONTIGUOUS'] else 'F',
                 subok=True,
-                copy=False)
-
-            sz = min(_driver.host_memory_size(ary), self.alloc_size)
-            _driver.host_to_device(self, ary, sz, stream=stream)
+                copy=not ary_core.flags['WRITEABLE'])
+            check_array_compatibility(self_core, ary_core)
+            _driver.host_to_device(self, ary_core, self.alloc_size, stream=stream)
 
     @devices.require_context
     def copy_to_host(self, ary=None, stream=0):
@@ -231,20 +224,7 @@ def copy_to_host(self, ary=None, stream=0):
         if ary is None:
             hostary = np.empty(shape=self.alloc_size, dtype=np.byte)
         else:
-            if ary.dtype != self.dtype:
-                raise TypeError('incompatible dtype')
-
-            if ary.shape != self.shape:
-                scalshapes = (), (1,)
-                if not (ary.shape in scalshapes and self.shape in scalshapes):
-                    raise TypeError('incompatible shape; device %s; host %s' %
-                                    (self.shape, ary.shape))
-            if ary.strides != self.strides:
-                scalstrides = (), (self.dtype.itemsize,)
-                if not (ary.strides in scalstrides and
-                                self.strides in scalstrides):
-                    raise TypeError('incompatible strides; device %s; host %s' %
-                                    (self.strides, ary.strides))
+            check_array_compatibility(self, ary)
             hostary = ary
 
         assert self.alloc_size >= 0, "Negative memory size"
@@ -305,6 +285,34 @@ def get_ipc_handle(self):
         desc = dict(shape=self.shape, strides=self.strides, dtype=self.dtype)
         return IpcArrayHandle(ipc_handle=ipch, array_desc=desc)
 
+    def squeeze(self, axis=None, stream=0):
+        """
+        Remove axes of size one from the array shape.
+
+        Parameters
+        ----------
+        axis : None or int or tuple of ints, optional
+            Subset of dimensions to remove. A `ValueError` is raised if an axis with
+            size greater than one is selected. If `None`, all axes with size one are
+            removed.
+        stream : cuda stream or 0, optional
+            Default stream for the returned view of the array.
+
+        Returns
+        -------
+        DeviceNDArray
+            Squeezed view into the array.
+
+        """
+        new_dummy, _ = self._dummy.squeeze(axis=axis)
+        return DeviceNDArray(
+            shape=new_dummy.shape,
+            strides=new_dummy.strides,
+            dtype=self.dtype,
+            stream=self._default_stream(stream),
+            gpu_data=self.gpu_data,
+        )
+
     def view(self, dtype):
         """Returns a new object by reinterpretting the dtype without making a
         copy of the data.
@@ -618,6 +626,24 @@ def from_record_like(rec, stream=0, gpu_data=None):
     return DeviceRecord(rec.dtype, stream=stream, gpu_data=gpu_data)
 
 
+def array_core(ary):
+    """
+    Extract the repeated core of a broadcast array.
+
+    Broadcast arrays are by definition non-contiguous due to repeated
+    dimensions, i.e., dimensions with stride 0. In order to ascertain memory
+    contiguity and copy the underlying data from such arrays, we must create
+    a view without the repeated dimensions.
+
+    """
+    if not ary.strides:
+        return ary
+    core_index = []
+    for stride in ary.strides:
+        core_index.append(0 if stride == 0 else slice(None))
+    return ary[tuple(core_index)]
+
+
 errmsg_contiguous_buffer = ("Array contains non-contiguous buffer and cannot "
                             "be transferred as a single memory region. Please "
                             "ensure contiguous buffer with numpy "
@@ -625,13 +651,9 @@ def from_record_like(rec, stream=0, gpu_data=None):
 
 
 def sentry_contiguous(ary):
-    if not ary.flags['C_CONTIGUOUS'] and not ary.flags['F_CONTIGUOUS']:
-        if ary.strides[0] == 0:
-            # Broadcasted, ensure inner contiguous
-            return sentry_contiguous(ary[0])
-
-        else:
-            raise ValueError(errmsg_contiguous_buffer)
+    core = array_core(ary)
+    if not core.flags['C_CONTIGUOUS'] and not core.flags['F_CONTIGUOUS']:
+        raise ValueError(errmsg_contiguous_buffer)
 
 
 def auto_device(obj, stream=0, copy=True):
@@ -663,3 +685,15 @@ def auto_device(obj, stream=0, copy=True):
             devobj.copy_to_device(obj, stream=stream)
         return devobj, True
 
+
+def check_array_compatibility(ary1, ary2):
+    ary1sq, ary2sq = ary1.squeeze(), ary2.squeeze()
+    if ary1.dtype != ary2.dtype:
+        raise TypeError('incompatible dtype: %s vs. %s' %
+                        (ary1.dtype, ary2.dtype))
+    if ary1sq.shape != ary2sq.shape:
+        raise ValueError('incompatible shape: %s vs. %s' %
+                         (ary1.shape, ary2.shape))
+    if ary1sq.strides != ary2sq.strides:
+        raise ValueError('incompatible strides: %s vs. %s' %
+                         (ary1.strides, ary2.strides))
diff --git a/numba/cuda/simulator/cudadrv/devicearray.py b/numba/cuda/simulator/cudadrv/devicearray.py
--- a/numba/cuda/simulator/cudadrv/devicearray.py
+++ b/numba/cuda/simulator/cudadrv/devicearray.py
@@ -82,11 +82,9 @@ def __setitem__(self, idx, val):
     def copy_to_host(self, ary=None, stream=0):
         if ary is None:
             ary = np.empty_like(self._ary)
-        # NOTE: np.copyto() introduced in Numpy 1.7
-        try:
-            np.copyto(ary, self._ary)
-        except AttributeError:
-            ary[:] = self._ary
+        else:
+            check_array_compatibility(self, ary)
+        np.copyto(ary, self._ary)
         return ary
 
     def copy_to_device(self, ary, stream=0):
@@ -98,24 +96,18 @@ def copy_to_device(self, ary, stream=0):
         whereas this expects the size of the arrays to be equal.
         '''
         sentry_contiguous(self)
+        self_core, ary_core = array_core(self), array_core(ary)
         if isinstance(ary, FakeCUDAArray):
             sentry_contiguous(ary)
-
-            if self.flags['C_CONTIGUOUS'] != ary.flags['C_CONTIGUOUS']:
-                raise ValueError("Can't copy %s-contiguous array to a %s-contiguous array" % (
-                    'C' if ary.flags['C_CONTIGUOUS'] else 'F',
-                    'C' if self.flags['C_CONTIGUOUS'] else 'F',
-                ))
+            check_array_compatibility(self_core, ary_core)
         else:
-            ary = np.array(
-                ary,
-                order='C' if self.flags['C_CONTIGUOUS'] else 'F',
+            ary_core = np.array(
+                ary_core,
+                order='C' if self_core.flags['C_CONTIGUOUS'] else 'F',
                 subok=True,
                 copy=False)
-        try:
-            np.copyto(self._ary, ary)
-        except AttributeError:
-            self._ary[:] = ary
+            check_array_compatibility(self_core, ary_core)
+        np.copyto(self_core._ary, ary_core)
 
     def to_host(self):
         warn('to_host() is deprecated and will be removed')
@@ -152,21 +144,56 @@ def split(self, section, stream=0):
             for a in np.split(self._ary, range(section, len(self), section))
         ]
 
+
+def array_core(ary):
+    """
+    Extract the repeated core of a broadcast array.
+
+    Broadcast arrays are by definition non-contiguous due to repeated
+    dimensions, i.e., dimensions with stride 0. In order to ascertain memory
+    contiguity and copy the underlying data from such arrays, we must create
+    a view without the repeated dimensions.
+
+    """
+    if not ary.strides:
+        return ary
+    core_index = []
+    for stride in ary.strides:
+        core_index.append(0 if stride == 0 else slice(None))
+    return ary[tuple(core_index)]
+
+
 def sentry_contiguous(ary):
-    if not ary.flags['C_CONTIGUOUS'] and not ary.flags['F_CONTIGUOUS']:
-        if ary.strides[0] == 0:
-            # Broadcasted, ensure inner contiguous
-            return sentry_contiguous(ary[0])
-        else:
-            raise ValueError(errmsg_contiguous_buffer)
+    core = array_core(ary)
+    if not core.flags['C_CONTIGUOUS'] and not core.flags['F_CONTIGUOUS']:
+        raise ValueError(errmsg_contiguous_buffer)
+
+
+def check_array_compatibility(ary1, ary2):
+    ary1sq, ary2sq = ary1.squeeze(), ary2.squeeze()
+    if ary1.dtype != ary2.dtype:
+        raise TypeError('incompatible dtype: %s vs. %s' %
+                        (ary1.dtype, ary2.dtype))
+    if ary1sq.shape != ary2sq.shape:
+        raise ValueError('incompatible shape: %s vs. %s' %
+                         (ary1.shape, ary2.shape))
+    if ary1sq.strides != ary2sq.strides:
+        raise ValueError('incompatible strides: %s vs. %s' %
+                         (ary1.strides, ary2.strides))
 
 
 def to_device(ary, stream=0, copy=True, to=None):
     sentry_contiguous(ary)
     if to is None:
-        return FakeCUDAArray(np.array(
-            ary,
-            order='C' if ary.flags['C_CONTIGUOUS'] else 'F', copy=True))
+        buffer_dtype = np.int64 if ary.dtype.char in 'Mm' else ary.dtype
+        return FakeCUDAArray(
+            np.ndarray(
+                buffer=np.copy(array_core(ary)).view(buffer_dtype),
+                dtype=ary.dtype,
+                shape=ary.shape,
+                strides=ary.strides,
+            ),
+        )
     else:
         to.copy_to_device(ary, stream=stream)
 
diff --git a/numba/dummyarray.py b/numba/dummyarray.py
--- a/numba/dummyarray.py
+++ b/numba/dummyarray.py
@@ -338,6 +338,34 @@ def reshape(self, *newdims, **kws):
 
         return ret, list(self.iter_contiguous_extent())
 
+    def squeeze(self, axis=None):
+        newshape, newstrides = [], []
+        if axis is None:
+            for length, stride in zip(self.shape, self.strides):
+                if length != 1:
+                    newshape.append(length)
+                    newstrides.append(stride)
+        else:
+            if not isinstance(axis, tuple):
+                axis = (axis,)
+            for ax in axis:
+                if self.shape[ax] != 1:
+                    raise ValueError(
+                        "cannot select an axis to squeeze out which has size not equal "
+                        "to one"
+                    )
+            for i, (length, stride) in enumerate(zip(self.shape, self.strides)):
+                if i not in axis:
+                    newshape.append(length)
+                    newstrides.append(stride)
+        newarr = self.from_desc(
+            self.extent.begin,
+            shape=newshape,
+            strides=newstrides,
+            itemsize=self.itemsize,
+        )
+        return newarr, list(self.iter_contiguous_extent())
+
     def ravel(self, order='C'):
         if order not in 'CFA':
             raise ValueError('order not C|F|A')


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
@@ -206,7 +206,7 @@ def test_devicearray_contiguous_copy_device(self):
         with self.assertRaises(ValueError) as e:
             d.copy_to_device(cuda.to_device(a_f))
         self.assertEqual(
-            "Can't copy F-contiguous array to a C-contiguous array",
+            "incompatible strides: {} vs. {}".format(a_c.strides, a_f.strides),
             str(e.exception))
 
         d.copy_to_device(cuda.to_device(a_c))
@@ -217,12 +217,60 @@ def test_devicearray_contiguous_copy_device(self):
         with self.assertRaises(ValueError) as e:
             d.copy_to_device(cuda.to_device(a_c))
         self.assertEqual(
-            "Can't copy C-contiguous array to a F-contiguous array",
+            "incompatible strides: {} vs. {}".format(a_f.strides, a_c.strides),
             str(e.exception))
 
         d.copy_to_device(cuda.to_device(a_f))
         self.assertTrue(np.all(d.copy_to_host() == a_f))
 
+    def test_devicearray_broadcast_host_copy(self):
+        try:
+            broadcast_to = np.broadcast_to
+        except AttributeError:
+            # numpy<1.10 doesn't have broadcast_to. The following implements
+            # a limited broadcast_to that only works along already existing
+            # dimensions of length 1.
+            def broadcast_to(arr, new_shape):
+                new_strides = []
+                for new_length, length, stride in zip(
+                    new_shape, arr.shape, arr.strides
+                ):
+                    if length == 1 and new_length > 1:
+                        new_strides.append(0)
+                    elif new_length == length:
+                        new_strides.append(stride)
+                    else:
+                        raise ValueError(
+                            "cannot broadcast shape {} to shape {}"
+                            .format(arr.shape, new_shape)
+                        )
+                return np.ndarray(
+                    buffer=np.squeeze(arr),
+                    dtype=arr.dtype,
+                    shape=new_shape,
+                    strides=tuple(new_strides),
+                )
+
+        broadsize = 4
+        coreshape = (2, 3)
+        coresize = np.prod(coreshape)
+        core_c = np.arange(coresize).reshape(coreshape, order='C')
+        core_f = np.arange(coresize).reshape(coreshape, order='F')
+        for dim in range(len(coreshape)):
+            newindex = (slice(None),) * dim + (np.newaxis,)
+            broadshape = coreshape[:dim] + (broadsize,) + coreshape[dim:]
+            broad_c = broadcast_to(core_c[newindex], broadshape)
+            broad_f = broadcast_to(core_f[newindex], broadshape)
+            dbroad_c = cuda.to_device(broad_c)
+            dbroad_f = cuda.to_device(broad_f)
+            np.testing.assert_array_equal(dbroad_c.copy_to_host(), broad_c)
+            np.testing.assert_array_equal(dbroad_f.copy_to_host(), broad_f)
+            # Also test copying across different core orderings
+            dbroad_c.copy_to_device(broad_f)
+            dbroad_f.copy_to_device(broad_c)
+            np.testing.assert_array_equal(dbroad_c.copy_to_host(), broad_f)
+            np.testing.assert_array_equal(dbroad_f.copy_to_host(), broad_c)
+
     def test_devicearray_contiguous_host_strided(self):
         a_c = np.arange(10)
         d = cuda.to_device(a_c)
@@ -239,6 +287,7 @@ def test_devicearray_contiguous_device_strided(self):
         self.assertEqual(
             devicearray.errmsg_contiguous_buffer,
             str(e.exception))
+        
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/numba/cuda/tests/cudadrv/test_events.py b/numba/cuda/tests/cudadrv/test_events.py
--- a/numba/cuda/tests/cudadrv/test_events.py
+++ b/numba/cuda/tests/cudadrv/test_events.py
@@ -12,7 +12,7 @@ def test_event_elapsed(self):
         evtend = cuda.event()
 
         evtstart.record()
-        cuda.to_device(np.arange(N), to=dary)
+        cuda.to_device(np.arange(N, dtype=np.double), to=dary)
         evtend.record()
         evtend.wait()
         evtend.synchronize()
@@ -27,7 +27,7 @@ def test_event_elapsed_stream(self):
         evtend = cuda.event()
 
         evtstart.record(stream=stream)
-        cuda.to_device(np.arange(N), to=dary, stream=stream)
+        cuda.to_device(np.arange(N, dtype=np.double), to=dary, stream=stream)
         evtend.record(stream=stream)
         evtend.wait(stream=stream)
         evtend.synchronize()
diff --git a/numba/tests/test_dummyarray.py b/numba/tests/test_dummyarray.py
--- a/numba/tests/test_dummyarray.py
+++ b/numba/tests/test_dummyarray.py
@@ -164,6 +164,34 @@ def test_reshape_3d1d(self):
         self.assertEqual(got.shape, expect.shape)
         self.assertEqual(got.strides, expect.strides)
 
+
+class TestSqueeze(unittest.TestCase):
+    def test_squeeze(self):
+        nparr = np.empty((1, 2, 1, 4, 1, 3))
+        arr = Array.from_desc(
+            0, nparr.shape, nparr.strides, nparr.dtype.itemsize
+        )
+        def _assert_equal_shape_strides(arr1, arr2):
+            self.assertEqual(arr1.shape, arr2.shape)
+            self.assertEqual(arr1.strides, arr2.strides)
+        _assert_equal_shape_strides(arr, nparr)
+        _assert_equal_shape_strides(arr.squeeze()[0], nparr.squeeze())
+        for axis in (0, 2, 4, (0, 2), (0, 4), (2, 4), (0, 2, 4)):
+            _assert_equal_shape_strides(
+                arr.squeeze(axis=axis)[0], nparr.squeeze(axis=axis)
+            )
+
+    def test_squeeze_invalid_axis(self):
+        nparr = np.empty((1, 2, 1, 4, 1, 3))
+        arr = Array.from_desc(
+            0, nparr.shape, nparr.strides, nparr.dtype.itemsize
+        )
+        with self.assertRaises(ValueError):
+            arr.squeeze(axis=1)
+        with self.assertRaises(ValueError):
+            arr.squeeze(axis=(2, 3))
+        
+
 class TestExtent(unittest.TestCase):
     def test_extent_1d(self):
         nparr = np.empty(4)