Python 性能分析与优化:8、line_profiler

本贴最后更新于 2049 天前,其中的信息可能已经时过境迁

这个性能分析器和 cProfile 不同。它可以帮助你一行一行地分析函数性能,而不是像 cProfile 那样做确定性性能分析。

可以用 piphttps://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_profilercProfile.Profile 一样,也提供了 runrunctxruncallenabledisable 方法。但是最后两个函数在嵌入模块统计性能时并不安全,使用时要当心。进行性能分析之后,可以用 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 将为我们做以下事情。

  • 它将和 cProfilelsprof 甚至 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_profilerkernprof 的基础知识,下面让我们看一些有趣的例子。

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 秒完成索引文件的预处理和保存。这个性能已经很好了,不过我们还可以对字符串连接进行一点优化。

保存数据之前,我们把一些字符串组合起来构成一个单词。在同样的循环体中,我们还重置了 indexLineglue 变量。这些操作放在一起消耗了大量的时间,所以我们应该改变策略。

优化后的代码如下:

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,可以让我们看到每一行代码的性能。我们还介绍了一些使用它们分析和优化代码的示例。

在下一章,我们将看到一些可视化工具,在工作中可以帮助我们展示本章出现的性能分析数据,但它们是通过图形的方式展示数据的。

  • Python

    Python 是一种面向对象、直译式电脑编程语言,具有近二十年的发展历史,成熟且稳定。它包含了一组完善而且容易理解的标准库,能够轻松完成很多常见的任务。它的语法简捷和清晰,尽量使用无异义的英语单词,与其它大多数程序设计语言使用大括号不一样,它使用缩进来定义语句块。

    556 引用 • 675 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...