我们知道,实际渲染的过程大部分是在GPU上完成的,CPU只负责发号施令。实际上,数据准备完成后,当你的程序调用了Draw函数后,CPU才会真正的将数据和命令提交到GPU上进行渲染。从命令提交到渲染完成通常需要数十毫秒的时间,甚至对于复杂的程序更是需要数秒的时间才能返回。如果Draw一直等到GPU渲染完成再返回并执行剩下的代码,那显然整个线程的时间都浪费在了等待GPU的结果上。
这个问题或许可以利用多线程编程来解决,但是这也意味着你的程序更加复杂了。所以在D3D中,Draw将命令发送给显卡之后立即返回,你的程序便可以接着做其它工作了,例如新渲染数据的准备、物理、逻辑、AI的计算、场景的优化等等。换句话说,我们称Draw是一个异步调用。
相信对D3D有所了解的人这一机制都已熟记于心。本文的内容,就是讨论这个“异步调用”是如何实现的。具体的内容包括:
- 描述异步调用机制的基本实现方法;
- 梳理用户代码和GPU对资源的操作(Map,Unmap),以及他们之间可能产生的相关性;
- 介绍一种可以保证异步和并行化结果正确的方法;
- 讨论异步调用时错误的处理。
这些内容可以帮助你理解Draw调用的实现原理,另一方面也可以作为你实现其他异步调用API的参考。需要说明的是,本文所述的大部分机制,均是由显卡驱动程序或D3D Runtime实现,但考虑到各家驱动实现不一以及版权和保密协议,本文所提供的方法没有参考任何实际的驱动程序和MS提供的参考代码,而以SALVIA渲染器正在开发中的代码为主要参考。
我们将先引入Producer/Consumer这一经典异步模型作为异步调用实现的基础;其次我们介绍一些保证并发程序正确性的一些常识;再来会介绍我们在Producer/Consumer的基础上所做的异步调用实现,并讨论如何解决CPU和GPU对同一份资源可能存在的访问冲突;在最后两节,我们会讨论跨线程的对象生命周期控制和检查,以及异步调用的错误处理机制。
CPU与GPU的Producer/Consumer模型
在Producer/Consumer模型中,最重要的角色有三个,产生命令和数据的Producer,执行命令和使用数据的Consumer,以及用于在Producer和Consumer之间传递消息的对象,这个对象通常是消息队列(Message Queue)。
我们来看一下CPU和GPU和合作关系。CPU和GPU是两个独立执行的硬件设备,但是GPU的运行都是受到CPU控制的。GPU和CPU最基本的工作模式是:CPU将数据准备好后,提供给GPU,GPU进行计算、渲染并输出。有时候CPU也会从GPU处取得一些数据。可以看出,CPU和GPU是个很典型的生产者/消费者模型。对于实际硬件来说,CPU和GPU的关系可能是多级的Producer/Consumer结构。例如用户代码到驱动是一级,驱动到硬件又是一级。因此,消息队列可能同时存在于软件和硬件中。往往看起来简单的模型,在实践中就是这样复杂起来的。
Draw调用到底做了哪些事情
CPU和GPU的通信主要出现在两个时候:第一,读写资源(Map/Unmap);第二,Draw的调用。这些通信都会变成Driver发给显卡的命令。例如,我们假设COMMAND是个四字节的命令,每个COMMAND最长可以有512个字节的数据;我们要将Buffer传到GPU的某块内存上,那么我们就能把需要传输的数据处理成这样的指令组:
COPYGPU_MEM_ADDRESS DATA_LENGTH DATA
然后通过总线发送给GPU,GPU拿到了指令和数据后,执行单元就会把数据写到显存的相应位置。当然有了DMA的存在,真正的数据拷贝还是比这个要高效的多。
除了往显存中写数据,还要给GPU提供一些状态。比如Vertex Buffer的地址,Index Buffer的地址,Texture的地址和行的Pitch,等等。可千万不要以为GPU中会保存一个ID3D10Buffer的对象,实际上到了GPU后,这些对象都只会变成最最原始的指针、和一些Bit位的开关。它们和对象之间的关系,都是由驱动程序来维护的。包括显存的分配、任务的安排和调度,都是驱动程序的责任。可以说,显卡的驱动程序几乎就是GPU的OS。这些状态,GPU中可以叫State Buffer,也可以叫Context,也可以叫Register File。总之怎么叫,那都是GPU设计公司的喜好了。
除了数据、基本状态,剩下就是有动作的命令。比如Transform、Rasterize、Tessellate、Query,等等。这些命令传送到显卡之后,显卡就真正的开始干活了。
说了这么多废话,总结一下就是:CPU发送给GPU的内容,可以粗浅的分为数据、状态和命令。那么这些内容都是什么时候被传输到GPU上的呢? 再说一句废话:只要数据在修改完毕后、使用之前传输到GPU上就可以了。那如果都开始渲染了,这些内容还没有传送完毕要怎么办呢?那渲染就只能等它们都传输好再开始工作。
为了避免渲染程序等待数据传输,为了减少宝贵的总线带宽,CPU和GPU之间的通讯需要经过一定的优化。对于数据(Constant Buffer,VB/IB,Texture)来说,因为数量多,传输时间也比较长,因此可以在Unmap一结束就将数据提交给GPU;而对于状态和命令而言,数量比较小,可能会遭遇频繁的更改,同时还需要维护彼此间的一致性,因此这部分内容可以延期到非提交不可的时候再传送到GPU上。
所谓非提交不可,就是执行Draw的时候。 Draw是实际执行绘制的函数。到了这里,绘制所需要的全部状态状态和数据都已经齐备,就只差Draw这个东风了。因此当Draw被调用的时候,除非硬件正忙,否则所有的工作没有理由再不进行了。此时就需要将渲染所需要的状态和命令在CPU上统计好,打包发送给硬件。在这一阶段,Draw需要完成很多工作,比如脏属性的检查以减少传输量,比如渲染状态的正确性和一致性检查等等,一般来说GPU命令的生成也可以放在这里完成。
CPU/GPU资源读写相关性分析
在D3D中,异步调用要求和同步调用的结果完全相同。但是因为异步调用的存在,前后函数的执行时间不再是严格的一前一后,而可会发生重叠(也就是并行)或重排(乱序)。这时就需要进行资源相关性的分析,确保并行或重排后的结果,与同步的、顺序执行的结果是一致的。
写到这一段,我内心深处不由得回想起伟大的程序员KULA的教导:“算法就是构造一个数据结构,然后把数据插入到指定的位置。”遵循着文成武德KULA巨巨的教导,我们也可以这么认为:异步调用的正确性分析,就是对数据操作顺序正确性的分析。
来看一下数据相关性分析的理论。流水线级的数据相关性分为四类:读后读(RAR),写后读(RAW),读后写(WAR)和写后写(WAW)。什么意思呢,就是说如果所有的指令都只对同一个数据是读操作,那这些指令随便怎么排序都是正确的;但是如果有写指令,那么写指令前后的读写操作,都不能随意调整位置。
1 2 3 4 5 6 7 8 9 |
|
比如说在上面的代码中,a和b是不相关的两个变量,那么这两个值的操作相互之间没有影响。a和b的赋值谁先谁后,c的结果都没有变化。但是,如果我们把c的计算放在a和b的赋值之前,那么结果就可能会变化。这是因为c的计算中有a和b的读取,如果将a的读取和a的写入对调,那么结果就会和预期的有所不同。所以如果进行并行操作的话,两个赋值语句是可以并行完成的。但是隐含着读取的加法操作,必须在赋值语句(写操作)完成之后方可进行。这是写后读(RAW)的情况。
其它情况也是类似的。 因此不管是读还是写,只要不违反上述对数据相关性的约束,那么它的结果就是正确的。当然对于并行编程而言,如果读写都针对同一个资源,那么还必须保证读或者写的操作是符合读写锁的互斥要求的。
回到D3D10中,我们将D3D10的资源按照读写限制来分,一共有四种:
去掉细节不谈, 所有资源中最简单的当数Immutable,它的数据在初始化时就要确定,确定以后再也不能变动。所以不管Command的调用顺序如何,Immutable资源的数据都是不变的。所以Command的执行顺序,对于Immutable来说没有影响的;Default资源的读写操作局限于GPU内部,所以试图在GPU内部并发执行的命令需要进行的协调;Dynamic的读写横跨CPU和GPU,需要进行同步;Staging的情况最为复杂,但是它有一个限制,就是GPU上不会参与渲染或计算过程,只能用于Copy。
要判断CPU和GPU的命令能否同时或异步执行、GPU命令内部能否同时执行,需要对命令流中前后命令的数据相关性进行考察。比如,CPU先让GPU进行渲染,然后再从GPU中读取一些东西。如果CPU将要读取的数据不是GPU要写的内容,那么CPU让GPU执行渲染后,就可以自顾自的读取数据了;但是如果它读取的内容恰好是GPU要渲染的内容,那CPU就只能等渲染结束才能读取了。甚至在数据相关性不高的时候,GPU还在渲染上一次调用,下一次调用就已经可以进入流水线了。说句题外话,我们这里所说的“Pipeline”和CPU还是有所不同的,流水的每一级都要工作很长时间,而且和下一级的在时间上的重叠度很高。是否需要通过前后渲染调用的重叠提高并行程度,在设计上需要进行取舍。
我们来看一个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
如果我们用表格把代码中命令和资源的关系表达出来就是:
接下就是要如何解决异步编程中两个重要问题:1. 调用次序能不能颠倒;2. 被调用函数和调用方能不能同时执行。解决这两个问题的最基本的办法是拓扑排序。拓扑排序的作用是确定一条命令会对哪些命令产生依赖。如果它依赖的命令都执行完了,那么就可以执行这条命令了。当然在拓扑排序之前,首先要构造一张依赖图。依赖图的顶点是一条Command,边是两个节点间的依赖关系。这一依赖关系可以由命令间的资源相关性得到:
Draw0和Draw1借助命令队列可以实现用户代码一侧的异步调用。但是根据这个图可以知道,Draw0和Draw1到了驱动之后,因为两个调用在Render Target上有一个顺序关系,所以驱动只能先执行Draw0;等执行完了,再执行Draw1。当Draw0和Draw1的异步调用被发起后,可能GPU还没有执行Draw0和Draw1,但是因为Map0是可以立即执行的;而第二个Map1就惨了,因为它要写Draw1用到的Index Buffer,如果Draw1正在画,那就是写冲突,如果Draw1还没画,Map1就把新数据写上了,那Draw1的结果就不是预期的了。所以Map1只能老老实实的等着Draw1绘制完毕。
如果我们用拓扑排序的概念来解释,那就是Draw1是Draw0的后继,所以要等Draw0结束Draw1才能开始执行;Map1和Draw2是Draw1的后继,所以只有Draw1绘制完毕,才能考虑绘制Map1和Draw2。当然因为Draw2又依赖Map1,所以如果这个依赖没有消除的话(就是Map1对Index Buffer的写操作结束),Draw2也没办法正常执行。
不过对所有命令利用资源的读写相关性构造拓扑排序是个比较大的消耗。因此在SALVIA的原型中实现了它的变种:我们建立了一个Command队列。队列中的每个Command都有一个被锁的资源计数;此外还有一个资源-命令队列表,表中每个资源都有一个关联命令队列:当一条Command执行完、或者没有任何Command执行的时候,都会根据Command使用结束的资源,去解除一部分命令的资源锁定。当一条Command所有的资源都不锁定时,Command就可以被执行了。
具体的代码可以参见这里:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
|
在实际的硬件和驱动中,Producer和Consumer自身可能都是串行的;那么此时只需对Producer所使用的