这个性能分析器和 cProfile
不同。它可以帮助你一行一行地分析函数性能,而不是像 cProfile
那样做确定性性能分析。
可以用 pip
(https://pypi.python.org/pypi)命令行工具,通过下面的代码安装 line_profiler
:
$ pip install line_profiler
如果安装过程中遇到问题,比如文件缺失,请确保你已经安装了相关依赖。在 Ubuntu 中,可以通过下面的命令安装需要的依赖:
$ sudo apt-get install python-dev libxml2-dev libxslt-dev
line_profiler
试图弥补 cProfile
和类似性能分析器的不足。其他性能分析器主要关注函数调用消耗的 CPU 时间。大多数情况下,这足以发现问题,消除瓶颈(就像我们之前看到的那样)。但是,有时候,瓶颈问题发生在函数的某一行中,这时就需要 line_profiler
解决了。
line_profiler
的作者建议使用 kernprof
工具,后面我们会介绍相关示例。kernprof
会创建一个性能分析器实例,并把名字添加到 __builtins__
命名空间的 profile
中。line_profiler
性能分析器被设计成一个装饰器,你可以装饰任何一个函数,它会统计每一行消耗的时间。
用下面的代码执行这个性能分析器:
$ kernprof -l script_to_profile.py
被装饰的函数将被分析:
@profile
def fib(n):
a, b = 0, 1
for i in range(0, n):
a, b = b, a+b
return a
kernprof
默认情况下会把分析结果写入 script_to_profile.py.lprof 文件,不过你可以用 -v
属性让结果立即显示在命令行里:
$ kernprof -l -v script_to_profile.py
下面是一个简单的示例结果,可帮助你理解看到的内容。
结果会显示函数的每一行,旁边是时间信息。共有 6 列信息,具体含义如下。
-
Line #
:表示文件中的行号。 -
Hits
:性能分析时一行代码的执行次数。 -
Time
:一行代码执行的总时间,由计时器的单位决定。在分析结果的最开始有一行Timer unit
,该数值就是转换成秒的计时单位(要计算总时间,需要用Time
数值乘以计时单位)。不同系统的计时单位可能不同。 -
Per hit
:执行一行代码的平均消耗时间,依然由系统的计时单位决定。 -
% Time
:执行一行代码的时间消耗占程序总消耗时间的比例。
如果你正在使用 line_profiler
进行性能分析,有两种方式可以获得函数的性能分析数据:用构造器或者用 add_function
方法。
line_profiler
和 cProfile.Profile
一样,也提供了 run
、runctx
、runcall
、enable
和 disable
方法。但是最后两个函数在嵌入模块统计性能时并不安全,使用时要当心。进行性能分析之后,可以用 dump_stats(filename)
方法把 stats
加载到文件中。也可以用 print_stats([stream])
方法打印结果。它会把结果打印到 sys.stdout
里,或者任何其他设置成参数的数据流中。
下面的例子和前面的函数一样。这次函数通过 line_profiler
的 API 进行性能分析:
import line_profiler
import sys
def test():
for i in range(0, 10):
print i**2
print "End of the function"
prof = line_profiler.LineProfiler(test) # 把函数传递到性能分析器中
prof.enable() # 开始性能分析
test()
prof.disable() # 停止性能分析
prof.print_stats(sys.stdout) # 打印性能分析结果
2.3.1 kernprof
kernprof
工具和 line_profiler
是集成在一起的,允许我们从源代码中抽象大多数性能分析代码。这就表示我们可以用它分析应用的性能,和前面做的一样。kernprof
将为我们做以下事情。
-
它将和
cProfile
、lsprof
甚至profile
模块一起工作,具体要看哪一个性能分析器可用。 -
它会自动寻找脚本文件,如果文件不在当前文件夹,它会检测
PATH
路径。 -
将实例化分析器,并把名字添加到
__builtins__
命名空间的profile
中。这样我们就可以在代码中使用性能分析器了。在line_profiler
示例中,我们甚至可以直接把它当作装饰器用,不需要导入。 -
stats
性能分析文件可以用pstats.Stats
类进行查看,或者使用下面的代码查看。$ python -m pstats stats_file.py.prof
或者在
lprof
文件中查看:$ python -m line_profiler stats_file.py.lprof
2.3.2 kernprof
注意事项
在读取 kernprof
的输出结果时,有两件事情需要注意。有时,输出结果可能会比较混乱,或者数字可能没增加到总时间。这些最常见问题的解决方案如下。
-
在性能分析函数调用另一个函数时,没有把每一行消耗的时间增加到总时间上:当完成一个函数的性能分析时,可能会发生之前的函数分析结果没有加到总时间上的情况。这是因为
kernprof
只记录函数内部消耗的时间,以免对程序造成额外的负担,如下图所示。之前的例子中显示的情况是:
printI
函数在性能分析器里消耗了 0.010539 秒。但是,在test
函数内,时间消耗量是 19 567 个单位时间,共计 0.019567 秒。 -
分析报告中,列表综合(list comprehension)表达式的 Hit 比它们实际消耗的要多很多:基本上是因为对表达式进行性能分析时,分析报告对每次迭代增加了一个 Hit。如下图所示。
你会看到表达式实际的 Hit 数是 102,printExpression
函数每次被调用时需要 2 次 Hit。其他 100 次 Hit 是 xrange
函数消耗的。
2.3.3 性能分析示例
我们已经学习了 line_profiler
和 kernprof
的基础知识,下面让我们看一些有趣的例子。
1. 回到斐波那契数列
让我们继续对斐波那契数列进行性能分析。通过对两种性能分析器结果进行比较,我们可以更好地了解两种工作方式。
让我们先看看新的性能分析器的输出结果:
通过报告中的所有数据,我们可以看出时间并不是问题。在 fib
函数里,没有一行代码消耗了太多时间(也不应该消耗很多时间)。在 fib_seq
里面,只有一行消耗了大量时间,但那是因为递归是在 fib
里面运行的。
所以,我们的问题(其实我们也已经知道)就是递归,以及执行 fib
函数的次数(共有 57 291 次)。每次调用函数时,解释器都要按名称查询一次,然后再执行函数。每次调用 fib
函数时,都需要调用两次。
首先要解决的问题就是降低递归的次数。
我们可以像之前那样重写一个快速的递归函数,或者用装饰器缓存结果。运行结果如下图所示。
Hit 数量从 57 291 将到了 21。这又一次证明了装饰器缓存在这个例子中是一个很好的优化方案。
2. 倒排索引
我们不重复使用之前的示例来演示新的性能分析器,而是来看另一个示例:创建倒排索引(http://en.wikipedia.org/wiki/inverted_index)。
倒排索引是许多搜索引擎用来同时在若干文件中搜索文字的工具。它的工作方式是预扫描文件,把内容分割成单词,然后保存单词与文件之间的对应关系(有时也记录单词的位置)。通过这种方式搜索单词时,可以实现 O(1)时间复杂度(恒定时间)。
让我们看看下面的例子:
// 用下面这些文件:
file1.txt = "This is a file"
file2.txt = "This is another file"
// 获得如下索引:
This, (file1.txt, 0), (file2.txt, 0)
is, (file1.txt, 5), (file2.txt, 5)
a, (file1.txt, 8)
another, (file2.txt, 8)
file, (file1.txt, 10), (file2.txt, 16)
现在,如果我们要查找单词 is
,我们知道它是在两个文件中(不同的位置)。让我们看看下面计算索引位置的代码(和之前一样,下面的代码中有一些明显需要改进的地方,请你耐心看完,后面会不断优化)。
#!/usr/bin/env python
import sys
import os
import glob
def getFileNames(folder):
return glob.glob("%s/*.txt" % folder)
def getOffsetUpToWord(words, index):
if not index:
return 0
subList = words[0:index]
length = sum(len(w) for w in subList)
return length + index + 1
def getWords(content, filename, wordIndexDict):
STRIP_CHARS = ",.\t\n |"
currentOffset = 0
for line in content:
line = line.strip(STRIP_CHARS)
localWords = line.split()
for (idx, word) in enumerate(localWords):
word = word.strip(STRIP_CHARS)
if word not in wordIndexDict:
wordIndexDict[word] = []
line_offset = getOffsetUpToWord(localWords, idx)
index = (line_offset) + currentOffset
currentOffset = index
wordIndexDict[word].append([filename, index])
return wordIndexDict
def readFileContent(filepath):
f = open(filepath, 'r')
return f.read().split(' ')
def list2dict(list):
res = {}
for item in list:
if item[0] not in res:
res[item[0]] = []
res[item[0]].append(item[1])
return res
def saveIndex(index):
lines = []
for word in index:
indexLine = ""
glue = ""
for filename in index[word]:
indexLine += "%s(%s, %s)" % (glue, filename, ','.join(map(str, index[word][filename])))
glue = ","
lines.append("%s, %s" % (word, indexLine))
f = open("index-file.txt", "w")
f.write("\n".join(lines))
f.close()
def __start__():
files = getFileNames('./files')
words = {}
for f in files:
content = readFileContent(f)
words = getWords(content, f, words)
for word in (words):
words[word] = list2dict(words[word])
saveIndex(words)
__start__()
前面的代码很简单。程序从.txt 文件获取任务,那正是我们需要的。它会加载所有的.txt 文件,然后分割成单词,计算这些单词在文件中的偏移量,再把这些信息都保存到 index-file.txt 文件里。
下面我们开始性能分析,看看结果如何。由于我们不知道哪个函数任务繁重,哪个函数任务简单,因此我们给每个函数都加上 @profile
来分析函数性能。
(1) getOffsetUpToWord
getOffsetUpToWord
函数看着像是进行性能优化的合适对象,因为它在执行过程中消耗了比较多的时间。让我们把装饰器加上看看它的性能。
(2) getWords
getWords
函数做了大量的动作。它里面有两层 for
循环,所以我们也要在上面使用装饰器。
(3) list2dict
list2dict
函数把每个单元是两个元素的数组构成的列表转换成字典。字典把每个数组的第一个元素作为键,第二个元素作为值。我们同样加上 @profile
分析性能。
(4) readFileContent
readFileContent
函数只有两行,就是简单地使用 split
方法对文件内容进行处理。这里没有需要优化的地方,所以我们忽略它,把注意力集中到其他函数上。
(5) saveIndex
saveIndex
用一种简单的格式生成文件处理的结果。从下面的性能分析结果可以看出,我们可以获得更好的结果。
(6) __start__
最后是主方法 __start__
,它主要就是调用其他函数,没有什么性能负担,所以我们同样忽略它。
综上所述,我们之前分析了 6 个函数的性能,忽略了其中两个函数,因为它们要么太简单,要么没有值得关心的内容。于是我们一共有 4 个函数需要优化。
(1) getOffsetUpToWord
让我们看看第一个函数 getOffsetUpToWord
,里面许多行代码就是简单地把单词的长度增加到当前的索引位置。有一种更加具有 Python 风格的方式,让我们试一试。
原函数运行共消耗了 1.4 秒,让我们简化代码来缩短程序运行时间。增加单词长度的代码可以缩短,如下所示:
def getOffsetUpToWord(words, index):
if(index == 0):
return 0
length = reduce(lambda curr, w: len(w) + curr, words[0:index], 0)
return length + index + 1
代码简化只是把多余的变量声明和查询取消了。这好像没什么。但是,如果我们运行代码,时间会降到 0.9 秒。不过代码里面还是有一个明显的缺陷,就是 lambda 表达式。每当我们调用 getOffsetUpToWord
函数时,都要动态地创建一个函数。我们一共调用了 313 868 次,所以更好的办法是事先创建好函数。我们在 reduce
表达式里面使用函数引用就可以了,如下所示:
def addWordLength(curr, w):
return len(w) + curr
@profile
def getOffsetUpToWord(words, index):
if(index == 0):
return 0
length = reduce(addWordLength, words[0:index], 0)
return length + index + 1
输出结果如下图所示。
通过一点小改进,执行时间降到了 0.8 秒。在上面的截图中,我们还发现函数的前两行仍然消耗了大量不想要的 Hit(也是时间)。if
检测语句没必要,因为 reduce
表达式的初始值就是 0。长度变量声明没有必要,我们可以直接返回长度、索引和整数 1 的和。
按照这个思路修改代码,如下所示:
def addWordLength(curr, w):
return len(w) + curr
@profile
def getOffsetUpToWord(words, index):
return reduce(addWordLength, words[0:index], 0) + index + 1
这样函数的总运行时间就从 1.4 秒降到了 0.67 秒。
(2) getWords
让我们来看下一个函数:getWords
。这个函数非常慢,从前面的截屏可以看出,它的运行时间长达 4 秒。这实在很糟糕,让我们看看是怎么回事。首先,函数中最费时的代码行是调用 getOffsetUpToWord
函数。由于我们前面已经优化过 getOffsetUpToWord
函数,所以现在运行时间从原来的 4 秒降低到了 2.2 秒。
这里对副作用的优化非常合理,但是我们还可以进一步优化。我们用了一个 wordIndexDict
词典变量,所以在插入新键之前需要先检查键存不存在。在函数中做这个检查要消耗大约 0.2 秒时间。虽然耗时不多,但仍然可以优化。要消除检查,我们可以用 defaultdict
类。它是 dict
的子类,只是增加了一个功能。如果键不存在,就使用预先设置的默认值。这样就可以为程序运行节省 0.2 秒。
另一个实用的小优化是变量的声明。虽然看着是小事,但是调用了 313 868 次就无疑要消耗一些时间了。因此,让我们看看这几行性能分析结果:
35 313868 1266039 4.0 62.9 line_offset = getOffsetUpToWord(localWords, idx)
36 313868 108729 0.3 5.4 index = (line_offset) + currentOffset
37 313868 101932 0.3 5.1 currentOffset = index
这三行代码可以用一行代码搞定,如下所示:
currentOffset += getOffsetUpToWord(localWords, idx)
这样我们就又缩减了 0.2 秒。最后我们对每一行和每个单词都进行了 strip
操作。我们可以在加载文件的时候,对文件内容使用几次 replace
方法来进行简化。这样既将要处理的文本清理干净了,又消除了在 getWords
函数里查询和调用方法的时间。
新的代码如下:
def getWords(content, filename, wordIndexDict):
currentOffset = 0
for line in content:
localWords = line.split()
for (idx, word) in enumerate(localWords):
currentOffset += getOffsetUpToWord(localWords, idx)
wordIndexDict[word].append([filename, currentOffset])])])
return wordIndexDict
现在只需要 1.57 秒了。还有一个优化值得我们看看。这个优化适合我们的例子,因为 getOffsetUpToWord
函数只用了一次。由于这个函数只有一行,我们可以把这一行直接写入 getWords
。这样可以把时间减少到 1.07 秒(减少了 0.5 秒)。下面就是最新版函数的样子:
如果你还要在其他地方调用这个函数,这么做不方便维护代码。开发过程中代码的可维护性也是非常重要的一个方面。当你要确定何时停止优化时,代码的可维护性可以作为一个重要的决定因素。
(3) list2dict
对于 list2dict
函数没有什么可以优化的,不过我们可以让它变得更易读,而且可以减少约 0.1 秒的时间。我们又一次为了代码可读性而放弃对时间的执着。我们可以再一次使用 defaultdict
类,去掉检查环节。最终代码如下:
def list2dict(list):
res = defaultdict(lambda: [])
for item in list:
res[item[0]].append(item[1])
return res
这样处理后,代码行数更少,更方便阅读,也更容易理解。
(4) saveIndex
最后,让我们看看 saveIndex
函数。通过之前的分析报告,可以看到一共用了 0.23 秒完成索引文件的预处理和保存。这个性能已经很好了,不过我们还可以对字符串连接进行一点优化。
保存数据之前,我们把一些字符串组合起来构成一个单词。在同样的循环体中,我们还重置了 indexLine
和 glue
变量。这些操作放在一起消耗了大量的时间,所以我们应该改变策略。
优化后的代码如下:
def saveIndex(index):
lines = []
for word in index:
indexLines = []
for filename in index[word]:
indexLines.append("(%s, %s)" % (filename, ','.join(index[word][filename])))
lines.append(word + "," + ','.join(indexLines))
f = open("index-file.txt", "w")
f.write("\n".join(lines))
f.close()
你会看到,在前面的代码中,我们改变了 for
循环结构。现在不是把新的字符串加入 indexLine
变量,而是追加到列表里。我们还去掉了 map
调用,这样直接调用 join
就可以处理字符串。map
函数被移动到了 list2dict
函数内,在添加字符串到列表时,直接用索引即可。
最后我们用 +
操作符连接字符串,而不是用 C 语言字符串的连接方式(%
),后者耗时更多。最终,函数的执行时间从 0.23 降到了 0.13 秒,速度提升了 0.1 秒。
2.4 小结
这一章介绍了两个 Python 性能分析器:cProfile
,是语言自带的;line_profiler
,可以让我们看到每一行代码的性能。我们还介绍了一些使用它们分析和优化代码的示例。
在下一章,我们将看到一些可视化工具,在工作中可以帮助我们展示本章出现的性能分析数据,但它们是通过图形的方式展示数据的。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于