剖析虚幻渲染体系(13)- RHI补充篇:现代图形API之奥义与指南

目录

13.1 本篇概述

13.1.1 本篇内容

本篇是RHI篇章的补充篇,将详细且深入地阐述现代图形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编程开销很大,主要表现在:

对于以上开销大的操作,传统图形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,可以获得的潜在收益有:

13.2 设备上下文

13.2.1 启动流程

对大多数图形API而言,应用程序使用它们时都存在以下几个阶段:

NoYesInitAPILoadingAssetsUpdatingAssetsPresentationAppClosedDestroy

现代图形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的使用建议:

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线×N+NBundle

如果有数百个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方面的建议如下:

13.3.2 Render Pass

概念 UE DirectX 12 DirectX 11 Vulkan Metal OpenGL Render Pass FRHIRenderPassInfo BeginRenderPass, EndRenderPass - VkRenderPass MTLRenderPassDescriptor - SubPass FRHIRenderPassInfo - - VkSubpassDescription Programmable Blending PLS

绘制命令必须记录在Render Pass实例中,每个Render Pass实例定义了一组输入、输出图像资源,以便在渲染期间使用。

DirectX 12录制命令队列示意图。其中命令包含了资源、光栅化等类型。

现代移动GPU已经普遍支持TBR架构,为了更好地利用此架构特性,让Render Pass期间的数据保持在Tile缓存区内,便诞生了Subpass技术。利用Subpass技术可以显著降低带宽,提升渲染效率。更多请阅读12.4.13 subpass和10.4.4.2 Subpass渲染。

Vulkan Render Pass内涉及的各类概念、资源及交互关系。

在OpenGL,采用Pixel Local Storage的技术来模拟Subpass。Metal则使用Programmable Blending(PB)来模拟Subpass机制(下图)。


上:传统的多Pass渲染延迟光照,多个GBuffer纹理会在GBuffer Pass和Lighting Pass期间来回传输于Tile Memeory和System Memory之间;下:利用Metal的PB技术,使得GBuffer数据在GBuffer Pass和Lighting Pass期间一直保持在Tile Memroy内。

Metal利用Render Pass的Store和Load标记精确地控制Framebuffer在Tile内,从而极大地降低读取和写入带宽。

创建和使用一个Render Pass的伪代码如下:

Start a render pass

// 以下代码会循环若干次
Bind all the resources
    Descriptor set(s)
    Vertex and Index buffers
    Pipeline state
Modify dynamic state
Draw

End render pass

Vulkan的Render Pass使用建议:

更多Render Pass相关的说明请阅读:12.4.13 subpass和10.4.4.2 Subpass渲染。

13.3.3 Texture, Shader

概念 UE DirectX 12 DirectX 11 Vulkan Metal OpenGL Texture FRHITexture ID3D12Resource ID3D11Texture2D vk::Image & vk::ImageView MTLTexture GLuint Shader FRHIShader ID3DBlob ID3D11VertexShader, ID3D11PixelShader vk::ShaderModule MTLLibrary GLuint

大多数现代图形api都有绑定数据结构,以便将Uniform Buffer和纹理连接到需要这些数据的图形管道。Metal的独特之处在于,可以在命令编码器中使用setVertexBuffer绑定Uniform,比Vulkan、DirectX 12和OpenGL更容易构建。

13.3.4 Shader Binding

概念 UE DirectX 12 DirectX 11 Vulkan Metal OpenGL Shader Binding FRHIUniformBuffer ID3D12RootSignature ID3D11DeviceContext::VSSetConstantBuffers(...) vk::PipelineLayout & vk::DescriptorSet [MTLRenderCommandEncoder setVertexBuffer: uniformBuffer] GLint Pipeline State FGraphicsPipelineStateInitializer ID3D12PipelineState Various State Calls vk::Pipeline MTLRenderPipelineState Various State Calls Descriptor - D3D12_ROOT_DESCRIPTOR - VkDescriptorBufferInfo, VkDescriptorImageInfo argument - Descriptor Heap - ID3D12DescriptorHeap - VkDescriptorPoolCreateInfo heap - Descriptor Table - D3D12_ROOT_DESCRIPTOR_TABLE - VkDescriptorSetLayoutCreateInfo argument buffer - Root Parameter - D3D12_ROOT_PARAMETER - VkDescriptorSetLayoutBinding argument in shader parameter list - Root Signature - ID3D12RootSignature - VkPipelineLayoutCreateInfo - -

Pipeline State(管线状态)是在执行光栅绘制调用、计算调度或射线跟踪调度时将要执行的内容的总体描述。DirectX 11和OpenGL没有专门的图形管道对象,而是在执行绘制调用之间使用调用来设置管道状态。

Root Signature(根签名)是定义着色器可以访问哪些类型的资源的对象,比如常量缓冲区、结构化缓冲区、采样器、纹理、结构化缓冲区等等(下图)。

