Python性能優(yōu)化
正文
注意:本文除非特殊指明,”python“都是代表CPython,即C語言實現(xiàn)的標準python,且本文所討論的是版本為2.7的CPython。
python為什么性能差:
當我們提到一門編程語言的效率時:通常有兩層意思,***是開發(fā)效率,這是對程序員而言,完成編碼所需要的時間;另一個是運行效率,這是對計算機而言,完成計算任務所需要的時間。編碼效率和運行效率往往是魚與熊掌的關系,是很難同時兼顧的。不同的語言會有不同的側重,python語言毫無疑問更在乎編碼效率,life is short,we use python。
雖然使用python的編程人員都應該接受其運行效率低的事實,但python在越多越來的領域都有廣泛應用,比如科學計算 、web服務器等。程序員當然也希望python能夠運算得更快,希望python可以更強大。
首先,python相比其他語言具體有多慢,這個不同場景和測試用例,結果肯定是不一樣的。這個網(wǎng)址給出了不同語言在各種case下的性能對比,這一頁是python3和C++的對比,下面是兩個case:
python運算效率低,具體是什么原因呢,下列羅列一些
***:python是動態(tài)語言
一個變量所指向對象的類型在運行時才確定,編譯器做不了任何預測,也就無從優(yōu)化。舉一個簡單的例子: r = a + b?!和b相加,但a和b的類型在運行時才知道,對于加法操作,不同的類型有不同的處理,所以每次運行的時候都會去判斷a和b的類型,然后執(zhí)行對應的操作。而在靜態(tài)語言如C++中,編譯的時候就確定了運行時的代碼。
另外一個例子是屬性查找,關于具體的查找順序在《python屬性查找》中有詳細介紹。簡而言之,訪問對象的某個屬性是一個非常復雜的過程,而且通過同一個變量訪問到的python對象還都可能不一樣(參見Lazy property的例子)。而在C語言中,訪問屬性用對象的地址加上屬性的偏移就可以了。
第二:python是解釋執(zhí)行,但是不支持JIT(just in time compiler)。雖然大名鼎鼎的google曾經嘗試Unladen Swallow 這個項目,但最終也折了。
第三:python中一切都是對象,每個對象都需要維護引用計數(shù),增加了額外的工作。
第四:python GIL
GIL是Python最為詬病的一點,因為GIL,python中的多線程并不能真正的并發(fā)。如果是在IO bound的業(yè)務場景,這個問題并不大,但是在CPU BOUND的場景,這就很致命了。所以筆者在工作中使用python多線程的情況并不多,一般都是使用多進程(pre fork),或者在加上協(xié)程。即使在單線程,GIL也會帶來很大的性能影響,因為python每執(zhí)行100個opcode(默認,可以通過sys.setcheckinterval()設置)就會嘗試線程的切換,具體的源代碼在ceval.c::PyEval_EvalFrameEx。
第五:垃圾回收,這個可能是所有具有垃圾回收的編程語言的通病。python采用標記和分代的垃圾回收策略,每次垃圾回收的時候都會中斷正在執(zhí)行的程序,造成所謂的頓卡。infoq上有一篇文章,提到禁用Python的GC機制后,Instagram性能提升了10%。感興趣的讀者可以去細讀。
Be pythonic
我們都知道 過早的優(yōu)化是罪惡之源,一切優(yōu)化都需要基于profile。但是,作為一個python開發(fā)者應該要pythonic,而且pythonic的代碼往往比non-pythonic的代碼效率高一些,比如:
- 使用迭代器iterator,for example:
dict的iteritems 而不是items(同itervalues,iterkeys)
使用generator,特別是在循環(huán)中可能提前break的情況
- 判斷是否是同一個對象使用 is 而不是 ==
- 判斷一個對象是否在一個集合中,使用set而不是list
- 利用短路求值特性,把“短路”概率過的邏輯表達式寫在前面。其他的lazy ideas也是可以的
- 對于大量字符串的累加,使用join操作
- 使用for else(while else)語法
- 交換兩個變量的值使用: a, b = b, a
基于profile的優(yōu)化
即使我們的代碼已經非常pythonic了,但可能運行效率還是不能滿足預期。我們也知道80/20定律,絕大多數(shù)的時間都耗費在少量的代碼片段里面了,優(yōu)化的關鍵在于找出這些瓶頸代碼。方式很多:到處加log打印時間戳、或者將懷疑的函數(shù)使用timeit進行單獨測試,但最有效的是使用profile工具。
python profilers
對于python程序,比較出名的profile工具有三個:profile、cprofile和hotshot。其中profile是純python語言實現(xiàn)的,Cprofile將profile的部分實現(xiàn)native化,hotshot也是C語言實現(xiàn),hotshot與Cprofile的區(qū)別在于:hotshot對目標代碼的運行影響較小,代價是更多的后處理時間,而且hotshot已經停止維護了。需要注意的是,profile(Cprofile hotshot)只適合單線程的python程序。
對于多線程,可以使用yappi,yappi不僅支持多線程,還可以精確到CPU時間
對于協(xié)程(greenlet),可以使用greenletprofiler,基于yappi修改,用greenlet context hook住thread context
下面給出一段編造的”效率低下“的代碼,并使用Cprofile來說明profile的具體方法以及我們可能遇到的性能瓶頸。
- # -*- coding: UTF-8 -*-
- from cProfile import Profile
- import math
- def foo():
- return foo1()
- def foo1():
- return foo2()
- def foo2():
- return foo3()
- def foo3():
- return foo4()
- def foo4():
- return "this call tree seems ugly, but it always happen"
- def bar():
- ret = 0
- for i in xrange(10000):
- ret += i * i + math.sqrt(i)
- return ret
- def main():
- for i in range(100000):
- if i % 10000 == 0:
- bar()
- else:
- foo()
- if __name__ == '__main__':
- prof = Profile()
- prof.runcall(main)
- prof.print_stats()
- #prof.dump_stats('test.prof') # dump profile result to test.prof
- code for profile
運行結果如下:
對于上面的的輸出,每一個字段意義如下:
ncalls 函數(shù)總的調用次數(shù)
tottime 函數(shù)內部(不包括子函數(shù))的占用時間
percall(***個) tottime/ncalls
cumtime 函數(shù)包括子函數(shù)所占用的時間
percall(第二個)cumtime/ncalls
filename:lineno(function) 文件:行號(函數(shù))
代碼中的輸出非常簡單,事實上可以利用pstat,讓profile結果的輸出多樣化,具體可以參見官方文檔python profiler。
profile GUI tools
雖然Cprofile的輸出已經比較直觀,但我們還是傾向于保存profile的結果,然后用圖形化的工具來從不同的維度來分析,或者比較優(yōu)化前后的代碼。查看profile結果的工具也比較多,比如,visualpytune、qcachegrind、runsnakerun,本文用visualpytune做分析。對于上面的代碼,按照注釋生成修改后重新運行生成test.prof文件,用visualpytune直接打開就可以了,如下:
字段的意義與文本輸出基本一致,不過便捷性可以點擊字段名排序。左下方列出了當前函數(shù)的calller(調用者),右下方是當前函數(shù)內部與子函數(shù)的時間占用情況。上如是按照cumtime(即該函數(shù)內部及其子函數(shù)所占的時間和)排序的結果。
造成性能瓶頸的原因通常是高頻調用的函數(shù)、單次消耗非常高的函數(shù)、或者二者的結合。在我們前面的例子中,foo就屬于高頻調用的情況,bar屬于單次消耗非常高的情況,這都是我們需要優(yōu)化的重點。
python-profiling-tools中介紹了qcachegrind和runsnakerun的使用方法,這兩個colorful的工具比visualpytune強大得多。具體的使用方法請參考原文,下圖給出test.prof用qcachegrind打開的結果。
qcachegrind確實要比visualpytune強大。從上圖可以看到,大致分為三部:。***部分同visualpytune類似,是每個函數(shù)占用的時間,其中Incl等同于cumtime, Self等同于tottime。第二部分和第三部分都有很多標簽,不同的標簽標示從不同的角度來看結果,如圖上所以,第三部分的“call graph”展示了該函數(shù)的call tree并包含每個子函數(shù)的時間百分比,一目了然。
profile針對優(yōu)化
知道了熱點,就可以進行針對性的優(yōu)化,而這個優(yōu)化往往根具體的業(yè)務密切相關,沒用***鑰匙,具體問題,具體分析。個人經驗而言,最有效的優(yōu)化是找產品經理討論需求,可能換一種方式也能滿足需求,少者稍微折衷一下產品經理也能接受。次之是修改代碼的實現(xiàn),比如之前使用了一個比較通俗易懂但效率較低的算法,如果這個算法成為了性能瓶頸,那就考慮換一種效率更高但是可能難理解的算法、或者使用dirty Flag模式。對于這些同樣的方法,需要結合具體的案例,本文不做贅述。
接下來結合python語言特性,介紹一些讓python代碼不那么pythonic,但可以提升性能的一些做法
***:減少函數(shù)的調用層次
每一層函數(shù)調用都會帶來不小的開銷,特別對于調用頻率高,但單次消耗較小的calltree,多層的函數(shù)調用開銷就很大,這個時候可以考慮將其展開。
對于之前調到的profile的代碼,foo這個call tree非常簡單,但頻率高。修改代碼,增加一個plain_foo()函數(shù), 直接返回最終結果,關鍵輸出如下:
跟之前的結果對比:
可以看到,優(yōu)化了差不多3倍。
第二:優(yōu)化屬性查找
上面提到,python 的屬性查找效率很低,如果在一段代碼中頻繁訪問一個屬性(比如for循環(huán)),那么可以考慮用局部變量代替對象的屬性。
第三:關閉GC
在本文的***章節(jié)已經提到,關閉GC可以提升python的性能,GC帶來的頓卡在實時性要求比較高的應用場景也是難以接受的。但關閉GC并不是一件容易的事情。我們知道python的引用計數(shù)只能應付沒有循環(huán)引用的情況,有了循環(huán)引用就需要靠GC來處理。在python語言中, 寫出循環(huán)引用非常容易。比如:
- 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
當然,大家可能說,誰會這么傻,寫出這樣的代碼,是的,上面的代碼太明顯,當中間多幾個層級之后,就會出現(xiàn)“間接”的循環(huán)應用。在python的標準庫 collections里面的OrderedDict就是case2:
要解決循環(huán)引用,***個辦法是使用弱引用(weakref),第二個是手動解循環(huán)引用。
第四:setcheckinterval
如果程序確定是單線程,那么修改checkinterval為一個更大的值,這里有介紹。
第五:使用__slots__
slots最主要的目的是用來節(jié)省內存,但是也能一定程度上提高性能。我們知道定義了__slots__的類,對某一個實例都會預留足夠的空間,也就不會再自動創(chuàng)建__dict__。當然,使用__slots__也有許多注意事項,最重要的一點,繼承鏈上的所有類都必須定義__slots__,python doc有詳細的描述。下面看一個簡單的測試例子:
- class BaseSlots(object):
- __slots__ = ['e', 'f', 'g']
- class Slots(BaseSlots):
- __slots__ = ['a', 'b', 'c', 'd']
- def __init__(self):
- self.a = self.b = self.c = self.d = self.e = self.f = self.g = 0
- class BaseNoSlots(object):
- pass
- class NoSlots(BaseNoSlots):
- def __init__(self):
- super(NoSlots,self).__init__()
- self.a = self.b = self.c = self.d = self.e = self.f = self.g = 0
- def log_time(s):
- begin = time.time()
- for i in xrange(10000000):
- s.a,s.b,s.c,s.d, s.e, s.f, s.g
- return time.time() - begin
- if __name__ == '__main__':
- print 'Slots cost', log_time(Slots())
- print 'NoSlots cost', log_time(NoSlots())
輸出結果:
- Slots cost 3.12999987602
- NoSlots cost 3.48100018501
python C擴展
也許通過profile,我們已經找到了性能熱點,但這個熱點就是要運行大量的計算,而且沒法cache,沒法省略。。。這個時候就該python的C擴展出馬了,C擴展就是把部分python代碼用C或者C++重新實現(xiàn),然后編譯成動態(tài)鏈接庫,提供接口給其它python代碼調用。由于C語言的效率遠遠高于python代碼,所以使用C擴展是非常普遍的做法,比如我們前面提到的cProfile就是基于_lsprof.so的一層封裝。python的大所屬對性能有要求的庫都使用或者提供了C擴展,如gevent、protobuff、bson。
筆者曾經測試過純python版本的bson和cbson的效率,在綜合的情況下,cbson快了差不多10倍!
python的C擴展也是一個非常復雜的問題,本文僅給出一些注意事項:
***:注意引用計數(shù)的正確管理
這是最難最復雜的一點。我們都知道python基于指針技術來管理對象的生命周期,如果在擴展中引用計數(shù)出了問題,那么要么是程序崩潰,要么是內存泄漏。更要命的是,引用計數(shù)導致的問題很難debug。。。
C擴展中關于引用計數(shù)最關鍵的三個詞是:steal reference,borrowed reference,new reference。建議編寫擴展代碼之前細讀python的官方文檔。
第二:C擴展與多線程
這里的多線程是指在擴展中new出來的C語言線程,而不是python的多線程,出了python doc里面的介紹,也可以看看《python cookbook》的相關章節(jié)。
第三:C擴展應用場景
僅適合與業(yè)務代碼的關系不那么緊密的邏輯,如果一段代碼大量業(yè)務相關的對象 屬性的話,是很難C擴展的
將C擴展封裝成python代碼可調用的接口的過程稱之為binding,Cpython本身就提供了一套原生的API,雖然使用最為廣泛,但該規(guī)范比較復雜。很多第三方庫做了不同程度的封裝,以便開發(fā)者使用,比如boost.python、cython、ctypes、cffi(同時支持pypy cpython),具體怎么使用可以google。
beyond CPython
盡管python的性能差強人意,但是其易學易用的特性還是贏得越來越多的使用者,業(yè)界大牛也從來沒有放棄對python的優(yōu)化。這里的優(yōu)化是對python語言設計上、或者實現(xiàn)上的一些反思或者增強。這些優(yōu)化項目一些已經夭折,一些還在進一步改善中,在這個章節(jié)介紹目前還不錯的一些項目。
cython
前面提到cython可以用到binding c擴展,但是其作用遠遠不止這一點。
Cython的主要目的是加速python的運行效率,但是又不像上一章節(jié)提到的C擴展那么復雜。在Cython中,寫C擴展和寫python代碼的復雜度差不多(多虧了Pyrex)。Cython是python語言的超集,增加了對C語言函數(shù)調用和類型聲明的支持。從這個角度來看,cython將動態(tài)的python代碼轉換成靜態(tài)編譯的C代碼,這也是cython高效的原因。使用cython同C擴展一樣,需要編譯成動態(tài)鏈接庫,在linux環(huán)境下既可以用命令行,也可以用distutils。
如果想要系統(tǒng)學習cython,建議從cython document入手,文檔寫得很好。下面通過一個簡單的示例來展示cython的使用方法和性能(linux環(huán)境)。
首先,安裝cython:
- pip install Cython
下面是測試用的python代碼,可以看到這兩個case都是運算復雜度比較高的例子:
- def f():
- 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文件,添加一下代碼:
- from distutils.core import setup
- from Cython.Build import cythonize
- setup(
- name = 'cython_example',
- ext_modules = cythonize("cython_example.pyx"),
- )
- step3:執(zhí)行python setup.py build_ext –inplace
可以看到 增加了兩個文件,對應中間結果和***的動態(tài)鏈接庫
- step4:執(zhí)行命令 python -c “import cython_example;cython_example.main()”(注意: 保證當前環(huán)境下已經沒有 cython_example.py)
運行結果:
- call f cost: 0.0874309539795
- call integrate_f cost: 2.92381191254
性能提升了大概兩倍,我們再來試試cython提供的靜態(tài)類型(static typing),修改cython_example.pyx的核心代碼,替換f()和integrate_f()的實現(xiàn)如下:
- def f(double x): # 參數(shù)靜態(tài)類型
- return x**2-x
- def integrate_f(double a, double b, int N):
- cdef int i
- cdef double s, dx
- s = 0
- dx = (b-a)/N
- for i in range(N):
- s += f(a+i*dx)
- return s * dx
然后重新運行上面的第三 四步:結果如下
- call f cost: 0.042387008667
- call integrate_f cost: 0.958620071411
上面的代碼,只是對參數(shù)引入了靜態(tài)類型判斷,下面對返回值也引入靜態(tài)類型判斷。
替換f()和integrate_f()的實現(xiàn)如下:
- cdef double f(double x): # 返回值也有類型判斷
- return x**2-x
- cdef double integrate_f(double a, double b, int N):
- cdef int i
- cdef double s, dx
- s = 0
- dx = (b-a)/N
- for i in range(N):
- s += f(a+i*dx)
- return s * dx
然后重新運行上面的第三 四步:結果如下
- call f cost: 1.19209289551e-06
- call integrate_f cost: 0.187038183212
Amazing!
pypy
pypy是CPython的一個替代實現(xiàn),其最主要的優(yōu)勢就是pypy的速度,下面是官網(wǎng)的測試結果:
在實際項目中測試,pypy大概比cpython要快3到5倍!pypy的性能提升來自JIT Compiler。在前文提到google的Unladen Swallow 項目也是想在CPython中引入JIT,在這個項目失敗后,很多開發(fā)人員都開始加入pypy的開發(fā)和優(yōu)化。另外pypy占用的內存更少,而且支持stackless,基本等同于協(xié)程。
pypy的缺點在于對C擴展方面支持的不太好,需要使用CFFi來做binding。對于使用廣泛的library來說,一般都會支持pypy,但是小眾的、或者自行開發(fā)的C擴展就需要重新封裝了。
ChangeLog
2017.03.10 增加了對__slots__的介紹
references
- 編程語言benchmark
- python屬性查找
- python profiler
- yappi
- greenletprofiler
- python-profiling-tools
- python C API
- cython
- Pyrex
- cython document
- pypy