上帝说:“选择了脚本,就不要考虑性能。”我是很支持这句话的,使用脚本要的就是开发速度、良好的扩展性以及可维护性。可惜到了最后,我们的程序难免会运行得太慢,我们的客户不能忍受,这时候,我们就不得不考虑对代码的性能进行优化了。
程序运行慢的原因有很多,比如存在太多的劣化代码(如在程序中存在大量的“.”操作符),但真正的原因往往是比较是一两段设计并不那么良好的不起眼的程序,比如对一序列元素进行自定义的类型转换等。因为程序性能影响是符合80/20法则的,即20%的代码的运行时间占用了80%的总运行时间(实际上,比例要夸张的多,通常是几十行代码占用了95%以上的运行时间),靠经验就很难找出造成性能瓶颈的代码了。这时候,我们需要一个工具——profile!最近我手上的项目也在一些关键的地方遇到了性能问题,那时已经接近项目完工日期,幸好因为平时的代码模块化程度比较高,所以通过profile分析相关的独立模块,基本上解决了性能问题。通过这件事,让我下决心写一篇关于profile的文章,分享一下profile的使用心得。
初识profile
profile是python的标准库。可以统计程序里每一个函数的运行时间,并且提供了多样化的报表。使用profile来分析一个程序很简单,举例说如果有一个程序如下:
def foo():
sum = 0
for i in range(100):
sum += i
return sum
if __name__ == "__main__":
foo()
现在要用profile分析这个程序,很简单,把if程序块改为如下:
if __name__ == "__main__":
import profile
profile.run("foo()")
我们仅仅是import了profile这个模块,然后以程序的入口函数名为参数调用了profile.run这个函数,程序运行的输出如下:
5 function calls in 0.143 CPU seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.000 0.000 :0(range)
1 0.143 0.143 0.143 0.143 :0(setprofile)
1 0.000 0.000 0.000 0.000 <string>:1(?)
1 0.000 0.000 0.000 0.000 prof1.py:1(foo)
1 0.000 0.000 0.143 0.143 profile:0(foo())
0 0.000 0.000 profile:0(profiler)
上表显示了prof1.py里函数调用的情况,根据图表我们可以清楚地看到foo()函数占用了100%的运行时间,foo()函数是这个程序里名至实归的热点。
除了用这种方式,profile还可以直接用python解释器调用profile模块来剖分py程序,如在命令行界面输入如下命令:
python -m profile prof1.py
产生的输出跟直接修改脚本调用profile.run()函数有一样的功效。
通常情况下,profile的输出都直接输出到命令行,而且默认是按照文件名排序输出的。这就给我们造成了障碍,我们有时候希望能够把输出保存到文件,并且能够以各种形式来查看结果。profile简单地支持了一些需求,我们可以在profile.run()函数里再提供一个实参,就是保存输出的文件名;同样的,在命令行参数里,我们也可以加多一个参数,用来保存profile的输出。
用pstats自定义报表
profile解决了我们的一个需求,还有一个需求:以多种形式查看输出,我们可以通过profile的另一个类Stats来解决。在这里我们需要引入一个模块pstats,它定义了一个类Stats,Stats的构造函数接受一个参数——就是profile的输出文件的文件名。Stats提供了对profile输出结果进行排序、输出控制等功能,如我们把前文的程序改为如下:
# …略
if __name__ == "__main__":
import profile
profile.run("foo()", "prof.txt")
import pstats
p = pstats.Stats("prof.txt")
p.sort_stats("time").print_stats()
引入pstats之后,将profile的输出按函数占用的时间排序,输出如下:
Sun Jan 14 00:03:12 2007 prof.txt
5 function calls in 0.002 CPU seconds
Ordered by: internal time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.002 0.002 0.002 0.002 :0(setprofile)
1 0.000 0.000 0.002 0.002 profile:0(foo())
1 0.000 0.000 0.000 0.000 G:/prof1.py:1(foo)
1 0.000 0.000 0.000 0.000 <string>:1(?)
1 0.000 0.000 0.000 0.000 :0(range)
0 0.000 0.000 profile:0(profiler)
Stats有若干个函数,这些函数组合能给我们输出不同的profile报表,功能非常强大。下面简单地介绍一下这些函数:
strip_dirs()
用以除去文件名前名的路径信息。
add(filename,[…])
把profile的输出文件加入Stats实例中统计
dump_stats(filename)
把Stats的统计结果保存到文件
sort_stats(key,[…])
最重要的一个函数,用以排序profile的输出
reverse_order()
把Stats实例里的数据反序重排
print_stats([restriction,…])
把Stats报表输出到stdout
print_callers([restriction,…])
输出调用了指定的函数的函数的相关信息
print_callees([restriction,…])
输出指定的函数调用过的函数的相关信息
这里最重要的函数就是sort_stats和print_stats,通过这两个函数我们几乎可以用适当的形式浏览所有的信息了,下面来详细介绍一下。
sort_stats()接受一个或者多个字符串参数,如”time”、”name”等,表明要根据哪一列来排序,这相当有用,例如我们可以通过用time为key来排序得知最消耗时间的函数,也可以通过cumtime来排序,获知总消耗时间最多的函数,这样我们优化的时候就有了针对性,也就事半功倍了。sort_stats可接受的参数如下:
‘ncalls’
被调用次数
‘cumulative’
函数运行的总时间
‘file’
文件名
‘module’
文件名
‘pcalls’
简单调用统计(兼容旧版,未统计递归调用)
‘line’
行号
‘name’
函数名
‘nfl’
Name/file/line
‘stdname’
标准函数名
‘time’
函数内部运行时间(不计调用子函数的时间)
另一个相当重要的函数就是print_stats
, 用以根据最后一次调用sort_stats
之后得到的报表。print_stats
有多个可选参数,用以筛选输出的数据;print_stats
的参数可以是数字也可以是perl风格的正则表达式,相关的内容通过其它渠道了解,这里就不详述啦,仅举三个例子:
print_stats(“.1”, “foo:”)
这个语句表示将stats里的内容取前面的10%,然后再将包含”foo:”这个字符串的结果输出。
print_stats(“foo:”,”.1”)
这个语句表示将stats里的包含”foo:”字符串的内容的前10%输出。
print_stats(10)
这个语句表示将stats里前10条数据输出。
实际上,profile输出结果的时候相当于这样调用了Stats的函数:
p.strip_dirs().sort_stats(-1).print_stats()
其中sort_stats
函数的参数是-1,这也是为了与旧版本兼容而保留的。sort_stats可以接受-1,0,1,2之一,这四个数分别对应”stdname”, “calls”, “time”和”cumulative”;但如果你使用了数字为参数,那么pstats只按照第一个参数进行排序,其它参数将被忽略。
hotshot——更好的profile
因为profile本身的机制(如使用精确到毫秒的计时器等)导致在相当多情况下profile模块的“测不准”问题相当严重。hotshot大部分都是用C实现的,相对于profile模块它的计时函数对性能剖分的影响就小得多,而且支持以行为单位统计运行时间。美中不足的是hotshot不支持多线程的程序,确切来说,是它的计时核心有个关于临界段的bug;更加不幸的是,hotshot已经不再被维护,而且可能在未来的python版本中会从标准库中移除。不过,对于没有使用多线程的程序而言,hotshot目前仍然是非常好的剖分器。
hotshot有一个Profile类,它的构造函数原型如下:
class Profile( logfile[, lineevents[, linetimings]])
logfile参数是保存剖分统计结果的文件名,lineevents表示是否统计每一行源码的运行时间,默认为0,即以函数执行时间为统计粒度,linetimings为是否记录时间信息,默认为1。下面仍然是示例:
# …略
if __name__ == "__main__":
import hotshot
import hotshot.stats
prof = hotshot.Profile("hs_prof.txt", 1)
prof.runcall(foo)
prof.close()
p = hotshot.stats.load("hs_prof.txt")
p.print_stats()
输出:
1 function calls in 0.003 CPU seconds
Random listing order was used
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.003 0.003 0.003 0.003 i:/prof1.py:1(foo)
0 0.000 0.000 profile:0(profiler)
我们可以看到来自hotshot的干扰信息比profile的少了很多,这也有利于我们分析数据找出热点。
通过这几个函数,我们可以非常方便地建立测试的桩模块,不必再像使用profile那样手工地编写很多驱动模块了。hotshot.Profile还提供其它有用的函数,具体请参考相关的manual。
Python 2.5注意事项
因为hotshot不能用于多线程,而且它的优势仅在速度,所以python 2.5版本声明不再维护hotshot模块,并且可能在以后的版本中移除它。有去必有来,取而代之的就是cProfile,与cPickle等模块类似,cProfile要比profile模块更快些。cProfile的接口跟profile是一样的,只要在使用到profile的地方用cProfile替换就可以在以前的项目中使用它。
pstats在python 2.5版本中也产生了一些微妙的变化,pstats.Stats的构造函数增加了一个默认参数,变为:
class Stats( filename[, stream=sys.stdout[, ...]])
对我们而言,这是没有坏处的,stream参数给了我们把profile统计报表保存到文件的机会,这正是我们需要的。
综上所述,如果你使用的python 2.5版,我建议你使用cProfile。
小巧实用的瑞士军刀——timeit
如果我们某天心血来潮,想要向list里append一个元素需要多少时间或者想知道抛出一个异常要多少时间,那使用profile就好像用牛刀杀鸡了。这时候我们更好的选择是timeit模块。
timeit除了有非常友好的编程接口,也同样提供了友好的命令行接口。首先来看看编程接口。timeit模块包含一个类Timer,它的构造函数是这样的:
class Timer( [stmt='pass' [, setup='pass' [, timer=<timer function>]]])
stmt参数是字符串形式的一个代码段,这个代码段将被评测运行时间;setup参数用以设置stmt的运行环境;timer可以由用户使用自定义精度的计时函数。
timeit.Timer有三个成员函数,下面简单介绍一下:
timeit( [number=1000000])
timeit()执行一次Timer构造函数中的setup语句之后,就重复执行number次stmt语句,然后返回总计运行消耗的时间。
repeat( [repeat=3 [, number=1000000]])
repeat()函数以number为参数调用timeit函数repeat次,并返回总计运行消耗的时间
>>> import timeit
>>> t = timeit.Timer("soundex.soundex('Pilgrim')",
... "import soundex")
>>> t.timeit()
8.21683733547
>>> t.repeat(3, 2000000)
[16.48319309109, 16.46128984923, 16.44203948912]
可以参考: http://woodpecker.org.cn/diveintopython/performance_tuning/timeit.html