2010年1月5日 星期二

Python 加速的方法

這一年來用 Python 開發滿爽的,用久後發覺 Python 真的是不夠快啊。雖說這就是 trade-off,拿 CPU 執行時間換工程師開發的時間,但關鍵時刻跑得不夠快,還是得投入時間研究如何加速。程式開發就是一連串的 trade-off,人生也是如此啊!…………………還是少嘴炮,切入正題吧。

用 Profiling 測全體執行時間

去除演算法、資料結構的問題外 (像是 O(N log(N)) vs. O(N^2) 的方法 ),千萬別自己亂猜效能瓶頸就一頭熱地改程式。隨便想想都能舉出一堆自以為聰明的反例。我印象最深的經驗是,有一次我在寫資料結構作業答案,題目是實作 hash table 來統計文章裡單字出現的次數,身為課程助教,程式當然不能跑得太慢啊。所以調了一下效能,結果發覺改了一些演算法 (如 hash table 的 「chain」用 binary tree 而不用 linked-list ),卻沒快多少 (變慢是常有的事),一用 profiler 量,卻發現 1/3 的時間花在把一行文字切成單字上。我原本用 Java 的 split 一次切完回傳一個 List,改用 StringTokenizer 後就省下整體 1/3 的時間。

Python 有內建 profiling 的工具,叫做 profile,可以透過命令列直接下參數使用:

1
python -m cProfile -s time MODULE.py ARGUMENTS

這樣會執行 MODULE 再將測量結果輸出到螢幕,時間依單一函式執行時間來排序。引用官網的例子,cProfile 的輸出格式如下:

1
2
3
4
5
6
7
8
     2706 function calls (2004 primitive calls) in 4.504 CPU seconds
 
Ordered by: standard name
 
ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    2    0.006    0.003    0.953    0.477 pobject.py:75(save_objects)
 43/3    0.533    0.012    0.749    0.250 pobject.py:99(evaluate)
 ...

前面的 tottime 是該函式自己執行的時間;後面的 cumtime 則是該函式整體 (包含它呼叫其它函式) 的執行時間。

附帶一提,以前試 Java profiling 工具時,執行速度會慢個數倍,但用 Python 的 cProfile 卻只變成一倍多慢而已,也可能我那時用的 Java 工具太糟,或參數沒設好吧。

用 timeit 來量單一 expression 的速度

timeit 也是內建模組,也可以用命令列執行:

1
python -m timeit EXPRESSION

下面是執行的例子:

1
2
3
4
5
$ python -m  timeit 'range(10)'
1000000 loops, best of 3: 0.3 usec per loop
 
$ python -m  timeit 'xrange(10)'
10000000 loops, best of 3: 0.166 usec per loop

我比較常寫程式來測 timeit,透過命令列執行 expression 相當受限,還是自己寫段程式包成函式後,再用 timeit 執行較方便。附上一個例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#!/usr/bin/env python
# Count numbers in a list.
# Demonstrate built-in functions' efficiency.
 
import timeit
import random
 
# prepare test data
numbers = range(2)
a = []
for n in numbers:
   a += [n] * 10000
random.shuffle(a)
 
def test():
   c = {}
   for x in a:
       c[x] = c.get(x, 0) + 1
 
def test2():
   c = {}
   for n in numbers:
       c[n] = 0
   for x in a:
       c[x] += 1
 
def test3():
   c = {}
   for n in numbers:
       c[n] = a.count(n)
 
 
if __name__=='__main__':
   t = timeit.Timer("test()", "from __main__ import test")
   print 'test', t.timeit(number=1000)
 
   t = timeit.Timer("test2()", "from __main__ import test2")
   print 'test2', t.timeit(number=1000)
 
   t = timeit.Timer("test3()", "from __main__ import test3")
   print 'test3', t.timeit(number=1000)

有興趣的人可以猜猜看那個方法最快 [1]。

找出瓶頸後加速的方法

用 profiling 找出瓶頸後,大概有幾種作法:

  • multiprocessing 平行化:這年頭 CPU 不用錢,隨便買都是 multi-core。若程式可以單純平行化,這個作法比用 C/C++ 方便一些,至少還是在寫 python code。但若程式稍微複雜,寫起來會很辛苦,且容易因 fork 或 lock 的 overhead 而沒快多少。
  • 將 CPU bound 的地方透過 ctypes 改寫成 C/C++:雖說 ctypes 很好用,不過寫 C/C++ 得注意的事比寫 Python 多太多了,所以我滿少這麼幹的。
  • 從 Python interpreter 下手,改用較快的寫法:這個作法需要一些 Python 運作的知識,才知道怎麼改會比較快。比方若不需用到整個 list 可以改用 generator 減少產生 list 的時間、使用 member field 存取資料以減少 function call、改用 built-in 函式組出要做的事 (如上面附的例子)。這時 timeit 就很好用了,可以先測好關鍵部份,減少重跑整個 profiling 的時間。

不勞而獲的加速方法

我只有偶而注意相關消息,還沒試過,只知道各家方法有些不適用的問題,這裡記些關鍵字。

Psyco 是老字號工具,大家都說讚,對數值運算特別有效,但它只支援 32-bit CPU,作者目前在開發 PyPy。另外 Google 開發的 unladen-swallow 也令人期待。也許在不久的將來,兩邊都會有足以實用的成果吧。

延伸閱讀

Victor 告知,Python 官方 Wiki 有幾篇超棒的文件,相當值得一看:

備註

  1. 這是我執行出來的數據:
    1
    2
    3
    
    test 5.41639614105
    test2 2.70715403557
    test3 0.619280815125

    即使走訪 list 兩次,方法三還是比前兩個方法 (只走訪一次) 快上不少,這是因為方法三用 C 實作的程式走訪迴圈,而前兩個方法用到 Python 的迴圈。

沒有留言:

張貼留言