Performance awareness when coding

Image from https://www.pexels.com/@cesar-galeao-1673528/
Image from https://www.pexels.com/@cesar-galeao-1673528/

 

Python is slow compared to many popular programming languages. It is because of its dynamic nature and versatility. Moreover, it is not statically typed which does not allow the compiler to optimize the code. This blog is about two cases where we can write better Python code (w.r.t. performance). The concept is the same when we are writing in other programming languages too.

I am running the code posted below on MacbookPro 2.6 GHz 6-Core Intel Core i7.

Setup

We created a decorator for measuring the time taken to execute a function.

import time

def timer(message: str):
    def decorator(fn):
        start = time.process_time()
        fn()
        print(f"{message}: time taken = {time.process_time() - start} secs")
    return decorator


Finding the minimum value in a list

We have seen this many times where software engineers sort a list just to get the minimum value. It can be because they are tired or do not pay much attention.

list_integers = [random.randint(1, 10000001) for item in range(1, 10000001)]

@timer("sort on list of integers")
def sort_list_int():
    list_integers.sort()
    print(list_integers[0])

and we got

sort on list of integers: time taken = 4.519529 secs

Some engineers may attempt to implement a for loop like this

@timer("for-loop on list of integers")
def min_fn():
    min_val = sys.maxsize
    for i in list_integers:
        min_val = min(min_val, i)
    print(min_val)

and the time taken is

for-loop on list of integers: time taken = 1.5227250000000012 secs

We can use the min function directly on the list to get better performance

@timer("min on list of integers")
def min_list_int():
    print(min(list_integers))

min is a Python built-in function, and we always lean towards using built-in functions. This function also shows the beauty of Python, just a single line of human-readable code.

min on list of integers: time taken = 0.15220400000000112 secs

We see a significant improvement in execution time in these three examples (from 4.5 to 1.5 to 0.15 seconds). Following is the code on the list of objects.

import random
import sys
from dataclasses import dataclass

@dataclass
class Sample:
    x: int
    y: int
    
list_objects = [Sample(random.randint(1, 10000001), random.randint(1, 10000001))
               for item in range(1, 10000001)]

@timer("sort list of objects")
def sort_objects():
    list_objects.sort(key=lambda item : item.x)
    print(list_objects[0])

@timer("for-loop on list of objects")
def min_fn_list_objects():
    min_val = sys.maxsize
    for i in list_integers:
        min_val = min(min_val, i)
    print(min_val)

@timer("min on list of objects")
def min_objects():
    print(min(list_objects, key=lambda item : item.x))

and we got

Sample(x=1, y=9125320)
sort list of objects: time taken = 7.828590999999996 secs

1
for-loop on list of objects: time taken = 6.7428839999999965 secs

Sample(x=1, y=9125320)
min on list of objects: time taken = 4.963247000000003 secs

From these results, we see that differences on a list of objects are not as good as on a list of integers.

Finding the first match

There are 2 ways to find the first match result.

@timer("comprehension list on list of integers")
def comp_list_on_list_int():
    print([x for x in list_integers if x < 1000][0])

@timer("generator on list of integers")
def generator_on_list_int():
    print(next(filter(lambda x: x < 1000, list_integers)))

for simplicity, we do not handle the Index out of bound and Stop Iteration error.

The former builds the entire list just to get the first item on the list. The latter creates a generator. And here are the result.

4
comprehension list on list of integers: time taken = 1.3737079999999935 secs
4
generator on list of integers: time taken = 2.3999999996249244e-05 secs

It is evident that the latter wins by a huge margin.

We also did the same tests on a list of objects

@timer("comprehension list on list of objects")
def comp_list_on_list_objects():
    print([x for x in list_objects if x.x < 1000][0])

@timer("generator on list of objects")
def generator_on_list_objects():
    print(next(filter(lambda x: x.x < 1000, list_objects)))

and we got

Sample(x=1, y=9125320)
comprehension list on list of objects: time taken = 4.486391999999995 secs

Sample(x=1, y=9125320)
generator on list of objects: time taken = 1.6000000002236447e-05 secs

For the list of objects, we are also seeing a huge performance boost.

Conclusion

These concepts do not limit to Python. I am using Python as the programming language here. And, these are also things that software developers need to know. Being aware of little things like this helps us to write better code.

I am active on Stackoverflow (https://stackoverflow.com/users/3808826/d-seah), and I see rooms for improvement in this area when I am looking at the questions posted.

gist for the code above


Comments

Popular posts from this blog

OpenAI: Functions Feature in 2023-07-01-preview API version

Storing embedding in Azure Database for PostgreSQL

Happy New Year, 2024 from DALL-E