Repository: scipy/scipy

Issue:
Memory leak in shgo triangulation
The Complex class in shgo triangulation uses an lru_cache with `max_size=None` :

https://github.com/scipy/scipy/blob/master/scipy/optimize/_shgo_lib/triangulation.py#L460
https://github.com/scipy/scipy/blob/master/scipy/optimize/_shgo_lib/triangulation.py#L482
https://github.com/scipy/scipy/blob/master/scipy/optimize/_shgo_lib/triangulation.py#L488

This infinite cache is a problem when using shgo in a long-running process.

I could detect it quickly because the minimized function was a bound method of an
object which itself was holding a reference to a large array :

```
class SomethingBig:
    def __init__(self):
        self.something = numpy.arange(500*1024*1024)

    def get_value(self, x):
        # The real calculation uses the large array
        return scipy.optimize.rosen(x)


def work():
    something_big = SomethingBig()
    # A large-enough iters is required to start using the cache
    scipy.optimize.shgo(something_big.get_value, ((-1, 1), (-1, 1)), iters=5)


for i in range(100):
    work()
    print(i)
```

The bug was confirmed when using the following workaround between calls to
`scipy.optimize.shgo` completely stopped the memory leak :

```
# 1: Confirm the evergrowing cache:
print(scipy.optimize._shgo_lib.triangulation.Complex.generate_sub_cell_t1.cache_info())
print(scipy.optimize._shgo_lib.triangulation.Complex.generate_sub_cell_t2.cache_info())
# 2: Clear it
scipy.optimize._shgo_lib.triangulation.Complex.generate_sub_cell_t1.cache_clear()
scipy.optimize._shgo_lib.triangulation.Complex.generate_sub_cell_t2.cache_clear()
```

A fixed-size cache would not really solve the problem, at least in my case :

The number of calls to `Complex.generate_sub_cell_t1` and `Complex.generate_sub_cell_t1`
during a single call to `scipy.optimize.shgo` is small (around 100) but the array held
by the cache is large and I can't afford to have more one of those arrays kept in memory.
In this situation I don't think there is a cache size which would be both useful and harmless.

If the cache is necessary, maybe it would be better to wrap the methods after the `__init__`
to make sure the cache is removed with the instance of Complex(), and never shared
between calls of shgo :

```
class Complex:
    def __init__(self, dim, func, func_args=(), symmetry=False, bounds=None,
                 g_cons=None, g_args=()):
        [...]
        self.generate_sub_cell_t1 = lru_cache(maxsize=None)(self.generate_sub_cell_t1)
        self.generate_sub_cell_t2 = lru_cache(maxsize=None)(self.generate_sub_cell_t2)
```

Instead of :
```
class Complex:
    @lru_cache(maxsize=None)
    def generate_sub_cell_t1(self, origin, v_x):
        [...]
    @lru_cache(maxsize=None)
    def generate_sub_cell_t2(self, origin, v_x):
        [...]
```

I can provide a patch if you think this is an acceptable solution.

#### Scipy/Numpy/Python version information:
```
>>> import sys, scipy, numpy; print(scipy.__version__, numpy.__version__, sys.version_info)
1.3.1 1.17.2 sys.version_info(major=3, minor=7, micro=3, releaselevel='final', serial=0)
```


Discussion:
Thanks for the report @kmichel-sereema.

@Stefan-Endres could you have a look at this?
Hi @kmichel-sereema, thank you for the detailed report.

I agree with what you're saying, in addition the performance benefit for avoiding the vector computations is not that big so I don't think that this cache is necessary at all (these caches are of course different from the cache for the objective function itself, the latter being necessary for most applications). 

In fact I've been trying to get rid of both the cache and the need for the `generate_sub_cell_#` methods in the new sampling method (which produces the same distribution/complex, but different, faster algorithms to compute it).

A quick patch for the scipy version would be appreciated as I still have some ways to go in terms of validating the new sampling method with the old simplicial complex.

Resolving patch:
diff --git a/scipy/optimize/_shgo_lib/triangulation.py b/scipy/optimize/_shgo_lib/triangulation.py
--- a/scipy/optimize/_shgo_lib/triangulation.py
+++ b/scipy/optimize/_shgo_lib/triangulation.py
@@ -1,102 +1,6 @@
 import numpy as np
 import copy
 
