原文鏈接: https://zhuanlan.zhihu.com/p/20712354
作為下一代圖形API以及OpenGL的繼承者,Vulkan也保留了GL跨平台和開發等特性。
然而Vulkan誕生的最重要的理由是性能,更具體的說,是優化CPU上圖形驅動相關的性能。下面首先大概談談傳統圖形API,例如OpenGL和D3D11,在設計上面有哪些潛在的不夠高效的地方。
傳統圖形API的局限
上圖是一段比較典型的圖形應用程序中的主循環偽代碼。循環的最外層,通常每一幀都會有好幾個render pass,例如shadow map和gbuffer的渲染,光照以及各種後處理等。每個pass都有需要設定特定的管線狀態,例如blending,depth,raster的狀態等等。
在下面幾層循環中通暢需要遍歷所有的shader和着色系統需要的材質參數,如紋理,常量等等。在最內層的循環中,則是需要遍歷共享材質的幾何體,在這裡同場需要綁定vertex buffer和index buffer,以及針對物體的常量參數例如矩陣等。
這裡潛在的問題是,當渲染的場景非常複雜的時候(集合數量多,材質系統複雜,紋理,參數多,渲染管線複雜等),所有這些每一幀大量的狀態更新,資源綁定操作所花費的計算時間就不能被簡單的忽略了。而在現在的圖形程序中,複雜場景,大量的多邊形,材質、shader的組合以及複雜的渲染管線卻正是所有高質量渲染所需要的。
在處理這些修改管線狀態的操作時,驅動會在後台運行許多工作,包括下載紋理,Mipmap Downsample,資源訪問的同步,渲染狀態組合正確性驗證,以及錯誤檢查等等等等。對於3D App開發者來說,這些工作什麼時候發生,是否發生,都是在API層面無法確定的。所以這樣的結果就是在CPU端造成卡頓。
卡頓也許是你第一次給管線綁定特定的Shader,VBO或者Blend Mode,Render Target的時候。由於不同的硬件廠商的驅動處理這些工作的方式都不一樣,所以App在不同顯卡上運行的症狀以及優化的方式都會有差別,優化也是無從下手。

傳統圖形API的另一個問題就是,並不多線程友好。現在多核的系統以及不能再更加普及,然而大多數的圖形應用和遊戲在CPU端並沒有將這些放在手邊的計算資源利用起來。當在驅動的工作非常費時的情況下,利用CPU端的多線程非常可能有效的提高整個程序的性能。
無論OpenGL還是Direct3D,都包含一個Context的概念。Context包括當前渲染管線中的所有狀態,綁定的Shader,Render Target等。在OpenGL中Context和單一線程是綁定的,所以所有需要作用於Context的操作,例如改變渲染狀態,綁定Shader,調用Draw Call,都只能在單一線程上進行。
NV_CommandList拓展可以讓App支持多線程的任務生成,但是所有渲染狀態的操作還是只能在主線程進行,此處不展開。在D3D中,多個線程訪問Context時需要App顯示的做Synchronization,程序寫起來比較麻煩,而且也會有一定性能的影響。
Vulkan的設計哲學及架構
Vulkan,亦或者Direct3D12的誕生都是為了擺脫以上提到的局限。Vulkan的API在設計上很明顯的可以看到以下幾個思路:
更依賴於程序自身的認知,讓程序有更多的權限和責任自主的處理調度和優化,而不依賴於驅動嘗試在後台的優化。程序開發者應該程序的最優化行為最為了解,傳統圖形API則靠驅動分析程序中調用API模式來揣測並且推斷所有操作的優化方法。
多線程友好。讓程序儘可能的利用所有CPU計算資源從而提高性能。Vulkan中不再需要依賴於綁定在某個線程上的Context,而是用全新的基於Queue的方式向GPU遞交任務,並且提供多種Synchronization的組件讓多線程編程更加親民。
強調復用,從而減少開銷。大多數Vulkan API的組件都可以高效的被復用。
下面通過簡單介紹Vulkan的API架構來討論下Vulkan是如何實踐這些哲學的。下圖是Vulkan中主要的組件以及它們之間的關係。

首先是Device:

