讀python高性能編程


寫在前面

最近看了本書,“python高性能編程”。其實買書的時候還是對這個書抱有很大的希望的,但是讀了一遍之后,感覺,翻譯,對,翻譯,實在是太爛了。好多中式英語不說,甚至有些地方不是很通順。不過對於我這樣英文一般的人來講肯定還是比英文書看的效率高些。書中其次講述了優化python效率,增強計算性能和節省空間的方法,部分內容還是很有啟發性的,這里簡單的聊一聊。(下面的內容和書中的介紹順序不一定一致,但是大多數內容都是可以在書中找到對應的)

python高性能的局限

我之前在學習大氣模式的時候也是解除了一些編程語言,C、c++、fortran。但是這次是第一次使用動態語言來實現高性能。各種原因主要是python是一個高級語言,python解釋器為了抽離底層用到的計算元素做了很多工作,我們實際上面對的就是一個python黑箱,只是知道我們的操作和計算是可以做到的但是執行的具體過程對開發者是不透明的;另一個原因就是開發python解釋器的時候為了線程安全所引入的全局解釋器鎖,這就是“臭名昭著的GIL”。
對於前一個問題,所造成的影響比較直觀,比如由於python虛擬機的影響使得矢量操作不能直接可用了,但是其實我們可以用例如numpy這樣的外部庫實現,這個並不是一個根本性的問題;另外就是數據局部性的問題,python抽象影響了任何需要為下一次計算所准備的緩存安排,這是由於python是一個垃圾收集語言,雖然對象在無引用的時候會進入垃圾收集過程,但是內存是自動分配並在需要的時候釋放的,這會導致內存碎片並且會影響向CPU緩存的傳輸;最后一個就是由於python是高級語言,所謂高處不勝寒。高級語言在讓開發者方便的實現程序原型的時候也帶來了一個問題,就是沒有來自編譯器的恰當優化。當我們編譯靜態代碼時,編譯器可以做很多的操作來改變對象的內存布局以及優化CPU的指令來優化。此外由於python支持動態類型,張三可能一開始還是個草履蟲,轉眼間就是人了,這讓優化過程更是難上加難。
對於后一個問題,首先談一談GIL本身。前面已經說過了,GIL中文名稱是全局解釋器鎖。但是需要明確一點,就是雖然這里提到了這個是影響python高性能的缺點,但是這並不是python語言的特性,這只是在實現python解釋器(CPython)的時候所引入的特性。一些其他的python解釋器是沒有這樣的問題的,如JPython。但是誰讓現在是CPython的天下呢?
回到GIL,官方的解釋是這樣的:

In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython’s memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)

可以看到,為了解決多線程之間的數據完整性和狀態同步,前人們用了最自然的解決方法,就是加鎖。但是由於這么干了之后很多庫的開始接受這樣的設定,就導致這個並不是python特性非常像“特性”。但是隨着python3開始的優化設計,至少在高密度I/O問題時,多線程仍然是一個可行的加速方案。

一些性能檢測工具

在書的第二章中,作者介紹了幾種比較好用的性能檢測工具(for python2,but some of them are available in python3)。

  • import time 使用python time模塊,這個是最為直觀且方便的方式,但是這樣的函數插在代碼段中不甚優雅
  • 定義一個修飾器,實際上也是調用了上面的time module,但是只需要在需要做性能分析的函數上@修飾器就可以完成,相對比較優雅;且由於上一種方法調用函數帶來了額外的開銷,所以一般情況下,這種方法測得的時間要小於上一種方法
  • 使用timeit模塊,如 python -m timeit -n 5 -r 5 "import 測試代碼" "代碼中具體函數,需要填寫具體形參",若使用的是ipython解釋器可以直接使用%timeit來減少工作量
  • 使用系統/usr/bin/time --verbose(注意這里不同於shell的內建time),返回三個時間,real ,user,和sys,對於一個單核機器來講real~=user+sys,但是對於多核機器,由於任務會分配到多個核上完成,所以這個一般不成立
  • 使用標准庫內建的cProfile工具,python -m cProfile -s cumulative ex.py
  • 對於cpu密集型程序可以使用line_profiler
  • heapy 調查堆上的對象
  • dowser畫出變量實例
  • dis 檢查字節碼

