第四章:Shader Core微架构——ALU阵列、SFU、线程调度器与寄存器文件设计
好,我们进入Shader Core的核心地带。这一章内容比较多,但都是硬骨头。我个人习惯把Shader Core拆成四个模块来看:ALU阵列、SFU、线程调度器、寄存器文件。这四个东西配合得好不好,直接决定了你的GPU能跑多快、能效比高不高。
我在做上一代架构时,就吃过寄存器文件带宽不够的亏。那时候调度器喂进来的指令,ALU等着数据,结果寄存器文件读端口不够,活活把流水线卡住了。嗯,这种坑踩过一次就记住了。
4.1 ALU阵列设计
ALU阵列,说白了就是Shader Core里干活的主力军。它负责执行各种算术和逻辑运算。你想想看,一个像素着色器里可能有几十条指令,大部分都要经过ALU。
ALU阵列的典型结构:
- SIMD宽度:常见的是16、32、64 lanes。我建议根据你的目标应用场景来选。移动端用16宽就够了,桌面级至少32宽。
- 流水线深度:整数ALU通常3-4级,浮点ALU可能需要5-7级。深度越深,频率能跑得越高,但分支预测失败的惩罚也越大。
- 操作数准备:每个lane需要从寄存器文件读取两个源操作数。这里要注意,读端口数量直接决定了你的带宽。
关键设计点:ALU阵列的每个lane并不是完全独立的。它们共享控制逻辑、共享立即数总线。但每个lane有自己的数据通路和寄存器文件端口。我曾经见过一个设计,为了省面积把lane之间的数据通路也共享了,结果导致某些指令无法并行执行,性能直接腰斩。
ALU指令类型:
- 整数运算:ADD、SUB、MUL、MAD、MIN、MAX、CMP
- 浮点运算:FADD、FMUL、FFMA(融合乘加)、FRCP、FRSQ
- 逻辑运算:AND、OR、XOR、NOT、SHL、SHR
- 特殊运算:ABS、NEG、SAT(饱和)、CLZ(前导零计数)
这里有个小技巧:FFMA(融合乘加)指令在GPU里非常常见。它把乘法和加法合并成一条指令,精度更高、延迟更低。我在设计时,会把FFMA的硬件单独优化,因为它占了浮点运算的60%以上。
4.2 特殊函数单元(SFU)
SFU,全称Special Function Unit。它处理那些ALU搞不定的复杂函数。比如三角函数、指数、对数、开方、倒数等。这些函数如果用ALU的微码来实现,那延迟和面积都受不了。
SFU的典型实现方式:
- 查找表(LUT)+ 多项式插值:这是最常用的方法。把函数值预先算好存到ROM里,然后通过线性或二次插值得到精确结果。
- CORDIC算法:适合三角函数和双曲函数。迭代次数多,但硬件开销小。
- 牛顿-拉夫森迭代:适合除法和开方。收敛速度快,但需要乘法器支持。
我的经验:SFU的精度是个大坑。很多设计为了省面积,把LUT的位宽做得很小,结果插值误差太大。我曾经遇到过,某个游戏场景里因为SFU精度不够,导致水面反射出现条纹。最后不得不把LUT从8位扩展到12位,面积增加了15%,但问题解决了。
SFU与ALU的协同:
SFU通常和ALU并行工作。调度器会把特殊函数指令发给SFU,把普通指令发给ALU。两者可以同时执行,互不干扰。但要注意,SFU的延迟通常比ALU长很多(比如ALU是4周期,SFU可能是16周期)。所以调度器需要处理好写回顺序,不能乱序。
我建议在SFU和ALU之间加一个写回仲裁器。谁先完成谁先写回,但必须保证同一个线程的写回顺序不乱。这个仲裁逻辑不复杂,但容易出错。
4.3 线程调度器(Scheduler)
线程调度器,是Shader Core的大脑。它负责决定哪个线程组(Warp/Wavefront)可以执行下一条指令。GPU的并行度,很大程度上取决于调度器的效率。
调度器的核心任务:
- 指令发射:从指令缓存中取出下一条指令,检查操作数是否就绪。
- 线程切换:当当前线程因为访存或同步而阻塞时,切换到另一个就绪的线程。
- 资源分配:为每个线程分配寄存器、共享内存等资源。
常见的调度策略:
| 策略 | 优点 | 缺点 |
|---|---|---|
| 轮询(Round-Robin) | 实现简单,公平性好 | 无法区分优先级,可能浪费周期 |
| 优先级调度 | 可以优先处理关键线程 | 可能导致饥饿问题 |
| 贪心调度 | 最大化吞吐量 | 延迟可能不稳定 |
注意:调度器的设计要避免「活锁」和「死锁」。活锁是指线程一直在切换但没实际进展。死锁是指所有线程都在等待资源,谁也动不了。我曾在调试一个多线程渲染器时,遇到死锁问题,查了三天才发现是调度器的资源分配逻辑有bug——它把寄存器分配给了已经阻塞的线程,导致其他线程拿不到资源。
调度器的硬件实现:
调度器通常是一个有限状态机(FSM),加上一个就绪队列。每个线程组有一个状态位(就绪/阻塞/完成)。调度器每个周期扫描就绪队列,选出一个线程组发射指令。
我建议用「两级调度」:第一级是粗粒度调度,决定哪个Warp可以执行;第二级是细粒度调度,决定Warp内的哪条指令可以发射。这样设计的好处是,可以同时处理多个Warp的指令,提高指令级并行度。
4.4 寄存器文件(Register File)设计
寄存器文件,是Shader Core里最吃面积、最吃功耗的模块之一。它要为每个线程提供大量的寄存器。一个典型的GPU可能有几千甚至上万个寄存器。
寄存器文件的挑战:
- 面积大:寄存器文件通常占Shader Core面积的20%-30%。
- 功耗高:每次读写都要驱动大量的位线,动态功耗很大。
- 带宽要求高:ALU阵列需要同时读取多个操作数,写回结果。
寄存器文件的结构:
通常采用多bank结构。每个bank是一个独立的SRAM阵列,有自己的读写端口。ALU的每个lane可以同时访问不同的bank,实现高带宽。
举个例子:一个32-lane的ALU,每个lane需要读2个源操作数、写1个结果。那么寄存器文件需要提供64个读端口和32个写端口。如果每个bank有4个读端口和2个写端口,那么至少需要16个bank。
关键设计点:bank冲突是寄存器文件设计的噩梦。当多个lane同时访问同一个bank时,就会发生冲突,导致流水线停顿。我建议在编译器层面做bank分配优化,尽量让同一个Warp内的线程访问不同的bank。硬件层面也可以加一个bank冲突检测器,在冲突时插入气泡。
寄存器文件的优化技巧:
- 分层寄存器文件:把寄存器分成L0和L1两级。L0是每个lane私有的小寄存器文件,延迟低;L1是所有lane共享的大寄存器文件,容量大但延迟高。编译器可以把频繁使用的变量放到L0里。
- 写合并:多个lane写同一个寄存器时,可以合并成一次写操作,减少写端口压力。
- 时钟门控:不用的bank可以关掉时钟,降低静态功耗。
我记得有一次,我们在做低功耗GPU时,发现寄存器文件的功耗占了整个Shader Core的40%。后来我们加了时钟门控和写合并,把功耗降到了25%。嗯,这个优化效果还是很明显的。
4.5 四个模块的协同工作
最后,我们来看看这四个模块怎么配合。一个典型的指令执行流程是这样的:
- 取指:调度器从指令缓存中取出下一条指令。
- 译码:判断指令类型(ALU、SFU、访存等)。
- 读寄存器:从寄存器文件中读取源操作数。
- 执行:ALU或SFU执行运算。
- 写寄存器:将结果写回寄存器文件。
这里有个关键点:调度器需要知道每条指令的延迟,才能决定什么时候发射下一条指令。ALU指令延迟短,可以连续发射;SFU指令延迟长,调度器需要插入等待周期,或者切换到其他线程。
我建议在调度器里维护一个「延迟表」,记录每种指令的延迟周期数。这样调度器可以精确地安排指令发射,避免数据冒险。
避坑指南:我曾经在设计调度器时,忽略了SFU的写回延迟。结果导致ALU指令读到了SFU还没写回的数据,出现了数据冒险。后来我在调度器里加了一个「写回跟踪器」,记录每条指令的写回时间,才解决了这个问题。
好了,这一章的内容就到这里。ALU阵列、SFU、调度器、寄存器文件,这四个模块是Shader Core的基石。设计得好,你的GPU就能跑得又快又省电;设计得不好,那就是各种坑等着你踩。下一章我们聊聊纹理单元和缓存系统,那也是GPU性能的关键。