Repository: dask/dask

Issue:
Try np.concatenate() for unregistered backends.
As discussed in https://github.com/plures/gumath/issues/22, unregistered backends work if `np.concatenate()` is tried.  This is a an example that deliberately does not touch the code path for registered backends but works for `xnd.array`:

```
diff --git a/dask/array/core.py b/dask/array/core.py
index f04ff0c..dd968a6 100644
--- a/dask/array/core.py
+++ b/dask/array/core.py
@@ -3505,6 +3505,13 @@ def concatenate3(arrays):
         x = unpack_singleton(arrays)
         return _concatenate2(arrays, axes=list(range(x.ndim)))
 
+    if hasattr(advanced, '__array_function__'):
+        try:
+            x = unpack_singleton(arrays)
+            return _concatenate2(arrays, axes=list(range(x.ndim)))
+        except TypeError:
+            pass # np.concatenate not implemented.
+
     ndim = ndimlist(arrays)
     if not ndim:
         return arrays
```


Discussion:
cc @mrocklin @pentschev I believe this is the right way forward, since it was the point of `__array_function__`. 😄 
This function is a little bit hacky (my apologies).  Ideally arrays use the logic at the bottom of this function, which allocates an array and then assigns into it.  The `_concatenate2` solution is slower and involves a new copy for every dimension.  

The proposed solution is sub-ideal because it slows down the common numpy case.  Probably short term we want to do something like ...

```python
if the arrays are all numpy arrays:
    do the assignment solution
else:
    try the concatenate2 solution
```

In the future presumably other fully featured implementations like cupy could also make use of the assignment solution at the bottom, if we used something like `np.empty_like(..., shape=)` (cc @pentschev)
I agree with what @mrocklin is saying, we should try to avoid slowing the current implementation down. Seems like his suggestion wouldn't be too difficult to get working, so if we have a PR that covers his concerns, that's +1 from me.

Resolving patch:
diff --git a/dask/array/core.py b/dask/array/core.py
--- a/dask/array/core.py
+++ b/dask/array/core.py
@@ -3757,12 +3757,27 @@ def concatenate3(arrays):
            [1, 2, 1, 2],
            [1, 2, 1, 2]])
     """
+    from .utils import IS_NEP18_ACTIVE
+    # We need this as __array_function__ may not exist on older NumPy versions.
+    # And to reduce verbosity.
+    NDARRAY_ARRAY_FUNCTION = getattr(np.ndarray, '__array_function__', None)
+
     arrays = concrete(arrays)
     if not arrays:
         return np.empty(0)
 
     advanced = max(core.flatten(arrays, container=(list, tuple)),
                    key=lambda x: getattr(x, '__array_priority__', 0))
+
+    if (IS_NEP18_ACTIVE and not all(NDARRAY_ARRAY_FUNCTION is
+                                    getattr(arr, '__array_function__', NDARRAY_ARRAY_FUNCTION)
+                                    for arr in arrays)):
+        try:
+            x = unpack_singleton(arrays)
+            return _concatenate2(arrays, axes=tuple(range(x.ndim)))
+        except TypeError:
+            pass
+
     if concatenate_lookup.dispatch(type(advanced)) is not np.concatenate:
         x = unpack_singleton(arrays)
         return _concatenate2(arrays, axes=list(range(x.ndim)))


Test patch:
diff --git a/dask/array/tests/test_array_function.py b/dask/array/tests/test_array_function.py
--- a/dask/array/tests/test_array_function.py
+++ b/dask/array/tests/test_array_function.py
@@ -104,3 +104,99 @@ def test_array_function_cupy_svd():
     assert_eq(u, u_base)
     assert_eq(s, s_base)
     assert_eq(v, v_base)
+
+
+@pytest.mark.skipif(missing_arrfunc_cond, reason=missing_arrfunc_reason)
+@pytest.mark.parametrize('func', [
+    lambda x: np.concatenate([x, x, x]),
+    lambda x: np.cov(x, x),
+    lambda x: np.dot(x, x),
+    lambda x: np.dstack(x),
+    lambda x: np.flip(x, axis=0),
+    lambda x: np.hstack(x),
+    lambda x: np.matmul(x, x),
+    lambda x: np.mean(x),
+    lambda x: np.stack([x, x]),
+    lambda x: np.sum(x),
+    lambda x: np.var(x),
+    lambda x: np.vstack(x),
+    lambda x: np.linalg.norm(x)])
+def test_unregistered_func(func):
+    def wrap(func_name):
+        """
+        Wrap a function.
+        """
+        def wrapped(self, *a, **kw):
+            a = getattr(self.arr, func_name)(*a, **kw)
+            return a if not isinstance(a, np.ndarray) else type(self)(a)
+
+        return wrapped
+
+    def dispatch_property(prop_name):
+        """
+        Wrap a simple property.
+        """
+        @property
+        def wrapped(self, *a, **kw):
+            return getattr(self.arr, prop_name)
+
+        return wrapped
+
+    class EncapsulateNDArray(np.lib.mixins.NDArrayOperatorsMixin):
+        """
+        A class that "mocks" ndarray by encapsulating an ndarray and using
+        protocols to "look like" an ndarray. Basically tests whether Dask
+        works fine with something that is essentially an array but uses
+        protocols instead of being an actual array.
+        """
+        __array_priority__ = 20
+
+        def __init__(self, arr):
+            self.arr = arr
+
+        def __array__(self, **kwargs):
+            return np.asarray(self.arr, **kwargs)
+
+        def __array_function__(self, f, t, arrs, kw):
+            arrs = tuple(arr if not isinstance(arr, type(self)) else arr.arr for arr in arrs)
+            t = tuple(ti for ti in t if not issubclass(ti, type(self)))
+            print(t)
+            a = self.arr.__array_function__(f, t, arrs, kw)
+            return a if not isinstance(a, np.ndarray) else type(self)(a)
+
+        __getitem__ = wrap('__getitem__')
+
+        __setitem__ = wrap('__setitem__')
+
+        def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
+            inputs = tuple(i if not isinstance(i, type(self)) else i.arr for i in inputs)
+            a = getattr(ufunc, method)(*inputs, **kwargs)
+            return a if not isinstance(a, np.ndarray) else type(self)(a)
+
+        shape = dispatch_property('shape')
+        ndim = dispatch_property('ndim')
+        dtype = dispatch_property('dtype')
+
+        astype = wrap('astype')
+        sum = wrap('sum')
+        prod = wrap('prod')
+
+    # Wrap a procol-based encapsulated ndarray
+    x = EncapsulateNDArray(np.random.random((100, 100)))
+
+    # See if Dask holds the array fine
+    y = da.from_array(x, chunks=(50, 50))
+
+    # Check if it's an equivalent array
+    assert_eq(x, y, check_meta=False)
+
+    # Perform two NumPy functions, one on the
+    # Encapsulated array
+    xx = func(x)
+
+    # And one on the Dask array holding these
+    # encapsulated arrays
+    yy = func(y)
+
+    # Check that they are equivalent arrays.
+    assert_eq(xx, yy, check_meta=False)