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

0嚮往0發表於2021-12-12

 

 

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)到8x8的對映是線性塊(linear block)。

    • 實際可能是(4x1)模式的紋理獲取方塊(quad)。
    • 實際可能引發V$儲存體衝突(bank conflict)。
    • GCN以4個執行緒為一組進行取樣。

以上是不好的配置,良好的Workgroup配置案例如下:

  • (512, 1, 1)的Workgroup被配置成(32, 16, 1)。

    • 8 wave / V$獲得區域性性。
    • 每個wave是8x8的Tile。(每個GPU廠商和GPU系列存在差異,這裡指AMD的GCN架構)
    • 8個wave被組織成4x2個8x8Tile的集合。(下圖)

  • 執行緒到8x8的tile對映是重組的塊線性(swizzled block linear)。

    • 良好的2x2模式的紋理獲取方塊。(上圖)
  • 專用的著色器優化。

    • 高度依賴2D空間的區域性性來獲得快取命中。
    • 在wave執行時更少的依賴。
  • 使用本地記憶體的一種常見技術是將輸入分割成塊,然後,當工作組對每個塊進行處理時,可以將其移動到本地記憶體中。

下面是NV和AMD對PS和CS的效能描述和建議:

NV使用PS的建議:不需要共享記憶體、執行緒在相同時間完成、高頻率的CB訪問、2D緩衝儲存;NV使用CS的建議:需要執行緒組共享記憶體、期望執行緒無序完成、高頻率使用暫存器、1D或3D緩衝儲存。

AMD使用PS的建議:從DS剔除中獲益、需要圖形渲染、需要利用顏色壓縮;AMD使用CS的建議:PS建議之外的所有情況。

利用Async Compute和多型別Queue,可以將傳統遊戲引擎的順序執行流程改造成並行的流程。


上:傳統遊戲引擎的線性渲染流程;下:利用GPU的多引擎並行地執行。

這樣的並行方式,可以減少單幀的渲染時間,降低延時,從而提升Draw Call和渲染效果。

不過,在並行實現時,需要格外注意各個工作的瓶頸,常見的瓶頸有:資料傳輸、著色器吞吐量、幾何資料處理,它們涉及的任務具體如下:

為了更好地並行效率,每個Engine的重疊部分儘量不要安排相同瓶頸的工作任務。

上:線性執行示意圖;中:Shadow Map和Stream Texture、Deferred Lighting和Animate Particle瓶頸衝突,只能獲得少量並行效率;下:避開瓶頸相同的任務,贏得較多的並行效率。

下圖左邊是良好的並行配對,右邊則是不良的並行配對:

不受限制的排程為糟糕的技術配對創造了機會,好處在於實現簡單,但壞處在於幀與幀具有不確定性和缺少配對控制:

.png)

更佳的做法是,通過巧妙地使用Fence來顯式地排程非同步計算任務。好處是幀和幀之間的確定性,應用程式可以完全控制技術配對!壞處是實現稍微複雜一些:

Copy Queue的特性、描述和使用建議如下:

  • 專門設計用於通過PCIE進行復制的專用硬體。

  • 獨立於其他佇列進行操作,讓圖形和計算佇列可以自由地進行圖形處理。

  • 如果從系統記憶體複製到local(視訊記憶體),使用複製佇列。例如,Texture Streaming。

  • 使用複製佇列在PCIE上傳輸資源。使用多GPU進行非同步傳輸是必不可少的。

  • 避免在複製佇列完成時自旋(spinning)。需提前做好傳輸計劃。

  • 注意複製深度+模板資源,複製僅深度可能觸發慢路徑(slow path)。(僅NV適應)

  • 多GPU下,支援p2p傳輸。

  • 確保GPU上有足夠的工作來確保不會在複製佇列上等待。

    • 儘可能早地開始複製,理想情況下在本地記憶體中需要複製之前,先複製幾幀。
  • 視訊記憶體內部的local到local的拷貝,分兩種情況:

    • 情況1:如果立即需要傳輸結果,使用Graphic Queue或Compute Queue。
    • 情況2:如果不立即需要傳輸結果,使用Copy Queue。比如上傳Buffer(constant、vertex、index buffer等),以及視訊記憶體碎片整理(defragging)。
      • 使用複製佇列移動來執行視訊記憶體碎片整理,比如佔用每幀1%的頻寬。讓圖形佇列繼續呈現,在Copy Queue不忙於Streaming的幀上執行。

    )