上面介紹的基本上涵蓋了不同粒度的性能分析工具,且上面的工具都是我查證python3可用的,畢竟幾個月之后就是徹底和python2說再見的時候了。
前四個工具只能提供一個大概的運行時間情況,只能讓我們知道哪個部分的代碼運行的時間比較長,但是不能讓我們真正的了解為什么。所以需要后面的cProfile了解代碼調用的情況,精確的定位位置,然后使用line_profiler逐行工具進行分析,必要的時候再加以dis字節碼檢查,基本上就是一個從粗到細的優化過程了。但是中間值得注意的是,我們使用的工具很多都是需要修飾器的,但是修飾器在進行代碼測試的時候會影響到代碼的正確性,實際上是一個很煩人的存在,所以我們需要使用No-op的修飾器來防止測試的時候出現問題。
最后,如果有條件的話,最好還是在做性能分析的時候保持系統和運行進程相對“干凈”,盡量不要影響到分析的結果。

計算使用的基本數據結構

列表和元組

首先用一句話說明列表和元組之前的聯系,他們都是可以存放數組的容器,列表是動態的數組,元組是靜態的數組。列表是可以重設長度的,但是素組不可以,其內部元素創建之后就不能可以改變了。且元組緩存與python運行時環境,也就是說我們在每次使用元組的時候無需訪問訪問內核來分配內存,無疑效率是比較高的。
詳細談談列表的內存分配方式,由於列表是可以改變大小的,在列表長度增加的時候,實際上python會在每一次創建新的數據的時候創建一個新的列表,但是這個新的列表的大小大於之前列表的長度+1,這是由於我們每一次新的添加可能是后面很多次類似添加的開始,邏輯上可以理解為是一種操作局部性。值得注意的是這個和過程中由於涉及到了內存復制,所以操作的代價是很大的。
那么元組又是怎樣的呢,不同於列表,元組是沒有“超售現象的”,所以元組每增加一個元素都會有分配和復制工作,而不是像列表一樣只在當前列表可使用的長度不夠的時候才做。另外,由於元組具有靜態特性,所以python在處理元組的時候是資源緩存的,也就是說即使是元組已經不再使用,它們的空間也不會立即返還給系統,而是留待未來使用。也就是未來要是需要同樣大小的元組的時候,不需要向操作系統申請內存,而是直接使用這樣的預留空間。
對於列表中數據的搜索問題,建議使用python內建的排序(Tim算法)+二分搜索。

字典和集合

