Nobody likes waiting for sluggish software to run. Users start to wonder whether their program has crashed or if they’ve done something wrong. In reality, the programmer is almost always at fault for slow Python execution time.

If you’re interested in writing faster Python code, keep reading. We’ll take a look at how to measure Python execution time using timeit, identify where slowdowns are most likely to occur, and give a practical example on how to benchmark your code.

What Is Execution Time?

Execution time is a measure of how long it takes for a program or function to run. It’s usually measured in nanoseconds or milliseconds, but very large or complex programs can take longer. As you learn to write code, execution time won’t be critical to your initial projects. But as your code grows in complexity and length, it will become more important.

Users perceive long execution times as a potential malfunction of the program. When a program runs slowly, it’s often because it’s using more resources. For example, if your code is taking a long time to multiply a large list of numbers or solve an equation, those operations are taking processing power and may be slowing down the user’s entire system.

How Do We Measure Execution Time In Python?

As a Python programmer, one of your tasks is to avoid slow execution times. You want to be using a software timer—not a calendar—to measure execution times. But most programmers need to be able to measure smaller time frames, so we’ll need to do it with software. One of the most popular libraries for measuring execution time in Python is timeit

Determining Python Execution Time With timeit

As a simple test, we can start working with timeit on the console. timeit is a core Python library, so it doesn’t need to be installed separately. As long as Python is installed on your computer, you can use timeit.

As a first test, we’ll have timeit measure how long it takes to find the factorial of 100. If you’re unfamiliar, factorials are a mathematical operation where a given number is multiplied by all whole numbers between itself and one. So the factorial of 5 can be expressed as 5 x 4 x 3 x 2 x 1.

Here’s the command we’ll use to measure the execution time:

python3 -m timeit -s "from math import factorial" "factorial(100)"

We’ll break down the command and explain everything in the next section. For now, let’s focus on the output:

500000 loops, best of 5: 800 nsec per loop

This means timeit ran our factorial operation 500,000 times, then repeated itself four more times. In all, timeit solved the factorial 2.5 million times. The fastest time was 800 nanoseconds. For context, a nanosecond is one billionth of a second. 

An Overview of our Methods

Now we’ll look at the timeit operation we just ran in depth. Let’s start by breaking the command up into chunks:

As you can see from the image above, we invoke Python with the timeit module. Then we pass a setup command, denoted by “-s,” that runs before our statement. Finally we specify the statement that timeit will evaluate.

We use similar syntax when invoking timeit within a Python module. We still have to create a setup command and statement, but this time we’ll use variables. Here’s an example:

import timeit

setup_code = "from math import factorial"
statement_code = "factorial(100)"
number_of_times = 500000
number_of_repeats = 5

If you look closely, you can see the same setup code and timeit statement above. But this time we have to tell timeit how many tests we want to run, and how many times we’d like to repeat the tests. 

With that done, we can write the code to invoke timeit:

print("Execution time to find the factorial of 100 a half-million times:")
 
results = timeit.repeat(
  setup=setup_code,
  stmt=statement_code,
  number=number_of_times,
  repeat=number_of_repeats)
 
print(results)

In the code above, we invoke timeit.repeat() using the variables we defined in the previous step. We store the results in a variable named results, then print it. This is our output:

Timeit is giving us the results for each set of 500,000 tests in a list. Every item in the list is the total execution time for each set of tests. We could divide each of our results by 500,000 to see the average execution time, but we’ll leave that as an exercise for you to perform at home.

When Does Execution Time in Python Matter?

Some operations are faster than others, but there are a few instances where Python is notoriously slow. Here are some examples of areas where Python struggles, and suggestions for how to speed up your code:

Speeding up Mathematical Operations

If your code has many complex calculations or large datasets, you’re sure to notice a slowdown. The heart of the problem is that Python is an interpreted language. Since there’s no compiler optimization like you’d find in C++, Python can struggle to do math efficiently.

There are Python libraries that mitigate slow mathematical operations. NumPy provides data types and functions to speed up calculations. NumPy’s data types ensure you’re using the minimum amount of memory necessary to hold a given variable, which speeds up execution time. 

