Python 效能優化

發表於2017-11-27

本文除非特殊指明,”python“都是代表CPython,即C語言實現的標準python,且本文所討論的是版本為2.7的CPython。另外,本文會不定期更新,如果大家有一些好的想法,請在評論裡面留言,我會補充到文章中去。

python為什麼效能差:

當我們提到一門程式語言的效率時:通常有兩層意思,第一是開發效率,這是對程式設計師而言,完成編碼所需要的時間;另一個是執行效率,這是對計算機而言,完成計算任務所需要的時間。編碼效率和執行效率往往是魚與熊掌的關係,是很難同時兼顧的。不同的語言會有不同的側重,python語言毫無疑問更在乎編碼效率,life is short,we use python。

雖然使用python的程式設計人員都應該接受其執行效率低的事實,但python在越多越來的領域都有廣泛應用,比如科學計算 、web伺服器等。程式設計師當然也希望python能夠運算得更快,希望python可以更強大。

首先,python相比其他語言具體有多慢,這個不同場景和測試用例,結果肯定是不一樣的。這個網址給出了不同語言在各種case下的效能對比,這一頁是python3和C++的對比,下面是兩個case:

1089769-20170306160930641-357020493

從上圖可以看出,不同的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採用標記和分代的垃圾回收策略,每次垃圾回收的時候都會中斷正在執行的程式,造成所謂的頓卡。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的具體方法以及我們可能遇到的效能瓶頸。

執行結果如下:

1089769-20170306160930641-357020493

對於上面的的輸出,每一個欄位意義如下:

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直接開啟就可以了,如下:

1089769-20170306160930641-357020493
欄位的意義與文字輸出基本一致,不過便捷性可以點選欄位名排序。左下方列出了當前函式的calller(呼叫者),右下方是當前函式內部與子函式的時間佔用情況。上如是按照cumtime(即該函式內部及其子函式所佔的時間和)排序的結果。

造成效能瓶頸的原因通常是高頻呼叫的函式、單次消耗非常高的函式、或者二者的結合。在我們前面的例子中,foo就屬於高頻呼叫的情況,bar屬於單次消耗非常高的情況,這都是我們需要優化的重點。

python-profiling-tools中介紹了qcachegrind和runsnakerun的使用方法,這兩個colorful的工具比visualpytune強大得多。具體的使用方法請參考原文,下圖給出test.prof用qcachegrind開啟的結果

1089769-20170306160930641-357020493

qcachegrind確實要比visualpytune強大。從上圖可以看到,大致分為三部:。第一部分同visualpytune類似,是每個函式佔用的時間,其中Incl等同於cumtime, Self等同於tottime。第二部分和第三部分都有很多標籤,不同的標籤標示從不同的角度來看結果,如圖上所以,第三部分的“call graph”展示了該函式的call tree幷包含每個子函式的時間百分比,一目瞭然。

profile針對優化

知道了熱點,就可以進行鍼對性的優化,而這個優化往往根具體的業務密切相關,沒用萬能鑰匙,具體問題,具體分析。個人經驗而言,最有效的優化是找產品經理討論需求,可能換一種方式也能滿足需求,少者稍微折衷一下產品經理也能接受。次之是修改程式碼的實現,比如之前使用了一個比較通俗易懂但效率較低的演算法,如果這個演算法成為了效能瓶頸,那就考慮換一種效率更高但是可能難理解的演算法、或者使用dirty Flag模式。對於這些同樣的方法,需要結合具體的案例,本文不做贅述。

接下來結合python語言特性,介紹一些讓python程式碼不那麼pythonic,但可以提升效能的一些做法

第一:減少函式的呼叫層次

每一層函式呼叫都會帶來不小的開銷,特別對於呼叫頻率高,但單次消耗較小的calltree,多層的函式呼叫開銷就很大,這個時候可以考慮將其展開。

對於之前調到的profile的程式碼,foo這個call tree非常簡單,但頻率高。修改程式碼,增加一個plain_foo()函式, 直接返回最終結果,關鍵輸出如下:

1089769-20170306160930641-357020493

跟之前的結果對比:

1089769-20170306160930641-357020493

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

第二:優化屬性查詢

上面提到,python 的屬性查詢效率很低,如果在一段程式碼中頻繁訪問一個屬性(比如for迴圈),那麼可以考慮用區域性變數代替物件的屬性。

第三:關閉GC

在本文的第一章節已經提到,關閉GC可以提升python的效能,GC帶來的頓卡在實時性要求比較高的應用場景也是難以接受的。但關閉GC並不是一件容易的事情。我們知道python的引用計數只能應付沒有迴圈引用的情況,有了迴圈引用就需要靠GC來處理。在python語言中, 寫出迴圈引用非常容易。比如:

當然,大家可能說,誰會這麼傻,寫出這樣的程式碼,是的,上面的程式碼太明顯,當中間多幾個層級之後,就會出現“間接”的迴圈應用。在python的標準庫 collections裡面的OrderedDict就是case2:

1089769-20170306160930641-357020493

要解決迴圈引用,第一個辦法是使用弱引用(weakref),第二個是手動解迴圈引用。

第四:setcheckinterval

如果程式確定是單執行緒,那麼修改checkinterval為一個更大的值,這裡有介紹。

第五:使用__slots__

slots最主要的目的是用來節省記憶體,但是也能一定程度上提高效能。我們知道定義了__slots__的類,對某一個例項都會預留足夠的空間,也就不會再自動建立__dict__。當然,使用__slots__也有許多注意事項,最重要的一點,繼承鏈上的所有類都必須定義__slots__,python doc有詳細的描述。下面看一個簡單的測試例子:

輸出結果:

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:

下面是測試用的python程式碼,可以看到這兩個case都是運算複雜度比較高的例子:

執行結果:

不改動任何python程式碼也可以享受到cython帶來的效能提升,具體做法如下:

  • step1:將檔名(cython_example.py)改為cython_example.pyx
  • step2:增加一個setup.py檔案,新增一下程式碼:

  • step3:執行python setup.py build_ext –inplace

1

可以看到 增加了兩個檔案,對應中間結果和最後的動態連結庫

  • step4:執行命令 python -c “import cython_example;cython_example.main()”(注意: 保證當前環境下已經沒有 cython_example.py)

執行結果:

效能提升了大概兩倍,我們再來試試cython提供的靜態型別(static typing),修改cython_example.pyx的核心程式碼,替換f()和integrate_f()的實現如下:

然後重新執行上面的第三 四步:結果如下

上面的程式碼,只是對引數引入了靜態型別判斷,下面對返回值也引入靜態型別判斷。

替換f()和integrate_f()的實現如下:

然後重新執行上面的第三 四步:結果如下

Amazing!

pypy

pypy是CPython的一個替代實現,其最主要的優勢就是pypy的速度,下面是官網的測試結果:

1089769-20170306192225281-264842175

在實際專案中測試,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

相關文章