Python性能分析-可视化

  |  

摘要: 《Python性能分析与优化》可视化章节的笔记

【对数据分析、人工智能、金融科技、风控服务感兴趣的同学,欢迎关注我哈,阅读更多原创文章】
我的网站:潮汐朝夕的生活实验室
我的公众号:潮汐朝夕
我的知乎:潮汐朝夕
我的github:FennelDumplings
我的leetcode:FennelDumplings


写在前面

这是 Python 性能分析与优化系列的第 6 篇文章,主要参考 Python性能分析与优化 这本书。

往期回顾

第一期: Python性能分析基础
第二期: Python性能分析器 — cProfile
第三期: 使用cProfile进行性能分析与优化实践
第四期: Python性能分析器 — line_profiler
第五期: line_profiler性能分析实践 — 优化倒排索引

在前几期中,我们一直都在观察各种性能分析数据。并试图通过努力,不断地降低Hit次数、运行时间,以及优化其他性能指标。但是这些数字表达的实际意义有时难以理解。

在性能分析过程中,如果能够更好地理解数据的意义将大有裨益。为此,我们需要使用可视化工具来展示此前见过的数据。


1. KCacheGrind – pyprof2calltree

KCacheGrind 是一个 GUI 工具。这个数据可视化工具可以用来分析和展示多种格式的性能分析数据。

要实现数据可视化,我们还需要使用命令行工具 pyprof2calltree,它可以把 cProfile 的输出结果转换成 KCacheGrind 支持的格式。

(1) Installation

1
2
pip install pyprof2calltree
sudo apt install kcachegrind

(2) Usage

命令行中使用

首先要生成性能分析输出文件,代码如下,主要是 stats.dump_stats("input-file.prof") 这一行。

1
2
3
4
5
6
7
8
9
10
11
12
import cProfile
import pstats

profiler = cProfile.Profile()
profiler.enable()

my_func()

profiler.create_stats()
stats = pstats.Stats(profiler)
stats.strip_dirs().sort_stats('cumulative').print_stats()
stats.dump_stats("input-file.prof")

在我们已经有性能分析输出文件时,使用很方便。

1
pyprof2calltree -o [output-file-name] -i input-file.prof

数含义如下:

  • k: 如果想立即运行KCacheGrind,就可以加上这个参数。
  • r: 如果还没有性能分析数据,可以用这个参数直接分析Python脚本文件生成最终结果。

Python 代码中使用

可以从 pyprof2calltree 包里导入 convert 或 visualize 函数
convert: 输出性能分析结果文件
visualize: 直接启动 KCacheGrind 显示结果

1
2
3
4
5
6
7
8
9
10
from cProfile import Profile
import pstats

profiler = Profile()

profiler.runctx("my_func()", locals(), globals())

from pyprof2calltree import convert, visualize
stats = pstats.Stats(profiler)
visualize(stats) # 运行kcachegrind

下图是后面的例子跑出来的,这里先看一下可视化结果是长什么样的。

左边就是我们此前用 cProfile 打印在命令行的结果。

  • Incl.列:这个指标表示函数的累计消耗时间。包括其他被它调用的函数消耗的时间。
  • Self列:只包含函数本身消耗的时间,不包括它调用的函数需要的时间。

右边有很多标签,其中:

【Callee Map】里面显示了一些矩形, 每个矩形与左边列表里对应函数的消耗时间一致。面积最大的那几个矩形通常是优化的目标。

【Call Graph】会显示所选函数的函数调用关系图

(3) A profiling example – TweetStats

现在我们回到 使用cProfile进行性能分析与优化实践 中的csv数据统计的例子。其中原始代码保存为 csv1.py,优化后的代码保存为 csv2.py,具体的优化过程参考前面的文章,这里只展示可视化结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
import cProfile
import pstats

import csv1

profiler = cProfile.Profile()
profiler.enable()

csv1.build_twit_stats()

profiler.create_stats()
stats = pstats.Stats(profiler)
stats.strip_dirs().sort_stats('cumulative').dump_stats("csv1.prof")

