Repository: numba/numba

Issue:
Non-det bug with prange code
This issue is reported on discourse https://numba.discourse.group/t/nested-loop-fails-in-version-0-59/ and affects 0.59.

Behavior is non-deterministic on 0.59. Git bisect points to https://github.com/numba/numba/pull/9255 as first bad commit.
Running reproducer on 0.58 have always passed when I tested locally.

Given OP discover that the bug depends on Python versions, the current guess is that it is dependent on either the bytecode ordering or the behavior (iteration ordering) for containers.

This is a blocking issue for 0.59.1


Discussion:

Resolving patch:
diff --git a/numba/core/ssa.py b/numba/core/ssa.py
--- a/numba/core/ssa.py
+++ b/numba/core/ssa.py
@@ -17,6 +17,7 @@
 
 from numba import config
 from numba.core import ir, ir_utils, errors
+from numba.core.utils import OrderedSet
 from numba.core.analysis import compute_cfg_from_blocks
 
 
@@ -154,8 +155,10 @@ def _find_defs_violators(blocks, cfg):
     states = dict(defs=defs, uses=uses)
     _run_block_analysis(blocks, states, _GatherDefsHandler())
     _logger.debug("defs %s", pformat(defs))
-    # Gather violators by number of definitions
-    violators = {k for k, vs in defs.items() if len(vs) > 1}
+    # Gather violators by number of definitions.
+    # The violators are added by the order that they are seen and the algorithm
+    # scan from the first to the last basic-block as they occur in bytecode.
+    violators = OrderedSet([k for k, vs in defs.items() if len(vs) > 1])
     # Gather violators by uses not dominated by the one def
     doms = cfg.dominators()
     for k, use_blocks in uses.items():
diff --git a/numba/core/utils.py b/numba/core/utils.py
--- a/numba/core/utils.py
+++ b/numba/core/utils.py
@@ -385,6 +385,28 @@ def key(x):
 T = _tp.TypeVar('T')
 
 
+class OrderedSet(MutableSet[T]):
+
+    def __init__(self, iterable: _tp.Iterable[T] = ()):
+        # Just uses a dictionary under-the-hood to maintain insertion order.
+        self._data = dict.fromkeys(iterable, None)
+
+    def __contains__(self, key):
+        return key in self._data
+
+    def __iter__(self):
+        return iter(self._data)
+
+    def __len__(self):
+        return len(self._data)
+
+    def add(self, item):
+        self._data[item] = None
+
+    def discard(self, item):
+        self._data.pop(item, None)
+
+
 class MutableSortedSet(MutableSet[T], _tp.Generic[T]):
     """Mutable Sorted Set
     """