Device很好理解,一個Device就代表着一個你系統中的物理GPU。它的功能除了讓你可以選擇用來渲染(或者計算)的GPU以外,主要功能就是為你提供其他GPU上的資源,例如所有要用到顯存的資源,以及接下來會提到的Queue和Synchronization等組件。

第二個主要的組件,比Device複雜很多,就是Pipeline。一個Pipeline包含了傳統API中大部分的狀態和設定。只不過Pipeline是需要事先創建好的,這樣所有的狀態組合的驗證和編譯都可以在初始化的時候完成,運行時不會再因為這些操作有任何性能上的浪費。但正是因為這一點,如果你不同的Pass需要不同的狀態,你需要預先創造多個不同的Pipeline。然而我們不能把所有渲染需要的信息全都prebake進pipeline中,一個Pipeline應該是可以通過綁定不同的資源而復用的。接下來介紹的幾個組件就可以被動態的綁定給任何Pipeline。

接下來是Buffer。Buffer是所有我們所熟悉的Vertex Buffer, Index Buffer, Uniform Buffer等等的統稱。而且一個Buffer的用途非常多樣。在Vulkan中需要特別注意Buffer是從什麼類型的內存中分配的,有的類型CPU可以訪問,有的則不行。有的類型會在CPU上被緩存。現在這些內存的類型是重要的功能屬性,不再只是對驅動的一個提示了。

Image在Vulkan中代表所有具有像素結構的數組,可以用於表示文理,Render Target等等。和其他組件一樣,Image也需要在創建的時候指定使用它的模式,例如Vulkan里有參數指定Image的內存Layout,可以是Linear,也可以是Tiled Linear便於紋理Filter。
如果把一個Linear layout的Image當做紋理使用,在某些平台上可能導致嚴重的性能損失。類似傳統的API,紋理本身並不直接綁定給Pipeline。需要讀取和使用Image則要依賴於ImageView。

講了幾種不同類型的內存, 但是內存是從什麼地方分配的呢?在Vulkan中,所有內存都分配與一個指定的Heap。一個Device也許支持幾種不同類型的Heap,有些也許可以分配Mappable的內存,有些不行。
具體的類型取決於程序運行的平台。值得注意的是,Vulkan Heap分配的內存和最終的Vulkan組件例如Buffer和Image直接可以不,也不應該是一對一的映射。一段內存可以分配成數段,並且分配給不同的資源使用。某種程度上這樣的資源復用也是Vulkan基本的設計哲學之一。

上面提到,Buffer和Image可以動態的綁定給任意Pipeline。而具體綁定的規則就是由Descriptor指定。和其他組件一樣,Descriptor Set也需要在被創建的時候,就由App指定它的固定的Layout,以減少渲染時候的計算量。
Descriptor Set Layout可以指定綁定在指定Descriptor Set上的所有資源的種類和數量,以及在Shader中訪問它們的索引。App可以定義多個不同的Descriptor Set Layout,所以如何為你的程序或者引擎設計Descriptor Set的Layout將是優化的重要一環。
當然,程序也可以擁有多個指定Layout的Descriptor Set。因為Descriptor Set是預先創建並且無法更改的,所以改變一個綁定的資源需要重新創建整個Descriptor Set,但改變一個資源的Offset可以非常快速的在綁定Descriptor Set的時候完成。一會我會討論如何利用這一點來實現高效的資源更新。

介紹了那麼多組件,都是渲染需要的數據。那麼Command Buffer就是渲染本身所需要的行為。在Vulkan里,沒有任何API允許你直接的,立即的像GPU發出任何命令。所有的命令,包括渲染的Draw Call,計算的調用,甚至內存的操作例如資源的拷貝,都需要通過App自己創建的Command Buffer。
Vulkan對於Command Buffer有特有的Flag,讓程序制定這些Command只會被調用一次(例如某些資源的初始化),亦或者應該被緩存從而重複調用多次(例如渲染循環中的某個Pass)。另一個值得注意的是,為了讓驅動能更加簡易的優化這些Command的調用,沒有任何渲染狀態會在Command Buffer之間繼承下來。
每一個Command Buffer都需要顯式的綁定它所需要的所有渲染狀態,Shader,和Descriptor Set等等。這和傳統API中,只要你不改某個狀態,某個狀態就一直不會變,這一點很不一樣。

