天天看點

《Python高性能程式設計》——第1章 了解高性能Python 1.1 基本的計算機系統

本節書摘來自異步社群《python高性能程式設計》一書中的第1章,第1.1節,作者[美] 戈雷利克 (micha gorelick),胡世傑,徐旭彬 譯,更多章節内容可以通路雲栖社群“異步社群”公衆号檢視。

讀完本章之後你将能夠回答下列問題

計算機架構有哪些元素?

常見的計算機架構有哪些?

計算機架構在python中的抽象表達是什麼?

實作高性能python代碼的障礙在哪裡?

性能問題有哪些種類?

計算機程式設計可以被認為是以特定的方式進行資料的移動和轉換來得到某種結果。然而這些操作有時間上的開銷。是以,高性能程式設計可以被認為是通過降低開銷(比如撰寫更高效的代碼)或改變操作方式(比如尋找一種更合适的算法)來讓這些操作的代價最小化。

資料的移動發生在實際的硬體上,我們可以通過降低代碼開銷的方式來了解更多硬體方面的細節。這樣的練習看上去可能沒什麼用,因為python做了很多工作将我們對硬體的直接操作抽象出來。然而,通過了解資料在硬體層面的移動方式以及python在抽象層面移動資料的方式,你會學到一些編寫高性能python程式的知識。

一台計算機的底層元件可被分為三大基本部分:計算單元,存儲單元,以及兩者之間的連接配接。除此之外,這些單元還具有多種屬性幫助我們了解它們。計算單元有一個屬性告訴我們它每秒能夠進行多少次計算,存儲單元有一個屬性告訴我們它能儲存多少資料,還有一個屬性告訴我們能以多快的速度對它進行讀寫,而連接配接則有一個屬性告訴我們它們能以多快的速度将資料從一個地方移動到另一個地方。

通過這些基本單元,我們就可以在各種不同的複雜度級别上描述一個标準工作站。例如,一個标準工作站可以被看作是具有一個中央處理單元(cpu)作為其計算單元,兩個獨立的存儲單元,分别是随機通路記憶體(ram)和硬碟(各自有不同的容量和讀寫速度),最後還有一個總線将所有這些連接配接在一起。然而,我們還可以深入cpu并發現其内部也有多個存儲單元:l1、l2,有時甚至有l3和l4緩存,它們的容量較小(從幾kb到十幾mb)但速度非常快。這些額外的存儲單元通過一個被稱為後端總線的特殊總線連接配接cpu。另外,新的計算機架構通常會有新的配置(比如intel的nehalem架構的cpu用英特爾快速通道互聯技術替換了前端總線并重新建構了很多連接配接)。最後,在上述案例的讨論中,我們還忽略了網絡連接配接,這是一種慢速的連接配接,用于連接配接其他許多潛在的計算單元和存儲單元。

為了幫助理清這些錯綜複雜的結構,讓我們去浏覽一下這些基本單元的簡要描述。

一台計算機的計算單元是其中央部件——它具有将接收到的任意輸入轉換成輸出的能力以及改變目前處理狀态的能力。cpu是最常見的計算單元;然而,最初被設計用于加速計算機圖像處理的圖形處理單元(gpu)現在變得更加适用于數值計算了,這是因為其自身的并行模式使得大量計算能并發進行。無論哪種類型,一個計算單元都會接收一系列比特(比如代表數字的比特)并輸出另外一堆比特(比如代表這些數字之和的比特)。除了實數的基本算數操作和二進制的比特操作以外,一些計算單元還提供了非常特殊的操作,比如“乘加混合計算”,接收三個數字a、b、c并傳回a * b + c的值。

計算單元的主要屬性是其每個周期能進行的操作數量以及每秒能完成多少個周期。第一個屬性通過每周期完成的指令數(ipc)[1]來衡量,而第二個屬性則是通過其時脈速度衡量。當新的計算單元被制造出來時,它們的這兩個參數總是互相競争。比如intel的core系列具有非常高的ipc但時脈速度較低,而pentium 4的晶片則完全相反。不過話又說回來,gpu的ipc和時脈速度都很高,但它們有别的問題,我們後面會提到。

另外,當時脈速度提高時,能夠立即提高該計算單元上所有的程式運作速度(因為它們每秒能進行更多運算),而提高ipc則在矢量計算能力上有相當程度的影響。矢量計算是指一次提供多個資料給一個cpu并能同時被操作。這種類型的cpu指令被稱為simd(單指令多資料)。

總之,計算單元在過去十年的進展頗為有限(圖1-1)。時脈速度和ipc的提升都限于停滞,因為半導體已經小到了實體的極限。結果就是晶片制造商開始依靠其他手段來獲得更高的速度,包括超線程技術,更聰明的亂序執行和多核架構。

