第 1 章 性能分析基础

第 1 章 性能分析基础

就像在12秒内跑完100米障碍跑的人在婴儿时期需要先学爬一样,程序员在精通性能分析(profiling)之前需要先了解一些基础知识。因此,在我们探索Python程序的性能优化与分析技术之前,需要对相关的基础知识有一个清晰的认识。

只要你掌握了这些基础知识,就可以进一步学习具体的工具和技术。因此,这一章将介绍所有你平时羞于开口问人却又应该掌握的性能分析知识。本章的具体内容如下。

  • 介绍性能分析的明确定义,概述各种性能分析技术。

  • 论述性能分析在开发周期中的重要作用,因为性能分析不是那种只做一次就抛到脑后的事情。性能分析应该是开发过程中一个完整的组成部分,就像写测试一样。

  • 介绍哪些东西适合进行性能分析。看看我们可以度量哪些资源,以及这些度量如何帮助我们发现性能瓶颈。

  • 分析过早优化的风险,即解释为什么未经性能分析便对代码进行优化通常不是一种好做法。

  • 学习关于程序运行时间复杂性的知识。虽然理解性能分析技术是成功优化程序的一个步骤,但我们也需要理解算法复杂性的度量指标,这样才能够明白是否有必要优化算法。

  • 一些好的做法。本章最后将介绍一些对项目进行性能分析时需要记住的好习惯。

1.1 什么是性能分析

没有优化过的程序通常会在某些子程序(subroutine)上消耗大部分的CPU指令周期(CPU cycle)。性能分析就是分析代码和它正在使用的资源之间有着怎样的关系。例如,性能分析可以告诉你一个指令占用了多少CPU时间,或者整个程序消耗了多少内存。性能分析是通过使用一种被称为性能分析器(profiler)的工具,对程序或者二进制可执行文件(如果可以拿到)的源代码进行调整来完成的。

通常,当需要优化程序性能,或者程序遇到了一些奇怪的bug时(一般与内存泄漏有关),开发者会对他们的程序进行性能分析。这时,性能分析可以帮助开发者深刻地了解程序是如何使用计算机资源的(即可以细致到一个函数被调用了多少次)。

根据这些信息,以及对源代码的深刻认知,开发者就可以找到程序的性能瓶颈或者内存泄漏所在,然后修复错误的代码。

性能分析软件有两类方法论:基于事件的性能分析(event-based profiling)和统计式性能分析(statistical profiling)。在使用这两类软件时,应该牢记它们各自的优缺点。

1.1.1 基于事件的性能分析

不是所有的编程语言都支持这类性能分析。支持这类基于事件的性能分析的编程语言主要有以下几种。

基于事件的性能分析器(event-based profiler,也称为轨迹性能分析器,tracing profiler)是通过收集程序执行过程中的具体事件进行工作的。这些性能分析器会产生大量的数据。基本上,它们需要监听的事件越多,产生的数据量就越大。这导致它们不太实用,在开始对程序进行性能分析时也不是首选。但是,当其他性能分析方法不够用或者不够精确时,它们可以作为最后的选择。如果你想分析程序中所有返回语句的性能,那么这类性能分析器就可以为你提供完成任务应该有的颗粒度,而其他性能分析器都不能为你提供如此细致的结果。

一个Python基于事件的性能分析器的简单示例代码如下所示(当学完后面的章节时,你对这个主题的理解将会更加深刻):

import profile
import sys

def profiler(frame, event, arg):
    print 'PROFILER: %r %r' % (event, arg)

sys.setprofile(profiler)

# 计算斐波那契数列的简单(也是非常低效的)示例
def fib(n):
    if n == 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fib(n-1) + fib(n-2)

def fib_seq(n):
    seq = [ ]
    if n > 0:
        seq.extend(fib_seq(n-1))
    seq.append(fib(n))
    return seq

print fib_seq(2)

上面程序的输出结果如下所示:

