在D3D10中,一個基本的渲染流程可分爲如下步驟:程序員
在這一過程當中,不被初學者注意、然而在深刻學習時定會遇到的一個特性是:D3D的Draw函數是一個異步調用。算法
咱們知道,實際渲染的過程大部分是在GPU上完成的,CPU只負責發號施令。實際上,數據準備完成後,當你的程序調用了Draw函數後,CPU纔會真正的將數據和命令提交到GPU上進行渲染。從命令提交到渲染完成一般須要數十毫秒的時間,甚至對於複雜的程序更是須要數秒的時間才能返回。若是Draw一直等到GPU渲染完成再返回並執行剩下的代碼,那顯然整個線程的時間都浪費在了等待GPU的結果上。編程
這個問題或許能夠利用多線程編程來解決,可是這也意味着你的程序更加複雜了。因此在D3D中,Draw將命令發送給顯卡以後當即返回,你的程序即可以接着作其它工做了,例如新渲染數據的準備、物理、邏輯、AI的計算、場景的優化等等。換句話說,咱們稱Draw是一個異步調用。緩存
相信對D3D有所瞭解的人這一機制都已熟記於心。本文的內容,就是討論這個「異步調用」是如何實現的。具體的內容包括:安全
這些內容能夠幫助你理解Draw調用的實現原理,另外一方面也能夠做爲你實現其餘異步調用API的參考。須要說明的是,本文所述的大部分機制,均是由顯卡驅動程序或D3D Runtime實現,但考慮到各家驅動實現不一以及版權和保密協議,本文所提供的方法沒有參考任何實際的驅動程序和MS提供的參考代碼,而以SALVIA渲染器正在開發中的代碼爲主要參考。數據結構
咱們將先引入Producer/Consumer這一經典異步模型做爲異步調用實現的基礎;其次咱們介紹一些保證併發程序正確性的一些常識;再來會介紹咱們在Producer/Consumer的基礎上所作的異步調用實現,並討論如何解決CPU和GPU對同一份資源可能存在的訪問衝突;在最後兩節,咱們會討論跨線程的對象生命週期控制和檢查,以及異步調用的錯誤處理機制。多線程
在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結構。例如用戶代碼到驅動是一級,驅動到硬件又是一級。所以,消息隊列可能同時存在於軟件和硬件中。每每看起來簡單的模型,在實踐中就是這樣複雜起來的。app
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命令的生成也能夠放在這裏完成。
在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內部都是串行的,因此也必定是順序一致的。具體的理論能夠參見《多核處理器編程的藝術》。):
固然,我還試圖作過一個更加簡單的版本,那就是,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的工程實踐,文章也寫了好幾天,可是仍是以爲敘述零碎,不夠完整,有諸多不滿意之處。因此此文可能仍然會更新一段時間以修正一些錯誤、補充一些材料。也懇請各位提出寶貴意見,助我修繕全文。在此先謝過。