Speeding up functions in Python#

Python’s versatility and ease of use make it a popular choice for a wide range of programming tasks. However, performance can sometimes be a concern, especially when working with large data sets or computationally intensive tasks. Repetitive calculations can be a particular problem, but Python provides a number of tools to help speed up these functions. One way is to use the functools.cache decorator to cache the results of a function which was introduced in Python 3.9. This can be used to avoid repeating calculations, and can significantly speed up the execution of a function.

Example with expensive calculations#

Lets take a look at a simple example. The following function calculates the factorial of a number and is a good candidate for caching. With the timeit module we can see how long it takes to run the function.

Slow function used for testing#
import timeit


def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n - 1)


def main():
    result_time = timeit.timeit(lambda: factorial(250))
    print(f"Time taken: {result_time}")


if __name__ == "__main__":
    main()

If we run this we can see that it takes around 74 seconds to run the function and that can be a problem if we need to run it multiple times.

Time taken by using factorial without cache#
Time taken: 74.4792584318202

Implementing the cache decorator#

By using the functools.cache decorator we can cache the results of the function and avoid repeating the calculations. This can be done by simply adding the decorator to the function. The first time the function is called it will run as normal, but subsequent calls will use the cached result.

Slow function with cache#
import timeit
from functools import cache


@cache
def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n - 1)


def main():
    result_time = timeit.timeit(lambda: factorial(250))
    print(f"Time taken: {result_time}")


if __name__ == "__main__":
    main()

Now that we have added the decorator we can see that the function runs much faster, taking around 0.13 seconds to run. This is a significant improvement and can be very useful when working with large data sets.

Time taken by using cache#
Time taken: 0.1316518820822239

Restricting the cache size#

Something to note is that the cache is unbounded, so it will continue to grow as more values are added. This can be a problem if you are working with a large data set, but it can be useful for smaller functions. If you need to limit the size of the cache you can use the functools.lru_cache decorator instead. This will limit the size of the cache and remove the least recently used values when the cache is full.

When looking at the source code for the functools.cache decorator we can see that it is actually just a wrapper around the functools.lru_cache decorator. The functools.cache decorator is a simplified version of the functools.lru_cache decorator and is designed to be used when you don’t need to limit the size of the cache.

The definition in python/cpython#
################################################################################
### cache -- simplified access to the infinity cache
################################################################################

def cache(user_function, /):
    'Simple lightweight unbounded cache.  Sometimes called "memoize".'
    return lru_cache(maxsize=None)(user_function)

The functools.lru_cache decorator can be used in the same way as the functools.cache decorator, but it also allows you to specify the size of the cache. This can be done by passing the maxsize argument to the decorator. The default value is None which means that the cache will be unbounded, but you can set it to any integer value to limit the size of the cache.

Slow function with lru_cache#
import timeit
from functools import lru_cache


@lru_cache
def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n - 1)


def main():
    result_time = timeit.timeit(lambda: factorial(250))
    print(f"Time taken: {result_time}")


if __name__ == "__main__":
    main()

If we run this we can see that the function runs much slightly slower, taking around 0.15 seconds to run. This is still a significant improvement and can be very useful when working with large data sets, but it reduces the size of the cache and can be useful if you need to limit the size of the cache.

Time taken by using lru_cache#
Time taken: 0.1564480559900403

Conclusion about speeding up functions in Python#

Caching is a powerful tool for optimizing Python applications. It can be used to avoid repeating calculations and can significantly speed up the execution of a function. The functools.cache decorator is a simplified version of the functools.lru_cache decorator and is designed to be used when you don’t need to limit the size of the cache. The functools.lru_cache decorator can be used in the same way as the functools.cache decorator, but it also allows you to specify the size of the cache. This can be done by passing the maxsize argument to the decorator. The default value is None which means that the cache will be unbounded, but you can set it to any integer value to limit the size of the cache.

Using a cache also adds overhead to the function, so it is important to consider whether the performance benefits outweigh the cost of using a cache. If you are working with a large data set then caching can be very useful, but if you are working with a small data set then it may not be worth the overhead of using a cache.