持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第17天,点击查看活动详情
本系列文章将从GPU硬件角度总结如何优化性能,主要资料来源于NVidia官方资料。本文硬件针对的是NVidia GPU,但原理可类推到其他厂商GPU。
从硬件角度看如何提高性能的总原则
- 尽可能的并行运行
- 尽可能高效的访问内存
- 尽可能高效的执行指令
Shader在 GPU上的执行
本篇中,将分析shader指令执行的结构。我们知道GPU有很多核心,GPU每个核心执行一个线程。但核心不是完全独立执行的,32个连续的硬件线程组成一个warp(线程束),指令的分发是以warp为单位的。而我们分配warp是通过Threadblocks。
指令执行的基本方式:SIMT
首先,GPU是以SIMT的方式执行指令的,所谓SIMT即单指令多线程。
- 一个指令发出后由一整个warp执行。一个warp = 32个连续的线程 (NVidia)
- 每个线程使用它自己的参数执行操作。
warps和Threadblocks
什么是Threadblock
Threadblocks是用来帮助程序员方便的规划线程使用的概念,在Compute Shader中叫做线程组(ThreadGroup)。Threadblocks可以是1D,2D或者3D。根据使用的情景选择方便的描述方式。
- 1D的例子:在GPU中并行处理场景中大量物体的AABB在视锥中的裁剪,可以指定数量为AABB个数的1D的线程组,让每个线程对应一个AABB进行裁剪。通过指定1D坐标,可以很方便的从compute shader的输入的结构数组中索引出相应的AABB数据。
- 2D的例子:在GPU中处理一副图片,每个像素的坐标x,y对应了2D线程组的坐标,这样可以使用2D坐标索引出像素,可以读取和该像素位置对应的资源。比如读取深度图。
从硬件角度看,Threadblocks永远是1D的,它就是一个线程的数组。
连续的32个线程组成一个warp
- 对于1D线程组:
- warp 0: threads 0..31
- warp 1: threads 32..63
- 对于2D/3D:
- 首先将2D/3D的线程组ID转换成1D:x是最快变化的维度,z是最慢变化的维度
- 之后和1D一样组织warp
硬件如何为每个线程组(threadblock)分配warp?
硬件会为线程组分配足够数量的warp,但如果线程组中的线程数量不是warp size(32)的整数倍,那么最后一个warp中的部分线程就啥也不干。一个warp重来不会分开给不同的线程组使用。举个例子:
上图中,我们定义了一个size为40x2的2D threadblock,这将创建80个线程。硬件会分配给线程组3个warp,图中的蓝色,红色和绿色分别表示3个warp。前两个warp中的所有32个线程都被分配出去了,但是第3个warp(绿色那个)只能分配:
80-32*2=16
个线程,剩下的16个线程(虚线部分)就是非激活的。这样会造成GPU核心没有被充分利用,影响了性能。
控制流对warp执行的影响
不同的warps可以执行完全不同的代码
- 每个warp都维护了自己的程序计数器(program counter)
- 由于是完全不同的控制流,没有任何性能影响
如果一个warp只有一部分线程要执行一个操作
- 没有参与的线程被“masked out”: 它们不会取操作数,也不会输出,这保证了正确性,但是它们仍然在这些指令上花费时间,因为它们不能执行其他指令。
warp内按条件执行(if/else)
- 如果是不同的warps执行不同的代码:
可以看到,不同的warp互相不影响,各自执行不同的代码,所以对性能没影响。(因为不同的warp有自己的程序计数器,不必互相等待)
- 如果是同一个warp内部执行不同的代码:
由于同一个warp内部的所有线程执行时是lock step的,大家必须一起往下走进。所以对于左边,部分线程执行if部分线程执行else的情况,必须要分别执行完if和else之后才能继续一起执行。而如果warp内所有的线程的条件判断都一样,比如右边,都是if,那就不需要执行else了。反之哪怕有一个线程需要执行else,也会让所有线程等待else执行完毕。
更靠近一些,进入指令级别
指令分发
- 编译器将shader编译成指令序列,指令按序列顺序分发。如果某个指令不具备执行条件,则会挂起当前warp
- 指令具备执行条件必须满足两个条件:
- 流水线已准备好执行该指令,某些流水线步骤需要多个指令周期才能分发warp
- 所有参数已准备好。如果参数需要之前的指令计算的结果,那么只有当结果已计算出来才算准备好。
延迟
- 指令的延迟大概10-20个周期
- DRAM内存的访问大概需要400-800个周期,显然内存访问比执行指令慢得多
指令级并行 (ILP)
指令虽然是顺序分发的,但是还是有可能并行执行
- 相互依赖的指令之间的那些互相独立的指令可以被并行执行
- ILP是否成立取决于编译器编译出的指令序列 举个例子:
图中两个FMUL指令是并行的(ILP),因为他们是相互独立的,但是它们所依赖的指令(FFMA)以及依赖它们的指令(ST.E)则不能并行。
warp切换
如果当前执行的warp的指令不具备执行条件,则会挂起warp。例如挂起了N个周期,那么会有N个其他的waprs的具备执行条件的指令被执行。也就是说同一个warp硬件上会来回切换执行不同的warp,已充分的利用硬件。 切换warp没有性能负担,因为寄存器,共享内存这些状态是划分给这些warp的,没有存储/恢复操作发生。
本篇总结
本篇中我们从硬件的角度了解了shader是如何按照线程组去分配到不同的warp上执行,warp内部是如何同步执行32个线程,每个线程上的指令是如何并发执行的以及warp何时会切换。
- 理解线程组的尺寸必须是warp尺寸的整数倍(在NVidia硬件上,是32的倍数)
- 理解分支对warp执行的影响,不同的warps没关系(执行不同的shader/变体),同一个warp看分支条件是否是动态的,如果是动态的就会造成分支等待,如果是静态的,同一个warp的所有32个线程是同一个条件,则没影响。
- 指令的分发是顺序的,但是指令可能并行执行(ILP),指令的延迟比内存延迟低,指令不满足条件会让当前warp挂起,并切换到满足条件的warp。