Minimizing the number of costly function calls when the parameters remain the same (python)
optimization, python
Solution
What you are looking for is a LRU cache; only most-recently used items are cached, limiting memory usage to balance invocation cost with memory requirements.
As your costly function is invoked with different values for `x`, up to a number of return values (per unique `x` value) is cached, with the least-recently used cache results discarded when the cache is full.
As of Python 3.2, the standard library comes with a decorator implementation: `@functools.lru_cache()`:
from functools import lru_cache
@lru_cache(16) # cache 16 different `x` return values
def costly_function_a(x):
print("costly_function_a has been called.")
return x #Dummy operation.
@lru_cache(32) # cache 32 different `x` return values
def costly_function_b(x):
print("costly_function_b has been called.")
return 5.*x #Dummy operation.
A backport is available for earlier versions, or pick one of the other available libraries that can handle LRU caches avaible on PyPI.
If you only ever need to cache one most recent item, create your own decorator:
from functools import wraps
def cache_most_recent(func):
cache = [None, None]
@wraps(func)
def wrapper(*args, **kw):
if (args, kw) == cache[0]:
return cache[1]
cache[0] = args, kw
cache[1] = func(*args, **kw)
return cache[1]
return wrapper
@cache_most_recent
def costly_function_a(x):
print("costly_function_a has been called.")
return x #Dummy operation.
@cache_most_recent
def costly_function_b(x):
print("costly_function_b has been called.")
return 5.*x #Dummy operation.
This simpler decorator has less overhead than the more featureful `functools.lru_cache()`.
Problem
Suppose that there is a function `costly_function_a(x)` such that: - it is very costly in terms of execution time; - it returns the same output whenever the same `x` is fed to it; and - it does not perform "additional tasks" besides returning an output. In these conditions, instead of calling the function twice in a row with the same `x`, we can store the result in a temporary variable, then use that variable to do these computations. Now suppose that there are some functions (`f(x)`, `g(x)` and `h(x)` in the example below) that call `costly_function_a(x)`, and that some of these functions may call each others (in the example below, `g(x)`and `h(x)` both call `f(x)`). In this case, using the simple method mentioned above still results in repeated calls to `costly_function_a(x)` with the same `x` (see `OkayVersion` below). I did found a way to minimize the number of calls, but it is "ugly" (see `FastVersion` below). Any ideas of a better way to do this? ``` #Dummy functions representing extremely slow code. #The goal is to call these costly functions as rarely as possible. def costly_function_a(x): print("costly_function_a has been called.") return x #Dummy operation. def costly_function_b(x): print("costly_function_b has been called.") return 5.*x #Dummy operation. #Simplest (but slowest) implementation. class SlowVersion: def __init__(self,a,b): self.a = a self.b = b def f(self,x): #Dummy operation. return self.a(x) + 2.*self.a(x)**2 def g(self,x): #Dummy operation. return self.f(x) + 0.7*self.a(x) + .1*x def h(self,x): #Dummy operation. return self.f(x) + 0.5*self.a(x) + self.b(x) + 3.*self.b(x)**2 #Equivalent to SlowVersion, but call the costly functions less often. class OkayVersion: def __init__(self,a,b): self.a = a self.b = b def f(self,x): #Same result as SlowVersion.f(x) a_at_x = self.a(x) return a_at_x + 2.*a_at_x**2 def g(self,x): #Same result as SlowVersion.g(x) return self.f(x) + 0.7*self.a(x) + .1*x def h(self,x): #Same result as SlowVersion.h(x) a_at_x = self.a(x) b_at_x = self.b(x) return self.f(x) + 0.5*a_at_x + b_at_x + 3.*b_at_x**2 #Equivalent to SlowVersion, but calls the costly functions even less often. #Is this the simplest way to do it? I am aware that this code is highly #redundant. One could simplify it by defining some factory functions... class FastVersion: def __init__(self,a,b): self.a = a self.b = b def f(self, x, _at_x=None): #Same result as SlowVersion.f(x) if _at_x is None: _at_x = dict() if 'a' not in _at_x: _at_x['a'] = self.a(x) return _at_x['a'] + 2.*_at_x['a']**2 def g(self, x, _at_x=None): #Same result as SlowVersion.g(x) if _at_x is None: _at_x = dict() if 'a' not in _at_x: _at_x['a'] = self.a(x) return self.f(x,_at_x) + 0.7*_at_x['a'] + .1*x def h(self,x,_at_x=None): #Same result as SlowVersion.h(x) if _at_x is None: _at_x = dict() if 'a' not in _at_x: _at_x['a'] = self.a(x) if 'b' not in _at_x: _at_x['b'] = self.b(x) return self.f(x,_at_x) + 0.5*_at_x['a'] + _at_x['b'] + 3.*_at_x['b']**2 if __name__ == '__main__': slow = SlowVersion(costly_function_a,costly_function_b) print("Using slow version.") print("f(2.) = " + str(slow.f(2.))) print("g(2.) = " + str(slow.g(2.))) print("h(2.) = " + str(slow.h(2.)) + "\n") okay = OkayVersion(costly_function_a,costly_function_b) print("Using okay version.") print("f(2.) = " + str(okay.f(2.))) print("g(2.) = " + str(okay.g(2.))) print("h(2.) = " + str(okay.h(2.)) + "\n") fast = FastVersion(costly_function_a,costly_function_b) print("Using fast version 'casually'.") print("f(2.) = " + str(fast.f(2.))) print("g(2.) = " + str(fast.g(2.))) print("h(2.) = " + str(fast.h(2.)) + "\n") print("Using fast version 'optimally'.") _at_x = dict() print("f(2.) = " + str(fast.f(2.,_at_x))) print("g(2.) = " + str(fast.g(2.,_at_x))) print("h(2.) = " + str(fast.h(2.,_at_x))) #Of course, one must "clean up" _at_x before using a different x... ``` The output of this code is: ``` Using slow version. costly_function_a has been called. costly_function_a has been called. f(2.) = 10.0 costly_function_a has been called. costly_function_a has been called. costly_function_a has been called. g(2.) = 11.6 costly_function_a has been called. costly_function_a has been called. costly_function_a has been called. costly_function_b has been called. costly_function_b has been called. h(2.) = 321.0 Using okay version. costly_function_a has been called. f(2.) = 10.0 costly_function_a has been called. costly_function_a has been called. g(2.) = 11.6 costly_function_a has been called. costly_function_b has been called. costly_function_a has been called. h(2.) = 321.0 Using fast version 'casually'. costly_function_a has been called. f(2.) = 10.0 costly_function_a has been called. g(2.) = 11.6 costly_function_a has been called. costly_function_b has been called. h(2.) = 321.0 Using fast version 'optimally'. costly_function_a has been called. f(2.) = 10.0 g(2.) = 11.6 costly_function_b has been called. h(2.) = 321.0 ``` Note that I do not want to "store" the results for all values of `x` used in the past (because this would require too much memory). Moreover, I do not want to have a function returning a tuple of the form `(f,g,h)` because there are cases where I only want `f` (so there is no need to evaluate `costly_function_b`).