渲染管線之旅|02 GPU內存架構和命令處理器

系列文章原發布在自己搭建的博客上:https://binean.top/

上一篇中主要介紹了3D渲染命令到達GPU之前經歷過的各個階段。用下圖可以概括上一篇中所講的內容,當然其中很多細節沒有出現在圖中。之前我們說KMD將命令送給了硬件,這個簡單的“送”的過程實際上並不是那麼簡單的。我們知道顯卡都是通過信號線連在主板上的,所以我們送命令都是需要走這些信號線的。還有就是我們把命令送給顯卡,那顯卡總得有個地方來接受命令吧,這必然需要涉及到內存的使用。然而系統的內存條是通過PCIe總線連接在主板上的,而且顯卡自己也可以配有自己的顯存,選擇使用他們中的哪一個顯然也是必須要考究的,那麼我們首先就來談談內存子系統。

a

[圖片來源] https://docs.microsoft.com/zh-cn/windows-hardware/drivers/display/windows-vista-and-later-display-driver-model-architecture

1. 內存子系統

GPU和我們其他插在主板上的設備有所不同,因爲GPU不僅可以使用系統的內存(一般稱爲內存,主存,system memory), 還可以使用顯卡上自己帶的內存(一般稱爲顯存,video memory, local memory)。這相比cpu使用內存差別巨大,這些差別的重要原因就是顯卡用途的特殊性,早期的顯卡主要解決的就是屏幕顯示刷新,3D渲染等等問題。我們來對比一下gpu和cpu,這裏以i7 2600k和GeForce GTX 480來對比。拿他們對比原因之一就是它們所處的時期差不多。i7 2600K的內存帶寬在表現好的時候可以達到19GB/s,然而GeForce GTX 480的內存帶寬近180GB/s, 這整整的相差了一個數量級。在這個角度來講,GPU相比CPU確實快的不止一點點。
對於第一代i7(Nehalem架構),一次cache miss的大概需要耗費140個時鐘週期(可以從https://www.anandtech.com/show/2542/5 給出的數據來計算)。從斯坦福給出的一個數據上看(http://www.stanford.edu/dept/ICME/docs/seminars/Rennich-2011-04-25.pdf ), GeForce GTX 480一次cache miss大概是需要400~800時鐘週期,從時鐘週期的角度看,GeForce GTX 480的內存延遲是i7的4倍多,同時我們還要考慮它們主頻,i7 2600K的主頻是2.93GHz,GTX 480的shader時鐘頻率是1.4GHz, 這又是一個2倍多的差距,乘以前面的4倍,這又是一個數量級了。
也就是說GPU的內存帶寬優勢是遠強於CPU的,但是它的內存延遲確實遠不及CPU的。GPU的帶寬大幅增加,但他們爲延遲的大量增加而付出代價。 這是GPU的一般模式:在GPU的整個延遲期間,不要等待那裏還沒有的結果,而是做別的事!
除了後續文中會說到的DRAM相關的信息,上面的內容幾乎是你需要知道的關於GPU內存的所有內容。不管從邏輯上還是從物理結構上,DRAM芯片是一種2D的網格結構,也就是說它有行線和列線組成的。就像下圖顯示的那樣。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-QzoN3442-1588585807641)(20190705渲染管線之旅02_2.png)]