其實這里講的字典和集合可以認為是上面列表的一種廣義表現。你看,我們在使用列表的時候,實際上index,或者說內存位置的offser就是字典的鍵值啊!而實際上,字典的實現就是借鑒了這個想法。總的來講,如果我們有一些無序數據,但是可以被唯一的索引對象來引用(任何可以被散列的類型都可以成為索引對象),那么我們就可以利用字典和集合了。集合其實還是更為特殊一些,我們可以認為集合只是由不包含value的keys所組成的。
字典的查詢作用是比較簡單的,但是在插入數據時則會需要散列函數的幫助。本質上,新插入數據的位置取決於數據的兩個屬性,鍵的散列值以及該值如何跟其他對象比較。這是由於當我們插入數據時,首先需要計算鍵的散列值並掩碼來得到一個有效的數組索引。掩碼是為了保證一個可能是任意數字的散列值最終可以轉化到索引區間中。插入過程中,如果找到了對應的索引位置,但是索引位置對應的值是空的,我們可以將值附上,但是若是索引位置已經被使用,則分成兩種情況,若索引位置中的值與我們希望插入的值相等,則直接返回,或不是相等關系,我們需要嗅探一個新的索引位置,而為了執行嗅探來找到新的位置我們需要使用一個函數計算出新的位置。這個函數實際上我們希望他具有兩個性質,一是對於一定的鍵值其輸出是確定的,不然在指定查找操作的時候我們會有大麻煩,另外就是對於不同的鍵值輸入函數的結果分散,也就是函數的熵應該足夠大。而在對字典性能的影響上,主要需要考慮的是當字典的規模逐漸變大的時候,當越來越多的內容插入散列表的時候,表本身必須改變大小來適應。我們有一個較為普遍的規律,就是一個不超過三分之二滿的表在具有最佳空間節約的同時依然具有不錯的散列碰撞避免率。但是當一個散列表滿的時候,就需要分配一個更大的表,並將掩碼調整為適合新的表,舊表中的所有元素再被重新插入新表。而這中間就需要重新計算索引,所以在對性能進行優化的時候需要對此保持警惕。盡量減少由於散列表長度不夠導致的重復分配。

字典和命名空間

python的命名空間和字典也有很大的關系,事實上python可以說是過度的使用了字典來進行查詢。當python訪問一個變量、函數、模塊的時候,都有一個機制決定如何對這些對象進行查找。順序上,python首先查找locals()數組,其內保存了所有本地變量的條目。實際上python在這里進行了比較大的優化工作,這里也是上面所提到的搜索鏈中唯一不需要字典查詢的部分。而如果python在locals()中沒有查到,則會搜索global()字典,最后則是會搜索__buildin__對象。所以在嘗試優化本地代碼的時候一個可選的方案是去使用本地變量保存外部函數。當然,最好顯示的給出以增加代碼的可讀性。

迭代器和生成器

一開始,我們先潑一盆冷水,事實上生成器並不能為計算效率做啥貢獻,不過確實可以減小內存消耗。另外書中介紹生成器的使用的時候說明了生成器可以與普通函數搭配起來,生成器用於創建數據,而普通函數則負責操作生成的數據。這種功能和邏輯上的划分增加了代碼的清晰度和功能,並且是解耦的體現。除此之外,書中也介紹了生成器帶來的問題,也就是“單通”問題,我們只能訪問當前的值,但是無法訪問數列中的其他元素。但是沒啥可抱怨的,畢竟生成器可以節省內存也就是節省在了這個上面,但是這並不是無法避免的trade-off。可以調用python標准庫中的itertools庫,其中有部分函數可以幫助我們解決這樣的問題(如islice等)。

矩陣和矢量計算

