如何讓 Python 像 Julia 一樣快地執行

發表於2016-03-10

Julia 與 Python 的比較

我是否應丟棄 Python 和其他語言,使用 Julia 執行技術計算?在看到 http://julialang.org/ 上的基準測試後,人們一定會這麼想。Python
和其他高階語言在速度上遠遠有些落後。但是,我想到的第一個問題有所不同:Julia 團隊能否以最適合 Python 的方式編寫 Python 基準測試?

我對這種跨語言比較的觀點是,應該根據要執行的任務來定義基準測試,然後由語言專家編寫執行這些任務的最佳程式碼。如果程式碼全由一個語言團隊編寫,則存在其他語言未得到最佳使用的風險。

Julia 團隊有一件事做得對,那就是他們將他們使用的程式碼釋出到了 github 上。具體地講,Python 程式碼可在此處找到。

第一眼看到該程式碼,就可以證實我所害怕的偏見。該程式碼是以 C 風格編寫的,在陣列和列表上大量使用了迴圈。這不是使用 Python 的最佳方式。

我不會責怪 Julia 團隊,因為我很內疚自己也有同樣的偏見。但我受到了殘酷的教訓:付出任何代價都要避免陣列或列表上的迴圈,因為它們確實會拖慢 Python
中的速度,請參閱 Python 不是 C

考慮到對 C 風格的這種偏見,一個有趣的問題(至少對我而言)是,我們能否改進這些基準測試,更好地使用 Python 及其工具?

在我給出答案之前,我想說我絕不會試圖貶低 Julia。在進一步開發和改進後,Julia 無疑是一種值得關注的語言。我只是想分析 Python
方面的事情。實際上,我正在以此為藉口來探索各種可用於讓程式碼更快執行的 Python 工具。

在下面的內容中,我使用 Docker 映象在 Jupyter Notebook 中使用 Python 3.4.3,其中已安裝了所有的 Python 科學工具組合。我還會通過
Windows 機器上的 Python 2.7.10,使用 Anaconda 來執行程式碼。計時是對 Python 3.4.3 執行的。包含下面的所有基準測試的完整程式碼的 Notebook 可在此處找到。

鑑於各種社交媒體上的評論,我新增了這樣一句話:我沒有在這裡使用 Python 的替代性實現。我沒有編寫任何 C
程式碼:如果您不信,可試試尋找分號。本文中使用的所有工具都是 Anaconda 或其他發行版中提供的標準的 Cython 實現。下面的所有程式碼都在單個 Notebook中執行。

我嘗試過使用來自 github 的 Julia 微效能檔案,但不能使用 Julia 0.4.2 原封不動地執行它。我必須編輯它並將 @timeit 替換為
@time,它才能執行。在對它們計時之前,我還必須新增對計時函式的呼叫,否則編譯時間也將包含在內。我使用的檔案位於此處。我在用於執行 Python 的同一個機器上使用 Julia 命令列介面執行它。

計時程式碼

Julia 團隊使用的第一項基準測試是 Fibonacci 函式的一段簡單編碼。

此函式的值隨 n 的增加而快速增加,例如:

可以注意到,Python 任意精度 (arbitrary precision) 很方便。在 C 等語言中編寫相同的函式需要花一些編碼工作來避免整數溢位。在 Julia
中,需要使用 BigInt 型別。

所有 Julia 基準測試都與執行時間有關。這是 Julia 中使用和不使用 BigInt 的計時:

在 Python Notebook 中獲得執行時間的一種方式是使用神奇的 %timeit。例如,在一個新單元中鍵入:

執行它會獲得輸出:

這意味著計時器執行了以下操作:

  1. 執行 fib(20) 100 次,儲存總執行時間
  2. 執行 fib(20) 100 次,儲存總執行時間
  3. 執行 fib(20) 100 次,儲存總執行時間
  4. 從 3 次執行中獲取最小的執行時間,將它除以 100,然後輸出結果,該結果就是 fib(20) 的最佳執行時間

這些迴圈的大小(100 次和 3 次)會由計時器自動調整。可能會根據被計時的程式碼的執行速度來更改迴圈大小。

Python 計時與使用了 BigInt 時的 Julia 計時相比出色得多:3 毫秒與 12 毫秒。在使用任意精度時,Python 的速度是 Julia 的 4
倍。

但是,Python 比 Julia 預設的 64 位整數要慢。我們看看如何在 Python 中強制使用 64 位整數。

使用 Cython 編譯