《Python高性能程式設計》——第1章 了解高性能Python 1.1 基本的計算機系統

超線程技術為主機的作業系統(os)虛拟了第二個cpu,而聰明的硬體邏輯則試圖将兩個指令線程交錯地插入單個cpu的執行單元。如果成功插入,能比單線程提升30%。一般來講,當兩個線程的工作分布在不同的執行單元上時,這樣做效果不錯——比如一個操作浮點而另一個操作整數時。

亂序執行允許編譯器檢測出一個線性程式中某部分可以不依賴于之前的工作,也就是說兩個工作能夠以各種順序執行或同時執行。隻要兩個工作的成果都能夠在正确的時間點上依次得到,哪怕它們的計算次序跟程式設計不同,程式也能繼續正确運作。這使得當一些指令被阻塞時(比如等待一次記憶體通路),另一些指令得以執行,以此來提升資源的使用率。

最後也是對于進階程式員來說最重要的是多核架構的普及。這些架構在同一個計算單元中包含了多個cpu,提高了總體計算能力,而且無須等待記憶體屏障,讓單個核心可以跑得更快。這就是為什麼現在已經很難找到少于雙核的計算機了——雙核意味着計算機有兩個互連的實體計算單元。雖然這增加了每秒可以進行的操作總數,但是想要讓兩個計算單元都達到最大使用率的話還需要考慮很多錯綜複雜的因素。

給cpu增加更多的核心并不一定能提升程式運作的速度。這是由阿姆達爾定律決定的。簡單地說,阿姆達爾定律認為如果一個可以運作在多核上的程式有某些執行路徑必須運作在單核上,那麼這些路徑就會成為瓶頸導緻最終速度無法通過增加更多核心來提高。

比如,假設我們有一個調查需要100個人參與,該調查需要花費1分鐘,如果我們隻有一位提問者(該提問者向第一位參與者提問,等待回答,然後移向下一位參與者),那麼我們可以用100分鐘完成這個任務。這個單人提問并等待回答的流程就是一個順序執行的過程。對于一個順序執行的過程,我們每次隻能完成一個操作,後面的操作必須等待前面的操作完成。

然而,如果我們有兩位提問者,他們就可以同時進行測試,用50分鐘完成任務。這是由于兩位提問者之間不需要互相了解,沒有依賴關系,是以整個任務就很容易劃分。

增加更多提問者可以進一步提速,直到我們有100位提問者。此時,整個流程僅需要1分鐘就可以完成,僅取決于參與者回答問題所需要的時間。繼續增加提問者将不會帶來任何速度提升,因為這些多餘的提問者無事可幹——所有的參與者都已經在接受調查!此時,唯一能夠降低整體時間的辦法是降低單個參與者完成調查的時間,也就是降低順序部分所需要的執行時間。同樣,對于cpu,我們可以增加更多的核心直到某個必須單核執行的任務成為瓶頸。也就是說,任何并行計算的瓶頸最終都會落在其順序執行的那部分任務上。

另外,對于python來說,充分利用多核性能的阻礙主要在于python的全局解釋器鎖(gil)。gil確定python程序一次隻能執行一條指令,無論目前有多少個核心。這意味着即使某些python代碼可以使用多個核心,在任意時間點僅有一個核心在執行python的指令。以前面調查的例子來說,即使我們有100位提問者,然而一次僅有一位可以提問和接受回答,并沒有什麼用!這看上去是個嚴重的阻礙,特别是當現在計算機發展的趨勢就是擁有更多而非更快的計算單元的時候。好在這個問題其實可以通過一些方法來避免,比如标準庫的multiprocessing,或numexpr、cython等技術,或分布式計算模型等。

計算機的存儲單元被用于儲存比特。這些比特可能代表程式中的變量,或一幅圖檔的像素。存儲單元的概念包括了主機闆上的寄存器、ram以及硬碟。所有這些不同類型的存儲單元的主要差別在于其讀寫資料的速度。更複雜的問題在于,其讀寫資料的速度還與資料的讀寫方式息息相關。

比如,大多數存儲單元一次讀一大塊資料的性能遠好于讀多次小塊資料(順序讀取vs随機資料)。将這些存儲單元中的資料想象成一本書中的書頁,大多數存儲單元的讀寫速度在連續翻頁時高于經常從一張随機頁跳至另一張随機頁。

所有的存儲單元或多或少都受到這一影響,但不同類型存儲單元受到的影響卻大不相同。

除了讀寫速度以外,存儲單元還有一個延時的屬性,表示了裝置為了查找到需要的資料所花費的時間。一個旋轉硬碟的延時可能較高,因為磁盤必須實體旋轉到一定速度且讀取磁頭必須移動到正确的位置。而從另一方面來說,ram的延時就比較小,因為一切都是固态的。下面是一個标準工作站内常見的各類存儲單元的簡短描述,以讀寫速度排序:

旋轉硬碟

計算機關機也能保持的長期存儲。讀寫速度通常較慢,因為磁盤必須實體旋轉和等待磁頭移動。随機通路性能下降但容量很高(tb級别)。

固态硬碟

類似旋轉硬碟,讀寫速度較快但容量較小(gb級别)。

ram

用于儲存應用程式的代碼和資料(比如用到的各種變量)。具有更快的讀寫速度且在随機通路時性能良好,但通常受限于容量(gb級别)。

l1/l2緩存

極快的讀寫速度。進入cpu的資料必須經過這裡。很小的容量(kb級别)。

圖1-2展示了當今市面上可以見到的這幾類存儲單元的差別。

一個清晰可見的趨勢是讀寫速度和容量成反比——當我們試圖加快速度時,容量就下降了。是以,很多系統都實作了一個分層的存儲:資料一開始都在硬碟裡,部分進入ram,然後很小的一個子集進入l1/l2緩存。這種分層使得程式可以根據通路速度的需求将資料儲存在不同的地方。在試圖優化程式的存儲通路模式時,我們隻是簡單優化了資料存放的位置、布局(為了增加順序讀取的次數),以及資料在不同位置之間移動的次數。另外,異步i/o和緩存預取等技術還提供了很多方法來確定資料在被需要時就已經存在于對應的地方而不需要浪費額外的計算時間——這些過程可以在進行其他計算時獨立進行!

《Python高性能程式設計》——第1章 了解高性能Python 1.1 基本的計算機系統

最後,讓我們看看這些基本單元如何互相通信。通信有很多模式,但它們都是同一樣東西的變種:總線。

比如說,前端總線是ram和l1/l2緩存之間的連接配接。它将已經準備好被處理器操作的資料移入一個集結場是以備計算所需,又将計算結果移出。除此之外還有其他總線,如外部總線就是硬體裝置(如硬碟和網卡)通向cpu和系統記憶體的主幹線。該總線通常比前端總線慢。

事實上,l1/l2緩存的很多好處實際上是來自更快的總線。因為可以将需要計算的資料在慢速總線(連接配接ram和緩存)上攢成大的資料塊,然後以非常快的速度從後端總線(連接配接緩存和cpu)傳入cpu,這樣cpu就可以進行更多計算而無須等待這麼長的時間。

同樣,使用gpu的不利之處很多都來自它所連接配接的總線:因為gpu通常是一個外部裝置,它通過pci總線通信,速度遠遠慢于前端總線。結果,gpu資料的輸入輸出就像是一種抽稅操作。異質架構,一種在前端總線上同時具有cpu和gpu的計算機架構的興起就是為了降低資料傳輸成本,使得gpu能夠被使用在需要傳輸大量資料的計算上。

除了計算機内部的通信子產品,網絡可以被認為是另一種通信子產品。不過這個子產品就比之前讨論的更為靈活,一個網絡裝置可以直接連接配接至一個儲存設備,如網絡連接配接存儲(nas)裝置或計算機叢集中的另一台計算機節點。但是網絡通信通常要比之前讨論的其他類型的通信慢很多。前端總線每秒可以傳輸數十gb,而網絡則僅有數十mb。

現在我們清楚,一條總線的主要屬性是它的速度:在給定時間内它能傳輸多少資料。該屬性由兩個因素決定:一次能傳輸多少資料(總線帶寬)和每秒能傳輸幾次(總線頻率)。需要說明的是一次傳輸的資料總是有序的:一塊資料先從記憶體中讀出,然後被移動到另一個地方。這就是為什麼總線的速度可以被拆分為兩個因素,因為這兩個因素分别獨立影響計算的不同方面:高的總線帶寬可以一次性移動所有相關資料,有助于矢量化的代碼(或任何順序讀取記憶體的代碼),而另一方面,低帶寬高頻率有助于那些經常随機讀取記憶體的代碼。有意思的是,這些屬性是由計算機設計者在主機闆的實體布局上決定的:當晶片之間相距較近時,它們之間的實體鍊路就較短,就可以允許更高的傳輸速度。而實體鍊路的數量則決定了總線的帶寬(帶寬這個詞真的具有實體上的意義!)。

由于實體接口可以針對某個特定應用優化,是以我們不會奇怪世上存在成百上千種不同類型的連接配接。圖1-3顯示了一些常見接口的比特率。注意這圖上完全沒提到連接配接的延時,延時決定了一個連接配接響應資料請求花費的時間(雖然延時跟具體的計算機系統息息相關,但是有來自實體接口的一些基本限制)。

《Python高性能程式設計》——第1章 了解高性能Python 1.1 基本的計算機系統