Async Compute建議如下:

  • 儘量少同步,理想情況下每幀只同步1-2次。每個同步點都有很大的開銷。
  • 將大型連續工作負載移到非同步佇列中。更多的機會重疊管道的drains / fills階段。
  • 更激進的做法:與下一幀重疊。
    • 通常情況下,幀以光柵繁重的工作開始,以計算繁重的後處理結束。
    • 可能增加延時!

13.4.6 其它管線技術

利用現代圖形API支援光線追蹤的特性,可以實現混合光線追蹤陰影(Hybrid Raytraced Shadows):

從而實現高質量的陰影效果:


上:傳統陰影圖效果;下:混合光線追蹤陰影效果。

值得一提的是,GPU管線的剔除會導致利用率降低,引起很多小的空閒區域:

GPU利用率不足是導致延時的常見原因。

現代GPU為了降低頻寬,在內部各部件之間廣泛地使用了壓縮格式,在取樣時,會從視訊記憶體中讀取壓縮的資料,然後在Shader Core中解壓。(下圖)

當需要匯出(寫入)資料時,會先壓縮成顏色塊,再寫入壓縮後的資料到視訊記憶體。(下圖)

GPU廠商工具通常可以觀察紋理的格式和是否開啟壓縮:

)

對於GPU內部的這種資料壓縮,需要注意以下幾點:

  • 使用獨佔佇列所有權。在共享所有權的情況下,驅動程式必須假定它使用在不能讀寫壓縮的硬體塊上。
  • 顯式地指明影像格式。UNKNOWN / MUTABLE會阻礙壓縮,可以工作在VK_KHR_image_format_list。
  • 只使用所需的影像用法。否則,資源最終可能會低於最佳壓縮級別。
  • 清理渲染或深度目標。會重置後設資料,防止額外的頻寬傳輸。

13.4.6.1 Wave

Wave在DirectX 12和Vulkan涉及的概念如下:

DirectX 12 Vulkan Desc
Lane Invocation 在wave內執行的一個著色器呼叫(執行緒)。
Wave Subgroup shader呼叫的集合,每個廠商呼叫的數量不同。

Lane和Wave結構示意圖。

Wave[DX]執行模式:所有Lane同時執行,並且鎖步(lock-step);Subgroup[VK]執行模型:Subgroup操作包含隱式屏障。

Wave機制的優勢在於:

  • 減少了barrier或interlock指令的使用。
    • 更簡單的著色器程式碼。
    • 更易維護,容易編碼。
  • 對DFC一致性的更多控制。
    • 有助於提高控制流(flow)一致性。
    • 有助於提高記憶體訪問一致性。

著色器標量化可以提高執行緒並行工作的速度,可用於照明,基於GPU的遮擋剔除,SSR等。

Wave指令集通過移除不必要的同步來提高標量運算的效率,支援DirectX 11和DirectX 12。它和Threadgroup、Dispatch處理不同的層級,所用的記憶體也不同(下圖),因此需要使用正確層級的原子進行同步。

當使用Wave操作對紋理進行訪問時,如果執行緒索引在一個計算著色器被組織在一個ROW_MAJOR模式,將匹配一個線性紋理,這種模式不能很好地保持鄰域性,無法很多地命中快取:

ng)

可以用標準重組(standard swizzle)來優化紋理訪問,這種紋理佈局的模式使得相鄰畫素被緊密地儲存在記憶體中,提升快取命中率:

下面是效能分析工具RGP抓取的以Wave為單位執行的VS、PS、CS圖例:


支援Wave的GPU而言,資料是波形化的uniform(wave-uniform),但著色器編譯器並不知道。一個典型的應用是,遍歷光源,告訴編譯器光源索引是wave-uniform,將資料從VGPR放入SGPR。