一種編譯方式是使用 Cython 編譯器。這個編譯器是使用 Python
編寫的。它可以通過以下命令安裝:

pip install Cython

如果使用 Anaconda,安裝會有所不同。因為安裝有點複雜,所以我編寫了一篇相關的部落格文章:將 Cython For Anaconda 安裝在 Windows 上

安裝後,我們使用神奇的 %load_ext 將 Cython 載入到 Notebook 中:

然後就可以在我們的 Notebook 中編譯程式碼。我們只需要將想要編譯的程式碼放在一個單元中,包括所需的匯入語句,使用神奇的 %%cython 啟動該單元:

執行該單元會無縫地編譯這段程式碼。我們為該函式使用一個稍微不同的名稱,以反映出它是使用 Cython
編譯的。當然,一般不需要這麼做。我們可以將之前的函式替換為相同名稱的已編譯函式。

對它計時會得到:

哇,幾乎比最初的 Python 程式碼快 3 倍!我們現在比使用 BigInt 的 Julia 快 100 倍。

我們還可以嘗試靜態型別。使用關鍵字 cpdef 而不是 def 來宣告該函式。它使我們能夠使用相應的 C 型別來鍵入函式的引數。我們的程式碼變成了:

執行該單元后,對它計時會得到:

太棒了,我們現在只花費了 36 微秒,比最初的基準測試快約 100 倍!這與 Julia 所花的 80 毫秒相比更出色。

有人可能會說,靜態型別違背了 Python
的用途。一般來講,我比較同意這種說法,我們稍後將檢視一種在不犧牲效能的情況下避免這種情形的方法。但我並不認為這是一個問題。Fibonacci
函式必須使用整數來呼叫。我們在靜態型別中失去的是 Python 所提供的任意精度。對於 Fibonacci,使用 C 型別 long
會限制輸入引數的大小,因為太大的引數會導致整數溢位。

請注意,Julia 計算也是使用 64 位整數執行的,因此將我們的靜態型別版本與 Julia 的對比是公平的。

快取計算

我們在保留 Python 任意精度的情況下能做得更好。fib 函式重複執行同一種計算許多次。例如,fib(20) 將呼叫 fib(19) 和
fib(18)。fib(19) 將呼叫 fib(18) 和 fib(17)。結果 fib(18) 被呼叫了兩次。簡單分析表明,fib(17) 將被呼叫 3
次,fib(16) 將被呼叫 5 次,等等。

在 Python 3 中,我們可以使用 functools 標準庫來避免這些重複的計算。

對此函式計時會得到:

速度又增加了 40 倍,比最初的 Python 程式碼快約 3,600 倍!考慮到我們僅向遞迴函式新增了一條註釋,此結果非常令人難忘。

Python 2.7 中沒有提供這種自動快取。我們需要顯式地轉換程式碼,才能避免這種情況下的重複計算。

請注意,此程式碼使用了 Python 同時分配兩個區域性變數的能力。對它計時會得到:

我們又快了 20 倍!讓我們在使用和不使用靜態型別的情況下編譯我們的函式。請注意,我們使用了 cdef 關鍵字來鍵入區域性變數。

我們可在一個單元中對兩個版本計時:

結果為:

靜態型別程式碼現在花費的時間為 51.9 納秒,比最初的基準測試快約 60,000(六萬)倍。

如果我們想計算任意輸入的 Fibonacci 數,我們應堅持使用無型別版本,該版本的執行速度快 3,500 倍。還不錯,對吧?

使用 Numba 編譯

讓我們使用另一個名為 Numba 的工具。它是針對部分 Python 版本的一個即時
(jit) 編譯器。它不是對所有 Python 版本都適用,但在適用的情況下,它會帶來奇蹟。

安裝它可能很麻煩。推薦使用像 Anaconda 這樣的 Python 發行版或一個已安裝了 Numba 的 Docker 映象。完成安裝後,我們匯入它的 jit 編譯器:

它的使用非常簡單。我們僅需要向想要編譯的函式新增一點修飾。我們的程式碼變成了:

對它計時會得到:

比無型別的 Cython 程式碼更快,比最初的 Python 程式碼快約 16,000 倍!

使用 Numpy

我們現在來看看第二項基準測試。它是快速排序演算法的實現。Julia 團隊使用了以下 Python 程式碼:

我將他們的基準測試程式碼包裝在一個函式中:

對它計時會得到:

