做渲染的時候我們都會遵循一些通用優化規則,比如 要盡量減少drawcall,要盡量降低渲染的面數,要避免在shader中使用循環判斷,要減少採樣的次數等等,要合併批次渲染,看起來的原則就是越少越好,我們也都知道GPU擁有強大的並行能力,這個所謂的並行是什麼級別的並行?是三角形和三角形之間的並行麼?我也曾經在工程中碰到一個疑惑,在渲染面積相同的情況下,兩個三角形還是64個三角形快?會不會有可能64個三角形參與運算的GPU核心多導致會比兩個三角形更快?帶著這些疑問,我們嘗試瞭解GPU的結構中,看看能不能找到答案。也會利用Opengl的幾個介面來驗證我們的判斷,GPU的硬體結構每家廠商的每代產品都是不一樣的,但是大致的結構應該都是類似的。

在渲染面積相同的情況下?左圖還是右圖渲染更快?

1.GPU渲染架構圖

NV給出的這個圖幾乎涵蓋了渲染的關鍵過程。

2.物理架構

從Fermi開始Nvdia使用類似的原理架構,使用一個Giga Thread Engine來管理所有正在進行的工作,GPU被劃分成多個GPCs(Graphics Processing Cluster),每個GPC擁有多個SM和一個光柵化引擎(Raster Engine),他們其中有很多的連接,最顯著的是Crossbar,他可以連接GPCs和其他功能性模塊例如ROP或者其他子系統。

著色器程序的執行都是在SM上完成的,如上圖所示,sm包含32個運算核心 ,16個LD/ST(load/store)模塊來載入和存儲數據,4個SFU(Special function units)執行特殊數學運算(sin、cos、log等),128KB寄存器,64KB L1緩存,全局內存緩存,Tex紋理讀取單元,TextureCache紋理緩存,polyMorph Engine多邊形引擎負責屬性裝配(attribute Setup)、頂點拉取(VertexFetch)、曲面細分、柵格化(這個模塊可以理解專門處理頂點相關的東西),最後就是Warp Schedulers這個模塊負責warp調度,一個warp由32個線程組成,warp調度器的指令通過Dispatch Units送到Core執行。

3.邏輯管線

1.程序通過圖形API(DXGLWEBGL)發出drawcall指令,指令會被推送到驅動程序,驅動會檢查指令的合法性,然後會把指令放到GPU可以讀取的Pushbuffer中。

2.經過一段時間或者顯式調用flush指令後,驅動程序把Pushbuffer的內容發送給GPU,GPU通過主機介面(Host Interface)接受這些命令,並通過Front End處理這些命令。

3.在圖元分配器(Primitive Distributor)中開始工作分配,處理indexbuffer中的頂點產生三角形分成批次(batches),然後發送給多個PGCs,這一步的理解就是提交上來n個三角形,分配個這幾個PGC同時來處理。

4.在GPC中,每個SM中的Poly Morph Engine負責通過三角形索引(triangle indices)取出三角形的數據(vertex data)[圖中的Vertex Fetch模塊]

5.在獲取數據之後,在sm中以32個線程為一組的線程束(warp)來調度,來開始處理頂點數據。warp是典型的單指令多線程(SIMT,SIMD單指令多數據的升級)的實現,也就是32個線程同時執行的指令是一模一樣的,只是線程數據不一樣,這樣的好處就是一個warp只需要一個套邏輯對指令進行解碼和執行就可以了,晶元可以做的更小更快,只所以可以這麼做是由於GPU需要處理的任務是天然並行的。

6.SM的warp調度器會按照順序分髮指令給整個warp,單個warp中的線程會鎖步(lock-step)執行各自的指令,如果線程碰到不激活執行的情況也會被遮掩(be masked out),被遮掩的原因有很多,例如當前的指令是if(true)的分支,但是當前線程的數據的條件是false,或者比如一個循環被終止了但是別的還在走,因此在shader中的分支會顯著增加時間消耗,在一個warp中的分支除非32個線程都走到if或者else裡面,否則相當於所有的分支都走了一遍,線程不能獨立執行指令而是以warp為單位,而這些warp相互之間是獨立的。

