1. 程式人生 > >D3D11和D3D12多執行緒渲染框架的比較(四)

D3D11和D3D12多執行緒渲染框架的比較(四)



1.命令列表及命令的原生並行性

至此如果你還沒有看暈的話,或者說你已經明白了前面的這些概念鋪墊之後,或許心中還有一個疑問就是為什麼說可以用多個命令列表來記錄可能不同的命令,最後再來執行,這樣不同的命令佇列之間會不會衝突呢(更直白的說不通的命令列表直接會不會有什麼先後關係的約束從而使得非同步執行這種方式失去了意義)?舉例來形象的說明,比如我們用一個CPU執行緒+一個命令佇列繪製了一個正方體,而用另一個CPU執行緒+另一個命令佇列繪製了一個球體,最後我們將這兩個命令佇列都提交給了同一個Immediate Context來執行,那麼如果在一些特定角度下正方體或球體有遮擋關係時,怎麼保證命令佇列之間以及命令佇列中各命令之間的正確先後執行關係呢?好的,如果你明白了這個問題,並真的想到了這個問題,那麼我只能說你對3D

程式設計的本質或者說程式之所以能並行執行的本質原因還沒有搞清楚。好吧,仁慈的我就為你解答一下吧。其實這正是因為我們進行3D渲染時,每個最細粒度的資料單位比如正方體的每個頂點、球體的每個頂點、以及他們光柵化之後的每個畫素等等這些資料天生都是滿足平行計算條件的,即每個輸入資料集之間及輸出資料集之間或者二者之間都是沒有任何交集的。直白的說就是這些資料的每一個都可以獨立計算而不依賴於任何一個其它的同類或異類資料。

當然光柵化之後的片元之間是有一些重疊覆蓋關係的,但是那是在輸出混合階段通過Z-Buffer演算法解決掉了,最終螢幕上的一個畫素點就只對應一個顏色,最終所有的畫素顏色都著色完之後就是我們看到的3D

場景的2D螢幕投影的結果了。因此無論你是先畫正方體還是先畫球體,對於一幀畫面來說最終結果都將是一樣的。

讓我們再設想另一種情況,就是兩個不同的命令佇列訪問同一個資源的情況,或者更具體的說如前的例子一個畫正方體的執行緒+命令佇列和另一個畫球體的執行緒+命令佇列都訪問同一個紋理來包裹這倆貨會不會有問題?我們果斷的說——不會有任何問題,因為這個紋理對任何命令佇列來說都是隻讀的,只要我們傳到了GPU的視訊記憶體中,不論那個GPU執行緒都只是讀(Sample)這個紋理上的某個畫素的值,而沒誰是需要改變它的(或者說是沒有寫入操作),所以這也不會造成任何問題。

同樣這也是將很多提交到GPU的緩衝顯式的設定為只讀或常量的意義。對於任何多執行緒(CPU

多執行緒或GPU多執行緒)來說,同時只讀某塊記憶體(視訊記憶體)是沒有任何問題的。而麻煩在於寫入,正如這裡說的,對於寫入渲染目標來說,聰明的GPU想出了Z-Buffer緩衝演算法(應當是聰明的人類發明的演算法)來規避了潛在的“髒讀”問題(好吧,垂直同步某種意義上來說也是為了避免“髒讀”問題,我不想在多解釋了)。

2.CPUGPU之間的同步(圍欄)

接下來我們就繼續我們的想象,因為我想對於每一個嚴謹到近乎苛刻的遊戲開發人員來說,前面的內容有很多“戲說”的成分在裡面,當然為了搞清楚整體框架概念,我只能暫時如此,而不過度拘泥於細節,因為我的目標就是為了讓大家對D3D12的多執行緒渲染先建立一個整體概念性的認識。

接下來如果你通過了前面內容的重重轟炸,而安全的看到了這裡的話,那麼恭喜你,如果你都看明白了,也請為自己點一個贊先!

那麼我們繼續探討的下一個話題自然就是CPUGPU之間最終如何同步了。我想你應該已經想到了,既然Draw Call變成非同步了,ExecuteCommandLists也是非同步的,那麼CPU執行緒最終如何確定當前幀畫面已經繪製完了?或者說如何判定究竟該什麼時候來呼叫Present呢?

