(原创)D3D11和D3D12多线程渲染框架的比较

发表于2018-01-09
评论6 9.8k浏览

1.   前言

D3D12伴随DirectX122014年正式发布以来已经近3年多时间了。遗憾的是我最近才有时间仔细研究D3D12接口及编程方面的内容。D3D12给我总体的感觉用一句话来概括就是——D3D12是一个“显卡操作系统!”。

得益于我对Windows内核编程的深入了解和掌握,突然发现掌握起D3D12多线程渲染时居然可以无障碍学习,看来并不是学过的东西都会过时,这也是让我暗自窃喜的地方。正所谓“众里寻他千百度,蓦然回首,那人却在灯火阑珊处!”

对比自D3D11中添加了对多线程渲染的支持以后,D3D12更是将多线程渲染支持发挥到了极致。如果说D3D11中对多线程渲染的支持还只是停留在“小荷才露尖尖角”的话,那么D3D12中的多线程渲染则是一番“接天莲叶无穷碧,映日荷花别样红”的盛况了。

本文就将从概念、编程原理、编程框架方面解刨一下D3D的多线程渲染技术。因本人知识水平有限,同时期待您的阅读与批评指正!

2.     D3D11多线程渲染基本原理

D3D11中,对于多线程渲染的支持主要可以概括为几个“小点”:

首先利用ID3D11Device::CreateDeferredContext方法创建一个(或多个)延迟渲染(Deferred Device Context)的设备上下文接口ID3D11DeviceContext

接着利用这个Deferred Device Context接口调用诸如:IASetPrimitiveTopologyIASetInputLayoutRSSetStateOMSetBlendState OMSetDepthStencilStateDrawIndexedDrawIndexedInstanced(最后两个为Draw Call)等等方法(可以统称为命令)像往常一样进行渲染调用;

所有的渲染调用都结束后,接着调用ID3D11DeviceContext::FinishCommandList方法,得到一个ID3D11CommandList接口;

最终通过即时设备接口(Immediate Device Context)的ID3D11DeviceContext::ExecuteCommandList方法执行这个Command List

当所有的Command ListExecute完成之后,就可以调用IDXGISwapChain::Present方法呈现最终渲染画面了。

其中最(ling)最(ren)吸(jing)引(tan)人的就是Deferred Device ContextID3D11DeviceContext接口可以有多个,并且每一个可以在不同的CPU线程(Windows线程)中分别记录命令(Command),然后提交给Immediate Device Context所对应的CPU线程(Windows线程)进行ExecuteMSDN中称之为Queues commands,这不是巧合!),然后也在同样的线程中调用Present即可。这也就是D3D11多线程渲染的全部核心奥秘了。

当然这不是全貌,只是一个示意性的核心原理说明,但至少说明使用D3D11加入多线程渲染在编程原理和具体实现上其实并不复杂。在D3D11中命令列表中的命令(其实主要是CPU发送给GPU执行的命令)是被快速记录下来,而不是立即执行的(包括那些可怕的被称之为Draw Call的性能杀手,当然一定要记得升级你的显卡驱动程序),直到你调用ExecuteCommandList方法(调用即返回,不等待)才被GPU真正的执行,此时那些使用延迟渲染设备接口的CPU线程以及主渲染线程(不一定是进程的主线程,此处是指调用Immediate Device Context::ExecuteCommandList方法的线程)又可以去干别的事情了,比如继续下一帧的输入变换、碰撞检测、物理变换、动画矩阵调色板准备、光照准备等等,从而为记录形成新的命令列表做准备。而此时GPU就忙碌的开始执行渲染命令了。这也就是延迟渲染设备名字的真正含义。最终这就形成了CPUGPU同时都在忙碌的高效渲染效果。

而在拥有D3D11多线程渲染之前,CPUGPU的工作就好像两个人打台球一样,一个击球时,另一个只能在旁边观望(CPU线程在Draw Call上等待GPU完成渲染)。如果你对游戏编程了解深刻的话,你就会明白,让任何硬件设备闲置等待另一个设备工作完成都是严重的犯罪(我们不怕费电)!而D3D11引入的多线程渲染,不但让CPUGPU可以同时处于忙碌状态,更让现代多核CPU及多GPU(前提是你要很有钱!)并行执行任务的能力得到根本上的解放。由此也可以看出来多线程渲染也是为解放生产力而生!

3.     D3D12多线程渲染基本原理

而在D3D12中多线程渲染与D3D11中是异曲同工的:

首先在D3D12中利用命令队列(Command Queue 接口:ID3D12CommandQueue)代替了ID3D11DeviceContext接口,更准确的说是代替了Immediate Device ContextID3D11DeviceContext接口(名字差别好大的样子...注意前面的小暗示),在D3D12中不论什么队列(Queues)都需要自己创建,而不是像D3D11Immediate Device Context伴随ID3D11Device一同被创建。

D3D12Command Queue被进一步细分为D3D12_COMMAND_LIST_TYPE_DIRECT(直接命令队列), D3D12_COMMAND_LIST_TYPE_BUNDLE(捆绑包), D3D12_COMMAND_LIST_TYPE_COMPUTE(计算命令队列),D3D12_COMMAND_LIST_TYPE_COPY(复制命令队列), D3D12_COMMAND_LIST_TYPE_VIDEO_DECODE(视频解码命令队列), D3D12_COMMAND_LIST_TYPE_VIDEO_PROCESS(视频处理命令队列)。

不论什么命令队列,它们本质上就是用来执行命令列表的,相当于D3D11中的Immediate Device Context,同样也是调用ExecuteCommandLists方法来执行命令列表。因此从其丰富的种类就可以感受到D3D12中命令队列本身就已经开始大大扩展了。