最後一個關鍵組件, Queue,是Vulkan中唯一給GPU遞交任務的渠道。Vulkan將Queue設計成了完全透明的對象,所以在驅動里沒有任何其他的隱藏Queue,也不會有任何的Synchronization發生。
在Vulkan中,給GPU遞交任務不再依賴於任何所謂的綁定在單一線程上的Context,Queue的API極其簡單,你向它遞交任務(Command Buffer),然後如果有需要的話,你可以等待當前Queue中的任務完成。
這些Synchronization操作是由Vulkan提供的各種同步組件完成的。例如Samaphore可以讓你同步Queue內部的任務,程序無法干預。Fence和Event則可以讓程序知道某個Queue中指定的任務已經完成。所有這些組件組合起來,使得基於Command Buffer和Queue遞交任務的Vulkan非常易於編寫多線程程序。
後文會簡單討論一些常見的多線程模式。最後,和前面提到的一樣,Queue不光接收圖形渲染的調用,也接受計算調用和內存操作。
Vulkan編程模式下面討論一些使用Vulkan時候比較常見的編程模式,這些模式也都各自彰顯了前面提到的Vulkan的設計哲學。例如對於內存的管理,Vulkan更加依賴於程序本身對自己資源壽命範圍的理解來達到更優化的內存分配和釋放。這裡提到的許多事情,在傳統的API中驅動可能會嘗試幫你做一部分,但是在Vulkan中,所有的控制權和責任都在程序本身上。
傳統API中,內存的分配,資源的創建以及資源的使用都是一對一的映射,很明顯這不是最佳的資源管理模式。在Vulkan中,一次來自Heap的資源分配可以同時創建多個Buffer,每個Buffer又可以用於不同的格式以及用途。
這樣相對傳統的情況已經有不少的優化。Vulkan甚至允許講一個Buffer對象的不同子區間劃分給格式以及用途不同的子Buffer,例如索引和頂點Buffer可以共享同一個Buffer,只要在綁定的時候指定不同的偏移量即可。這也是最優的做法,它既減少了內存分配的頻率,也減少了Buffer綁定的頻率。

正是因為Vulkan在Descriptor Set中綁定資源的時候,不僅需要指定Buffer,也需要指定Buffer的中資源的偏移量,所以我們可以利用這個特性達到高效的更新以及綁定的資源。因為我們可以同時綁定多個不同的資源到同一個大Buffer的不同子區間,然後在需要綁定不同的資源的時候可以重複使用同一個Descriptor Set,指定不同的偏移量即可。

至於如何組合這些資源和Buffer的組合以及Layout,Vulkan需要程序開發本身找到最佳的資源的分配,綁定以及更新模式。不再依賴於任何驅動的優化。。因為批量資源分配,更新的最佳頻率就是資源本身的更新頻率。有些資源每次只需要每次運行更新一次,有些則是每個場景更新一次,也有的動態資源每幀都需要更新。然而程序本身這些更新的頻率是最清楚明了的,永遠都能比驅動分析的結果更加準確。所以將這個任務交給程序本身其實也是非常合理的。

下面說說另一個非常重要的話題,就是多線程渲染。如前面所說,Vulkan基於Queue的API設計對多線程非常友好,同時也提供了多種Synchronization的方法。常見的並行方法有兩種,第一種是在CPU端並行的更新一些Buffer中的數據。這裡要注意的是,多線程的情況下更新資源要保證安全。
如果你的程序渲染的非常高效,通常在CPU端會同時有好幾幀的數據要處理。所以程序會可以Round Robin的方法更新並且使用這些資源。這個時候要是別的線程寫的前面某一幀還沒有被讀取完的數據則會造成錯誤。Vulkan的Event可以被插入在Command Buffer中,在使用指定資源的調用後面。這樣App回一直等到SetEvent被調用之後才會更新指定的資源。
當然在最理想的情況,程序不用真正的等這些Event,因為它早已經被Set過了。當然具體情況要取決於整個系統的性能,以及你的Round robin環有多長。

