繞過 GIL 提高 Python 性能的通常方法

做爲一種使用動態類型的解釋型語言,Python 的執行性能(主要指 CPU 運算方面)常常會讓一些人感到糾結。若是說以犧牲代碼執行效率來換取代碼編寫效率還能夠接受的話,GIL 的存在就顯得有些不講道理了,由於它使得 Python 沒法在線程級別利用多核心運算。 <br /> #GIL

在併發訪問過程當中保持數據一致性是一個很廣泛的問題,而使用鎖則是該問題一個很廣泛的解決方法。全局解釋器鎖(global interpreter lock)如其名運行在解釋器主循環中,在多線程環境下,任何一條線程想要執行代碼的時候,都必須獲取(acquire)到這個鎖,運行必定數量字節碼,而後釋放(release)掉,而後再嘗試獲取。這樣 GIL 就保證了同時只有一條線程在執行python

通常來講,GIL 並不會帶來麻煩,由於大多數程序的性能瓶頸都在 IO 上(IO-bound)。但當你運行計算密集型代碼,並且計算量又很是大的時候,好比數據分析,你就會發現問題了——你的程序對 CPU 的使用率很是低。以我如今使用的 i5-460m 雙核四線程(2C/4T)處理器來講,Python 最多隻能佔用 25% 的 CPU 負載,正好是一條線程的量。不管是多核心,仍是 intel 的超線程技術,如今提高 CPU 性能的有效手段已經從提升單核性能轉變爲增長核心數量,即執行並行(parallelism)運算。顯然 GIL 的存在與這種思路有些格格不入。編程

若是參考其餘實現的話,你可能會問一個問題,爲何要使用全局鎖,而不是一個更細粒度的鎖呢?實際上 Linux 的文件系統就是這樣作的,進程給目標文件加鎖的時候,能夠只加必定字節數的鎖,只要另外一個進程準備加的鎖與其沒有交集的話,這兩個鎖就能夠共存,這兩個進程也能夠同時修改這一個文件(的不一樣部分)。所以對於 Python,也許能夠給對象加鎖(一切皆對象嘛),同時不限制線程的並行執行。但從網上的信息來看,彷佛這種思路曾經被嘗試實現過,但細粒度的鎖會給單線程模式下的性能形成明顯影響。因此仍是用 GIL 吧~網絡

一般,咱們不會責備某我的笨,但要是他笨還不努力的話可能就得說道說道了。Python 如今的狀況就是,使用這門語言的人基本都已經接受它不如 C 快這一點了,可是對於那些用着 4 核 8 線程,甚至 6 核 12 線程的 CPU 的人來講,看着本身的程序在 10% CPU 負載下執行兩個小時確實是一件很彆扭的事。數據結構

經過我前面不停提到的超線程(Hyper-Threading)技術,也許有人已經想到了,經過禁用超線程,是否是能夠提高單線程性能呢?若是 i5-460m 只有兩條線程的話,那 Python 解釋器不就能夠直接使用到 50% 負載嗎?甚至在網上也常有這種說法——禁用超線程後,不少遊戲會運行的更流暢。可是很惋惜,事情沒有那麼簡單。 <br /> #超線程

intel 的超線程,是使一個物理核心能夠在極短期內「同時」執行兩條或多條線程的技術,它在操做系統上的體現爲:一個物理核心會被當作兩個邏輯核心使用。這即是當你打開任務管理器,並切換到【性能】標籤頁後,會看到比你 CPU 核心數量多一倍的小方格的緣由。多線程

更加具體的物理實現這裏就不說了,由於我也不懂。下面只經過一些實驗數據來看一下超線程 CPU 在不一樣線程下的效率表現。禁用 HT 的操做必須在 BIOS 中進行,很惋惜筆記本的 BIOS 是簡化過的,沒有這個選項,因此我用本身電腦進行的測試都是運行在 HT enable 的狀態,禁用後的數據來自網絡。併發

以雙核四線程的 i5-460m 爲例,在 Windows 上表現爲 4 個邏輯核心,分別名爲 「CPU 0」,「CPU 1」,「CPU 2」 和 「CPU 3」。其中 0 和 1 是同一個物理核心,2 和 3 是另外一個物理核心。在任務管理器的進程標籤頁中能夠爲指定進程設置處理器相關性,即爲其分配指定的邏輯內核。或者也能夠經過 cmd 命令:start /affinity <mask> .exe 來在程序打開前指定處理器相關性,其中 <mask> 應被替換爲 CPU 編號的 16 進制掩碼。如,爲名爲 test.py 的腳本分配 CPU 0 和 CPU 2 並執行的命令爲:start /affinity 5 python test.py(2^0 ^+ 2^2 ^= 5)。對於如象棋或 CINEBENCH 這樣的測試軟件,直接在任務管理器中設置便可,出於執行時間考慮,本文使用國際象棋來進行測試。 <br /> ###測試數據(HT 啓用) 單核單線程: <br />函數