Capcom的RE引擎利用Wave操作,提升了約4.3%的效能:

)

關於Wave的更多技術細節請參閱:Wave Programming in D3D12 and Vulkan

13.4.6.2 ExecuteIndirect

ExecuteIndirect機制允許組合若干個Draw、DrawIndexed、Dispatch到同一個呼叫裡,更像是MultiExecuteIndirect()。在Draws/Dispatches之間,可以改變以下資料:

  • 頂點緩衝、索引緩衝、圖元數量等。
  • 根簽名、根常量。
  • 根SRV和UAV。

下面是DX 12的ExecuteIndirect介面:

利用此介面,可以實現:

  • 在一個ExecuteIndirect中繪製數千個不同的物件。為數百個物件節省了大量的CPU時間。
  • 間接計算工作。為了獲得理想的效能,可以使用NULL計數器緩衝引數。
  • 圖形繪製呼叫。為了獲得理想的效能,保持計數器緩衝計數和ArgMaxCount呼叫差不多。

以下是DX11和DX12繪製樹的對比:

)

此外,可以實現基於GPU的遮擋剔除。

13.4.6.3 Predication

Predication是DX12的特性,它完全與查詢解耦,對緩衝區中某個位置的值的預測,GPU在執行SetPredication時讀取buffer值。

支援Predication的API有:

  • DrawInstanced
  • DrawIndexedInstanced
  • Dispatch
  • CopyTextureRegion
  • CopyBufferRegion
  • CopyResource
  • CopyTiles
  • ResolveSubresource
  • ClearDepthStencilView
  • ClearRenderTargetView
  • ClearUnorderedAccessViewUint
  • ClearUnorderedAccessViewFloat
  • ExecuteIndirect

使用案例就是基於非同步CPU的遮擋剔除:一個CPU執行緒錄製Command List,另外一個CPU執行緒執行軟體(非硬體)遮擋查詢並填充到Predication緩衝區。(下圖)

13.4.6.4 UAV Overlap

首先要理解現代圖形API如果沒有依賴,可以並行地執行。

而UAV Barrier具體不明確的依賴,不清楚是讀還是寫,如果每個批處理寫到一個單獨的位置,它可以並行執行,前提是可以避免WAW(write-after-write)錯誤。

可以為每個compute shader的排程控制UAV同步,禁用UAV的同步使並行執行成為可能,在DirectX 11中,可以使用AGS和NVAPI引入等效函式。

啟用UAV Overlap機制,Capcom的RE引擎總體效能有些許的改善,大約提升了3.5%:

13.4.6.5 Multi GPU

現代圖形API可顯式、精確地控制多GPU,協同多GPU並行渲染,從而提升效率。主要體現在:

  • 完全控制每個GPU上的內容。

  • 在指定圖形處理器上建立資源。

  • 在特定的gpu上執行命令列表。

  • 在GPU之間顯式複製資源。完美的DirectX 12複製佇列用例。

  • 在GPU之間分配工作負載。不限於AFR(交叉幀渲染)。

    多GPU協同工作示意圖。

除了以上涉及的技術或特性,現代圖形API還支援保守光柵化(Conservative Raster)、型別UAV載入(Typed UAV Loads)、光柵化有序檢視(Rasterizer-Ordered Views )、模板引用輸出(Stencil Reference Output)、UAV插槽、Sparse Resource等等特性。

 

13.5 綜合應用

本章將闡述以下現代圖形API的常見的綜合性應用。

13.5.1 Rendering Hardware Interface

現代圖形API有3種,包含Vulkan、DirectX、Metal,如果是渲染引擎,為了跑著多平臺上,必然需要一箇中間抽象層,來封裝各個圖形API的差異,以便在更上面的層提供統一的呼叫方式,提升開發效率,並且獲得可擴充套件性和優化的可能性。

UE稱這個封裝層為RHI(Rendering Hardware Interface,渲染硬體介面),更具體地說,UE提供FDynamicRHI和其子類來封裝各個平臺的差異。下面是FDynamicRHI的繼承結構圖:

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的綜合性應用有哪些?說說它們的實現過程。

 

特別說明

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

 

參考文獻

相關文章