具体地说,Root Signature可以设置3种类型的资源和数据:Descriptor Table、Descriptor、Constant Data。

DirectX 12根签名数据结构示意图。

这三种资源在CPU和GPU的消耗刚好相反,需权衡它们的使用:

Root Signature3种类型(Descriptor Table、Descriptor、Constant Data)在GPU内存获取消耗依次降低,但CPU消耗依次提升。

更具体地说,改变Table的指针消耗非常小(只是改变指针,没有同步开销),但改变Table的内容比较困难(处于使用中的Table内容无法被修改,没有自动重命名机制)。

因此,需要尽量控制Root Signature的大小,有效控制Shader可见范围,只在必要时才更新Root Signature数据。

Root Signature在DirectX 12上最大可达64 DWORD,可以包含数据(会占用很大存储空间)、Descriptor(2 DWORD)、指向Descriptor Table的指针(下图)。

Descriptor(描述符)是一小块数据,用来描述一个着色器资源(如缓冲区、缓冲区视图、图像视图、采样器或组合图像采样器)的参数,只是不透明数据(没有OS生命周期管理),是硬件代表的视图。

Descriptor的数据图例。

Descriptor被组织成Descriptor Table(描述符表),这些Descriptor Table在命令记录期间被绑定,以便在随后的绘图命令中使用。

每个Descriptor Table中内容的编排由Descriptor Table中的Layout(布局)决定,该布局决定哪些Descriptor可以存储在其中,管道可以使用的Descriptor Table或Root Parameter(根参数)的序列在Root Signature中指定。每个管道对象使用的Descriptor Table和Root Parameter有数量限制。

Descriptor Heap(描述符堆)是处理内存分配的对象,用于存储着色器引用的对象的描述。

Root Signature、Root Parameter、Descriptor Table、Descriptor Heap的关系。其中Root Signature存储着若干个Root Parameter实例,每个Root Parameter可以是Descriptor Table、UAV、SRV等对象,Root Parameter的内存内容存在了Descriptor Heap中。

DX12的根签名在GPU内部的交互示意图。其中Root Signature在所有Shader Stage中是共享的。

下面举个Vulkan Descriptor Set的使用示例。已知有以下3个Descriptor Set A、B、C:

通过以下C++代码绑定它们:

vkBeginCommandBuffer();
// ...
vkCmdBindPipeline(); // Binds shader

// 绑定Descriptor Set B和C, 其中C在序号0, B在序号2. A没有被绑定.
vkCmdBindDescriptorSets(firstSet = 0, pDescriptorSets = &descriptor_set_c);
vkCmdBindDescriptorSets(firstSet = 2, pDescriptorSets = &descriptor_set_b);

vkCmdDraw(); // or dispatch
// ...
vkEndCommandBuffer();

则经过上述代码绑定之后,Shader资源的绑定序号如下图所示:

对应的GLSL代码如下:

layout(set = 0, binding = 0) uniform sampler2D myTextureSampler;
layout(set = 0, binding = 2) uniform uniformBuffer0 {
    float someData;
} ubo_0;
layout(set = 0, binding = 3) uniform uniformBuffer1 {
    float moreData;
} ubo_1;

layout(set = 2, binding = 0) buffer storageBuffer {
    float myResults;
} ssbo;

对于复杂的渲染场景,应用程序可以修改只有变化了的资源集,并且要保持资源绑定的更改越少越好。下面是渲染伪代码:

foreach (scene) {
    vkCmdBindDescriptorSet(0, 3, {sceneResources,modelResources,drawResources});
    foreach (model) {
        vkCmdBindDescriptorSet(1, 2, {modelResources,drawResources});
        foreach (draw) {
            vkCmdBindDescriptorSet(2, 1, {drawResources});
            vkDraw();
        }
    }
}

对应的shader伪代码:

layout(set=0,binding=0) uniform { ... } sceneData;
layout(set=1,binding=0) uniform { ... } modelData;
layout(set=2,binding=0) uniform { ... } drawData;

void main() { }

Vulkan绑定Descriptor流程图。

下图是另一个Vulkan的VkDescriptorSetLayoutBinding案例:

关于着色器绑定的使用,建议如下:

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是个同步操作,会卡住调用线程,直到内存分配完毕。它的使用建议如下:

对此,可以使用提前执行策略(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种方式:

这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资源使用建议:

资源的整块分配和子分配(Suballocation)对比图如下:

面对如此多的类型和属性,我们可以根据需求来选择不同的用法和组合:

更高效的Heap使用建议:

UE的Vulkan RHI允许异步创建顶点和索引缓冲,减少渲染线程的卡顿。

对于物理内存的重用,无论是reserved还是placed资源,必须遵循以下和D3D11的分块资源(Tiled Resource)相同的规则:

D3D12在内存映射方面提供了显式的控制,可以每帧创建一个大buffer,暂存所有数据,对Const buffer没有专用的需求,转由应用程序按需构建。

对于高吞吐量的渲染,建议如下:

以下是Ashes的CPU作业和GPU内存交互示意图:

13.4.1.2 Resource Update

对于现代图形API而言,资源更新的特点通常具有以下几点:

Metal中可以被更改和不能被更改的资源示意图。

13.4.2 Pipeline State Object

在D3D11,拥有很多小的状态对象,导致GPU硬件不匹配开销:

)

到了D3D12,将管线的状态分组到单个对象,直接拷贝PSO到硬件状态:

下面是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涉及以下的状态(黑色和白色方块):

会影响编译的状态在对象创建后不能更改(如VS、PS、RT、像素格式、颜色写掩码、MSAA、混合状态、深度缓冲状态):

PSO的设计宗旨在于不在渲染过程中存在隐性的Shader编译和链接,在创建PSO之时就已经生成大部分硬件指令(编译进硬件寄存器)。由于PSO的shader输入是二进制的,对Shader Cache非常友好。下图是PSO在渲染管线的交互图:

PSO配合根签名、描述符表之后的运行机制图例如下:

开发者仍然可以动态切换正在使用的PSO,硬件只需要直接拷贝最少的预计算状态到硬件寄存器,而不是实时计算硬件状态。通过使用PSO,Draw Call的开销显著减少,每帧可以有更多的Draw Call。但开发者需要注意:

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线程之间存在依赖,就需要各类同步对象进行同步,确保依赖关系正常。这些同步对象的作用如下:

Barrier的GPU消耗常以时间戳(timestamp)来衡量,对于不需要解压的Barrier通常只需要微米(μs)级别的时间,需要耗费百分比级别的情况比较罕见,除非需要解压包含MSAA数据的表面。每个可写入的表面不应该超过2个Barrier。

每帧的表面(Surface)写入是个大问题,写入表面可能会因为Barrier丢失而损坏数据,每帧每个表面不要超过两个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的实现方案有以下几种:

育碧的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同步模型可以考虑以下方式:

同时也要注意CPU可以通过ExecuteCommandLists(ECL)调度GPU,意味着CPU的空隙会传导到GPU上。

13.4.3.3 Pipeline Barrier

Pipeline Barrier在Vulkan用于解决命令之间的执行依赖(Execution Dependency)问题,以及内存依赖(Memory Dependency)问题。

大多数Vulkan命令以队列提交顺序启动,但可以以任何顺序执行,即使使用了相同管道阶段。

当两个命令相互依赖时,必须告诉Vulkan两个同步范围(synchronization scope):

当内存数据存在依赖时,必须告诉Vulkan两个访问范围(access scope):

下面举个具体的例子:

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)是有序的:

内存访问掩码位是独立的:

假设有以下命令队列:

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(可能很慢)

下面是特殊的执行依赖的说明:

下面是特殊的内存访问掩码的说明:

更多Pipeline Barrier相关的说明请阅读:12.4.13 subpass。

13.4.4 Parallel Command Recording

在现代图形API出现之前,由于无法在多个线程并行地录制渲染命令,使得渲染线程所在的CPU核极度忙碌,而其它核心处于空闲状态:

现代图形API(如Vulkan)从一开始就被创建为线程友好型,大量规范详细说明了线程安全性和调用的后果,并且所有的控制权和责任都落在应用程序上。

随着现代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)陷入停顿,并冲刷管道。

下图是渲染帧中各个阶段花费的时间的一个案例:

其中Lighting、Post Process和大多数阴影相关的工作都可以放到Compute Shader中。此外,为了防止帧的后处理等待同一帧的前面部分(裁剪、阴影、光照等),可以放到Compute Queue,和下一帧的前面阶段并行:

利用现代图形API,渲染引擎可以方便地实现帧和帧之间的重叠(Overlap)。基本思路是:

但这种方式存在一些缺点:

引入Async Compute之后,普遍可以提升15%左右的性能:

对于Workgroup的优化,从PS迁移到CS的传统建议如下:

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

下面是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瓶颈冲突,只能获得少量并行效率;下:避开瓶颈相同的任务,赢得较多的并行效率。

下图左边是良好的并行配对,右边则是不良的并行配对:

不受限制的调度为糟糕的技术配对创造了机会,好处在于实现简单,但坏处在于帧与帧具有不确定性和缺少配对控制:

更佳的做法是,通过巧妙地使用Fence来显式地调度异步计算任务。好处是帧和帧之间的确定性,应用程序可以完全控制技术配对!坏处是实现稍微复杂一些:

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

Async Compute建议如下:

13.4.6 其它管线技术

