赵走x博客
网站访问量:151562
首页
书籍
软件
工具
古诗词
搜索
登录
Python性能分析与优化:8、line_profiler
Python性能分析与优化:7、性能分析示例
Python性能分析与优化:6、性能分析器:cProfile
Python性能分析与优化:5、性能分析最佳实践
Python性能分析与优化:4、运行时间复杂度
Python性能分析与优化:3、内存消耗和内存泄漏、过早优化的风险
Python性能分析与优化:2、性能分析可以分析什么
Python性能分析与优化:8、line_profiler
资源编号:75784
书籍
Python性能分析与优化
热度:87
这个性能分析器和`cProfile`不同。它可以帮助你一行一行地分析函数性能,而不是像`cProfile`那样做确定性性能分析。
这个性能分析器和`cProfile`不同。它可以帮助你一行一行地分析函数性能,而不是像`cProfile`那样做确定性性能分析。 可以用`pip`([https://pypi.python.org/pypi](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(n): a,b=b,a+b return a if __name__ == '__main__': print(fib(3)) ``` `kernprof`默认情况下会把分析结果写入script_to_profile.py.lprof文件,不过你可以用`-v`属性让结果立即显示在命令行里: ``` $ kernprof -l -v script_to_profile.py ``` 下面是一个简单的示例结果,可帮助你理解看到的内容。 ``` 2 Wrote profile results to 8-1.py.lprof Timer unit: 1e-06 s Total time: 0.00026 s File: 8-1.py Function: fib at line 3 Line # Hits Time Per Hit % Time Line Contents ============================================================== 3 @profile 4 def fib(n): 5 1 255.0 255.0 98.1 a,b=0,1 6 4 4.0 1.0 1.5 for i in range(n): 7 3 1.0 0.3 0.4 a,b=b,a+b 8 1 0.0 0.0 0.0 return a ``` 结果会显示函数的每一行,旁边是时间信息。共有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) ``` 按照下面命令运行 ``` kernprof -l -v 8-2.py ``` 结果为: ``` 0 1 4 9 16 25 36 49 64 81 End of the function Timer unit: 1e-06 s Total time: 0.000166 s File: 8-2.py Function: test at line 4 Line # Hits Time Per Hit % Time Line Contents ============================================================== 4 def test(): 5 11 39.0 3.5 23.5 for i in range(0,10): 6 10 123.0 12.3 74.1 print(i**2) 7 1 4.0 4.0 2.4 print('End of the function') Wrote profile results to 8-2.py.lprof Timer unit: 1e-06 s ``` ### **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](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`,可以让我们看到每一行代码的性能。我们还介绍了一些使用它们分析和优化代码的示例。 在下一章,我们将看到一些可视化工具,在工作中可以帮助我们展示本章出现的性能分析数据,但它们是通过图形的方式展示数据的。