在每一個行線和列線的交叉位置,都有一個晶體管和一個電容。關於它們是如何來存儲信息的,可以到wiki百科上搜索(https://en.wikipedia.org/wiki/Dynamic_random-access_memory#Operation_principle). 不管怎樣,我們這裏的重點是DRAM中的位置地址被分成行地址和列地址,並且內部的DRAM讀/寫總是最終同時訪問給定行中的所有列。這意味着訪問映射到一個DRAM行的內存區比訪問跨多行的相同內存量的效率要高得多。目前,看起來這可能只是一個隨機的DRAM的一點瑣事,但這將在後面變得非常重要。這裏先明確一點:每次只讀取整個內存中的幾個字節,是無法達到上述峯值內存帶寬數字的; 如果你想讓內存帶寬飽和,你最好一次完成一個完整的DRAM行。

2. PCIe主機接口

從圖形程序員的角度來看,這個接口硬件並不非常有趣。 實際上,對於GPU硬件架構師也可能同樣如此。問題是,一旦它慢到成爲整個系統的瓶頸,你仍然會開始關心它。 所以你要做的就是讓專業的人去把它做好,以確保瓶頸不會發生。PCIe接口使得CPU可以對顯存和一堆GPU的寄存器進行讀/寫訪問,GPU也可以對主內存(部分)進行GPU讀/寫訪問。很多人都會頭疼於這些事務的延遲甚至比內存延遲還要差。因爲信號必須從GPU芯片中流出,進入插槽,在主板上移動,然後在之後到達CPU中的某個位置。雖然大多數GPU現在使用的16通道PCIe 2.0連接的(2011年的時候)帶寬高達約8GB / s(理論上)峯值聚合帶寬,但帶寬相當不錯,因此佔總CPU內存帶寬的一半到三分之一,這是一個不錯的比例。PCIe 2.0與AGP等早期標準不同,它是一種對稱的點對點鏈路,帶寬可以達到兩個方向,AGP擁有從CPU到GPU的快速通道,但不能相反傳輸。

3. 內存的其他點滴

事實上,談到這裏我們現在已經非常接近實際看到的3D命令了!但是還有一件事我們需要先弄清楚。,因爲現在我們有兩種內存 - 顯存和系統內存。它們就像一個是向北的一天旅途,另一個是沿着PCI Express高速公路向南行進一週到達目的地。那我們選擇那一條路呢?
最簡單的解決方案就是添加一個額外的地址行,告訴你要走哪條路。 這種方式簡單,也能工作得很好。或者是你講這兩中內存統一到一種內存框架上,就像一些遊戲控制器那樣,在這樣的情況下,就不存在選在的餘地了。
如果你想要更高級的東西,你可以添加一個MMU(內存管理單元),它可以爲你提供一個完全虛擬化的地址空間,並且你可以在顯存中快速訪問紋理的各個部分(它們很快)。 也可以訪問系統內存中的一部分,因爲大部分都沒有映射不能訪問 。MMU 它還允許對顯存地址空間進行碎片整理,而無需在開始耗盡視頻內存時複製內容。MMU/虛擬內存實際上並不是你可以添加的東西(不管在一個有緩存和內存一致性問題的架構中),但它確實不是特定的任何特定階段,但我不得不在某處提到它, 所以我只是把它放在這裏。
其實還有一個DMA engine可以複製內存,而不必涉及任何我們寶貴的3D硬件/着色器內核。 通常,這至少可以在系統存儲器和顯存之間複製(在兩個方向上)。 它通常也可以從顯存到顯存(如果你必須進行任何DRAM碎片整理,這是一個有用的東西)。 它通常不能將系統內存寫入系統內存備份,因爲這是一個GPU,而不是內存複製單元在CPU上做系統內存備份,因此也不必在兩個方向上通過PCIe!

上圖中顯示了一些更多的細節,現在你的GPU有多個內存控制器,每個內存控制器控制多個內存庫,前面有一個Memory Hub。
好了,來看看我們已經有了哪些內容。我們在CPU上準備了一個命令緩衝區。 我們有PCIe主機接口,因此CPU實際上可以告訴我們這個,並將命令緩衝區地址寫入某個寄存器。 我們有邏輯將該地址轉換爲實際返回數據的加載位置,如果它來自系統內存,那它通過PCIe,如果我們決定在顯存中使用命令緩衝區,KMD可以設置DMA傳輸, GPU上的着色器內核和CPU和都不需要關心它。 然後我們可以通過內存子系統從顯存中獲取數據。 所有路徑都有了,而且我們已經設置好了寄存器,最後準備看命令了!

4. 命令處理器

我們對命令處理器的討論開始了,就像現在這麼多事情一樣,只用一個詞:
“緩衝…”
如之前所述,我們的顯存路徑是高帶寬的,但同時也是高延遲的。 對於GPU管道中的大多數模塊,選擇解決此問題的方法是運行大量獨立線程。但是,我們的命令處理器需要按順序吃掉我們的命令緩衝區(因爲這個命令緩衝區中包含諸如狀態更改和渲染命令之類的事情,需要以正確的順序執行)。 所以我們做了下一個最好的事情:添加足夠大的緩衝區並預先取出足夠的預取以避免中途暫停吃命令。
對於這個緩衝區來說,進入實際的命令處理前端,命令處理前端是一個知道如何解析命令的狀態機(具有特定於硬件的格式)。如果存在一個單獨的2D命令處理器來處理2D渲染操作,那麼3D前端就不會看到2D命令。現代GPU上一般仍然隱藏着專用的2D硬件,就像在芯片上的某個地方仍然支持文本模式,4bit/像素位平面模式,平滑滾動和所有這些東西的VGA芯片一樣。2D硬件這些東西確實存在,但後面我再也不會提到它了,因爲我們主要關注於3D。 然後有一些命令實際上將一些圖元(primitives)傳遞給3D /着色器管道, 將在後續的部分中介紹它們。
然後是改變狀態的命令。 作爲一名程序員,你認爲它們只是改變一個變量。 但是GPU是一個大規模並行計算機,你不能只是改變一個並行系統中的全局變量,並希望一切正常,如果你不能保證一切都可以通過你執行的一些不變量來工作, 一旦有一個錯誤,你最終會打斷它的執行。 有幾種流行的方法來處理,而且基本上所有的芯片都會使用不同的方法來處理不同類型的狀態。

  • 無論何時更改狀態,都需要等待引用該狀態的所有模塊(即基本上是部分管道刷新)。 從歷史上看,這就是圖形芯片如何處理大多數狀態變化的簡單方法,如果batch較少,三角形較少且管道較短,則成本並不高。 但是,隨着batch和三角形數量增加,管道變長,因此這種方法的成本會逐步上升。 這種處理方式仍然存在於處理那些不經常更改的東西(十幾個部分管道沖洗在整個幀的過程中並不是那麼大)或者過於昂貴/難以實現的部分。

  • 可以使硬件單元完全無狀態。 只需將狀態更改命令傳遞到關注它的階段; 然後讓那個階段將在每個發送命令的時候都將當前狀態附加到它的下游模塊。它沒有存儲在任何地方,但它總是存在的,所以任何的流水線階段想要查看狀態中的幾個位,它可以做到,因爲這些狀態都是被傳入進來的。 如果你的狀態恰好只是幾位,這種方式並不是非常有效和實用。

  • 只存儲狀態的一個備份,並且每次更改階段時都必須刷新,這會使得整個整個流程串行化,但是如果使用兩個或者四個情況就好很多,你的狀態設置前端可能會提前一些。假設您有足夠的寄存器來存儲每個狀態的兩個版本,並且一些活動作業引用slot 0, 那麼你就可以安全地修改slot 1而不會暫停該作業而去等待slot 0使用完,也就是你不需要通過管道發送整個狀態,每個命令使用一個位來表示選擇是使用slot 0還是slot 1。當然,如果slot 0和slot 1都遇到busy的時候,你仍然需要等待。 這種機制可以完全適用到兩個以上的slot上面。

  • 對於像採樣器或紋理着色器資源視圖狀態這樣的東西,可能同時設置非常多的數量。 你不希望爲2 * 128個活動紋理保留狀態空間,因爲目前活動的只有2個正在運行的狀態集對於這種情況,你可以使用一種寄存器重命名方案:具有128個物理紋理描述符池。如果有人在一個着色器中實際需要128個紋理,那麼狀態變化將變慢,但是在可能性更大的情況下,一個使用少於20個紋理的應用程序種你有很多空間來保持多個版本,這使得在這種情況下的運行效率更高。
    上面的這些這並不是所有的方式,但它們的主要目的是讓這些狀態的改變像在應用程序中更改變量那麼簡單(甚至在UMD / KMD和命令緩衝區中也是如此!)。但是,實際上這些都是需要一些非常重要的支持硬件,以防止減慢運行速度。

5. 同步

CPU向GPU發送指令,那麼CPU怎麼知道GPU當前已經處理了哪些指令?因爲命令實際上都是卸載內存中的,那存放命令的內存什麼時候可以再一次被CPU寫入呢?GPU中模塊之間怎麼共享數據呢?這些都和同步有關,這一篇我們就來講講同步。

通常來說,所有的“同步問題”都可以歸爲“如果事件X發生,才能做Y“的形式。我們首先從Y這部分看起,一般有兩種合理處理Y這部分的方式,從GPU的角度來看,第一種是主動模式(push-model),也就是GPU去主動通知CPU來處理事務,比如說GPU在進入垂直回掃期的時候會通知CPU“喂!CPU!我現在正在顯示器0上進入垂直回掃,如果你想翻轉緩衝區,那麼現在可以開始了!”另一種是被動模式(pull-model),這種方式下,GPU僅將已經處理的事情記錄下來,CPU可以來向GPU查詢。比如說:

第一種方式通常使用中斷實現,僅用於不頻繁出現的事件和高優先級的事件,因爲中斷的代價比較大。後者需要的只是一些CPU可見的GPU寄存器,然後在某個事件發生後GPU將值從命令緩衝區將值寫入GPU寄存器。假設你有16個這樣的寄存器。然後你可以將當前的命令緩衝區Id分配給寄存器0。你爲每個提交給GPU的DMA(這是在KMD中)分配一個序列號。然後在命令解釋器中添加“如果你到達這個 指向命令緩衝區,把DMA中的緩衝區id寫入到寄存器0中“這樣的邏輯。這樣,我們就知道GPU正在消耗哪個命令緩衝區了!因爲我們知道命令處理器嚴格按順序執行命令,因此如果命令緩衝器303中的第一個命令被執行,則意味着id爲302包括它之前的所有命令緩衝區都已經在命令解釋器中完成執行,那麼KMD Driver就可以可以回收,釋放, 修改302以及之前的命令緩存區的內存空間了。
對於事件X發生,"如果執行到了這裏"是最簡單的一個例子。其實這裏還有很多其他的,比如,如果現在所有shader都在命令緩衝區中完成了的對所有紋理讀取,這就標誌着現在是回收紋理/渲染目標內存的安全點了。如果現在渲染到所有活動的render target/UAVs已完成,那麼標誌着現在可以將這些活動的render target/UAVs當作textures來使用了,等等。順便說一下,這種操作通常被稱爲“Fences”。選擇寫入狀態寄存器的值有不同的方法,但就我而言,唯一明智的方法是使用順序計數器(可能會借用其中的某些bit來表示其他的信息)。
我們現在可以將狀態從GPU報告給CPU,這使我們能夠在我們的驅動程序中進行合理的進行內存管理(特別是,我們現在可以找到實際回收內存的安全點。頂點緩衝區,命令緩衝區,紋理和其他資源)。但是這隻完成了CPU和GPU之間的同步,如果我們需要純粹在GPU端進行同步,該怎麼辦?讓我們回到渲染目標示例。在渲染實際完成之前我們不能將它用作紋理。解決方案是使用“wait”式指令:“等到寄存器M包含值N”。這可以是相等的比較,也可以是小於,或者更多花哨的東西。這裏爲了簡單, 我們就使用equals來說明問題。這種方式允許我們在提交批處理之前執行渲染目標的同步。也允許我們構建一個完整的GPU刷新操作:“如果所有掛起的作業都完成,則將寄存器設置爲++seqId“ 或者是等到寄存器包含seqId”。對於常規的渲染,GPU/GPU同步就解決了。DX11推出的Compute Shaders則需要另一種更細粒度的同步。
順便說一句,如果你可以從CPU端設置這些寄存器,那麼你也可以使用另一種方式 :提交一個包含等待特定值的命令,然後從CPU而不是GPU更改寄存器。這可用於實現D3D11樣式的多線程渲染,你可以在其中提交一個引用頂點/索引緩衝區的批處理,這些緩衝區仍然鎖定在CPU端(可能由另一個線程寫入)。您只需在實際渲染調用之前填充等待命令,然後一旦頂點/索引緩衝區解鎖,CPU就可以更改寄存器的內容。
當然,你也不一定必須使用設置寄存器/等待寄存器模型, 對於GPU / GPU同步,您可以簡單地使用“rendertarget barrier”指令來確保rendertarget可以安全使用,以及“flush everything”命令。但是相比之下,我更喜歡set register-style模型,因爲它不僅實現了GPU的自同步,它還可以隨時向CPU報告什麼資源正在使用。
在這裏,我繪製了一個圖表。爲了讓它不那麼令人費解,所以我將來會降低細節數量。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-4N95m2bf-1588585807643)(20190705渲染管線之旅02_3.png)]

命令處理器前面有一個長長的FIFO,然後是命令解碼邏輯,由2D單元,3D前端(常規3D渲染)或着色器單元(Computer Shader)直接通信的各種處理 ,然後有一個處理同步/等待命令的塊(它包含我們前面所討論的公開可見的寄存器),還有一個處理命令緩衝區跳轉/調用的單元(它改變了進入FIFO的當前提取地址)。我們派遣工作的所有模塊都需要向我們發送完成事件,以便我們知道何時紋理不再被使用,以至於它們的內存可以被及時的回收。

關於命令處理器的基本上就講完了。下一篇我們就可以繼續向下,進入實際渲染的工作流程。最後要說的一點是文中很可能存在很多不妥之處,如果你發現有問題,請留言告訴我,避免傳遞了錯誤的知識。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章