一文讀懂:GPU是如何運作的?

今天我們來詳細聊聊GPU的工作原理。

隨著AI、HPC的快速成長,GPU加速運算已成為推動科學發展的關鍵力量,在天文學、物理學等研究領域,GPU加速的AI正在幫助科學家解決前所未有的複雜問題。


與CPU相比,GPU在設計上更擅長處理大量平行任務,這使得它們在執行運算密集型任務時表現的更出色。今天我們先從GPU的運作機制和設計原理來聊聊為什麼GPU在平行運算的時候更有效率。


 處理器的三個組成部分

我們知道,任何處理器內部都是由三個部分組成,分別為算術邏輯單元(ALU)、控制單元和快取。但CPU(Central Processing Unit)和GPU(Graphics Processing?Unit)是兩種不同類型的電腦處理器。


簡單來說,CPU更善於一次處理一項任務,而且GPU則可以同時處理多項任務。這是因為CPU是為延遲最佳化的,而GPU則是頻寬最佳化的。就好比有些人善於依序一項項執行任務,有些人可同時進行多項任務。

我用打比方來通俗的解釋二者的區別。 CPU就好比一輛摩托車賽車,而GPU則相當於一輛大巴車,如果二者的任務都是從A位置將一個人送到B位置,那麼CPU(摩托車)肯定會更快到達,但是如果將100個人從A位置送到B位置,那麼GPU(巴士)由於一次可以運送的人更多,則運送100人需要的時間更短。


換句話說,CPU 單次執行任務的時間更快,但是在需要大量重複工作負載時,GPU 優勢就越顯著(例如矩陣運算:(A*B)*C)。因此,雖然CPU單次運送的時間更快,但是在處理影像處理、動畫渲染、深度學習這些需要大量重複工作負載時,GPU優勢就越顯著。

綜上所述,CPU 是個集各種運算能力的大成者。它的優點在於調度、管理、協調能力強,並且可以做複雜的邏輯運算,但由於運算單元和內核較少,只適合做相對少量的運算。GPU 無法單獨運作,它相當於一大群接受CPU 調度的管線員工,適合做大量的簡單運算。 CPU 和GPU 在功能上各有所長,互補不足,透過相互配合使用,達到最佳的運算效能。

那麼是什麼導致CPU和GPU運作的方式不同呢?那還要從二者設計理念來說。


FLOPS並不是核心問題?

FLOPS每秒浮點運算次數(FLoating point Operations Per Second,簡稱FLOPS)是基於處理器在一秒鐘內可以執行的浮點算術計算數量,經常用來衡量電腦效能的指標。雖然大家常問一個設備的FLOPS是多少,但其實這並不是核心問題。


我們可以換一種說話,就是雖然有一些專家或特定演算法的時候會特別關注FLOPS。但FLOPS其實並不是大眾關心的焦點。


為什麼會這樣說呢?我們以上圖為例,讓我們來看看CPU的運作情況:CPU能以大約2000 GFLOPs FP64的速度進行運算,但記憶體卻只能以200 GB/s的速度向CPU提供數據,這是現代處理器的典型性能。於是當CPU想要每秒處理2兆個雙精度數值,但記憶體每秒只能提供250億個。這時候就會產生設備的「運算強度」不平衡,這個時候就需要CPU設備需要付出多少努力來彌補記憶體提供資料的速度不足。

否則,處理器就會因為閒置造成浪費,陷入所謂的「記憶體頻寬限制」模式。事實上,至少有四分之三甚至更多的程式在實際運行中都會受到記憶體頻寬的限制,因為很少有演算法能在每次資料載入時完成足夠的運算來充分利用硬體效能。這時購買更便宜的CPU或許更為合適。

這種高計算強度要求對於大多數演算法來說都是難以達到的。實際上,只有矩陣乘法這類特殊演算法能滿足這項要求。接下來我們看下GPU是怎麼來彌補這個計算強度的。


透過上面的表格,我們比較GPU和CPU幾個不同進程的效能。你會發現,雖然NVIDIA晶片擁有更高的FLOPS,但是他們計算強度幾乎相同,這是因為NVIDIA配備了更高頻寬的記憶體以保持平衡。

其實,每一代GPU在增加FLOPS方面的速度往往超過了增加記憶體頻寬的速度。這導致計算強度不斷上升,為演算法程式設計帶來了更大的挑戰。這就需要GPU不斷努力優化演算法,以確保這些強大的晶片能夠保持高效運作。因為很少有演算法能在每次資料載入時完成足夠的運算來充分利用硬體效能。

當然,高記憶體支援和程式碼最佳化並不是GPU效能優勢的全部,我們還需要看一下延遲。我們來深入談談延遲這個概念。為何延遲如此關鍵呢?


為何延遲如此關鍵?

