TIL: Timing Function Execution

Posted by Michael Knott on Fri 11 August 2023 Updated on Sun 13 August 2023

Timing Code

Recently a colleague presented on ways to time code execution using time.perf_counter{} to make informed decisions on code performance in terms of speed. This led me to think about creating some utilities that simplify the process. I've included examples of a context manager, a decorator and the timeit function below:

Timing Code with a Context Manager

import random
import time

class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        self.end = time.perf_counter()
        self.interval = self.end - self.start


def unique_nums_using_set(nums):
    return list(set(nums))

def unique_nums_using_fromkeys(nums):
    return list(dict.fromkeys(nums))


def main():
    nums = [random.randint(0, 10) for _ in range(1000000)]
    with Timer() as timer_one:
        unique_nums = unique_nums_using_set(nums)
        print(f"func = {unique_nums_using_set.__name__}")
        print(f"Total = {unique_nums}")
    print(f"Elapsed time: {timer_one.interval:0.4f} seconds")

    with Timer() as timer_two:
        unique_nums = unique_nums_using_fromkeys(nums)
        print(f"func = {unique_nums_using_fromkeys.__name__}")
        print(f"Total = {unique_nums}")
    print(f"Elapsed time: {timer_two.interval:0.4f} seconds")

if __name__ == "__main__":
    main()

# terminal output
func = unique_nums_using_set
Total = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Elapsed time: 0.0118 seconds
func = unique_nums_using_fromkeys
Total = [10, 3, 8, 9, 2, 6, 5, 4, 0, 1, 7]
Elapsed time: 0.0231 seconds

One thing I learnt while implementing the context manager is that you need to return self from the __enter__ method to use the as keyword. The below implementation shows another implementation without returning self. Note in the main() function that an instance of Timer is instantiated and assigned to t to be used with the with keyword.

import random
import time

class Timer:
    def __enter__(self):
        self.start = time.perf_counter()

    def __exit__(self, exc_type, exc_value, traceback):
        self.end = time.perf_counter()
        self.interval = self.end - self.start


def unique_nums_using_set(nums):
    return list(set(nums))

def unique_nums_using_fromkeys(nums):
    return list(dict.fromkeys(nums))


def main():
    nums = [random.randint(0, 10) for _ in range(1000000)]
    timer_one = Timer()
    timer_two = Timer()

    with timer_one:
        unique_nums = unique_nums_using_set(nums)
        print(f"func = {unique_nums_using_set.__name__}")
        print(f"Total = {unique_nums}")
    print(f"Elapsed time: {timer_one.interval:0.4f} seconds")

    with timer_two:
        unique_nums = unique_nums_using_fromkeys(nums)
        print(f"func = {unique_nums_using_fromkeys.__name__}")
        print(f"Total = {unique_nums}")
    print(f"Elapsed time: {timer_two.interval:0.4f} seconds")

if __name__ == "__main__":
    main()

# terminal output
func = unique_nums_using_set
Total = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Elapsed time: 0.0118 seconds
func = unique_nums_using_fromkeys
Total = [3, 4, 1, 6, 2, 10, 9, 5, 7, 8, 0]
Elapsed time: 0.0233 seconds

Timing Code with a Decorator

import random
import time
from functools import wraps

def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        func(*args, **kwargs)
        end = time.perf_counter()
        print(f'Elapsed time: {end-start:0.4f}')
    return wrapper

@timer
def unique_nums_using_set(nums):
    print(f"func = {unique_nums_using_set.__name__}")
    return list(set(nums))

@timer
def unique_nums_using_fromkeys(nums):
    print(f"func = {unique_nums_using_fromkeys.__name__}")
    return list(dict.fromkeys(nums))

if __name__ == "__main__":
    nums = [random.randint(0, 10) for _ in range(1000000)]
    unique_nums_using_set(nums)
    unique_nums_using_fromkeys(nums)

# terminal output
func = unique_nums_using_set
Elapsed time: 0.0119
func = unique_nums_using_fromkeys
Elapsed time: 0.0254

Using @wraps to return the original function name

The @wraps decorator is required to return the wrapped functions name when calling unique_nums_using_set.__name__ or unique_nums_using_fromkeys.__name__ instead of returning wrapper as the name. @wraps updates the the metadata of the wrapper function to return the metadata of the original function.

Timing Code with timeit

As the timeit function is used to measure small snippets of Python code, I've passed the expressions using set() and dict.fromkeys directly to the timeit function.

import random
from timeit import timeit

nums = [random.randint(0, 10) for _ in range(1000000)]

set_time = timeit(
    stmt="list(set(nums))", setup="from __main__ import nums", number=100
)

fromkeys_time = timeit(
    stmt="list(dict.fromkeys(nums))", setup="from __main__ import nums", number=100
)

print(f"Time using set(): {set_time:.2f} seconds")
print(f"Time using dict.fromkeys(): {fromkeys_time:.2f} seconds")

# terminal output
Time using set(): 1.54 seconds
Time using dict.fromkeys(): 2.85 seconds

The statement I've passed to the timeit setup parameter seems clumsy. Another alternative is to use the globals parameter to allow the timeit function to access nums.

set_time = timeit(
    stmt="list(set(nums))", globals=globals(), number=100
)

fromkeys_time = timeit(
    stmt="list(dict.fromkeys(nums))", globals=globals(), number=100
)