You are reading solutions
Cansın-Guler-profile-photo.jpg
Author: Cansin Guler
Software Engineer

Create a Timer in Python: Elapsed Time, Decorators, and more

In this short guide, we will explore different timer implementations in Python.

In short, we can make a simple timer with Python's time builtin library like so:

import time

start_time = time.time()

# The thing to time. Using sleep as an example
time.sleep(10)

end_time = time.time()
elapsed_time = end_time - start_time

print(elapsed_time)
Out:
10.004060745239258

Although very straightforward, this is not the best option for timing the performance of your code.

For the rest of this article, we will expand on this concept by calculating and reporting a function's execution time.

Marking a Point in Time: Python's time Module

The time module is equipped with many methods to report the GMT or the local time nicely formatted in years, months, days, hours, minutes, and seconds.

For our timing purposes, it's beneficial to have time represented as a single number. We achieved this in the intro code, but here's what the time() function actually outputs:

current_time = time.time()

print(current_time)
Out:
1659543906.2127182

The number printed on the screen represents the time in mere seconds passed since some system-dependent epoch (most systems use January 1st, 1970), stripped from any other construct.

Since we have time reduced to a floating-point number, we can just subtract one point in time from the other.

Let's bring our example back from the intro:

start_time = time.time()

time.sleep(10)

end_time = time.time()
elapsed_time = end_time - start_time

print(elapsed_time)
Out:
10.007065773010254

Here, we have used time.sleep(10) to suspend the execution for ten seconds. We have marked the time before and after time.sleep(), calculated the elapsed time, and printed it out.

The result is a little over ten seconds and is a slightly different number at each execution. This is because timing depends on other system activities—the system runs random operations in between, driving us over ten seconds.

Better alternatives to time()

Since we have roughly established how to calculate the elapsed time, we can refine our code a little. Python's time module offers us alternatives to the time() function, all reporting the time in seconds but in different ways.

The time() we have employed up until now reports the system's wall-clock time. We also have a function named monotonic() at our disposal. This function uses a monotonic clock that cannot go backward or be affected by system clock updates.

If we were planning on measuring a long-running process, we would opt for monotonic() for its integrity, but we will be measuring simple procedures here.

Python provides us access to a performance counter for short time measurements, called perf_counter(), which uses the system clock with the highest resolution. Moving on, we will employ perf_counter() in our calculations.

Timers using context managers

Context managers let us wrap logic with additional setup and teardown code, allowing us to manage resources efficiently. Since we intend to surround code with timing logic, context managers are a perfect fit.

Let's first look at a simple example of a context manager to better understand its structure and use.

from contextlib import contextmanager

@contextmanager
def demonstrate_cm():
    print('Anything before yield is executed at the very beginning')
    yield
    print('Anything after yield is executed at the very end')


with demonstrate_cm():
    for n in range(10):
      print(n, end="\n")
Out:
Anything before yield is executed at the very beginning
0
1
2
3
4
5
6
7
8
9
Anything after yield is executed at the very end

Here, demonstrate_cm is implemented as a context manager using @contextmanager decorator. The yield keyword divides the function body into two parts: 1) The expressions above yield are executed right before the code that is managed 2) The expressions below yield are executed right after.

Once defined, we attach demonstrate_cm to a code block using a with statement.

In the next section, we will use the same structure to create a timer.

Defining a timer context manager

The timer context manager below is very similar to the context manager above, except now we are surrounding the yield with our timing logic.

@contextmanager
def timer():
    t0 = time.perf_counter()
    try:
        yield
    finally:
        t1 = time.perf_counter()
        elapsed = t1 - t0
        print(f'{elapsed:0.4f}')

Let's quickly review this code.

First, notice that we have placed yield within a try-finally block to ensure the framed code block won't affect the context manager's job. If the managed code creates an error, the timer will still report the elapsed time until the error occurred.

Above the try-finally, we have marked the starting point: the t0. Within finally, we have marked the endpoint and calculated the elapsed time. In the end, we have restricted the representation of elapsed to four decimal points via an f-string and printed it out.

Let's put it to use.

# Using the timer like a regular context manager 
with timer():
  for i in range(1000000):
    i -= 1 
        
# Using the timer as a decorator
@timer()
def example(num):
    time.sleep(num)

     
example(5)
Out:
0.0884
5.0148