7.warp中的指令可以被一次完成,也可能經過多次調度,例如sm中的載入紋理、數據存取明顯少於數學運算。

8.由於某些指令比其他指令需要更長的時間才能完成,特別是內存載入,warp調度器可能會簡單地切換到另一個沒有內存等待的warp,這是gpu如何克服內存讀取延遲的關鍵,只是簡單地切換活動線程組。為了使這種切換非常快,調度器管理的所有warp在寄存器文件中都有自己的寄存器。這裡就會有個矛盾產生,shader需要越多的寄存器,就會給warp留下越少的空間,就會產生越少的warp,這時候在碰到內存延遲的時候就會只是等待,而沒有可以運行的warp可以切換。

9.一旦warp完成了vertex-shader的所有指令,運算結果會被Viewport Transform模塊處理,三角形會被裁剪然後準備柵格化,GPU會使用L1和L2緩存來進行vertex-shader和pixel-shader的數據通信

10.接下來這些三角形將被分割,再分配給多個GPC,三角形的範圍決定著它將被分配到哪個光柵引擎(raster engines),每個raster engines覆蓋了多個屏幕上的tile,這等於把三角形的渲染分配到多個tile上面。也就是像素階段就把按三角形劃分變成了按顯示的像素劃分了。

11.sm上的Attribute Setup保證了從vertex-shader來的數據經過插值後是pixel-shade是可讀的

12.GPC上的光柵引擎(raster engines)在它接收到的三角形上工作,來負責這些這些三角形的像素信息的生成(同時會處理背面剔除和early z剔除)

13.32個像素線程將被分成一組,或者說8個2X2的像素塊,這是在像素著色器上面的最小工作單元,在這個像素線程內,如果沒有被三角形覆蓋就會被遮掩,sm中的warp調度器會管理像素著色器的任務。

14.接下來的階段就和vertex-shader中的邏輯步驟完全一樣,但是變成了在像素著色器線程中執行。 由於不耗費任何性能可以獲取一個像素內的值,導致鎖步執行(SIMD)非常便利,所有的線程可以保證所有的指令可以在同一點。

15.最後一步,現在像素著色器已經完成了顏色的計算還有深度值的計算,在這個點上,我們必須考慮三角形的原始api順序,然後才將數據移交給渲染輸入單元ROP(render output unit),一個ROP內部有很多ROP單元,在ROP單元中處理深度測試,和framebuffer的混合,深度和顏色的設置必須是原子操作,否則的話兩個不同的三角形在同一個像素點就會有衝突和錯誤。

4.內存速度

GPU的內存分為好幾個類型,不同類型的速度不一樣

這裡看到在shader中直接使用的寄存器內存還是比較快的,紋理和常量內存的還有全局內存的速度實在是慢太多。

5.試驗驗證

接下來用我自己的顯卡,這個顯卡是GeForce GT 755M,只有2個sm,每個sm最大64個warp,每個warp中最多32個thread,配合nv的opengl擴展,通過gl_ThreadInWarpNV,gl_WarpIDNV,gl_SMIDNV這幾個指令我們可以驗證一下上面的流程,同時可以確定一些上面沒有提到的東西。

5.1 頂點處理

  • a)圖是按threadID(color.g=gl_ThreadInWarpNV/gl_WarpSizeNV)來輸出的,三角網格是100*100,可以看到一個線程對應一個頂點,一個warp對應一組頂點。
  • b)圖是按warpID(color.r=gl_WarpIDNV/gl_WarpsPerSMNV)來輸出的,三角網格是。100*100,網格傾斜了15度。黑色的是另外一個sm的。
  • c)圖是warpID,三角網格是15*15的。統計後的warp數是8。d)圖可以看到對應的三角網格線。
  • e)圖是warpID,三角網格是40*40的。統計後的warp數是25。f)圖可以看到對應的三角網格線。
  • g)圖是warpID,三角網格是100*100的。統計後的warp數是23。頂點著色器是比較簡單的
  • h)圖是warpID,三角網格是100*100的。統計後的warp數是58。作為(g)的對比圖,頂點著色器直接做了一個100萬次的循環,目的是拉長頂點著色器的完成時間。