性能统计信息保存在 csv1.prof 文件中。我们可以用下面的命令把文件转换成可视化图形

1
pyprof2calltree -i csv1.prof -k

将代码中的 csv1 改成 csv2,看优化后的代码的性能分析可视化结果

遗憾的是, KCacheGrind不能显示程序运行消耗的总时间。但是【Callee Map】已经清晰地展示了代码简化和优化的结果。

(4) A profiling example – Inverted Index

现在我们回到 line_profiler性能分析实践 — 优化倒排索引 中的倒排索引的例子。

原始代码 ori.py,优化后的代码 my.py。具体优化过程参考上面的文章,这里只展示可视化结果的变化。

将 main() 改为以下代码即可

1
2
3
4
5
6
7
8
9
10
11
import cProfile
import pstats

profiler = cProfile.Profile()
profiler.enable()

main()

profiler.create_stats()
stats = pstats.Stats(profiler)
stats.strip_dirs().sort_stats('cumulative').dump_stats('ori.prof')

首先对左边的列表按照Self字段进行排序,这样我们就可以看到内部消耗时间最长的函数了(并不是函数总共消耗的时间,不包含对其他函数的调用时间),结果如下

通过前面的列表可以看出,目前最成问题的两个函数是 get_offset_upto_word 和 list2dict。

优化后的代码的可视化结果如下


2. RunSnakeRun

RunSnakeRun 是另一个可对性能分析结果进行可视化的工具。这个工具也可以把cProfile的输出结果可视化。它还带有方块图和可排序的列表。

这个项目是KCacheGrind的简化版。KCacheGrind也适用于C和C++开发者,而RunSnakeRun是专门为Python开发者定制的。

这个工具可以提供的特征如下所示

  • 可排序的网格视图, 包括:
    • 函数名称
    • 总调用次数
    • 累计时间
    • 文件名和行号
  • 函数的具体调用信息,比如函数的调用者和被调用者名称
  • 面积与函数运行时间成正比的方块图

(1) Installation

依赖 wxpython

1
conda install wxpython

import wx 时缺少 libpng12.so

1
2
3
wget -q -O /tmp/libpng12.deb http://mirrors.kernel.org/ubuntu/pool/main/libp/libpng/libpng12-0_1.2.54-1ubuntu1_amd64.deb
sudo dpkg -i /tmp/libpng12.deb
rm /tmp/libpng12.deb

import wx 时缺少 libiconv.so

1
2
3
4
5
6
7
8
9
10
11
wget -q -O ~/tmp/libiconv-1.16.tar.gz https://ftp.gnu.org/pub/gnu/libiconv/libiconv-1.16.tar.gz
cd ~/tmp/
tar zxvf libiconv-1.16.tar.gz
cd libiconv-1.16

sudo ./configure --prefix=/usr/local
sudo make
sudo make install

sudo ln -s /usr/local/lib/libiconv.so.2 /usr/lib/libiconv.so.2
sudo ldconfig

此时可以 import wx 成功

安装 SquareMap, RunSnakeRun

1
pip install SquareMap, RunSnakeRun

使用方法,以生成倒排索引为例

下面以 line_profiler性能分析实践 — 优化倒排索引 中的倒排索引的例子,看一下 RunSnakeRun 的用法。

使用方法: 我们还是用cProfile作为性能分析器把分析结果输出到一个文件里。然后, 调用 runsnake 打开文件

1
runsnake ori.prof

从上面的截图中会发现三个有趣的区域。

  • 左边: 可排序列表,里面包含了cProfile输出的所有数据。
  • 右下角: 函数详细信息区域,里面包含了调用函数(Callers)、被调用函数(Callees)和源代码(Source Code)等标签。
  • 右上角: 方块图区域,用图形显示运行的函数调用关系树。

这个GUI工具的优点之一是,当你点击左边列表中的函数时,右边的方块就会高亮显示。如果你点击右边的方块,左边的列表中对应的函数也会高亮显示。

(3) Usage Profiling examples – the lowest common multiplier

