top of page

Optimizing Python Code: An In-depth Guide to Analyzing Runtime and Memory Usage



Understanding and Examining Runtime


Every code we write has a cost, be it in terms of memory usage or runtime. Just like a car guzzling fuel, your code requires resources to run. Efficient code can be compared to a fuel-efficient car - it gets the job done using minimal resources.


Let's dive in and see how we can become more efficient "drivers" of our code by understanding and examining runtime.


Why Examine the Runtime?


Before we jump into how we can measure the runtime, let's discuss why it's crucial. Consider an instance where you have two versions of the same code - one takes an hour to run while the other takes only a minute. The second one is far more efficient, right? But, how can we measure this without guesswork? That's where examining runtime comes into play.


Timing Your Code


Python offers several ways to time your code. One of them is by using the magic commands in an IPython environment. These commands act as convenient features in IPython, and one of them is %timeit, which is particularly useful for timing your code. Consider it as your stopwatch to measure how long your code takes to run.


A Closer Look at %timeit


Let's demonstrate the use of %timeit:

import numpy as np

def sum_of_squares(n):
    return np.sum(np.arange(n) ** 2)

%timeit sum_of_squares(1000)

The output may look something like this:

10000 loops, best of 5: 20.5 µs per loop


This output tells you that it ran the code 10000 times and took the best result out of 5 runs. The best run took an average of 20.5 microseconds per loop.


Customizing %timeit Runs


You can specify the number of runs and loops using -r and -n flags:

%timeit -r 10 -n 1000 sum_of_squares(1000)


%timeit Modes: Line Magic and Cell Magic


%timeit works in two modes: line magic mode and cell magic mode. Line magic is invoked with a single % prefix and works on a single line of code. For example: %timeit sum_of_squares(1000). Cell magic mode, on the other hand, works on multiple lines of code and is invoked using a %% prefix:

%%timeit
arr = np.arange(1000)
np.sum(arr ** 2)


Saving %timeit Output


You can store the output of %timeit into a variable using -o flag. This variable will contain a TimeitResult object from which various statistics can be extracted:

timeit_result = %timeit -o sum_of_squares(1000)
print(timeit_result.best)


Comparing Built-in Data Structures


Let's time how long it takes to sum elements of a list and a NumPy array:

python_list = list(range(1000))
numpy_array = np.arange(1000)

%timeit sum(python_list)
%timeit np.sum(numpy_array)


The output will indicate which data structure is faster at performing the sum

operation, and hence, more efficient.


Code Profiling for Runtime


Now that we've learned how to time our code, let's go a step further and profile it. Profiling is like getting a detailed breakdown of your phone bill - it allows us to understand how much time each part of our code takes to execute.


Understanding Line_profiler


For detailed profiling, we'll use the line_profiler package. You can install it using pip install line_profiler. Think of it as a more sophisticated stopwatch that measures the runtime of each individual line of your code.

%load_ext line_profiler

def sum_of_squares(n):
    arr = np.arange(n)
    result = np.sum(arr ** 2)
    return result

%lprun -f sum_of_squares sum_of_squares(1000)


Deciphering %lprun Output


The %lprun command returns a detailed report of the function's runtime:

Timer unit: 1e-06 s

Total time: 0.000406 s
File: <ipython-input-27-aa8d1694bf1c>
Function: sum_of_squares at line 1

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
     1                                           def sum_of_squares(n):
     2         1        209.0    209.0     51.5      arr = np.arange(n)
     3         1        197.0    197.0     48.5      result = np.sum(arr ** 2)
     4         1          0.0      0.0      0.0      return result


This report tells us how many times each line was hit, the time taken by each hit,

and the percentage of time taken by each line.


The Difference between Total Time


You might wonder why the total time reported by %lprun and %timeit might differ. This is because %timeit runs the function multiple times and takes the best result, whereas %lprun reports the time of a single run.


Code Profiling for Memory Usage


Beyond runtime, it's also important to understand how our code impacts memory. Imagine going on a shopping spree with a small backpack - you want to make sure you're only filling it with essentials, right? Memory profiling is similar - it helps us to ensure we're only using the memory we need.


A Basic Approach


Python's built-in sys module allows us to measure the size of an object in bytes:

import sys

num_list = list(range(1000))
print(sys.getsizeof(num_list), "bytes")


However, to dig deeper into how our code uses memory, we'll use the

memory_profiler package.


Using Memory_profiler


memory_profiler provides detailed statistics on memory usage in our code, much like a scanner that tells us how much space each item in our backpack occupies.

%load_ext memory_profiler

def sum_of_squares(n):
    arr = np.arange(n)
    result = np.sum(arr ** 2)
    return result

%mprun -f sum_of_squares sum_of_squares(1000)


Understanding %mprun Output


The %mprun command returns a detailed report of the function's memory usage:

Filename: <ipython-input-35-83880b3dfba1>

Line #    Mem usage    Increment   Line Contents
================================================
     1     91.1 MiB     91.1 MiB   def sum_of_squares(n):
     2     91.2 MiB      0.1 MiB       arr = np.arange(n)
     3     91.2 Mi

B      0.0 MiB       result = np.sum(arr ** 2)
     4     91.2 MiB      0.0 MiB       return result


This report tells us the memory usage and increment after executing each line of code.


Reported Memory Units


It's important to know that the reported memory units are in MiB (Mebibytes), not MB (Megabytes). 1 MiB = 2^20 bytes, while 1 MB = 10^6 bytes.


Conclusion


In this tutorial, we've covered the importance of understanding your code's runtime and memory usage. We've learned how to time our code, profile it for runtime analysis, and examine its memory footprint. By following these practices, you can write code that's efficient and optimized, much like a well-driven, fuel-efficient car. Happy coding!

bottom of page