Repository: cupy/cupy

Issue:
Deny numpy arrays in ufunc outside fuse decorator
Currently `ufunc`s defined in `cupy.fusion` accepts numpy arrays. This is not intended.


**Expected behavior** (`cupy.math.arithmetic.add`)
```
>>> cupy.math.arithmetic.add(numpy.array(1), numpy.array(2))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "cupy/core/elementwise.pxi", line 736, in cupy.core.core.ufunc.__call__ (cupy/core/core.cpp:48698)
    args = _preprocess_args(args)
  File "cupy/core/elementwise.pxi", line 110, in cupy.core.core._preprocess_args (cupy/core/core.cpp:36647)
    raise TypeError('Unsupported type %s' % type(arg))
TypeError: Unsupported type <class 'numpy.ndarray'>
```

**Fusion** (`cupy.add` = `cupy.fusion.add`)
```
>>> cupy.fusion.add(numpy.array(1), numpy.array(2))
3
```


These `ufunc`s should accept `numpy.ndarray` only within `@fuse` decorator.


Discussion:

Resolving patch:
diff --git a/cupy/core/fusion.py b/cupy/core/fusion.py
--- a/cupy/core/fusion.py
+++ b/cupy/core/fusion.py
@@ -1,6 +1,7 @@
 import six
 from six.moves import builtins
 import string
+import threading
 import warnings
 
 import numpy
@@ -14,6 +15,9 @@
 from cupy import util
 
 
