python性能优化

目录

 

正文

 
注意本文除非特殊指明,”python“都是代表 CPython,即 C 语言实现的标准 python,且本文所讨论的是版本为 2.7 的 CPython。另外,本文会不定期更新,如果大家有一些好的想法,请在评论里面留言,我会补充到文章中去
 
姊妹篇:《Python 内存优化
姊妹篇:《使用 gc、objgraph 干掉 python 内存泄露与循环引用!

python 为什么性能差:

回到顶部
  当我们提到一门编程语言的效率时:通常有两层意思,第一是开发效率,这是对程序员而言,完成编码所需要的时间;另一个是运行效率,这是对计算机而言,完成计算任务所需要的时间。编码效率和运行效率往往是鱼与熊掌的关系,是很难同时兼顾的。不同的语言会有不同的侧重,python 语言毫无疑问更在乎编码效率,life is short,we use python。
 
  虽然使用 python 的编程人员都应该接受其运行效率低的事实,但 python 在越多越来的领域都有广泛应用,比如科学计算 、web 服务器等。程序员当然也希望 python 能够运算得更快,希望 python 可以更强大。
  首先,python 相比其他语言具体有多慢,这个不同场景和测试用例,结果肯定是不一样的。这个网址给出了不同语言在各种 case 下的性能对比,这一页是 python3 和 C++ 的对比,下面是两个 case:
 
  

 

  从上图可以看出,不同的 case,python 比 C++ 慢了几倍到几十倍。

  
  python 运算效率低,具体是什么原因呢,下列罗列一些
 
  第一:python 是动态语言
  一个变量所指向对象的类型在运行时才确定,编译器做不了任何预测,也就无从优化。举一个简单的例子: r = a + b。 a 和 b 相加,但 a 和 b 的类型在运行时才知道,对于加法操作,不同的类型有不同的处理,所以每次运行的时候都会去判断 a 和 b 的类型,然后执行对应的操作。而在静态语言如 C++ 中,编译的时候就确定了运行时的代码。
  另外一个例子是属性查找,关于具体的查找顺序在《python 属性查找》中有详细介绍。简而言之,访问对象的某个属性是一个非常复杂的过程,而且通过同一个变量访问到的 python 对象还都可能不一样(参见 Lazy property 的例子)。而在 C 语言中,访问属性用对象的地址加上属性的偏移就可以了。
 
  第二:python 是解释执行,但是不支持 JIT(just in time compiler)。虽然大名鼎鼎的 google 曾经尝试Unladen Swallow 这个项目,但最终也折了。
 
  第三:python 中一切都是对象,每个对象都需要维护引用计数,增加了额外的工作。
 
  第四:python GIL
  GIL 是 Python 最为诟病的一点,因为 GIL,python 中的多线程并不能真正的并发。如果是在 IO bound 的业务场景,这个问题并不大,但是在 CPU BOUND 的场景,这就很致命了。所以笔者在工作中使用 python 多线程的情况并不多,一般都是使用多进程(pre fork),或者在加上协程。即使在单线程,GIL 也会带来很大的性能影响,因为 python 每执行100 个 opcode(默认,可以通过 sys.setcheckinterval() 设置)就会尝试线程的切换,具体的源代码在ceval.c::PyEval_EvalFrameEx。
 
  第五:垃圾回收,这个可能是所有具有垃圾回收的编程语言的通病。python 采用标记和分代的垃圾回收策略,每次垃圾回收的时候都会中断正在执行的程序(stop the world),造成所谓的顿卡。infoq上有一篇文章,提到禁用 Python 的 GC 机制后,Instagram 性能提升了 10%。感兴趣的读者可以去细读。

Be pythonic

