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

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



1.CPU執行緒和GPU執行緒的區別

另外我們還需要深刻的理解的一個概念就是CPU執行緒和GPU執行緒的區別。

1.1.CPU執行緒

CPU執行緒在Windows作業系統中更多的是指一個儲存了幾乎所有CPU暫存器狀態以及堆疊等資源資訊的核心物件(可能還有核心安全資訊等),是一個複雜的重量級的物件,並且在Windows中執行緒是最小的執行單元。同時得益於CPU單核運算能力的強大,一個CPU執行緒就可以執行很多複雜的任務。

從數量上來說,因為CPU核心加之所謂的多執行緒技術所能提供的真正執行緒並行執行數量是少的可憐的,就算伺服器CPU其核心數也就是那麼幾十個而已,而更多的執行緒幾乎都是靠“併發”執行的,一句話概括之就是“你方唱罷我登場,城頭變幻大王旗”的形式。

1.2.GPU執行緒

對於GPU來說,它上面可以執行的執行緒數量就非常可觀了,得益於現代GPU先進的架構,隨隨便便一個入門級顯示卡上都會有近幾百個被稱之為“流處理器”的計算單元,而這些計算單元你可以看做是精簡了條件分支指令、系統指令等高階擴充套件指令後只剩向量指令和一些簡單控制指令的的簡版CPU核心,當然它也是簡約而不簡單。在有些高階顯示卡上,流處理器的數量甚至可以達到幾千個,在可以預見的未來,幾萬個“流處理器”被整合在一個GPU核上也是完全有可能的(截止我寫這篇文章時Nvidia剛剛釋出了具有5120個流處理器的TITAN V計算卡)。因此在GPU上常常可以啟動成千上萬個執行緒(我就曾用DirectCompute

在我的GPU上一次啟動了一千萬個執行緒)。

相較於CPU執行緒來說GPU執行緒就輕量級的多了,它幾乎沒多少狀態需要儲存,更不需要管理複雜的中斷向量列表,GPU壓根也沒這能力,它的核就是為進行純粹的向量計算而生。並且GPU執行緒的狀態往往是分組統一儲存的,也就是說可能幾千幾萬個GPU執行緒使用的是同一個執行緒狀態物件,這樣對於獨立的一個GPU執行緒來說,它自己只需要維護一組簡單的暫存器狀態(可能僅為函式呼叫的棧幀)。同時處於一個相同分組中的執行緒往往也是執行相同的一組任務(相同的GPU程式碼),只是各自所要處理的資料有所不同而已,這也是SIMD架構處理器的典型特徵。因此我們往往也將一組GPU執行緒看成一個物件,而不需要太去區別對待,更多的時候我們可能是從一組執行緒處理的資料來感知它們,比如一段Vertex Shader

程式碼可能就是處理一個模型的幾萬個頂點。此處提示你可以形象的將GPU執行緒想象成一群由一個蟻后帶領的一窩螞蟻,因為數量的眾多,雖然每一隻可能很弱小,但是集中起來之後就有可能搬走一隻大象了。

因此GPU執行緒的數量級更是可以達到CPU執行緒的成千上萬倍。由此我們可以想象一下如果還按照管理CPU執行緒的方法來管理GPU執行緒的話,其工作難度將是難以想象的。這就好比在一個公司中如果將CPU理解為老闆,而將GPU理解為公司的員工的話,當員工數量達到一定規模後,管理形式就要發生質的變化了,比如公司員工數上了幾百人的話,再讓老闆一個個員工單獨管理的話,不要說普通人,就是超人也要歇菜了。此時我們常常採用的方式就是將員工組織成若干個部門,每個部門指定一名經理,老闆只需要指揮幾個部門經理即可。對於D3D12來說,概念也是類似的,就是我們利用多個CPU執行緒(部門經理)來帶領不同部門的員工(GPU執行緒叢集,或者直接理解為前面提到的命令佇列物件)執行不同的任務,同時CPU執行緒(部門經理)只是開會(錄製)告訴部門員工(GPU)去執行某個專案(命令列表)中的某幾個任務(命令),然後部門經理就可以去幹別的活計,而部門員工就開始勤奮的執行這批任務。因此我們在說CPUGPU同步時,更多的是說CPU執行緒同某個GPU之間的同步,或者更確切的說是CPU執行緒和某幾個命令佇列之間的同步,而不是說CPU執行緒和具體的GPU執行緒之間的同步,我們只需要將命令整體傳送給GPU即可,至於具體的GPU上啟動了多少執行緒,哪些執行緒執行哪個命令或者哪些執行緒執行到了哪條命令等等資訊,在GPU執行緒顆粒度上是不明確的。或者換一種說法,我們是將GPU看做一個整體來對待的。就好像我們看待CPU是部隊的指揮官,而GPU就是一隻由若干人(幾百或幾千人)組成的部隊一樣,指揮官只是給部隊不斷的下命令,而部隊就整體的不斷執行命令。

D3D12中一組GPU執行緒的代表物就是ID3D12CommandQueue介面。

1.3.CPU執行緒和GPU執行緒執行的動態描述

