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

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

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

    534 引用 • 671 回帖

相关帖子

欢迎来到这里!

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

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