這章節的內容主要是介紹python矢量計算可能存在的瓶頸,並介紹了原因和解決方法。過程是用一個擴散方程作為實例展開講的,其中值得注意的點有幾個。首先是在高密度的CPU計算環境下,內存分配確實不便宜,每次當我們需要內存用於存儲一個變量或列表,Python都必須花時間向操作系統申請更多的內存空間,然后還要遍歷新分配的空間來將他初始化為某個值,所以在變量分配足夠的情況下,應當盡量利用已(或者說復用)已分配的內存空間,這樣會給我們帶來一定的速度提升。另一個點是內存碎片,在前面也提到過,Python對矢量計算的核心問題,Python並不支持矢量操作。這主要是由兩個原因導致的,Python列表存儲的是指向實際數據的指針,而實際的數據則不是在內存中順序存儲的;且Python字節碼本身也並沒有對矢量操作進行優化。上面所講的原因在真是的使用中對矢量操作的影響是相當大的,首先一個簡單的讀取元素的工作就會被分解為先在列表中按照索引找到對應位置,但由於對應位置所存的是值的地址,所以還需要解引用來獲得對應地址的值,另外在更大的粒度上,當我們試圖講數據分為塊,我們只能對單獨的小片分別傳輸,而不能一次性的傳輸整個塊,而我們也就不能預計緩存中會出現怎樣的情況了,糟糕!一個“馮諾依曼瓶頸”出現了。在試圖找到解決方法之前,可以使用linux的perf工具了解CPU是怎樣處理運行中的程序的,親測是個好工具,但是安裝的時候最好確認linux kernel版本一致。而書中由之分析得到的結果也不難理解,矢量計算在我們將相關數據都填入到CPU緩存的時候才會實現。但是由於總線只能移動連續的內存數據,所以只有數據在RAM中是連續存儲時才有可能。Python中的array對象可以在內存中連續存儲數據,但是Python的字節碼問題仍然得不到解決,更何況實際上array類型創建列表實際上比list還要慢,所以我們需要一個好的工具,介紹numpy的時間到了。
numpy能將數據連續的存儲在內存中,並支持數據的矢量操作。而在實際的代碼中盡量使用numpy對應的函數(盡量是特化的函數,畢竟一般專用的代碼要比通用的代碼性能更好),可以帶來性能上的提升。並且實際的代碼中,我們也可以通過使用就地操作來避免內存分配帶來的影響,畢竟內存分配是比緩存失效代價更高的。因為其不僅僅在緩存中找不到數據而需要到RAM中去尋找,並且內存分配還必須像操作系統請求一塊可用的數據保留它。向操作系統進行請求所需的開銷比簡單的填充緩存大很多。畢竟填充一次緩存失效是一個在主板上優化過的硬件行為(前面提到的元組也是類似的機制使得再填充比較快),但是內存分配則需要跟另一個進程、內核打交道。不過比較難受的是,雖然我們可以通過使用numexpr工具來使得就地操作(尤其是連續的就地操作)更直觀更方便,但是就地操作的代碼可讀性仍然比較差。
最后值得一提的是,在進行矢量操作的研究中,最好提前了解下硬件緩存相關的知識,會對理解數據局部性、研究perf結果和程序優化有很大的幫助。

解決動態語言的毛病! 編譯成C

一開始就介紹過,由於動態語言沒有編譯器優化,所以在實際執行代碼的過程中很難提升效率。書中給出了幾種方法來講我們的部分代碼編譯為機器碼。

  • Cython 編譯成C的最通用工具。 支持numpy和標准python 默認gcc
  • shed skin 用於非numpy代碼的,自動將Python轉換成C的轉化器 使用了g++
  • Numba 專用於numpy代碼的新編譯器
  • Pythran 用於numpy和非numpy代碼的新編譯器
  • PyPy 可以取代常規Python(主要是指Cpython)的即時編譯器,但是需要注意的是JIT相對與AOT有冷啟動的問題,不要用JIT來處理短小且頻繁運行的腳本

經過我的考察,在python3中可以獲得比較好使用體驗的應該就是Cython和PyPy,但是PyPy對numpy的支持比較差,之前做了一個numpypy的項目,用於轉化基於Cpython的numpy庫,做了80%的工作之后numpy項目discontinued,取而代之的是使用轉階層的numpy。但是效率嘛,你還要啥自行車啊!不過可惜的是,但從效率方面上看,PyPy對於Cython還是有優勢的,作為JIT普遍效率是Cython的3倍,另外PyPy是永久支持python2的,所以對於不怎么使用numpy這樣的外部庫,或者需要stay in python2的朋友來說可以考慮下PyPy,畢竟純Python環境下,它是最贊得了。不過暗自還是希望以后可以在pypy中安然的使用numpy,畢竟還是對JIT有信心的,萬一JIT帶來的速度提升真的可以抵消掉cpyext的存在呢?
其實到了這章開始私以為才是真的有科技含量的內容,不過也別高興的太好,首先讓我們心里有點b數,我們用過編譯器最多可以帶來怎樣的提升呢?
首先調用外部庫的代碼,哈哈,編譯之后是不會有速度提升的,其次我們我不能寄希望於I/O密集型的代碼可以獲得速度上的提升。也就是說,清醒一點編譯后的代碼不可能比正常編寫的C代碼更快(畢竟我們選擇了python啊)。所以我們在優化代碼的時候勢必會得到一條工作曲線,首先我們剖析代碼來理解程序的行為,隨后開始有依據的修改算法以及使用編譯器獲得性能提升,最后我們終究會意識到工作量的增大指揮帶來很小的回報,是時候收手啦!