延遲,讓我們透過一個時間線來直觀地理解。從最基礎的運算操作來看:ax + y。首先,要載入變數x。接著,加載y。因為運算是a乘以x再加上y。所以,會同時發起對y的載入請求。然後,會經歷一段相當長的等待時間,直到x的資料回傳。這段時間往往是空閒的,也就是我們所說的延遲,這樣就導致計算非常不高效。


雖然這個時間很短,也可能被其它有用的計算工作所掩蓋,不會造成明顯的延遲。但處理器編譯器實際上花費了大量精力來進行管線優化,確保資料載入盡可能早地發起,以便被其它計算操作所覆蓋。這種管線處理是大多數程式效能最佳化的關鍵,因為記憶體存取的延遲往往比運算延遲要大得多。


那為什麼會這樣呢?

這是因為在一個時鐘週期內,光只能傳播很短的距離。考慮到晶片的尺寸,電訊號從晶片的一側傳輸到另一側可能需要一個或多個時脈週期。因此,物理定律成為了限制性能的關鍵因素。尤其是當需要從記憶體中取得資料時,資料的往返傳輸可能就需要十到二十個時脈週期。


延遲就意味著花費了大量時間等待資料的到來。

在之前提到CPU經常處於空閒狀態,因為記憶體延遲導致它無法保持忙碌。儘管CPU擁有強大的運算能力(即FLOPS),但我希望記憶體能夠與之匹配,確保資料能夠及時到達。


以Xeon 8280為例,這款CPU擁有131GB的記憶體和89奈秒的延遲。當記憶體頻寬為131GB/s時候,那麼在一個記憶體延遲週期內,只能移動約11659位元組的資料。這似乎還不錯,但當我們考慮到DAXPY操作只載入了兩個8位元組的值(即x和y),總共只有16位元組時,效率就顯得非常低下,只有0.14%。這顯然不是一個好的結果。即使有高頻寬的記憶體來應對計算強度,實際上幾乎沒有利用到它的優勢。為高效能的CPU和記憶體付出了龐大的成本,但結果卻並不理想。


這是因為程式受到了延遲綁定的影響,這是一種常見的記憶體限制形式,其發生的頻率遠高於我們的想像。這也解釋了為什麼我對FLOPS並不太關心,因為即使記憶體頻寬無法充分利用,計算單元更是無法忙碌起來。

如果我將11659位元組的資料除以16位元組(即DAXPY操作載入x和y所需的總位元組數),發現需要同時執行729個DAXPY迭代,才能讓花在記憶體上的錢物有所值。因此,面對這種低記憶體效率,需要同時處理729個操作。


這時候,就需要並發來解決這個問題了。並發,顧名思義,就是同時進行許多事情。但請注意,這些操作不必是嚴格同時發生的,它們只需要能夠獨立進行。 GPU編譯器有一種最佳化手段叫做循環展開,它能夠辨識出可以獨立執行的部分,並將它們連續地發出,從而提高執行效率。

但是在實際循環進行的最佳化方式受限於硬體能夠同時追蹤的操作數量,幾乎是不可能完成的。在硬體的管線中,它只能同時處理有限數量的事務,超出這個數量就必須等待先前的事務完成。因此,循環展開確實有益,它可以讓管線更加飽滿,但顯然它也受到機器架構中其它多種因素的限制。


這個時候,就需要看硬體的所能支援的最大執行緒數了,這意味著多個操作是真正同時發生的。 GPU在這方面做了很好的支援。


線程在GPU中扮演什麼角色?

GPU與CPU之間非常值得關注的差異點,GPU的延遲和頻寬需求比CPU高得多,這意味著它需要大約40倍的執行緒來彌補這種延遲。但實際上,GPU擁有的執行緒數量比其它類型的處理器多100倍。因此,在實際應用中,GPU的表現反而較好。


實際上,GPU擁有的執行緒數量比實際運算所需的多出五倍半,而其它類型的CPU,它們的執行緒數量可能只夠覆蓋1.2吋範圍內的操作,這就是GPU設計中最為關鍵的一點。如果你從這次講解中只能記住一件事,那就是:GPU擁有大量的線程,遠超過它實際需要的數量,這是因為它被設計為「超量訂閱」(oversubscription)。它旨在確保有大量線程在同時工作,這樣即使某些線程在等待記憶體操作完成,仍然有其它線程可以繼續執行。

GPU通常被稱為“吞吐量機器”。 GPU的設計者將所有的資源都投入到了增加執行緒數量而不是減少延遲。相較之下,CPU則更側重於減少延遲,因此它通常被稱為「延遲機器」。

CPU期望單一執行緒能夠完成大部分工作。在CPU中切換線程(從一個線程切換到另一個線程)是一個資源消耗高的操作,它涉及到上下文切換,因此只需要足夠的線程來覆蓋記憶體延遲。

