概述
在D3D10中,一個基本的渲染流程可分為以下步驟:
- 清理幀快取;
- 執行若干次的繪製:
- 通過Device API建立所需Buffer;
- 通過Map/Unmap填充資料到Buffer中;
- 將Buffer設定到DeviceContext中;
- 呼叫Draw執行繪製過程;
- 呼叫Present提交渲染結果。
在這一過程中,不被初學者注意、然而在深入學習時定會遇到的一個特性是:D3D的Draw函式是一個非同步呼叫。
我們知道,實際渲染的過程大部分是在GPU上完成的,CPU只負責發號施令。實際上,資料準備完成後,當你的程式呼叫了Draw函式後,CPU才會真正的將資料和命令提交到GPU上進行渲染。從命令提交到渲染完成通常需要數十毫秒的時間,甚至對於複雜的程式更是需要數秒的時間才能返回。如果Draw一直等到GPU渲染完成再返回並執行剩下的程式碼,那顯然整個執行緒的時間都浪費在了等待GPU的結果上。
這個問題或許可以利用多執行緒程式設計來解決,但是這也意味著你的程式更加複雜了。所以在D3D中,Draw將命令傳送給顯示卡之後立即返回,你的程式便可以接著做其它工作了,例如新渲染資料的準備、物理、邏輯、AI的計算、場景的優化等等。換句話說,我們稱Draw是一個非同步呼叫。
相信對D3D有所瞭解的人這一機制都已熟記於心。本文的內容,就是討論這個“非同步呼叫”是如何實現的。具體的內容包括:
- 描述非同步呼叫機制的基本實現方法;
- 梳理使用者程式碼和GPU對資源的操作(Map,Unmap),以及他們之間可能產生的相關性;
- 介紹一種可以保證非同步和並行化結果正確的方法;
- 討論非同步呼叫時錯誤的處理。
這些內容可以幫助你理解Draw呼叫的實現原理,另一方面也可以作為你實現其他非同步呼叫API的參考。需要說明的是,本文所述的大部分機制,均是由顯示卡驅動程式或D3D Runtime實現,但考慮到各家驅動實現不一以及版權和保密協議,本文所提供的方法沒有參考任何實際的驅動程式和MS提供的參考程式碼,而以SALVIA渲染器正在開發中的程式碼為主要參考。
我們將先引入Producer/Consumer這一經典非同步模型作為非同步呼叫實現的基礎;其次我們介紹一些保證併發程式正確性的一些常識;再來會介紹我們在Producer/Consumer的基礎上所做的非同步呼叫實現,並討論如何解決CPU和GPU對同一份資源可能存在的訪問衝突;在最後兩節,我們會討論跨執行緒的物件生命週期控制和檢查,以及非同步呼叫的錯誤處理機制。
CPU與GPU的Producer/Consumer模型
在Producer/Consumer模型中,最重要的角色有三個,產生命令和資料的Producer,執行命令和使用資料的Consumer,以及用於在Producer和Consumer之間傳遞訊息的物件,這個物件通常是訊息佇列(Message Queue)。
我們來看一下CPU和GPU和合作關係。CPU和GPU是兩個獨立執行的硬體裝置,但是GPU的執行都是受到CPU控制的。GPU和CPU最基本的工作模式是:CPU將資料準備好後,提供給GPU,GPU進行計算、渲染並輸出。有時候CPU也會從GPU處取得一些資料。可以看出,CPU和GPU是個很典型的生產者/消費者模型。對於實際硬體來說,CPU和GPU的關係可能是多級的Producer/Consumer結構。例如使用者程式碼到驅動是一級,驅動到硬體又是一級。因此,訊息佇列可能同時存在於軟體和硬體中。往往看起來簡單的模型,在實踐中就是這樣複雜起來的。
Draw呼叫到底做了哪些事情
CPU和GPU的通訊主要出現在兩個時候:第一,讀寫資源(Map/Unmap);第二,Draw的呼叫。這些通訊都會變成Driver發給顯示卡的命令。例如,我們假設COMMAND是個四位元組的命令,每個COMMAND最長可以有512個位元組的資料;我們要將Buffer傳到GPU的某塊記憶體上,那麼我們就能把需要傳輸的資料處理成這樣的指令組:
COPY GPU_MEM_ADDRESS DATA_LENGTH DATA
然後通過匯流排傳送給GPU,GPU拿到了指令和資料後,執行單元就會把資料寫到視訊記憶體的相應位置。當然有了DMA的存在,真正的資料拷貝還是比這個要高效的多。
除了往視訊記憶體中寫資料,還要給GPU提供一些狀態。比如Vertex Buffer的地址,Index Buffer的地址,Texture的地址和行的Pitch,等等。可千萬不要以為GPU中會儲存一個ID3D10Buffer的物件,實際上到了GPU後,這些物件都只會變成最最原始的指標、和一些Bit位的開關。它們和物件之間的關係,都是由驅動程式來維護的。包括視訊記憶體的分配、任務的安排和排程,都是驅動程式的責任。可以說,顯示卡的驅動程式幾乎就是GPU的OS。這些狀態,GPU中可以叫State Buffer,也可以叫Context,也可以叫Register File。總之怎麼叫,那都是GPU設計公司的喜好了。
除了資料、基本狀態,剩下就是有動作的命令。比如Transform、Rasterize、Tessellate、Query,等等。這些命令傳送到顯示卡之後,顯示卡就真正的開始幹活了。
說了這麼多廢話,總結一下就是:CPU傳送給GPU的內容,可以粗淺的分為資料、狀態和命令。那麼這些內容都是什麼時候被傳輸到GPU上的呢? 再說一句廢話:只要資料在修改完畢後、使用之前傳輸到GPU上就可以了。那如果都開始渲染了,這些內容還沒有傳送完畢要怎麼辦呢?那渲染就只能等它們都傳輸好再開始工作。
為了避免渲染程式等待資料傳輸,為了減少寶貴的匯流排頻寬,CPU和GPU之間的通訊需要經過一定的優化。對於資料(Constant Buffer,VB/IB,Texture)來說,因為數量多,傳輸時間也比較長,因此可以在Unmap一結束就將資料提交給GPU;而對於狀態和命令而言,數量比較小,可能會遭遇頻繁的更改,同時還需要維護彼此間的一致性,因此這部分內容可以延期到非提交不可的時候再傳送到GPU上。
所謂非提交不可,就是執行Draw的時候。 Draw是實際執行繪製的函式。到了這裡,繪製所需要的全部狀態狀態和資料都已經齊備,就只差Draw這個東風了。因此當Draw被呼叫的時候,除非硬體正忙,否則所有的工作沒有理由再不進行了。此時就需要將渲染所需要的狀態和命令在CPU上統計好,打包傳送給硬體。在這一階段,Draw需要完成很多工作,比如髒屬性的檢查以減少傳輸量,比如渲染狀態的正確性和一致性檢查等等,一般來說GPU命令的生成也可以放在這裡完成。
CPU/GPU資源讀寫相關性分析
在D3D中,非同步呼叫要求和同步呼叫的結果完全相同。但是因為非同步呼叫的存在,前後函式的執行時間不再是嚴格的一前一後,而可會發生重疊(也就是並行)或重排(亂序)。這時就需要進行資源相關性的分析,確保並行或重排後的結果,與同步的、順序執行的結果是一致的。
寫到這一段,我內心深處不由得回想起偉大的程式設計師KULA的教導:“演算法就是構造一個資料結構,然後把資料插入到指定的位置。”遵循著文成武德KULA巨巨的教導,我們也可以這麼認為:非同步呼叫的正確性分析,就是對資料操作順序正確性的分析。
來看一下資料相關性分析的理論。流水線級的資料相關性分為四類:讀後讀(RAR),寫後讀(RAW),讀後寫(WAR)和寫後寫(WAW)。什麼意思呢,就是說如果所有的指令都只對同一個資料是讀操作,那這些指令隨便怎麼排序都是正確的;但是如果有寫指令,那麼寫指令前後的讀寫操作,都不能隨意調整位置。
// 基本例子 int a = 5; int b = 3; int c = a + b; // c = 8 // 交換a和b的賦值順序 int b = 3; int a = 5; int c = a + b; // c = 8
比如說在上面的程式碼中,a和b是不相關的兩個變數,那麼這兩個值的操作相互之間沒有影響。a和b的賦值誰先誰後,c的結果都沒有變化。但是,如果我們把c的計算放在a和b的賦值之前,那麼結果就可能會變化。這是因為c的計算中有a和b的讀取,如果將a的讀取和a的寫入對調,那麼結果就會和預期的有所不同。所以如果進行並行操作的話,兩個賦值語句是可以並行完成的。但是隱含著讀取的加法操作,必須在賦值語句(寫操作)完成之後方可進行。這是寫後讀(RAW)的情況。
其它情況也是類似的。 因此不管是讀還是寫,只要不違反上述對資料相關性的約束,那麼它的結果就是正確的。當然對於並行程式設計而言,如果讀寫都針對同一個資源,那麼還必須保證讀或者寫的操作是符合讀寫鎖的互斥要求的。
回到D3D10中,我們將D3D10的資源按照讀寫限制來分,一共有四種:
去掉細節不談, 所有資源中最簡單的當數Immutable,它的資料在初始化時就要確定,確定以後再也不能變動。所以不管Command的呼叫順序如何,Immutable資源的資料都是不變的。所以Command的執行順序,對於Immutable來說沒有影響的;Default資源的讀寫操作侷限於GPU內部,所以試圖在GPU內部併發執行的命令需要進行的協調;Dynamic的讀寫橫跨CPU和GPU,需要進行同步;Staging的情況最為複雜,但是它有一個限制,就是GPU上不會參與渲染或計算過程,只能用於Copy。
要判斷CPU和GPU的命令能否同時或非同步執行、GPU命令內部能否同時執行,需要對命令流中前後命令的資料相關性進行考察。比如,CPU先讓GPU進行渲染,然後再從GPU中讀取一些東西。如果CPU將要讀取的資料不是GPU要寫的內容,那麼CPU讓GPU執行渲染後,就可以自顧自的讀取資料了;但是如果它讀取的內容恰好是GPU要渲染的內容,那CPU就只能等渲染結束才能讀取了。甚至在資料相關性不高的時候,GPU還在渲染上一次呼叫,下一次呼叫就已經可以進入流水線了。說句題外話,我們這裡所說的“Pipeline”和CPU還是有所不同的,流水的每一級都要工作很長時間,而且和下一級的在時間上的重疊度很高。是否需要通過前後渲染呼叫的重疊提高並行程度,在設計上需要進行取捨。
我們來看一個例子:
// Init idxBuffer and idxBuffer2 devContext->IASetIndexBuffer(idxBuffer); devContext->Draw(); devContext->IASetIndexBuffer(idxBuffer2); devContext->Draw(); devContext->Map(idxBuffer2, READ); // Write idxBuffer2 devContext->Unmap(); devContext->Map(idxBuffer, WRITE); // Write idxBuffer devContext->Unmap(); devContext->IASetIndexBuffer(idxBuffer); devContext->Draw(); devContext->IASetIndexBuffer(idxBuffer2); devContext->Draw();
如果我們用表格把程式碼中命令和資源的關係表達出來就是:
接下就是要如何解決非同步程式設計中兩個重要問題:1. 呼叫次序能不能顛倒;2. 被呼叫函式和呼叫方能不能同時執行。解決這兩個問題的最基本的辦法是拓撲排序。拓撲排序的作用是確定一條命令會對哪些命令產生依賴。如果它依賴的命令都執行完了,那麼就可以執行這條命令了。當然在拓撲排序之前,首先要構造一張依賴圖。依賴圖的頂點是一條Command,邊是兩個節點間的依賴關係。這一依賴關係可以由命令間的資源相關性得到:
Draw0和Draw1藉助命令佇列可以實現使用者程式碼一側的非同步呼叫。但是根據這個圖可以知道,Draw0和Draw1到了驅動之後,因為兩個呼叫在Render Target上有一個順序關係,所以驅動只能先執行Draw0;等執行完了,再執行Draw1。當Draw0和Draw1的非同步呼叫被髮起後,可能GPU還沒有執行Draw0和Draw1,但是因為Map0是可以立即執行的;而第二個Map1就慘了,因為它要寫Draw1用到的Index Buffer,如果Draw1正在畫,那就是寫衝突,如果Draw1還沒畫,Map1就把新資料寫上了,那Draw1的結果就不是預期的了。所以Map1只能老老實實的等著Draw1繪製完畢。
如果我們用拓撲排序的概念來解釋,那就是Draw1是Draw0的後繼,所以要等Draw0結束Draw1才能開始執行;Map1和Draw2是Draw1的後繼,所以只有Draw1繪製完畢,才能考慮繪製Map1和Draw2。當然因為Draw2又依賴Map1,所以如果這個依賴沒有消除的話(就是Map1對Index Buffer的寫操作結束),Draw2也沒辦法正常執行。
不過對所有命令利用資源的讀寫相關性構造拓撲排序是個比較大的消耗。因此在SALVIA的原型中實現了它的變種:我們建立了一個Command佇列。佇列中的每個Command都有一個被鎖的資源計數;此外還有一個資源-命令佇列表,表中每個資源都有一個關聯命令佇列:當一條Command執行完、或者沒有任何Command執行的時候,都會根據Command使用結束的資源,去解除一部分命令的資源鎖定。當一條Command所有的資源都不鎖定時,Command就可以被執行了。
具體的程式碼可以參見這裡:
class CommandLock { ResourceAccessType access; uint32_t lockedResourcesCount; }; class ResourceLock { deque<commandlock*> lockedCommandLocks; ResourceAccessType lockingAccess; uint32_t lockingCount; }; class Queue { public: void PushCommand(Command* cmd) { { lock mutexLocker(mMutex); mProducerCond.wait(mutexLocker, [this](){return !this->mCommmands.full(); }); for(auto res: cmd->Resources() ) { auto iter = mResourceLocks.find(res); if ( iter == mResourceLocks.end() ) { iter = mResourceLocks.insert( make_pair(res, AllocateResouceLock()) ); } ResourceLock* resLock = iter->second; resLock->lockedCommandLocks.push_front( cmd->CommandLock() ); } mCommands.push_front(cmd); mNewCommand = true; } mConsumerCond.notify_one(); } void ExecuteCommands() { while(true) { { lock mutexLocker(mMutex); mConsumerCond.wait(mMutex, [this](){ return this->Executable(); }); if (mNewCommand) { UnlockCommandResources(nullptr); mNewCommand = false; } while(true) { Command* cmd = mCommands.back(); if( !Executable(cmd) ) break; AsyncExecute(cmd); mCommands.pop_back(); } } mProducerCond.notify_one(); } } void ReleaseResource(Resource* res) { lock mutexLocker(mMutex); auto iter = mResourceLocks.find(res); if (iter != mResourceLocks.end() ) { FreeResourceLock(iter->second); mResourceLocks.erase(iter); } } private: vector<resourcelock*> mResourceLockPool; unordered_map<resource*, resourcelock*> mResourceLocks; deque<command*> mCommands; bool mNewCommand; ResourceLock* AllocateResourceLock() { if( mResourceLockPool.empty() ) { mResourceLockPool.push_back( new ResourceLock() ); } ResourceLock* ret = mResourceLockPool.back(); mResourceLockPool.pop_back(); return ret; } void FreeResourceLock(ResourceLock* resLock) { mResourceLockPool.push_back(resLock); } bool Executable() { if ( mCommands.empty() ) { return false; } if( Executable(mCommands.back()) ) { return true; } return false; } bool Executable(Command* cmd) { return cmd->ResourceCommandLock().lockedResourcesCount == 0; } void AsyncExecute(Command* cmd) { async( [this](){ cmd->Execute(); this->UnlockCommand(cmd);} ); } template void UnlockResource(IteratorT const& iter) { ResourceLock* resLock = iter->second; bool isUnlockingReaders = false; if( resLock->lockingCount > 0) { if( resLock->lockingAccess == ResourceAccessType::Read ) { isUnlockingReaders = true; } else { return; } } while(!resLock->lockedCommandLocks.empty()) { CommandLock* cmdLock = resLock->lockedCommandLocks.back(); if (isUnlockingReaders && cmdLock->access != ResourceAccessType::Read) { break; } --cmdLock->lockedResourcesCount; ++resLock->lockingCount; lockedCommandLocks->pop_back(); if(cmdLock->access == ResourceAccessType::Read) { isUnlockingReaders = true; } else { break; } } } void UnlockCommandResources(Commmand* cmd) { if( cmd == nullptr ) { for(auto iter = mResourceLocks.begin(); iter != mResourceLocks.end(); ++iter) { UnlockResource(iter); } } else { for(auto res: cmd->Resources()) { auto iter = mResourceLocks.find(res); --(*iter)->lockingCount; UnlockResource(iter); } } } void UnlockCommand(command* cmd) { { lock mutexLocker(mMutex); UnlockCommandResources(cmd); } mConsumerCond.notify_one(); } };
在實際的硬體和驅動中,Producer和Consumer自身可能都是序列的;那麼此時只需對Producer所使用的資源做讀寫計數即可(這個引用計數相當於是一個Critical Section,只是為了讓Consumer和Producer進行同步,Consumer和Producer內部都是序列的,所以也一定是順序一致的。具體的理論可以參見《多核處理器程式設計的藝術》。):
- 如果是GPU執行的命令,在進入GPU Queue時,增加命令所使用的資源讀或寫的引用計數;當GPU的命令執行完後,驅動會收到資訊,減少引用計數。
- 如果是CPU端的Map/Unmap,直接檢查GPU資源引用計數,如果資源仍然被GPU佔用,那麼就阻塞或返回;如果沒有GPU佔用,那就正常的對映到記憶體中。
當然,我還試圖做過一個更加簡單的版本,那就是,CPU一旦需要鎖定資源,那乾脆就阻塞到所有的Producer命令結束再執行。這個實現手段更加簡單,只不過不該等的也等了,效果上自然也要更差一些。
通過這些手段,可以大大減少CPU要等待GPU執行完才能繼續執行的情況。當然,如果在GPU工作時仍然要讀寫GPU上的資源會導致訪問衝突,由此帶來的阻塞也是不可避免的。此時就需要應用程式視情況進行優化,或者通過NO_OVERWRITE或DISCARD明確的告訴驅動,使用者程式碼對於資源的讀寫與正在執行的操作不衝突。
跨執行緒物件的生命期管理
在沒有GC的情況下,執行緒安全的引用計數/智慧指標幾乎是最好、也是唯一的跨執行緒物件生命期管理手段。如果你的智慧指標與std中的shared_ptr一樣,這裡也沒有特殊強調的地方。
但是如果是類似於COM物件,是一個有著內嵌引用計數的裸指標這樣的呢?要如何避免以下的程式碼出現致命的錯誤?
ID3D11Buffer* buffer = dev->CreateBuffer( ... ); buffer->Release(); devContext->IASetIndexBuffer(buffer); // ... devContext->Draw(...);
我們知道,COM物件在Create之後就Release,COM的引用計數就會歸零,物件也會被析構。此時的buffer就相當於是一個懸掛指標。對它的一切操作幾乎都會導致不可預料的後果。
指標本身也沒有任何辦法說明自己的有效性。那麼D3D Runtime如何檢查這樣的懸掛指標呢?
我們注意到,Buffer是從Device中建立出來的。一個比較容易考慮到的方案是:
在Device中保留有所有建立出來的Buffer,並且Buffer也有一個Device指標,Buffer在釋放的時候也會通知Device,Device將指標在表中移除。
在通過API設定的時候,可以通過Device檢查這個Buffer是否存活。
當然,這事兒你可以做的更極端,例如
memset(buffer, 0, YouKnowTheSizeOfBuffer); devContext->IASetIndexBuffer(buffer);
那通過這種方式是檢查不了的。甚至即便在物件欄位中增加Guard加以檢查和保護,也沒有辦法避免對物件資料進行鍼對性的破壞。
不過好在這些問題只可能在User Mode Driver(UMD)中發生。如果出現異常,大不了程式Crash就好了。真正和裝置、和作業系統核心服務打交道的,是Kernel Mode Driver(KMD)。UMD到KMD是嚴格隔離的,KM中的程式有自己的地址空間,彼此之間無法直接訪問記憶體,資料的傳遞必須進行拷貝。這些隔離措施,都是我們常說的使用者態到核心態切換成本的一部分。
非同步呼叫的錯誤返回機制
和同步呼叫相比,非同步呼叫對於錯誤處理是不那麼友好的。使用者發起的呼叫還在執行、甚至還沒開始執行,函式就已經返回了,所以你根本就不知道發起的非同步呼叫出現了什麼錯誤;錯誤發生了、非同步呼叫中斷了,又不知道怎麼傳遞給呼叫方;呼叫方拿到錯誤了,又不一定知道哪裡發生的。
非同步呼叫的錯誤返回機制就是為了解決這三個問題,雖然未必能解決的了。
在討論非同步呼叫的錯誤和異常處理方法之前,先要看看必要性。
1.如果錯誤不需要被處理,而且執行過程有容錯機制,那麼只要將命令甩出去執行就好了,不需要關心有什麼錯誤、是怎麼處理的。例如顯示卡上一些Shader值的錯誤會導致目標渲染成警告色(例如紅色),但是硬體本身不會崩潰,也不會給使用者返回任何的錯誤資訊;
2.如果呼叫方不需要知道究竟發生了什麼錯誤,只要這個錯誤被處理就行了,而且它知道怎麼樣處理錯誤,那可以使用回撥函式來處理錯誤,或者是CPS的呼叫風格;
3.呼叫方需要知道發生了什麼錯誤。這種情況需要有隱式或顯式的同步點,在這個同步點上,呼叫方會等待被非同步呼叫的函式給它返回一個訊號。這個訊號要麼是結果,要麼是一個錯誤或異常。C++11引入的std::future就可以解決這一個問題。下面這段虛擬碼大致解釋了它的實現原理。
void thread_func() { // work, work. } // 這個 wrapper 的作用就是捕獲執行緒函式的錯誤,防止錯誤被傳播到執行緒外。 void thread_func_wrapper(thread_result& result) { try { thread_func(); } catch( exception& e ) { // result是一個條件變數,設定了異常或者值後,被這個條件變數阻塞的執行緒會繼續執行。 result.set_exception(e); return; } result.set_value(e); } void thread_caller() { // 非同步呼叫。注意,呼叫的是那個能捕獲錯誤的函式 thread_result result; async( bind(thread_func_wrapper, result) ); // ... 乾點兒別的 ... try { // 等這個條件變數。 // 如果執行緒呼叫了set_value,那阻塞結束後就返回結果;否則就把這個異常重新丟擲來。 result_value = result.get_result(); } catch( exception& e ) { // 現在你知道是什麼錯誤了,處理它吧。 } }
如果異常中有堆疊資訊,或者執行緒異常一觸發就被偵錯程式捕獲,那你自然就知道異常出現在什麼地方了。當然這個例子中,異常不是必須的,你也可以用返回值來表示非同步呼叫的函式是否正確。
但是對於D3D10來說,這個問題要更復雜一些。因為非同步呼叫之後,沒有顯式的同步點。比如沒有API能讓你寫下面這一段程式碼:
devContext->Draw( ... ); // ... 乾點別的 ... devContext->IsLastFuckingDrawFuckingSucceed();
雖然有一些同步點,例如Present(D3D 11.2 以後,這裡也沒得同步了)。但是你總不能把Draw的錯誤放在Present上吧,而且你還不知道是哪個Draw的。
所以D3D採用了一個折中的方案:
- 如果一個函式執行時有錯能立刻檢查出來,那就通過返回值返回。
- 如果檢查不出來,那就容錯。
所以D3D的API在呼叫的時候都有儘可能多的檢查;特別是在Draw之前,會檢查各個渲染狀態之間互不衝突。如果檢查出有任何問題,例如無法分配Buffer等,就會通過HRESULT返回給呼叫方。一旦檢查結束,將Draw呼叫轉化成GPU執行的指令,那再出任何問題,就只能期待KMD和硬體的容錯機制了。
後記
儘管此文醞釀時間不短,從整理需求、閱讀API Remark、設計非同步解決方案開始算起已經有月餘,又有三四個版本原型的SALVIA的工程實踐,文章也寫了好幾天,但是還是覺得敘述零碎,不夠完整,有諸多不滿意之處。所以此文可能仍然會更新一段時間以修正一些錯誤、補充一些材料。也懇請各位提出寶貴意見,助我修繕全文。在此先謝過。