剖析虛幻渲染體系(13)- RHI補充篇:現代圖形API之奧義與指南
- 2021 年 12 月 12 日
- 筆記
- C/C++/Lua, GPU, GPU優化, UE4, Unreal, Unreal Engine, 引擎研發, 渲染優化, 遊戲前端, 遊戲引擎, 計算機圖形學
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
InitAPI –> LoadingAssets
LoadingAssets –> UpdatingAssets
UpdatingAssets –> Presentation
Presentation –> AppClosed
AppClosed–>LoadingAssets:No
AppClosed–>Destroy:Yes
Destroy –> [*]
- 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)到8×8的映射是線性塊(linear block)。
- 實際可能是(4×1)模式的紋理獲取方塊(quad)。
- 實際可能引發V$存儲體衝突(bank conflict)。
- GCN以4個線程為一組進行採樣。
以上是不好的配置,良好的Workgroup配置案例如下:
-
(512, 1, 1)的Workgroup被配置成(32, 16, 1)。
- 8 wave / V$獲得局部性。
- 每個wave是8×8的Tile。(每個GPU廠商和GPU系列存在差異,這裡指AMD的GCN架構)
- 8個wave被組織成4×2個8x8Tile的集合。(下圖)
-
線程到8×8的tile映射是重組的塊線性(swizzled block linear)。
- 良好的2×2模式的紋理獲取方塊。(上圖)
-
專用的着色器優化。
- 高度依賴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的繼承結構圖:
class FDynamicRHI{
void* RHIGetNativeDevice()
void* RHIGetNativeInstance()
IRHICommandContext* RHIGetDefaultContext()
IRHIComputeContext* RHIGetDefaultAsyncComputeContext()
IRHICommandContextContainer* RHIGetCommandContextContainer()
}
FDynamicRHI <|– FMetalDynamicRHI
class FMetalDynamicRHI{
FMetalRHIImmediateCommandContext ImmediateContext
FMetalRHICommandContext* AsyncComputeContext
}
FDynamicRHI <|– FD3D12DynamicRHI
class FD3D12DynamicRHI{
static FD3D12DynamicRHI* SingleD3DRHI
FD3D12Adapter* ChosenAdapters
FD3D12Device* GetRHIDevice()
}
FDynamicRHI <|– FD3D11DynamicRHI
class FD3D11DynamicRHI{
IDXGIFactory1* DXGIFactory1
FD3D11Device* Direct3DDevice
FD3D11DeviceContext* Direct3DDeviceIMContext
}
FDynamicRHI <|– FOpenGLDynamicRHI
class FOpenGLDynamicRHI{
FPlatformOpenGLDevice* PlatformDevice
}
FDynamicRHI <|– FVulkanDynamicRHI
class FVulkanDynamicRHI{
VkInstance Instance
FVulkanDevice* Devices
}
其中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](//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](//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