Repository: cupy/cupy

Issue:
Indexing with assignment between broadcastable arrays is inconsistent with NumPy
When performing `A[idx, ...] = B` with `B` broadcastable over `A[idx, ...]` (so no storage expansion for `A[idx, ...]` is necessary) with `B.ndim > A.ndim` CuPy throws a shape mismatch error while NumPy handles this case.

* Code to reproduce
```python
In [1]: import numpy

In [2]: import cupy

In [3]: def test(module):
   ...:     x = module.zeros((3, 3, 3))
   ...:     y = module.ones((1, 3, 3))
   ...:     x[0, ...] = y
   ...:     return x
   ...: 
   ...: 

In [4]: test(numpy)
Out[4]: 
array([[[1., 1., 1.],
        [1., 1., 1.],
        [1., 1., 1.]],

       [[0., 0., 0.],
        [0., 0., 0.],
        [0., 0., 0.]],

       [[0., 0., 0.],
        [0., 0., 0.],
        [0., 0., 0.]]])

In [5]: test(cupy)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-5-3f849ce2914e> in <module>()
----> 1 test(cupy)

<ipython-input-3-450cff366473> in test(module)
      2     x = module.zeros((3, 3, 3))
      3     y = module.ones((1, 3, 3))
----> 4     x[0, ...] = y
      5     return x

cupy/_core/core.pyx in cupy._core.core.ndarray.__setitem__()

cupy/_core/_routines_indexing.pyx in cupy._core._routines_indexing._ndarray_setitem()

cupy/_core/_routines_indexing.pyx in cupy._core._routines_indexing._scatter_op()

cupy/_core/_kernel.pyx in cupy._core._kernel.ufunc.__call__()

cupy/_core/_kernel.pyx in cupy._core._kernel._get_out_args()

ValueError: Out shape is mismatched

```

* Conditions
```
OS                           : Linux-5.4.0-81-generic-x86_64-with-debian-bullseye-sid
Python Version               : 3.6.7
CuPy Version                 : 9.5.0
CuPy Platform                : NVIDIA CUDA
NumPy Version                : 1.19.5
SciPy Version                : None
Cython Build Version         : 0.29.24
Cython Runtime Version       : None
CUDA Root                    : /home/nik/.conda/envs/pytorch-cuda-dev
nvcc PATH                    : /home/nik/.conda/envs/pytorch-cuda-dev/bin/nvcc
CUDA Build Version           : 11020
CUDA Driver Version          : 11030
CUDA Runtime Version         : 11020
cuBLAS Version               : (available)
cuFFT Version                : 10401
cuRAND Version               : 10203
cuSOLVER Version             : (11, 1, 0)
cuSPARSE Version             : (available)
NVRTC Version                : (11, 2)
Thrust Version               : 101000
CUB Build Version            : 101000
Jitify Build Version         : <unknown>
cuDNN Build Version          : 8201
cuDNN Version                : 8004
NCCL Build Version           : None
NCCL Runtime Version         : None
cuTENSOR Version             : None
cuSPARSELt Build Version     : None
Device 0 Name                : NVIDIA GeForce RTX 2060
Device 0 Compute Capability  : 75
Device 0 PCI Bus ID          : 0000:01:00.0
Device 1 Name                : NVIDIA GeForce RTX 2060
Device 1 Compute Capability  : 75
Device 1 PCI Bus ID          : 0000:21:00.0
```



Discussion:
`cupy.copyto` is inconsistent, too.

```python
>>> numpy.copyto(numpy.zeros((3, 2)).T, numpy.ones((1, 2, 3)))
>>> cupy.copyto(cupy.zeros((3, 2)).T, cupy.ones((1, 2, 3)))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/path/to/cupy/_manipulation/basic.py", line 68, in copyto
    _core.elementwise_copy(src, dst)
  File "cupy/_core/_kernel.pyx", line 1172, in cupy._core._kernel.ufunc.__call__
  File "cupy/_core/_kernel.pyx", line 605, in cupy._core._kernel._get_out_args
ValueError: Out shape is mismatched
```

But #6115 often hides this issue.
> ```c
>         int ndim_tmp = PyArray_NDIM(src);
>         npy_intp *src_shape_tmp = PyArray_DIMS(src);
>         npy_intp *src_strides_tmp = PyArray_STRIDES(src);
>         /*
>          * As a special case for backwards compatibility, strip
>          * away unit dimensions from the left of 'src'
>          */
>         while (ndim_tmp > PyArray_NDIM(dst) && src_shape_tmp[0] == 1) {
>             --ndim_tmp;
>             ++src_shape_tmp;
>             ++src_strides_tmp;
>         }
> ```

https://github.com/numpy/numpy/blob/fc23679cc0da2716965279add044d323a02e2c42/numpy/core/src/multiarray/array_assign_array.c#L354-L365

Resolving patch:
diff --git a/cupy/_manipulation/basic.py b/cupy/_manipulation/basic.py
--- a/cupy/_manipulation/basic.py
+++ b/cupy/_manipulation/basic.py
@@ -40,6 +40,16 @@ def copyto(dst, src, casting='same_kind', where=None):
     if not can_cast:
         raise TypeError('Cannot cast %s to %s in %s casting mode' %
                         (src_dtype, dst.dtype, casting))