另一種並行的方式帶來的性能提升更加顯著,尤其是在渲染非常複雜的場景的時候。這也是Vulkan相比傳統API最能體現提高的情況。那就是並行的在不同線程上生成場景不同部分的渲染任務,並且生成自己的Command Buffer,不用任何線程間的Synchronization。最後,不同的線程可以將Command Buffer的Handle傳給主線程然後由主線程將它們寫入Queue中,也可以直接寫入子線程中的per-thread Queue遞交給GPU。不過Queue的任務遞交時間並不是完全可以忽略的,所以這裡還是建議將Command傳給主線程一起遞交。這樣的模式達到了計算資源利用的最大化,多個CPU核都參與了場景的渲染,並且有大量的渲染任務同時遞交給GPU最大化了GPU的吞吐量。下圖說明了這種模式。

和Buffer更新時候的線程安全一樣,Command Buffer的更新也需要注意不能直接復蓋還未被使用的Command Buffer。Vulkan的Queue寫入API接收一個Fence參數,這個Fence會在這個Queue中的任務都被GPU處理完畢後會被Signal。
所以程序將Command Buffer遞交給Queue後,可以馬上接着並行的更新和遞交新的任務。直到Fence之前的Fence被Signal之後,才可以安全的覆蓋那個Fence所對應Queue中的Command Buffer。

另一個需要主意的多線程相關的組件是Command Buffer Pool。Command Buffer Pool是Command Buffer的父親組件,負責分配Command Buffer。Command Buffer相關的操作會對其對應的Command Buffer Pool里造成一定的工作,例如內存分配和釋放等等。因為多個線程會並行的進行Command Buffer相關的操作,這個時候如果所有的Command Buffer都來自同一個Command Buffer Pool的話,這時Command Buffer Pool內的操作一定要在線程間被同步。所以這裡建議每個線程都有自己的Command Buffer Pool,這樣每個線程才可以任意的做任何Command Buffer相關的操作。

Command Buffer Pool的另一個性質就是支持非常高效的重置。一旦重置,所有由當前Pool分配的Command Buffer都會被清零,並且不會有任何內存管理上的碎片。
所以程序只要為每一個幀和線程的組合分配一個Command Buffer Pool,就可以利用這一點,在更新Round Robin中的Command Buffer時非常快速的將需要的Buffer清零。

另一個類似Command Buffer Pool的組件,就是Descriptor Pool。所有Descriptor Set都由Descriptor Pool分配,Descriptor Set操作會導致對應的Descriptor Pool工作而且需要線程間同步,並且Descriptor Pool也支持非常高效的將所有由當前Pool分配的Descriptor Set一次性清零。
所以程序應該為每個線程分配一個Descriptor Pool,可以根據Descriptor Set的更新頻率,創建不同的Descriptor Pool,例如每幀、每場景等等。
快寫完了,說一說Vulkan到底適用於哪些人。如果程序性能的瓶頸在於CPU上和圖形相關的部分,並且這部分任務能相對容易的並行化,那麼Vulkan很有可能有機會提升它的性能。亦或者對於想要榨乾某個計算資源相對有限的平台上的性能,那麼Vulkan中允許程序對所有資源直接的分配和管理也可能對性能有一定的幫助。
再者,對於非常執着於儘可能的減少程序中的延遲和卡頓,因為Vulkan的驅動不會在背後做太多複雜的工作,那麼也許也會有幫助。
但是,如果程序本身的瓶頸是GPU,Vulkan不見得有任何幫助。如果立即需要支持許多平台,並且想要有許多第三方的庫,那Vulkan畢竟還非常新。如果程序在CPU端非常難以多線程並行化,那麼Vulkan帶來的提升也會比較有限。
最後,本文只提供了一些非常概念性的介紹,並不期望讀完後會用Vulkan畫一個三角形。但是卻介紹了Vulkan背後的理念以及一些使用時候的一些模式和注意事項。相信這些東西比Hello World更加有價值。關於Vulkan編程的教程以及資源,請參考下面的鏈接:
NVIDIA Developer,包括幾個多線程的Sample:https://developer.nvidia.com/Vulkan
Vulkan Spec:Vulkan API Reference Pages
Vulkan SDK:Home Page
Render Doc的教程:Vulkan in 30 minutes
Cinder引擎的整合:Vulkan Notes :: Cinder