PROFILER: 'call' None
PROFILER: 'call' None
PROFILER: 'call' None
PROFILER: 'call' None
PROFILER: 'return' 0
PROFILER: 'c_call' <built-in method append of list object at 0x7f570ca215f0>
PROFILER: 'c_return' <built-in method append of list object at 0x7f570ca215f0>
PROFILER: 'return' [0]
PROFILER: 'c_call' <built-in method extend of list object at 0x7f570ca21bd8>
PROFILER: 'c_return' <built-in method extend of list object at 0x7f570ca21bd8>
PROFILER: 'call' None
PROFILER: 'return' 1
PROFILER: 'c_call' <built-in method append of list object at 0x7f570ca21bd8>
PROFILER: 'c_return' <built-in method append of list object at 0x7f570ca21bd8>
PROFILER: 'return' [0, 1]
PROFILER: 'c_call' <built-in method extend of list object at 0x7f570ca55bd8>
PROFILER: 'c_return' <built-in method extend of list object at 0x7f570ca55bd8>
PROFILER: 'call' None
PROFILER: 'call' None
PROFILER: 'return' 1
PROFILER: 'call' None
PROFILER: 'return' 0
PROFILER: 'return' 1
PROFILER: 'c_call' <built-in method append of list object at 0x7f570ca55bd8>
PROFILER: 'c_return' <built-in method append of list object at 0x7f570ca55bd8>
PROFILER: 'return' [0, 1, 1]
[0, 1, 1]
PROFILER: 'return' None
PROFILER: 'call' None
PROFILER: 'c_call' <built-in method discard of set object at 0x7f570ca8a960>
PROFILER: 'c_return' <built-in method discard of set object at 0x7f570ca8a960>
PROFILER: 'return' None
PROFILER: 'call' None
PROFILER: 'c_call' <built-in method discard of set object at 0x7f570ca8f3f0>
PROFILER: 'c_return' <built-in method discard of set object at 0x7f570ca8f3f0>
PROFILER: 'return' None

你会发现,PROFILER会被每一个事件调用。我们可以打印/收集PROFILER函数里我们觉得有意义的内容。在上面的简单示例代码中,最后一行表示执行fib_seq(2)生成一组数值。如果我们处理一个实际点儿的程序,性能分析输出的结果可能要比上述结果大好几个数量级。这就是基于事件的性能分析软件通常作为性能分析的最后选择的原因。虽然其他性能分析软件(马上就会看到)产生的结果会少很多,但是分析的精确程度也要低一些。

1.1.2 统计式性能分析

统计式性能分析器以固定的时间间隔对程序计数器(program counter)进行抽样统计。这样做可以让开发者掌握目标程序在每个函数上消耗的时间。由于它对程序计数器进行抽样,所以数据结果是对真实值的统计近似。不过,这类软件足以窥见被分析程序的性能细节,查出性能瓶颈之所在。

这类性能分析软件的优点如下所示。

  • 分析的数据更少:由于我们只对程序执行过程进行抽样,而不用保留每一条数据,因此需要分析的信息量会显著减少。

  • 对性能造成的影响更小:由于使用抽样的方式(用操作系统中断),目标程序的性能遭受的干扰更小。虽然使用性能分析器并不能做到100%无干扰,但是统计式性能分析器比基于事件的性能分析器造成的干扰要小。