+
+    if not src_is_python_scalar and src.ndim > dst.ndim:
+        # NumPy allows stripping leading unit dimensions.
+        try:
+            src = src.squeeze(tuple(range(src.ndim - dst.ndim)))
+        except ValueError:
+            # "cannot select an axis to squeeze out
+            # which has size not equal to one"
+            pass  # raise an error later
+
     if fusion._is_fusing():
         if where is None:
             _core.elementwise_copy(src, dst)


Test patch:
diff --git a/tests/cupy_tests/core_tests/test_ndarray_indexing.py b/tests/cupy_tests/core_tests/test_ndarray_indexing.py
--- a/tests/cupy_tests/core_tests/test_ndarray_indexing.py
+++ b/tests/cupy_tests/core_tests/test_ndarray_indexing.py
@@ -204,3 +204,33 @@ def test_T(self, xp):
     def test_T_vector(self, xp):
         a = testing.shaped_arange((4,), xp)
         return a.T
+
+
+class TestSetItemCompatBroadcast:
+    @testing.numpy_cupy_array_equal()
+    def test_simple(self, xp):
+        dtype = int
+        a = xp.zeros(4, dtype)
+        a[:] = testing.shaped_arange((1, 4), xp, dtype)
+        return a
+
+    @testing.numpy_cupy_array_equal()
+    def test_other1(self, xp):
+        dtype = int
+        a = xp.zeros((2, 1, 3), dtype)
+        a[:] = testing.shaped_arange((1, 2, 1, 3), xp, dtype)
+        return a
+
+    @testing.numpy_cupy_array_equal()
+    def test_0d(self, xp):
+        dtype = int
+        a = xp.zeros((), dtype)
+        a[...] = testing.shaped_arange((1, 1), xp, dtype)
+        return a
+
+    @testing.numpy_cupy_array_equal()
+    def test_remain0d(self, xp):
+        dtype = int
+        a = xp.zeros((2, 3, 4), dtype)
+        a[0, 1, 2] = testing.shaped_arange((1, 1, 1), xp, dtype)
+        return a
diff --git a/tests/cupy_tests/manipulation_tests/test_basic.py b/tests/cupy_tests/manipulation_tests/test_basic.py
--- a/tests/cupy_tests/manipulation_tests/test_basic.py
+++ b/tests/cupy_tests/manipulation_tests/test_basic.py
@@ -19,6 +19,14 @@ def test_copyto(self, xp, dtype):
         xp.copyto(b, a)
         return b
 
+    @testing.for_all_dtypes()
+    @testing.numpy_cupy_array_equal()
+    def test_copyto_different_contiguity(self, xp, dtype):
+        a = testing.shaped_arange((2, 3, 2), xp, dtype).T
+        b = xp.empty((2, 3, 2), dtype=dtype)
+        xp.copyto(b, a)
+        return b
+
     @testing.for_all_dtypes()
     @testing.numpy_cupy_array_equal()
     def test_copyto_dtype(self, xp, dtype):
@@ -35,6 +43,30 @@ def test_copyto_broadcast(self, xp, dtype):
         xp.copyto(b, a)
         return b
 
+    @testing.for_all_dtypes()
+    @testing.numpy_cupy_array_equal()
+    def test_copyto_squeeze(self, xp, dtype):
+        a = testing.shaped_arange((1, 1, 3, 4), xp, dtype)
+        b = xp.empty((3, 4), dtype=dtype)
+        xp.copyto(b, a)
+        return b
+
+    @testing.for_all_dtypes()
+    @testing.numpy_cupy_array_equal()
+    def test_copyto_squeeze_different_contiguity(self, xp, dtype):
+        a = testing.shaped_arange((1, 1, 3, 4), xp, dtype)
+        b = xp.empty((4, 3), dtype=dtype).T
+        xp.copyto(b, a)
+        return b
+
+    @testing.for_all_dtypes()
+    @testing.numpy_cupy_array_equal()
+    def test_copyto_squeeze_broadcast(self, xp, dtype):
+        a = testing.shaped_arange((1, 2, 1, 4), xp, dtype)
+        b = xp.empty((2, 3, 4), dtype=dtype)
+        xp.copyto(b, a)
+        return b
+
     @testing.for_all_dtypes()
     @testing.numpy_cupy_array_equal()
     def test_copyto_where(self, xp, dtype):
@@ -44,6 +76,15 @@ def test_copyto_where(self, xp, dtype):
         xp.copyto(a, b, where=c)
         return a
 
+    @testing.for_all_dtypes()
+    @testing.numpy_cupy_array_equal()
+    def test_copyto_where_squeeze_broadcast(self, xp, dtype):
+        a = testing.shaped_arange((2, 3, 4), xp, dtype)
+        b = testing.shaped_reverse_arange((1, 2, 1, 4), xp, dtype)
+        c = testing.shaped_arange((3, 4), xp, '?')
+        xp.copyto(a, b, where=c)
+        return a
+
     @pytest.mark.parametrize('shape', [(2, 3, 4), (0,)])
     @testing.for_all_dtypes(no_bool=True)
     def test_copyto_where_raises(self, dtype, shape):