利用现代图形API支持光线追踪的特性,可以实现混合光线追踪阴影(Hybrid Raytraced Shadows):

从而实现高质量的阴影效果:


上:传统阴影图效果;下:混合光线追踪阴影效果。

值得一提的是,GPU管线的剔除会导致利用率降低,引起很多小的空闲区域:

GPU利用率不足是导致延时的常见原因。

现代GPU为了降低带宽,在内部各部件之间广泛地使用了压缩格式,在采样时,会从显存中读取压缩的数据,然后在Shader Core中解压。(下图)

当需要导出(写入)数据时,会先压缩成颜色块,再写入压缩后的数据到显存。(下图)

GPU厂商工具通常可以观察纹理的格式和是否开启压缩:

对于GPU内部的这种数据压缩,需要注意以下几点:

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机制的优势在于:

着色器标量化可以提高线程并行工作的速度,可用于照明,基于GPU的遮挡剔除,SSR等。

Wave指令集通过移除不必要的同步来提高标量运算的效率,支持DirectX 11和DirectX 12。它和Threadgroup、Dispatch处理不同的层级,所用的内存也不同(下图),因此需要使用正确层级的原子进行同步。

当使用Wave操作对纹理进行访问时,如果线程索引在一个计算着色器被组织在一个ROW_MAJOR模式,将匹配一个线性纹理,这种模式不能很好地保持邻域性,无法很多地命中缓存:

可以用标准重组(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之间,可以改变以下数据:

下面是DX 12的ExecuteIndirect接口:

利用此接口,可以实现:

以下是DX11和DX12绘制树的对比:

此外,可以实现基于GPU的遮挡剔除。

13.4.6.3 Predication

Predication是DX12的特性,它完全与查询解耦,对缓冲区中某个位置的值的预测,GPU在执行SetPredication时读取buffer值。

支持Predication的API有:

使用案例就是基于异步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并行渲染,从而提升效率。主要体现在:

除了以上涉及的技术或特性,现代图形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的继承结构图:

FDynamicRHIvoid* RHIGetNativeDevice()void* RHIGetNativeInstance()IRHICommandContext* RHIGetDefaultContext()IRHIComputeContext* RHIGetDefaultAsyncComputeContext()IRHICommandContextContainer* RHIGetCommandContextContainer()FMetalDynamicRHIFMetalRHIImmediateCommandContext ImmediateContextFMetalRHICommandContext* AsyncComputeContextFD3D12DynamicRHIstatic FD3D12DynamicRHIFD3D12Adapter* ChosenAdaptersFD3D12Device* GetRHIDevice()FD3D11DynamicRHIIDXGIFactory1* DXGIFactory1FD3D11Device* Direct3DDeviceFD3D11DeviceContext* Direct3DDeviceIMContextFOpenGLDynamicRHIFPlatformOpenGLDevice* PlatformDeviceFVulkanDynamicRHIVkInstance InstanceFVulkanDevice* 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侧多线程需要考量:

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的峰值线程数量是:

64 CU × 4 SIMD/CU × 10 Wavefronts/SIMD × 64 Threads/Wavefront =16384064 CU × 4 SIMD/CU × 10 Wavefronts/SIMD × 64 Threads/Wavefront =163840

Radeon Fury X是多年前(2015年)的GPU产品,现在的GPU可以达到百万级别的线程数量。

为了减少卡顿和空闲,CPU端需要多个前端(front-end),使用并发的多线程(超线程),交错两个共享执行资源的指令流。下面是Bloom和DOF并行运行的图例:

交错两个共享执行资源的指令流示例:Bloom和DOF。

使用队列内Barrier和跨队列Barrier进行同步。

使用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。

任务良好调度的多线程渲染案例2。

下图是D3D11和D3D12的多线程性能对比图:

由此可知,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线程中:


上:耗时图形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。

Anvil引擎生成的Schedule Graph部分放大图。

Anvil引擎的Shader Inpute Group是尽量在离线阶段收集并编译PSO:

对于PSO,尽量将耗时的状态提前到离线和加载时刻:

经过以上基于DX12等现代图形API的系统构建完成之后,Anvil的CPU平均可以获得15%-30%左右的提升,GPU则只有约5%:

另外,UE的RDG和Frostbite的Frame Graph都是基于渲染图的方式达成现代图形API的多线程渲染、资源管理、状态转换、同步操作等等。

寒霜引擎采用帧图方式实现的延迟渲染的顺序和依赖图。

13.5.4 GPU-Driven Rendering Pipeline

下面对场景如何分解为工作项的高级概述。

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,可以得出很多有意思的结论:

总结起来就是国内的图形渲染技术离国外还有相当大的差距!吾辈当自强不息!

13.6.2 本篇思考

按惯例,本篇也布置一些小思考,以加深理解和掌握现代图形API:

特别说明

参考文献