所以,CPU的設計者將所有資源都投入了減少延遲而不是增加執行緒數量。

GPU和CPU在執行緒上的解決方法是完全相反的,雖然它們都是用來解決相同的延遲問題,但實際上也是GPU和CPU在運作方式和運作方式上的根本差異所在。記住,GPU設計者透過增加執行緒數量來對抗延遲,而不是透過減少延遲來降低延遲。

另外,要注意的是GPU是被超量訂閱的。這意味著,當一些執行緒在等待讀取資料時,其它執行緒已經完成了讀取並準備執行。這就是GPU運作原理的關鍵。它可以在一個時鐘週期中輕鬆地在不同的warp之間切換,因此幾乎沒有上下文切換的開銷。它可以連續運行線程。這意味著,為了彌補延遲,GPU需要保持的活躍執行緒數要遠遠超過系統在任何時候能夠運行的執行緒數。這與CPU的工作方式截然不同,對於CPU來說,它永遠不希望線程過多。

除了執行緒上的不同,記憶體也是GPU工作的極為關鍵的因素,這是因為所有的程式設計工作都是圍繞著記憶體展開的。


 GPU記憶體需要夠大

GPU為每個執行緒分配了大量的暫存器來儲存即時數據,從而實現了非常低的延遲。這是因為與CPU相比,GPU中每個執行緒都需要處理更多的數據,因此它需要能夠快速存取這些數據。所以,GPU需要一種靠近其計算核心的快速內存,並且這種內存需要足夠大,以便能夠存儲進行有用計算所需的所有數據。

不僅如此,當你發出一個載入操作(例如將某個指標的值載入到變數x中)時,硬體需要一個地方來暫存這個載入結果。所以,當說從記憶體中載入資料時,我實際上是指將這個載入結果放入暫存器中,這樣就可以對它進行計算了。而GPU所擁有的暫存器數量直接決定了它能夠同時處理的記憶體操作數量。


GPU的主記憶體就是高頻寬的HBM記憶體。如果我把GPU主記憶體的頻寬看成一個單位,無論它有多快,都只能算一。而L2緩存頻寬則是它的五倍,L1緩存,也就是我即將提到的共享內存,更是快了13倍。因此,隨著頻寬的增加,它更容易滿足計算強度的需求,這無疑是一件好事。如果可能的話,大家希望能充分利用快取來滿足運算強度。

我們再來看一下每個記憶體層在操作時所需的計算強度。對於HBM,我們之前看過的計算強度是100。而L2快取的計算強度則好得多,只需要39次載入操作,L1快取更是只需要8次,這是一個非常可實現的數字。這就是為什麼L1快取、共享記憶體和GPU如此有用的原因,因為我實際上可以讓資料足夠接近運算核心,從而有意義地進行8次操作並充分利用FLOP。所以,如果可以的話,所有資料都能從快取中讀取帶來的提升是最有價值的。

但是要注意的是,PCIe的頻寬很有限,延遲又很大。 N VLink在效能上比PCIe更接近主記憶體。這也是為什麼NVLink作為晶片之間和GPU之間的互連方式,比PCIe匯流排好得多的原因。


通俗解說GPU的工作原理

好了,看了上面複雜的內容,讓我們來透過一些形象的例子來了解GPU的運作機制。首先我們來談談吞吐量和延遲。首先我們來打個比方,例如這個人住在舊金山,但在聖克拉拉工作。


這時候這個人上班就有兩種方式選擇。可以開車,只需要45分鐘,或可以搭火車,需要73分鐘。


這個時候,汽車是為減少延遲而設計的,但火車是一個吞吐量機器。想像一下,開車的優點是在於它盡量快速地完成一次旅程,但並沒有真正幫助其他人。它速度快,但效率不高,只能載少數人,只能從一個地方到另一個地方。另一方面,火車可以載很多人,而且它能夠在很多地方停靠,所以沿途的所有人都可以藉助火車來上班。可而且設置很多列火車來運輸乘客。

這時候,火車不同班次就相當於GPU的延遲系統,被超量訂閱,性能就會大打折扣。但如果路上的車太多,交通陷入癱瘓,汽車沒人能順利到達目的地。但同樣,如果火車已經滿員,你只需要等待下一班。而且,與汽車不同,火車延誤通常不會太久,因為總有下一班火車可以搭乘。

所以,GPU其實可以看作是吞吐量機器,它的設計初衷是能夠處理比它一次運作的工作多得多的任務。這就像火車系統,如果火車沒有滿載,那就沒有充分利用其運輸能力。對於GPU來說也是如此,吞吐量系統通常希望有深度的等待佇列。火車公司其實希望你在月台上等待,因為如果火車到站時月台上沒有人,車廂沒有滿載,那他們就是在浪費資源。 GPU也是如此,它需要保持忙​​碌狀態,才能充分發揮其效能。