通過這些圖的對比我們可以得出結論

  1. 對比a)和b),頂點著色器是以一個頂點為一個線程來處理的,32線程為一個warp。
  2. 對比c和d其中的warp數,可以看到頂點越多warp數就越多
  3. 對比g和h其中的warp數,系統調用的warp會根據頂點數和頂點著色器的任務來分配,越少的頂點warp越少,而且每個著色器上的warp數不會按照最大數來分配,畢竟warp數是軟體概念,當單個warp時間過長時,系統為了隱藏延遲會調用更多的warp來參與計算
  4. 頂點著色器最終的運行效率會取決於頂點數,和複雜程度。但是在真實的環境中頂點數不會真正的瓶頸,20個頂點和200個頂點差別不會很大。

5.2 像素處理

這個圖和頂點的處理的類似,只是換成像素階段,另外另外一個sm換成藍色方便觀察

  • a)圖直接顯示出來warp和thread,thread是綠色的塊,thread是warp等於0的時候才顯示。
  • b)圖是a)圖的局部放大,一個線程塊是4*8個像素
  • c)圖是兩個三角的warp,藍色的是另外一個sm的,可以看到按塊來著色,其中一個塊就是一個光柵化引擎
  • d) e)圖都是c)圖的放大
  • f)圖是5*5的網格
  • g)圖是f)圖的放大細節

根據上面的我們可以得出一些結論

  1. 觀察a),可以看到這個三角形是傾斜的,可以到裡面的分塊並沒有傾斜而是始終平行於屏幕,所以光柵化會無視原來三角的位置,只會處理三角覆蓋屏幕的位置,這個和頂點程序是完全不一樣的。
  2. 觀察b),可以看到一個像素就是一個thread,像素著色依舊是按照warp來調度的。
  3. 觀察d),每個光柵化引擎(raster engines)都是16*16的像素塊,也就是每個包含4*4一個warp。
  4. 對比d)e)g),,在三角分割出,可以看到如果一個光柵化引擎恰好覆蓋兩個三角,那麼兩個三角會有可能被兩個sm覆蓋,這裡可以確定這個光柵化引擎包含兩個sm,一個光柵化引擎在處理覆蓋的像素的時候,如果覆蓋的區域包含多個三角,每個三角都有可能被不同的sm處理,但是不同的三角分配的warp肯定是不同的,同時在g)圖也可以看到即使一個光柵化引擎只包含一個三角,可有可能分配給不同的sm裡面的不同warp處理。
  5. 光柵化塊是按照順序一個一個在進行。

6.結論

到此為止我們可以我們就可以得出一些結論

  1. 頂點著色器和像素著色都是在同一個單元中執行的(在原來的架構中vs和ps的確是分開的,後來nv把這個統一了)vs是按照三角形來並行處理的,ps是按照像素來並行處理的。
  2. vs和ps中的數據是通過L1和L2緩存傳遞的。
  3. warp和thread都是邏輯上的概念,sm和sp都是物理上的概念。線程數≠流處理器數。
  4. 上述第12步裡面z-cull是early z optimization而不是常說的z-test,z-test是要在ROP的時候發生,但是這個z-cull要在alpha test, user clip,multi-sampling,texkill都是關閉的情況下才能生效,任何導致需要混合後面顏色的操作都會導致z-cull失敗。

