為什麼對陣列排序讓Python迴圈執行更快

奔跑發表於2014-07-24

早上我偶然看見一篇介紹兩個Python指令碼的博文,其中一個效率更高。這篇博文已經被刪除,所以我沒辦法給出文章連結,但指令碼基本可以歸結如下:
fast.py

slow.py

如你所見,兩個指令碼有完全相同的行為。都產生一個包含前一百萬個整數的列表,並列印對這些整數求和的時間。唯一的不同是 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)] 的處理效率要比隨機列表高。換句話說,這個列表可以用下面的生成器替代:

我想這可能在時間效率上更高效些。畢竟,如果Python在內部使用生成器替代真正的列表可以避免在記憶體中一次儲存所有整數的麻煩,這可以節省很多開銷。slow.py 中的隨機列表不能輕易的被一個簡單生成器捕獲,所有VM(虛擬機器)無法進行這樣的優化。

然而,這不是一個有用的發現。如果在slow.py的 shuffle() 和迴圈之間插入 a.sort(),程式會像 fast.py一樣快。很明顯,數字排序後的一些細節讓程式更快。

失敗的開端:列表對比陣列

我的第二個想法是有可能資料結構造成的快取問題。a 是一個列表,這自然讓我相信a實際上是通過連結串列來實現的。如果shuffle操作故意隨機化這個連結串列的節點,那麼 fast.py 可能可以把列表的所有連結串列元素分配在相鄰地址,從而採用高階區域性快取,而slow.py會出現很多快取未命中的情況,因為每個節點引用不在同一個快取行上的另外一個節點。

不幸的是,這也不對。Python的列表物件不是連結的列表,而是真正意義上的陣列。尤其是用C結構體定義了Python列表物件:

……換句話說,ob_item 是一個指向PyObjects指標陣列的指標,並且分配的大小是我們分配給陣列的大小。因此,這對於解決這個問題也沒幫助(儘管這對我不確定Python中關於列表操作的演算法複雜度有些安慰:列表的新增操作演算法複雜度是O(1),訪問任意列表元素的演算法複雜度是O(1),等等)。我只是想說明為什麼Guido選擇稱它們為列表“lists”而不是陣列“arrays”,而實際上它們卻是陣列。

解決辦法:整體物件

陣列元素在記憶體中是相鄰的,因此這樣的資料結構不會帶來快取問題。事實證明快取位置是 slow.py 變慢的原因,但這來自於一個意料之外的地方。在Python中,整數是分配在堆中的物件而不是一個簡單的值。尤其是在虛擬機器中,整數物件看起來像下面這樣:

上面結構體中唯一“有趣”的元素是ob_ival(類似於C語言中的整數)。如果你覺得使用一個完整的堆物件來實現整數很浪費,你也許是對的。很多語言就為了避免這樣而做優化。例如 Matz的 Ruby 直譯器通常以指標的方式儲存物件,但是對頻繁使用的指標做例外處理。簡單來說,Ruby直譯器把定長數作為物件應用塞到同樣的空間,並用最低有效位來標記這是一個整數而不是一個指標(在所有現代系統中,malloc總是返回以2的倍數對齊的記憶體地址)。在那時,你只需要通過合適的位移來獲取整數的值——不需要堆位置或者重定向。如果CPython做類似的優化,slow.py 和 fast.py 會有同樣的速度(而且他們可能都會更快)。

那麼CPython是怎樣處理整數的呢?直譯器的什麼行為給我們如此多的疑惑?Python直譯器每次將整數分配到40Byte的“塊”中(block)。當Python需要生成新的整型物件時,就在當前的整數“塊”中開闢下一個可用空間,並將整數儲存在其中。我們的程式碼在陣列中分配一百萬個整數,大部分相鄰的整數會被放到相鄰的記憶體中。因此,在有序的一百萬個數中遍歷展現出不錯的快取定位,而在隨機排序的前一百萬個數中定位出現頻繁的快取未命中。

因此,“為什麼對陣列排序使得程式碼更快”的答案就是它根本沒有這個作用。沒有打亂順序的陣列遍歷的速度更快,因為我們訪問整型物件的順序和分配的順序一致(他們必須被分配)。

9月4日修改:在原文中,我引入了一些關於Python列印一個整數需要的指令條數。圖片有些誤導,所以我刪除了。不用到處找了。

相關文章