回到顶部
  我们都知道 过早的优化是罪恶之源,一切优化都需要基于 profile。但是,作为一个 python 开发者应该要 pythonic,而且 pythonic 的代码往往比 non-pythonic 的代码效率高一些,比如:
  • 使用迭代器 iterator,for example:
      dict 的 iteritems 而不是 items(同 itervalues,iterkeys)
      使用 generator,特别是在循环中可能提前 break 的情况
  • 判断是否是同一个对象使用 is 而不是 ==
  • 判断一个对象是否在一个集合中,使用 set 而不是 list
  • 利用短路求值特性,把“短路”概率过的逻辑表达式写在前面。其他的lazy ideas也是可以的
  • 对于大量字符串的累加,使用 join 操作
  • 使用 for else(while else)语法
  • 交换两个变量的值使用: a, b = b, a
 

基于 profile 的优化

回到顶部
  即使我们的代码已经非常 pythonic 了,但可能运行效率还是不能满足预期。我们也知道80/20 定律,绝大多数的时间都耗费在少量的代码片段里面了,优化的关键在于找出这些瓶颈代码。方式很多:到处加 log 打印时间戳、或者将怀疑的函数使用 timeit 进行单独测试,但最有效的是使用 profile 工具。
 

python profilers

  对于 python 程序,比较出名的 profile 工具有三个:profile、cprofile 和 hotshot。其中 profile 是纯 python 语言实现的,Cprofile 将 profile 的部分实现 native 化,hotshot 也是 C 语言实现,hotshot 与 Cprofile 的区别在于:hotshot 对目标代码的运行影响较小,代价是更多的后处理时间,而且 hotshot 已经停止维护了。需要注意的是profile(Cprofile hotshot)只适合单线程的 python 程序
  对于多线程,可以使用yappi,yappi 不仅支持多线程,还可以精确到 CPU 时间
  对于协程(greenlet),可以使用greenletprofiler,基于 yappi 修改,用 greenlet context hook 住 thread context
 
  下面给出一段编造的”效率低下“的代码,并使用 Cprofile 来说明 profile 的具体方法以及我们可能遇到的性能瓶颈。
  
 1 # -*- coding: UTF-8 -*-
 2 
 3 from cProfile import Profile
 4 import math
 5 def foo():
 6     return foo1() 
 7 
 8 def foo1():
 9     return foo2()
10 
11 def foo2():
12     return foo3()
13 
14 def foo3():
15     return foo4()
16 
17 def foo4():
18     return "this call tree seems ugly, but it always happen"
19 
20 def bar():
21     ret = 0
22     for i in xrange(10000):
23         ret += i * i + math.sqrt(i)
24     return ret
25 
26 def main():
27     for i in range(100000):
28         if i % 10000 == 0:
29             bar()
30         else:
31             foo()
32 
33 if __name__ == '__main__':
34     prof = Profile()
35     prof.runcall(main)
36     prof.print_stats()
37     #prof.dump_stats('test.prof') # dump profile result to test.prof
code for profile

 

   运行结果如下:

  

  对于上面的的输出,每一个字段意义如下:

  ncalls 函数总的调用次数
  tottime 函数内部(不包括子函数)的占用时间
  percall(第一个) tottime/ncalls
  cumtime 函数包括子函数所占用的时间
  percall(第二个)cumtime/ncalls
    filename:lineno(function)  文件:行号(函数)
  
  代码中的输出非常简单,事实上可以利用 pstat,让 profile 结果的输出多样化,具体可以参见官方文档python profiler
   

profile GUI tools

  虽然 Cprofile 的输出已经比较直观,但我们还是倾向于保存 profile 的结果,然后用图形化的工具来从不同的维度来分析,或者比较优化前后的代码。查看 profile 结果的工具也比较多,比如,visualpytuneqcachegrindrunsnakerun,本文用 visualpytune 做分析。对于上面的代码,按照注释生成修改后重新运行生成 test.prof 文件,用 visualpytune 直接打开就可以了,如下:
 
  字段的意义与文本输出基本一致,不过便捷性可以点击字段名排序。左下方列出了当前函数的 calller(调用者),右下方是当前函数内部与子函数的时间占用情况。上如是按照 cumtime(即该函数内部及其子函数所占的时间和)排序的结果。
  造成性能瓶颈的原因通常是高频调用的函数、单次消耗非常高的函数、或者二者的结合。在我们前面的例子中,foo 就属于高频调用的情况,bar 属于单次消耗非常高的情况,这都是我们需要优化的重点。
  python-profiling-tools中介绍了 qcachegrind 和 runsnakerun 的使用方法,这两个 colorful 的工具比 visualpytune 强大得多。具体的使用方法请参考原文,下图给出 test.prof 用 qcachegrind 打开的结果

 

  qcachegrind 确实要比 visualpytune 强大。从上图可以看到,大致分为三部:。第一部分同 visualpytune 类似,是每个函数占用的时间,其中 Incl 等同于 cumtime, Self 等同于 tottime。第二部分和第三部分都有很多标签,不同的标签标示从不同的角度来看结果,如图上所以,第三部分的“call graph”展示了该函数的 call tree 并包含每个子函数的时间百分比,一目了然。

 