例子: 寻找两个正整数的最小公倍数

初始代码

1
2
3
4
5
6
7
8
9
def lowest_common_multiplier(a, b):
i = max(a, b)
while i < (a * b):
if i % min(a, b) == 0:
return i
i += max(a, b)
return (a * b)

print(lowest_common_multiplier(41391237, 2830338))
1
2
python -m cProfile -o ori.prof ori.py
runsnake ori.prof

可以看到总共运行时间 0.448s,其中 max 和 min 查询各占 0.105 和 0.103,下面我们把这两个查询去掉。

1
2
3
4
5
6
7
8
9
10
11
12
def lowest_common_multiplier(a, b):
maxx = max(a, b)
minx = min(a, b)
prod = a * b
i = maxx
while i < prod:
if i % minx == 0:
return i
i += maxx
return prod

print(lowest_common_multiplier(41391237, 2830338))

总运行时间降为 0.08s

(4) A profiling example – search using the inverted index(用倒排索引查询)

在这个例子中,我们将使用倒排索引生成的索引结果,自己编写一个使用该索引结果的搜索程序。

我们将要分析的函数很简单,它只查询索引中的一个单词。具体的方法也很简单。

1
2
3
4
5
(1) 把索引结果加载到内存。
(2) 搜索单词并抓取索引信息。
(3) 解析索引信息。
(4) 对每个索引信息,查询相关的文件,然后把包含单词的行提取出来。
(5) 打印结果

要解析的索引文件示例,以词 acid 为例

