Repository: nv-legate/cunumeric

Issue:
Copying a slice to another slice in the same array fails silently
Bug report due to @piyueh

The following code, when run with `-lg:numpy:test`, prints `[False]`, indicating that the slice has not been updated:

```
from legate import numpy
a = numpy.random.random((3, 3))
a[:, 0] = a[:, 2]
print(numpy.allclose(a[:, 0], a[:, 2]))
```

After some digging I found that we skip copies between sub-regions if they're backed by the same field, which is actually only safe if the slices are equivalent: https://github.com/nv-legate/legate.numpy/blob/2b460c5dfdd60b673e37e25231bf625fdf3ead0e/legate/numpy/deferred.py#L101-L105

If we simply skip this check then the copy ends up happening through a CopyTask, which works with subregions of the same base region.

However, the runtime errors out if the two slices overlap, e.g. if we do `a[0,0:2] = a[0,1:3]` (vanilla NumPy accepts this, and does the expected thing). We should at least check for overlaps in python and produce a reasonable error message.

We also want to add a case for this to the test suite.


Discussion:
Here's another variation on this problem that we need to handle:

```
a[ [4,2,5,1,3] ] = b[2:7]
```

Requires a destination indirect (scatter) copy with an additional transform indirection on the source.
The problem here is that we are missing a translation from NumPy (local) index space (i.e. every view's indices start from 0) to Legion (global) index space (i.e. every view's indices start wherever that view is placed within the base array) when emitting a scatter copy.

The most general case of this pattern (writing through an advanced view) also includes a view on the LHS:

```
a[10:15][ [4,2,5,1,3] ] = b[2:7]
```

I believe the best way to handle this case  is to set up a full scatter-gather copy as follows:
(1) materialize a field to store the `0:5 -> 2:7` mapping, use that as `src_indirect`
(2) manually apply the `0:5 -> 10:15` transformation on top of `[4,2,5,1,3]` to produce `[14,12,15,11,13]`, use that as `dst_indirect`

The work on https://github.com/StanfordLegion/legion/issues/705 would allow us to avoid materializing an explicit field for (1).
Hi, is it possible to raise a `NotImplementedError` when application code uses this kind of in-array copying? It seems it takes time to resolve it. However, this kind of copying is very common in numerical methods for solving PDEs, for example, in periodic boundary conditions and 1st-order approximation of Neumann boundary conditions. So I think raising an error to notify application developers is a better short-term solution.