profile 针对优化

  知道了热点,就可以进行针对性的优化,而这个优化往往根具体的业务密切相关,没用万能钥匙,具体问题,具体分析。个人经验而言,最有效的优化是找产品经理讨论需求,可能换一种方式也能满足需求,少者稍微折衷一下产品经理也能接受。次之是修改代码的实现,比如之前使用了一个比较通俗易懂但效率较低的算法,如果这个算法成为了性能瓶颈,那就考虑换一种效率更高但是可能难理解的算法、或者使用dirty Flag模式。对于这些同样的方法,需要结合具体的案例,本文不做赘述。
  接下来结合 python 语言特性,介绍一些让 python 代码不那么 pythonic,但可以提升性能的一些做法
第一:减少函数的调用层次
    每一层函数调用都会带来不小的开销,特别对于调用频率高,但单次消耗较小的 calltree,多层的函数调用开销就很大,这个时候可以考虑将其展开。
  对于之前调到的 profile 的代码,foo 这个 call tree 非常简单,但频率高。修改代码,增加一个 plain_foo() 函数, 直接返回最终结果,关键输出如下:
  

  跟之前的结果对比:

  

  可以看到,优化了差不多 3 倍。

 

第二:优化属性查找
    上面提到,python 的属性查找效率很低,如果在一段代码中频繁访问一个属性(比如 for 循环),那么可以考虑用局部变量代替对象的属性。
 
第三:关闭 GC
  在本文的第一章节已经提到,关闭 GC 可以提升 python 的性能,GC 带来的顿卡在实时性要求比较高的应用场景也是难以接受的。但关闭 GC 并不是一件容易的事情。我们知道 python 的引用计数只能应付没有循环引用的情况,有了循环引用就需要靠 GC 来处理。在 python 语言中, 写出循环引用非常容易。比如:
  case 1:
  a, b = SomeClass(), SomeClass()
  a.b, b.a = b, a
   
  case 2:
  lst = []
  lst.append(lst)
 
  case 3:
  self.handler = self.some_func
  当然,大家可能说,谁会这么傻,写出这样的代码,是的,上面的代码太明显,当中间多几个层级之后,就会出现“间接”的循环应用。在 python 的标准库 collections 里面的 OrderedDict 就是 case2:
  

  要解决循环引用,第一个办法是使用弱引用(weakref),第二个是手动解循环引用。

  
第四:setcheckinterval
  如果程序确定是单线程,那么修改 checkinterval 为一个更大的值,这里有介绍。
 
第五:使用 __slots__
  slots 最主要的目的是用来节省内存,但是也能一定程度上提高性能。我们知道定义了 __slots__ 的类,对某一个实例都会预留足够的空间,也就不会再自动创建 __dict__。当然,使用 __slots__ 也有许多注意事项,最重要的一点,继承链上的所有类都必须定义 __slots__,python doc 有详细的描述。下面看一个简单的测试例子:
    
 1 class BaseSlots(object):
 2     __slots__ = ['e', 'f', 'g']
 3 
 4 class Slots(BaseSlots):
 5     __slots__ = ['a', 'b', 'c', 'd']
 6     def __init__(self):
 7         self.a = self.b = self.c = self.d = self.e = self.f  = self.g = 0
 8 
 9 class BaseNoSlots(object):
