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

概述

在D3D10中,一个基本的渲染流程可分为如下步骤:程序员

  • 清理帧缓存;
  • 执行若干次的绘制:
    • 经过Device API建立所需Buffer;
    • 经过Map/Unmap填充数据到Buffer中;
    • 将Buffer设置到DeviceContext中;
    • 调用Draw执行绘制过程;
  • 调用Present提交渲染结果。

在这一过程当中,不被初学者注意、然而在深刻学习时定会遇到的一个特性是:D3D的Draw函数是一个异步调用。算法

咱们知道,实际渲染的过程大部分是在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结构。例如用户代码到驱动是一级,驱动到硬件又是一级。所以,消息队列可能同时存在于软件和硬件中。每每看起来简单的模型,在实践中就是这样复杂起来的。app

Draw调用到底作了哪些事情

CPU和GPU的通讯主要出如今两个时候:第一,读写资源(Map/Unmap);第二,Draw的调用。这些通讯都会变成Driver发给显卡的命令。例如,咱们假设COMMAND是个四字节的命令,每一个COMMAND最长能够有512个字节的数据;咱们要将Buffer传到GPU的某块内存上,那么咱们就能把须要传输的数据处理成这样的指令组:异步

COPY GPU_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)。什么意思呢,就是说若是全部的指令都只对同一个数据是读操做,那这些指令随便怎么排序都是正确的;可是若是有写指令,那么写指令先后的读写操做,都不能随意调整位置。

// 基本例子
int a = 5;
int b = 3;
int c = a + b; // c = 8

// 交换a和b的赋值顺序
int b = 3;
int a = 5;
int c = a + b; // c = 8

好比说在上面的代码中,a和b是不相关的两个变量,那么这两个值的操做相互之间没有影响。a和b的赋值谁先谁后,c的结果都没有变化。可是,若是咱们把c的计算放在a和b的赋值以前,那么结果就可能会变化。这是由于c的计算中有a和b的读取,若是将a的读取和a的写入对调,那么结果就会和预期的有所不一样。因此若是进行并行操做的话,两个赋值语句是能够并行完成的。可是隐含着读取的加法操做,必须在赋值语句(写操做)完成以后方可进行。这是写后读(RAW)的状况。

其它状况也是相似的。 所以无论是读仍是写,只要不违反上述对数据相关性的约束,那么它的结果就是正确的。固然对于并行编程而言,若是读写都针对同一个资源,那么还必须保证读或者写的操做是符合读写锁的互斥要求的。

回到D3D10中,咱们将D3D10的资源按照读写限制来分,一共有四种:

image

去掉细节不谈, 全部资源中最简单的当数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仍是有所不一样的,流水的每一级都要工做很长时间,并且和下一级的在时间上的重叠度很高。是否须要经过先后渲染调用的重叠提升并行程度,在设计上须要进行取舍。

咱们来看一个例子:

// Init idxBuffer and idxBuffer2

devContext->IASetIndexBuffer(idxBuffer);
devContext->Draw();

devContext->IASetIndexBuffer(idxBuffer2);
devContext->Draw();

devContext->Map(idxBuffer2, READ);
// Write idxBuffer2
devContext->Unmap();

devContext->Map(idxBuffer, WRITE);
// Write idxBuffer
devContext->Unmap();

devContext->IASetIndexBuffer(idxBuffer);
devContext->Draw();

devContext->IASetIndexBuffer(idxBuffer2);
devContext->Draw();

若是咱们用表格把代码中命令和资源的关系表达出来就是:

image

接下就是要如何解决异步编程中两个重要问题:1. 调用次序能不能颠倒;2. 被调用函数和调用方能不能同时执行。解决这两个问题的最基本的办法是拓扑排序。拓扑排序的做用是肯定一条命令会对哪些命令产生依赖。若是它依赖的命令都执行完了,那么就能够执行这条命令了。固然在拓扑排序以前,首先要构造一张依赖图。依赖图的顶点是一条Command,是两个节点间的依赖关系。这一依赖关系能够由命令间的资源相关性获得:

image

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就能够被执行了。

具体的代码能够参见这里:

class CommandLock
{
    ResourceAccessType  access;
    uint32_t            lockedResourcesCount;
};

class ResourceLock
{
    deque<commandlock*> lockedCommandLocks;
    ResourceAccessType  lockingAccess;
    uint32_t            lockingCount;
};