更具體的我們舉例說明一下,比如現在我們需要渲染一個帶紋理的正方體,正像好多D3D程式入門示例上顯示的那樣,為了完成這個任務,CPU執行緒就需要先準備方塊的頂點資訊和索引資訊,這通常只需要一個CPU執行緒一次讀入8個頂點和12個索引資料(三角形)及一個紋理即可,讀完之後,CPU先對這些資料進行必要的處理,比如碰撞檢測、位置變換(實際是準備物體在場景中的位置向量,然後傳到GPU),接著這個CPU執行緒就依次將這些資料(模型資料、MVP、光照、材質、紋理等等)都提交到GPU,然後呼叫著名的Draw Call即可。接著GPU如何工作呢?此時GPU就會啟動(實際更準確的說法應該是在呼叫了ExecuteCommandLists的情況下)至少8GPU執行緒來分別為每個頂點資料執行VertexShader指令碼完成幾何變換,然後啟動不少於12GPU執行緒來進行光柵化(實際數量要遠多於12個)得到片元,然後每個執行緒再為片元上的每個畫素執行Pixel Shader指令碼進行著色,最終畫素顯示到螢幕上就完成了渲染。在過去很多教程資料中講解GPU具體渲染時,頂多只是說這些處理是並行的而已,而在這裡我則明確告訴各位實際就是這麼多GPU執行緒同時在並行的執行,以便大家建立形象思維,從而徹底理解神祕的GPU及渲染管線等等的執行時狀態。

1.4.CPUGPU管理視訊記憶體

由於GPU流處理器的簡單性,它們甚至往往是分成若干組公用一個簡單的視訊記憶體管理器(較之CPU的記憶體管理器來說的簡單,但其效能和頻寬是遠遠超過CPU的記憶體管理器的),所以D3D12中的堆記憶體管理,其實只是CPU替某個或某幾個GPU管理視訊記憶體的分配的(稍後還要講多GPU系統)。也就是我們利用CPU執行緒顯式呼叫D3D12的堆介面方法來實現。這樣在多個命令佇列及多個CPU執行緒之間,就避免了因視訊記憶體生命週期不一致(主要是分配和釋放)而導致的各種訪問違規問題。

2.D3D12命令列表與命令分配器

D3D12中最後一個比較重要的與多執行緒記憶體管理有關的概念就是命令分配器了。因為如前所述我們都是使用一個CPU執行緒+一個命令列表的形式來呼叫,我們知道命令列表實際最終需要在GPU上執行,所以最終命令列表實際上是需要記錄在GPU的視訊記憶體中的(類似CPU記憶體中的程式碼段的概念),這樣就形成了CPU寫入視訊記憶體GPU最終從視訊記憶體中讀取的情況,而CPUGPU事先(至少在你寫程式碼前)是不知道需要執行多少命令的,因此這是一個“動態”的過程,為此D3D12就提供了一個ID3D12CommandAllocator介面來作為命令列表的記憶體分配器,稱之為命令分配器。因此我們就需要先建立一個命令分配器(ID3D12Device::CreateCommandAllocator),然後在建立命令列表(ID3D12Device::CreateCommandList)時指定具體的命令分配器即可。當然與命令佇列和命令列表分類相對應,命令分配器也被分為各種不同型別。

3.Draw Call的原罪

在擁有D3D11的多執行緒渲染以前,之前說的那個Draw Call命令是一個嚴重的效能陷阱,甚至成為了很多公司面試遊戲開發人員的面試題。因為CPU執行緒在呼叫Draw Call的時候GPU的工作才真正啟動並執行,而直到GPU所有的執行緒都執行結束後CPU執行緒的DrawCall函式(DrawIndexedDrawInstancedDrawIndexedInstanced等)才返回,這期間CPU執行緒幾乎就是乾等著的,也就是切換出了當前執行環境,變成等待狀態。

這種情形讓我想起了歷史上那個ReadFileWriteFile操作,以及阻塞式SOCKET通訊中的SentRecv呼叫,它們與Draw Call一樣都有一個共同的特徵,即在同步呼叫的情況下都需要CPU執行緒等待執行結果的返回,對於寫慣了順序執行程式碼的程式設計師來說,這沒什麼毛病,但是對於程式最終的效能來說它們都是具有極大殺傷力的,如果你習慣於用Profile分析這些程式的效能的時候,往往都會看到這些程式主要的效能瓶頸就在這些IO函式、以及Draw Call上。對於傳統的IO操作,Windows系統很早就提供了改進效能的措施,比如重疊IOIOCP執行緒池等等(這些東西的相關內容在我的部落格中其它文章中都有詳細介紹,歡迎閱讀),說白了這些方法的一個本質特徵就是讓這些呼叫立即返回,比如重疊IO情況下,WriteFile函式呼叫就立即返回,此時CPU執行緒就可以去執行別的任務,而系統IO裝置則去真正執行WriteFile要求的寫入硬碟的操作(可能是通過系統核心執行緒或者直接利用DMA機制等等),當真正的寫入結束的時候核心就設定一個提前預置的Event物件為有訊號狀態,或者喚醒一個處於可警告狀態的執行緒執行IO完成操作等。在這裡對於Draw Call來說,也是一樣的機制,就是在非同步執行的情況下呼叫Draw Call立即返回,不執行任何繪製動作,甚至是簡單的記錄到Command Lists中,直到Execute Command List的時候才去執行,而這個執行則是GPU去執行,CPU執行緒則在呼叫ExecuteCommand Lists時也立即返回了。這樣CPUGPU就可以同時並行的執行各自的任務了。這樣就從根本上解決了Draw Call等待,從而導致CPU執行緒效能低下的問題。

對於一個複雜場景的渲染來說,這個等待時常(時長)是無法容忍的,因為這時CPU完全可以去處理輸入、音效、網路、物理變換等等。這也是歷史上Draw Call被稱之為同步呼叫的全部意義。當然現在D3D11D3D12的多執行緒渲染框架中,這已經通過命令佇列先記錄後非同步執行的方式徹底解決了。