Direct3D Draw函数 异步调用原理解析

Direct3D Draw函数 异步调用原理解析
我们知道实际渲染的过程大部分是在GPU上完成的CPU只负责发号施令。实际上数据准备完成后当你的程序调用了Draw函数后CPU才会真正的将数据和命令提交到GPU上进行渲染。从命令提交到渲染完成通常需要数十毫秒的时间甚至对于复杂的程序更是需要数秒的时间才能返回。如果Draw一直等到GPU渲染完成再返回并执行剩下的代码那显然整个线程的时间都浪费在了等待GPU的结果上。这个问题或许可以利用多线程编程来解决但是这也意味着你的程序更加复杂了。所以在D3D中Draw将命令发送给显卡之后立即返回你的程序便可以接着做其它工作了例如新渲染数据的准备、物理、逻辑、AI的计算、场景的优化等等。换句话说我们称Draw是一个异步调用。相信对D3D有所了解的人这一机制都已熟记于心。本文的内容就是讨论这个“异步调用”是如何实现的。具体的内容包括描述异步调用机制的基本实现方法梳理用户代码和GPU对资源的操作MapUnmap以及他们之间可能产生的相关性介绍一种可以保证异步和并行化结果正确的方法讨论异步调用时错误的处理。这些内容可以帮助你理解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将数据准备好后提供给GPUGPU进行计算、渲染并输出。有时候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然后通过总线发送给GPUGPU拿到了指令和数据后执行单元就会把数据写到显存的相应位置。当然有了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 BufferVB/IBTexture来说因为数量多传输时间也比较长因此可以在Unmap一结束就将数据提交给GPU而对于状态和命令而言数量比较小可能会遭遇频繁的更改同时还需要维护彼此间的一致性因此这部分内容可以延期到非提交不可的时候再传送到GPU上。所谓非提交不可就是执行Draw的时候。 Draw是实际执行绘制的函数。到了这里绘制所需要的全部状态状态和数据都已经齐备就只差Draw这个东风了。因此当Draw被调用的时候除非硬件正忙否则所有的工作没有理由再不进行了。此时就需要将渲染所需要的状态和命令在CPU上统计好打包发送给硬件。在这一阶段Draw需要完成很多工作比如脏属性的检查以减少传输量比如渲染状态的正确性和一致性检查等等一般来说GPU命令的生成也可以放在这里完成。CPU/GPU资源读写相关性分析在D3D中异步调用要求和同步调用的结果完全相同。但是因为异步调用的存在前后函数的执行时间不再是严格的一前一后而可会发生重叠也就是并行或重排乱序。这时就需要进行资源相关性的分析确保并行或重排后的结果与同步的、顺序执行的结果是一致的。写到这一段我内心深处不由得回想起伟大的程序员KULA的教导“算法就是构造一个数据结构然后把数据插入到指定的位置。”遵循着文成武德KULA巨巨的教导我们也可以这么认为异步调用的正确性分析就是对数据操作顺序正确性的分析。来看一下数据相关性分析的理论。流水线级的数据相关性分为四类读后读RAR写后读RAW读后写WAR和写后写WAW。什么意思呢就是说如果所有的指令都只对同一个数据是读操作那这些指令随便怎么排序都是正确的但是如果有写指令那么写指令前后的读写操作都不能随意调整位置。123456789// 基本例子inta 5;intb 3;intc a b;// c 8// 交换a和b的赋值顺序intb 3;inta 5;intc a b;// c 8比如说在上面的代码中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还是有所不同的流水的每一级都要工作很长时间而且和下一级的在时间上的重叠度很高。是否需要通过前后渲染调用的重叠提高并行程度在设计上需要进行取舍。我们来看一个例子123456789101112131415161718192021// Init idxBuffer and idxBuffer2devContext-IASetIndexBuffer(idxBuffer);devContext-Draw();devContext-IASetIndexBuffer(idxBuffer2);devContext-Draw();devContext-Map(idxBuffer2, READ);// Write idxBuffer2devContext-Unmap();devContext-Map(idxBuffer, WRITE);// Write idxBufferdevContext-Unmap();devContext-IASetIndexBuffer(idxBuffer);devContext-Draw();devContext-IASetIndexBuffer(idxBuffer2);devContext-Draw();如果我们用表格把代码中命令和资源的关系表达出来就是接下就是要如何解决异步编程中两个重要问题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就可以被执行了。具体的代码可以参见这里123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199classCommandLock{ResourceAccessType access;uint32_t lockedResourcesCount;};classResourceLock{dequecommandlock* lockedCommandLocks;ResourceAccessType lockingAccess;uint32_t lockingCount;};classQueue{public:voidPushCommand(Command* cmd){{lock mutexLocker(mMutex);mProducerCond.wait(mutexLocker, [this](){return!this-mCommmands.full(); });for(autores: cmd-Resources() ){autoiter mResourceLocks.find(res);if( iter mResourceLocks.end() ){iter mResourceLocks.insert( make_pair(res, AllocateResouceLock()) );}ResourceLock* resLock iter-second;resLock-lockedCommandLocks.push_front( cmd-CommandLock() );}mCommands.push_front(cmd);mNewCommand true;}mConsumerCond.notify_one();}voidExecuteCommands(){while(true){{lock mutexLocker(mMutex);mConsumerCond.wait(mMutex, [this](){returnthis-Executable(); });if(mNewCommand){UnlockCommandResources(nullptr);mNewCommand false;}while(true){Command* cmd mCommands.back();if( !Executable(cmd) )break;AsyncExecute(cmd);mCommands.pop_back();}}mProducerCond.notify_one();}}voidReleaseResource(Resource* res){lock mutexLocker(mMutex);autoiter mResourceLocks.find(res);if(iter ! mResourceLocks.end() ){FreeResourceLock(iter-second);mResourceLocks.erase(iter);}}private:vectorresourcelock* mResourceLockPool;unordered_mapresource*, resourcelock* mResourceLocks;dequecommand* mCommands;boolmNewCommand;ResourceLock* AllocateResourceLock(){if( mResourceLockPool.empty() ){mResourceLockPool.push_back(newResourceLock() );}ResourceLock* ret mResourceLockPool.back();mResourceLockPool.pop_back();returnret;}voidFreeResourceLock(ResourceLock* resLock){mResourceLockPool.push_back(resLock);}boolExecutable(){if( mCommands.empty() ){returnfalse;}if( Executable(mCommands.back()) ){returntrue;}returnfalse;}boolExecutable(Command* cmd){returncmd-ResourceCommandLock().lockedResourcesCount 0;}voidAsyncExecute(Command* cmd){async( [this](){ cmd-Execute();this-UnlockCommand(cmd);} );}templatevoidUnlockResource(IteratorTconst iter){ResourceLock* resLock iter-second;boolisUnlockingReaders false;if( resLock-lockingCount 0){if( resLock-lockingAccess ResourceAccessType::Read ){isUnlockingReaders true;}else{return;}}while(!resLock-lockedCommandLocks.empty()){CommandLock* cmdLock resLock-lockedCommandLocks.back();if(isUnlockingReaders cmdLock-access ! ResourceAccessType::Read){break;}--cmdLock-lockedResourcesCount;resLock-lockingCount;lockedCommandLocks-pop_back();if(cmdLock-access ResourceAccessType::Read){isUnlockingReaders true;}else{break;}}}voidUnlockCommandResources(Commmand* cmd){if( cmd nullptr){for(autoiter mResourceLocks.begin(); iter ! mResourceLocks.end(); iter){UnlockResource(iter);}}else{for(autores: cmd-Resources()){autoiter mResourceLocks.find(res);--(*iter)-lockingCount;UnlockResource(iter);}}}voidUnlockCommand(command* cmd){{lock mutexLocker(mMutex);UnlockCommandResources(cmd);}mConsumerCond.notify_one();}};在实际的硬件和驱动中Producer和Consumer自身可能都是串行的那么此时只需对Producer所使用的