class Queue
{
public:
    void PushCommand(Command* cmd)
    {
        {
            lock mutexLocker(mMutex);
            mProducerCond.wait(mutexLocker, [this](){return !this->mCommmands.full(); });
            
            for(auto res: cmd->Resources() )
            {
                auto iter = 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();
    }
    
    void ExecuteCommands()
    {
        while(true)
        {
            {
                lock mutexLocker(mMutex);
                mConsumerCond.wait(mMutex, [this](){ return this->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();
        }
    }

    void ReleaseResource(Resource* res)
    {
        lock mutexLocker(mMutex);
        
        auto iter = mResourceLocks.find(res);
        if (iter != mResourceLocks.end() )
        {
            FreeResourceLock(iter->second);
            mResourceLocks.erase(iter);
        }
    }
    
private:
    vector<resourcelock*>                   mResourceLockPool;
    unordered_map<resource*, resourcelock*> mResourceLocks;
    deque<command*>                         mCommands;
    bool                                    mNewCommand;
    
    ResourceLock* AllocateResourceLock()
    {
        if( mResourceLockPool.empty() )
        {
            mResourceLockPool.push_back( new ResourceLock() );
        }
        ResourceLock* ret = mResourceLockPool.back();
        mResourceLockPool.pop_back();
        return ret;
    }
    
    void FreeResourceLock(ResourceLock* resLock)
    {
        mResourceLockPool.push_back(resLock);
    }
    
    bool Executable()
    {
        if ( mCommands.empty() )
        {
            return false;
        }
        
        if( Executable(mCommands.back()) )
        {
            return true;
        }
        
        return false;
    }
    
    bool Executable(Command* cmd)
    {
        return cmd->ResourceCommandLock().lockedResourcesCount == 0;
    }
    
    void AsyncExecute(Command* cmd)
    {
        async( [this](){ cmd->Execute(); this->UnlockCommand(cmd);} );
    }
    
    template 
    void UnlockResource(IteratorT const& iter)
    {
        ResourceLock* resLock = iter->second;
        
        bool isUnlockingReaders = 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;
            }
        }
    }
    
    void UnlockCommandResources(Commmand* cmd)
    {
        if( cmd == nullptr )
        {
            for(auto iter = mResourceLocks.begin(); iter != mResourceLocks.end(); ++iter)
            {
                UnlockResource(iter);
            }
        }
        else
        {
            for(auto res: cmd->Resources())
            {
                auto iter = mResourceLocks.find(res);
                --(*iter)->lockingCount;
                UnlockResource(iter);
            }
        }
    }
    
    void UnlockCommand(command* cmd)
    {
        {
            lock mutexLocker(mMutex);
            UnlockCommandResources(cmd);
        }
        
        mConsumerCond.notify_one();
    }

};

在实际的硬件和驱动中,Producer和Consumer自身可能都是串行的;那么此时只需对Producer所使用的资源作读写计数便可(这个引用计数至关因而一个Critical Section,只是为了让Consumer和Producer进行同步,Consumer和Producer内部都是串行的,因此也必定是顺序一致的。具体的理论能够参见《多核处理器编程的艺术》。):

  • 若是是GPU执行的命令,在进入GPU Queue时,增长命令所使用的资源读或写的引用计数;当GPU的命令执行完后,驱动会收到信息,减小引用计数。
  • 若是是CPU端的Map/Unmap,直接检查GPU资源引用计数,若是资源仍然被GPU占用,那么就阻塞或返回;若是没有GPU占用,那就正常的映射到内存中。

固然,我还试图作过一个更加简单的版本,那就是,CPU一旦须要锁定资源,那干脆就阻塞到全部的Producer命令结束再执行。这个实现手段更加简单,只不过不应等的也等了,效果上天然也要更差一些。

经过这些手段,能够大大减小CPU要等待GPU执行完才能继续执行的状况。固然,若是在GPU工做时仍然要读写GPU上的资源会致使访问冲突,由此带来的阻塞也是不可避免的。此时就须要应用程序视状况进行优化,或者经过NO_OVERWRITE或DISCARD明确的告诉驱动,用户代码对于资源的读写与正在执行的操做不冲突。

跨线程对象的生命期管理

在没有GC的状况下,线程安全的引用计数/智能指针几乎是最好、也是惟一的跨线程对象生命期管理手段。若是你的智能指针与std中的shared_ptr同样,这里也没有特殊强调的地方。
可是若是是相似于COM对象,是一个有着内嵌引用计数的裸指针这样的呢?要如何避免如下的代码出现致命的错误?

ID3D11Buffer* buffer = dev->CreateBuffer( ... ); 
buffer->Release(); 
devContext->IASetIndexBuffer(buffer);
// ...
devContext->Draw(...);

咱们知道,COM对象在Create以后就Release,COM的引用计数就会归零,对象也会被析构。此时的buffer就至关因而一个悬挂指针。对它的一切操做几乎都会致使不可预料的后果。
指针自己也没有任何办法说明本身的有效性。那么D3D Runtime如何检查这样的悬挂指针呢?

咱们注意到,Buffer是从Device中建立出来的。一个比较容易考虑到的方案是:
在Device中保留有全部建立出来的Buffer,而且Buffer也有一个Device指针,Buffer在释放的时候也会通知Device,Device将指针在表中移除。

在经过API设置的时候,能够经过Device检查这个Buffer是否存活。

固然,这事儿你能够作的更极端,例如

memset(buffer, 0, YouKnowTheSizeOfBuffer); 
devContext->IASetIndexBuffer(buffer);

那经过这种方式是检查不了的。甚至即使在对象字段中增长Guard加以检查和保护,也没有办法避免对对象数据进行针对性的破坏。

不过好在这些问题只可能在User Mode Driver(UMD)中发生。若是出现异常,大不了程序Crash就行了。真正和设备、和操做系统内核服务打交道的,是Kernel Mode Driver(KMD)。UMD到KMD是严格隔离的,KM中的程序有本身的地址空间,彼此之间没法直接访问内存,数据的传递必须进行拷贝。这些隔离措施,都是咱们常说的用户态到内核态切换成本的一部分。

异步调用的错误返回机制

和同步调用相比,异步调用对于错误处理是不那么友好的。用户发起的调用还在执行、甚至还没开始执行,函数就已经返回了,因此你根本就不知道发起的异步调用出现了什么错误;错误发生了、异步调用中断了,又不知道怎么传递给调用方;调用方拿到错误了,又不必定知道哪里发生的。

异步调用的错误返回机制就是为了解决这三个问题,虽然未必能解决的了。

在讨论异步调用的错误和异常处理方法以前,先要看看必要性。

1.若是错误不须要被处理,并且执行过程有容错机制,那么只要将命令甩出去执行就行了,不须要关心有什么错误、是怎么处理的。例如显卡上一些Shader值的错误会致使目标渲染成警告色(例如红色),可是硬件自己不会崩溃,也不会给用户返回任何的错误信息;
2.若是调用方不须要知道究竟发生了什么错误,只要这个错误被处理就好了,并且它知道怎么样处理错误,那可使用回调函数来处理错误,或者是CPS的调用风格;
3.调用方须要知道发生了什么错误。这种状况须要有隐式或显式的同步点,在这个同步点上,调用方会等待被异步调用的函数给它返回一个信号。这个信号要么是结果,要么是一个错误或异常。C++11引入的std::future就能够解决这一个问题。下面这段伪代码大体解释了它的实现原理。

void thread_func() 
{ 
    // work, work. 
}

// 这个 wrapper 的做用就是捕获线程函数的错误,防止错误被传播到线程外。 
void thread_func_wrapper(thread_result& result) 
{ 
    try 
    { 
        thread_func(); 
    } 
    catch( exception& e ) 
    { 
        // result是一个条件变量,设置了异常或者值后,被这个条件变量阻塞的线程会继续执行。 
        result.set_exception(e); 
        return; 
    } 
    
    result.set_value(e); 
}


void thread_caller() 
{ 
    // 异步调用。注意,调用的是那个能捕获错误的函数 
    thread_result result; 
    async( bind(thread_func_wrapper, result) ); 
    
    // ... 干点儿别的 ... 
    
    try 
    { 
        // 等这个条件变量。
        // 若是线程调用了set_value,那阻塞结束后就返回结果;不然就把这个异常从新抛出来。 
        result_value = result.get_result();    
    } 
    catch( exception& e ) 
    { 
        // 如今你知道是什么错误了,处理它吧。 
    } 
}

若是异常中有堆栈信息,或者线程异常一触发就被调试器捕获,那你天然就知道异常出如今什么地方了。固然这个例子中,异常不是必须的,你也能够用返回值来表示异步调用的函数是否正确。

可是对于D3D10来讲,这个问题要更复杂一些。由于异步调用以后,没有显式的同步点。好比没有API能让你写下面这一段代码:

devContext->Draw( ... );
// ... 干点别的 ...
devContext->IsLastFuckingDrawFuckingSucceed();

虽然有一些同步点,例如Present(D3D 11.2 之后,这里也没得同步了)。可是你总不能把Draw的错误放在Present上吧,并且你还不知道是哪一个Draw的。

因此D3D采用了一个折中的方案:

  1. 若是一个函数执行时有错能马上检查出来,那就经过返回值返回。
  2. 若是检查不出来,那就容错。

因此D3D的API在调用的时候都有尽量多的检查;特别是在Draw以前,会检查各个渲染状态之间互不冲突。若是检查出有任何问题,例如没法分配Buffer等,就会经过HRESULT返回给调用方。一旦检查结束,将Draw调用转化成GPU执行的指令,那再出任何问题,就只能期待KMD和硬件的容错机制了。

后记

尽管此文酝酿时间不短,从整理需求、阅读API Remark、设计异步解决方案开始算起已经有月余,又有三四个版本原型的SALVIA的工程实践,文章也写了好几天,可是仍是以为叙述零碎,不够完整,有诸多不满意之处。因此此文可能仍然会更新一段时间以修正一些错误、补充一些材料。也恳请各位提出宝贵意见,助我修缮全文。在此先谢过。

相关文章
相关标签/搜索