上述程式碼與 C 程式碼非常相似。Cython 應該能很好地處理它。除了使用 Cython 和靜態型別之外,讓我們使用 Numpy
陣列代替列表。在陣列大小較大時,比如數千個或更多元素,Numpy 陣列確實比
Python 列表更快。

安裝 Numpy 可能會花一些時間,推薦使用 Anaconda 或一個已安裝了 Python 科學工具組合的 Docker 映象

在使用 Cython 時,需要將 Numpy 匯入到應用了 Cython 的單元中。在使用 C 型別時,還必須使用 cimport 將它作為 C 模組匯入。Numpy
陣列使用一種表示陣列元素型別和陣列維數(一維、二維等)的特殊語法來宣告。

對 benchmark_qsort_numpy_cython() 函式計時會得到:

我們比最初的基準測試快了約 15 倍,但這仍然不是使用 Python 的最佳方法。最佳方法是使用 Numpy 內建的 sort()
函式。它的預設行為是使用快速排序演算法。對此程式碼計時:

會得到:

我們現在比最初的基準測試快 52 倍!Julia 在該基準測試上花費了 419 微秒,因此編譯的 Python 快 20%。

我知道,一些讀者會說我不會進行同類比較。我不同意。請記住,我們現在的任務是使用主機語言以最佳的方式排序輸入陣列。在這種情況下,最佳方法是使用一個內建的函式。

剖析程式碼

我們現在來看看第三個示例,計算 Mandelbrodt 集。Julia 團隊使用了這段 Python 程式碼:

最後一行是一次合理性檢查。對 mandelperf() 函式計時會得到:

使用 Cython 會得到:

還不錯,但我們可以使用 Numba 做得更好。不幸的是,Numba 還不會編譯列表推導式 (list
comprehension)。因此,我們不能將它應用到第二個函式,但我們可以將它應用到第一個函式。我們的程式碼類似以下程式碼。

對它計時會得到:

還不錯,比 Cython 快 4 倍,比最初的 Python 程式碼快 9 倍!

我們還能做得更好嗎?要知道是否能做得更好,一種方式是剖析程式碼。內建的 %prun 剖析器在這裡不夠精確,我們必須使用一個稱為 line_profiler 的更好的剖析器。它可以通過
pip 進行安裝:

安裝後,我們需要載入它:

然後使用一個神奇的命令剖析該函式:

它在一個彈出視窗中輸出以下資訊。

我們看到,大部分時間都花費在了 mandelperf_numba() 函式的第一行和最後一行上。最後一行有點複雜,讓我們將它分為兩部分來再次剖析:

剖析器輸出變成:

我們可以看到,對函式 mandel_numba() 的呼叫僅花費了總時間的 1/4。剩餘時間花在 mandelperf_numba()
函式上。花時間優化它是值得的。

 

再次使用 Numpy

使用 Cython 在這裡沒有太大幫助,而且 Numba 不適用。擺脫此困境的一種方法是再次使用 Numpy。我們將以下程式碼替換為生成等效結果的 Numpy
程式碼。

此程式碼構建了所謂的二維網格。它計算由 r1 和 r2 提供座標的點的複數表示。點 Pij 的座標為 r1[i] 和 r2[j]。Pij 通過複數 r1[i] +
1j*r2[j] 進行表示,其中特殊常量 1j 表示單個虛數 i。

我們可以直接編寫此計算的程式碼:

請注意,我將返回值更改為了一個二維整數陣列。如果要顯示結果,該結果與我們需要的結果更接近。

對它計時會得到:

我們比最初的 Python 程式碼快約 33 倍!Julia 在該基準測試上花費了 196 微秒,因此編譯的 Python 快 40%。

向量化

讓我們來看另一個示例。老實地講,我不確定要度量什麼,但這是 Julia 團隊使用的程式碼。

實際上,Julia 團隊的程式碼有一條額外的指令,用於在存在末尾的 ‘L’ 時刪除它。我的 Anaconda 安裝需要這一行,但我的 Python 3
安裝不需要它,所以我刪除了它。最初的程式碼是:

對修改後的程式碼計時會得到:

Numba 似乎沒什麼幫助。Cython 程式碼執行速度快了約 5 倍:

Cython 程式碼執行速度快了約 5 倍,但這還不足以彌補與 Julia 的差距。

我對此基準測試感到迷惑不解,我剖析了最初的程式碼。以下是結果:

可以看到,大部分時間都花費在了生成隨機數上。我不確定這是不是該基準測試的意圖。

加速此測試的一種方式是使用 Numpy 將隨機數生成移到迴圈之外。我們一次性建立一個隨機數陣列。

對它計時會得到:

還不錯,快了 4 倍,接近於 Cython 程式碼的速度。

擁有陣列後,通過迴圈它來一次向某個元素應用 hex() 和 int() 函式似乎很傻。好訊息是,Numpy 提供了一種向陣列應用函式的方法,而不必使用迴圈,該函式是
numpy.vectorize() 函式。此函式接受一次處理一個物件的函式。它返回一個處理陣列的新函式。

此程式碼執行速度更快了一點,幾乎像 Cython 程式碼一樣快:

我肯定 Python 專家能夠比我在這裡做得更好,因為我不太熟悉 Python 解析,但這再一次表明避免 Python 迴圈是個不錯的想法。

結束語

上面介紹瞭如何加快 Julia 團隊所使用的 4 個示例的執行速度。還有 3 個例子:

  • pisum 使用 Numba 的執行速度快 29 倍。
  • randmatstat 使用 Numpy 可將速度提高 2 倍。
  • randmatmul 很簡單,沒有工具可應用到它之上。

包含所有 7 個示例的完整程式碼的 Notebook 可在此處獲得。

我們在一個表格中總結一下我們的結果。我們給出了在最初的 Python 程式碼與優化的程式碼之間實現的加速。我們還給出了對 Julia
團隊使用的每個基準測試示例使用的工具。

時間以微秒為單位 Julia Python
優化後的程式碼
Python 初始程式碼 Julia / Python 優化後的程式碼 Numpy Numba Cython
Fibonacci
64 位
80 36 不使用 2.2 X
Fib BigInt 12,717 1,220 3,330 10
快速排序 419 350 18,300 1.2 X
Mandelbrodt 196 140 4,620 1.4 X X
pisum 34,783 35,300 804,000 0.99 X
randmatmul 95,975 137,000 137,000 0.73
parse int 244 617 3,330 0.4 X X
randmatstat 14,544 117,000 230,000 0.12 X

這個表格表明,在前 4 個示例中,優化的 Python 程式碼比 Julia 更快,後 3 個示例更慢。請注意,為了公平起見,對於
Fibonacci,我使用了遞迴程式碼。

我認為這些小型的基準測試沒有提供哪種語言最快的明確答案。舉例而言,randmatstat 示例處理 5×5 矩陣。使用 Numpy
陣列處理它有點小題大做。應該使用更大的矩陣執行基準測試。

我相信,應該在更復雜的程式碼上對語言執行基準測試。Python 與
Julia 比較–一個來自機器學習的示例
中提供了一個不錯的示例。在該文章中,Julia 似乎優於 Cython。如果我有時間,我會使用 Numba
試一下。

無論如何,可以說,在這個小型基準測試上,使用正確的工具時,Python 的效能與 Julia 的效能不相上下。相反地,我們也可以說,Julia 的效能與編譯後的
Python 不相上下。考慮到 Julia 不需要對程式碼進行任何註釋或修改,所以這本身就很有趣。

補充說明

我們暫停一會兒。我們已經看到在 Python 程式碼效能至關重要時,應該使用許多工具:

  • 使用 line_profiler 執行剖析。
  • 編寫更好的 Python 程式碼來避免不必要的計算。
  • 使用向量化的操作和通過 Numpy 來廣播。
  • 使用 Cython 或 Numba 編譯。

使用這些工具來了解它們在哪些地方很有用。與此同時,請謹慎使用這些工具。分析您的程式碼,以便可以將精力放在值得優化的地方。重寫程式碼來讓它變得更快,有時會讓它難以理解或通用性降低。因此,僅在得到的加速物有所值時這麼做。Donald
Knuth 曾經恰如其分地提出了這條建議:

“我們在 97% 的時間應該忘記較小的效率:不成熟的優化是萬惡之源。”

但是請注意,Knuth 的引語並不意味著優化是不值得的,例如,請檢視停止錯誤地引用 Donald Knuth 的話!‘不成熟的優化是惡魔’的謊言

Python 程式碼可以優化,而且應該在有意義的時間和位置進行優化。

我們最後給出一個討論我所使用的工具和其他工具的有趣文章列表:

2015 年 12 月 16 日更新。Python 3.4 擁有一個能顯著加速 Fibonacci() 函式的內建快取。我更新了這篇文章來展示它的用途。

2015 年 12 月 17 日更新。在執行 Python 的相同機器上 執行 Julia 0.4.2 會導致時間增加。

相關文章