早上我偶然看見一篇介紹兩個Python腳本的博文,其中一個效率更高。這篇博文已經被刪除,所以我沒辦法給出文章鏈接,但腳本基本可以歸結如下:
fast.py
1
2
3
4
5
6
7
8
|
import time a = [i for i in range ( 1000000 )] sum = 0 t1 = time.time() for i in a: sum = sum + i t2 = time.time() print t2 - t1 |
slow.py
1
2
3
4
5
6
7
8
9
10
|
import time from random import shuffle a = [i for i in range ( 1000000 )] shuffle(a) sum = 0 t1 = time.time() for i in a: sum = sum + i t2 = time.time() print t2 - t1 |
如你所見,兩個腳本有完全相同的行為。都產生一個包含前一百萬個整數的列表,并打印對這些整數求和的時間。唯一的不同是 slow.py 先將整數隨機排序。盡管這看起來有些奇怪,似乎隨機化足夠將程序明顯變慢。在我機器上,運行的Python2.7.3, fast.py 始終比 slow.py 快十分之一秒(fast.py 執行大約耗時四分之三秒,這是不平常的增速)。你不妨也試試看。(我沒有在Python3上測試,但結果應該不會差太多。)
那為什么列表元素隨機化會導致這么明顯的減速呢?博文的原作者把這記作“分支預測(branch prediction)”。如果你對這個術語不熟悉,可以在 StackOverflow 的提問中看看,這里很好地解釋了這個概念。(我的疑慮是原文的原作者遇到了這個問題或者與此類似的問題,并把這個想法應用到不太適合應用的Python片段中。)
當然,我懷疑分支預測(branch prediction)是否是真正導致問題的原因。在這份Python代碼中沒有頂層條件分支,而且合乎情理的是兩個腳本在循環體內有嚴格一致的分支。程序中沒有哪一部分是以這些整數為條件的,并且每個列表的元素都是不依賴于數據本身的。當然,我還是不確定python是否算得上足夠“底層”,以至于CPU級別的分支預測能夠成為python腳本性能分析中的一個因素。Python畢竟是一門高級語言。
因此,如果不是分支預測的原因,那為什么 slow.py 會這么慢?通過一點研究,經過一些“失敗的開端”之后,我覺得自己找到了問題。這個答案需要對Python內部虛擬機有點熟悉。
失敗的開端:列表vs.生成器(lists and generators)
我的第一想法是Python對排序的列表[i for i in range(1000000)] 的處理效率要比隨機列表高。換句話說,這個列表可以用下面的生成器替代:
1
2
3
4
5
|
def numbers(): i = 0 while i < 1000000 : yield i i + = 1 |
我想這可能在時間效率上更高效些。畢竟,如果Python在內部使用生成器替代真正的列表可以避免在內存中一次保存所有整數的麻煩,這可以節省很多開銷。slow.py 中的隨機列表不能輕易的被一個簡單生成器捕獲,所有VM(虛擬機)無法進行這樣的優化。
然而,這不是一個有用的發現。如果在slow.py的 shuffle() 和循環之間插入 a.sort(),程序會像 fast.py一樣快。很明顯,數字排序后的一些細節讓程序更快。
失敗的開端:列表對比數組
我的第二個想法是有可能數據結構造成的緩存問題。a 是一個列表,這自然讓我相信a實際上是通過鏈表來實現的。如果shuffle操作故意隨機化這個鏈表的節點,那么 fast.py 可能可以把列表的所有鏈表元素分配在相鄰地址,從而采用高級局部緩存,而slow.py會出現很多緩存未命中的情況,因為每個節點引用不在同一個緩存行上的另外一個節點。
不幸的是,這也不對。Python的列表對象不是鏈接的列表,而是真正意義上的數組。尤其是用C結構體定義了Python列表對象:
1
2
3
4
5
|
typedef struct { PyObject_VAR_HEAD PyObject * * ob_item; Py_ssize_t allocated; } PyListObject; |
……換句話說,ob_item 是一個指向PyObjects指針數組的指針,并且分配的大小是我們分配給數組的大小。因此,這對于解決這個問題也沒幫助(盡管這對我不確定Python中關于列表操作的算法復雜度有些安慰:列表的添加操作算法復雜度是O(1),訪問任意列表元素的算法復雜度是O(1),等等)。我只是想說明為什么Guido選擇稱它們為列表“lists”而不是數組“arrays”,而實際上它們卻是數組。
解決辦法:整體對象
數組元素在內存中是相鄰的,因此這樣的數據結構不會帶來緩存問題。事實證明緩存位置是 slow.py 變慢的原因,但這來自于一個意料之外的地方。在Python中,整數是分配在堆中的對象而不是一個簡單的值。尤其是在虛擬機中,整數對象看起來像下面這樣:
1
2
3
4
|
typedef struct { PyObject_HEAD long ob_ival; } PyIntObject; |
上面結構體中唯一“有趣”的元素是ob_ival(類似于C語言中的整數)。如果你覺得使用一個完整的堆對象來實現整數很浪費,你也許是對的。很多語言就為了避免這樣而做優化。例如 Matz的 Ruby 解釋器通常以指針的方式存儲對象,但是對頻繁使用的指針做例外處理。簡單來說,Ruby解釋器把定長數作為對象應用塞到同樣的空間,并用最低有效位來標記這是一個整數而不是一個指針(在所有現代系統中,malloc總是返回以2的倍數對齊的內存地址)。在那時,你只需要通過合適的位移來獲取整數的值——不需要堆位置或者重定向。如果CPython做類似的優化,slow.py 和 fast.py 會有同樣的速度(而且他們可能都會更快)。
那么CPython是怎樣處理整數的呢?解釋器的什么行為給我們如此多的疑惑?Python解釋器每次將整數分配到40Byte的“塊”中(block)。當Python需要生成新的整型對象時,就在當前的整數“塊”中開辟下一個可用空間,并將整數存儲在其中。我們的代碼在數組中分配一百萬個整數,大部分相鄰的整數會被放到相鄰的內存中。因此,在有序的一百萬個數中遍歷展現出不錯的緩存定位,而在隨機排序的前一百萬個數中定位出現頻繁的緩存未命中。
因此,“為什么對數組排序使得代碼更快”的答案就是它根本沒有這個作用。沒有打亂順序的數組遍歷的速度更快,因為我們訪問整型對象的順序和分配的順序一致(他們必須被分配)。