5 Best Ways to Time Functions Using perf_counter in Python

πŸ’‘ Problem Formulation: When optimizing code in Python, it is essential to measure execution time accurately to identify bottlenecks and verify performance improvements. The time.perf_counter() function provides a high-resolution timer for benchmarking purposes. This article demonstrates different ways of using perf_counter to time code execution, with input being the code to time and the desired output being the execution duration in seconds.

Method 1: Basic Start and End Timing

Timing a Python function is straightforward using time.perf_counter. It can be called at two points to measure the elapsed time between them. The function returns the current value of the high-resolution performance counter, offering fine-grained timing.

Here’s an example:

import time

start_time = time.perf_counter()
# Simulate a process that takes time
for i in range(1000000):
    pass
end_time = time.perf_counter()
print(f"Duration: {end_time - start_time} seconds")

Output:

Duration: 0.0867261 seconds

This code snippet measures the time it takes to execute a loop one million times. The time.perf_counter() function is called before and after the loop, and the difference in their returned values indicates the elapsed time.

Method 2: Using a Timer Context Manager

The context manager pattern can be used to create a timer that automatically starts and stops. By defining a with block, timing starts when the block is entered and ends when it is exited. It reduces the risk of forgetting to stop the timer or misplacing the end timing code.

Here’s an example:

from contextlib import contextmanager
import time

@contextmanager
def timer():
    start = time.perf_counter()
    yield
    end = time.perf_counter()
    print(f"Duration: {end - start} seconds")

with timer():
    # Code to be timed
    sum(range(1000000))

Output:

Duration: 0.0279195 seconds

Within the with timer() block, the code that needs timing is run. When the block is exited, the elapsed time is automatically printed. This pattern ensures timing is always properly stopped and reported.

Method 3: Decorator for Function Timing

A decorator in Python can be applied to a function to automate the process of timing every call to that function. This method is useful for repeatedly timed functions, such as those used in algorithmic analysis or performance-critical applications.

Here’s an example:

import time

def time_this(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f"{func.__name__} took {end-start} seconds to run")
        return result
    return wrapper

@time_this
def compute():
    return sum(range(1000000))

compute()

Output:

compute took 0.0215478 seconds to run

The @time_this decorator wraps the compute() function. It measures how long the function takes to run every time it is called, printing the duration after each call.

Method 4: Profiling with timeit Module

While perf_counter() is great for manual timing, the timeit module in Python automates timing and runs code multiple times to get a more accurate measurement of execution time. It avoids a number of common traps for measuring execution times and can be used from the command line or as a library.

Here’s an example:

import timeit

code_to_test = """
sum(range(1000000))
"""

# Run the code 10 times and take the average
execution_time = timeit.timeit(code_to_test, number=10) / 10
print(f"Average execution time: {execution_time} seconds")

Output:

Average execution time: 0.02568429 seconds

Here, the timeit.timeit() function takes a string of code and runs it a specified number of times, defaulting to one million. The example code calculates the average execution time over 10 runs for higher accuracy.

Bonus One-Liner Method 5: Using Lambda Function

A quick way to measure a single expression or function call can involve a lambda function wrapped around time.perf_counter(). It is particularly handy for timing inline operations or simple functions.

Here’s an example:

import time

# Start timer, run code, print duration
print((lambda t0: f"Duration: {time.perf_counter() - t0} seconds")(time.perf_counter()))

# Result of code to be timed
sum(range(1000000))

Output:

Duration: 2.1999e-05 seconds

The lambda function captures the start time t0 as an argument, then immediately evaluates the elapsed time and prints the duration. However, it’s crucial to remember that the lambda is only wrapping the timer; it does not include the code to be timed, which follows next.

Summary/Discussion

  • Method 1: Basic Start and End Timing. Strengths: Simplicity and direct control over timing. Weaknesses: Manual, error-prone, and inconvenient for repetitive timing or multiple points in a program.
  • Method 2: Using a Timer Context Manager. Strengths: Automated start and stop, easy to read. Weaknesses: Slightly more complex to set up than basic timing, less direct control.
  • Method 3: Decorator for Function Timing. Strengths: Reusable, clean, and automatic for function-level timing. Weaknesses: Only suitable for functions, not code blocks.
  • Method 4: Profiling with timeit Module. Strengths: Accurate, avoids common timing pitfalls, can be used for command line or within code. Weaknesses: Overhead from running code multiple times.
  • Method 5: Using Lambda Function. Strengths: Quick and inline for simple timing. Weaknesses: Not suitable for complex timing scenarios, can be confusing.