下面是一个Linux统计式性能分析器OProfile(http://oprofile.sourceforge.net/news/)的分析结果:

Function name,File name,Times Encountered,Percentage
"func80000","statistical_profiling.c",30760,48.96%
"func40000","statistical_profiling.c",17515,27.88%
"func20000","static_functions.c",7141,11.37%
"func10000","static_functions.c",3572,5.69%
"func5000","static_functions.c",1787,2.84%
"func2000","static_functions.c",768,1.22%
"func1500","statistical_profiling.c",701,1.12%
"func1000","static_functions.c",385,0.61%
"func500","statistical_profiling.c",194,0.31%

下面的性能分析结果,是通过Python的统计式性能分析器statprof对前面的代码进行分析得出的:

%     cumulative      self
time     seconds   seconds  name
100.00      0.01      0.01  B02088_01_03.py:11:fib
  0.00      0.01      0.00  B02088_01_03.py:17:fib_seq
  0.00      0.01      0.00  B02088_01_03.py:21:<module>
---
Sample count: 1
Total time: 0.010000 seconds

你会发现,两个性能分析器对同样代码的分析结果差异非常大。

1.2 性能分析的重要性

现在我们已经知道了性能分析的涵义,还应该理解在产品开发周期中进行性能分析的重要性和实际意义。

性能分析并不是每个程序都要做的事情,尤其对于那些小软件来说,是没多大必要的(不像那些杀手级嵌入式软件或专门用于演示的性能分析程序)。性能分析需要花时间,而且只有在程序中发现了错误的时候才有用。但是,仍然可以在此之前进行性能分析,捕获潜在的bug,这样可以节省后期的程序调试时间。

在硬件变得越来越先进、越来越快速且越来越便宜的今天,开发者自然也越来越难以理解,为什么我们还要消耗资源(主要是时间)去对开发的产品进行性能分析。毕竟,我们已经拥有测试驱动开发、代码审查、结对编程,以及其他让代码更加可靠且符合预期的手段。难道不是吗?

然而,我们没有意识到的是,随着我们使用的编程语言越来越高级(几年间我们就从汇编语言进化到了JavaScript),我们愈加不关心CPU循环周期、内存配置、CPU寄存器等底层细节了。新一代程序员都通过高级语言学习编程技术,因为它们更容易理解而且开箱即用。但它们依然是对硬件和与硬件交互行为的抽象。随着这种趋势的增长,新的开发者越来越不会将性能分析作为软件开发中的一个步骤了。

让我们看看下面这种情景。

我们已经知道,性能分析是用来测量程序所使用的资源的。前面已经说过,资源正变得越来越便宜。因此,生产软件并让更多的客户使用我们的软件,其成本变得越来越低。

如今,随便开发一个软件就可以获得上千用户。如果通过社交网络一推广,用户可能马上就会呈指数级增长。一旦用户量激增,程序通常会崩溃,或者变得异常缓慢,最终被客户无情抛弃。

上面这种情况,显然可能是由于糟糕的软件设计和缺乏扩展性的架构造成的。毕竟,一台服务器有限的内存和CPU资源也可能会成为软件的瓶颈。但是,另一种可能的原因,也是被证明过许多次的原因,就是我们的程序没有做过压力测试。我们没有考虑过资源消耗情况;我们只保证了测试已经通过,而且乐此不疲。也就是说,我们目光短浅,结果就是项目崩溃夭折。

性能分析可以帮助我们避免项目崩溃夭折,因为它可以相当准确地为我们展示程序运行的情况,不论负载情况如何。因此,如果在负载非常低的情况下,通过性能分析发现软件在I/O操作上消耗了80%的时间,那么这就给了我们一个提示。有人可能觉得,在测试阶段程序运行很正常,在负载很重的情况下也应该不会有问题。想想内存泄漏的情况吧。在这种情况下,小测试是不会发现大负载里出现的bug的。但是,产品负载过重时,内存泄漏就会发生。性能分析可以在负载真的过重之前,为我们提供足够的证据来发现这类隐患。

1.3 性能分析可以分析什么

要想深入地理解性能分析,很重要的一点是明白性能分析方法究竟能够分析什么指标。因为测量是性能分析的核心,所以让我们仔细看看程序运行时可以测量的指标。

1.3.1 运行时间

做性能分析时,我们能够收集到的最基本的数值就是运行时间。整个进程或代码中某个片段的运行时间会暴露相应的性能。如果你对运行的程序有一些经验(比如说你是一个网络开发者,正在使用一个网络框架),可能很清楚运行时间是不是太长。例如,一个简单的网络服务器查询数据库、响应结果、反馈到客户端,一共需要100毫秒。但是,如果程序运行得很慢,做同样的事情需要花费60秒,你就得考虑做性能分析了。你还需要考虑不同场景的可比性。再考虑另一个进程:一个MapReduce任务把2TB数据存储到文件中要消耗20分钟,这时你可能不会认为进程很慢了,即使它比之前的网络服务器处理时间要长很多。

为了获得运行时间,你不需要拥有大量性能分析经验和一堆复杂的分析工具。你只需要把几行代码加入程序运行就可以了。

例如,下面的代码会计算斐波那契数列的前30位:

import datetime

tstart = None
tend = None

def start_time():
    global tstart
    tstart = datetime.datetime.now()

def get_delta():
    global tstart
    tend = datetime.datetime.now()
    return tend - tstart



def fib(n):
     return n if n == 0 or n == 1 else fib(n-1) + fib(n-2)

def fib_seq(n):
    seq = []
    if n > 0:
        seq.extend(fib_seq(n-1))
    seq.append(fib(n))
    return seq

start_time()
print "About to calculate the fibonacci sequence for the number 30"
delta1 = get_delta()

start_time()
seq = fib_seq(30)
delta2 = get_delta()

print "Now we print the numbers: "
start_time()
for n in seq:
    print n
delta3 = get_delta()

print "====== Profiling results ======="
print "Time required to print a simple message: %(delta1)s" % locals()
print "Time required to calculate fibonacci: %(delta2)s" % locals()
print "Time required to iterate and print the numbers: %(delta3)s" % locals()
print "======  ======="

程序的输出结果如下所示:

About to calculate the Fibonacci sequence for the number 30
Now we print the numbers:
0
1
1
2
3
5
8
13
21
#……省略一些数字
4181
6765
10946
17711
28657
46368
75025
121393
196418
317811
514229
832040
====== Profiling results =======
Time required to print a simple message: 0:00:00.000030
Time required to calculate fibonacci: 0:00:00.642092
Time required to iterate and print the numbers: 0:00:00.000102

通过最后三行结果,我们会发现,代码中最费时的部分就是斐波那契数列的计算。

下载源代码

你可以用自己的账户登录http://www.packtpub.com,下载你购买过的Packt出版社的所有图书的示例代码。如果你是在其他地方购买的Packt出版社的书籍,可以通过http://www.packtpub.com/support注册账户,然后要求Packt把示例代码通过邮件发给你。

1.3.2 瓶颈在哪里

只要你测量出了程序的运行时间,就可以把注意力移到运行慢的环节上做性能分析。通常,瓶颈都是由下面的一种或几种原因造成的。

  • 沉重的I/O操作,比如读取和分析大文件,长时间执行数据库查询,调用外部服务(比如HTTP请求),等等。

  • 出现了内存泄漏,消耗了所有的内存,导致后面的程序没有内存来正常执行。

  • 未经优化的代码被频繁地执行。

  • 密集的操作在可以缓存时没有缓存,占用了大量资源。

I/O关联的代码(文件读/写、数据库查询等)很难优化,因为优化有可能会改变程序执行I/O操作的方式(通常是语言的核心函数操作I/O)。相反,优化计算关联的代码(比如程序使用的算法很糟糕),改善性能会比较容易(并不一定很简单)。这是因为优化计算关联的代码就是改写程序。

在性能优化接近尾声的时候,剩下的大多数性能瓶颈都是由I/O关联的代码造成的。

1.4 内存消耗和内存泄漏

软件开发过程中需要考虑的另一个重要资源是内存。一般的软件开发者不会意识到这一点,因为640KB RAM电脑的时代早已成为过去。但是一个内存泄漏的程序会把服务器糟蹋成640KB电脑。内存消耗不仅仅是关注程序使用了多少内存,还应该考虑控制程序使用内存的数量。

有一些开发项目,比如嵌入式系统开发,就会要求开发者关注内存配置,因为这类系统的资源是相当有限的。但是,普通开发者总希望目标系统能够提供他们需要的RAM。

随着RAM和高级编程语言都开始支持自动内存管理(比如垃圾回收机制),开发者不需要关注内存优化了,系统会帮忙完成的。

跟踪程序内存的消耗情况比较简单。最基本的方法就是使用操作系统的任务管理器。它会显示很多信息,包括程序占用的内存数量或者占用总内存的百分比。任务管理器也是检查CPU时间使用情况的好工具。在下面的截图中,你会发现一个简单的Python程序(就是前面那段程序)几乎占用了全部CPU(99.8%),内存只用了0.1%。

{%}

用这样的工具(Linux系统在命令行用top命令),可以轻松检测内存泄漏问题,不过这也要根据程序的具体情况综合考虑。如果你的程序在持续加载数据,那么其内存消耗的比例,可能会与那些没有频繁使用外部资源的程序不同。

例如,如果我们把一个调用大量外部资源的程序的内存消耗随时间的变化描绘出来,可能如下图所示。

{%}

资源加载时,内存使用曲线出现高峰;资源释放时,曲线会下降。虽然程序的内存消耗变化有点儿大,但是我们可以统计没有加载资源时程序的内存消耗的平均值。只要确定了这个平均值(图中用矩形表示),就可以判断内存泄漏的情况。

让我们再看一个资源加载效果比较糟糕的程序的内存消耗图(没有完全释放资源)。

{%}

在上图中你会发现,每当资源不再使用时,占用的内存并没有完全释放,这时内存消耗曲线就会位于矩形之上。这就表示程序会消耗越来越多的内存,即使加载资源已经释放也是如此。

同理,也可以检测那些没有负载的程序的内存消耗情况,把执行特定任务的程序运行一段时间。有了数据,就很容易检测内存消耗和内存泄漏情况了。

让我们来看一个例子:

{%}

当运行过程启动之后,内存消耗会在一个范围内不断增加。如果发现增幅超出范围,而且消耗增大之后一直没有回落,就可以判断出现内存泄漏了。

一个内存泄漏的例子如下图所示。

{%}

1.5 过早优化的风险

优化通常被认为是一个好习惯。但是,如果一味优化反而违背了软件的设计原则就不好了。在开始开发一个新软件时,开发者经常犯的错误就是过早优化(permature optimization)。

如果过早优化代码,结果可能会和原来的代码截然不同。它可能只是完整解决方案的一部分,还可能包含因优化驱动的设计决策而导致的错误。

一条经验法则是,如果你还没有对代码做过测量(性能分析),优化往往不是个好主意。首先,应该集中精力完成代码,然后通过性能分析发现真正的性能瓶颈,最后对代码进行优化。

1.6 运行时间复杂度

在进行性能分析和优化时,理解运行时间复杂度(Running Time Complexity,RTC)的知识,以及学习使用它们适当地优化代码十分重要。

RTC可以用来对算法的运行时间进行量化。它是对算法在一定数量输入条件下的运行时间进行数学近似的结果。因为是数学近似,所以我们可以用这些数值对算法进行分类。

RTC常用的表示方法是大O标记(big O notation)。数学上,大O标记用于表示包含无限项的函数的有限特征(类似于泰勒展开式)。如果把这个概念用于计算机科学,就可以把算法的运行时间描述成渐进的有限特征(数量级)。

也就是说,这种标记通过宽泛的估计,让我们了解算法在任意数量输入下的运行时间。但是它不能提供精确的时间值,需要对代码进行深入的分析才能获得。

前面说过,用这种标记方法可以对算法进行分类,下面就是常用的算法类型。

1.6.1 常数时间——O(1)

常数时间(constant time)是最简单的算法复杂度类型。这基本上表示我们的测量结果将是恒定值,算法运行时间不会随着输入的增加而增加。

运行时间为O(1)的代码示例如下所示。

  • 判断一个数是奇数还是偶数:

    if number % 2:
        odd = True
    else:
        odd = False
    
  • 用标准输出方式打印信息:

    print "Hello world!"
    
    

对于理论上更复杂的操作,比如在字典(或哈希表)中查找一个键的值,如果算法合理,就可以在常数时间内完成。技术上看,在哈希表中查找元素的消耗时间是O(1)平均时间,这意味着每次操作的平均时间(不考虑特殊情况)是固定值O(1)。

1.6.2 线性时间——O(n)

线性时间复杂度表示,在任意 n 个输入下,算法的运行时间与 n 呈线性关系,例如,3n,4n+5,等等。

{%}

如上图所示,当 x 轴无线延伸时,蓝线(3n)和红线(4n+5)会和黑线(n)达到同样的上限。因此,为了简化,我们把这些算法都看成O(n)类。

这种数量级(order)的算法案例有:

  • 查找无序列表中的最小元素

  • 比较两个字符串

  • 删除链表中的最后一项

1.6.3 对数时间——O(logn)

对数时间(logarithmic time)复杂度的算法,表示随着输入数量的增加,算法的运行时间会达到固定的上限。随着输入数量的增加,对数函数开始增长很快,然后慢慢减速。它不会停止增长,但是越往后增长的速度越慢,甚至可以忽略不计。

{%}

上图显示了三种不同的对数函数。你会看到三条线都是同样的形状,随着x的增大,都是无限增加的。

对数时间的算法示例如下所示:

  • 二分查找(binary search)

  • 计算斐波那契数列(用矩阵乘法)

1.6.4 线性对数时间——O(n logn)

把前面两种时间类型组合起来就变成了线性对数时间(linearithmic time)。随着 x 的增大,算法的运行时间会快速增长。

这类算法的示例如下所示:

  • 归并排序(merge sort)

  • 堆排序(heap sort)

  • 快速排序(quick sort,至少是平均运行时间)

下图中的线性对数函数曲线可以让我们更好地理解这类算法。

{%}

1.6.5 阶乘时间——O(n!)

阶乘时间(factorial time)复杂度的算法是最差的算法。其时间增速特别快,图都很难画。

下图是对阶乘函数的近似描述,可以看成这类算法的运行时间。

{%}

阶乘时间复杂度的一个示例,就是用暴力破解搜索方法解货郎担问题(遍历所有可能的路径)。

1.6.6 平方时间——O(n2)

平方时间是另一个快速增长的时间复杂度。输入数量越多,需要消耗的时间越长(大多数算法都是这样,这类算法尤其如此)。平方时间复杂度的运行效率比线性时间复杂度要慢。

这类算法的示例如下:

  • 冒泡排序(bubble sort)

  • 遍历二维数组

  • 插入排序(insertion sort)

这类函数的曲线图如下所示:

{%}

最后,我们把所有算法运行时间复杂度放在一张图上,比较一下运行效率:

{%}

不考虑常数时间复杂度(虽然它是最快的,但是显然复杂算法都不可能达到这个速度),那么时间复杂度排序如下所示:

  • 对数

  • 线性

  • 线性对数

  • 平方

  • 阶乘

有时候,你可能也没办法,只能选择平方时间复杂度作为最佳解决方案。理论上我们总是希望实现更快速的算法,但是问题和技术的限制往往会影响结果。

 注意,平方时间类型与阶乘时间类型之间,有一些变体,如三次方时间类型、四次方时间类型等。

还有很重要的一点需要考虑,就是算法的时间复杂度往往不止一种类型,可能是三种类型,包括最好情况、正常情况和最差情况。三种情况是由输入条件的不同属性决定的。例如,如果结果已经排序,插入排序算法的运行速度会比较快(最好情况),其他情况则要更慢一些(指数复杂度)。

另外数据类型也会影响时间复杂度。算法运行时间复杂度也与实际的操作方式有关(索引、插入、搜索等)。常见的数据类型和操作的时间复杂度如下所示。

数据结构时间复杂度
正常情况最差情况
索引查找插入删除索引查找插入删除
列表(list)O(1)O(n)--O(1)O(n)--
单向链表(linked list)O(n)O(n)O(1)O(1)O(n)O(n)O(1)O(n)
双向链表(doubly linked list)O(n)O(n)O(1)O(1)O(n)O(n)O(1)O(1)
字典(dictionary)-O(1)O(1)O(1)-O(n)O(n)O(n)
二分查找树(Binary Search Tree,BST)O(log(n))O(log(n))O(log(n))O(log(n))O(n)O(n)O(n)O(n)

1.7 性能分析最佳实践

性能分析是重复性的工作。为了获得最佳性能,你可能需要在一个项目中做很多次性能分析,在另一个项目里还要再做一次。和软件开发中的其他重复性任务一样,有许多最佳实践可以帮助你高效地完成大多数性能分析工作。让我们来具体看看。

1.7.1 建立回归测试套件

在进行性能优化时,需要保证不管代码怎么变化,功能都不会变糟。最好的做法,尤其是面对大型项目时,就是建立测试套件。确保代码具有足够的覆盖率,可以让你信心去优化。覆盖率只有60%的测试套件在优化时可能会导致严重后果。

回归测试套件可以保证你在代码中尝试任何优化时,都不用担心代码的结构被破坏。

1.7.2 思考代码结构

函数代码之所以容易进行重构(refactor),是因为这种代码结构没有副作用。这样可以降低改变系统中其他部分的风险。如果你的代码没有局部可变的状态,将是另一个优势。这是因为,代码应该很容易理解和改变。没有按照前面的规则编写的代码,在重构过程中可能都需要额外的工作和注意。

1.7.3 耐心

性能分析不是一个快速、简单、精确的过程。也就是说,你不能指望运行一下性能分线器就可以把问题找到。有时候也许可以这样。但是,大多数情况下,你遇到的问题都不是很容易解决的。这就表明你必须浏览数据,描绘图形以便理解,不断地缩小检测范围,直到你重新开启新一轮分析,或者最终找到问题所在。

值得注意的是,对数据分析得越深入,表明你陷入的坑越深,数据将无法指明正确的优化方向,因此要时刻清楚自己的目标,并且在你开始之前已准备好正确的工具。然而,也可能搞了半天除了备受挫折,什么进展也没有。

1.7.4 尽可能多地收集数据

根据软件的不同类型和规模,在分析之前,你可能需要获取尽量多的数据。性能分析器很适合做这件事。但是,还有其他数据资源,如网络应用的系统日志、自定义日志、系统资源快照(如操作系统任务管理器),等等。

1.7.5 数据预处理

当你拥有了性能分析器的信息、日志和其他资源之后,在分析之前可能需要对数据进行预处理。不要因为性能分析器不能理解就回避非结构化数据。数据分析会往往从其他数据中受益。

例如,如果分析网络应用的性能,获取网络服务器日志是个不错的主意,但是这些日志文件就是一行一个请求。解析文件并把数据存入数据库系统(像MongoDB、MySQL等),你就可以为数据确定含义(解析日期数据,通过IP获溯源地理位置等),并在后面进行查询。

前面这个过程称为ETL(extracting the data from it's sources, transforming it into something with meaning, and loading it into another system),表示从源抽取数据,根据数据含义转换形式,并加载到其他系统中使用。

1.7.6 数据可视化

如果在错误发生之前,你不清楚自己要找的问题,只是想知道优化代码的方式,那么洞察你已经预处理过的数据的最好方式就是数据可视化。计算机很擅长处理数据,但是人类擅于通过图像来发现模式和理解现有信息中的某种特征。

例如,继续前面的网络服务器日志示例,一个简单的请求时间图(比如在微软的Excel中绘制)就可以显示客户行为的某种特征:

{%}

上图很清晰地显示出客户访问集中在下午晚些时候,并持续到深夜。后面你可以进一步针对这个特征进行性能分析。例如,针对这种现象的优化方案,可能就是在高峰期为基础设施增加更多资源(像亚马逊的AWS可以满足这类需求)。

另一个例子是用自定义性能分析数据可以画出下图:

{%}

上图是对本章第一个代码示例的性能分析结果中那些触发profile函数的事件进行数量统计。我们可以把它画成饼图,直观地看出数量最多的事件。可以看出,调用callreturn占用了程序运行的绝大多数时间。

1.8 小结

在这一章,我们介绍了性能分析的基础知识,理解了性能分析方法及其重要性,并学会了如何使用它分析大多数代码的性能。

下一章我们将动手试试Python的性能分析器,看看它们是如何对应用进行性能分析的。

目录

  • 版权声明
  • 译者序
  • 前言
  • 致谢
  • 第 1 章 性能分析基础
  • 第 2 章 性能分析器
  • 第 3 章 可视化——利用GUI理解性能分析数据
  • 第 4 章 优化每一个细节
  • 第 5 章 多线程与多进程
  • 第 6 章 常用的优化方法
  • 第 7 章 用Numba、Parakeet和pandas实现极速数据处理
  • 第 8 章 付诸实践