Line-by-line Profiler for Python
Sometimes you may experience unexpectedly long execution of some of your functions. In some cases, you will not even have an idea what causes this behavior and running code line-by-line will not give you much insight.
Thankfully, there's a tool called line-profiler
(link) which solves this nicely.
Example
Let's take a look at this simplified function that processes a pandas
dataframe.
import numpy as np
import pandas as pd
def process_df(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df['new_col'] = df.apply(lambda row: np.sum(np.cos(row)), axis=1)
extra_df = pd.DataFrame({
'col1': np.random.randint(200, size=1000),
'col2': np.random.randint(200, size=1000),
})
df = df.merge(extra_df, how='left', left_on=1, right_on='col1')
return df
if __name__ == '__main__':
process_df(pd.DataFrame(np.random.randint(200, size=(50000, 20))))
The process_df
function takes quite some time to return the results. But what is to blame? It can be any of these:
df.copy()
which copies the whole dataframe- calculation of the
new_col
which is doing acos
for each single number in the dataframe df.merge()
that join two large dataframes
Profiling
Let's start with installing the profiler:
$ pip install line_profiler
Then, we need to tell it which function(s) to profile. It's as easy as adding a @profile
decorator:
@profile # you don't have to import it, kernprof imports it on its own
def process_df(df: pd.DataFrame) -> pd.DataFrame:
Now it's ready to be profiled, use kernprof command (which is installed with profiler).
-l
- use line-by-line profiling-v
- show results (default behavior is to write results to a binary .lprof file)
$ kernprof -l -v test.py
Wrote profile results to test.py.lprof
Timer unit: 1e-06 s
Total time: 18.4293 s
File: test.py
Function: process_df at line 5
Line # Hits Time Per Hit % Time Line Contents
==============================================================
5 @profile
6 def process_df(df: pd.DataFrame) -> pd.DataFrame:
7 1 5610.0 5610.0 0.0 df = df.copy()
8
9 1 18389148.0 18389148.0 99.8 df['new_col'] = df.apply(lambda row: np.sum(np.cos(row)), axis=1)
10
11 1 2.0 2.0 0.0 extra_df = pd.DataFrame({
12 1 60.0 60.0 0.0 'col1': np.random.randint(200, size=1000),
13 1 900.0 900.0 0.0 'col2': np.random.randint(200, size=1000),
14 })
15
16 1 33577.0 33577.0 0.2 df = df.merge(extra_df, how='left', left_on=1, right_on='col1')
17
18 1 1.0 1.0 0.0 return df
Now it's super clear, that the new_col
calculation is the culprit.
The results are saved in a .lprof
file which you can send to someone or later see the output again:
$ python -m line_profiler test.py.lprof
Usage in IPython
To use the profiler in ipython you can use the %lprun extension that comes with the package:
In [1]: %load_ext line_profiler
In [2]: %lprun -f process_df for _ in range(1000): process_df(pd.DataFrame(np.random.randint(200, size=(10, 20))))
Timer unit: 1e-06 s
Total time: 8.51505 s
File: /Users/stasdeep/Code/sd-blog/_test.py
Function: process_df at line 5
Line # Hits Time Per Hit % Time Line Contents
==============================================================
5 def process_df(df: pd.DataFrame) -> pd.DataFrame:
6 1000 68483.0 68.5 0.8 df = df.copy()
7
8 1000 5250545.0 5250.5 61.7 df['new_col'] = df.apply(lambda row: np.sum(np.cos(row)), axis=1)
9
10 1000 1594.0 1.6 0.0 extra_df = pd.DataFrame({
11 1000 36796.0 36.8 0.4 'col1': np.random.randint(200, size=1000),
12 1000 719944.0 719.9 8.5 'col2': np.random.randint(200, size=1000),
13 })
14
15 1000 2436563.0 2436.6 28.6 df = df.merge(extra_df, how='left', left_on=1, right_on='col1')
16
17 1000 1128.0 1.1 0.0 return df
Note that the profile counts the number of hits to every line. In this case each line was executed 1000 times, as the function was inside a loop.