­

剖析虛幻渲染體系(13)- RHI補充篇:現代圖形API之奧義與指南

 

 

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而言,應用程序使用它們時都存在以下幾個階段:

stateDiagram-v2
[*] –> 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數量上的建議是:

\[N_{錄製線程} \times N_{緩衝幀} + N_{Bundle池}
\]

如果有數百個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 subpass10.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 subpass10.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工作。
  • 盡量緩存並重用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)。
  • 在同一個線程上編譯類似的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, &region); // buffer_a是拷貝源
vkCmdCopyBuffer(cb, buffer_c, buffer_a, 1, &region); // buffer_a是拷貝目標

上面的代碼沒有使用Pipline Barrier,會觸發WAR(Write after read)衝突。可以添加Pipeline Barrier防止衝突:

vkCmdCopyBuffer(cb, buffer_a, buffer_b, 1, &region);
// 創建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, &region);

管線階段位(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的繼承結構圖:

classDiagram-v2
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的峰值線程數量是:

\[\text{64 CU } \times \text{ 4 SIMD/CU } \times \text{ 10 Wavefronts/SIMD } \times \text{ 64 Threads/Wavefront } = 163840
\]

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詳情可參閱:

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)

粗略統計了一下他們所在的公司和行業,如下表:

公司 行業 人數
Google 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的綜合性應用有哪些?說說它們的實現過程。

 

特別說明

  • 感謝所有參考文獻的作者,部分圖片來自參考文獻和網絡,侵刪。
  • 本系列文章為筆者原創,只發表在博客園上,歡迎分享本文鏈接,但未經同意,不允許轉載
  • 系列文章,未完待續,完整目錄請戳內容綱目
  • 系列文章,未完待續,完整目錄請戳內容綱目
  • 系列文章,未完待續,完整目錄請戳內容綱目

 

參考文獻