10         pass
11 
12 class NoSlots(BaseNoSlots):
13     def __init__(self):
14         super(NoSlots,self).__init__()
15         self.a = self.b = self.c = self.d = self.e = self.f  = self.g = 0
16 
17 def log_time(s):
18     begin = time.time()
19     for i in xrange(10000000):
20         s.a,s.b,s.c,s.d, s.e, s.f, s.g
21     return time.time() - begin
22 
23 if __name__ == '__main__':
24     print 'Slots cost', log_time(Slots())
25     print 'NoSlots cost', log_time(NoSlots())

  输出结果:

Slots cost 3.12999987602
NoSlots cost 3.48100018501

python C 扩展

回到顶部
  也许通过 profile,我们已经找到了性能热点,但这个热点就是要运行大量的计算,而且没法 cache,没法省略。。。这个时候就该 python 的 C 扩展出马了,C 扩展就是把部分 python 代码用 C 或者 C++ 重新实现,然后编译成动态链接库,提供接口给其它 python 代码调用。由于 C 语言的效率远远高于 python 代码,所以使用 C 扩展是非常普遍的做法,比如我们前面提到的 cProfile 就是基于 _lsprof.so 的一层封装。python 的大所属对性能有要求的库都使用或者提供了 C 扩展,如 gevent、protobuff、bson。
  笔者曾经测试过纯 python 版本的 bson 和 cbson 的效率,在综合的情况下,cbson 快了差不多 10 倍!
  python 的 C 扩展也是一个非常复杂的问题,本文仅给出一些注意事项:
第一:注意引用计数的正确管理
  这是最难最复杂的一点。我们都知道 python 基于指针技术来管理对象的生命周期,如果在扩展中引用计数出了问题,那么要么是程序崩溃,要么是内存泄漏。更要命的是,引用计数导致的问题很难 debug。。。
  C 扩展中关于引用计数最关键的三个词是:steal reference,borrowed reference,new reference。建议编写扩展代码之前细读 python 的官方文档
 
第二:C 扩展与多线程
  这里的多线程是指在扩展中 new 出来的 C 语言线程,而不是 python 的多线程,出了 python doc 里面的介绍,也可以看看《python cookbook》的相关章节。
 
第三:C 扩展应用场景
  仅适合与业务代码的关系不那么紧密的逻辑,如果一段代码大量业务相关的对象 属性的话,是很难 C 扩展的
 
  将 C 扩展封装成 python 代码可调用的接口的过程称之为 binding,Cpython 本身就提供了一套原生的 API,虽然使用最为广泛,但该规范比较复杂。很多第三方库做了不同程度的封装,以便开发者使用,比如boost.python、cython、ctypes、cffi(同时支持 pypy cpython),具体怎么使用可以 google。
 

beyond CPython

回到顶部
  尽管 python 的性能差强人意,但是其易学易用的特性还是赢得越来越多的使用者,业界大牛也从来没有放弃对 python 的优化。这里的优化是对 python 语言设计上、或者实现上的一些反思或者增强。这些优化项目一些已经夭折,一些还在进一步改善中,在这个章节介绍目前还不错的一些项目。

cython

  前面提到cython可以用到 binding c 扩展,但是其作用远远不止这一点。
  Cython 的主要目的是加速 python 的运行效率,但是又不像上一章节提到的 C 扩展那么复杂。在 Cython 中,写 C 扩展和写 python 代码的复杂度差不多(多亏了Pyrex)。Cython 是 python 语言的超集,增加了对 C 语言函数调用和类型声明的支持。从这个角度来看,cython 将动态的 python 代码转换成静态编译的 C 代码,这也是 cython 高效的原因。使用 cython 同 C 扩展一样,需要编译成动态链接库,在 linux 环境下既可以用命令行,也可以用 distutils。
 
  如果想要系统学习 cython,建议从cython document入手,文档写得很好。下面通过一个简单的示例来展示 cython 的使用方法和性能(linux 环境)。
  首先,安装 cython:
  pip install Cython
  下面是测试用的 python 代码,可以看到这两个 case 都是运算复杂度比较高的例子:
# -*- coding: UTF-8 -*-
def f(x):
    return x**2-x

def integrate_f(a, b, N):
s
= 0
dx
= (b-a)/N
for i in range(N):
s
+= f(a+i*dx)
return s * dx

def main():
import time
begin
= time.time()
for i in xrange(10000):
for i in xrange(100):f(10)
print 'call f cost:', time.time() - begin
begin
= time.time()
for i in xrange(10000):
integrate_f(
1.0, 100.0, 1000)
print 'call integrate_f cost:', time.time() - begin

if name == 'main':
main()

  运行结果:
  call f cost: 0.215116024017
  call integrate_f cost: 4.33698010445
 
  不改动任何 python 代码也可以享受到 cython 带来的性能提升,具体做法如下:
  • step1:将文件名(cython_example.py)改为 cython_example.pyx
  • step2:增加一个 setup.py 文件,添加一下代码:
  • 1 from distutils.core import setup
    2 from Cython.Build import cythonize
    3 
    4 setup(
    5   name = 'cython_example',
    6   ext_modules = cythonize("cython_example.pyx"),
    7 )
  • step3:执行python setup.py build_ext --inplace
    
    可以看到 增加了两个文件,对应中间结果和最后的动态链接库
  • step4:执行命令 python -c "import cython_example;cython_example.main()"(注意: 保证当前环境下已经没有 cython_example.py)
  运行结果:
  call f cost: 0.0874309539795
  call integrate_f cost: 2.92381191254
 
  性能提升了大概两倍,我们再来试试 cython 提供的静态类型(static typing),修改 cython_example.pyx 的核心代码,替换 f()和 integrate_f() 的实现如下:
 
 1 def f(double x): # 参数静态类型
 2     return x**2-x
 3 
 4 def integrate_f(double a, double b, int N):
 5     cdef int i
 6     cdef double s, dx
 7     s = 0
 8     dx = (b-a)/N
 9     for i in range(N):
10         s += f(a+i*dx)
11     return s * dx

   然后重新运行上面的第三 四步:结果如下

  call f cost: 0.042387008667
  call integrate_f cost: 0.958620071411
 
  上面的代码,只是对参数引入了静态类型判断,下面对返回值也引入静态类型判断。
  替换 f()和 integrate_f() 的实现如下:
   
 1 cdef double f(double x): # 返回值也有类型判断
 2     return x**2-x
 3 
 4 cdef double integrate_f(double a, double b, int N):
 5     cdef int i
 6     cdef double s, dx
 7     s = 0
 8     dx = (b-a)/N
 9     for i in range(N):
10         s += f(a+i*dx)
11     return s * dx

 

  然后重新运行上面的第三 四步:结果如下
  call f cost: 1.19209289551e-06
  call integrate_f cost: 0.187038183212
 
  Amazing!
 

pypy

  pypy是 CPython 的一个替代实现,其最主要的优势就是 pypy 的速度,下面是官网的测试结果:
  

  在实际项目中测试,pypy 大概比 cpython 要快 3 到 5 倍!pypy 的性能提升来自 JIT Compiler。在前文提到 google 的Unladen Swallow 项目也是想在 CPython 中引入 JIT,在这个项目失败后,很多开发人员都开始加入 pypy 的开发和优化。另外 pypy 占用的内存更少,而且支持 stackless,基本等同于协程。

  pypy 的缺点在于对 C 扩展方面支持的不太好,需要使用 CFFi 来做 binding。对于使用广泛的 library 来说,一般都会支持 pypy,但是小众的、或者自行开发的 C 扩展就需要重新封装了。
 

ChangeLog

回到顶部
2017.03.10 增加了对 __slots__ 的介绍

references

回到顶部
编程语言 benchmark
python 属性查找
python profiler
yappi
greenletprofiler
python-profiling-tools
python C API
cython
Pyrex
cython document
pypy