Repository: cupy/cupy

Issue:
`copyto` does not test shape for memcpy cases
### Description

There should be a shape check before these:
https://github.com/cupy/cupy/blob/e7f2e797216954dd152efe0169ec6a0094d7777d/cupy/_manipulation/basic.py#L54-L55
https://github.com/cupy/cupy/blob/e7f2e797216954dd152efe0169ec6a0094d7777d/cupy/_manipulation/basic.py#L76-L80

### To Reproduce

```py
>>> dst = cupy.ones((2, 3), dtype=int)
>>> cupy.copyto(dst, cupy.arange(6))
>>> dst
array([[0, 1, 2],
       [3, 4, 5]])
>>> numpy.copyto(numpy.ones((2, 3), dtype=int), numpy.arange(6))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<__array_function__ internals>", line 5, in copyto
ValueError: could not broadcast input array from shape (6,) into shape (2,3)
```

```py
>>> cupy.copyto(cupy.empty((3, 0, 4, 5)), cupy.empty((6, 7, 0)))
>>> numpy.copyto(numpy.empty((3, 0, 4, 5)), numpy.empty((6, 7, 0)))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<__array_function__ internals>", line 5, in copyto
ValueError: could not broadcast input array from shape (6,7,0) into shape (3,0,4,5)
```



Discussion:

Resolving patch:
diff --git a/cupy/_manipulation/basic.py b/cupy/_manipulation/basic.py
--- a/cupy/_manipulation/basic.py
+++ b/cupy/_manipulation/basic.py
@@ -1,3 +1,5 @@
+import itertools
+
 import numpy
 
 from cupy import _core
@@ -41,22 +43,35 @@ def copyto(dst, src, casting='same_kind', where=None):
         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():
+        # TODO(kataoka): NumPy allows stripping leading unit dimensions.
+        # But fusion array proxy does not currently support
+        # `shape` and `squeeze`.
+
         if where is None:
             _core.elementwise_copy(src, dst)
         else:
             fusion._call_ufunc(search._where_ufunc, where, src, dst, dst)
         return
 
+    if not src_is_python_scalar:
+        # Check broadcast condition
+        # - for fast-paths and
+        # - for a better error message (than ufunc's).
+        # NumPy allows stripping leading unit dimensions.
+        if not all([
+            s in (d, 1)
+            for s, d in itertools.zip_longest(
+                reversed(src.shape), reversed(dst.shape), fillvalue=1)
+        ]):
+            raise ValueError(
+                "could not broadcast input array "
+                f"from shape {src.shape} into shape {dst.shape}")
+        squeeze_ndim = src.ndim - dst.ndim
+        if squeeze_ndim > 0:
+            # always succeeds because broadcast conition is checked.
+            src = src.squeeze(tuple(range(squeeze_ndim)))
+
     if where is not None:
         _core.elementwise_copy(src, dst, _where=where)
         return


Test patch:
diff --git a/tests/cupy_tests/core_tests/fusion_tests/test_routines.py b/tests/cupy_tests/core_tests/fusion_tests/test_routines.py
--- a/tests/cupy_tests/core_tests/fusion_tests/test_routines.py
+++ b/tests/cupy_tests/core_tests/fusion_tests/test_routines.py
@@ -1,6 +1,7 @@
 import unittest
 
 import numpy
+import pytest
 
 from cupy import testing
 from cupy_tests.core_tests.fusion_tests import fusion_utils
@@ -394,6 +395,14 @@ def test_where(self, xp, dtype1, dtype2):
     def test_copyto(self, xp, dtype1, dtype2):
         return lambda cond, x, y: xp.copyto(x, y)
 
+    # TODO(imanishi): Supoort complex dtypes
+    @pytest.mark.xfail(reason='Issue #5848')
+    @testing.for_all_dtypes_combination(
+        names=('dtype1', 'dtype2'), no_complex=True)
+    @fusion_utils.check_fusion(accept_error=(TypeError,))
+    def test_copyto_compat_broadcast(self, xp, dtype1, dtype2):
+        return lambda cond, x, y: xp.copyto(x, y[None])
+
     # TODO(imanishi): Supoort complex dtypes
     @testing.for_all_dtypes_combination(
         names=('dtype1', 'dtype2'), no_complex=True)
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
@@ -43,6 +43,19 @@ def test_copyto_broadcast(self, xp, dtype):
         xp.copyto(b, a)
         return b
 
+    @pytest.mark.parametrize(('dst_shape', 'src_shape'), [
+        ((), (2,)),
+        ((2, 0, 5, 4), (2, 0, 3, 4)),
+        ((6,), (2, 3)),
+        ((2, 3), (6,)),
+    ])
+    def test_copyto_raises_shape(self, dst_shape, src_shape):
+        for xp in (numpy, cupy):
+            dst = xp.zeros(dst_shape, int)
+            src = xp.zeros(src_shape, int)
+            with pytest.raises(ValueError):
+                xp.copyto(dst, src)
+
     @testing.for_all_dtypes()
     @testing.numpy_cupy_array_equal()
     def test_copyto_squeeze(self, xp, dtype):