Repository: numba/numba

Issue:
issue with prange writing to preallocated list entries
<!--

Thanks for opening an issue! To help the Numba team handle your information
efficiently, please first ensure that there is no other issue present that
already describes the issue you have
(search at https://github.com/numba/numba/issues?&q=is%3Aissue).

For more general "how do I do X?" type questions, please speak to us in real
time on https://gitter.im/numba/numba or post to the Numba mailing list
https://groups.google.com/a/continuum.io/forum/#!forum/numba-users.

-->

## Reporting a bug

<!--

Before submitting a bug report please ensure that you can check off these boxes:

-->

- [x] I am using the ~latest released~ master version of Numba (most recent is visible in
 the change log (https://github.com/numba/numba/blob/master/CHANGE_LOG).
- [x] I have included below a minimal working reproducer (if you are unsure how
 to write one see http://matthewrocklin.com/blog/work/2018/02/28/minimal-bug-reports).
This:
```python
from numba import njit, prange
import numpy as np

@njit(parallel=True)
def ilikebuffers(x):
    n = len(x)
    buf = [np.zeros_like(x) for _ in range(n)]
    for i in prange(n):
        buf[i] = x + i
    return buf

a = np.ones((10,))

out = ilikebuffers(a)
[print(x) for x in out]

print("")

out = ilikebuffers.py_func(a)
[print(x) for x in out]
```
does this:
```
[2. 2. 2. 2. 2. 2. 2. 2. 2. 2.]
[2. 2. 2. 2. 2. 2. 2. 2. 2. 2.]
[4. 4. 4. 4. 4. 4. 4. 4. 4. 4.]
[4. 4. 4. 4. 4. 4. 4. 4. 4. 4.]
[6. 6. 6. 6. 6. 6. 6. 6. 6. 6.]
[6. 6. 6. 6. 6. 6. 6. 6. 6. 6.]
[10. 10. 10. 10. 10. 10. 10. 10. 10. 10.]
[10. 10. 10. 10. 10. 10. 10. 10. 10. 10.]
[10. 10. 10. 10. 10. 10. 10. 10. 10. 10.]
[10. 10. 10. 10. 10. 10. 10. 10. 10. 10.]

[1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
[2. 2. 2. 2. 2. 2. 2. 2. 2. 2.]
[3. 3. 3. 3. 3. 3. 3. 3. 3. 3.]
[4. 4. 4. 4. 4. 4. 4. 4. 4. 4.]
[5. 5. 5. 5. 5. 5. 5. 5. 5. 5.]
[6. 6. 6. 6. 6. 6. 6. 6. 6. 6.]
[7. 7. 7. 7. 7. 7. 7. 7. 7. 7.]
[8. 8. 8. 8. 8. 8. 8. 8. 8. 8.]
[9. 9. 9. 9. 9. 9. 9. 9. 9. 9.]
[10. 10. 10. 10. 10. 10. 10. 10. 10. 10.]
```
and it shouldn't. Suspect it's analysis of writing to the loop slots, but may be something else.


Discussion:
CC @DrTodd13 
@stuartarchibald I'm unable to replicate.
```
NUMBA_DEBUG_ARRAY_OPT=0 python /tmp/issue4294.py
[1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
[2. 2. 2. 2. 2. 2. 2. 2. 2. 2.]
[3. 3. 3. 3. 3. 3. 3. 3. 3. 3.]
[4. 4. 4. 4. 4. 4. 4. 4. 4. 4.]
[5. 5. 5. 5. 5. 5. 5. 5. 5. 5.]
[6. 6. 6. 6. 6. 6. 6. 6. 6. 6.]
[7. 7. 7. 7. 7. 7. 7. 7. 7. 7.]
[8. 8. 8. 8. 8. 8. 8. 8. 8. 8.]
[9. 9. 9. 9. 9. 9. 9. 9. 9. 9.]
[10. 10. 10. 10. 10. 10. 10. 10. 10. 10.]

[1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
[2. 2. 2. 2. 2. 2. 2. 2. 2. 2.]
[3. 3. 3. 3. 3. 3. 3. 3. 3. 3.]
[4. 4. 4. 4. 4. 4. 4. 4. 4. 4.]
[5. 5. 5. 5. 5. 5. 5. 5. 5. 5.]
[6. 6. 6. 6. 6. 6. 6. 6. 6. 6.]
[7. 7. 7. 7. 7. 7. 7. 7. 7. 7.]
[8. 8. 8. 8. 8. 8. 8. 8. 8. 8.]
[9. 9. 9. 9. 9. 9. 9. 9. 9. 9.]
[10. 10. 10. 10. 10. 10. 10. 10. 10. 10.]

```
@DrTodd13 thanks for trying, after poking about a bit more, I think this is related to core count. I'm going to guess that you ran this on a >10 core machine? Try this one, I get 4 distinct blocks of results, and ran this on a 4 core machine:

```python
from numba import njit, prange
import numpy as np

@njit(parallel=True)
def ilikebuffers(x):
    n = 100
    buf = [np.zeros_like(x) for _ in range(n)]
    for i in prange(n):
        buf[i] = x + i
    return buf

a = np.ones((2,))

out = ilikebuffers(a)
[print(x) for x in out]

print("")

out = ilikebuffers.py_func(a)
[print(x) for x in out]
```
also if I set `NUMBA_NUM_THREADS=8` I get 8 distinct blocks in the results.

Thanks.
@stuartarchibald Confirmed.  NUMBA_NUM_THREADS=1 gives me an output consisting of all 10's.

Resolving patch:
diff --git a/numba/npyufunc/parfor.py b/numba/npyufunc/parfor.py
--- a/numba/npyufunc/parfor.py
+++ b/numba/npyufunc/parfor.py
@@ -591,13 +591,21 @@ def find_vars(var, varset):
     return var
 
 def _hoist_internal(inst, dep_on_param, call_table, hoisted, not_hoisted,
-                    typemap):
+                    typemap, stored_arrays):
+    if inst.target.name in stored_arrays:
+        not_hoisted.append((inst, "stored array"))
+        if config.DEBUG_ARRAY_OPT >= 1:
+            print("Instruction", inst, " could not be hoisted because the created array is stored.")
+        return False
+
     uses = set()
     visit_vars_inner(inst.value, find_vars, uses)
     diff = uses.difference(dep_on_param)
+    if config.DEBUG_ARRAY_OPT >= 1:
+        print("_hoist_internal:", inst, "uses:", uses, "diff:", diff)
     if len(diff) == 0 and is_pure(inst.value, None, call_table):
         if config.DEBUG_ARRAY_OPT >= 1:
-            print("Will hoist instruction", inst)
+            print("Will hoist instruction", inst, typemap[inst.target.name])
         hoisted.append(inst)
         if not isinstance(typemap[inst.target.name], types.npytypes.Array):
             dep_on_param += [inst.target.name]
@@ -613,17 +621,28 @@ def _hoist_internal(inst, dep_on_param, call_table, hoisted, not_hoisted,
                 print("Instruction", inst, " could not be hoisted because it isn't pure.")
     return False
 
-def find_setitems_block(setitems, block):
+def find_setitems_block(setitems, itemsset, block, typemap):
     for inst in block.body:
         if isinstance(inst, ir.StaticSetItem) or isinstance(inst, ir.SetItem):
             setitems.add(inst.target.name)
+            # If we store a non-mutable object into an array then that is safe to hoist.
+            # If the stored object is mutable and you hoist then multiple entries in the
+            # outer array could reference the same object and changing one index would then
+            # change other indices.
+            if getattr(typemap[inst.value.name], "mutable", False):
+                itemsset.add(inst.value.name)
         elif isinstance(inst, parfor.Parfor):
-            find_setitems_block(setitems, inst.init_block)
-            find_setitems_body(setitems, inst.loop_body)
+            find_setitems_block(setitems, itemsset, inst.init_block, typemap)
+            find_setitems_body(setitems, itemsset, inst.loop_body, typemap)
 
-def find_setitems_body(setitems, loop_body):
+def find_setitems_body(setitems, itemsset, loop_body, typemap):
+    """
+      Find the arrays that are written into (goes into setitems) and the
+      mutable objects (mostly arrays) that are written into other arrays
+      (goes into itemsset).
+    """
     for label, block in loop_body.items():
-        find_setitems_block(setitems, block)
+        find_setitems_block(setitems, itemsset, block, typemap)
 
 def hoist(parfor_params, loop_body, typemap, wrapped_blocks):
     dep_on_param = copy.copy(parfor_params)
@@ -635,15 +654,18 @@ def hoist(parfor_params, loop_body, typemap, wrapped_blocks):
     (call_table, reverse_call_table) = get_call_table(wrapped_blocks)
 
     setitems = set()
-    find_setitems_body(setitems, loop_body)
+    itemsset = set()
+    find_setitems_body(setitems, itemsset, loop_body, typemap)
     dep_on_param = list(set(dep_on_param).difference(setitems))
+    if config.DEBUG_ARRAY_OPT >= 1:
+        print("hoist - def_once:", def_once, "setitems:", setitems, "itemsset:", itemsset, "dep_on_param:", dep_on_param, "parfor_params:", parfor_params)
 
     for label, block in loop_body.items():
         new_block = []
         for inst in block.body:
             if isinstance(inst, ir.Assign) and inst.target.name in def_once:
                 if _hoist_internal(inst, dep_on_param, call_table,
-                                   hoisted, not_hoisted, typemap):
+                                   hoisted, not_hoisted, typemap, itemsset):
                     # don't add this instruction to the block since it is
                     # hoisted
                     continue
@@ -656,7 +678,7 @@ def hoist(parfor_params, loop_body, typemap, wrapped_blocks):
                     if (isinstance(ib_inst, ir.Assign) and
                         ib_inst.target.name in def_once):
                         if _hoist_internal(ib_inst, dep_on_param, call_table,
-                                           hoisted, not_hoisted, typemap):
+                                           hoisted, not_hoisted, typemap, itemsset):
                             # don't add this instuction to the block since it is hoisted
                             continue
                     new_init_block.append(ib_inst)


Test patch:
diff --git a/numba/tests/test_parfors.py b/numba/tests/test_parfors.py
--- a/numba/tests/test_parfors.py
+++ b/numba/tests/test_parfors.py
@@ -1939,9 +1939,27 @@ def test_impl(A):
         self.prange_tester(test_impl, A, scheduler_type='signed',
                            check_fastmath=True, check_fastmath_result=True)
 
-    # should this work?
     @skip_unsupported
     def test_prange25(self):
+        def test_impl(A):
+            n = len(A)
+            buf = [np.zeros_like(A) for _ in range(n)]
+            for i in range(n):
+                buf[i] = A + i
+            return buf
+        A = np.ones((10,))
+        self.prange_tester(test_impl, A,  patch_instance=[1],
+                           scheduler_type='unsigned', check_fastmath=True,
+                           check_fastmath_result=True)
+
+        cpfunc = self.compile_parallel(test_impl, (numba.typeof(A),))
+        diagnostics = cpfunc.metadata['parfor_diagnostics']
+        hoisted_allocs = diagnostics.hoisted_allocations()
+        self.assertEqual(len(hoisted_allocs), 0)
+
+    # should this work?
+    @skip_unsupported
+    def test_prange26(self):
         def test_impl(A):
             B = A[::3]
             for i in range(len(B)):