Cython

其實其工作過程非常好理解,就是通過運行一個制導的setup.py將.pyc文件轉化為由Cython編譯的C代碼,我們會得到c中間代碼和靜態庫。
使用的時候可以通過Cython自帶的注解選項在瀏覽器通過GUI查看可注解的代碼塊,然后通過增加注解(勢必會失去一些通用性)以及對於計算列表移除boundscheck來優化程序。當然openmp ready也是很好的一點,指定with nogil,然后就可以發車了。

Shed Skin

感覺是個不溫不火的項目,而且對python3支持有限,所以這里沒有做深入的了解,私以為對於一般的python編譯是非常傻瓜式的解決方案,只需要給出一個種子做示例,shed skin就可以自動實現編譯,適合開發者容許用戶修改代碼,然后“舒服”的利用編譯進行加速而不至於要軟件使用者掌握這方面的知識。不過值得注意的是Shed Skin是在一個獨立的內存空間中執行,所以會有額外的內存拷貝的開銷。

PyPy

pypy是我最喜歡的獲得編譯加速的方式,但是由於其對於numpy並不是原生支持,而是通過cpyext做連接(膠水?),所以對於numpy這樣底層使用c的庫不是很友好,但是PyPy是個很活躍的項目,隨着PyPy6.0的到來,實際上可以看到在numpy的性能上已經有了很大的提高,更何況JIT實在強力,我很看好PyPy的發展。

並發

主要是為了解決I/O對程序執行流的影響,並發可以讓我們在等待一個I/O操作完成的時候進行其他操作,可以讓我們將這個時間利用起來。這次我們的目標是解決I/O等待。
深入來看,當一個程序進入I/O等待之后,會暫停執行,這樣內核就能執行I/O請求相關的低級操作(也就是完成了一次上下文切換),直到I/O操作完成時才會繼續。但是上下文切換是重量級的操作,需要消耗很大資源。具體來說,上下文切換需要我們先保存程序的狀態(換言之,也會使得我們丟失了CPU層面上的任何類型的緩存),並退出CPU的使用。這樣,我們之后被再次允許運行的時候,就必須花時間重新初始化程序並准備好繼續運行。
那我們一般使用並發的思路是怎樣的呢?典型情況,我們需要一個“事件循環”來管理程序中應該運行什么,什么時候運行。實質上,一個事件循環只是需要運行的一個函數列表(或者說隊列?)。在列表頂端的函數得到執行,接着輪到下一個,and so on。但是這樣的操作實際上也是有代價的,畢竟函數之間的切換也是有開銷的,內核必須花費時間來設置在內存中被調用的函數,而且緩存的狀態也無法預測。但是在程序有很多的I/O等待時,函數切換可以大大的將I/O等待的時間利用起來,相比於其開銷,總體的性能會有較大的提升。
而前面所提到的時間循環編程主要也是有兩種方式:回調或future。
主要可以使用到的異步庫有下面這些:

  • gevent 主要邏輯遵照了讓異步函數返回future的模式,代碼中大部分邏輯會保持一致
  • tornado 使用回調方式實現異步行為
  • AsynclO

多進程