<table style="font-size:14px"> <tr> <th>CPU 0</th> <th>CPU 1</th> <th>CPU 2</th> <th>CPU 3</th> </tr> <tr> <td>1645</td> <td>1627</td> <td>1726</td> <td>1704</td> </tr> </table> <br /> **雙核單線程:** <br /> <table style="font-size:14px"> <tr> <th>CPU 0+1</th> <th>CPU 1+2</th> <th>CPU 2+3</th> </tr> <tr> <td>1654</td> <td>1743</td> <td>1735</td> </tr> </table> <br /> 雙核心跑單線程時,CPU 負載會分佈在兩個核心中。這時若是把資源監視器裏面的 CPU 視圖截圖下來,並把兩個核心中的一個(上圖)作垂直旋轉,而後以 50% 透明度疊加到另外一個核心的視圖(下圖)上,就能看到他們基本是徹底互補的。0+1 和 1+2 有少許重疊的陰影存在,緣由不是很肯定,多是其餘進程的負載吧。這樣兩個核心的負載總和只有單邏輯核心的 100%,即 i5-460m 的 25% 。性能

雙核心單線程

四核單線程:測試

1747優化

雙核雙線程: <br />

<table style="font-size:14px"> <tr> <th>CPU 0+1</th> <th>CPU 0+2</th> <th>CPU 0+3</th> <th>CPU 1+2</th> <th>CPU 1+3</th> <th>CPU 2+3</th> </tr> <tr> <td>2282</td> <td>3537</td> <td>3496</td> <td>3494</td> <td>3526</td> <td>2375</td> </tr> </table> <br /> **四核四線程:**

4472 <br /> ###測試數據(HT 禁用) HT 禁用模式下的數據來源於網絡,由於與個人環境不一樣,這裏直接給結論吧:

  • 單線程模式下,HT 開關對結果分數無影響 —— 引用來源
  • 多線程模式下,禁用 HT 會帶來 20%+ 的性能損失 —— 引用來源

<br /> ###結論 上面數據中的看點,或者說比較出乎我意料的地方在於:

  1. 雙核雙線程時,若是這兩個核心屬於同一個物理核心,那麼結果的分數並不是雙核單線程的兩倍,而是隻多出 30%+;若是這兩個邏輯核心來自於不一樣的物理核心,則能夠達到兩倍的分數。便是說,即便按最初預想的,關閉 HT 後,單物理核心能夠在單線程模式下發揮所有性能的話,也僅是增長 30%+,而不是一倍。
  2. 而實際上關閉超線程後,這 30%+ 的性能提高也是不存在的,由於從結果來看開關 HT 對單線程運算力沒有影響。而從 CPU 總體來看,禁用超線程也帶了 20%+ 的性能損失。即禁用超線程的結果並不是簡單拿掉了一種線程切換機制,更是屏蔽了一部分性能,我傾向於將其理解爲物理相關的,即核心中有一部分元件被禁用了。
  3. 4 核心 4 線程(即滿負載)時的分數正好爲(同一物理核心中)雙核雙線程分數的兩倍:4472 ≈ 2282 * 2,這很科學。

因此禁用超線程這條路算是走不通啦~其實最初看到 CPU 負載 25% 的時候,實際狀況並無想象的那麼差。單線程的 Python 程序對 i5-460m 運算性能的使用率應該爲 1654 / 4472 = 37% ,性能與負載之間並不是線性對應關係。 <br /> #多進程

想要不改動任何代碼地實現性能提高看來是不可行了。其實對於 GIL 的問題,包括官方給出的最多見的解決方案是:使用多進程,multiprocessing 模塊。

multiprocessing 模塊擁有和多線程模塊 threading 很相似的 API。但有一些值得注意的細節,出於時間考慮這裏就先不研究了,畢竟如今還用不到。由於多進程編程在你依賴第三方包的時候會變得有些麻煩,而 Python 代碼會出現 CPU 性能瓶頸的地方,也基本都與科學計算或數據分析的第三方包有關(numpy、pandas 等)。這種狀況下,本身編寫的某種運算的多進程版未必能比第三方包內建的單線程版快上多少,尤爲在你的 CPU 並無不少核心的時候,並且還須要注意 bug。所以這種狀況下,將多進程運算集成進第三方包多是一個更好的方法。 <br /> #Cython

上一節說到本身寫的多進程程序未必比單線程的第三方包快,一方面的緣由在於,計算密集型的擴展包不少都是用 C/C++ 這樣的編譯型語言寫的。而這類擴展包有一個很重要的特性在於,它能夠在被調用時選擇釋放 GIL,從而在不影響主程序的狀況下獨立運算。

但問題在於 C 代碼很差寫,更別說須要使用第三方數據結構的狀況了。這時候在 C 擴展和 Python 之間的一種折中方案就是 Cython,對於 Cython 能夠簡單地將其當作是擁有靜態類型並能嵌入 C 函數的 Python。Cython 也一樣支持釋放 GIL 後的並行。

不管是多進程,仍是編寫擴展,在實行前都得計算一下成本收益,依據能夠節省的時間的量來決定付出多少的努力。對於多數偶發性的狀況,可能優化一下本身的代碼就能帶來很好的效率提高,又沒必要爲此花掉太多時間。所以對於 multiprocessing 和 Cython 的實現細節,這裏就先不深究了。(好水的文)

相關文章
相關標籤/搜索