同時也能回答開頭中提到的問題了

  • 為啥drawcall越少越好?因為即使渲染一個三角形,在GPU中也要走系列複雜的流程,這系列流程帶來的延遲遠超過計算一個三角本身,只有同時並行多處理才能發揮GPU的強大並行能力,這也是我們優化的時候要合併渲染的原因,越合併越能最大限度的利用GPU。總之一句話我們拿到的GPU是衝鋒槍,衝鋒槍最大的優勢是連發,不能老用點射把衝鋒槍當步槍用。
  • 為啥要降低渲染面數?面數越少VS計算使用的線程就越少,頂點計算就越快。
  • 為啥要避免在shader中使用if else?因為按照SIMD的執行方式,if else可能會完全不生效,導致兩個分支都要走一遍。同樣循環中的break也會導致這樣的問題。
  • 為啥要降低採樣次數?因為紋理的讀取速度實在是太慢了,讀取跟不上運算會導致極大的延遲。
  • 一個和多個三角形哪個更快?當然是一個更快了,在覆蓋面積相等的情況下頂點多少越好。

另外還有一個我想提一下涉及CPU和GPU的交互,CPU和GPU是類似服務端-客戶端的模式,他們之間的交互成本也是很高的,GPU的調用指令也是越少越好,最簡單的一個就是類似gl裡面uniform的設置,uniform設置的數據量都是比較小的,這會導致指令調用成本大於數據傳送成本,如果shader中大量的uniform vec3類似的東西還是合併成數組一次送入GPU(當然這會降低程序可讀性,需要權衡)。另外一個任何從GPU回讀的操作都是相當耗時的,即時是類似gl中獲得句柄的操作,比如getUniformLocation,要避免在刷幀中使用。

7.TileBase

說完桌面版的GPU架構,順帶說下移動端的架構,移動端的gpu架構和桌面版本是完全不同的,桌面版的ROP單元是負責把結果寫入framebuffer,處理器跟顯存是在一塊的導致這塊幾乎不太有什麼耗時。但是到了移動端,顯存是在主存上面的,它離顯卡的核心太遠了,導致這塊的帶寬很低,即時現在手機的顯卡越來越好(但是屏幕解析度也越來越大)帶寬依舊是個瓶頸,主存的速度又太慢,導致這個操作又慢又費電,至少桌面端是不用考慮費電的問題的。基於此移動端都是用了tile-based架構。

7.1.主要區別

前面我們分析了nvdia顯卡的渲染的詳細過程,這個我們只需要說區別就好了,桌面版都是 vs-ps直接進行的,但是tile-based把這個階段分開了,在一幀中包含數百次drawcall,注意這裡是一幀的所有drawcall,先全部進行vs,然後在按照tile的順序ps,桌面是一個drawcall的vs然後ps,桌面版也是用tile的模式來ps的,只不過桌面的ps都是一次drawcall,但是移動端是順序vs然後按照tile打包ps。

7.2.為什麼會快

舉個例子,比如我們有兩次drawcall,每個drawcall包含一個三角形,這兩個三角形都覆蓋全部屏幕了。

在桌面版中,第一次darwcall對第一個三角形進行vs-ps,這時候要對framebuffer操作一次,第二個drawcall還要執行同樣的處理就需要對framebuffer再操作一次(至少進行一次讀的操作,如果第二個三角不覆蓋第一個三角就少一次寫的操作)。

在tile-base中,兩次drawcall,執行兩次vs,不操作framebuffer,然後在每個tile上,處理兩個三角形,處理完這兩個三角,也就確定最終的像素顏色了,直接吧這個顏色寫入framebuffer就可以了,等於是framebuffer只有一次的寫入操作,這裡面還有另外的優化。

上面的設計是為瞭解決主要的帶寬的問題,同時會產生很多其他的優化。

在桌面版,每個三角都要進行ps,但是在tile-base中就可以提前進行z-test,有可能會使ps只進行一次就夠了(gpu會對此優化,想辦法剔除隱藏的面),顏色混合就快多了,不需要跟framebuffer有關係,都是在tile上進行的。還有個問題就是vs-ps不是在一幀中進行的,這一幀的ps是使用上一幀的vs。

這篇文章從架構的角度解釋了常用優化手段的背後原因,如果內容哪個有誤歡迎大家指正。


推薦閱讀:
相關文章