CPU則更偏向於一個延遲機器。切換執行緒需要消耗資源,所以CPU希望每個執行緒都能盡快完成其任務。但如果任務太多,系統就會陷入停滯。因此,CPU的目標是盡快完成每個任務,然後為下一個任務騰出空間。這就像我們希望車輛在路上暢通無阻,而不是停滯不前,因為道路上的車輛數量是有限的。簡而言之,我們利用這些執行緒來解決延遲問題,這是一個非常有效的策略。

現在我們已經了解了延遲問題,接下來看看頻寬的挑戰。由於整個系統都是基於吞吐量的設計,GPU通常會超量訂閱資源。這意味著GPU總是有任務在執行,記憶體也不斷地被存取。

在這個過程中,我們必須考慮非同步性。很重要的一點是,CPU和GPU是獨立的處理器,這意味著它們可以同時處理不同的任務,而且應該這樣做。如果CPU停下來等待GPU,或GPU停下來等待CPU,那麼整個系統的效率就會下降。這就像每個站點都要等待下一班火車才能繼續前行,這樣顯然不如只有一個高效的處理器。

非同步性的重要性在於它讓所有的處理器都在工作,沒有人停下來等待。 CPU可以向GPU發送工作指令,然後繼續執行其它任務,而GPU則獨立地處理這些任務。我們只需要等待最終的結果。


為了更形象化地解釋這個概念,我們可以想像一下道路交通。如果你想一次移動很多東西,那麼你需要更多的車道,就像右邊的道路一樣。這樣的交通是異步的,每個車輛都可以獨立地前進,不會被前面的車輛阻塞,因為車道夠多。相反,如果交通是同步的,那麼只有一條車道,所有的車輛都必須等待最慢的那輛車,效率就會大打折扣。因此,非同步性對於我們追求的高吞吐量至關重要。


然而,在現實世界中,很少有工作是完全獨立的。 DAXPY就是一個很好的例子。這些被稱為逐元素(element-wise)演算法,只有最簡單的演算法才能以這種方式運作。大多數演算法至少需要一個或多個元素,例如卷積操作,它會考慮影像中的每個像素及其鄰居。還有一些演算法,如傅立葉變換,需要每個元素與其它每個元素互動。這些被稱為全對全演算法,它們的行為方式與逐元素演算法截然不同。


GPU工作中是如何取得吞吐量的?

現在,讓我們一起看下GPU上並行處理的工作原理,以及GPU是如何獲得所需的吞吐量的。


我們假設訓練了一個AI來辨識網路上的貓。現在,我們有一張貓的圖片。我會在這張圖片上覆蓋一個網格,這個網格將圖片分割成許多工作區塊。然後,我會獨立地處理每個工作區塊。這些工作區塊是彼此獨立的,它們在圖片的不同部分工作,而且工作區塊的數量非常多。因此,GPU會被這些工作區塊過度訂閱。但請記住,過度訂閱是我們追求高效執行和最大記憶體使用的策略。


在每個工作區塊中,都有許多執行緒共同工作。這些線程可以共享資料並完成共同的任務。所有的執行緒都同時並行運行,這樣GPU就能夠實現高效的平行處理。現在,已經建構了層次結構。在最高層,有總工作量,它透過網格被分解成工作塊,這些工作塊為GPU提供了所需的過度訂閱。然後,在每個工作區塊中,都有一些本地線程,它們一起協同工作。透過這種方式,能夠充分利用GPU的平行處理能力,實現高效率的吞吐量。


當我們訓練了一個AI來處理圖像。這些執行緒協同工作,它們在各自的分片(tile)上工作,組成一個個區塊。請記住,每個區塊都以自己的速度獨立運行,最終,整個影像會被處理完成。

在GPU上,工作是以網格的形式運行的,這些網格進一步被分解成線程塊。每個區塊都擁有並行運行的線程,確保它們能夠同時處理任務並共享資料。然而,所有的區塊都是獨立調度的,這種模式被稱為過度訂閱。


這帶來了兩種最佳的運算的結合。它既能保持機器的忙碌狀態,又能夠提供所需的吞吐量,同時還允許線程之間進行必要的互動。這就是GPU編程的精髓:將問題分解成多個區塊,在這些區塊中,協作的執行緒共同處理任務,每個區塊都保持相對的獨立性。

好吧,就到這裡吧,我們我們已經詳細介紹了GPU的工作原理,延遲被超量訂閱所掩蓋,但其實延遲實際上是GPU成功的關鍵。所有這些——大量的線程、超量訂閱、網格和區塊的程式設計模型,以及在區塊中運行的線程——它們都是為了對抗延遲而存在的。如今NVIDIA GPU已經做到了,並且取得了成功,但現在我們受到了頻寬的限制,這是接下來NVIDIA研發的重點。(芯師爺)