-try:
-    from functools import lru_cache  # For Python 3 only
-except ImportError:  # Python 2:
-    import time
-    import functools
-    import collections
-
-    # Note to avoid using external packages such as functools32 we use this code
-    # only using the standard library
-    def lru_cache(maxsize=255, timeout=None):
-        """
-        Thanks to ilialuk @ https://stackoverflow.com/users/2121105/ilialuk for
-        this code snippet. Modifications by S. Endres
-        """
-
-        class LruCacheClass(object):
-            def __init__(self, input_func, max_size, timeout):
-                self._input_func = input_func
-                self._max_size = max_size
-                self._timeout = timeout
-
-                # This will store the cache for this function,
-                # format - {caller1 : [OrderedDict1, last_refresh_time1],
-                #  caller2 : [OrderedDict2, last_refresh_time2]}.
-                #   In case of an instance method - the caller is the instance,
-                # in case called from a regular function - the caller is None.
-                self._caches_dict = {}
-
-            def cache_clear(self, caller=None):
-                # Remove the cache for the caller, only if exists:
-                if caller in self._caches_dict:
-                    del self._caches_dict[caller]
-                    self._caches_dict[caller] = [collections.OrderedDict(),
-                                                 time.time()]
-
-            def __get__(self, obj, objtype):
-                """ Called for instance methods """
-                return_func = functools.partial(self._cache_wrapper, obj)
-                return_func.cache_clear = functools.partial(self.cache_clear,
-                                                            obj)
-                # Return the wrapped function and wraps it to maintain the
-                # docstring and the name of the original function:
-                return functools.wraps(self._input_func)(return_func)
-
-            def __call__(self, *args, **kwargs):
-                """ Called for regular functions """
-                return self._cache_wrapper(None, *args, **kwargs)
-
-            # Set the cache_clear function in the __call__ operator:
-            __call__.cache_clear = cache_clear
-
-            def _cache_wrapper(self, caller, *args, **kwargs):
-                # Create a unique key including the types (in order to
-                # differentiate between 1 and '1'):
-                kwargs_key = "".join(map(
-                    lambda x: str(x) + str(type(kwargs[x])) + str(kwargs[x]),
-                    sorted(kwargs)))
-                key = "".join(
-                    map(lambda x: str(type(x)) + str(x), args)) + kwargs_key
-
-                # Check if caller exists, if not create one:
-                if caller not in self._caches_dict:
-                    self._caches_dict[caller] = [collections.OrderedDict(),
-                                                 time.time()]
-                else:
-                    # Validate in case the refresh time has passed:
-                    if self._timeout is not None:
-                        if (time.time() - self._caches_dict[caller][1]
-                                > self._timeout):
-                            self.cache_clear(caller)
-
-                # Check if the key exists, if so - return it:
-                cur_caller_cache_dict = self._caches_dict[caller][0]
-                if key in cur_caller_cache_dict:
-                    return cur_caller_cache_dict[key]
-
-                # Validate we didn't exceed the max_size:
-                if len(cur_caller_cache_dict) >= self._max_size:
-                    # Delete the first item in the dict:
-                    try:
-                        cur_caller_cache_dict.popitem(False)
-                    except KeyError:
-                        pass
-                # Call the function and store the data in the cache (call it
-                # with the caller in case it's an instance function)
-                if caller is not None:
-                    args = (caller,) + args
-                cur_caller_cache_dict[key] = self._input_func(*args, **kwargs)
-
-                return cur_caller_cache_dict[key]
-
-        # Return the decorator wrapping the class (also wraps the instance to
-        # maintain the docstring and the name of the original function):
-        return (lambda input_func: functools.wraps(input_func)(
-            LruCacheClass(input_func, maxsize, timeout)))
-
 
 class Complex:
     def __init__(self, dim, func, func_args=(), symmetry=False, bounds=None,
@@ -338,7 +242,6 @@ def split_generation(self):
         self.gen += 1
         return no_splits  # USED IN SHGO
 
-    # @lru_cache(maxsize=None)
     def construct_hypercube(self, origin, supremum, gen, hgr,
                             printout=False):
         """
@@ -351,21 +254,22 @@ def construct_hypercube(self, origin, supremum, gen, hgr,
         gen : generation
         hgr : parent homology group rank
         """
-
         # Initiate new cell
+        v_o = np.array(origin)
+        v_s = np.array(supremum)
+
         C_new = Cell(gen, hgr, origin, supremum)
-        C_new.centroid = tuple(
-            (np.array(origin) + np.array(supremum)) / 2.0)
+        C_new.centroid = tuple((v_o + v_s) * .5)
 
         # Build new indexed vertex list
         V_new = []
 
-        # Cached calculation
         for i, v in enumerate(self.C0()[:-1]):
-            t1 = self.generate_sub_cell_t1(origin, v.x)
-            t2 = self.generate_sub_cell_t2(supremum, v.x)
+            v_x = np.array(v.x)
+            sub_cell_t1 = v_o - v_o * v_x
+            sub_cell_t2 = v_s * v_x
 
-            vec = t1 + t2
+            vec = sub_cell_t1 + sub_cell_t2
 
             vec = tuple(vec)
             C_new.add_vertex(self.V[vec])
@@ -457,39 +361,6 @@ def split_simplex_symmetry(self, S, gen):
 
         return
 
-    @lru_cache(maxsize=None)
-    def generate_sub_cell_2(self, origin, supremum, v_x_t):  # No hits
-        """
-        Use the origin and supremum vectors to find a new cell in that
-        subspace direction
-
-        NOTE: NOT CURRENTLY IN USE!
-
-        Parameters
-        ----------
-        origin : tuple vector (hashable)
-        supremum : tuple vector (hashable)
-
-        Returns
-        -------
-
-        """
-        t1 = self.generate_sub_cell_t1(origin, v_x_t)
-        t2 = self.generate_sub_cell_t2(supremum, v_x_t)
-        vec = t1 + t2
-        return tuple(vec)
-
-    @lru_cache(maxsize=None)
-    def generate_sub_cell_t1(self, origin, v_x):
-        # TODO: Calc these arrays outside
-        v_o = np.array(origin)
-        return v_o - v_o * np.array(v_x)
-
-    @lru_cache(maxsize=None)
-    def generate_sub_cell_t2(self, supremum, v_x):
-        v_s = np.array(supremum)
-        return v_s * np.array(v_x)
-
     # Plots
     def plot_complex(self):
         """


Test patch: