1. 命令列表及命令的原生并行性
至此如果你还没有看晕的话,或者说你已经明白了前面的这些概念铺垫之后,或许心中还有一个疑问就是为什么说可以用多个命令列表来记录可能不同的命令,最后再来执行,这样不同的命令队列之间会不会冲突呢(更直白的说不通的命令列表直接会不会有什么先后关系的约束从而使得异步执行这种方式失去了意义)?举例来形象的说明,比如我们用一个CPU线程+一个命令队列绘制了一个正方体,而用另一个CPU线程+另一个命令队列绘制了一个球体,最后我们将这两个命令队列都提交给了同一个Immediate Context来执行,那么如果在一些特定角度下正方体或球体有遮挡关系时,怎么保证命令队列之间以及命令队列中各命令之间的正确先后执行关系呢?好的,如果你明白了这个问题,并真的想到了这个问题,那么我只能说你对3D编程的本质或者说程序之所以能并行执行的本质原因还没有搞清楚。好吧,仁慈的我就为你解答一下吧。其实这正是因为我们进行3D渲染时,每个最细粒度的数据单位比如正方体的每个顶点、球体的每个顶点、以及他们光栅化之后的每个像素等等这些数据天生都是满足并行计算条件的,即每个输入数据集之间及输出数据集之间或者二者之间都是没有任何交集的。直白的说就是这些数据的每一个都可以独立计算而不依赖于任何一个其它的同类或异类数据。
当然光栅化之后的片元之间是有一些重叠覆盖关系的,但是那是在输出混合阶段通过Z-Buffer算法解决掉了,最终屏幕上的一个像素点就只对应一个颜色,最终所有的像素颜色都着色完之后就是我们看到的3D场景的2D屏幕投影的结果了。因此无论你是先画正方体还是先画球体,对于一帧画面来说最终结果都将是一样的。
让我们再设想另一种情况,就是两个不同的命令队列访问同一个资源的情况,或者更具体的说如前的例子一个画正方体的线程+命令队列和另一个画球体的线程+命令队列都访问同一个纹理来包裹这俩货会不会有问题?我们果断的说——不会有任何问题,因为这个纹理对任何命令队列来说都是只读的,只要我们传到了GPU的显存中,不论那个GPU线程都只是读(Sample)这个纹理上的某个像素的值,而没谁是需要改变它的(或者说是没有写入操作),所以这也不会造成任何问题。
同样这也是将很多提交到GPU的缓冲显式的设置为只读或常量的意义。对于任何多线程(CPU多线程或GPU多线程)来说,同时只读某块内存(显存)是没有任何问题的。而麻烦在于写入,正如这里说的,对于写入渲染目标来说,聪明的GPU想出了Z-Buffer缓冲算法(应当是聪明的人类发明的算法)来规避了潜在的“脏读”问题(好吧,垂直同步某种意义上来说也是为了避免“脏读”问题,我不想在多解释了)。
2. CPU与GPU之间的同步(围栏)
接下来我们就继续我们的想象,因为我想对于每一个严谨到近乎苛刻的游戏开发人员来说,前面的内容有很多“戏说”的成分在里面,当然为了搞清楚整体框架概念,我只能暂时如此,而不过度拘泥于细节,因为我的目标就是为了让大家对D3D12的多线程渲染先建立一个整体概念性的认识。
接下来如果你通过了前面内容的重重轰炸,而安全的看到了这里的话,那么恭喜你,如果你都看明白了,也请为自己点一个赞先!
那么我们继续探讨的下一个话题自然就是CPU和GPU之间最终如何同步了。我想你应该已经想到了,既然Draw Call变成异步了,ExecuteCommandLists也是异步的,那么CPU线程最终如何确定当前帧画面已经绘制完了?或者说如何判定究竟该什么时候来调用Present呢?
这时就需要在D3D11和D3D12多线程渲染中的“围栏(Fence)”这个概念来帮忙了。围栏说白了其实就是一个同步对象,只不过它是用来同步CPU线程和GPU线程的。至于它名字的来历的话,我想可能是因为GPU线程太多,就像草原上的羊群一样,为了方便管理我们需要一个围栏把它们圈起来(果真如此,那么不得不佩服微软开发人员的想象力,哈哈)。它的基本原理就是为GPU线程的执行设定一个目标围栏目标值(UINT),接着为这个值再设置一个CPU事件句柄(Windows Event内核同步对象,期初是无信号状态),然后GPU线程就分头去执行自己的任务,而此时CPU可以在这个Event上等待,直到所有GPU线程都到达这个目标值(具体的说也就是命令队列中的某个位置,通常我们也就是在绘制结束的时候设置一个值,以方便我们知道命令队列中的命令都执行结束了)时,就执行CPU一开始安排的命令ID3D12CommandQueue::Signal将目标值设置为Event对象对应的值,这时GPU线程就会使得Event对象变成有信号的状态,接下来在这个Event上Wait的CPU线程就被立即唤醒,通常接着就可以执行Present,此时CPU线程就被唤醒,而GPU线程可以继续执行后续的命令,或者是已经执行完了命令而变成空置等待状态,准备进行下一轮命令的执行。
当然这里需要提示的就是,如果是为了真正的提高性能,我们不应让CPU线程在一个Event上只是简单的使用INFINITE来Wait,实际在引擎中使用时,应当在游戏循环中(也可能是多个CPU线程中的循环)使用0值来Wait,这时Wait立即返回,相当于轮询下Event的状态,接着就去执行别的操作了。这样才能真正的发挥多线程渲染的高性能。
3. 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工程师们为资源屏障起名为“屏障”的主要原因吧,再次佩服他们丰富的想象力!
暂无评论内容