Test patch:
diff --git a/numba/tests/parfor_iss9490_usecase.py b/numba/tests/parfor_iss9490_usecase.py
new file mode 100644
--- /dev/null
+++ b/numba/tests/parfor_iss9490_usecase.py
@@ -0,0 +1,77 @@
+"""
+This is a testcase for https://github.com/numba/numba/issues/9490.
+The bug is very sensitive to the control-flow and variable uses.
+It is impossible to shrink the reproducer in any meaningful way.
+
+The test is also sensitive to PYTHONHASHSEED.
+PYTHONHASHSEED=1 will trigger the bug.
+
+Example of traceback:
+
+  File "/numba/parfors/parfor.py", line 2070, in _arrayexpr_to_parfor
+    index_vars, loopnests = _mk_parfor_loops(pass_states.typemap, size_vars,
+                                             scope, loc)
+  File "/numba/parfors/parfor.py", line 1981, in _mk_parfor_loops
+    for size_var in size_vars:
+TypeError: Failed in nopython mode pipeline (step: convert to parfors)
+'NoneType' object is not iterable
+"""
+
+import numba
+import numpy as np
+
+
+@numba.jit(nopython=True, parallel=True)
+def stable_fit(X, y, threshold=3):
+    min_obs = int(X.shape[1] * 1.5)
+    beta = np.zeros((X.shape[1], y.shape[1]), dtype=np.float64)
+    residuals = np.full_like(y, np.nan)
+    stable = np.empty((y.shape[1]))
+    for idx in numba.prange(y.shape[1]):
+        y_sub = y[:, idx]
+        isna = np.isnan(y_sub)
+        X_sub = X[~isna]
+        y_sub = y_sub[~isna]
+        is_stable = False
+
+        # Run until minimum observations
+        # or until stability is reached
+        for jdx in range(len(y_sub), min_obs - 1, -2):
+            # Timeseries gets reduced by two elements
+            # each iteration
+            y_ = y_sub[-jdx:]
+            X_ = X_sub[-jdx:]
+            beta_sub = np.linalg.solve(np.dot(X_.T, X_), np.dot(X_.T, y_))
+            resid_sub = np.dot(X_, beta_sub) - y_
+            # Check for stability
+            rmse = np.sqrt(np.mean(resid_sub ** 2))
+            first = np.fabs(resid_sub[0]) / rmse < threshold
+            last = np.fabs(resid_sub[-1]) / rmse < threshold
+            # Break if stability is reached
+            is_stable = first & last
+            if is_stable:
+                break
+
+        beta[:, idx] = beta_sub
+        residuals[-jdx:, idx] = resid_sub
+        stable[idx] = is_stable
+    return beta, residuals, stable.astype(np.bool_)
+
+
+def check():
+    np.random.seed(0)
+    X_shape = (10, 4)
+    y_shape = (10, 5)
+    X = np.random.random(X_shape)
+    y = np.random.random(y_shape)
+
+    got_beta, got_residuals, got_stable = stable_fit(X, y)
+    exp_beta, exp_residuals, exp_stable = stable_fit.py_func(X, y)
+
+    np.testing.assert_allclose(got_beta, exp_beta)
+    np.testing.assert_allclose(got_residuals, exp_residuals)
+    np.testing.assert_allclose(got_stable, exp_stable)
+
+
+if __name__ == "__main__":
+    check()
diff --git a/numba/tests/support.py b/numba/tests/support.py
--- a/numba/tests/support.py
+++ b/numba/tests/support.py
@@ -549,7 +549,8 @@ def _assertNumberEqual(first, second, delta=None):
             _assertNumberEqual(first, second, delta)
 
     def subprocess_test_runner(self, test_module, test_class=None,
-                               test_name=None, envvars=None, timeout=60):
+                               test_name=None, envvars=None, timeout=60,
+                               _subproc_test_env="1"):
         """
         Runs named unit test(s) as specified in the arguments as:
         test_module.test_class.test_name. test_module must always be supplied
@@ -560,7 +561,8 @@ def subprocess_test_runner(self, test_module, test_class=None,
             environment variable name (str) -> value (str)
         It is most convenient to use this method in conjunction with
         @needs_subprocess as the decorator will cause the decorated test to be
-        skipped unless the `SUBPROC_TEST` environment variable is set to 1
+        skipped unless the `SUBPROC_TEST` environment variable is set to
+        the same value of ``_subproc_test_env``
         (this special environment variable is set by this method such that the
         specified test(s) will not be skipped in the subprocess).
 
@@ -575,7 +577,7 @@ def subprocess_test_runner(self, test_module, test_class=None,
         fully_qualified_test = '.'.join(x for x in parts if x is not None)
         cmd = [sys.executable, '-m', 'numba.runtests', fully_qualified_test]
         env_copy = os.environ.copy()
-        env_copy['SUBPROC_TEST'] = '1'
+        env_copy['SUBPROC_TEST'] = _subproc_test_env
         try:
             env_copy['COVERAGE_PROCESS_START'] = os.environ['COVERAGE_RCFILE']
         except KeyError:
@@ -601,15 +603,18 @@ def run_test_in_subprocess(maybefunc=None, timeout=60, envvars=None):
         subprocess_test_runner."""
         def wrapper(func):
             def inner(self, *args, **kwargs):
-                if os.environ.get("SUBPROC_TEST", None) != "1":
+                if os.environ.get("SUBPROC_TEST", None) != func.__name__:
                     # Not in a subprocess test env, so stage the call to run the
                     # test in a subprocess which will set the env var.
                     class_name = self.__class__.__name__
-                    self.subprocess_test_runner(test_module=self.__module__,
-                                                test_class=class_name,
-                                                test_name=func.__name__,
-                                                timeout=timeout,
-                                                envvars=envvars,)
+                    self.subprocess_test_runner(
+                        test_module=self.__module__,
+                        test_class=class_name,
+                        test_name=func.__name__,
+                        timeout=timeout,
+                        envvars=envvars,
+                        _subproc_test_env=func.__name__,
+                    )
                 else:
                     # env var is set, so we're in the subprocess, run the
                     # actual test.
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
@@ -2368,6 +2368,23 @@ def def_in_loop(x):
         self.assertEqual(expected, njit(parallel=False)(def_in_loop)(4))
         self.assertEqual(expected, njit(parallel=True)(def_in_loop)(4))
 
+    @needs_lapack  # use of np.linalg.solve
+    def test_issue9490_non_det_ssa_problem(self):
+        cmd = [
+            sys.executable,
+            "-m",
+            "numba.tests.parfor_iss9490_usecase",
+        ]
+
+        try:
+            subp.check_output(cmd, env={**os.environ,
+                                        "PYTHONHASHSEED": "1"},
+                              stderr=subp.STDOUT,
+                              encoding='utf-8')
+        except subp.CalledProcessError as e:
+            msg = f"subprocess failed with output:\n{e.output}"
+            self.fail(msg=msg)
+
 
 @skip_parfors_unsupported
 class TestParforsLeaks(MemoryLeakMixin, TestParforsBase):