概述
在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的工程實踐,文章也寫了好幾天,但是還是覺得敘述零碎,不夠完整,有諸多不滿意之處。所以此文可能仍然會更新一段時間以修正一些錯誤、補充一些材料。也懇請各位提出寶貴意見,助我修繕全文。在此先謝過。