+_thread_local = threading.local()
+
+
 class FusionOp(object):
 
     def __init__(self, name, operation, param_names,
@@ -592,6 +596,13 @@ def __repr__(self):
         return "<Fusion '%s'>" % self.name
 
     def __call__(self, *args, **kwargs):
+        _thread_local.in_fusion = True
+        try:
+            return self._call(*args, **kwargs)
+        finally:
+            _thread_local.in_fusion = False
+
+    def _call(self, *args, **kwargs):
         axis = kwargs['axis'] if 'axis' in kwargs else None
         if len(args) == 0:
             raise Exception('number of arguments must be more than 0')
@@ -684,12 +695,14 @@ def __repr__(self):
         return repr(self._cupy_op)
 
     def __call__(self, *args, **kwargs):
-        if builtins.any(type(_) is _FusionRef for _ in args):
-            return _convert(self._fusion_op)(*args, **kwargs)
-        elif builtins.any(type(_) is numpy.ndarray for _ in args):
-            return self._numpy_op(*args, **kwargs)
-        else:
-            return self._cupy_op(*args, **kwargs)
+        in_fusion = getattr(_thread_local, 'in_fusion', False)
+        if in_fusion:
+            if builtins.any(isinstance(_, _FusionRef) for _ in args):
+                return _convert(self._fusion_op)(*args, **kwargs)
+            elif builtins.any(isinstance(_, numpy.ndarray) for _ in args):
+                return self._numpy_op(*args, **kwargs)
+
+        return self._cupy_op(*args, **kwargs)
 
     __doc__ = core.ufunc.__doc__
     __call__.__doc__ = core.ufunc.__call__.__doc__


Test patch:
diff --git a/tests/cupy_tests/core_tests/test_fusion.py b/tests/cupy_tests/core_tests/test_fusion.py
--- a/tests/cupy_tests/core_tests/test_fusion.py
+++ b/tests/cupy_tests/core_tests/test_fusion.py
@@ -534,13 +534,13 @@ def sample_function(x, y, z):
         return cupy.square(cupy.add(x, y))
 
     def random_bool(self):
-        return numpy.random.randint(0, 1, (3, 3)) == 0
+        return cupy.random.randint(0, 1, (3, 3)) == 0
 
     def random_int(self, lower=-1000, higher=1000):
-        return numpy.random.randint(lower, higher, (3, 3))
+        return cupy.random.randint(lower, higher, (3, 3))
 
     def random_real(self, lower=-1000, higher=1000):
-        return numpy.random.rand(3, 3) * (higher - lower) + lower
+        return cupy.random.rand(3, 3) * (higher - lower) + lower
 
     def check(self, func, n, gen, *args):
         self._check(func, n, gen, args, True)
@@ -554,24 +554,18 @@ def f(*x):
             return func(*x)
 
         if type(gen) == tuple:
-            ndata = [g(*a) for i, g, a in zip(range(n), list(gen), args)]
+            data = [g(*a) for i, g, a in zip(range(n), list(gen), args)]
         else:
-            ndata = [gen(*args) for i in range(n)]
-        nret = func(*ndata)
-        fnret = f(*ndata)
-        nret = list(nret) if type(nret) == tuple else [nret]
-        fnret = list(fnret) if type(fnret) == tuple else [fnret]
-        for n, fn in zip(nret, fnret):
-            numpy.testing.assert_array_almost_equal(n, fn)
-
-        cdata = [cupy.asarray(_) for _ in ndata]
-        cret = func(*cdata)
-        fcret = f(*cdata)
-        cret = list(cret) if type(cret) == tuple else [cret]
-        fcret = list(fcret) if type(fcret) == tuple else [fcret]
-        for n, c, fc in zip(nret, cret, fcret):
-            numpy.testing.assert_array_almost_equal(n, c.get())
-            numpy.testing.assert_array_almost_equal(n, fc.get())
+            data = [gen(*args) for i in range(n)]
+
+        ret0 = func(*data)  # Non-fused
+        ret1 = f(*data)  # Fused
+        if not isinstance(ret0, tuple):
+            ret0 = [ret0]
+        if not isinstance(ret1, tuple):
+            ret1 = [ret1]
+        for nf, f in zip(ret0, ret1):
+            numpy.testing.assert_array_almost_equal(nf.get(), f.get())
 
     def check_reduce(self, func, n, reduce_f, gen, *args):
         self._check_reduce(func, n, reduce_f, gen, args, True)
@@ -584,11 +578,10 @@ def _check_reduce(self, func, n, reduce_f, gen, args, omit_nin):
         def f(*x):
             return func(*x)
 
-        ndata = [gen(*args) for i in range(n)]
-        fnret = f(*ndata)
-        cdata = [cupy.asarray(_) for _ in ndata]
-        fcret = f(*cdata)
-        numpy.testing.assert_array_almost_equal(fnret, fcret.get())
+        data = [gen(*args) for i in range(n)]
+        ret0 = reduce_f(func(*data))  # Non-fused
+        ret1 = f(*data)  # Fused
+        numpy.testing.assert_array_almost_equal(ret0.get(), ret1.get())
 
     def test_bitwise(self):
         self.check(cupy.bitwise_and, 2, self.random_int)
diff --git a/tests/cupy_tests/math_tests/test_arithmetic.py b/tests/cupy_tests/math_tests/test_arithmetic.py
--- a/tests/cupy_tests/math_tests/test_arithmetic.py
+++ b/tests/cupy_tests/math_tests/test_arithmetic.py
@@ -1,5 +1,8 @@
+import itertools
+import numpy
 import unittest
 
+import cupy
 from cupy import testing
 
 
@@ -41,19 +44,34 @@ def check_binary_negative_float(self, name, xp, dtype):
         b = xp.array([4, 3, 2, 1, -1, -2], dtype=dtype)
         return getattr(xp, name)(a, b)
 
+    def check_raises_with_numpy_input(self, nargs, name):
+        # Check TypeError is raised if numpy.ndarray is given as input
+        func = getattr(cupy, name)
+        for input_xp_list in itertools.product(*[[numpy, cupy]] * nargs):
+            if all(xp is cupy for xp in input_xp_list):
+                # We don't test all-cupy-array inputs here
+                continue
+            arys = [xp.array([2, -3]) for xp in input_xp_list]
+            with self.assertRaises(TypeError):
+                func(*arys)
+
     def test_add(self):
         self.check_binary('add')
+        self.check_raises_with_numpy_input(2, 'add')
 
     def test_reciprocal(self):
         with testing.NumpyError(divide='ignore', invalid='ignore'):
             self.check_unary('reciprocal')
+        self.check_raises_with_numpy_input(1, 'reciprocal')
 
     def test_multiply(self):
         self.check_binary('multiply')
+        self.check_raises_with_numpy_input(2, 'multiply')
 
     def test_divide(self):
         with testing.NumpyError(divide='ignore'):
             self.check_binary('divide')
+        self.check_raises_with_numpy_input(2, 'divide')
 
     def test_divide_negative(self):
         with testing.NumpyError(divide='ignore'):
@@ -61,16 +79,19 @@ def test_divide_negative(self):
 
     def test_power(self):
         self.check_binary('power')
+        self.check_raises_with_numpy_input(2, 'power')
 
     def test_power_negative(self):
         self.check_binary_negative_float('power')
 
     def test_subtract(self):
         self.check_binary('subtract')
+        self.check_raises_with_numpy_input(2, 'subtract')
 
     def test_true_divide(self):
         with testing.NumpyError(divide='ignore'):
             self.check_binary('true_divide')
+        self.check_raises_with_numpy_input(2, 'true_divide')
 
     def test_true_divide_negative(self):
         with testing.NumpyError(divide='ignore'):
@@ -79,6 +100,7 @@ def test_true_divide_negative(self):
     def test_floor_divide(self):
         with testing.NumpyError(divide='ignore'):
             self.check_binary('floor_divide')
+        self.check_raises_with_numpy_input(2, 'floor_divide')
 
     def test_floor_divide_negative(self):
         with testing.NumpyError(divide='ignore'):
@@ -87,6 +109,7 @@ def test_floor_divide_negative(self):
     def test_fmod(self):
         with testing.NumpyError(divide='ignore'):
             self.check_binary('fmod')
+        self.check_raises_with_numpy_input(2, 'fmod')
 
     def test_fmod_negative(self):
         with testing.NumpyError(divide='ignore'):
@@ -105,6 +128,7 @@ def test_modf(self, xp, dtype):
     def test_remainder(self):
         with testing.NumpyError(divide='ignore'):
             self.check_binary('remainder')
+        self.check_raises_with_numpy_input(2, 'remainder')
 
     def test_remainder_negative(self):
         with testing.NumpyError(divide='ignore'):