之前已經介紹過GIL和它給我們優化代碼帶來的危害。但是一個GIL的作用空間就是一個對應的python進程,當我們同時運行好幾個python進程的時候並不會受到GIL的影響(當然我們就需要轉而考慮消息傳遞的問題了)。
python的multiprocessing module可以喔讓我們使用基於進程和基於線程的並行處理,在隊列上共享任務,以及在進程上共享數據。其主要針對的是單機多核的問題,比較廣泛的應用空間解決CPU密集型問題。其實前面也介紹過使用cython編譯C代碼來使用openmp框架,這里介紹的multiprocessing是工作在更高的層次上,在我們需要廣泛的使用如numpy之類的python庫做並行計算時的選擇。

multiproceing module可以做一些典型的工作:

  • 用進程和pool對象來並行化一個CPU密集型任務
  • 用dummy module在線程池中並行化一個I/O密集型任務
  • 由隊列共享捎帶的工作
  • 在並行worker之間共享狀態,可共享的數據類型由字節,原生數據類型,字典和列表

multiproceing module的主要組件有:

  • 進程,一個當前進程的派生拷貝,創建了一個新的進程標識符,並且任務在操作系統中以一個獨立的子進程運行。開發者可以啟動並查詢進程的狀態並給它提供一個運行的方法。但是也由於以上的特點,我們在使用的時候應當小心如生成隨機數這樣的操作(由於派生拷貝的問題,各個進程生成的隨機數可能是完全一樣的:-)
  • 池,pool,包裝了進程和線程。在一個worker線程池中共享了一塊工作並返回聚合的結果。
  • 隊列
  • 管理者,一個單向或雙向的在兩個進程間的通信渠道
  • ctypes,允許在進程forked之后,在父子進程間共享原生數據類型
  • 同步原語

書中分別介紹了兩個較有代表性的並行化例子。蒙特卡洛方法求pi和尋找素數。前者任務負載是均衡的,后者則在不同的計算區間是不平均的。第一個例子中,在實行分析時可以發現,超線程資源是一個很小的附加值;當然最大的問題是使用超線程的CPython使用了很多RAM,畢竟超線程不是緩存友好的,所以對每個芯片上剩余資源的利用率很低,所以一般情況下,我們可以將超線程視作一個附加值,而不是一個優化的目標,更多的情況下,考慮適當的通信壓力,增加CPU才是王道。
在第二個尋找素數的例子中,作者總結了一些解決棘手的並行問題的策略:

  • 將工作分成獨立的工作單元
  • 對於尋找素數這樣的worker問題空間不平均的問題,可以采用隨機化工作序列的方法
  • 對工作隊列進行排列,盡量使平均時間最少,甚至是先使用串行做預檢來避免並行部分的開銷
  • 要是沒有好的理由,最好老老實實使用默認的chunksize

集群和工作隊列

實際上對於一般問題的處理,一台計算機上需要花的心思是遠遠少於一個集群的,所以首先要確定這個時間和精力花的值得。而python有比較成熟的三個集群化的解決方法,分別是parallel python、IPython parallel和NSQ。當然這三者入門的難易程度基本上也是這個順序。

  • parallel python,接口易於上手,將multiprocessing的接口稍作修改就可以,但是功能不是很強勁
  • Ipython集群則是由於支持MPI,並且調試比較方便(畢竟是以交互的方式運行的)
  • NSQ 一個高性能的分布式消息平台 魯棒性比較強 但是相應的實現難度也更高

結尾

其實這本書的內容總的來說還是十分豐富的,基本上我們想用python將程序運行的更快的方法都介紹了(也許少了點硬件配置,超頻?linux優化?)。並且給讀者一個很大的提醒,就是在解決高性能計算問題的時候,實戰經驗是非常重要的,書中的內容光看是吃不透的。另外,自以為為了達成程序調優的目的操作系統和計算機組成還是繞不開的兩個必須次重要知識體系,所以有時間還是需要看看相關的材料。
最后,感謝nathanmarz的blog:You should blog even if you have no readers讓我重新有了寫blog的動力。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM