# Execution Times in Python
Here we disucss how to measure the execution time of Python code [1], [2].
## Simple Timer Decorator
Here is a simple decorator that we can easily apply to real-world problems for debugging code [1].
```py
def timer(func):
"""
Display the time it took for the function to run.
"""
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
# Call the actual function
res = func(*args, **kwargs)
duration = time.perf_counter() - start
print(f'[{wrapper.__name__}] took {duration * 1000} ms')
return res
return wrapper
```
```py
@timer
def isprime(number: int):
""" Check if a number is a prime number """
isprime = False
for i in range(2, number):
if ((number % i) == 0):
isprime = True
break
return isprime
```
## Logging Method Execution Time
We can use Python decorators to easily log the execution time of any Python function [3].
```py
import logging
import time
from functools import wraps
logging.basicConfig()
logger = logging.getLogger("my-logger")
logger.setLevel(logging.DEBUG)
def timed(func):
"""This decorator prints the execution time for the decorated function."""
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
logger.debug("{} ran in {}s".format(func.__name__, round(end - start, 2)))
return result
return wrapper
```
Here is some sample code using the decorator.
```py
from timer import timed
@timed
def my_printer(max):
sum = 0
for i in range(max):
sum += i
if __name__ == "__main__":
my_printer(100000000)
```
## Building a custom Timer class
Here we discuss how to measure the execution time of your code in Python the right way [2].
Since Python 3.7, many functions in the time module with the suffix `_ns` return integer nanoseconds while the original float (seconds) versions can be called using the functions of the same name without the suffix [2].
#### Use perf_counter for precision
The `time.perf_counter_ns` function is the preferred way to time execution. However, absolute value is meaningless since only the subtraction of times makes sense.
```py
import time
start_counter_ns = time.perf_counter_ns()
do_complex_stuff()
end_counter_ns = time.perf_counter_ns()
timer_ns = end_counter_ns - start_counter_ns
print(timer_ns)
```
#### Use process_time for CPU time
Sometimes we want to measure the CPU time of the process, regardless of thread sleep (other processes doing other things). In such cases, we should use `time.process_time_ns`.
```py
import time
start_time_ns = time.process_time_ns()
do_complex_stuff()
end_time_ns = time.process_time_ns()
timer_ns = end_time_ns - start_time_ns
print(timer_ns)
```
#### Use a monotonic clock for long processes
When timing longer processes, system time changes can occur such as an NTP sync service or daylight saving time.
Therefore, we need a clock that does not change even when the system time is changed called `time.monotonic_ns` which is suitable for timing longer processes.
```py
import time
start_time_ns = time.monotonic_ns()
do_complex_stuff()
end_time_ns = time.monotonic_ns()
timer_ns = end_time_ns - start_time_ns
print(timer_ns)
```
#### Disable garbage collector for accurate timing
One of the features of Python is automatic garbage collection which is basically memory deallocation for unused objects.
Garbage collection can impact execution times, so it can happen during the timing of your code block and the time it consumes is not negligible.
Therefore, it is better to disable GC while measuring time.
#### Custom Timer class
We can create a custom `Timer` class by using what we now know about the garbage collector, the different clocks we can use, and the need for a more Pythonic timer [2].
Here is what we want from the timer [2]:
- the possibility of turning off garbage collection
- to select the type of clock we want
- use time in nanoseconds except when specifically converting to seconds
```py
import time
import gc
from typing import Literal, Optional, NoReturn
class Timer:
"""
timer type can only take the following string values:
- "performance": the most precise clock in the system.
- "process": measures the CPU time, meaning sleep time is not measured.
- "long_running": it is an increasing clock that do not change when the
date and or time of the machine is changed.
"""
_counter_start: Optional[int] = None
_counter_stop: Optional[int] = None
def __init__(
self,
timer_type: Literal["performance", "process", "long_running"] = "performance",
disable_garbage_collect: bool = True,
) -> None:
self.timer_type = timer_type
self.disable_garbage_collect = disable_garbage_collect
def start(self) -> None:
if self.disable_garbage_collect:
gc.disable()
self._counter_start = self._get_counter()
def stop(self) -> None:
self._counter_stop = self._get_counter()
if self.disable_garbage_collect:
gc.enable()
@property
def time_nanosec(self) -> int:
self._valid_start_stop()
return self._counter_stop - self._counter_start # type: ignore
@property
def time_sec(self) -> float:
return self.time_nanosec / 1e9
def _get_counter(self) -> int:
counter: int
match self.timer_type:
case "performance":
counter = time.perf_counter_ns()
case "process":
counter = time.process_time_ns()
case "long_running":
counter = time.monotonic_ns()
return counter
def _valid_start_stop(self) -> Optional[NoReturn]:
if self._counter_start is None:
raise ValueError("Timer has not been started.")
if self._counter_stop is None:
raise ValueError("Timer has not been stopped.")
return None
```
To instantiate the class, we need to specify the timer_type: if we will use the performance clock, process clock, or the long-running clock (monotonic). We can also determine whether to disable garbage collection.
```py
import time
timer = Timer(timer_type="long_running")
timer.start()
do_complex_stuff()
timer.stop()
print(timer.time_nanosec, timer.time_sec)
```
#### Timing context manager
The context manager is one of Python’s top syntactic sugar features such as when we read code with the keyword “with”.
Simple context managers are not hard to create, especially when using the standard module named `contextlib`.
So we can create a context manager that uses our Timer class and takes care of starting and stopping it.
```py
from contextlib import contextmanager
from typing import Literal
@contextmanager
def timing(
timer_type: Literal["performance", "process", "long_running"] = "performance"
):
timer = Timer(timer_type=timer_type)
try:
timer.start()
yield timer
finally:
timer.stop()
```
Then we can use it to time a block of code.
```py
with timing() as timer:
do_complex_stuff()
print(timer.time_sec)
```
## References
[1]: [5 real handy python decorators for analyzing/debugging your code](https://towardsdatascience.com/5-real-handy-python-decorators-for-analyzing-debugging-your-code-c22067318d47)
[2]: [Execution Times in Python](https://towardsdatascience.com/execution-times-in-python-ed45ecc1bb4d)
[3]: [Logging Method Execution Time in Python](https://medium.com/geekculture/logging-method-execution-time-in-python-145469c92653)