One advantage of using the @contextmanager decorator to define our timer is that we can use the timer both using the with statement and as a function decorator, like in the last example. If we wanted to time a certain operation once, we could call in timer() using a with statement. However, if we wanted to track a function's run time at every execution, we can decorate the function using @timer().

What if we want to pass arguments to our context manager? We'll explore this idea in the next section.

Parameterizing the Timer

We have managed to program a timer that can be applied to any function. It is neat and portable. Yet, we can still go one step further and add some flexibility to it.

In the following code block, we've created a parameterized version of our timer, called timer2. We have removed the hard-coded methods of accessing and reporting the time and allowed a message parameter to format the message string.

@contextmanager
def timer2(time_func, message):
    t0 = time_func()
    try:
        yield
    finally:
        t1 = time_func()
        elapsed = t1 - t0
        print(message.format(elapsed))

        
with timer2(time.process_time, "The function completed in {:0.2f} seconds"):
  for i in range(1000000):
    i -= 1
Out:
The function completed in 0.09 seconds

Inside the with statement, we passed two arguments: (1) the timer function to use and a custom message for the function to print. Since we're using process_time() as our timing function, we'll measure the CPU time, excluding the time spent sleeping.

In the next section, we'll work through converting our timer into a decorator, making it easier to apply timing capabilities to our functions.

Defining a Timer Decorator

We could also define our timer as a run-off-the-mill decorator. We'll design this implementation of our timer for recording an average run time for a given function.

In the code below, we're creating a standard decorator that takes the function to be decorated as its parameter. The nested timer is essentially like the previous timers we've created.

def timer_wrapper(func):
    total_time, runs = 0, 0
    
    def timer(*args, **kwargs): 
        t0 = time.time()
        result = func(*args, **kwargs)
        t1 = time.time()
        elapsed = t1 - t0
        
        # bring variables into scope
        nonlocal total_time, runs
        runs += 1
        total_time += elapsed
        
        print(f"@timer: {func.__name__} took {elapsed:0.4f} seconds")
        print(f"The average run time is {(total_time / runs):0.4f} seconds\n")
        
        return result
    return timer

The timer code is almost the same as previous timers, but now we've added two variables for tracking time: total_time and runs. These values will accumulate and hold the values needed to calculate the average.

Now, we'll apply this decorator to a function we wish to time:

@timer_wrapper
def random_fn():   
    for i in range(1000000):
        i -= 1 

for i in range(5):
  random_fn()
Out:
@timer: random_fn took 0.0400 seconds
The average run time is 0.0400 seconds

@timer: random_fn took 0.0436 seconds
The average run time is 0.0418 seconds

@timer: random_fn took 0.0410 seconds
The average run time is 0.0415 seconds

@timer: random_fn took 0.0409 seconds
The average run time is 0.0414 seconds

@timer: random_fn took 0.0455 seconds
The average run time is 0.0422 seconds

When we decorate random_fn with @timer_wrapper, it is shorthand for writing random_fn = timer_wrapper(random_fn), which masks the original definition of random_fn to provide the additional functionality from the decorator. Note that timer_wrapper() runs only once—it replaces random_fn(), and its job is done.

The timer() that is returned from timer_wrapper():

  1. Takes in *args, **kwargs—these represent the arguments func may or may not have.
  2. Records the time as t0.
  3. Executes func with *args, **kwargs, and saves the result.
  4. Measures the elapsed time since t0.
  5. Reaches out to the parent scope with nonlocal to grab the variables total_time and runs. The keyword nonlocal announces that timer() is not creating new values here, but rather is reaching out to grab non-local ones.
  6. Adds one to runs, adds the elapsed time to total_time.
  7. Prints the elapsed time -elapsed.
  8. Prints the average run time -(total_time/runs).
  9. Returns the result from func back to the caller.

Overall, this is a helpful pattern for timing various functions in your code; add the decorator whenever your curious about performance and remove it when no longer needed.

Summary

Measuring the elapsed time inside a program can be accomplished by calling the time.perf_counter() twice, once at the beginning and once at the end. The difference in the return values would then reveal the execution time.

This functionality can be programmed into a context manager, allowing us to measure the execution time of any code block using a with statement. We can also implement it using a decorator and inject time measurement calls to the function that is decorated


Meet the Authors

Cansın-Guler-profile-photo.jpg

Software engineer, technical writer and trainer.

Brendan Martin
Editor: Brendan Martin
Founder of LearnDataSci

Get updates in your inbox

Join over 7,500 data science learners.