D3D12中也有与D3D11中相类似的命令列表的概念,具体是用ID3D12GraphicsCommandList接口来表达,它就相当于D3D11中的Deferred Device Context。当然其内涵比在D3D11中要丰富的多,在D3D11中记录命令列表是由Deferred Device Context代俎越庖的,也就是我们按逻辑调用一堆ID3D11DeviceContext的方法,结束的时候使用FinishCommandList方法得到一个命令队列的接口ID3D11CommandList,而这个接口几乎没什么方法,仅仅是个“概念标志物”,或者直白的说就是仅仅代表GPU上的一个命令队列而已,其自身并没有什么方法可供调用。而在D3D12ID3D12GraphicsCommandList却包含了几乎所有的可供放入命令队列中的命令方法(几乎全部是渲染相关的方法),并且在D3D12中命令队列本身是被创建的,使用的是ID3D12Device::CreateCommandList方法,有了这个接口以后你就可以在对应的其它CPU线程中按照渲染逻辑调用ID3D12GraphicsCommandList接口的方法生成一个命令列表(Command List)了,通常这个过程被称作“记录(或录制)”一个命令列表。同样录制只是说记录了你调用的顺序和使用的资源(CPU从内存传入显存),而不是立即执行这些方法,这些方法都会快速返回,因为是CPU调用这些方法,这个过程可以想象为你到餐馆去点菜,并不是你点一个菜,就做一个菜上一个菜再点下一个,而是生成一个菜单(Command Lists),统一提交到厨房(Queues Commands & Execute)。对应于不同的命令队列,命令列表也分为很多种类,基本上就是有多少种命令队列,就有多少种命令列表。

最后在命令列表(Command Lists)记录完成后,与D3D11中相同,提交到对应的命令队列(Command Queues)上去执行(ExecuteCommandLists)即可。

在命令队列执行命令列表的对应关系上,D3D12中基本的原则就是直接命令队列几乎可以执行所有种类的命令列表,而其它的命令队列只能执行对应种类的命令列表,如复制命令队列原则上只执行复制命令列表。

最终当所有的命令列表都执行结束后,主渲染线程(这时往往指的就是运行直接命令队列的CPU线程了)再调用Present方法将最终画面呈现出来即可。

需要注意的就是在D3D12中最终的Execute Command Lists操作也是立即返回,此时CPU线程可以进行其它的操作,而GPU就同时忙着执行各种渲染命令了,只有到所有都结束后才调用Present。当然这很好理解,在所有渲染没有完成之前,后台缓冲区里还不是我们想要的最终画面。

由此可以看出,至少从原理上来说D3D12D3D11多线程渲染框架基本是一致的。都是通过在不同的CPU线程中录制命令列表(Command Lists),最后再统一执行(Execute)的方式完成多线程渲染。并且都从根本上屏蔽了令人发指的Draw Call同步调用,而改为CPUGPU完全异步执行的方式(并行!),从而在整体渲染效率和性能上获得巨大的提升。

当然如果你看到这里就觉得原来D3D12D3D11在多线程渲染上没多大区别,或者D3D12相对于D3D11没多大改进的话,甚至认为我说D3D12是一个显卡操作系统有些太夸张了的话,那么我只能说你图样图森破了!当然你能理解到这一步,也已经非常不错了,至少说明你不但明白了D3D11多线程渲染是怎么回事,同时对D3D12的多线程渲染框架也有了一个初步的认识。

接下来就让我们更详细的了解下D3D12多线程渲染的一些更深入的内容(注意还不是细节,本文中我不打算写过多细节,那样这篇文章会被写成一本书的,考虑到我还要养家糊口,所以你懂的!)

4.     多线程的一些基础知识和问题

4.1.    并发和并行

如果你对多线程编程理解比较深刻的话,那么首先第一个要搞明白的概念就是“并发”和“并行”的区别,并发很多时候指的是在一个时间段内,共同执行的任务数。比如1秒钟之内轮流执行了50个左右的线程;再比如说一个网站的并发访问峰值是1秒钟10000次,这些任务中的某几个可能在某一时刻内是同时执行的也可能是在某个时间段内是先后执行的,但总的在一个较大的时间颗粒度比如刚才说的1秒钟之内看来是同时执行的(对于现代计算机来说1秒钟已经是一个非常长的时间颗粒度了。颤抖吧,孱弱的人类!)。最终并发则被更多的描述的像一个统计值。

而并行则是指任务之间真正的同时执行,比如多核CPU上每个核执行不同的线程,或者CPU执行物理变换,而GPU同时忙着进行画面渲染等等,这些任务在任何一个时刻都是真正的同时执行的。正如之前例子中所说现代计算机中CPUGPU也是可以同时执行任务的。甚至在今天我们在说某某设备的计算性能时,更多的是说它的并行计算性能,比如刚刚发布的TITAN V计算卡的计算能力是15TFlops,则是指它的并行计算能力,还有值得我们骄傲的“曙光一号”超算其计算性能也是指并行计算能力。因此可以说现在是一个并行计算的时代,其重要标志就是严重依赖并行计算能力的人工智能大行其道!

4.2.    并行计算极简史

历史上在没有D3D11多线程渲染的年代里,游戏开发人员也大多都没有什么多线程编程经验,在那时CPU通常只有一个核,甚至也还没有GPU的概念,人们所能做的就是编写一个“死循环”,在里面读取用户输入、接着进行物理碰撞检测、从而进行游戏逻辑计算、更新场景、渲染(很多时候是CPU+GPU渲染),然后进入下一次循环,如此往复。那时我们期盼的就是CPU足够Strong,能够飞快的执行每一项任务,从而使我们的游戏可以更加复杂,或者说画面更加的逼真。

