How to Measure Functions Performance Using Decorators

Decorators in Python are a powerful tool that allows to adjust or enhance the behavior of functions without modifying them.

This structural pattern can be especially useful in performance measurements, where we can gauge and log the execution time of functions. Timing decorators can be helpful:

  • In code optimization, especially in complex applications, it’s crucial to know which functions are consuming the most time. Timing decorators help pinpoint these performance bottlenecks.

  • To compare performance and choose the most efficient algorithm or approach for a task.

  • In services with Service Level Agreements (SLAs), timing decorators can ensure that certain operations complete within the required time limits.

  • In resource-limited environments, like embedded systems or IoT devices, timing decorators help to run functions in acceptable time and memory frames.

  • In web applications, particularly those with RESTful APIs, timing decorators can measure the response times of endpoints, helping to ensure that they meet performance standards and provide a good user experience.

Using Timing Decorator

  • We measure the execution time of functions in seconds with perf_counter function of time module by putting timestamps.

  • Use wraps function from functools module to get the correct name of decorated function and its docstring in output.

    • This can be useful when we work with several functions - to differentiate them.

    • By default decorators hide the function they are decorating which means that decorator renames decorated functions by its own name

import time 
from functools import wraps


def timing_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Running function: '{func.__name__}'")
        print(f'Docstring of running function: {func.__doc__}')
        start = time.perf_counter()  # we put a timestamp before function execution
        result = func(*args, **kwargs)
        end = time.perf_counter()  # and put a timestamp after function execution
        elapsed = end - start  # calculate the final amount of time spent on functions implementation
        print(f'Time elapsed: {elapsed:.4f} sec')
        print(f'-' * 33)
        return result
    return wrapper


@timing_decorator
def slow_function(data_size):
    """Iterates a for loop, simulating a slow process"""
    for _ in range(data_size):
        pass


@timing_decorator
def artificial_delay_function(seconds):
    """Introduces an artificial delay for the specified number of seconds"""
    time.sleep(seconds)
    pass


slow_function(200000)
artificial_delay_function(3)

# Output:
Running function: 'slow_function'
Docstring of running function: Iterates a for loop, simulating a slow process
Time elapsed: 0.0028 sec
---------------------------------
Running function: 'artificial_delay_function'
Docstring of running function: Introduces an artificial delay for the specified number of seconds
Time elapsed: 3.0031 sec
---------------------------------

Timing Decorator + Execution Logging

  • In this example we added the logging of the date and time when function is executed using datetime.today method.
import time 
from functools import wraps
from datetime import datetime


def timing_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Running function: '{func.__name__}'")
        print(f'Docstring of running function: {func.__doc__}')
        start = time.perf_counter()  # we put a timestamp before function execution
        result = func(*args, **kwargs)
        end = time.perf_counter()  # and put a timestamp after function execution
        elapsed = end - start  # calculate the final amount of time spent on functions implementation
        print(f'Time elapsed: {elapsed:.4f} sec')
        print(f'Run on: {datetime.today().strftime("%Y-%m-%d %H:%M:%S")}')  # logging the date and time of function execution 
        print(f'-' * 33)
        return result
    return wrapper


@timing_decorator
def slow_function(data_size):
    """Iterates a for loop, simulating a slow process"""
    for _ in range(data_size):
        pass


@timing_decorator
def artificial_delay_function(seconds):
    """Introduces an artificial delay for the specified number of seconds"""
    time.sleep(seconds)
    pass


slow_function(200000)
artificial_delay_function(3)

# Output:
Running function: 'slow_function'
Docstring of running function: Iterates a for loop, simulating a slow process
Time elapsed: 0.0076 sec
Run on: 2024-06-24 18:28:18
---------------------------------
Running function: 'artificial_delay_function'
Docstring of running function: Introduces an artificial delay for the specified number of seconds
Time elapsed: 3.0031 sec
Run on: 2024-06-24 18:28:21
---------------------------------

Timing Decorator + Execution Logging + Memory Usage

  • Here we will also add memory usage measures of every function using tracemalloc module.

    • Is a debug tool to trace memory blocks allocated by Python.

    • Is used to compute the differences between two snapshots to detect memory leaks.

import time 
from functools import wraps
import tracemalloc
from datetime import datetime


def timing_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        tracemalloc.start()  # start tracking memory allocations
        print(f"Running function: '{func.__name__}'")
        print(f'Docstring of running function: {func.__doc__}')
        start = time.perf_counter()  # we put a timestamp before function execution
        result = func(*args, **kwargs)
        current, peak = tracemalloc.get_traced_memory()
        end = time.perf_counter()  # and put a timestamp after function execution
        elapsed = end - start  # calculate the final amount of time spent on functions implementation
        print(f'Time elapsed: {elapsed:.4f} sec')
        print(f'Memory usage: {current / 10**6:.4f} MB \n'
              f'Peak memory usage: {peak / 10**6:.4f} MB')
        print(f'Run on: {datetime.today().strftime("%Y-%m-%d %H:%M:%S")}')
        print(f'-' * 33)
        tracemalloc.stop()  # stop tracking memory allocations
        return result
    return wrapper


@timing_decorator
def slow_function(data_size):
    """Iterates a for loop, simulating a slow process"""
    for _ in range(data_size):
        pass


@timing_decorator
def artificial_delay_function(seconds):
    """Introduces an artificial delay for the specified number of seconds"""
    time.sleep(seconds)
    pass


slow_function(200000)
artificial_delay_function(3)

# Output:
Running function: 'slow_function'
Docstring of running function: Iterates a for loop, simulating a slow process
Time elapsed: 0.1446 sec
Memory usage: 0.0062 MB 
Peak memory usage: 0.0064 MB
Run on: 2024-06-24 18:50:35
---------------------------------
Running function: 'artificial_delay_function'
Docstring of running function: Introduces an artificial delay for the specified number of seconds
Time elapsed: 3.0032 sec
Memory usage: 0.0160 MB 
Peak memory usage: 0.0850 MB
Run on: 2024-06-24 18:50:38
---------------------------------