Optimizing Disk Operations

Compared to accessing memory and the CPU, hard disk operations are very slow. Solid state drives are somewhat faster than traditional hard drives, but disk input/output is still a common bottleneck. 

To optimize your disk operations, try to limit the number of times you read from and write to files. We recommend using a buffer, a variable that contains the data you’re working with. Read from the file into the buffer only once, work with your data as needed, and then write the contents back to disk when you’re finished. 

Network Operations And Concurrency

Network operations are another pain point for Python programmers. While your program won’t spend too much time making a network request, the server might take a while to respond. In this case, your code would be slowed down due to blocking. “Blocking” means that Python execution will pause while your code waits for a response. 

The most popular solution to blocking is to use concurrency, or parallelism. Python supports many kinds of parallelism, notably multithreaded parallelism and multiprocessing parallelism. In both cases, Python runs the blocking code separately so the main body of your code can continue execution. 

A Practical Example of Optimizing Python Execution Time

Let’s turn to an example of how you might optimize execution time in your own software. We’ll look at some Python JSON libraries, and compare how they perform when packing and unpacking large JSON objects.

In case you’re unfamiliar with JSON, it’s a format for storing and transmitting data. JSON stands for JavaScript Object Notation, though it’s widely used by many programming languages. JSON files are typically deserialized to Python dictionaries when you want to work with them.

The process of deserialization can take awhile, especially when you’re working with large JSON files. It would be useful to compare how fast different Python libraries are capable of deserializing JSON objects. Let’s use that as our example.

Finding the Fastest Python JSON Library

We’ll start by creating a Python dictionary for each JSON library we’re going to test. We’ve chosen four of the most popular JSON libraries, these being: Python’s native JSON library, orjson, ujson, and rapidjson.

We’ll use a Python dictionary to store the name, setup, and statement for each JSON library:

import timeit
 
number_of_times = 10
number_of_repeats = 3
 
timeit_dictionary = {
  0: {
     "name": "Native JSON",
     "setup": "import json",
     "statement": "with open('./large-file.json') as file: json.loads(file.read())"
  },
  1: {
     "name": "orjson",
     "setup": "import orjson",
     "statement": "with open('./large-file.json') as file: orjson.loads(file.read())"
  },
  2: {
     "name": "ujson",
     "setup": "import ujson",
     "statement": "with open('./large-file.json') as file: ujson.loads(file.read())"
  },
  3: {
     "name": "rapidjson",
     "setup": "import rapidjson",
     "statement": "with open('./large-file.json') as file: rapidjson.loads(file.read())"
  }

At the top of this codeblock we’ll import timeit and define a few variables for our tests. We want to repeat each test 10 times, and run each series 3 times. Next we’ll use a Python dictionary to create the parameters for each library we’re testing.  

If this seems complicated, take a look at our article on working with Python dictionaries. Using dictionaries will save a lot of coding, since we can iterate through the dictionaries and run our tests. Here’s how we do it:

for id, library in timeit_dictionary.items():
  print(f"Time it takes to deserialize 10 large JSON files using {library['name']}")
  result = timeit.repeat(stmt=library["statement"],
     setup=library["setup"],
     number=number_of_times,
     repeat=number_of_repeats)
  print(result)

Pay close attention to how we’re iterating through the dictionary and passing in the name, setup, and statement. 

Next, we run the test and print the results. Here’s the output from our testing:

As you can see from the results, we’d be better off using orjson if our project needs to deserialize a large amount of JSON data. We can cut roughly 20% off of our execution time compared to the native Python JSON library.

Mastering timeit To Benchmark Python Execution Time

Python’s timeit library is a great asset when trying to speed up your code’s execution time. If you use it correctly, you can identify slowdowns in your code and test solutions against one another. 

Speeding up Python code can be an artform. There are all sorts of tips and tricks for carrying out a given operation faster. Now that you can measure Python execution time using timeit, you’ll know for sure whether the changes you make have an impact.

Looking to continue your coding journey? 

Check out Udacity’s “Introduction to Programming” nanodegree!