Repository: numba/numba

Issue:
Potential memory leak starting with 0.43.1
- [X] I am using the latest released 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).

There seems to be a potential memory leak if a Numpy array is used in a for-loop, when that Numpy array is defined within an if-statement in that for-loop. This memory leak did not exist prior to Numba 0.43.1. I tested this using two identical environments created using conda. The memory leak still exists in Numba 0.44. The leak seems specific to Numba, it does not occur for the same function that is not jitted. I analyzed this on Mac OS X High Sierra 10.13.6.

The two environments used:

```
name: memory_leak_0420
dependencies:
  - python=3.7.3
  - numba=0.42
  - numpy=1.16.4
```

and

```
name: memory_leak_0431
dependencies:
  - python=3.7.3
  - numba=0.43.1
  - numpy=1.16.4
```

Python code that demonstrates the issue:

```python
import numpy as np
import numba
import platform

print('Python:', platform.python_version())
print('Numpy:', np.__version__)
print('Numba:', numba.__version__)

def f_memory_leak(N):
    sum_vec = np.zeros(10)

    for n in range(N):
        if not n == N:
            vec = np.zeros(10)

        # do something else

        if not n == N:
            sum_vec += vec

    return sum_vec


def f_fixed(N):
    sum_vec = np.zeros(10)

    for n in range(N):
        vec = np.ones(10)

        if not n == N:
            vec = np.zeros(10)

        # do something else

        if not n == N:
            sum_vec += vec

    return sum_vec


def f_fixed_as_well(N):
    sum_vec = np.zeros(10)

    for n in range(N):
        if not n == N:
            vec = np.zeros(10)

            # nothing is done in between, single if statement

            sum_vec += vec

    return sum_vec

njit_f_memory_leak = numba.jit(f_memory_leak, nopython=True)
njit_f_fixed = numba.jit(f_fixed, nopython=True)
njit_f_fixed_as_well = numba.jit(f_fixed_as_well, nopython=True)

N = 1000000

njit_f_memory_leak(N) # each call increases memory used
njit_f_fixed(N) # is fine
njit_f_fixed_as_well(N) # is fine
f_memory_leak(N) # is fine
f_fixed(N) # is fine
f_fixed_as_well(N) # is fine

def increases_memory():
    njit_f_memory_leak(N) # each call increases memory used

def is_fine():
    njit_f_fixed(N) # is fine
    njit_f_fixed_as_well(N) # is fine
    f_memory_leak(N) # is fine
    f_fixed(N) # is fine
    f_fixed_as_well(N) # is fine
```

In summary: `njit_f_memory_leak` is the culprit here. It leaks memory, the other functions do not and the non-jitted version does not leak memory either.

Using Numba 0.43.1+
* If `memory_leak.increases_memory()` is called it will increase the memory used by Python
* If `memory_leak.is_fine()` is called the memory does not increase

Using Numba 0.42.0
* If `memory_leak.increases_memory()` is called the memory does not increase
* If `memory_leak.is_fine()` is called the memory does not increase

The problem seems to be related to the two separate `if`-function calls in the `for`-loop. If they are combined in a single `if`-function call (as in `f_fixed_as_well`) the memory leak disappears.
Potential memory leak starting with 0.43.1
- [X] I am using the latest released 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).

There seems to be a potential memory leak if a Numpy array is used in a for-loop, when that Numpy array is defined within an if-statement in that for-loop. This memory leak did not exist prior to Numba 0.43.1. I tested this using two identical environments created using conda. The memory leak still exists in Numba 0.44. The leak seems specific to Numba, it does not occur for the same function that is not jitted. I analyzed this on Mac OS X High Sierra 10.13.6.

The two environments used:

```
name: memory_leak_0420
dependencies:
  - python=3.7.3
  - numba=0.42
  - numpy=1.16.4
```

and

```
name: memory_leak_0431
dependencies:
  - python=3.7.3
  - numba=0.43.1
  - numpy=1.16.4
```

Python code that demonstrates the issue:

```python
import numpy as np
import numba
import platform

print('Python:', platform.python_version())
print('Numpy:', np.__version__)
print('Numba:', numba.__version__)

def f_memory_leak(N):
    sum_vec = np.zeros(10)

    for n in range(N):
        if not n == N:
            vec = np.zeros(10)

        # do something else

        if not n == N:
            sum_vec += vec

    return sum_vec


def f_fixed(N):
    sum_vec = np.zeros(10)

    for n in range(N):
        vec = np.ones(10)

        if not n == N:
            vec = np.zeros(10)

        # do something else

        if not n == N:
            sum_vec += vec

    return sum_vec


def f_fixed_as_well(N):
    sum_vec = np.zeros(10)

    for n in range(N):
        if not n == N:
            vec = np.zeros(10)

            # nothing is done in between, single if statement

            sum_vec += vec

    return sum_vec

njit_f_memory_leak = numba.jit(f_memory_leak, nopython=True)
njit_f_fixed = numba.jit(f_fixed, nopython=True)
njit_f_fixed_as_well = numba.jit(f_fixed_as_well, nopython=True)

N = 1000000

njit_f_memory_leak(N) # each call increases memory used
njit_f_fixed(N) # is fine
njit_f_fixed_as_well(N) # is fine
f_memory_leak(N) # is fine
f_fixed(N) # is fine
f_fixed_as_well(N) # is fine

def increases_memory():
    njit_f_memory_leak(N) # each call increases memory used

def is_fine():
    njit_f_fixed(N) # is fine
    njit_f_fixed_as_well(N) # is fine
    f_memory_leak(N) # is fine
    f_fixed(N) # is fine
    f_fixed_as_well(N) # is fine
```

In summary: `njit_f_memory_leak` is the culprit here. It leaks memory, the other functions do not and the non-jitted version does not leak memory either.

Using Numba 0.43.1+
* If `memory_leak.increases_memory()` is called it will increase the memory used by Python
* If `memory_leak.is_fine()` is called the memory does not increase

Using Numba 0.42.0
* If `memory_leak.increases_memory()` is called the memory does not increase
* If `memory_leak.is_fine()` is called the memory does not increase

The problem seems to be related to the two separate `if`-function calls in the `for`-loop. If they are combined in a single `if`-function call (as in `f_fixed_as_well`) the memory leak disappears.


