13.1 本篇概述
13.1.1 本篇內容
本篇是RHI篇章的補充篇,將詳細且深入地闡述現代圖形API的特點、原理、機制和優化技巧。更具體地,本篇主要闡述以下內容:
- 現代圖形API的基礎概念。
- 現代圖形API的特性。
- 現代圖形API的使用方式。
- 現代圖形API的原理和機制。
- 現代圖形API的優化建議。
此文所述的現代圖形API指DirectX12、Vulkan、Metal等,而不包含DirectX11和Open GL(ES),但也不完全排除後者的內容。
由於UE的RHI封裝以DirectX為主,所以此文也以DirectX作為主視角,Vulkan、Metal等作為輔視角。
13.1.2 概念總覽
我們都知道,現存的API有很多種(下表),它們各具特點,自成體系,涉及了眾多不同但又相似的概念。
圖形API | 適用系統 | 著色語言 |
---|---|---|
DirectX | Windows、XBox | HLSL(High Level Shading Language) |
Vulkan | 跨平臺 | SPIR-V |
Metal | iOS、MacOS | MSL(Metal Shading Language) |
OpenGL | 跨平臺 | GLSL(OpenGL Shading Language) |
OpenGL ES | 移動端 | ES GLSL |
下面是它們涉及的概念和名詞的對照表:
DirectX | Vulkan | OpenGL(ES) | Metal |
---|---|---|---|
texture | image | texture and render buffer | texture |
render target | color attachments | color attachments | color attachments or render target |
command list | command buffer | part of context, display list, NV_command_list | command buffer |
command list | secondary command buffer | - | parallel command encoder |
command list bundle | - | light-weight display list | indirect command buffer |
command allocator | command pool | part of context | command queue |
command queue | queue | part of context | command queue |
copy queue | transfer queue | glBlitFramebuffer() | blit command encoder |
copy engine | transfer engine | - | blit engine |
predication | conditional rendering | conditional rendering | - |
depth / stencil view | depth / stencil attachment | depth attachment and stencil attachment | depth attachment and stencil attachment, depth render target and stencil render target |
render target view, depth / stencil view, shader resource view, unordered access view | image view | texture view | texture view |
typed buffer SRV, typed buffer UAV | buffer view, texel buffer | texture buffer | texture buffer |
constant buffer views (CBV) | uniform buffer | uniform buffer | buffer in constant address space |
rasterizer order view (ROV) | fragment shader interlock | GL_ARB_fragment_shader_interlock | raster order group |
raw or structured buffer UAV | storage buffer | shader storage buffer | buffer in device address space |
descriptor | descriptor | - | argument |
descriptor heap | descriptor pool | - | heap |
descriptor table | descriptor set | - | argument buffer |
heap | device memory | - | placement heap |
- | subpass | pixel local storage | programmable blending |
split barrier | event | - | - |
ID3D12Fence::SetEventOnCompletion | fence | fence, sync | completed handler, -[MTLComandBuffer waitUntilComplete] |
resource barrier | pipeline barrier, memory barrier | texture barrier, memory barrier | texture barrier, memory barrier |
fence | semaphore | fence, sync | fence, event |
D3D12 fence | timeline semaphore | - | event |
pixel shader | fragment shader | fragment shader | fragment shader or fragment function |
hull shader | tessellation control shader | tessellation control shader | tessellation compute kernel |
domain shader | tessellation evaluation shader | tessellation evaluation shader | post-tessellation vertex shader |
collection of resources | fragmentbuffer | fragment object | - |
pool | heap | - | - |
heap type, CPU page property | memory type | automatically managerd, texture storage hint, buffer storage | storage mode, CPU cache mode |
GPU virtual address | buffer device address | - | - |
image layout, swizzle | image tiling | - | - |
matching semantics | interface matching (in / out) | varying (removed in GLSL 4.20) | - |
thread, lane | invocation | invocation | thread, lane |
threadgroup | workgroup | workgroup | threadgroup |
wave, wavefront | subgroup | subgroup | SIMD-group, quadgroup |
slice | layer | - | slice |
device | logical device | context | device |
multi-adapter device | device group | implicit(E.g. SLICrossFire) | peer group |
adapter, node | physical device | - | device |
view instancing | multiview rendering | multiview rendering | vertex amplification |
resource state | image layout | - | - |
pipeline state | pipeline | stage and program or program pipeline | pipeline state |
root signature | pipeline layout | - | - |
root parameter | descriptor set layout binding, push descriptor | - | argument in shader parameter list |
resulting ID3DBlob from D3DCompileFromFile | shader module | shader object | shader library |
shading rate image | shading rate attachment | - | rasterization rate map |
tile | sparse block | sparse block | sparse tile |
reserved resource(D12), tiled resource(D11) | sparse image | sparse texture | sparse texture |
window | surface | HDC, GLXDrawable, EGLSurface | layer |
swapchain | swapchain | Pairt of HDC, GLXDrawable, EGLSurface | layer |
- | swapchain image | default framebuffer | drawable texture |
stream-out | transform feedback | transform feedback | - |
從上表可知,Vulkan和OpenGL(ES)比較相似,但多了很多概念。Metal作為後起之秀,很多概念和DirectX相同,但部分又和Vulkan相同,相當於是前輩們的混合體。
對於Vulkan,涉及的概念、層級和資料互動關係如下圖所示:
Vulkan概念和層級架構圖。涉及了Instance、PhysicalDevice、Device等層級,每個層級的各個概念或資源之間存在錯綜複雜的引用、組合、轉換、互動等關係。
Metal資源和概念框架圖。
13.1.3 現代圖形API特點
對於傳統圖形API(DirectX11及更早、OpenGL、OpenGL ES),GPU程式設計開銷很大,主要表現在:
- 狀態校驗(State validation):
- 確認API標記和資料合法。
- 編碼API狀態到硬體狀態。
- 著色器編譯(Shader compilation):
- 執行時生成著色器機器碼。
- 狀態和著色器之間的互動。
- 傳送工作到GPU(Sending work to GPU):
- 管理資源生命週期。
- 批處理渲染命令。
對於以上開銷大的操作,傳統圖形API和現圖形代API的描述如下:
階段 | 頻率 | 傳統圖形API | 現代圖形API |
---|---|---|---|
應用程式構建 | 一次 | - | 著色器編譯 |
內容載入 | 少次 | - | 狀態校驗 |
繪製呼叫 | 1000次每幀 | 狀態校驗,著色器編譯,傳送工作到GPU | 傳送工作到GPU |
以上可知,傳統API將開銷較大的狀態校驗、著色器編譯和傳送工作到GPU全部放到了執行時,而現代圖形API將著色器編譯放到了應用程式構建期間,而狀態校驗移至內容載入之時,只保留髮送工作到GPU在繪製呼叫期間,從而極大減輕了執行時的工作負擔。
現代圖形API(DirectX12、Vulkan、Metal)和傳統圖形API的描述對照表如下:
現代圖形API | 傳統圖形API |
---|---|
基於物件的狀態,沒有全域性狀態。 | 單一的全域性狀態機。 |
所有的狀態概念都放置到命令緩衝區中。 | 狀態被繫結到單個上下文。 |
可以多執行緒編碼,並且受驅動和硬體支援。 | 渲染操作只能被順序執行。 |
可以精確、顯式地操控GPU的記憶體和同步。 | GPU的記憶體和同步細節通常被驅動程式隱藏起來。 |
驅動程式沒有執行時錯誤檢測,但存在針對開發人員的驗證層。 | 廣泛的執行時錯誤檢測。 |
相比OpenGL(ES)等傳統API,Vulkan支援多執行緒,輕量化驅動層,可以精確地管控GPU記憶體、同步等資源,避免執行時建立和消耗資源堆,避免執行時校驗,避免CPU和GPU的同步點,基於命令佇列的機制,沒有全域性狀態等等(下圖)。
Vulkan擁有更輕量的驅動層,使得應用程式能夠擁有更大的自由度控制GPU,也有更多的硬體效能。
圖形API、驅動層、作業系統、核心層架構圖。
Metal(右)比OpenGL(左)擁有更輕量的驅動層。
DirectX11驅動程式(上)和DirectX12應用程式(下)執行的工作對比圖。
得益於Vulkan的先進設計理念,使得它的渲染效能更高,通常在CPU、GPU、頻寬、能耗等指標都優於OpenGL。但如果是應用程式本身的CPU或者GPU負載高,則使用Vulkan的收益可能沒有那麼明顯:
對於使用了傳統API的渲染引擎,如果要遷移到現代圖形API,潛在收益和工作量如下圖所示:
從OpenGL(ES)遷移到現代圖形API的成本和收益對比。橫座標是從OpenGL(ES)遷移其它圖形API的工作量,縱座標是潛在的效能收益。可見Vulkan和DirectX12的潛在收益比和工作量都高,而Metal次之。
部分GPU廠商(如NVidia)會共享OpenGL和Vulkan驅動,甚至在應用程式層,它們可以混合:
NV的OpenGL和Vulkan共享架構圖。可以共享資源、工具箱,提升效能,提升可移植性,允許應用程式在最重要的地方增加Vulkan,獲取了OpenGL即獲取了Vulkan,減少驅動程式的開發工作量。
利用現代圖形API,可以獲得的潛在收益有:
- 更好地利用多核CPU。如多執行緒錄製、多執行緒渲染、多佇列、非同步技術等。
- 更小的驅動層開銷。
- 精確的記憶體和資源管理。
- 提供精確的多裝置訪問。
- 更多的Draw Call,更多的渲染細節。
- 更高的最小、最大、平均幀率。
- 更高效的GPU硬體使用。
- 更高效的整合GPU硬體使用。
- 降低系統功率。
- 允許新的架構設計,以前由於傳統API的技術限制而認為是不可能的,如TBR。
13.2 裝置上下文
13.2.1 啟動流程
對大多數圖形API而言,應用程式使用它們時都存在以下幾個階段:
- InitAPI:建立訪問API內部工作所需的核心資料結構。
- LoadingAssets:建立資料結構需要載入的東西(如著色器),以描述圖形管道,建立和填充命令緩衝區讓GPU執行,並將資源傳送到GPU的專用記憶體。
- UpdatingAssets:更新任何Uniform資料到著色器,執行應用程式級別的邏輯。
- Presentation:將命令緩衝區列表傳送到命令佇列,並呈現交換鏈。
- AppClosed:如果應用程式沒有傳送關閉命令,則重複LoadingAssets、UpdatingAssets、Presentation階段,否則執行Destroy階段。
- Destroy:等待GPU完成所有剩餘工作,並銷燬所有資料結構和控制程式碼。
現代圖形API啟動流程。
後續章節將按照上面的步驟和階段涉及的概念和機制進行闡述。
13.2.2 Device
初始化圖形API階段,涉及了Factory、Instance、Device等等概念,它們的概念在各個圖形API的對照表如下:
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Entry Point | FDynamicRHI | IDXGIFactory4 | IDXGIFactory | vk::Instance | CAMetalLayer | Varies by OS |
Physical Device | - | IDXGIAdapter1 | IDXGIAdapter | vk::PhysicalDevice | MTLDevice | glGetString(GL_VENDOR) |
Logical Device | - | ID3D12Device | ID3D11Device | vk::Device | MTLDevice | - |
Entry Point(入口點)是應用程式的全域性例項,通常一個應用程式只有一個入口點例項。用來儲存全域性資料、配置和狀態。
Physical Device(物理裝置)對應著硬體裝置(顯示卡1、顯示卡2、整合顯示卡),可以查詢重要的裝置具體細節,如記憶體大小和特性支援。
Logical Device(邏輯裝置)可以訪問API的核心內部函式,比如建立紋理、緩衝區、佇列、管道等圖形資料結構,這種型別的資料結構在所有現代圖形api中大部分是相同的,它們之間的變化很少。Vulkan和DirectX 12通過Logical Device建立記憶體資料結構來控制記憶體。
每個應用程式通常有且只有一個Entry Point,UE的Entry Point是FDynamicRHI的子類。每個Entry Point擁有1個或多個Physical Device,每個Physical Device擁有1個或多個Logical Device。
13.2.3 Swapchain
應用程式的後快取和交換鏈根據不同的系統或圖形API有所不同,涉及了以下概念:
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Window Surface | FRHIRenderTargetView | ID3D12Resource | ID3D11Texture2D | vk::Surface | CAMetalLayer | Varies by OS |
Swapchain | - | IDXGISwapChain3 | IDXGISwapChain | vk::Swapchain | CAMetalDrawable | Varies by OS |
Frame Buffer | FRHIRenderTargetView | ID3D12Resource | ID3D11RenderTargetView | vk::Framebuffer | MTLRenderPassDescriptor | GLuint |
在DirectX上,由於只有Windows / Xbox作為API的目標,最接近Surface(表面)的東西是從交換連結收到的紋理返回緩衝區。交換連結收視窗控制程式碼,從那裡DirectX驅動程式內部會建立一個Surface。對於Vulkan,需要以下幾個步驟建立可呈現的視窗表面:
Vulkan WSI的步驟示意圖。
由於MacOS和iOS視窗具有分層結構(hierarchical structure),其中應用程式包含一個檢視(View),檢視可以包含一個層(layer),在Metal中最接近Surface的東西是layer或包裹它的view。
Metal和OpenGL缺少交換鏈的概念,而把交換鏈留給了作業系統的視窗API。
DirectX 12和11沒有明確的資料結構表明Frame Buffer,最接近的是Render Target View。
Swapchain(交換鏈)包含單緩衝、雙緩衝、三緩衝,分別應對不同的情況。應用程式必須做顯式的緩衝區旋轉:
DirectX:IDXGISwapChain3::GetCurrentBackBufferIndex()
下面是對Swapchain的使用建議:
- 如果應用程式總是比vsync執行得慢,那麼在交換鏈中使用1個Surface。
- 如果應用程式總是比vsync執行得快,那麼在交換鏈中使用2個Surface,可以減少記憶體消耗。
- 如果應用程式有時比vsync執行得慢,那麼在交換鏈中使用3個Surface,可以給應用程式提供最佳效能。
Vulkan交換鏈執行示意圖。
13.3 管線資源
現代圖形渲染管線涉及了複雜的流程、概念、資源、引用和資料流關係。(下圖)
Vulkan渲染管線關係圖。
13.3.1 Command
現代圖形API的Command(命令)包含應用程式向GPU互動的所有操作,涉及了以下幾種概念:
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Command Queue | - | ID3D12CommandQueue | ID3D11DeviceContext | vk::Queue | MTLCommandQueue | - |
Command Allocator | - | ID3D12CommandAllocator | ID3D11DeviceContext | vk::CommandPool | MTLCommandQueue | - |
Command Buffer | FRHICommandList | ID3D12GraphicsCommandList | ID3D11DeviceContext | vk::CommandBuffer | MTLRenderCommandEncoder | - |
Command List | FRHICommandList | ID3D12CommandList[] | ID3D11CommandList | vk::SubmitInfo | MTLCommandBuffer | - |
Command Queue允許我們將任務加入佇列給GPU執行。GPU是一種非同步計算裝置,需要讓它一直處於繁忙狀態,同時控制何時將專案新增到佇列中。
Command Allocator允許建立Command Buffer,可以定義想要GPU執行的函式。Command Allocator數量上的建議是:
如果有數百個Command Allocator,是錯誤的做法。Command Allocator只會增加,意味著:
- 不能從分配器中回收記憶體。回收分配器將把它們增加到最壞情況下的大小。
- 最好將它們分配到命令列表中。
- 儘可能按大小分配池。
- 確保重用分配器/命令列表,不要每幀重新建立。
Command Buffer是一個非同步計算單元,可以描述GPU執行的過程(例如繪製呼叫),將資料從CPU-GPU可訪問的記憶體複製到GPU的專用記憶體,並動態設定圖形管道的各個方面,比如當前的scissor。Vulkan的Command Buffer為了達到重用和精確的控制,有著複雜的狀態和轉換(即有限狀態機):
Command List是一組被批量推送到GPU的Command Buffer。這樣做是為了讓GPU一直處於繁忙狀態,從而減少CPU和GPU之間的同步。每個Command List嚴格地按照順序執行。Command List可以呼叫次級Command List(Bundle、Secondary Command List)。這兩級的Command List都可以被呼叫多次,但需要等待上一次提交完成。
下圖是DX12的命令相關的概念構成的層級結構關係圖:
對於相似的Command List或Allocator,儘量複用之:
當重置Command List或Allocator時,儘量保持它們引用的資源不變(沒有銷燬或新的分配)。
但如果資料很不相似,則銷燬之,銷燬之前必須釋放記憶體。
為了更好的效能,在Command方面的建議如下:
-
對Command Buffer使用雙緩衝、三緩衝。在CPU上填充下一個,而前一個仍然在GPU上執行。
-
拆分一幀到多個Command Buffer。更有規律的GPU工作提交,命令越早提交越少延時。
-
限制Command Buffer數量。比如每幀15~30個。
-
將多個Command Buffer批處理到一個提交呼叫中,限制提交次數。比如每幀每個佇列5個。
-
控制Command Buffer的粒度。提交大量的工作,避免多次小量的工作。
-
記錄幀的一部分,每幀提交一次。
-
在多個執行緒上並行記錄多個Command Buffer。
-
大多數物件和資料(包含但不限於Descriptor、CB等記憶體資料)在GPU上使用時不會被圖形API執行引用計數或版本控制。確保它們在GPU使用時保持生命週期和不被修改。可以和Command Buffer的雙緩衝、三緩衝一起使用。
-
使用Ring Buffer儲存動態資料。
13.3.2 Render Pass
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Render Pass | FRHIRenderPassInfo | BeginRenderPass, EndRenderPass | - | VkRenderPass | MTLRenderPassDescriptor | - |
SubPass | FRHIRenderPassInfo | - | - | VkSubpassDescription | Programmable Blending | PLS |
繪製命令必須記錄在Render Pass例項中,每個Render Pass例項定義了一組輸入、輸出影像資源,以便在渲染期間使用。
DirectX 12錄製命令佇列示意圖。其中命令包含了資源、光柵化等型別。
現代移動GPU已經普遍支援TBR架構,為了更好地利用此架構特性,讓Render Pass期間的資料保持在Tile快取區內,便誕生了Subpass技術。利用Subpass技術可以顯著降低頻寬,提升渲染效率。更多請閱讀12.4.13 subpass和10.4.4.2 Subpass渲染。
Vulkan Render Pass內涉及的各類概念、資源及互動關係。
在OpenGL,採用Pixel Local Storage的技術來模擬Subpass。Metal則使用Programmable Blending(PB)來模擬Subpass機制(下圖)。
上:傳統的多Pass渲染延遲光照,多個GBuffer紋理會在GBuffer Pass和Lighting Pass期間來回傳輸於Tile Memeory和System Memory之間;下:利用Metal的PB技術,使得GBuffer資料在GBuffer Pass和Lighting Pass期間一直保持在Tile Memroy內。
Metal利用Render Pass的Store和Load標記精確地控制Framebuffer在Tile內,從而極大地降低讀取和寫入頻寬。
建立和使用一個Render Pass的虛擬碼如下:
Start a render pass
// 以下程式碼會迴圈若干次
Bind all the resources
Descriptor set(s)
Vertex and Index buffers
Pipeline state
Modify dynamic state
Draw
End render pass
Vulkan的Render Pass使用建議:
- 即使是幾個subpass組成一個小的Render Pass,也是好做法。
- Depth pre-pass, G-buffer render, lighting, post-process
- 依賴不是必定需要的。
- 多個陰影貼圖通道產生多個輸出。
- 把要做的任務重疊到Render Pass中。
- 優先使用load op clear而不是vkCmdClearAttachment。
- 優先使用渲染通道附件的最終佈局,而不是明確的Barrier。
- 充分利用“don’t care”。
- 使用解析附件執行MSAA解析。
更多Render Pass相關的說明請閱讀:12.4.13 subpass和10.4.4.2 Subpass渲染。
13.3.3 Texture, Shader
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Texture | FRHITexture | ID3D12Resource | ID3D11Texture2D | vk::Image & vk::ImageView | MTLTexture | GLuint |
Shader | FRHIShader | ID3DBlob | ID3D11VertexShader, ID3D11PixelShader | vk::ShaderModule | MTLLibrary | GLuint |
大多數現代圖形api都有繫結資料結構,以便將Uniform Buffer和紋理連線到需要這些資料的圖形管道。Metal的獨特之處在於,可以在命令編碼器中使用setVertexBuffer繫結Uniform,比Vulkan、DirectX 12和OpenGL更容易構建。
13.3.4 Shader Binding
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Shader Binding | FRHIUniformBuffer | ID3D12RootSignature | ID3D11DeviceContext::VSSetConstantBuffers(...) | vk::PipelineLayout & vk::DescriptorSet | [MTLRenderCommandEncoder setVertexBuffer: uniformBuffer] | GLint |
Pipeline State | FGraphicsPipelineStateInitializer | ID3D12PipelineState | Various State Calls | vk::Pipeline | MTLRenderPipelineState | Various State Calls |
Descriptor | - | D3D12_ROOT_DESCRIPTOR | - | VkDescriptorBufferInfo, VkDescriptorImageInfo | argument | - |
Descriptor Heap | - | ID3D12DescriptorHeap | - | VkDescriptorPoolCreateInfo | heap | - |
Descriptor Table | - | D3D12_ROOT_DESCRIPTOR_TABLE | - | VkDescriptorSetLayoutCreateInfo | argument buffer | - |
Root Parameter | - | D3D12_ROOT_PARAMETER | - | VkDescriptorSetLayoutBinding | argument in shader parameter list | - |
Root Signature | - | ID3D12RootSignature | - | VkPipelineLayoutCreateInfo | - | - |
Pipeline State(管線狀態)是在執行光柵繪製呼叫、計算排程或射線跟蹤排程時將要執行的內容的總體描述。DirectX 11和OpenGL沒有專門的圖形管道物件,而是在執行繪製呼叫之間使用呼叫來設定管道狀態。
Root Signature(根簽名)是定義著色器可以訪問哪些型別的資源的物件,比如常量緩衝區、結構化緩衝區、取樣器、紋理、結構化緩衝區等等(下圖)。
具體地說,Root Signature可以設定3種型別的資源和資料:Descriptor Table、Descriptor、Constant Data。
DirectX 12根簽名資料結構示意圖。
這三種資源在CPU和GPU的消耗剛好相反,需權衡它們的使用:
Root Signature3種型別(Descriptor Table、Descriptor、Constant Data)在GPU記憶體獲取消耗依次降低,但CPU消耗依次提升。
更具體地說,改變Table的指標消耗非常小(只是改變指標,沒有同步開銷),但改變Table的內容比較困難(處於使用中的Table內容無法被修改,沒有自動重新命名機制)。
因此,需要儘量控制Root Signature的大小,有效控制Shader可見範圍,只在必要時才更新Root Signature資料。
Root Signature在DirectX 12上最大可達64 DWORD,可以包含資料(會佔用很大儲存空間)、Descriptor(2 DWORD)、指向Descriptor Table的指標(下圖)。
Descriptor(描述符)是一小塊資料,用來描述一個著色器資源(如緩衝區、緩衝區檢視、影像檢視、取樣器或組合影像取樣器)的引數,只是不透明資料(沒有OS生命週期管理),是硬體代表的檢視。
Descriptor的資料圖例。
Descriptor被組織成Descriptor Table(描述符表),這些Descriptor Table在命令記錄期間被繫結,以便在隨後的繪圖命令中使用。
每個Descriptor Table中內容的編排由Descriptor Table中的Layout(佈局)決定,該佈局決定哪些Descriptor可以儲存在其中,管道可以使用的Descriptor Table或Root Parameter(根引數)的序列在Root Signature中指定。每個管道物件使用的Descriptor Table和Root Parameter有數量限制。
Descriptor Heap(描述符堆)是處理記憶體分配的物件,用於儲存著色器引用的物件的描述。
Root Signature、Root Parameter、Descriptor Table、Descriptor Heap的關係。其中Root Signature儲存著若干個Root Parameter例項,每個Root Parameter可以是Descriptor Table、UAV、SRV等物件,Root Parameter的記憶體內容存在了Descriptor Heap中。
DX12的根簽名在GPU內部的互動示意圖。其中Root Signature在所有Shader Stage中是共享的。
下面舉個Vulkan Descriptor Set的使用示例。已知有以下3個Descriptor Set A、B、C:
通過以下C++程式碼繫結它們:
vkBeginCommandBuffer();
// ...
vkCmdBindPipeline(); // Binds shader
// 繫結Descriptor Set B和C, 其中C在序號0, B在序號2. A沒有被繫結.
vkCmdBindDescriptorSets(firstSet = 0, pDescriptorSets = &descriptor_set_c);
vkCmdBindDescriptorSets(firstSet = 2, pDescriptorSets = &descriptor_set_b);
vkCmdDraw(); // or dispatch
// ...
vkEndCommandBuffer();
則經過上述程式碼繫結之後,Shader資源的繫結序號如下圖所示:
對應的GLSL程式碼如下:
layout(set = 0, binding = 0) uniform sampler2D myTextureSampler;
layout(set = 0, binding = 2) uniform uniformBuffer0 {
float someData;
} ubo_0;
layout(set = 0, binding = 3) uniform uniformBuffer1 {
float moreData;
} ubo_1;
layout(set = 2, binding = 0) buffer storageBuffer {
float myResults;
} ssbo;
對於複雜的渲染場景,應用程式可以修改只有變化了的資源集,並且要保持資源繫結的更改越少越好。下面是渲染虛擬碼:
foreach (scene) {
vkCmdBindDescriptorSet(0, 3, {sceneResources,modelResources,drawResources});
foreach (model) {
vkCmdBindDescriptorSet(1, 2, {modelResources,drawResources});
foreach (draw) {
vkCmdBindDescriptorSet(2, 1, {drawResources});
vkDraw();
}
}
}
對應的shader虛擬碼:
layout(set=0,binding=0) uniform { ... } sceneData;
layout(set=1,binding=0) uniform { ... } modelData;
layout(set=2,binding=0) uniform { ... } drawData;
void main() { }
Vulkan繫結Descriptor流程圖。
下圖是另一個Vulkan的VkDescriptorSetLayoutBinding案例:
關於著色器繫結的使用,建議如下:
-
Root Signature最好儲存在單個Descriptor Heap中,使用RingBuffer資料結構,使用靜態的Sampler(最多2032個)。
-
不要超過Root Signature的尺寸。
- Root Signature內的CBV和常量應該最可能每個Draw Call都改變。
- 大部分在CB內的常量資料不應該是根常量。
-
只把小的、頻繁使用的每次繪製都會改變的常量,直接放到Root Signature。
-
按照更新頻率拆分Descriptor Table,最頻繁更新的放在最前面(僅DirectX 12,Vulkan相反,Metal未知)。
-
Per-Draw,Per-Material,Per-Light,Per-Frame。
-
通過將最頻繁改變的資料放置到根簽名前面,來提供更新頻率提示給驅動程式。
-
-
在啟動時複製Root Signature到SGPR。
- 在編譯器就確定好佈局。
- 只需要為每個著色階段拷貝。
- 如果佔用太多SGPR,Root Signature會被拆分到Local Memory(下圖),應避免這種情況!!
-
儘可能地使用靜態表,可以提升效能。
-
保持RST(根簽名表)儘可能地小。可以使用多個RST。
-
目標是每個Draw Call只改變一個Slot。
-
將資源可見性限制到最小的階段集。
- 如果沒必要,不要使用D3D12_SHADER_VISIBILITY_ALL。
- 儘量使用DENY_xxx_SHADER_ROOT_ACCESS。
-
要小心,RST沒有邊界檢測。
-
在更改根簽名之後,不要讓資源繫結未定義。
-
AMD特有建議:
- 只有常量和CBV的逐Draw Call改變應該在RST內。
- 如果每次繪製改變超過一個CBV,那麼最好將CBV放在Table中。
-
NV特有建議:
- 將所有常量和CBV放在RST中。
- RST中的常量和CBV確實會加速著色器。
- 根常量不需要建立CBV,意味著更少的CPU工作。
- 將所有常量和CBV放在RST中。
-
儘量快取並重用DescriptorSet。
Fortnite快取並複用DescriptorSet圖例。
13.3.5 Heap, Buffer
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Heap | FRHIResource | ID3D12Resource, ID3D12Heap | - | Vk::MemoryHeap | MTLBuffer | - |
Buffer | FRHIIndexBuffer, FRHIVertexBuffer | ID3D12Resource | ID3D11Buffer | vk::Buffer & vk::BufferView | MTLBuffer | GLuint |
Heap(堆)是包含GPU記憶體的物件,可以用來上傳資源(如頂點緩衝、紋理)到GPU的專用記憶體。
Buffer(緩衝區)主要用於上傳頂點索引、頂點屬性、常量緩衝區等資料到GPU。
13.3.6 Fence, Barrier, Semaphore
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Fence | FRHIGPUFence | ID3D12Fence | ID3D11Fence | vk::Fence | MTLFence | glFenceSync |
Barrier | FRDGBarrierBatch | D3D12_RESOURCE_BARRIER | - | vkCmdPipelineBarrier | MTLFence | glMemoryBarrier |
Semaphore | - | HANDLE | HANDLE | vk::Semaphore | dispatch_semaphore_t | Varies by OS |
Event | FEvent | - | - | Vk::Event | MTLEvent, MTLSharedEvent | Varies by OS |
Fence(柵欄)是用於同步CPU和GPU的物件。CPU或GPU都可以被指示在柵欄處等待,以便另一個可以趕上。可以用來管理資源分配和回收,使管理總體圖形記憶體使用更容易。
Barrier(屏障)是更細粒度的同步形式,用在Command Buffer內。
Semaphore(訊號量)是用於引入操作之間依賴關係的物件,例如在向裝置佇列提交命令緩衝區之前,在獲取交換鏈中的下一個影像之前等待。Vulkan的獨特之處在於,訊號量是API的一部分,而DirectX和Metal將其委託給OS呼叫。
Event(事件)和Barrier類似,用來同步Command Buffer內的操作。對DirectX和OpenGL而言,需要依賴作業系統的API來實現Event。在UE內部,FEvent用來同步執行緒之間的訊號。
Vulkan同步機制:semaphore(訊號)用於同步Queue;Fence(柵欄)用於同步GPU和CPU;Event(事件)和Barrier(屏障)用於同步Command Buffer。
Vulkan semaphore在多個Queue之間的同步案例。
13.4 管線機制
13.4.1 Resource Management
對於現代的硬體架構而言,常見的記憶體模型如下所示:
現代計算機記憶體模型架構圖。從上往下,容量越來越小,但頻寬越來越大。
對於DirectX 11等傳統API而言,資源記憶體需要依賴作業系統來管理生命週期,記憶體填充遍佈所有時間,大部分直接變成了視訊記憶體,會導致溢位,回傳到系統記憶體。這種情況在之前沒有受到太多人關注,而且似乎我們都習慣了驅動程式在背後偷偷地做了很多額外的工作,即便它們並非我們想要的,並且可能會損耗效能。
DirectX 11記憶體管理模型圖例。部分資源同時存在於Video和System Memory中。若Video Memory已經耗盡,部分資源不得不遷移到System Memory。
相反,DirectX 12、Vulkan、Metal等現代圖形API允許應用程式精確地控制資源的儲存位置、狀態、轉換、生命週期、依賴關係,以及指定精確的資料格式和佈局、是否開啟壓縮等等。現代圖形API的驅動程式也不會做過多額外的記憶體管理工作,所有權都歸應用程式掌控,因為應用程式更加知道資源該如何管理。
DX11和DX12的記憶體分配對比圖。DX11基於專用的記憶體塊,而DX12基於堆分配。
現代圖形API中,幾乎所有任務都是延遲執行的,所以要確保不要更改仍在處理佇列中的資料和資源。開發者需要處理資源的生命週期、儲存管理和資源衝突。
利用現代圖形API管理資源記憶體,首選要考慮的是預留記憶體空間。
// DirectX 12通過以下介面實現查詢和預留視訊記憶體
IDXGIAdapter3::QueryVideoMemoryInfo()
IDXGIAdapter3::SetVideoMemoryReservation()
如果是前臺應用程式,QueryVideoMemory
會在空閒系統中啟動大約一半的VRAM,如果更少,可能意味著另一個重量級應用已經在執行。
記憶體耗盡是一個最小規格問題(min spec issue),應用程式需要估量所需的記憶體空間,提供配置以修改預留記憶體的尺寸,並且需要根據硬體規格提供合理的選擇值。
預留空間之後,DirectX 12可以通過MakeResident
二次分配記憶體。需要注意的是,MakeResident
是個同步操作,會卡住呼叫執行緒,直到記憶體分配完畢。它的使用建議如下:
-
對多次
MakeResident
進行合批。 -
必須從渲染執行緒抽離,放到額外的專用執行緒中。分頁操作將與渲染相交織。(下圖)
-
確保在使用前就準備好資源,否則即便已經使用了專用的資源執行緒,依然會引發卡頓。
對此,可以使用提前執行策略(Run-ahead Strategie)。提前預測現在和之後可能會用到什麼資源,在渲染執行緒之前執行幾幀,更多緩衝區將獲得更少的卡頓,但會引入延遲。
也可以不使用residency機制,而是預載入可能用於系統記憶體的資源,不要立即移動它們到視訊記憶體。當資源被使用時,才複製到Video Memory,然後重寫描述符或重新對映頁面(下圖)。當需要減少記憶體使用時,反向操作並收回視訊記憶體副本。
但是,這個方法對VR應用面臨巨大挑戰,會引發長時間延時的解決方案顯然行不通。可以明智地使用系統記憶體,並在流(streaming)中具備良好的前瞻性。
另外,需要謹慎處理資源的衝突,需要用同步物件控制可能的資源衝突:
上:CPU在處理資料更新時和GPU處理繪製起了資源衝突;下:CPU需要顯示加入同步等待,以便等待GPU處理完繪製呼叫之後,再執行資料更新。
常見的資源衝突情況:
- 陰影圖。
- 延遲著色、光照。
- 實時反射和折射。
- ...
- 任何應用渲染目標作為後續渲染中貼圖的情況。
13.4.1.1 Resource Allocation
在 Direct3D 11 中,當使用D3D11_MAP_WRITE_DISCARD標識呼叫ID3D11DeviceContext::Map
時,如果GPU仍然使用的緩衝區,runtime返回一個新記憶體區塊的指標代替舊的緩衝資料。這讓GPU能夠在應用程式往新緩衝填充資料的同時仍然可以使用舊的資料,應用程式不需要額外的記憶體管理,舊的緩衝在GPU使用完後會自動銷燬或重用。
D3D11等傳統API在分配資源時,通常每塊資源對應一個GPU VA(虛擬地址)和物理頁面。
D3D11記憶體分配模型。
在 Direct3D 12 中,所有的動態更新(包括 constant buffer,dynamic vertex buffer,dynamic textures 等等)都由應用程式來控制。這些動態更新包括必要的 GPU fence 或 buffering,由應用程式來保證記憶體的可用性。
現代圖形API需要應用程式控制資源的所有操作。
Vulkan建立資源步驟:先建立CPU可見的暫存緩衝區(staging buffer),再將資料從暫存緩衝區拷貝到視訊記憶體中。
在D3D12等現代圖形API中,資源的GPU VA和物理頁面被分離開來,應用程式可以更好地分攤物理頁面分配的開銷,可以重用臨時空置的記憶體,也可以調整場景不再使用的記憶體的用途。
D3D12記憶體分配模型。
不同的堆型別和分配的位置如下:
Heap Type | Memory Location |
---|---|
Default | Video Memory |
Upload | System Memory |
Readback | System Memory |
下表是可能的拷貝操作的組合:
Source | Destination |
---|---|
Upload | Default |
Default | Default |
Default | Readback |
Upload | Readback |
不同的組合在不同型別的Queue的拷貝速度存在很大的差異:
在RTX 2080上在堆型別之間複製64-256 MB資料時,命令佇列之間的比較。
在RTX 2080上在堆型別之間複製資料時,跨所有命令佇列的平均複製時間和資料大小之間的比較。
堆的型別和標記存在若干種,它們的用途和意義都有所不同:
對於Resource Heap,相關屬性的描述如下:
資源建立則有3種方式:
-
提交(Committed)。單塊的資源,D3D11風格。
-
放置(Placed)。在已有堆中偏移。
-
預留(Reserved)。像Tiled資源一樣對映到堆上。
這3種資源的選擇描述如下:
Heap Type | Desc |
---|---|
Committed | 需要逐資源駐留;不需要重疊(Aliasing)。 |
Placed | 更快地建立和銷燬;可以在堆中分組相似的駐留;需要和其它資源重疊;小塊資源。 |
Tiled / Reserved | 需要靈活的記憶體管理;可以容忍ResourceMap在CPU和GPU的開銷。 |
下表是資源型別和VA、物理頁面的支援關係:
Heap Type | Physical Page | Virtual Address |
---|---|---|
Committed | Yes | Yes |
Heap | Yes | No |
Placed | No | Yes |
Tiled / Reserved | No | Yes |
每種不同的GPU VA和物理頁面的組合標記適用於不同的場景。下圖是3種方式的分配機制示意圖:
Committed資源使用建議:
-
用於RTV, DSV, UAV。
-
分配適合資源所需的最小尺寸的堆。
-
應用程式必須對每個資源呼叫MakeResident/Evict。
-
應用程式受作業系統分頁邏輯的支配。
- 在“MakeResident”上,作業系統決定資源的放置位置。
- 同步呼叫,會卡住,直到它返回為止。
資源的整塊分配和子分配(Suballocation)對比圖如下:
面對如此多的型別和屬性,我們可以根據需求來選擇不同的用法和組合:
- 如果是涉及頻繁的GPU讀和寫(如RT、DS、UAV):
- 分配視訊記憶體:D3D12_HEAP_TYPE_DEFAULT / VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT。
- 最先分配。
- 如果是頻繁的GPU讀取,極少或只有一次CPU寫入:
- 分配視訊記憶體:D3D12_HEAP_TYPE_DEFAULT / VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT。
- 在系統記憶體分配staging copy buffer:D3D12_HEAP_TYPE_UPLOAD / VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,將資料從staging copy buffer拷貝到視訊記憶體。
- 放置在系統記憶體中作為備份(fallback)。
- 如果是頻繁的CPU寫入和GPU讀取:
- 如果是Vulkan和AMD GPU,用DEVICE_LOCAL + HOST_VISIBLE記憶體,以便直接在CPU上寫,在GPU上讀。
- 否則,在系統記憶體和視訊記憶體各自保留一份拷貝,然後進行傳輸。
- 如果是頻繁的GPU寫入和CPU讀取:
- 使用快取的系統記憶體:D3D12_HEAP_TYPE_READBACK / HOST_VISIBLE + HOST_CACHED。
更高效的Heap使用建議:
-
首選由upload heap填充的default heap。
-
從一個或多個提交的上傳緩衝區(committed upload buffer)資源中構建一個環形緩衝區(ring buffer),並讓每個緩衝區永久對映以供CPU訪問。
-
在CPU側,順序地寫入資料到每個buffer,按需對齊偏移。
-
指示GPU在每幀結束時發出增加的Fence值的訊號。
-
在GPU沒有達到Fence只之前,不要修改upload heap的資料。
-
-
在整個渲染過程種,重用上傳堆用來存放傳送到GPU的動態資料。
-
建立更大的堆。
- 大約10-100 MB。
- 子分配(Sub-allocate)用以存放placed resource。
-
逐Heap呼叫MakeResident/Evict,而不是逐資源。
-
需要應用程式跟蹤分配。同樣,應用程式需要跟蹤每個堆中空閒/使用的記憶體範圍。
-
謹慎使用MakeResident/Evict來分配或釋放GPU記憶體。
- CPU + GPU的成本是顯著的,所以批處理MakeResident和UpdateTileMappings。
- 如果有必要,將大量的工作負載分攤到多個幀。
- MakeResident是同步的。
- 不會返回,直到所有資源駐留完成。
- 批處理之。小批量是低效的,因為會產生大量的小型分頁操作。
- 作業系統可能會開啟計算來確定資源的位置,這將花費大量時間。呼叫執行緒會被卡住,直到它返回為止。
- 確保在工作執行緒,防止卡主執行緒。
- 需要處理MakeResident失敗的情況。
- 通常意味著工作執行緒上沒有足夠的可用記憶體。
- 但即使有足夠的記憶體(碎片)也會發生。
- Non-resident讀取是個頁面錯誤,很可能引起程式崩潰!!
- Evict的描述和行為如下:
- Evict可能不會立即採取任何行動。會被延遲到下一個MakeResident呼叫。
- 消耗比MakeResident小。
-
如果視訊記憶體溢位,會導致效能急劇波動,需採取一系列措施解決或避免。
- 需關注記憶體密集型的應用程式,如瀏覽器。提供解析度/質量設定讓使用者更改。
- 考慮1GB、2GB等不同硬體效能的配置。
- 如果視訊記憶體已經沒有可用記憶體了,可以在系統記憶體中建立溢位堆,並從視訊記憶體堆中移動一些資源到系統記憶體。
- 應用程式比任何驅動程式/作業系統更有優勢,可以知道什麼資源是最重要的,從而將它們保留在視訊記憶體中,而不重要的資源遷移出去。
-
也可以將非效能關鍵的資源移出視訊記憶體,放到系統記憶體的溢位堆(overflow heap)。遷移最頂級的mip。
-
將資源移出視訊記憶體步驟:
- 釋放本地拷貝。
- 在轉移到系統記憶體之前,瞭解資源的訪問模式。
- 只讀一次。
- 具有高區域性性的可預測訪問模式更佳。
-
遷移最頂級的mip,可以節省約70%的記憶體。
- 如果做得武斷,視覺上有微小的差別。
- 如果做得明智,視覺上沒有差別。
- 當紋理被放置在堆中的資源時,更容易實現。
-
重疊(或稱為別名,Aliasing或Overlap)資源可以顯著節省記憶體佔用。
- 需要使用重疊屏障(Aliasing Barrier)。
- Committed RTV/DSV資源由驅動程式優先考慮。
- NV:當讀取一致時,使用常量緩衝區而不是結構化緩衝區。例如,tiled lighting。
重疊資源示意圖。其中GBuffer和Helper RT在時間上不重疊,可以分配在同一塊記憶體上。
-
優化從哪種堆分配哪些資源可以提升2%以上的效能。包括調整分配資源的規則。
-
配合LRU資源管理策略大有裨益。
- 在資源最後一次使用後,將其保留在記憶體中一段時間。
- 只有資源使用駐留時才引進。
-
對於統一記憶體架構的裝置,移除Staging Buffer。
-
對部分資源(如頂點快取、索引緩衝)執行非同步建立。
UE的Vulkan RHI允許非同步建立頂點和索引緩衝,減少渲染執行緒的卡頓。
對於實體記憶體的重用,無論是reserved還是placed資源,必須遵循以下和D3D11的分塊資源(Tiled Resource)相同的規則:
- 當實體記憶體被一個新的資源重用時,必須入隊一個重疊屏障(aliasing barrier)。
- 首次使用或重新使用用作渲染資源或深度模板資源的實體記憶體時,應用程式必須使用清除或複製操作初始化資源記憶體。
D3D12在記憶體對映方面提供了顯式的控制,可以每幀建立一個大buffer,暫存所有資料,對Const buffer沒有專用的需求,轉由應用程式按需構建。
對於高吞吐量的渲染,建議如下:
- 為了得到Draw Call的收益,必須安插相關處理到遊戲邏輯種。
- 對於每個單位(如炮塔、導彈軌跡),CPU計算位置或顏色等資料必須儘快地上傳到GPU。
以下是Ashes的CPU作業和GPU記憶體互動示意圖:
13.4.1.2 Resource Update
對於現代圖形API而言,資源更新的特點通常具有以下幾點:
-
CPU和GPU共享相同的儲存,沒有隱式的拷貝。(只適用於耦合式的CPU-GPU架構,如Apple A7及之後的SoC)
-
自動的CPU和GPU緩衝一致性模型。
- CPU和GPU在命令緩衝區執行邊界觀察寫操作。
- 不需要顯式的CPU快取管理。
-
可以顯著提高效能,但應用程式開發者需要承擔更多的同步責任。
-
資源結構(尺寸、層級、格式)由於會引發執行時編譯和資源驗證,產生很大的開銷,因此不能被更改,但資源的內容可以被更改。(下圖)
Metal中可以被更改和不能被更改的資源示意圖。
-
更新資料緩衝時,CPU直接訪問儲存區,而不需要呼叫如同傳統API的LockXXX介面。
-
更新紋理資料時,實現了私有儲存區,可以快速有效地執行上傳路徑。
-
可以利用GPU的Copy Engine實現硬體加速的管線更新。
-
可以與其他紋理共享儲存,為相同畫素大小的紋理解釋為不同的畫素格式。
- 例如sRGB vs RGB,R32 vs RGBA8888。
-
可以與其他緩衝區共享紋理儲存。
- 假設是行線性(row-linear)的畫素資料。
-
將多個分散的紋理資料上傳打包到同一個Command Buffer。
13.4.2 Pipeline State Object
在D3D11,擁有很多小的狀態物件,導致GPU硬體不匹配開銷:
)
到了D3D12,將管線的狀態分組到單個物件,直接拷貝PSO到硬體狀態:
g)
下面是D3D11和D3D12的渲染上下文的對比圖:
上:D3D11裝置上下文;下:D3D12裝置上下文。
Pipeline State(管線狀態)通常擁有以下物件:
Pipeline State | Description |
---|---|
DepthStencil | DepthStencil comparison functions and write masks |
Sampler | Filter states, addressing modes, LOD state |
Render Pipeline | Vertex and pixel shader functions, Vertex data layout, Multisample state, Blend state, Color write masks... |
Compute Shader涉及了以下Pipeline State:
Pipeline State | Description |
---|---|
Compute State | Compute functions, workgroup configuration |
Sampler | Filter states, addressing modes, LOD state |
更具體地,PSO涉及以下的狀態(黑色和白色方塊):
ng)
會影響編譯的狀態在物件建立後不能更改(如VS、PS、RT、畫素格式、顏色寫掩碼、MSAA、混合狀態、深度緩衝狀態):
)
PSO的設計宗旨在於不在渲染過程中存在隱性的Shader編譯和連結,在建立PSO之時就已經生成大部分硬體指令(編譯進硬體暫存器)。由於PSO的shader輸入是二進位制的,對Shader Cache非常友好。下圖是PSO在渲染管線的互動圖:
PSO配合根簽名、描述符表之後的執行機制圖例如下:
開發者仍然可以動態切換正在使用的PSO,硬體只需要直接拷貝最少的預計算狀態到硬體暫存器,而不是實時計算硬體狀態。通過使用PSO,Draw Call的開銷顯著減少,每幀可以有更多的Draw Call。但開發者需要注意:
-
需要在單獨的執行緒中建立PSO。編譯可能需要幾百毫秒。
- Streaming執行緒也可以處理PSO。
- 收集狀態和建立。
- 防止阻塞。
- 還可處理特化(specialization)。
- Streaming執行緒也可以處理PSO。
-
在同一個執行緒上編譯類似的PSO。
- 例如,不同的混合狀態但VS、PS相同的PSO。
- 如果狀態不影響著色器,會重用著色器編譯。
- 同時編譯相同著色器的工作執行緒將等待第一次編譯的結果,從而減少其它同時編譯相同著色器的工作執行緒的等待時間。
-
對於無關緊要的變數,儘量使用相同的預設值。例如,如果深度測試被關閉,則以下資料無關緊要,儘量保持一樣的預設值:
int DepthBias; float DepthBiasClamp; float SlopeScaledDepthBias; bool DepthClipEnable;
-
在連續的Draw Call中,儘量保證PSO狀態相似。(例如UE按照PS、VS等鍵值對繪製指令進行排序)
-
所有設定到Command Buffer的渲染狀態組合到一個呼叫。
-
儘量減少組合爆炸。
- 儘早剔除未使用的排列。
- 在適當的地方考慮Uber Shader。
- 在D3D12中,將常量放到Root上。
- 在Vulkan中,特殊化(Specialization)常量。
-
如果在執行中構建PSO,請提前完成。
-
延遲的PSO更新。
-
編譯越快越早,結果越好。
- 簡單、通用、無消耗地初始著色器。
- 開始編譯,得到更好的結果。
- 當編譯結果準備好時,替換掉PSO。
-
通用、特化特別有用。
- 預編譯通用的案例。
- 特殊情況下更優的路徑是在低優先順序執行緒上編譯。
-
-
使用著色器和管線快取。
-
應用程式可以分配和管理管道快取物件。
-
與管道建立一起使用的管道快取物件。如果管道狀態已經存在於快取中,則重用它。
-
應用程式可以將快取儲存到磁碟,以便下次執行時重用
-
使用Vulkan的裝置UUID,甚至可以儲存在雲端。
-
快取的Hash值不要用指標,應當用著色器程式碼(Shader Code)。
-
-
對Draw Call按照PSO的相似性排序。
- 比如,可以按Tessellation/GS是否開啟排序。
-
保持根簽名儘可能地小。
- 按更新模式分組描述集。
-
按更新頻率排序根條目。
- 變數頻率最快的放最前面。
-
儲存PSO和其他狀態。
- 絕大多數畫素著色器只有幾個排列,可通過雜湊訪問排列。
- 為每個狀態建立唯一的狀態雜湊。
- 將所有狀態塊放入具有惟一ID的池中。
- 使用塊ID作為位來構造一個狀態雜湊。
- 從狀態管理中刪除取樣器狀態物件。
- UE採用16個固定取樣器狀態。
13.4.3 Synchronization
13.4.3.1 Barrier
現代圖形API提供了種類較多的同步方式,諸如Fence、Barrier、Semaphore、Event、Atomic等。
CPU Barrier使用案例。上:沒有Barrier,CPU多核之間的依賴會因為Overlap而無法達成;下:通過Barrier解決Overlap,從而實現同步。
GPU擁有數量眾多的處理執行緒,在沒有Barrier的情況下,驅動程式和硬體會盡量讓這些執行緒處理Overlap,以提升效能。但是,如果GPU執行緒之間存在依賴,就需要各類同步物件進行同步,確保依賴關係正常。這些同步物件的作用如下:
-
同步(Synchronisation)
確保嚴格和正確的工作順序。常因GPU流水線的深度引發,比如UAV RAW/WAW屏障,避免著色器波(wave)重疊執行。
假設有以下3個Draw Call(DC),不同顏色屬於不同的DC,每個DC會產生多個Wave:
假設DC 3依賴DC1,如果在DC1完成之後增加一個Barrier,則DC2其實是多餘的等待:
如果在DC2完成之後增加一個Barrier,則DC2依然存在冗餘的等待:
假設DC3和DC2依賴於DC1需要寫入的不同資源,如果在DC1-2之間和DC1-3之間加入Barrier,則會引入更多的冗餘等待:
可以將原本的兩個Barrier合併成一個,此時只有一個同步點,但依然會引起少量的冗餘等待:
此時,可以拆分DC1和DC3之間的Barrier,DC1之後設為”Done“,在DC3之前設為”Make Ready“,此時DC2不受DC1影響,只有DC3需要等待DC1,這樣的冗餘等待將大大降低:
因此,拆分屏障(Split Barrier)可以減少同步等待的時間(前提是在上次使用結束和新使用開始之間有其它工作,如上例的DC2)。多個併發的Barrier也可以減少同步,並且儘量做到一次性清除多個Barrier。
如果Barrier丟失,將引發資料時序問題(timing issue)。
-
可見性(Visibility)
確保先前寫入的資料對目標單元可見。
可見性涉及到GPU內部的多個元器件,如多個小的L1 Cache、大的L2 Cache(主要連線到著色器核心)。(下圖)
舉具體的例子加以說明。若要將緩衝區UAV轉換成
SHADER_RESOURCE | CONSTANT_BUFFER
標記,則會重新整理紋理L1到L2,重新整理Shader L1:若要將
RENDER_TARGET
變成COMMON
標記,則涉及很多操作:- 重新整理Color L1。
- 重新整理可能所有的L1。
- 重新整理L2。
這種操作非常昂貴,佔用更多時間和記憶體頻寬,儘量避免此操作。此外,以下建議可以減少消耗:
- 合併多個Barrier成單個呼叫。聯合多個Cache的重新整理,減少冗餘的重新整理。
- 考量之前的資源狀態,例如增加額外的RT->SRV覆蓋RT->COMMON,反而沒有任何開銷!
- Split Barrier同樣適應於可見性。注意,這也意味著要花額外的精力觀察和消除Barrier。
-
格式轉換(Format conversion)
確保資料的格式與目標單元相容,最常見於解壓(Decompression)。
很多GPU硬體支援無失真壓縮,例如DCC(Delta Color Compression)、UBWC、AFBC等,以節省頻寬。但是在讀取這些壓縮資料時可能會解壓,UAV寫入也會引起解壓。
NV Pascal記憶體壓縮圖例。
NV 的多級級聯資料壓縮技術。聯合了RLE、Delta、Bit-packing等技術。
RT和DS表面在壓縮時表現得更好,可以獲得2倍速或更多的效能。
在最新的硬體上有兩種不同的壓縮方法:Full(全部)和Part(部分)。Full必須解壓後才能讀取RT或DS內容,Part也可以用於SRV。
如果需要解壓,必須在某個地方承受效能卡頓。儘量避免需要解壓的情況。
如果Barrier丟失,將引發資料意外損壞。
Barrier的GPU消耗常以時間戳(timestamp)來衡量,對於不需要解壓的Barrier通常只需要微米(μs)級別的時間,需要耗費百分比級別的情況比較罕見,除非需要解壓包含MSAA資料的表面。每個可寫入的表面不應該超過2個Barrier。
每幀的表面(Surface)寫入是個大問題,寫入表面可能會因為Barrier丟失而損壞資料,每幀每個表面不要超過兩個Barrier。
下面是一些負面的同步使用案例:
-
RT- > SRV -> Copy_source- > SRV -> RT。
- 不要忘記,可以通過將OR操作組合多個標記。
- 永遠不要有read到read(SRV -> Copy_source,Copy_source -> SRV)的Barrier。
- 資源的起始狀態應放到正確的狀態。
-
偶爾拷貝某個資源,但總是執行RT-> SRV|Copy。
- RT -> SR可能很低開銷,但RT -> SRV|Copy可能很高開銷。
- 資源的起始狀態應放到正確的狀態。
-
由於不知道資源的下一個狀態是什麼,所以總是在Command List後期轉換所有資源到COMMON。
- 這樣做的代價是巨大的!會導致所有表面強制解壓!大多數Command List在啟動前需要等待空閒。
-
只考慮正在使用的和/或在內部迴圈中的Barrier。
- 阻礙了Barrier合併。
-
負面的Barrier使用案例1:
void UploadTextures() { for(auto resource : resources) { pD3D12CmdList->Barrier(resource, Copy); pD3D12CmdList->CopyTexture(src, dest); pD3D12CmdList->Barrier(resource, SR); } }
應改成:
void UploadTextures() { BarrierList list; // 所有紋理放在單個Barrier呼叫。 for(auto resource : resources) AddBarrier(list, resource, Copy) pD3D12CmdList->Barrier(list); list->clear(); // 拷貝紋理。 for(auto resource : resources) pD3D12CmdList-> CopyTexture(src, dest); // 另外一個合併的Barrier處理資源轉換。 for(auto resource : resources) AddBarrier(list, resource, SR) pD3D12CmdList->Barrier(list); }
-
負面的Barrier使用案例2:
for (auto& stage : stages) { for (auto& resource : resources) { if (resource.state & STATE_READ == 0) { ResourceBarrier (1, &resource.Barrier (STATE_READ)); } } }
理想的繪製順序如下:
但上述程式碼是逐材質逐Stage加入Barrier,會打亂理想的執行順序,產生大量連續的空閒等待:
部分工具(RGP、PIX)會對Barrier展示詳細資訊或發出警告:
需要注意的是,圖形API的Flush命令可以實現同步,但會強制GPU的Queue執行完,以使Shader Core不重疊,從而引發空閒,降低利用率:
DirectX 12和Vulkan的Barrier相當於圖形API的Flush,等同於D3D12_RESOURCE_UAV_BARRIER,在draws/dispatche之間為transition/pipeline barrier新增一個執行緒flush,試著將非依賴的繪製/分派在Barrier之間分組。(這部分結論在未來的GPU可能不成立)
執行緒在記憶體訪問時會引發卡頓,Cache重新整理會引發空閒,有限著色器使用的任務包含:僅深度光柵化、On-Chip曲面細分和GS、DMA(直接記憶體訪問)。為了減少卡頓和空閒,CPU端需要多個前端(front-end),併發的多執行緒(超執行緒),交錯兩個共享執行資源的指令流。
總之,GPU的Barrier涉及GPU執行緒同步、快取重新整理、資料轉換(解壓),描述了可見性和依賴。
為了不讓Barrier成為破壞效能的罪魁禍首,需要遵循以下的Barrier使用規則和建議:
-
儘可能地合批Barrier。
- 使用最小的使用標誌集。避免多餘的Flush。
- 避免read-to-read的Barrier。為所有後續讀取獲得處於正確狀態的資源。
- 儘可能地使用split-barrier。
Barrier合批案例1。上:未合批的Barrier導致了更多的GPU空閒;下:合批之後的Barrier讓GPU工作更緊湊,減少空閒。
Barrier合批案例2。上:未合批的Barrier導致了更大的GPU空閒;下:合批之後的Barrier讓GPU工作更緊湊,減少空閒。
Barrier合批案例3。上:對不同時間點的Barrier向前搜尋前面資源的Barrier;中:找到這些Barrier的共同時間點;下:遷移後面Barrier到同一時間點,執行合批。
-
COPY_SOURCE可能比SHADER_RESOURCE的開銷要大得多。
-
Barrier數量應該大約是所寫表面數量的兩倍。
-
Barrier會降低GPU利用率,更大的dispatch可以獲得更好的利用率,更長時間的執行執行緒會導致更高的Flush消耗。
-
如果要寫入資源,最好將Barrier插入到最後的那個Queue。
-
將transition放置在semaphore(訊號量)附近。
-
需要明確指定源/目標佇列。
-
如果還不能使用渲染通道,在任務邊界上批處理Barrier,渲染通道是大多數障礙問題的最佳解決方案。
-
移動Barrier,讓不依賴的工作可以重疊。
上:Barrier安插在兩個不依賴的工作之間,導致中間產生大量的空閒;下:將Barrier移至兩個任務末尾,讓它們可以良好地重疊,減少空閒,降低整體執行時間。
-
避免跟蹤每個資源的狀態。
- 沒有那麼多資源來轉換!
- 狀態跟蹤使得批處理變得困難。
- 不牢固。
-
避免轉換所有的東西,因為Barrier是有消耗的!
- 成本通常隨解析度的變化而變化。
- 不同GPU代之間的消耗成本有所不同。
-
儘可能少的障礙——不要跟蹤每個資源狀態。
-
儘可能優先使用渲染通道。
-
明確所需的狀態。
-
使用聯合位來合併Barrier。
-
預留時間給驅動程式處理資源轉換,使用Split Barrier等。
3.png)
Split Barrier自動生成案例。上:生產者邊界的Barrier;下:由於Depth在後面會被讀取,結束寫入,轉成讀取狀態。
Barrier的實現方案有以下幾種:
-
手工放置。
- 在簡單引擎中非常友好。
- 但很快就變得複雜。
-
幕後自動生成。
- 逐資源追蹤。
- 難以準確。
- 隨需應變的過渡可能會導致批處理的缺乏,並經常在不理想的地方出現Barrier。
-
在D3D12上用渲染通道模擬。
- 更好的可移植性。
-
Frame Graph。
- 分析每個Pass,找出依賴關係。
- 然後可以確定每個資源記憶體重疊(aliasing)的範圍。
- 比如,Frostbite的Frame Graph、UE的RDG。
- 所有的資源轉換都由主渲染執行緒提交。主渲染執行緒也可以記錄命令列表,並執行所有多執行緒同步。
育碧的Anvil Next引擎實現了精確的自動化的資源跟蹤和依賴管理,自動跟蹤資源生命時間,以確定記憶體重用的選項(針對placed resource),自動跟蹤資源訪問同步,使用者可以新增手動同步,以更好地匹配工作負載。(下圖)
13.4.3.2 Fence
Fence(柵欄)是GPU的訊號量,使用案例是確保GPU在驅逐(evict)前完成了資源處理。
可以每幀使用一個Fence,來保護逐幀(per-frame)的資源。儘量用單個Fence包含更多的資源。
Fence操作是在Command Queue上,而非Command List或Bundle。
每個Fence的CPU和GPU成本與ExecuteCommandLists差不多。不要期望Fence比逐ExecuteCommandLists呼叫更細的粒度觸發訊號。
Fence包含了隱式的acquire / release Barrier,也是Fence開銷高的其中一個原因。
嘗試使用Fence實現資源的細粒度重用,理想情況是最終使用一個SignalFence來同步所有資源重用。
下面是DX12的Barrier和Fence使用示例程式碼:
// ------ Barrier示例 ------
// 陰影貼圖從一般狀態切換到深度可寫狀態,得以將場景深度渲染至其中
pCommandList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(pShadowTexture,
D3D12_RESOURCE_STATE_COMMON, D3D12_RESOURCE_STATE_DEPTH_WRITE));
// 陰影貼圖將作為畫素著色器的 Shader Resource 使用,場景渲染時,將對陰影貼圖進行取樣
pCommandList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(pShadowTexture,
D3D12_RESOURCE_STATE_DEPTH_WRITE, D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE));
// 陰影貼圖恢復到一般狀態
pCommandList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(pShadowTexture,
D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE, D3D12_RESOURCE_STATE_COMMON));
// ------ Fence示例 ------
// 建立一個Fence,其中fenceValue為初始值
ComPtr<ID3D12Fence> pFence;
pDevice->CreateFence(fenceValue, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&pFence)));
// 傳送Fence訊號。
pCommandQueue->Signal(pFence.Get(), fenceValue);
// Fence案例1:由CPU端查詢Fence上的完成值(進度),如果比fenceValue小,則呼叫DoOtherWork
if (pFence->GetCompletedValue() < fenceValue)
{
DoOtherWork();
}
// Fence案例2:通過指定Fence上的值實現CPU和GPU同步
if (pFence->GetCompletedValue() < fenceValue)
{
pFence->SetEventOnCompletion(fenceValue, hEvent);
WaitForSingleObject(hEvent, INFINITE);
}
Fence和Semaphore會同步所有的GPU執行和記憶體訪問,這就是為什麼有時候什麼都不等待或什麼都不阻塞是可以的。
CPU和GPU同步模型可以考慮以下方式:
-
即發即棄(Fire-and-forget)。
- 工作開始時,通過圍欄進行同步。但是,部分工作負載在幀與幀之間是不同的,會導致非預期的工作配對,從而影響整幀效能。
- 同樣的情況,應用程式在ECL之間引入了CPU延時,CPU延遲傳導到了GPU,導致非預期的工作配對,等等……
-
握手(Handshake)。
- 同步工作配對的開始和結束,確保配對確定性,可能會錯過一些非同步機會(HW可管理) 。
同時也要注意CPU可以通過ExecuteCommandLists(ECL)排程GPU,意味著CPU的空隙會傳導到GPU上。
13.4.3.3 Pipeline Barrier
Pipeline Barrier在Vulkan用於解決命令之間的執行依賴(Execution Dependency)問題,以及記憶體依賴(Memory Dependency)問題。
大多數Vulkan命令以佇列提交順序啟動,但可以以任何順序執行,即使使用了相同管道階段。
當兩個命令相互依賴時,必須告訴Vulkan兩個同步範圍(synchronization scope):
- srcStageMask:Barrier之前會發生什麼。
- dstStageMask:Barrier之後會發生什麼。
當記憶體資料存在依賴時,必須告訴Vulkan兩個訪問範圍(access scope):
- srcAccessMask:在Barrier之前發生的命令記憶體訪問。Barrier執行的任何快取清理(或重新整理) 僅發生在此。
- dstAccessMask:在Barrier之後發生的命令記憶體訪問。Barrier執行的任何快取無效(cache invalidate) 僅發生在此。
下面舉個具體的例子:
vkCmdCopyBuffer(cb, buffer_a, buffer_b, 1, ®ion); // buffer_a是拷貝源
vkCmdCopyBuffer(cb, buffer_c, buffer_a, 1, ®ion); // buffer_a是拷貝目標
上面的程式碼沒有使用Pipline Barrier,會觸發WAR(Write after read)衝突。可以新增Pipeline Barrier防止衝突:
vkCmdCopyBuffer(cb, buffer_a, buffer_b, 1, ®ion);
// 建立VkBufferMemoryBarrier
auto buffer_barrier = lvl_init_struct<VkBufferMemoryBarrier>();
buffer_barrier.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
buffer_barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
buffer_barrier.buffer = buffer_a;
// 新增VkBufferMemoryBarrier
vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 1, &buffer_barrier, 0,nullptr);
// 拷貝資料。
vkCmdCopyBuffer(cb, buffer_c, buffer_a, 1, ®ion);
管線階段位(pipeline stage bit)是有序的:
-
在vulkan規範中定義的邏輯順序。
-
在srcStageMask,每個Stage位需要等待所有更早的Stage。
-
在dstStageMask,每個Stage位需要卡住所有更遲的Stage。
上:沒有很好地設定管線階段依賴位,導致並行率降低;下:良好地設定了管線階段依賴位,提升了並行效率,降低整體執行時間。
上圖的Vertex_Shader階段會等待所有的灰色階段,也會卡住所有的綠色階段。
-
通常只需要設定正在同步的位。
記憶體訪問掩碼位是獨立的:
- 需要設定所有正在同步的位。
- 但是,如果想使用需要的訪問掩碼,則必須顯式地指定每個管道階段。 (這是常見的錯誤來源)
假設有以下命令佇列:
Command A
Barrier1
Command B
Barrier2
Command C
為了讓A, B, C有序地執行,需要確保Barrier1.dstMask等同於或更早於Barrier2.srcMask。下表是不同情況的依賴關係:
Barrier1.dstMask | Barrier2.srcMask | dependency chain? |
---|---|---|
DRAW_INDIRECT | DRAW_INDIRECT | Yes |
DRAW_INDIRECT | COMPUTE_SHADER | No |
COMPUTE_SHADER | DRAW_INDIRECT | Yes |
BOTTOM_OF_PIPE or ALL_COMMANDS | DRAW_INDIRECT | Yes(可能很慢) |
下面是特殊的執行依賴的說明:
- srcStageMask = ALL_COMMANDS:會阻塞並等待所有階段,強制等待直到GPU空閒,通常會損害效能。
- srcStageMask = NONE or TOP_OF_PIPE:不會等待任何東西,只能構建上一個Barrier攜帶了dstStageMask = ALL_COMMANDS標記的執行依賴鏈。
- dstStageMask = NONE or BOTTOM_OF_PIPE:沒有任何東西等待此Barrier,用srcStageMask = ALL_COMMANDS構建一個執行依賴鏈。
下面是特殊的記憶體訪問掩碼的說明:
- NONE:沒有記憶體訪問,用於定義執行barrier。
- MEMORY_READ, MEMORY_WRITE:StageMask允許的任何記憶體訪問。
- SHADER_READ:在sync2中擴開為(SAMPLER_READ | STORAGE_READ | UNIFORM_READ)。
- SHADER_WRITE:在sync2中擴充套件為STORAGE_WRITE(大於2^32) 。
更多Pipeline Barrier相關的說明請閱讀:12.4.13 subpass。
13.4.4 Parallel Command Recording
在現代圖形API出現之前,由於無法在多個執行緒並行地錄製渲染命令,使得渲染執行緒所在的CPU核極度忙碌,而其它核心處於空閒狀態:
現代圖形API(如Vulkan)從一開始就被建立為執行緒友好型,大量規範詳細說明了執行緒安全性和呼叫的後果,並且所有的控制權和責任都落在應用程式上。
隨著現代CPU核心數量愈來愈多,應用程式對多執行緒處理渲染的需求愈來愈強烈,最顯著的就是希望能夠從多個執行緒生成渲染工作,在多個執行緒中分攤驗證和提交成本。具體的用例如下:
- 執行緒化的資源更新。
- CPU頂點資料或例項化資料動畫(如形變動畫)。
- CPU統一緩衝區資料更新。(如變化矩陣更新)。
- 並行的渲染狀態建立。
- 著色器編譯和狀態驗證。
- 執行緒化的渲染和繪製呼叫。
- 在多個執行緒中生成命令緩衝區。
Vulkan支援獨立的工作描述和提交:
Vulkan資源、命令、繪製、提交等關係示意圖。其中Work specification包含了繫結管線狀態、頂點和索引緩衝、描述符集及繪製指令,涉及的資源有Command Buffer、繪製狀態、資源引用,而資源引用又由描述符指定了資源實際的位置。Work specification通過vkQueueSubmit進行提交,提交時可以指定精確的同步操作。Queue最後在GPU內部被執行。
對於現代圖形API的Command Buffer,所有的渲染都通過Command Buffer執行,可以單次使用多次提交,驅動程式可以相應地優化緩衝區,存在主要和次級Command Buffer,允許靜態工作被重用。更重要的是,沒有狀態是跨命令緩衝區繼承的!
Vulkan多核並行地生成Command Buffer示意圖。
Vulkan並行Pass呼叫和圖例。
如果想要重用Vulkan的Command Buffer,應用程式可以利用Fence等確保被重用的Command Buffer不在使用狀態,確保執行緒安全:
Metal也允許應用程式顯式地構造和提交很多輕量級的Command Buffer。這些緩衝區可以並行地在多個執行緒中錄製(下圖),並且執行順序可以由應用程式指定。這種方式非常高效,且確保執行效能可伸縮。
Metal並行錄製命令緩衝區示意圖。
Metal並行Pass呼叫和圖例。
和Vulkan、Metal類似,DirectX 12也擁有多執行緒錄製渲染命令機制:
DX12多執行緒錄製模型。注意圖中的Bundle A被執行了兩次。
除了Command Buffer可以被並行建立和重用,Command Allocator(Pool)也可以被多執行緒並行地建立,並且不同執行緒的Command Buffer必須被不同的Command Allocator(Pool)例項建立(否則需要額外的同步操作):
因此,良好的設計方案下,每個執行緒需要有多個命令緩衝區,並且執行緒每幀可能有多個獨立的緩衝區,以便快速重置和重用不再使用的Command Allocator(Pool):
使用多個Command Queue提交繪製指令可能在GPU並行地執行,但依賴於OS排程、驅動層、GPU架構和狀態、Queue和Command List的型別,和CPU執行緒相似。
)
多個Command佇列提升GPU核心利用率示意圖。
另外,需要指出的是,D3D12的Command Queue不等於硬體的Queue,硬體的Queue可能有很多,也可能只有1個,作業系統/排程器會扁平化並行提交,利用Fence讓依賴對排程器可見。通過GPUView/PIX/RGP/Nsight等工具可以檢視具體詳情!
Vulkan的Queue又有著很大不同,顯式繫結到公開的佇列,但仍然不能保證是一個硬體佇列。Vulkan的Queue Family類似於D3D12 Engine。
多核CPU面臨並行操作和快取一致性問題。對GPU而言也類似,Command Processor等同於Task Scheduler,Shader Core等同於Worker Core。
當其它命令佇列被提交時,新的命令佇列可以並行地構建,在提交和呈現期間不要有空閒。可以重用命令列表,但應用程式需要負責停止併發使用。
不要拆分工作到太多的命令佇列。每幀可以擬定合理的任務數量,比如15-30個命令佇列,5-10個ExecuteCommandLists
個呼叫。
每個ExecuteCommandLists
都有固定的CPU開銷,所以在這個呼叫後面觸發一個重新整理,並且合批命令佇列,減少呼叫次數。儘量讓每個ExecuteCommandLists
可以讓GPU執行200μs,最好達到500μs。提交足夠的工作可以隱藏OS排程器(scheduler)的延時,因為小量工作的ExecuteCommandLists
執行時間會快於OS排程器提交新的工作。
小量的命令佇列提交導致了大量空閒的案例。
Bundle是個在幀間更早提交工作的好方法。但在GPU上中,Bundle並沒有本質上更快,所以要謹慎地對待。充分利用從呼叫命令列表繼承狀態(但協調繼承狀態可能需要CPU或GPU成本),可以帶來不錯的CPU效率提升。對NV來言,每個Dispatch擁有5個以上相同的繪製,則使用Bundle;AMD則建議只有CPU側是瓶頸時才使用Bundle。
13.4.5 Multi Queue
現代圖形API都支援3種佇列:Copy Queue、Compute Queue、Graphics Queue。Graphics Queue可以驅動Compute Queue,Compute Queue可以驅動Copy Queue。(下圖)
Copy Queue通常用來拷貝資料,非常適合PCIe的資料傳輸(有硬體支援的優化),不會佔用著色器資源。常用於紋理、資料在CPU和GPU之間傳輸,加速Mimap生成,填充常量緩衝區等等。開啟非同步資料拷貝和傳輸,和Graphic、Compute Engine並行地執行。
Compute Queue通常用來local到local(即GPU視訊記憶體內部)的資源,也可以用於和Graphics Queue非同步執行的計算任務。可以驅動Copy Engine。Compute Shader涉及了以下Pipeline State:
Pipeline State | Description |
---|---|
Compute State | Compute functions, workgroup configuration |
Sampler | Filter states, addressing modes, LOD state |
Graphics Queue可以執行任何任務,繪製通常是最大的工作負載。可以驅動Compute Engine和Copy Engine。
在硬體層面,GPU有3種引擎:複製引擎(Copy Engine)、計算引擎(Compute Engine)和3D引擎(3D Engine),它們也可以並行地執行,並且通過柵欄(Fence)、訊號(Signal)或屏障(Barrier)來等待和同步。
DirectX12中的CPU執行緒、命令列表、命令佇列、GPU引擎之間的執行機制示意圖。
在錄製階段,就需要指明Queue的型別,相同的型別支援多個Queue,在同一個Queue內,任務是有序地執行,但不同的Queue之間,在硬體Engine內可能是打亂的:
利用Async Quque的並行特性,可以提升額外的渲染效率。並行思路是將具有不同瓶頸的工作負載安排在一起,例如陰影圖渲染通常受限於幾何吞吐量,而Compute Shader通常受限於資料獲取(可以使用LDS優化記憶體獲取效率),極少受限於ALU。
但是,如果使用不當,Async Compute可能影響Graphics Queue的效能。例如,將Lighting和CS安排在一起就會引起同時競爭ALU的情況。需要時刻利用Profiler工具監控管線並行狀態,揪出並行瓶頸並想方設法優化之。
對於渲染引擎,實現時最好構建基於作業的渲染器(如UE的TaskGraph和RDG),可有效處理屏障,也應該允許使用者手動指定哪些任務可以並行。作業不應該太小,需要保持每幀的Fence數量在個位數範圍內,因為每個訊號都會使前端(frontend)陷入停頓,並沖刷管道。
下圖是渲染幀中各個階段花費的時間的一個案例:
g)
其中Lighting、Post Process和大多數陰影相關的工作都可以放到Compute Shader中。此外,為了防止幀的後處理等待同一幀的前面部分(裁剪、陰影、光照等),可以放到Compute Queue,和下一幀的前面階段並行:
利用現代圖形API,渲染引擎可以方便地實現幀和幀之間的重疊(Overlap)。基本思路是:
- 設定可排隊幀的數量為3來代替2。
- 從圖形佇列建立一個單獨的呈現佇列。
- 在渲染結束的時候,不是立即呈現,而是釋出一個計算任務,並向渲染器傳送幀的post任務。
- 當幀的post任務完成後,傳送一個訊號給特殊的圖形佇列做實際的呈現。(下圖)
但這種方式存在一些缺點:
- 實現複雜,會引入各種同步和等待。
- 幀會被拆分成多次進行提交。(儘量將命令緩衝區保持在1-2ms範圍內)
- 最終會有1/ 2到1/3的額外延遲。
引入Async Compute之後,普遍可以提升15%左右的效能:
對於Workgroup的優化,從PS遷移到CS的傳統建議如下:
-
遷移PS到Workgroup尺寸為(8, 8, 1)的CS。
- 1 wave/V$以獲得空間區域性性(但可能比PS更糟糕)。
- AMD的GCN在移動到下一個CU之前以逐CU(1 V$ / CU)執行一個Workgroup。
-
執行緒(lane,threa)到8x8的對映是線性塊(linear block)。
- 實際可能是(4x1)模式的紋理獲取方塊(quad)。
- 實際可能引發V$儲存體衝突(bank conflict)。
- GCN以4個執行緒為一組進行取樣。
以上是不好的配置,良好的Workgroup配置案例如下:
-
(512, 1, 1)的Workgroup被配置成(32, 16, 1)。
- 8 wave / V$獲得區域性性。
- 每個wave是8x8的Tile。(每個GPU廠商和GPU系列存在差異,這裡指AMD的GCN架構)
- 8個wave被組織成4x2個8x8Tile的集合。(下圖)
-
執行緒到8x8的tile對映是重組的塊線性(swizzled block linear)。
- 良好的2x2模式的紋理獲取方塊。(上圖)
-
專用的著色器優化。
- 高度依賴2D空間的區域性性來獲得快取命中。
- 在wave執行時更少的依賴。
-
使用本地記憶體的一種常見技術是將輸入分割成塊,然後,當工作組對每個塊進行處理時,可以將其移動到本地記憶體中。
下面是NV和AMD對PS和CS的效能描述和建議:
NV使用PS的建議:不需要共享記憶體、執行緒在相同時間完成、高頻率的CB訪問、2D緩衝儲存;NV使用CS的建議:需要執行緒組共享記憶體、期望執行緒無序完成、高頻率使用暫存器、1D或3D緩衝儲存。
AMD使用PS的建議:從DS剔除中獲益、需要圖形渲染、需要利用顏色壓縮;AMD使用CS的建議:PS建議之外的所有情況。
利用Async Compute和多型別Queue,可以將傳統遊戲引擎的順序執行流程改造成並行的流程。
上:傳統遊戲引擎的線性渲染流程;下:利用GPU的多引擎並行地執行。
這樣的並行方式,可以減少單幀的渲染時間,降低延時,從而提升Draw Call和渲染效果。
不過,在並行實現時,需要格外注意各個工作的瓶頸,常見的瓶頸有:資料傳輸、著色器吞吐量、幾何資料處理,它們涉及的任務具體如下:
為了更好地並行效率,每個Engine的重疊部分儘量不要安排相同瓶頸的工作任務。
上:線性執行示意圖;中:Shadow Map和Stream Texture、Deferred Lighting和Animate Particle瓶頸衝突,只能獲得少量並行效率;下:避開瓶頸相同的任務,贏得較多的並行效率。
下圖左邊是良好的並行配對,右邊則是不良的並行配對:
不受限制的排程為糟糕的技術配對創造了機會,好處在於實現簡單,但壞處在於幀與幀具有不確定性和缺少配對控制:
.png)
更佳的做法是,通過巧妙地使用Fence來顯式地排程非同步計算任務。好處是幀和幀之間的確定性,應用程式可以完全控制技術配對!壞處是實現稍微複雜一些:
Copy Queue的特性、描述和使用建議如下:
-
專門設計用於通過PCIE進行復制的專用硬體。
-
獨立於其他佇列進行操作,讓圖形和計算佇列可以自由地進行圖形處理。
-
如果從系統記憶體複製到local(視訊記憶體),使用複製佇列。例如,Texture Streaming。
-
使用複製佇列在PCIE上傳輸資源。使用多GPU進行非同步傳輸是必不可少的。
-
避免在複製佇列完成時自旋(spinning)。需提前做好傳輸計劃。
-
注意複製深度+模板資源,複製僅深度可能觸發慢路徑(slow path)。(僅NV適應)
-
多GPU下,支援p2p傳輸。
-
確保GPU上有足夠的工作來確保不會在複製佇列上等待。
- 儘可能早地開始複製,理想情況下在本地記憶體中需要複製之前,先複製幾幀。
-
視訊記憶體內部的local到local的拷貝,分兩種情況:
- 情況1:如果立即需要傳輸結果,使用Graphic Queue或Compute Queue。
- 情況2:如果不立即需要傳輸結果,使用Copy Queue。比如上傳Buffer(constant、vertex、index buffer等),以及視訊記憶體碎片整理(defragging)。
- 使用複製佇列移動來執行視訊記憶體碎片整理,比如佔用每幀1%的頻寬。讓圖形佇列繼續呈現,在Copy Queue不忙於Streaming的幀上執行。
)
Async Compute建議如下:
- 儘量少同步,理想情況下每幀只同步1-2次。每個同步點都有很大的開銷。
- 將大型連續工作負載移到非同步佇列中。更多的機會重疊管道的drains / fills階段。
- 更激進的做法:與下一幀重疊。
- 通常情況下,幀以光柵繁重的工作開始,以計算繁重的後處理結束。
- 可能增加延時!
13.4.6 其它管線技術
利用現代圖形API支援光線追蹤的特性,可以實現混合光線追蹤陰影(Hybrid Raytraced Shadows):
從而實現高質量的陰影效果:
上:傳統陰影圖效果;下:混合光線追蹤陰影效果。
值得一提的是,GPU管線的剔除會導致利用率降低,引起很多小的空閒區域:
GPU利用率不足是導致延時的常見原因。
現代GPU為了降低頻寬,在內部各部件之間廣泛地使用了壓縮格式,在取樣時,會從視訊記憶體中讀取壓縮的資料,然後在Shader Core中解壓。(下圖)
當需要匯出(寫入)資料時,會先壓縮成顏色塊,再寫入壓縮後的資料到視訊記憶體。(下圖)
GPU廠商工具通常可以觀察紋理的格式和是否開啟壓縮:
)
對於GPU內部的這種資料壓縮,需要注意以下幾點:
- 使用獨佔佇列所有權。在共享所有權的情況下,驅動程式必須假定它使用在不能讀寫壓縮的硬體塊上。
- 顯式地指明影像格式。UNKNOWN / MUTABLE會阻礙壓縮,可以工作在VK_KHR_image_format_list。
- 只使用所需的影像用法。否則,資源最終可能會低於最佳壓縮級別。
- 清理渲染或深度目標。會重置後設資料,防止額外的頻寬傳輸。
13.4.6.1 Wave
Wave在DirectX 12和Vulkan涉及的概念如下:
DirectX 12 | Vulkan | Desc |
---|---|---|
Lane | Invocation | 在wave內執行的一個著色器呼叫(執行緒)。 |
Wave | Subgroup | shader呼叫的集合,每個廠商呼叫的數量不同。 |
Lane和Wave結構示意圖。
Wave[DX]執行模式:所有Lane同時執行,並且鎖步(lock-step);Subgroup[VK]執行模型:Subgroup操作包含隱式屏障。
Wave機制的優勢在於:
- 減少了barrier或interlock指令的使用。
- 更簡單的著色器程式碼。
- 更易維護,容易編碼。
- 對DFC一致性的更多控制。
- 有助於提高控制流(flow)一致性。
- 有助於提高記憶體訪問一致性。
著色器標量化可以提高執行緒並行工作的速度,可用於照明,基於GPU的遮擋剔除,SSR等。
Wave指令集通過移除不必要的同步來提高標量運算的效率,支援DirectX 11和DirectX 12。它和Threadgroup、Dispatch處理不同的層級,所用的記憶體也不同(下圖),因此需要使用正確層級的原子進行同步。
當使用Wave操作對紋理進行訪問時,如果執行緒索引在一個計算著色器被組織在一個ROW_MAJOR模式,將匹配一個線性紋理,這種模式不能很好地保持鄰域性,無法很多地命中快取:
ng)
可以用標準重組(standard swizzle)來優化紋理訪問,這種紋理佈局的模式使得相鄰畫素被緊密地儲存在記憶體中,提升快取命中率:
下面是效能分析工具RGP抓取的以Wave為單位執行的VS、PS、CS圖例:
支援Wave的GPU而言,資料是波形化的uniform(wave-uniform),但著色器編譯器並不知道。一個典型的應用是,遍歷光源,告訴編譯器光源索引是wave-uniform,將資料從VGPR放入SGPR。
Capcom的RE引擎利用Wave操作,提升了約4.3%的效能:
)
關於Wave的更多技術細節請參閱:Wave Programming in D3D12 and Vulkan。
13.4.6.2 ExecuteIndirect
ExecuteIndirect機制允許組合若干個Draw、DrawIndexed、Dispatch到同一個呼叫裡,更像是MultiExecuteIndirect()。在Draws/Dispatches之間,可以改變以下資料:
- 頂點緩衝、索引緩衝、圖元數量等。
- 根簽名、根常量。
- 根SRV和UAV。
下面是DX 12的ExecuteIndirect介面:
利用此介面,可以實現:
- 在一個ExecuteIndirect中繪製數千個不同的物件。為數百個物件節省了大量的CPU時間。
- 間接計算工作。為了獲得理想的效能,可以使用NULL計數器緩衝引數。
- 圖形繪製呼叫。為了獲得理想的效能,保持計數器緩衝計數和ArgMaxCount呼叫差不多。
以下是DX11和DX12繪製樹的對比:
)
此外,可以實現基於GPU的遮擋剔除。
13.4.6.3 Predication
Predication是DX12的特性,它完全與查詢解耦,對緩衝區中某個位置的值的預測,GPU在執行SetPredication時讀取buffer值。
支援Predication的API有:
- DrawInstanced
- DrawIndexedInstanced
- Dispatch
- CopyTextureRegion
- CopyBufferRegion
- CopyResource
- CopyTiles
- ResolveSubresource
- ClearDepthStencilView
- ClearRenderTargetView
- ClearUnorderedAccessViewUint
- ClearUnorderedAccessViewFloat
- ExecuteIndirect
使用案例就是基於非同步CPU的遮擋剔除:一個CPU執行緒錄製Command List,另外一個CPU執行緒執行軟體(非硬體)遮擋查詢並填充到Predication緩衝區。(下圖)
13.4.6.4 UAV Overlap
首先要理解現代圖形API如果沒有依賴,可以並行地執行。
而UAV Barrier具體不明確的依賴,不清楚是讀還是寫,如果每個批處理寫到一個單獨的位置,它可以並行執行,前提是可以避免WAW(write-after-write)錯誤。
可以為每個compute shader的排程控制UAV同步,禁用UAV的同步使並行執行成為可能,在DirectX 11中,可以使用AGS和NVAPI引入等效函式。
啟用UAV Overlap機制,Capcom的RE引擎總體效能有些許的改善,大約提升了3.5%:
13.4.6.5 Multi GPU
現代圖形API可顯式、精確地控制多GPU,協同多GPU並行渲染,從而提升效率。主要體現在:
-
完全控制每個GPU上的內容。
-
在指定圖形處理器上建立資源。
-
在特定的gpu上執行命令列表。
-
在GPU之間顯式複製資源。完美的DirectX 12複製佇列用例。
-
在GPU之間分配工作負載。不限於AFR(交叉幀渲染)。
多GPU協同工作示意圖。
除了以上涉及的技術或特性,現代圖形API還支援保守光柵化(Conservative Raster)、型別UAV載入(Typed UAV Loads)、光柵化有序檢視(Rasterizer-Ordered Views )、模板引用輸出(Stencil Reference Output)、UAV插槽、Sparse Resource等等特性。
13.5 綜合應用
本章將闡述以下現代圖形API的常見的綜合性應用。
13.5.1 Rendering Hardware Interface
現代圖形API有3種,包含Vulkan、DirectX、Metal,如果是渲染引擎,為了跑著多平臺上,必然需要一箇中間抽象層,來封裝各個圖形API的差異,以便在更上面的層提供統一的呼叫方式,提升開發效率,並且獲得可擴充套件性和優化的可能性。
UE稱這個封裝層為RHI(Rendering Hardware Interface,渲染硬體介面),更具體地說,UE提供FDynamicRHI和其子類來封裝各個平臺的差異。下面是FDynamicRHI的繼承結構圖:
其中FDynamicRHI提供了統一的呼叫介面,具體的子類負責實現對應圖形API平臺的呼叫。
更多詳情可參閱:剖析虛幻渲染體系(10)- RHI。
13.5.2 Multithreaded Rendering
摩爾定律的放緩,導致CPU廠商朝著多核CPU發展,作為圖形API的制定者們,也在朝著充分利用多核CPU的方向發展。而現代圖形API的重要改變點就是可以實現多核CPU的渲染。
DX9、DX11、DX12的多執行緒模型對比示意圖。
DX11、DX12的GPU執行模型對比示意圖。
為了利用現代圖形API實現多執行緒渲染,需要考慮CPU多執行緒和GPU多執行緒。CPU側多執行緒需要考量:
- 多執行緒化的Command Buffer構建。
- 向佇列提交不是執行緒安全的。
- 將幀拆分為大的渲染作業。
- 從主執行緒中分離著色器編譯。
- 合批Command Buffer的提交。
- 在提交和呈現期間,不要阻塞執行緒。
- 可以並行的任務包括:
- Command List生成。需要用不同的command buffer。
- Descriptor Set建立。需要用不同的descriptor pool。
- Bundle生成。
- PSO建立。
- 資源建立。
- 動態資料生成。
GPU側多執行緒需要考量硬體計算單元、核心、記憶體尺寸和頻寬、ALU等效能,還要考慮CU、SIMD、Wave、執行緒數等指標。下表是Radeon Fury和Radeon Fury X的硬體引數:
Radeon Fury X | Radeon Fury | |
---|---|---|
Compute Units(CU) | 64 | 56 |
Core Frequency | 1050 Mhz | 1000 Mhz |
Memory Size | 4 GB | 4 GB |
Memory BW | 512 GB/s | 512 GB/s |
ALU | 8.6 TFlops | 7.17 TFlops |
從上表可以得出Radeon Fury X的峰值執行緒數量是:
Radeon Fury X是多年前(2015年)的GPU產品,現在的GPU可以達到百萬級別的執行緒數量。
為了減少卡頓和空閒,CPU端需要多個前端(front-end),使用併發的多執行緒(超執行緒),交錯兩個共享執行資源的指令流。下面是Bloom和DOF並行執行的圖例:
)
交錯兩個共享執行資源的指令流示例:Bloom和DOF。
使用佇列內Barrier和跨佇列Barrier進行同步。
ad5.png)
使用DirectX實現交錯指令流的圖例。
以下是使用DX12實現最簡單的多執行緒渲染的虛擬碼:
// 主執行緒渲染函式。
void OnRender_MainThread()
{
// 通知每一個子渲染執行緒開始渲染
for workerId in workerIdList
{
SetEvent(BeginRendering_Events[workerId]);
}
// Pre Command List 用於渲染準備工作
// 重置 Pre Command List
pPreCommandList->Reset(...);
// 設定後臺緩衝區從呈現狀態到渲染目標的屏障
pPreCommandList->ResourceBarrier(1, (..., D3D12_RESOURCE_STATE_PRESENT, D3D12_RESOURCE_STATE_RENDER_TARGET));
// 清除後臺緩衝區顏色
pPreCommandList->ClearRenderTargetView(...);
// 清除後臺緩衝區深度/模板
pPreCommandList->ClearDepthStencilView(...);
// 其它 Pre Command List 上的操作
// ...
// 關閉 Pre Command List
pPreCommandList->Close();
// Post Command List 用於渲染後收尾工作
// 設定後臺緩衝區從呈現狀態到渲染目標的屏障
pPostCommandList->ResourceBarrier(1, (..., D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_PRESENT));
// 其它 Post Command List 上的操作
// ...
// 關閉 Post Command List
pPostCommandList->Close();
// 等待所有工作執行緒完成任務 1
WaitForMultipleObjects(Task1_Events);
// 提交已完成渲染命令(Pre Command List 和所有工作執行緒上的用於任務 1 的 Command List)
pCommandQueue->ExecuteCommandLists(..., pPreCommandList + pCommandListsForTask1);
// 等待所有工作執行緒完成任務 2
WaitForMultipleObjects(Task2_Events);
// 提交已完成渲染命令(所有工作執行緒上的用於任務 2 的 Command List)
pCommandQueue->ExecuteCommandLists(..., pCommandListsForTask2);
// ...
// 等待所有工作執行緒完成任務 N
WaitForMultipleObjects(TaskN_Events);
// 提交已完成渲染命令(所有工作執行緒上的用於任務 N 的 Command List)
pCommandQueue->ExecuteCommandLists(..., pCommandListsForTaskN);
// 提交剩下的 Command List(pPostCommandList)
pCommandQueue->ExecuteCommandLists(..., pPostCommandList);
// 使用 SwapChain 呈現
pSwapChain->Present(...);
}
void OnRender_WorkerThread(workerId)
{
// 每一次迴圈代表子執行緒一幀渲染工作
while (running)
{
// 等待主執行緒開始一幀渲染事件通知
WaitForSingleObject(BeginRendering_Events[workerId]);
// 渲染子任務 1
{
pCommandList1->SetGraphicsRootSignature(...);
pCommandList1->IASetVertexBuffers(...);
pCommandList1->IASetIndexBuffer(...);
// ...
pCommandList1->DrawIndexedInstanced(...);
pCommandList1->Close();
// 通知主執行緒當前工作執行緒上的渲染子任務 1 完成
SetEvent(Task1_Events[workerId]);
}
// 渲染子任務 2
{
pCommandList2->SetGraphicsRootSignature(...);
pCommandList2->IASetVertexBuffers(...);
pCommandList2->IASetIndexBuffer(...);
// ...
pCommandList2->DrawIndexedInstanced(...);
pCommandList2->Close();
// 通知主執行緒當前工作執行緒上的渲染子任務 2 完成
SetEvent(Task2_Events[workerId]);
}
// 更多渲染子任務
// ...
// 渲染子任務 N
{
pCommandListN->SetGraphicsRootSignature(...);
pCommandListN->IASetVertexBuffers(...);
pCommandListN->IASetIndexBuffer(...);
// ...
pCommandListN->DrawIndexedInstanced(...);
pCommandListN->Close();
// 通知主執行緒當前工作執行緒上的渲染子任務 N 完成
SetEvent(TaskN_Events[workerId]);
}
}
}
以上程式碼成功地把任務分配給了子執行緒去處理,而主執行緒只關注如準備以及渲染後處理這樣的工作。
子執行緒只需要適時通知主執行緒自己的工作情況,使用多個Command List可以無須打斷地將一幀的渲染命令處理完成。同時,主執行緒也可以專心處理自己的工作,在合適的情況下,等待子執行緒完成階段性工作,將子執行緒中相關的Command List使用Command Queue提交給 GPU。
當然只要能確保渲染順序正確,子執行緒也可以通過 Command Queue 提交Command List上的命令。這裡為了便於說明,把Command Queue提交Command List的操作,放在了主執行緒上。
在實現引擎的多執行緒渲染時,確保引擎能夠覆蓋所有的核心,以充分所有核心的運算效能,提升並行效率。配合Task Graph的多執行緒系統更好,一個執行緒提交所有命令佇列,其它多個工作執行緒並行地構建命令佇列。
另外,在現代3D遊戲中,大量地使用了後期處理,可以將後期處理這樣的任務放在主執行緒中,或者放在一個或多個子執行緒中。
任務良好排程的多執行緒渲染案例1。
/multithread13.png)
任務良好排程的多執行緒渲染案例2。
下圖是D3D11和D3D12的多執行緒效能對比圖:
g)
由此可知,D3D12的多執行緒效率更高,相比D3D11,整幀的時間減少了約31%,GPU時間減少了約50%。
在本月初(2021年12月)Epic Games召開的UOD 2021大會上,就職於騰訊光子的Leon Wei講解了通過改造多執行緒渲染系統來並行化處理和提交OpenGL的API。
他的思路是先總結出目前UE的多執行緒渲染體系的總體機制:
然後找出OpenGL呼叫中耗時較重的API:
glBufferData()
glBufferSubData()
glCompressedTexImage2D() / glCompressedTexImage3D()
glCompressedTexSubImage2D() / glCompressedTexSubImage3D()
glTexImage2D() / glTexImage3D()
glTexSubImage2D() / glTexSubImage3D()
glcompileshader / glshadersource
gllinkprogram
......
接著想辦法將這些耗時嚴重的API從RHI主執行緒中抽離到其它輔助的RHI執行緒中:
ng)
上:耗時圖形API呼叫在同一個RHI執行緒時會影響該執行緒的效率;下:將耗時API抽離到其它輔助執行緒,從而不卡RHI主執行緒。
下圖是改造後的多RHI輔助執行緒的架構圖:
在新的多RHI架構中,需要額外處理多執行緒、資源之間的同步等工作。更多詳情可訪問Leon Wei本人的文章:基於UE4的多RHI執行緒實現。
13.5.3 Frame Graph
現代圖形API提供瞭如此多的許可權給應用程式,如果這一切都暴露給遊戲應用層開發者,將是一種災難。
遊戲引擎作為基礎且重要的中間層角色,非常有必要實現一種機制,可以良好地掌控現代圖形API帶來的遍歷,並且儘量隱藏它的複雜性。此時,Frame Graph橫空出世,正是為了解決這些問題。
Frame Graph旨在將引擎的各類渲染功能(Feature)、上層渲染邏輯(Renderer)和下層資源(Shader、RenderContext、圖形API等)隔離開來,以便做進一步的解耦、優化。
育碧的Anvil引擎為了解決渲染管線的複雜度和依賴關係,構建了Producer System(生產系統)、Shader Inpute Groups(著色器輸入組),精確地管理管線狀態和資源。
Anvil引擎內複雜的渲染管線示意圖。
其中Anvil引擎的Producer System目標是實現資源依賴(資源生命週期、跨佇列同步、資源狀態轉換、命令佇列順序執行和合並),精確地追蹤資源依賴關係:
Anvil引擎追蹤資源依賴和生命週期圖例。
Anvil引擎實現記憶體重用圖例。
Anvil引擎實現資源同步圖例。
Anvil引擎實現和優化狀態轉換圖例。
除此之外,Anvil可以自動生成排程圖(Schedule Graph),可以察看GPU執行順序、命令佇列、生產者等資訊:
Anvil引擎生成的Schedule Graph。
g)
Anvil引擎生成的Schedule Graph部分放大圖。
Anvil引擎的Shader Inpute Group是儘量在離線階段收集並編譯PSO:
對於PSO,儘量將耗時的狀態提前到離線和載入時刻:
ng)
經過以上基於DX12等現代圖形API的系統構建完成之後,Anvil的CPU平均可以獲得15%-30%左右的提升,GPU則只有約5%:
另外,UE的RDG和Frostbite的Frame Graph都是基於渲染圖的方式達成現代圖形API的多執行緒渲染、資源管理、狀態轉換、同步操作等等。
寒霜引擎採用幀圖方式實現的延遲渲染的順序和依賴圖。
13.5.4 GPU-Driven Rendering Pipeline
下面對場景如何分解為工作項的高階概述。
- 首先進行粗粒度的檢視剔除,然後倖存的叢集通過各種測試進行三角形剔除。
- 在通道上執行一個快速壓縮,以確保如果一個網格秒點完全被剔除(就像遮擋或截錐剔除的情況),以保證不會有零尺寸的繪製。
- 在管道的最後,有一組索引的繪製引數,使用DirectX 12的ExecuteIndirect(OpenGL需要AMD_mul _draw_indirect擴充套件,Xbox One需要ExecuteIndirect特殊擴充套件)執行GPU剔除。
- 通過間接引數切換PSO,意味著可以為整個場景只需要呼叫單個ExecuteIndirect,而不管狀態或資源變化。
GPU-Driven管線的裁剪框架圖。
GPU-Driven Rendering Pipeline執行過程還可以結合眾多的裁剪技術(Frustum裁剪、Cluster裁剪、三角形裁剪、零面積圖元裁剪、小面積圖元裁剪、朝向裁剪、深度裁剪、分塊深度裁剪、層級深度裁剪)和優化技術(非交叉資料結構、合批、壓縮),以獲得更高的渲染效能。
UE5的Nanite和Lumen將GPU-Driven Rendering Pipeline技術發揮得淋漓盡致,從而在PC端實現了影視級的實時渲染效果。
更多GPU-Driven Rendering Pipeline詳情可參閱:
- Optimizing the Graphics Pipeline with Compute
- GPU-Driven Rendering Pipelines
- 剖析虛幻渲染體系(06)- UE5特輯Part 1(特性和Nanite)
- 剖析虛幻渲染體系(06)- UE5特輯Part 2(Lumen和其它)
13.5.5 Performance Monitor
AMD為DirectX12提供了效能檢測工具,可以監控管線的很多資料(頂點快取效率、裁剪率、過繪製等):
NV的官方開發人員測試了OpenGL、DX11、DX12的部分特性和API的效能,如下所示:
可見DX12的多執行緒、Bundle、原生API呼叫等效能遠遠領先其它傳統圖形API。
AMD也提供了相關的效能分析工具。對於渲染管線而言,常見的狀態如下所示:
常見的管線狀態:Inside Draw(繪製內)、Outside Draw(繪製外)、Occupancy(佔用率)、Fill(填充)、Drain(疲態)。
Radeon GPU Profiler(RGP)可以檢視Wave執行細節:
AMD內部工具甚至可以追蹤Wave的生命週期、各個部件的指令狀態和問題:
)
甚至可以估算平均延時和各級快取命中率:
下圖展示了Barrier和系列依賴+小量作業導致GPU大量的空閒:
下圖展示的是簡單幾何體無法填滿GPU和SIMULTANEOUS_USE_BIT命令緩衝區阻礙了並行引發的大量空閒:
下圖展示的是多個Drain和Fill狀態導致的GPU利用率降低:
下圖則展示了冷快取(Cold Cache)導致的GPU耗時增加:
但是,即便RGP顯示管線的Wave佔用率高,也可能會因為指令快取丟失和大量空閒導致效能不高:
分析出了症狀,就需要對症下藥,採用各種各樣的措施才能真正達到GPU的高效能。下圖是常見Pass通過AMD分析工具的效能情況:
更多參見:ENGINE OPTIMIZATION HOT LAP。
GPUView也可以檢視GPU(支援多個)的執行詳情:
此外,Ensure Correct Vulkan Synchronization by Using Synchronization Validation詳細地講解了如何校驗Vulkan的同步錯誤。
下圖是Vulkan驗證層的執行機制:
)
13.6 本篇總結
本篇主要闡述了現代圖形API的特點、機制和使用建議,然後給出了部分應用案例。
13.6.1 Vulkan貢獻者名單
筆者在查閱Vulkan資料時,無意間翻到Vulkan 1.2貢獻者名單:Appendix I: Credits (Informative)。
粗略統計了一下他們所在的公司和行業,如下表:
公司 | 行業 | 人數 |
---|---|---|
OS | 26 | |
AMD | CPU、GPU | 21 |
Samsung Electronics | 裝置 | 19 |
NVIDIA | GPU | 18 |
Intel | CPU、GPU | 18 |
LunarG | 軟體 | 16 |
Qualcomm | GPU | 11 |
Imagination Technologies | GPU | 11 |
Arm | GPU | 10 |
Khronos | 軟體標準 | 7 |
Oculus | VR | 6 |
Codeplay | 軟體 | 6 |
Independent | 軟體 | 6 |
Unity Technologies | 遊戲引擎 | 4 |
Valve Software | 軟體 | 4 |
Epic Games | 遊戲引擎 | 3 |
Mediatek | 軟體 | 3 |
Igalia | 軟體 | 3 |
Mobica | 軟體 | 3 |
Red Hat | OS | 2 |
Blizzard Entertainment | 遊戲 | 1 |
Huawei | 裝置 | 1 |
從上表的資料可知總人數223,可以得出很多有意思的結論:
- Vulkan的標準主要由OS、GPU、CPU等公司提供,佔比一半以上。
- Microsoft、Apple並未在列,因為他們有各自的圖形API標準DirectX和Metal。
- 遊戲行業僅有Unity、Epic Games、Blizzard等公司在列,佔總人數(223)比例僅3.6%。
- 疑似華人總人數僅13,佔總數比例僅5.8%。
- 國內企業只有華為在列,僅1人,佔總人數比例僅0.45%。
總結起來就是國內的圖形渲染技術離國外還有相當大的差距!吾輩當自強不息!
13.6.2 本篇思考
按慣例,本篇也佈置一些小思考,以加深理解和掌握現代圖形API:
- 現代圖形API的特點有哪些?請詳細列舉。
- 現代圖形API的同步方式有哪些?說說它們的特點和高效使用方式。
- 現代圖形API的綜合性應用有哪些?說說它們的實現過程。
特別說明
- 感謝所有參考文獻的作者,部分圖片來自參考文獻和網路,侵刪。
- 本系列文章為筆者原創,只發表在部落格園上,歡迎分享本文連結,但未經同意,不允許轉載!
- 系列文章,未完待續,完整目錄請戳內容綱目。
- 系列文章,未完待續,完整目錄請戳內容綱目。
- 系列文章,未完待續,完整目錄請戳內容綱目。
參考文獻
- Unreal Engine Source
- Rendering and Graphics
- Materials
- Graphics Programming
- Vulkan® 1.2.201 - A Specification
- Vulkan 1.1 Quick Reference
- Vulkan Tutorial
- Vulkan® Guide
- Vulkan Decoder Ring
- A Comparison of Modern Graphics APIs
- Raw Vulkan
- Raw Metal
- Raw DirectX 12
- DirectX 12 技術白皮書
- Raw DirectX 11
- Raw OpenGL
- Understanding Vulkan® Objects
- UE4/UE5的RHI(Vulkan為例)
- Direct3D 12 programming guide
- Direct3D 11 Programming guide
- Right on Queue: Advanced DirectX 12 Programming
- Metal API Overview
- Metal Programming Guide
- Metal Best Practices Guide
- Metal Shading Language Specification
- Working with Metal—Overview
- OpenGL 4.6 Core Specification
- OpenGL® ES 3.1 Reference Pages
- 移動遊戲效能優化通用技法
- 遊戲引擎隨筆 0x11:現代圖形 API 特性的統一:Attachment Fetch
- Using Next-Generation APIs on Mobile GPUs
- What's the main difference of pipeline process between Vulkan and DX12?
- Encoder results reuse
- Encoding Indirect Command Buffers on the CPU
- Rendering a Scene with Deferred Lighting in C++
- 基於UE4的多RHI執行緒實現
- Yet another blog explaining Vulkan synchronization
- ENGINE OPTIMIZATION HOT LAP
- Vulkan Multi-Threading
- An exploratory study of high performance graphics application programming interfaces
- Approaching Minimum Overhead with Direct3D12
- The NVIDIA Turing GPU Architecture Deep Dive: Prelude to GeForce RTX
- EXPLORING COMPRESSION IN THE GPU MEMORY HIERARCHY FOR GRAPHICS AND COMPUTE
- Optimizing Data Transfer Using Lossless Compression with NVIDIA nvcomp
- Breaking Down Barriers - An Intro to GPU Synchronization
- DirectX 12: A New Meaning for Efficiency and Performance
- DirectX12: A Resource Heap Type Copying Time Analysis
- Ensure Correct Vulkan Synchronization by Using Synchronization Validation
- Memory Management in Vulkan™ and DX12
- Practical DirectX 12 - Programming Model and Hardware Capabilities
- D3D12 & Vulkan Done Right
- GDC2017: Moving To DX12 Lessons Learned
- Getting The Best Out Of D3D 12
- DX12 & Vulkan Dawn of a New Generation of Graphics APIs
- Introduction to Direct 3D 12 by Ivan Nevraev
- Surfing the wave(front)s with Radeon™ GPU Profiler
- Vulkan on NVIDIA GPUs
- [Vulkan: Migrating from OpenGL ES](http://cdn.imgtec.com/sdk-documentation/Vulkan.Migrating from OpenGL ES.pdf)
- VULKAN FAST PATHS
- Real-Time Rendering
- An Overview of Next-Generation Graphics APIs
- Getting Explicit How hard is Vulkan Really?
- A sampling of UE4 rendering improvements Arne Schober
- Advanced Rendering with DirectX 11 and DirectX 12
- Porting your engine to Vulkan or DX12
- GDC 2016:D3D12 & Vulkan: Lessons learned
- GDC 2017:D3D12 & Vulkan: Lessons learned
- DirectX 12 Optimization Techniques in Capcom's RE ENGINE
- DirectX™ 12 Case Studies
- Efficient rendering in The Division 2
- Optimizing the Graphics Pipeline with Compute
- Deep Dive: Asynchronous Compute
- Explicit DirectX12 Multi GPU rendering
- Get the Most from Vulkan in Unity with Practical Examples from Infinite Dreams
- Wave Programming in D3D12 and Vulkan
- AMD GPU Performance Revealed
- CONCURRENCY MODEL IN EXPLICIT GRAPHICS APIS
- [AMD RYZEN™ PROCESSOR SOFTWARE OPTIMIZATION](https://gpuopen.com/wp-content/uploads/slides/GPUOpen_Let’sBuild2020_AMD Ryzen™ Processor Software Optimization.pdf)
- Optimizing for the RDNA Architecture
- Optimization with Radeon GPU Profiler A Vulkan Case
- High Zombie Throughput in Modern Graphics
- How we rethought driver abstraction
- Introduction to 3D Game Programming with DirectX® 12
- Triangles Are Precious - Let's Treat Them With Care
- FrameGraph: Extensible Rendering Architecture in Frostbite
- Advanced Graphics Techniques Tutorial Day: Practical DirectX 12 - Programming Model and Hardware Capabilities
- Programming GPU
- Efficient GPU Programming in Modern C++
- GPU-Driven Rendering Pipelines
- Understanding DirectX* Multithreaded Rendering Performance by Experiments
- Bringing Fortnite to Mobile with Vulkan and OpenGL ES
- DirectX 12: Advanced Graphics Performance
- DX12 Do's And Don'ts
- Vulkan Subgroup Tutorial
- Vulkan Best Practices for Mobile Developers
- API without Secrets: Introduction to Vulkan
- Advanced Graphics Tech: How to Thrive on the Bleeding Edge Whilst Avoiding Death by 1,000 Paper Cuts
- Ubisoft's Experience Developing with Vulkan
- VRS Tier 1 with DirectX 12 From Theory To Practice
- Vulkan in Rocksolid
- OPTIMISING A AAA VULKAN TITLE ON DESKTOP
- Making use of New Vulkan Features
- Vulkan: The State of the Union
- Microsoft Game Development Guide