1. CPU线程和GPU线程的区别
另外我们还需要深刻的理解的一个概念就是CPU线程和GPU线程的区别。
1.1. CPU线程
CPU线程在Windows操作系统中更多的是指一个存储了几乎所有CPU寄存器状态以及堆栈等资源信息的内核对象(可能还有内核安全信息等),是一个复杂的重量级的对象,并且在Windows中线程是最小的执行单元。同时得益于CPU单核运算能力的强大,一个CPU线程就可以运行很多复杂的任务。
从数量上来说,因为CPU内核加之所谓的多线程技术所能提供的真正线程并行执行数量是少的可怜的,就算服务器CPU其内核数也就是那么几十个而已,而更多的线程几乎都是靠“并发”执行的,一句话概括之就是“你方唱罢我登场,城头变幻大王旗”的形式。
1.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)去执行某个项目(命令列表)中的某几个任务(命令),然后部门经理就可以去干别的活计,而部门员工就开始勤奋的执行这批任务。因此我们在说CPU和GPU同步时,更多的是说CPU线程同某个GPU之间的同步,或者更确切的说是CPU线程和某几个命令队列之间的同步,而不是说CPU线程和具体的GPU线程之间的同步,我们只需要将命令整体发送给GPU即可,至于具体的GPU上启动了多少线程,哪些线程执行哪个命令或者哪些线程执行到了哪条命令等等信息,在GPU线程颗粒度上是不明确的。或者换一种说法,我们是将GPU看做一个整体来对待的。就好像我们看待CPU是部队的指挥官,而GPU就是一只由若干人(几百或几千人)组成的部队一样,指挥官只是给部队不断的下命令,而部队就整体的不断执行命令。
在D3D12中一组GPU线程的代表物就是ID3D12CommandQueue接口。
1.3. CPU线程和GPU线程运行的动态描述
更具体的我们举例说明一下,比如现在我们需要渲染一个带纹理的正方体,正像好多D3D程序入门示例上显示的那样,为了完成这个任务,CPU线程就需要先准备方块的顶点信息和索引信息,这通常只需要一个CPU线程一次读入8个顶点和12个索引数据(三角形)及一个纹理即可,读完之后,CPU先对这些数据进行必要的处理,比如碰撞检测、位置变换(实际是准备物体在场景中的位置向量,然后传到GPU),接着这个CPU线程就依次将这些数据(模型数据、MVP、光照、材质、纹理等等)都提交到GPU,然后调用著名的Draw Call即可。接着GPU如何工作呢?此时GPU就会启动(实际更准确的说法应该是在调用了ExecuteCommandLists的情况下)至少8个GPU线程来分别为每个顶点数据执行VertexShader脚本完成几何变换,然后启动不少于12个GPU线程来进行光栅化(实际数量要远多于12个)得到片元,然后每个线程再为片元上的每个像素执行Pixel Shader脚本进行着色,最终像素显示到屏幕上就完成了渲染。在过去很多教程资料中讲解GPU具体渲染时,顶多只是说这些处理是并行的而已,而在这里我则明确告诉各位实际就是这么多GPU线程同时在并行的执行,以便大家建立形象思维,从而彻底理解神秘的GPU及渲染管线等等的执行时状态。
1.4. CPU替GPU管理显存
由于GPU流处理器的简单性,它们甚至往往是分成若干组公用一个简单的显存管理器(较之CPU的内存管理器来说的简单,但其性能和带宽是远远超过CPU的内存管理器的),所以D3D12中的堆内存管理,其实只是CPU替某个或某几个GPU管理显存的分配的(稍后还要讲多GPU系统)。也就是我们利用CPU线程显式调用D3D12的堆接口方法来实现。这样在多个命令队列及多个CPU线程之间,就避免了因显存生命周期不一致(主要是分配和释放)而导致的各种访问违规问题。
2. D3D12命令列表与命令分配器
在D3D12中最后一个比较重要的与多线程内存管理有关的概念就是命令分配器了。因为如前所述我们都是使用一个CPU线程+一个命令列表的形式来调用,我们知道命令列表实际最终需要在GPU上执行,所以最终命令列表实际上是需要记录在GPU的显存中的(类似CPU内存中的代码段的概念),这样就形成了CPU写入显存GPU最终从显存中读取的情况,而CPU和GPU事先(至少在你写代码前)是不知道需要执行多少命令的,因此这是一个“动态”的过程,为此D3D12就提供了一个ID3D12CommandAllocator接口来作为命令列表的内存分配器,称之为命令分配器。因此我们就需要先创建一个命令分配器(ID3D12Device::CreateCommandAllocator),然后在创建命令列表(ID3D12Device::CreateCommandList)时指定具体的命令分配器即可。当然与命令队列和命令列表分类相对应,命令分配器也被分为各种不同类型。
3. Draw Call的原罪
在拥有D3D11的多线程渲染以前,之前说的那个Draw Call命令是一个严重的性能陷阱,甚至成为了很多公司面试游戏开发人员的面试题。因为CPU线程在调用Draw Call的时候GPU的工作才真正启动并执行,而直到GPU所有的线程都执行结束后CPU线程的DrawCall函数(DrawIndexed、DrawInstanced、DrawIndexedInstanced等)才返回,这期间CPU线程几乎就是干等着的,也就是切换出了当前执行环境,变成等待状态。
这种情形让我想起了历史上那个ReadFile、WriteFile操作,以及阻塞式SOCKET通讯中的Sent和Recv调用,它们与Draw Call一样都有一个共同的特征,即在同步调用的情况下都需要CPU线程等待执行结果的返回,对于写惯了顺序执行代码的程序员来说,这没什么毛病,但是对于程序最终的性能来说它们都是具有极大杀伤力的,如果你习惯于用Profile分析这些程序的性能的时候,往往都会看到这些程序主要的性能瓶颈就在这些IO函数、以及Draw Call上。对于传统的IO操作,Windows系统很早就提供了改进性能的措施,比如重叠IO、IOCP线程池等等(这些东西的相关内容在我的博客中其它文章中都有详细介绍,欢迎阅读),说白了这些方法的一个本质特征就是让这些调用立即返回,比如重叠IO情况下,WriteFile函数调用就立即返回,此时CPU线程就可以去执行别的任务,而系统IO设备则去真正执行WriteFile要求的写入硬盘的操作(可能是通过系统内核线程或者直接利用DMA机制等等),当真正的写入结束的时候内核就设置一个提前预置的Event对象为有信号状态,或者唤醒一个处于可警告状态的线程执行IO完成操作等。在这里对于Draw Call来说,也是一样的机制,就是在异步执行的情况下调用Draw Call立即返回,不执行任何绘制动作,甚至是简单的记录到Command Lists中,直到Execute Command List的时候才去执行,而这个执行则是GPU去执行,CPU线程则在调用ExecuteCommand Lists时也立即返回了。这样CPU和GPU就可以同时并行的执行各自的任务了。这样就从根本上解决了Draw Call等待,从而导致CPU线程性能低下的问题。
对于一个复杂场景的渲染来说,这个等待时常(时长)是无法容忍的,因为这时CPU完全可以去处理输入、音效、网络、物理变换等等。这也是历史上Draw Call被称之为同步调用的全部意义。当然现在D3D11、D3D12的多线程渲染框架中,这已经通过命令队列先记录后异步执行的方式彻底解决了。
暂无评论内容