Discussion:
Thanks for the report, I can reproduce.
This is a refcount leak:
```python
import psutil
import numpy as np
import numba
rtsys = numba.runtime.rtsys

@numba.njit
def f_memory_leak(N):
    sum_vec = np.zeros(3)

    for n in range(N):
        if n >= 0:
            vec = np.zeros(7)
        sum_vec += vec[0]

    return sum_vec


if __name__ == "__main__":
    proc = psutil.Process()
    print(proc.memory_full_info())
    print(rtsys.get_allocation_stats())

    f_memory_leak(4) # each call increases memory used
    print(proc.memory_full_info())
    print(rtsys.get_allocation_stats())
```
with debug switched on I can see:
```
pfullmem(rss=71536640, vms=446259200, shared=31617024, text=2342912, lib=0, data=33374208, dirty=0, uss=68976640, pss=69791744, swap=0)
nrt_mstats(alloc=0, free=0, mi_alloc=0, mi_free=0)
NRT_Allocate bytes=128 ptr=0x5653f8ad8230
NRT_MemInfo_alloc_safe_aligned 0x5653f8ad8260 24
NRT_Allocate bytes=160 ptr=0x5653f8834930
NRT_MemInfo_alloc_safe_aligned 0x5653f8834960 56
NRT_Allocate bytes=160 ptr=0x5653f8ad7470
NRT_MemInfo_alloc_safe_aligned 0x5653f8ad74a0 56
NRT_Allocate bytes=160 ptr=0x5653f8960090
NRT_MemInfo_alloc_safe_aligned 0x5653f89600c0 56
NRT_Allocate bytes=160 ptr=0x5653f8ad1350
NRT_MemInfo_alloc_safe_aligned 0x5653f8ad1380 56
NRT_MemInfo_call_dtor 0x5653f8ad1350
nrt_internal_dtor_safe 0x5653f8ad1380, 0x38
NRT_Free 0x5653f8ad1350
NRT_MemInfo_release 0x5653f8ad8230 refct=1
NRT_MemInfo_call_dtor 0x5653f8ad8230
nrt_internal_dtor_safe 0x5653f8ad8260, 0x18
NRT_Free 0x5653f8ad8230
pfullmem(rss=84959232, vms=459538432, shared=35225600, text=2342912, lib=0, data=42803200, dirty=0, uss=82395136, pss=83216384, swap=0)
nrt_mstats(alloc=5, free=2, mi_alloc=5, mi_free=2)
```
the allocations in the loop are not all free'd.
Thanks for the report, I can reproduce.
This is a refcount leak:
```python
import psutil
import numpy as np
import numba
rtsys = numba.runtime.rtsys

@numba.njit
def f_memory_leak(N):
    sum_vec = np.zeros(3)

    for n in range(N):
        if n >= 0:
            vec = np.zeros(7)
        sum_vec += vec[0]

    return sum_vec


if __name__ == "__main__":
    proc = psutil.Process()
    print(proc.memory_full_info())
    print(rtsys.get_allocation_stats())

    f_memory_leak(4) # each call increases memory used
    print(proc.memory_full_info())
    print(rtsys.get_allocation_stats())
```
with debug switched on I can see:
```
pfullmem(rss=71536640, vms=446259200, shared=31617024, text=2342912, lib=0, data=33374208, dirty=0, uss=68976640, pss=69791744, swap=0)
nrt_mstats(alloc=0, free=0, mi_alloc=0, mi_free=0)
NRT_Allocate bytes=128 ptr=0x5653f8ad8230
NRT_MemInfo_alloc_safe_aligned 0x5653f8ad8260 24
NRT_Allocate bytes=160 ptr=0x5653f8834930
NRT_MemInfo_alloc_safe_aligned 0x5653f8834960 56
NRT_Allocate bytes=160 ptr=0x5653f8ad7470
NRT_MemInfo_alloc_safe_aligned 0x5653f8ad74a0 56
NRT_Allocate bytes=160 ptr=0x5653f8960090
NRT_MemInfo_alloc_safe_aligned 0x5653f89600c0 56
NRT_Allocate bytes=160 ptr=0x5653f8ad1350
NRT_MemInfo_alloc_safe_aligned 0x5653f8ad1380 56
NRT_MemInfo_call_dtor 0x5653f8ad1350
nrt_internal_dtor_safe 0x5653f8ad1380, 0x38
NRT_Free 0x5653f8ad1350
NRT_MemInfo_release 0x5653f8ad8230 refct=1
NRT_MemInfo_call_dtor 0x5653f8ad8230
nrt_internal_dtor_safe 0x5653f8ad8260, 0x18
NRT_Free 0x5653f8ad8230
pfullmem(rss=84959232, vms=459538432, shared=35225600, text=2342912, lib=0, data=42803200, dirty=0, uss=82395136, pss=83216384, swap=0)
nrt_mstats(alloc=5, free=2, mi_alloc=5, mi_free=2)
```
the allocations in the loop are not all free'd.

Resolving patch:
diff --git a/numba/cgutils.py b/numba/cgutils.py
--- a/numba/cgutils.py
+++ b/numba/cgutils.py
@@ -364,15 +364,16 @@ def alloca_once(builder, ty, size=None, name='', zfill=False):
     pointed by ``builder`` withe llvm type ``ty``.  The optional ``size`` arg
     set the number of element to allocate.  The default is 1.  The optional
     ``name`` arg set the symbol name inside the llvm IR for debugging.