在那个年代里,有些游戏开发人员甚至对并发是非常深恶痛绝的。因为在Windows引入多线程结构的早期年代,其实就是利用多个线程不断的在一个CPU内核上来回切换的并发执行方式,让人们产生多线程执行多任务的“错觉”的(因为毕竟人类的反应速度是很慢的),其中线程的切换时间颗粒度大概是20毫秒,而切换线程上下文的时间也是不容忽视的(往往是系统内核执行时间,更深入的知识希望你读过《Windows核心编程》,对你理解D3D12多线程渲染也是大有帮助的),所以总体上虽然我们获得了一定的多任务的体验,但实质上是牺牲了一些系统性能的(系统内核时间几乎不会执行用户真正需要的任务,纯粹是管理开销,想象一个公司中全部都是领导和各种复杂的办事流程,你就明白我在说什么了)。尤其当系统中线程数量非常可观的时候,Windows就表现的非常卡顿,有时甚至像死机一样。如果你有过这样的感受的话,你一定是记忆犹新的。而游戏开发人员则是利用前面所述的死循环来让自己的游戏线程想尽一切办法独占CPU的,所以他们很不喜欢这种并发带来的所谓多任务体验,他们更追求的是游戏的高效流畅运行。

在今天,多核CPU已经是基本配置的情况下,并发的多任务(多线程)则有可能利用多个不同的CPU内核并行的执行,从而真正的让人们感受到多任务带来的优越性。如果我没有记错的话,第一代可以并行执行多任务(多线程)的CPU(超线程)大约出现在2002年左右,距今已经十多年过去了。

那么到了今天这个并行计算大行其道的时代,作为游戏开发人员的你就不能再对多线程编程或者并行计算置若罔闻了。我们所要做的就是掌握它,征服它,从而更好的在游戏开发中应用它。这也就是学习D3D12多线程渲染所能够带来的最大好处!

对多线程渲染本身来说,它使的我们有能力去渲染更复杂的场景,甚至可以在实时渲染中加入很多光线追踪的渲染效果(并行!),从而使最终画面几乎可以达到电影级的质量(这也是当代游戏画质越来越好的原因之一),这些编程能力的解放想想都令人神往,同时这也是多线程渲染带来的巨大优势!

4.3.    多线程内存管理问题

接着你要明白的概念就是多线程内存管理中常说的“脏读”问题了,举个例子来说,就像你往黑板上写字,还没有写完,其他一些同学就记录了这还没写完的信息作为全部信息,或者你只写了一部分,而另一半黑板还没有擦除,还剩余着上次写的内容,这时其他同学把黑板上所有的信息都抄下来作为了完整的信息。无论哪种情况,我们都可以看出来,其他同学读到的信息都不是真正完整有效的信息,这也是多线程无序访问内存时必然遇到的问题,称之为“脏读”。

当然内存访问冲突的问题不止这么简单,无论是“并发”还是“并行”最终都有可能遇到各种内存无序访问的问题,而这些问题对于需要高效执行的程序尤其是并行程序来说都是致命的错误。或者说多线程带来的首要问题就是内存并发(并行)访问问题(内存管理问题)。在C++中还有可能是一个线程已经释放了某块内存,而另一个线程又去试图访问这块内存,或者CPU向显存提交了某块缓冲,在GPU正式渲染之前,这块显存又被意外释放了,从而造成GPU访问违规的问题。现实中多线程或并行内存访问带来的问题,远比这里描述的要复杂的多,传说中曾有程序员为修正多线程程序中的内存访问冲突问题而最终猝死。当然我也曾经遇到过这样的噩梦。

为了解决多线程访问内存冲突的问题,现代操作系统中首先提供了同步堆(Heap,当然你喜欢酷炫的话可以叫它原子堆)来管理内存的分配,同时堆操作方法提供了线程同步访问的原子操作(也称为线程安全的函数),也就是当一个线程调用一个堆方法时,其它线程只能等待它执行结束,才能继续下一个访问(通常是指分配和释放内存操作)。你可以想象这是只有一个马桶的厕所,不能两个以上的人同时访问,大家只能排队一个个访问。这样就最终保障了内存访问的一致性。同样的D3D12中也借助堆这个概念来解决CPUGPU之间交互访问内存(主要是显存访问)的问题。

当然为了解决多线程访问内存冲突的问题,系统中还准备了很多线程同步对象,比如知名的Event对象、Mutex对象等等。甚至在现代的C++语言标准库中也引入了类似的等价物,来帮助开发人员更好的驾驭多线程编程或者并行编程。

最后还有一个非常可怕的消息,就是过去那些为了单线程渲染而优化设计的无锁的(非同步)的内存池,对象缓冲池,BSP树结构,四叉树等结构,在多线程渲染架构下有可能都会失效了,开发人员可能面临着编写这些对象或结构代码的高效同步版本的问题,以应对多线程访问带来的潜在冲突。给你个小的提示,如果想要高效同步,那么利用Lock-Free算法是个很不错的主意!

5.     D3D12中的内存管理

5.1.    显存堆

D3D12中为了更好的支持多线程渲染,显存管理也主要以堆的形式来管理。当然为了渲染,需要管理的内存就不仅仅是系统内存这么简单了,其中还包括GPU上的显存(对于共享内存的核显来说被当做显存的那段内存是无法再当做内存来用的)。对于系统内存来说,Windows系统已经有了完整函数族来管理了,没必要在D3D12中再另起炉灶了。因此D3D12中的堆就主要是显存堆了,记住这一点特别重要不然你会被D3D12那冒着浓浓的C语言味道的代码给熏晕的。(此处描述的堆是内存管理的堆,而不是简单的数据结构中的堆栈,不要搞混。)

Windows堆来类比D3D12的堆的话,D3D12的堆在种类上要丰富的多,这是因为对于GPU渲染来说,不同资源(顶点、索引、常量、纹理、渲染目标等)的显存之间的访问方式和处理方式是大相径庭的,所以很有必要区别对待,所以显存堆的种类就比CPU堆的丰富。

D3D12中堆主要被分为三大类:一类是资源堆,用于存放各种缓冲、纹理、常量等的堆,使用ID3D12Device::CreateHeap创建;另一类是描述符堆,用于存放各类资源视图(RTVDSVCBVUAV等),使用ID3D12Device::CreateDescriptorHeap方法创建;最后一类就是查询堆,主要用于性能分析查询,使用ID3D12Device::CreateQueryHeap创建。

