介绍
早期的图像加速技术是使用三角形扫描,将这些扫描的颜色通过插值显示在屏幕上,而且也拥有访问数据的能力,将这些访问的数据通过插值显示在屏幕上
程序内部又加上了许多的可见性的像素检查,如深度测试等,由于这些过程处理的次数多,每次的处理都比较简单,所有就专门从CPU中分离出一个硬件
单元处理这些,从而减少CPU的工作量。
CPU的数据访问延迟
着⾊器核⼼是⼀个⽤于执⾏某些相对独⽴任务的⼩型处理器,例如将⼀个世界空间中的顶点位置,变换到屏幕空间中;或者是计算⼀个像素(被三⻆形覆盖)的颜⾊。由于每帧都会有成千上万的三⻆形被发送到屏幕上,因此每秒可能会有数⼗亿次的着⾊器调⽤(shader invocation),即运⾏着⾊器程序的单独实例。⾸先我们需要知道⼀点,由于在存储中访问数据需要花费⼀定时间,因此延迟是所有处理器都会⾯临的⼀个问题。考虑延迟的⼀个基本⽅法是,数据距离处理器越远(物理意义上的距离),访问所需要等待的时间就越⻓,相对于本地寄存器⽽⾔,访问内存碎⽚中的信息则需要花费更多的时间,这个问题的关键在于,等待数据检索意味着此时处理器处于停滞状态,这会降低性能表现。
数据并⾏结构
不同的处理器架构使⽤了不同的策略来避免停滞。CPU 经过优化,可以处理⼤量的数据结构和⼤型代码段,CPU ⼀般都具有多个处理器,每个处理器都以串⾏的⽅式来执⾏代码,但是有限的 SIMD 向量处理是⼀个⼩例外。为了最⼩化延迟所带来的影响,CPU 芯⽚中的⼤部分⾯积都是⾼速的本地缓存,这些缓存中存满了接下来可能会⽤到的数据。CPU 还会使⽤⼀些智能技术来避免停滞,例如分⽀预测(branch predication)、指令重排序(instruction reordering)、寄存器重命名(register renaming)和缓存预取(prefetching)等
⽽ GPU 则采⽤了不同的策略,GPU 芯⽚中的很⼤⼀⽚⾯积都是⼤量的处理器,也叫做着⾊器核⼼(shader core),GPU 芯⽚中通常会有数千个着⾊器核⼼。GPU 是⼀个流处理器,它会依次处理有序的相似数据。由于数据的相似性(例如⼀组顶点或者像素),因此 GPU 可以通过⼤规模并⾏的⽅式来处理这些数据。另⼀个重要因素是,这些着⾊器调⽤都是尽可能独⽴的,即它们不需要来⾃邻近调⽤的信息,也不需要共享可写⼊的内存位置。有时候为了使⽤⼀些新功能,这个规定会被打破,但是这种例外会带来潜在的额外延迟,因为⼀个处理器可能会需要等待另⼀个处理器执⾏结束之后,才能开始⼯作。
GPU 专⻔对吞吐量(throughput)进⾏了优化,吞吐量指的是数据能够被处理的最⼤速度。但是这种快速处理是有代价的,由于⽤于缓存和控制逻辑的芯⽚⾯积较少,因此每个着⾊器核⼼的延迟,通常都会⽐ CPU 处理器所遇到的延迟要⼤。
假设⼀个模型⽹格需要被光栅化,它⽣成了两千个需要进⾏处理的⽚元,那么像素着⾊器将会被调⽤两千次。想象现在我们有⼀个世界上性能最弱的 GPU,它只包含了⼀个着⾊处理器(shader processor)。它⾸先会为两千个⽚元中的第⼀个执⾏着⾊器程序,这个着⾊处理器会对存储在寄存器中的值进⾏⼀些算术操作。这⾥的寄存器是本地的,它的访问速度很快,因此处理器不会发⽣停滞。然后处理器遇到了⼀个访问纹理的指令,例如:对于⼀个给定的表⾯位置,程序需要知道纹理上对应位置的像素颜⾊,⽽这个纹理是⼀个独⽴的资源,并不是像素着⾊程序本地内存中的⼀部分,因此可能会涉及到访问纹理的操作。读取内存数据需要花费成百上千个时钟周期,⽽在此期间 GPU 处理器将不会进⾏任何操作。此时处理器将会停滞,并等待这个纹理颜⾊值的返回。
为了让这个弱鸡的 GPU 变得更好⼀点,我们为每个⽚元的本地寄存器都提供了⼀⼩⽚存储空间。现在这个着⾊处理器不会在访问纹理的时候发⽣停滞了,⽽是会进⾏切换并执⾏另⼀个⽚元的着⾊器程序,即两千个⽚元中的第⼆个。这个⽚元切换的过程是很快的,除了需要注意第⼀个⽚元当前执⾏的是哪个指令之外,不会有其他任何影响。现在处理器会执⾏第⼆个⽚元的着⾊器程序,与第⼀个⽚元⼀样,这⾥进⾏了⼀些相同的算术函数,然后会再次遇到这个纹理访问的问题,然后着⾊器核⼼会再次切换到第三个⽚元的着⾊器程序。最终,所有 2000 个⽚元都会以这种形式依次进⾏处理,然后着⾊处理器会返回到第⼀个⽚元,这时已经获取到了纹理的颜⾊,因此着⾊器程序可以继续进⾏执⾏。处理器会以这种⽅式继续执⾏,直到遇到另⼀个会导致停滞的指令,或者是程序执⾏完成。在这种执⾏⽅式下,处理单个⽚元会花费更⻓的时间,但是整体的⽚元执⾏时间将会⼤⼤减少。
在这种架构中,当遇到会令着⾊处理器停滞的指令时,我们通过切换并执⾏其他⽚元程序的⽅式,来让 GPU 时刻保持忙碌,从⽽避免延迟。更进⼀步GPU 可以将指令执⾏的逻辑与数据分离开来,这种设计叫做单指令、多数据(single instruction,multiple data,SIMD),这种设计会在固定数量的着⾊器程序上,以⼀个固定的步⻓来执⾏完全相同的指令。与使⽤⼀个独⽴的逻辑单元和调度单元来执⾏每个程序相⽐,SIMD 的优势在于,可以使⽤更少硅芯⽚(也意味着更⼩的功耗)来⽤于处理数据和进⾏切换。使⽤现代 GPU 的术语来说,每个⽚元的像素着⾊器调⽤都可以被称为⼀个线程(thread),但是这⾥所说的线程不同于 CPU 上的线程,它还包括⽤于存储着⾊器输⼊数据的存储空间,以及⽤于着⾊器执⾏的任何寄存器空间。这些使⽤相同着⾊器程序的线程会被打包成组,NVIDIA 将其称作为⼀个 warp,AMD 将其称作为⼀个 wavefront。⼀个 warp/wavefronts 负责调度⼀定数量的 GPU 处理核⼼,可能是 8 到 64 个,并且都会使⽤ SIMD 处理。每个线程都会被映射到⼀个 SIMD 通道(SIMD lane)。
假设我们现在有 2000 个线程要执⾏,NVIDIA GPU 中的⼀个 warp 包含 32 个线程,2000/32 = 62.5,这就意味着我需要分配 63 个 wrap 来执⾏这些线程,其中有⼀个 warp 只被占⽤了⼀半。⼀个 wrap 的执⾏和单个 GPU 处理器类似,同⼀个 warp内的 32 个处理器,都会以⼀个固定的步⻓来执⾏着⾊器程序,所有线程都会执⾏完全相同的指令,即当⼀个线程遇到存储读取的时候,所有线程都会同时遇到这个存储读取。这个读取信号表明了这个 warp 中的所有线程都会发⽣停滞,并等待各⾃的返回结果。此时这个 warp 会切换到另⼀个包含 32 个线程的 warp,然后由 32 个处理器进⾏执⾏。这个交换过程与我们之前单个处理器的切换过程⼀样快,在交换 warp的过程中,每个线程中的数据不会被修改,每个线程都有着各⾃的寄存器,并且每个warp 都会记录下当前正在执⾏的指令。交换⼀个 warp,只是将⼀组处理器核⼼指向另⼀组需要被执⾏的线程⽽已,没有其他的额外开销。每个 warp 只会在所有任务都完成之后,才会进⾏交换,如下图
图中是⼀个简化的着⾊器执⾏例⼦。⼀个三⻆形的⽚元(被称作线程),被打包成很多个 warp。为了简化表示,上图中的每个 warp 都只包含四个线程,⽽真正的 warp ⼀般包含 32 个线程。这个等待执⾏的着⾊器程序中包含五个指令。这四个处理器为⼀组,开始执⾏第⼀个 warp 中的指令,直到在遇到“txr”指令时,检测到了⼀个停滞状态,这个指令需要花费⼀些时间来从存储中获取数据。然后第⼆个 warp 会被切换到处理器中,同样执⾏着⾊器程序中的前三条指令,直到再次检测到⼀个停滞状态,第三个 warp 也同样如此。然后处理器会再次切换回第⼀个 warp,继续进⾏执⾏,如果这个时候“txr”指令所需要的数据还没有返回的话,那么处理器才会真正的停滞下来,直到这些数据可⽤的时候。每个 warp 都会按照顺序轮流执⾏。
在我们的这个简单例⼦中,在存储中读取纹理所带来的延迟,可能会导致 warp 进⾏交换。由于这个切换的成本⾮常低,所以实际上的 warp 可以通过交换来获得更低的延迟。还有⼀些其他的技术可以⽤来优化执⾏效率,但是 warp 交换是 GPU 上最重要的延迟隐藏技术。这个过程的⼯作效率还涉及到好⼏个其他因素,例如:如果线程的数量很少,那么就只能创建很少的 warp,这可能就没法有效隐藏延迟。影响执⾏效率的另⼀个重要特征是着⾊器程序的结构,其中最重要的⼀个因素就是每个线程所使⽤的寄存器数量。我们现在假设 GPU 上可以同时存在两千个线程,每个线程中运⾏的着⾊器程序所需要使⽤的寄存器数量越多,那么 GPU 上能够同时存在的线程数量和 warp 数量也就越少。数量较少的 warp 意味着,可能⽆法通过 warp交换来缓解处理器核⼼的停滞,正在执⾏的 warp 被称作“in flight”,其数量被称为占⽤率(occupancy)。更⾼的占⽤率意味着存在更多⽤于处理的 warp,也意味着
会有更少的空闲处理器,较低的占⽤率往往意味着性能表现不佳。存储读取的频率同样也会对延迟产⽣影响,Lauriten 指出了着⾊器所使⽤的寄存器数量,以及共享存储是如何影响 GPU 占⽤率的。Wronski 讨论了理想中的 GPU 占⽤
率,是如何跟着着⾊器操作类型⽽发⽣改变。
另⼀个影响整体运⾏效率的因素是由“if”语句和循环语句导致的动态分⽀(dynamic branching)。假设现在我们的着⾊器程序中遇到了⼀个“if”语句,如果所有线程都进⼊了相同的分⽀,那么这个 warp 可以不⽤管其他的分⽀,继续执⾏进⼊的那个分⽀即可。但是,如果其中有⼏个线程,甚⾄是只有⼀个线程进⼊了其他的分⽀,那么这个 warp 就必须把两个分⽀都执⾏⼀遍,然后再根据每个线程的具体情况,丢弃不需要的结果。这个问题叫做线程发散(thread divergence),它意味着有⼀些线程需要去执⾏⼀个循环操作,或者是进⼊了所在 warp 中其他线程都没有进⼊的“if”分⽀,这会导致其他的线程空转。所有的 GPU 实现都应⽤了这些架构思想,虽然这样会导致系统具有严重的限制,但是这也提供了⼤量的每瓦计算能⼒。理解这个系统是如何进⾏操作的,可以帮助开发⼈员更加⾼效的利⽤ GPU 性能。在接下来的⼩节中,我们会讨论 GPU 是如何实现渲染管线的,可编程着⾊器是如何进⾏操作的,以及每个 GPU 阶段的演变与功能。