-    If ``zfill`` is set, also filling zeros to the memory.
+    If ``zfill`` is set, fill the memory with zeros at the current
+    use-site location.  Note that the memory is always zero-filled after the
+    ``alloca`` at init-site (the entry block).
     """
     if isinstance(size, utils.INT_TYPES):
         size = ir.Constant(intp_t, size)
     with builder.goto_entry_block():
         ptr = builder.alloca(ty, size=size, name=name)
-        # Zero-fill at the init-site
-        if zfill:
-            builder.store(ty(None), ptr)
+        # Always zero-fill at init-site.  This is safe.
+        builder.store(ty(None), ptr)
     # Also zero-fill at the use-site
     if zfill:
         builder.store(ty(None), ptr)
diff --git a/numba/lowering.py b/numba/lowering.py
--- a/numba/lowering.py
+++ b/numba/lowering.py
@@ -1169,7 +1169,8 @@ def alloca_lltype(self, name, lltype):
         # Is user variable?
         is_uservar = not name.startswith('$')
         # Allocate space for variable
-        aptr = cgutils.alloca_once(self.builder, lltype, name=name, zfill=True)
+        aptr = cgutils.alloca_once(self.builder, lltype,
+                                   name=name, zfill=False)
         if is_uservar:
             # Emit debug info for user variable
             sizeof = self.context.get_abi_sizeof(lltype)


Test patch:
diff --git a/numba/tests/test_practical_lowering_issues.py b/numba/tests/test_practical_lowering_issues.py
new file mode 100644
--- /dev/null
+++ b/numba/tests/test_practical_lowering_issues.py
@@ -0,0 +1,133 @@
+"""
+Tests for practical lowering specific errors.
+"""
+
+
+from __future__ import print_function
+
+import numpy as np
+from numba import njit
+
+from .support import MemoryLeakMixin, TestCase
+
+
+class TestLowering(MemoryLeakMixin, TestCase):
+    def test_issue4156_loop_vars_leak(self):
+        """Test issues with zero-filling of refct'ed variables inside loops.
+
+        Before the fix, the in-loop variables are always zero-filled at their
+        definition location. As a result, their state from the previous
+        iteration is erased. No decref is applied. To fix this, the
+        zero-filling must only happen once after the alloca at the function
+        entry block. The loop variables are technically defined once per
+        function (one alloca per definition per function), but semantically
+        defined once per assignment. Semantically, their lifetime stop only
+        when the variable is re-assigned or when the function ends.
+        """
+        @njit
+        def udt(N):
+            sum_vec = np.zeros(3)
+            for n in range(N):
+                if n >= 0:
+                    # `vec` would leak without the fix.
+                    vec = np.ones(1)
+                if n >= 0:
+                    sum_vec += vec[0]
+
+            return sum_vec
+
+        got = udt(4)
+        expect = udt.py_func(4)
+        self.assertPreciseEqual(got, expect)
+
+    def test_issue4156_loop_vars_leak_variant1(self):
+        """Variant of test_issue4156_loop_vars_leak.
+
+        Adding an outer loop.
+        """
+        @njit
+        def udt(N):
+            sum_vec = np.zeros(3)
+            for x in range(N):
+                for y in range(N):
+                    n = x + y
+                    if n >= 0:
+                        # `vec` would leak without the fix.
+                        vec = np.ones(1)
+                    if n >= 0:
+                        sum_vec += vec[0]
+
+            return sum_vec
+
+        got = udt(4)
+        expect = udt.py_func(4)
+        self.assertPreciseEqual(got, expect)
+
+    def test_issue4156_loop_vars_leak_variant2(self):
+        """Variant of test_issue4156_loop_vars_leak.
+
+        Adding deeper outer loop.
+        """
+        @njit
+        def udt(N):
+            sum_vec = np.zeros(3)
+            for z in range(N):
+                for x in range(N):
+                    for y in range(N):
+                        n = x + y + z
+                        if n >= 0:
+                            # `vec` would leak without the fix.
+                            vec = np.ones(1)
+                        if n >= 0:
+                            sum_vec += vec[0]
+
+            return sum_vec
+
+        got = udt(4)
+        expect = udt.py_func(4)
+        self.assertPreciseEqual(got, expect)
+
+    def test_issue4156_loop_vars_leak_variant3(self):
+        """Variant of test_issue4156_loop_vars_leak.
+
+        Adding inner loop around allocation
+        """
+        @njit
+        def udt(N):
+            sum_vec = np.zeros(3)
+            for z in range(N):
+                for x in range(N):
+                    n = x + z
+                    if n >= 0:
+                        for y in range(N):
+                            # `vec` would leak without the fix.
+                            vec = np.ones(y)
+                    if n >= 0:
+                        sum_vec += vec[0]
+
+            return sum_vec
+
+        got = udt(4)
+        expect = udt.py_func(4)
+        self.assertPreciseEqual(got, expect)
+
+    def test_issue4156_loop_vars_leak_variant4(self):
+        """Variant of test_issue4156_loop_vars_leak.
+
+        Interleaves loops and allocations
+        """
+        @njit
+        def udt(N):
+            sum_vec = 0
+
+            for n in range(N):
+                vec = np.zeros(7)
+                for n in range(N):
+                    z = np.zeros(7)
+                sum_vec += vec[0] + z[0]
+
+            return sum_vec
+
+        got = udt(4)
+        expect = udt.py_func(4)
+        self.assertPreciseEqual(got, expect)