1
2
3
4
5
6
7
8
acid, (./files/783.txt, (0, 39)),(./files/714.txt, (289, 210)),(./files/525.txt, (12, 192)),(./files/359.txt, (345, 95)),(./files/193.txt, (49, 107)),(./files/480.txt, (
62, 29)),(./files/337.txt, (24, 76)),(./files/32.txt, (14, 17)),(./files/885.txt, (278, 37)),(./files/570.txt, (50, 169)),(./files/596.txt, (57, 126)),(./files/433.txt,
(435, 15)),(./files/105.txt, (80, 127)),(./files/931.txt, (161, 206)),(./files/290.txt, (153, 151)),(./files/59.txt, (271, 290)),(./files/824.txt, (227, 384)),(./files/3
52.txt, (175, 39)),(./files/121.txt, (130, 201)),(./files/200.txt, (6, 248)),(./files/204.txt, (123, 339)),(./files/682.txt, (372, 285)),(./files/960.txt, (78, 226)),(.$files/282.txt, (60, 20)),(./files/585.txt, (452, 194)),(./files/610.txt, (279, 36)),(./files/194.txt, (164, 92)),(./files/42.txt, (89, 142)),(./files/606.txt, (1, 0)),($/files/865.txt, (90, 131)),(./files/941.txt, (41, 190)),(./files/716.txt, (59, 62)),(./files/880.txt, (162, 180)),(./files/319.txt, (146, 137)),(./files/605.txt, (332, $
31)),(./files/897.txt, (76, 285)),(./files/694.txt, (215, 155)),(./files/341.txt, (377, 282)),(./files/478.txt, (106, 175)),(./files/681.txt, (261, 9)),(./files/939.txt$
(143, 392)),(./files/357.txt, (19, 89)),(./files/213.txt, (40, 352)),(./files/322.txt, (5, 448),(64, 219)),(./files/711.txt, (184, 42)),(./files/870.txt, (36, 19)),(./$iles/594.txt, (221, 154)),(./files/911.txt, (41, 76)),(./files/394.txt, (387, 201)),(./files/544.txt, (38, 7)),(./files/942.txt, (201, 227)),(./files/86.txt, (128, 236)$
,(./files/514.txt, (43, 240)),(./files/132.txt, (392, 0)),(./files/981.txt, (414, 26)),(./files/377.txt, (161, 323)),(./files/876.txt, (408, 316)),(./files/82.txt, (149$
368)),(./files/25.txt, (192, 198)),(./files/392.txt, (67, 137)),(./files/456.txt, (63, 155),(66, 170)),(./files/74.txt, (326, 209)),(./files/133.txt, (137, 32)),(./fil$s/372.txt, (45, 412)),(./files/973.txt, (40, 22)),(./files/995.txt, (50, 0)),(./files/26.txt, (325, 205)),(./files/768.txt, (393, 35)),(./files/326.txt, (359, 24))

初始代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import re
import sys

# 把倒排索引中的项目转换成词典对象
# 以单词为索引键
def list2dict(l):
mapping = {}
for line in l:
i = 0
while line[i] != ",":
i += 1
word = line[:i]
while line[i] != "(":
i += 1
items = line[i:]
items = items.replace("),(./files", ")|(./files")
values = items.split("|")
mapping[word] = values
return mapping

# 把索引文件的内容加载进内存
def load_index():
index_file = "./index-file.txt"
with open(index_file, "r") as f:
index_lines = []
for line in f:
index_lines.append(line)
index = list2dict(index_lines)
return index

# 读取文件内容,单词使用 UTF-8 编码格式,
# 移除不需要的单词(不希望出现在索引中的单词)
def read_file_content(filepath):
with open(filepath, 'r') as f:
return [x.replace(",", "").replace(".", "").replace("\t", "").replace("\r", "") for x in f]

def find_match(results):
matches = []
for record in results:
i = 0
while record[i] != ",":
i += 1
filepath = record[1:i]
filecontent = read_file_content(filepath)
while record[i] != "(":
i += 1
offsets_str = record[i:-1]
offsets_str = offsets_str.replace("),(", ")|(")
offsets = offsets_str.split("|")
for offset in offsets:
line_idx = int(offset.split(",")[0][1:])
matches.append(filepath + ": " + filecontent[line_idx])
return matches

# 在索引文件中搜索单词
def search_word(w):
index = None
index = load_index()
result = index.get(w)
if result:
return find_match(result)
else:
return []

def main():
search_key = sys.argv[1] if len(sys.argv) > 1 else None

if search_key is None:
print("Usage: python ori_search.py <search word>")
else:
results = search_word(search_key)
if not results:
print("No result found for {}".format(search_key))
else:
for r in results:
print(r)

if __name__ == "__main__":
main()

部分输出结果如下

1
2
3
4
5
6
7
8
9
10
./files/973.txt: digitally nonetheless acid carboy recompensing blowpipe pawning adventurer kitchenettes endemically deceitfulness drivers affirmatively officiant mythologist prepays chemotherapy showgirl cowry unleashed scads episodically unlocked contaminates forks subcommittee logier insetting hurtles hombre foregathers participates
discolouration chaises forewarn cramping earmarking fleapits brink saying

./files/995.txt: acid solicitors uncivilised dowering breakfront perihelion sleeps unsnaps concerto adolescences minsters uptime crinkly victim united tooted shilled chalky phoneying hardstands inclosed croaks quantified mommie unwelcome commuted former sheikhdom dillies beneficence straddled divined

./files/26.txt: xenophobes splinters colourlessly pretends smallest oligarchical divisible dockworkers delineations whits skills hibachis aorta recolonised episode butternuts stars incarcerated metricated curbstone oiled acid kilohertz sweetbread sublimely proselytiser canons plebs aleatory flashest testatrices reapplication fruitier

./files/768.txt: daiquiri encysts workhorses lacked acid walloping cartwheel caseloads visiting nontransferable filibuster dogsleds exceeds concessionary rectums time-honored cincture scalpels tonnes exerted sears monetized counterpoint scientifically gimlets macadamizing levees

./files/326.txt: besmear riverbeds roman acid trusting grubbiness polyclinic shills misdirect whitish freshers recurrent deliria marry watercress bins survey imbed overfilling policy stowaway intellectual rhenium
1
python -m cProfile -o ori_search.prof ori_search.py acid

在左边的列表中,我们可以看到两个最耗时的函数是 split 和 replace,它门的调用次数很多,其次是 loadIndex 和 list2dict。

优化的方向就是 split,replace,loadIndex 和 list2dict。


Share