這時就需要在D3D11D3D12多執行緒渲染中的“圍欄(Fence)”這個概念來幫忙了。圍欄說白了其實就是一個同步物件,只不過它是用來同步CPU執行緒和GPU執行緒的。至於它名字的來歷的話,我想可能是因為GPU執行緒太多,就像草原上的羊群一樣,為了方便管理我們需要一個圍欄把它們圈起來(果真如此,那麼不得不佩服微軟開發人員的想象力,哈哈)。它的基本原理就是為GPU執行緒的執行設定一個目標圍欄目標值(UINT),接著為這個值再設定一個CPU事件控制代碼(Windows Event核心同步物件,期初是無訊號狀態),然後GPU執行緒就分頭去執行自己的任務,而此時CPU可以在這個Event上等待,直到所有GPU執行緒都到達這個目標值(具體的說也就是命令佇列中的某個位置,通常我們也就是在繪製結束的時候設定一個值,以方便我們知道命令佇列中的命令都執行結束了)時,就執行CPU一開始安排的命令ID3D12CommandQueue::Signal將目標值設定為Event物件對應的值,這時GPU執行緒就會使得Event物件變成有訊號的狀態,接下來在這個EventWaitCPU執行緒就被立即喚醒,通常接著就可以執行Present,此時CPU執行緒就被喚醒,而GPU執行緒可以繼續執行後續的命令,或者是已經執行完了命令而變成空置等待狀態,準備進行下一輪命令的執行。

當然這裡需要提示的就是,如果是為了真正的提高效能,我們不應讓CPU執行緒在一個Event上只是簡單的使用INFINITEWait,實際在引擎中使用時,應當在遊戲迴圈中(也可能是多個CPU執行緒中的迴圈)使用0值來Wait,這時Wait立即返回,相當於輪詢下Event的狀態,接著就去執行別的操作了。這樣才能真正的發揮多執行緒渲染的高效能。

3.GPU執行緒間的同步(資源屏障)

討論完了CPU執行緒和GPU執行緒之間的同步之後,我們來看看GPU執行緒之間如何去同步的問題。首先讓我們來想象一下這樣一個場景,在之前的討論中我說過,命令佇列分為很多種類,其中有一類是複製命令佇列,而另一類是直接命令佇列。現實中我們常常使用複製命令佇列來將各種資源(紋理等)從上傳堆(一種GPU視訊記憶體堆,CPU寫入GPU讀取,回憶一下D3D11中的Dynamic型別的緩衝區)複製到預設堆(一種GPU視訊記憶體堆,CPU不能訪問,而GPU只讀,效能很高,回憶下D3D11中的D3D11_USAGE_DEFAULT緩衝區),然後直接命令佇列就可以執行命令操作這些資源。這時有一個很明顯的問題就是怎樣知道複製命令佇列已經將某個資源完整的複製完了,而直接命令佇列可以讀取操作了呢?這並不能簡單的通過先呼叫複製命令佇列的ExecuteCommandLists方法後呼叫直接命令佇列的ExecuteCommandLists方法來保證。因為他們都是非同步的,執行的先後順序是沒法根本保障的。這樣最終的執行結果是無法預期的,也可能直接命令佇列先執行完了,而複製命令佇列才去執行,這樣我們也許什麼畫面也看不到,因為紋理取樣的結果可能完全就是黑的。或者二者同時執行,有可能發生“髒讀”問題。

因此在D3D12中又專門提供了稱之為資源屏障(resource barrier)的物件來提供不同GPU執行緒之間的同步控制機制。具體的做法就是利用ID3D12GraphicsCommandList::ResourceBarrier方法通過設定許可權轉換標誌的方式,來進行不同GPU執行緒間訪問資源的同步。之所以要設定許可權轉換,主要是因為不同GPU執行緒(不同種類的命令佇列、命令列表)對每種資源的訪問要求是不一樣的,比如對於複製佇列來說複製源只需要只讀的許可權,而複製目標只需要有寫入的許可權,而對於直接命令佇列來說複製佇列的複製目標可能就是它需要讀取的一個紋理資源,只需要有隻讀許可權即可,因為只有明確了只讀許可權之後對於直接命令佇列的GPU執行緒中的那些“小螞蟻”來說,才能夠以最高的效率來訪問。這樣在GPU執行緒內部要進行許可權的轉換,就必須要之前的那些個“小螞蟻”都完成了自己的工作,比如複製佇列複製完成了一整幅紋理的複製工作之後許可權轉換才能進行,也只有當這個許可權轉換完成了,後續的命令才能繼續執行。這種場景就好像有一道屏障在中間,之前的一隊“小螞蟻”把所有的資源(也許對它們來說是食物)都搬到屏障的一邊,完成之後屏障才撤除,而另一隊“小螞蟻”就開始將這堆資源搬至別處。OK,我想這也許就是微軟的那堆DX工程師們為資源屏障起名為“屏障”的主要原因吧,再次佩服他們豐富的想象力!