Actually, even in the last step of the 12-step to CFD (the one used in GTC presentation), we also have this type of copying in `pressure_poisson_periodic`. (However, in this particular case, the pressure field does not change, so a failure in the copying does not affect the solution's correctness.)

Another example is the code shown in issue #29.
Yes, I meant to tackle this properly after fixing #12, but that's taking longer than expected. I will add some error messages as a stopgap.

Resolving patch:
diff --git a/legate/numpy/deferred.py b/legate/numpy/deferred.py
--- a/legate/numpy/deferred.py
+++ b/legate/numpy/deferred.py
@@ -98,11 +98,21 @@ def copy(self, rhs, deep, stacklevel, callsite=None):
             return
         src = src_array.base
         dst = dst_array.base
-        # Sometimes we get asked to do this in case of in-place updates
-        # in which case both src and dst will have the same field so
-        # there is nothing for us to do
+        # Check for intra-array copies
         if src.field is dst.field:
-            return
+            if src.region is dst.region and src.transform == dst.transform:
+                # Self-copy; This will happen because of how Python expands
+                # in-place updates: x[:] += y becomes x[:] = x[:].__iadd__(y),
+                # and that eventually executes x[:] = x[:]. In this case we
+                # have nothing to do.
+                return
+            else:
+                # XXX: This will happen if we are copying between two different
+                # sub-arrays within the same array. If the two sub-arrays
+                # overlap we will fail with a runtime error. Unfortunately
+                # there is no cheap and accurate way to test for overlap in the
+                # presence of views.
+                pass
         # Check to see if we already have a target launch space for
         # the destination meaning it has been partitioned already
         if dst.has_parallel_launch_space():
@@ -111,12 +121,8 @@ def copy(self, rhs, deep, stacklevel, callsite=None):
         else:
             launch_space = src.compute_parallel_launch_space()
             src_parallel = True
-        # Check to see if they are the same or there are no transforms
-        if (
-            src.region is dst.region
-            or src.region.index_space is dst.region.index_space
-            or (src.transform is None and dst.transform is None)
-        ):
+        # Check to see if there are no transforms
+        if src.transform is None and dst.transform is None:
             # In this case we can just do a normal Legion copy
             if launch_space is not None:
                 if src_parallel:
@@ -359,6 +365,8 @@ def get_item(self, key, stacklevel, view=None, dim_map=None):
             launch_space = index.compute_parallel_launch_space()
             src = self.base
             dst = result_field
+            if src.transform is not None or index.transform is not None:
+                raise NotImplementedError("views with advanced indexing")
             if launch_space is not None:
                 # Index copy launch
                 if self.ndim == index_array.ndim:
@@ -496,6 +504,14 @@ def set_item(self, key, rhs, stacklevel):
             launch_space = index.compute_parallel_launch_space()
             dst = self.base
             src = value_array.base
+            if src.field is dst.field:
+                raise NotImplementedError("intra-array advanced coyping")
+            if (
+                src.transform is not None
+                or index.transform is not None
+                or dst.transform is not None
+            ):
+                raise NotImplementedError("views with advanced indexing")
             if launch_space is not None:
                 # Index copy launch
                 if self.ndim == index_array.ndim:
@@ -3141,6 +3157,7 @@ def sort(self, rhs, stacklevel, callsite=None):
                 raise NotImplementedError("Need flatten copy")
             else:
                 # Copy the data to the output array
+                # TODO: consider transforms
                 copy = Copy(mapper=self.runtime.mapper_id)
                 copy.add_src_requirement(rhs.region, rhs.field.field_id)
                 copy.add_dst_requirement(lhs.region, lhs.field.field_id)
diff --git a/legate/numpy/runtime.py b/legate/numpy/runtime.py
--- a/legate/numpy/runtime.py
+++ b/legate/numpy/runtime.py
@@ -493,6 +493,29 @@ def __del__(self):
         if self.attach_array is not None:
             self.detach_numpy_array(unordered=True, defer=True)
 
+    def overlaps(self, other):
+        # TODO: This check is too conservative to be useful. It intersects the
+        # convex hulls of the accessed regions on the base array (after
+        # applying any transforms). This ignores slice steps, and is very
+        # inaccurate when going from a 2d view to a 1d base array (as is the
+        # case with reshape).
+        if self.field is not other.field:
+            return False
+        arrays = [self, other]
+        lo = [None, None]
+        hi = [None, None]
+        for i in range(2):
+            lo[i] = (0,) * len(arrays[i].shape)
+            hi[i] = tuple(x - 1 for x in arrays[i].shape)
+            if arrays[i].transform is not None:
+                lo[i] = arrays[i].transform.apply(lo[i])
+                hi[i] = arrays[i].transform.apply(hi[i])
+        assert len(lo[0]) == len(lo[1])
+        for a_lo, a_hi, b_lo, b_hi in zip(lo[0], hi[0], lo[1], hi[1]):
+            if a_hi < b_lo or b_hi < a_lo:
+                return False
+        return True
+
     def has_parallel_launch_space(self):
         return self.launch_space is not None
 


Test patch:
diff --git a/tests/slicing.py b/tests/slicing.py
--- a/tests/slicing.py
+++ b/tests/slicing.py
@@ -79,6 +79,10 @@ def test():
     # print(thirdslicegold)
     # assert(lg.array_equal(thirdslice, thirdslicegold))
 
+    x = lg.arange(10)
+    x[0:5] = x[5:10]
+    assert np.array_equal(x, [5, 6, 7, 8, 9, 5, 6, 7, 8, 9])
+
     return