说到这里很多人可能就疑问重重了,说好的是跟大家讲D3D12的多线程渲染的怎么讲了这么多堆概念呢?请大家稍安勿躁,听我慢慢说来,因为D3D12多线程并不是D3D11的升级那么简单,正如我说的,它实际是一个“显卡操作系统”,所以要了解它的核心理念,就需要多一些概念做铺垫。

比之D3D11来说,D3D12中是将显存管理的概念单独以堆的形式独立了出来。其实这主要是为了方便在极其复杂的多趟渲染过程中的不同线程之间,或者是不同的命令队列之间在并行执行的时候,尤其是在CPU线程与GPU线程之间并行(异步)执行任务时,高效安全的管理显存。在D3D11中,这些工作都是隐藏在一个个接口方法之内的,因为显存管理上的固化,其实反倒限制了多线程渲染的自由灵活性,同时还带来了显存重复利用上的限制,因为你根本无法控制那些在显存中的资源的生命周期,有时不得不重复的向显存中提交各种资源。因此D3D12中干脆加入了单独的显存堆管理接口及方法,让显存管理从幕后走到台前,从而为开发人员带来了极大的灵活性。

5.2.    显存的虚拟化管理(tile

当然在D3D12中还加入了强大的针对显存的虚拟内存管理的概念,被称之为tiletiled resources。如果你熟悉Windows的内存管理的话,应该立刻就能明白,在Windows中其实也有虚拟内存管理,主要用于大规模内存管理(以MBGB为单位),而只需要少许的真实物理内存来支持(若干个物理内存页,每页4KB大小)。而D3D12中的tile概念跟这个是异曲同工,由此可以看出tile也是主要解决大规模内存管理之用,当然它对于多线程渲染的助益有限(它的主要目的是管理大规模内存的,因为现在的很多精细纹理动辄可以上几百兆),此文中我就不再过多讲述了,回头另写文章详解之。

6.     CPU线程和GPU线程的区别

另外我们还需要深刻的理解的一个概念就是CPU线程和GPU线程的区别。

6.1.   CPU线程

CPU线程在Windows操作系统中更多的是指一个存储了几乎所有CPU寄存器状态以及堆栈等资源信息的内核对象(可能还有内核安全信息等),是一个复杂的重量级的对象,并且在Windows中线程是最小的执行单元。同时得益于CPU单核运算能力的强大,一个CPU线程就可以运行很多复杂的任务。

从数量上来说,因为CPU内核加之所谓的多线程技术所能提供的真正线程并行执行数量是少的可怜的,就算服务器CPU其内核数也就是那么几十个而已,而更多的线程几乎都是靠“并发”执行的,一句话概括之就是“你方唱罢我登场,城头变幻大王旗”的形式。

6.2.   GPU线程

对于GPU来说,它上面可以执行的线程数量就非常可观了,得益于现代GPU先进的架构,随随便便一个入门级显卡上都会有近几百个被称之为“流处理器”的计算单元,而这些计算单元你可以看做是精简了条件分支指令、系统指令等高级扩展指令后只剩向量指令和一些简单控制指令的的简版CPU内核,当然它也是简约而不简单。在有些高端显卡上,流处理器的数量甚至可以达到几千个,在可以预见的未来,几万个“流处理器”被集成在一个GPU核上也是完全有可能的(截止我写这篇文章时Nvidia刚刚发布了具有5120个流处理器的TITAN V计算卡)。因此在GPU上常常可以启动成千上万个线程(我就曾用DirectCompute在我的GPU上一次启动了一千万个线程)。

相较于CPU线程来说GPU线程就轻量级的多了,它几乎没多少状态需要存储,更不需要管理复杂的中断向量列表,GPU压根也没这能力,它的核就是为进行纯粹的向量计算而生。并且GPU线程的状态往往是分组统一存储的,也就是说可能几千几万个GPU线程使用的是同一个线程状态对象,这样对于独立的一个GPU线程来说,它自己只需要维护一组简单的寄存器状态(可能仅为函数调用的栈帧)。同时处于一个相同分组中的线程往往也是执行相同的一组任务(相同的GPU代码),只是各自所要处理的数据有所不同而已,这也是SIMD架构处理器的典型特征。因此我们往往也将一组GPU线程看成一个对象,而不需要太去区别对待,更多的时候我们可能是从一组线程处理的数据来感知它们,比如一段Vertex Shader代码可能就是处理一个模型的几万个顶点。此处提示你可以形象的将GPU线程想象成一群由一个蚁后带领的一窝蚂蚁,因为数量的众多,虽然每一只可能很弱小,但是集中起来之后就有可能搬走一只大象了。

因此GPU线程的数量级更是可以达到CPU线程的成千上万倍。由此我们可以想象一下如果还按照管理CPU线程的方法来管理GPU线程的话,其工作难度将是难以想象的。这就好比在一个公司中如果将CPU理解为老板,而将GPU理解为公司的员工的话,当员工数量达到一定规模后,管理形式就要发生质的变化了,比如公司员工数上了几百人的话,再让老板一个个员工单独管理的话,不要说普通人,就是超人也要歇菜了。此时我们常常采用的方式就是将员工组织成若干个部门,每个部门指定一名经理,老板只需要指挥几个部门经理即可。对于D3D12来说,概念也是类似的,就是我们利用多个CPU线程(部门经理)来带领不同部门的员工(GPU线程集群,或者直接理解为前面提到的命令队列对象)执行不同的任务,同时CPU线程(部门经理)只是开会(录制)告诉部门员工(GPU)去执行某个项目(命令列表)中的某几个任务(命令),然后部门经理就可以去干别的活计,而部门员工就开始勤奋的执行这批任务。因此我们在说CPUGPU同步时,更多的是说CPU线程同某个GPU之间的同步,或者更确切的说是CPU线程和某几个命令队列之间的同步,而不是说CPU线程和具体的GPU线程之间的同步,我们只需要将命令整体发送给GPU即可,至于具体的GPU上启动了多少线程,哪些线程执行哪个命令或者哪些线程执行到了哪条命令等等信息,在GPU线程颗粒度上是不明确的。或者换一种说法,我们是将GPU看做一个整体来对待的。就好像我们看待CPU是部队的指挥官,而GPU就是一只由若干人(几百或几千人)组成的部队一样,指挥官只是给部队不断的下命令,而部队就整体的不断执行命令。

D3D12中一组GPU线程的代表物就是ID3D12CommandQueue接口。

6.3.    CPU线程和GPU线程运行的动态描述

更具体的我们举例说明一下,比如现在我们需要渲染一个带纹理的正方体,正像好多D3D程序入门示例上显示的那样,为了完成这个任务,CPU线程就需要先准备方块的顶点信息和索引信息,这通常只需要一个CPU线程一次读入8个顶点和12个索引数据(三角形)及一个纹理即可,读完之后,CPU先对这些数据进行必要的处理,比如碰撞检测、位置变换(实际是准备物体在场景中的位置向量,然后传到GPU),接着这个CPU线程就依次将这些数据(模型数据、MVP、光照、材质、纹理等等)都提交到GPU,然后调用著名的Draw Call即可。接着GPU如何工作呢?此时GPU就会启动(实际更准确的说法应该是在调用了ExecuteCommandLists的情况下)至少8GPU线程来分别为每个顶点数据执行Vertex Shader脚本完成几何变换,然后启动不少于12GPU线程来进行光栅化(实际数量要远多于12个)得到片元,然后每个线程再为片元上的每个像素执行Pixel Shader脚本进行着色,最终像素显示到屏幕上就完成了渲染。在过去很多教程资料中讲解GPU具体渲染时,顶多只是说这些处理是并行的而已,而在这里我则明确告诉各位实际就是这么多GPU线程同时在并行的执行,以便大家建立形象思维,从而彻底理解神秘的GPU及渲染管线等等的执行时状态。

6.4.    CPUGPU管理显存

由于GPU流处理器的简单性,它们甚至往往是分成若干组公用一个简单的显存管理器(较之CPU的内存管理器来说的简单,但其性能和带宽是远远超过CPU的内存管理器的),所以D3D12中的堆内存管理,其实只是CPU替某个或某几个GPU管理显存的分配的(稍后还要讲多GPU系统)。也就是我们利用CPU线程显式调用D3D12的堆接口方法来实现。这样在多个命令队列及多个CPU线程之间,就避免了因显存生命周期不一致(主要是分配和释放)而导致的各种访问违规问题。

7.     D3D12命令列表与命令分配器

D3D12中最后一个比较重要的与多线程内存管理有关的概念就是命令分配器了。因为如前所述我们都是使用一个CPU线程+一个命令列表的形式来调用,我们知道命令列表实际最终需要在GPU上执行,所以最终命令列表实际上是需要记录在GPU的显存中的(类似CPU内存中的代码段的概念),这样就形成了CPU写入显存GPU最终从显存中读取的情况,而CPUGPU事先(至少在你写代码前)是不知道需要执行多少命令的,因此这是一个“动态”的过程,为此D3D12就提供了一个ID3D12CommandAllocator接口来作为命令列表的内存分配器,称之为命令分配器。因此我们就需要先创建一个命令分配器(ID3D12Device::CreateCommandAllocator),然后在创建命令列表(ID3D12Device::CreateCommandList)时指定具体的命令分配器即可。当然与命令队列和命令列表分类相对应,命令分配器也被分为各种不同类型。

8.     Draw Call的原罪

在拥有D3D11的多线程渲染以前,之前说的那个Draw Call命令是一个严重的性能陷阱,甚至成为了很多公司面试游戏开发人员的面试题。因为CPU线程在调用Draw Call的时候GPU的工作才真正启动并执行,而直到GPU所有的线程都执行结束后CPU线程的Draw Call函数(DrawIndexedDrawInstancedDrawIndexedInstanced等)才返回,这期间CPU线程几乎就是干等着的,也就是切换出了当前执行环境,变成等待状态。

这种情形让我想起了历史上那个ReadFileWriteFile操作,以及阻塞式SOCKET通讯中的SentRecv调用,它们与Draw Call一样都有一个共同的特征,即在同步调用的情况下都需要CPU线程等待执行结果的返回,对于写惯了顺序执行代码的程序员来说,这没什么毛病,但是对于程序最终的性能来说它们都是具有极大杀伤力的,如果你习惯于用Profile分析这些程序的性能的时候,往往都会看到这些程序主要的性能瓶颈就在这些IO函数、以及Draw Call上。对于传统的IO操作,Windows系统很早就提供了改进性能的措施,比如重叠IOIOCP线程池等等(这些东西的相关内容在我的博客中其它文章中都有详细介绍,欢迎阅读),说白了这些方法的一个本质特征就是让这些调用立即返回,比如重叠IO情况下,WriteFile函数调用就立即返回,此时CPU线程就可以去执行别的任务,而系统IO设备则去真正执行WriteFile要求的写入硬盘的操作(可能是通过系统内核线程或者直接利用DMA机制等等),当真正的写入结束的时候内核就设置一个提前预置的Event对象为有信号状态,或者唤醒一个处于可警告状态的线程执行IO完成操作等。在这里对于Draw Call来说,也是一样的机制,就是在异步执行的情况下调用Draw Call立即返回,不执行任何绘制动作,甚至是简单的记录到Command Lists中,直到Execute Command List的时候才去执行,而这个执行则是GPU去执行,CPU线程则在调用Execute Command Lists时也立即返回了。这样CPUGPU就可以同时并行的执行各自的任务了。这样就从根本上解决了Draw Call等待,从而导致CPU线程性能低下的问题。

对于一个复杂场景的渲染来说,这个等待时常(时长)是无法容忍的,因为这时CPU完全可以去处理输入、音效、网络、物理变换等等。这也是历史上Draw Call被称之为同步调用的全部意义。当然现在D3D11D3D12的多线程渲染框架中,这已经通过命令队列先记录后异步执行的方式彻底解决了。

9.     命令列表及命令的原生并行性

至此如果你还没有看晕的话,或者说你已经明白了前面的这些概念铺垫之后,或许心中还有一个疑问就是为什么说可以用多个命令列表来记录可能不同的命令,最后再来执行,这样不同的命令队列之间会不会冲突呢(更直白的说不通的命令列表直接会不会有什么先后关系的约束从而使得异步执行这种方式失去了意义)?举例来形象的说明,比如我们用一个CPU线程+一个命令队列绘制了一个正方体,而用另一个CPU线程+另一个命令队列绘制了一个球体,最后我们将这两个命令队列都提交给了同一个Immediate Context来执行,那么如果在一些特定角度下正方体或球体有遮挡关系时,怎么保证命令队列之间以及命令队列中各命令之间的正确先后执行关系呢?好的,如果你明白了这个问题,并真的想到了这个问题,那么我只能说你对3D编程的本质或者说程序之所以能并行执行的本质原因还没有搞清楚。好吧,仁慈的我就为你解答一下吧。其实这正是因为我们进行3D渲染时,每个最细粒度的数据单位比如正方体的每个顶点、球体的每个顶点、以及他们光栅化之后的每个像素等等这些数据天生都是满足并行计算条件的,即每个输入数据集之间及输出数据集之间或者二者之间都是没有任何交集的。直白的说就是这些数据的每一个都可以独立计算而不依赖于任何一个其它的同类或异类数据。

当然光栅化之后的片元之间是有一些重叠覆盖关系的,但是那是在输出混合阶段通过Z-Buffer算法解决掉了,最终屏幕上的一个像素点就只对应一个颜色,最终所有的像素颜色都着色完之后就是我们看到的3D场景的2D屏幕投影的结果了。因此无论你是先画正方体还是先画球体,对于一帧画面来说最终结果都将是一样的。

让我们再设想另一种情况,就是两个不同的命令队列访问同一个资源的情况,或者更具体的说如前的例子一个画正方体的线程+命令队列和另一个画球体的线程+命令队列都访问同一个纹理来包裹这俩货会不会有问题?我们果断的说——不会有任何问题,因为这个纹理对任何命令队列来说都是只读的,只要我们传到了GPU的显存中,不论那个GPU线程都只是读(Sample)这个纹理上的某个像素的值,而没谁是需要改变它的(或者说是没有写入操作),所以这也不会造成任何问题。

同样这也是将很多提交到GPU的缓冲显式的设置为只读或常量的意义。对于任何多线程(CPU多线程或GPU多线程)来说,同时只读某块内存(显存)是没有任何问题的。而麻烦在于写入,正如这里说的,对于写入渲染目标来说,聪明的GPU想出了Z-Buffer缓冲算法(应当是聪明的人类发明的算法)来规避了潜在的“脏读”问题(好吧,垂直同步某种意义上来说也是为了避免“脏读”问题,我不想在多解释了)。

10. CPUGPU之间的同步(围栏)

接下来我们就继续我们的想象,因为我想对于每一个严谨到近乎苛刻的游戏开发人员来说,前面的内容有很多“戏说”的成分在里面,当然为了搞清楚整体框架概念,我只能暂时如此,而不过度拘泥于细节,因为我的目标就是为了让大家对D3D12的多线程渲染先建立一个整体概念性的认识。

接下来如果你通过了前面内容的重重轰炸,而安全的看到了这里的话,那么恭喜你,如果你都看明白了,也请为自己点一个赞先!

那么我们继续探讨的下一个话题自然就是CPUGPU之间最终如何同步了。我想你应该已经想到了,既然Draw Call变成异步了,ExecuteCommandLists也是异步的,那么CPU线程最终如何确定当前帧画面已经绘制完了?或者说如何判定究竟该什么时候来调用Present呢?

这时就需要在D3D11D3D12多线程渲染中的“围栏(Fence)”这个概念来帮忙了。围栏说白了其实就是一个同步对象,只不过它是用来同步CPU线程和GPU线程的。至于它名字的来历的话,我想可能是因为GPU线程太多,就像草原上的羊群一样,为了方便管理我们需要一个围栏把它们圈起来(果真如此,那么不得不佩服微软开发人员的想象力,哈哈)。它的基本原理就是为GPU线程的执行设定一个目标围栏目标值(UINT),接着为这个值再设置一个CPU事件句柄(Windows Event内核同步对象,期初是无信号状态),然后GPU线程就分头去执行自己的任务,而此时CPU可以在这个Event上等待,直到所有GPU线程都到达这个目标值(具体的说也就是命令队列中的某个位置,通常我们也就是在绘制结束的时候设置一个值,以方便我们知道命令队列中的命令都执行结束了)时,就执行CPU一开始安排的命令ID3D12CommandQueue::Signal将目标值设置为Event对象对应的值,这时GPU线程就会使得Event对象变成有信号的状态,接下来在这个EventWaitCPU线程就被立即唤醒,通常接着就可以执行Present,此时CPU线程就被唤醒,而GPU线程可以继续执行后续的命令,或者是已经执行完了命令而变成空置等待状态,准备进行下一轮命令的执行。

当然这里需要提示的就是,如果是为了真正的提高性能,我们不应让CPU线程在一个Event上只是简单的使用INFINITEWait,实际在引擎中使用时,应当在游戏循环中(也可能是多个CPU线程中的循环)使用0值来Wait,这时Wait立即返回,相当于轮询下Event的状态,接着就去执行别的操作了。这样才能真正的发挥多线程渲染的高性能。

11. GPU线程间的同步(资源屏障)

讨论完了CPU线程和GPU线程之间的同步之后,我们来看看GPU线程之间如何去同步的问题。首先让我们来想象一下这样一个场景,在之前的讨论中我说过,命令队列分为很多种类,其中有一类是复制命令队列,而另一类是直接命令队列。现实中我们常常使用复制命令队列来将各种资源(纹理等)从上传堆(一种GPU显存堆,CPU写入GPU读取,回忆一下D3D11中的Dynamic类型的缓冲区)复制到默认堆(一种GPU显存堆,CPU不能访问,而GPU只读,性能很高,回忆下D3D11中的D3D11_USAGE_DEFAULT缓冲区),然后直接命令队列就可以执行命令操作这些资源。这时有一个很明显的问题就是怎样知道复制命令队列已经将某个资源完整的复制完了,而直接命令队列可以读取操作了呢?这并不能简单的通过先调用复制命令队列的ExecuteCommandLists方法后调用直接命令队列的ExecuteCommandLists方法来保证。因为他们都是异步的,执行的先后顺序是没法根本保障的。这样最终的执行结果是无法预期的,也可能直接命令队列先执行完了,而复制命令队列才去执行,这样我们也许什么画面也看不到,因为纹理采样的结果可能完全就是黑的。或者二者同时执行,有可能发生“脏读”问题。

因此在D3D12中又专门提供了称之为资源屏障(resource barrier)的对象来提供不同GPU线程之间的同步控制机制。具体的做法就是利用ID3D12GraphicsCommandList::ResourceBarrier方法通过设置权限转换标志的方式,来进行不同GPU线程间访问资源的同步。之所以要设置权限转换,主要是因为不同GPU线程(不同种类的命令队列、命令列表)对每种资源的访问要求是不一样的,比如对于复制队列来说复制源只需要只读的权限,而复制目标只需要有写入的权限,而对于直接命令队列来说复制队列的复制目标可能就是它需要读取的一个纹理资源,只需要有只读权限即可,因为只有明确了只读权限之后对于直接命令队列的GPU线程中的那些“小蚂蚁”来说,才能够以最高的效率来访问。这样在GPU线程内部要进行权限的转换,就必须要之前的那些个“小蚂蚁”都完成了自己的工作,比如复制队列复制完成了一整幅纹理的复制工作之后权限转换才能进行,也只有当这个权限转换完成了,后续的命令才能继续执行。这种场景就好像有一道屏障在中间,之前的一队“小蚂蚁”把所有的资源(也许对它们来说是食物)都搬到屏障的一边,完成之后屏障才撤除,而另一队“小蚂蚁”就开始将这堆资源搬至别处。OK,我想这也许就是微软的那堆DX工程师们为资源屏障起名为“屏障”的主要原因吧,再次佩服他们丰富的想象力!

12. 多趟渲染与多线程渲染

接下来让我们更进一步,再来设想一个更复杂的场景,还是之前那个一个线程(CPU线程)+命令列表(GPU线程)渲染一个正方体,另一个线程+命令列表渲染一个球体的例子,当然我们还要加入一个线程+命令队列渲染一个平面,而球体和正方体都放在这个平面上(看起来有点像素描,原谅我很懒没有配任何图像),只不过在这里我们加入了阴影效果(求此时你看懂这个问题时的心理阴影面积!)。如果你是一个资深的3D开发人员的话,至少知道这个地方需要的就是我们通常所说的多趟渲染(Pass,第一趟我们需要关掉渲染目标,只记录深度缓冲和模板值,第二趟我们打开渲染目标,根据之前记录的深度缓冲和模板值来渲染整个场景,这样我们就可以得到带有阴影效果的场景值。

正如刚才所说,这三个命令队列是并行记录的,然后统一提交执行的,那么问题来了,如何保证这些命令队列之间的先后顺序呢?或者说怎样保证三个独立命令队列都先进行第一趟渲染得到阴影图,再去执行第二趟中带阴影的渲染呢?希望我将这个问题描述清楚了,当然更希望聪明的你也已经从我蹩脚的文字描述中理解了这个问题(就是在你的脑子中已经完成了类似GPU线程所做的渲染工作,并得到了最终图像)。

乍看起来,这个场景好像本身是没法多线程渲染的,复杂的遮挡关系就打破了数据并行计算的条件,因为计算某一个物体上一点处的像素颜色时,可能就需要知道有没有其它物体遮挡了它,从而使它在另一物体的阴影中,而这个遮挡它的物体完全可能在另一个线程中渲染。

要解决这个问题其实我们需要做的仅仅是将这个多趟渲染对应到多个命令列表上去(此例中是两趟渲染,two pass),在我们这个例子场景中,我们就为每个CPU线程设定两个命令列表,每一个CPU线程的第一个命令列表记录第一趟渲染阴影图的命令,并设置一个围栏值,然后先提交到对应的命令队列(GPU线程)上执行一下, CPU的主渲染线程就在这个围栏值上等待(因为是多个线程在渲染不同物体的阴影,所以需要同步等待多个围栏值的Event,希望你知道还有个WaitForMultipleObjects),然后各CPU线程再利用第二个命令列表录制第二趟渲染得到带阴影的整个场景图。当然在启动每个线程的第二趟渲染的命令列表之前CPU的主渲染线程需要等待所有的GPU线程(命令队列)都执行完了第一趟的渲染,再启动代表第二个趟渲染的命令列表的执行,这样就最终确保了所有的阴影图都正确的渲染显示出来。

这样也就是说多趟渲染也完全适用于多线程渲染框架,无非就是在第一趟渲染命令记录结束时,插入一个“围栏”,然后录制第二趟的命令列表,最终在执行时,CPU线程先命令GPU执行第一趟的命令列表,然后在围栏上等待,当所有的GPU线程说第一趟的命令列表执行完毕了(Signal),CPU线程再命令他们开始执行第二趟的命令列表。

13. 酷炫的多线程多显卡渲染

我想如果你能够耐心的看到这里,并且大体明白了我所介绍的所有截止此处之前的所有D3D12多线程渲染的内容的话,那么请思考一下D3D12多线程渲染针对D3D11多线程渲染所扩展的内容究竟有些什么让我们惊奇的地方?其实细想过来并不多,无非就是增加了显存管理、丰富的命令队列(GPU线程)、以及围栏和资源屏障这类同步对象(其实D3D11中也有简化的对等物)。当然如果你都弄明白了的话,其实这并不令人惊叹,说白了这一切都是为了让那个令人发指的Draw Call命令变成异步的而已!

当然D3D12带来的改变不仅仅是这么简单而已,它最最让人可以惊叹一下的就是可以通过纯软件编程的方式来操作多个不同的独立GPU协同工作,也可以称之为多显卡渲染(至少我是这么命名这种能力的),甚至你都可以不需要STL排线或CrossFire排线了,也可以操控多个GPU(显卡),或者甚至是不同结构、不同GPU核的多个显卡。我觉得这种能力才是彻底的解放了硬件的生产力。

因为就拿我写这系列文章的笔记本电脑来说,它其中就有一个独立的GTX965M显卡和一个集成在CPU之内的HD530显卡。在拥有D3D12多显卡渲染之前,这俩货在我的电脑里同时只能有一个在工作,最常见的情况就是我在用Word写文档的时候,是HD530在工作,而GTX965M则休眠,而我在进行游戏的时候,则是GTX965M在工作,而HD530则处于休眠状态。虽然就渲染能力上来说HD530甚至连入门级显卡都比不上,但终究它还是能够执行Shader进行Shader Model5.0级别的渲染的。而遗憾的就是它们都只能各自独立的工作。

自从我安装了最新版的Windows10系统并升级了DirectX12运行时库,及对应的Windows SDK后,我就可以通过编程的方式让这两款显卡同时工作了。而让我感到非常兴奋的是,其中一块来自Nvidia,而另一块则来自Intel的集成显卡。这样的情况在拥有D3D12多显卡渲染以前是无论如何都没法轻易做到的。小小的遗憾就是目前还没有多少支持D3D12多显卡渲染的游戏,让我来切身感受一下这种多显卡加速所带来的效果增强。当然对于如我这样的程序员来说,这都不是事!自己写程序看效果就行了!正所谓“自己动手丰衣足食”!当然我想在我写完了这系列文章之后,D3D12多显卡多线程渲染的游戏应该会如同雨后春笋般诞生了!那时的情形用一句话来形容就是“待到山花烂漫时,它在丛中笑。”,当然但愿彼时我已经去研究和普及D3D13以及D3D14了!

从现实的编程角度来说,如果你认为在D3D12以前其实也可以利用在不同的显卡适配器上创建不同的Device接口,然后再利用多个Device分别渲染来实现的话,我只能提醒你注意一个问题,你的渲染目标究竟应该在哪个设备上呢?或者多个渲染目标(以及对应的深度缓冲和蜡板(Stencil他们叫模板,而我更喜欢叫蜡板,因为在我上小学初中的时候,老师们都是用蜡板刻卷子,并用油墨来印刷的,原理上跟我们使用的Stencil很相似的,并且Stencil本身也有蜡板的意思))应该如何去“合并”成最终的结果呢?所以没有D3D12之前,要做到多显卡渲染是没那么容易的。

D3D12中,最终通过共享句柄的办法最终解决了多显卡之间的资源共享问题,通过复制命令队列的方式启用独立的GPU线程完成在不同显卡之间的资源复制的工作,另外可以通过ID3D12Device::CheckFeatureSupport方法返回的

D3D12_FEATURE_DATA_D3D12_OPTIONS. CrossAdapterRowMajorTextureSupported字段来判定显卡是否支持交叉型资源,其实就是一个资源横跨多个显卡之间的显存。这种能力本身就是为多显卡协同工作而生。因为现代的计算机体系结构都是冯诺依曼体系架构的,核心就是围绕内存(存储)的系统架构,因此如果能够共用内存(存储)则充分说明两个系统间可以进行几乎无缝的协同工作。

同时在D3D12中还有很多结构体都具有NodeMask参数,如果你懂的CPU线程的亲缘性的话,这个参数是很好理解的,它是一个32位的标志值,在一个有多个显卡的系统中,每一位对应一个序号的显卡,比如第一位1b(二进制)代表第一块显卡,10b(二进制)代表第二块显卡,100b(二进制)代表第三块显卡等,当然你可以组合11b表示同时使用第一第二块显卡。而在单显卡系统中只需要为此字段设置0值即可。

当然D3D12中的多显卡编程还是具有挑战性的,这里就不再多赘述了,之后的文章中我会更详细的以编程的方式介绍这块内容。最终大家只要知道一旦渲染目标可以得到统一,那么实际上多显卡支持就变成了可能,剩下的就是看你如何具体编程来控制多块显卡以何种方式协同工作了(可能的方式有每个显卡负责渲染一帧,交替进行,或者一个显卡负责资源复制、另一个负责渲染、或者一个负责执行计算命令队列和复制,另一个负责渲染等等),总之D3D12在多显卡协同渲染方面给了我们无限的可能!

14. 展望下一代游戏引擎架构

时间马上就要到2018年了,在搞明白了D3D12中的多线程多显卡渲染架构之后,我是非常激动的。展望未来,我们可以想象将会有这样一款高性能高画质的实时渲染引擎——它拥有异构多显卡、多线程渲染支持,并且充分利用了系统提供的线程池,以及各种轻量级锁或Lock-Free原子锁,它几乎让系统中所有的硬件(除了CPUGPU外还包括输入设备、声卡、网卡、硬盘、显示器等等)都在以最高性能的方式并行协同工作,为HDR及高分辨率显示器或者VRAR设备提供着实时电影级镜头画质的逼真画面,人们完全沉浸在美好的幻想和现实超统一的世界中进行游戏,甚至进行着教学、学习、工作、设计、交流等等活动,获得着普通现实很难给予的满足感、成就感、幸福感、愉悦感等等,从而让灵魂得到前所未有的升华!我们甚至可以想象,在人类还没有真正登上火星之前,你已经可以通过VR设备真实体验火星的环境;在人类无法登陆的太阳表面,通过VR超近距离研究太阳黑子活动......

而这一切都需要每一个游戏引擎开发人员继续不懈的努力!

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引