3. 计算单元设计:流处理器簇(SM/CU)的内部结构、标量与向量单元设计
好,咱们今天聊聊GPU里最核心的模块——流处理器簇。NVIDIA叫它SM,AMD叫它CU,说白了就是一群计算单元抱团组成的计算集群。我个人习惯叫它“计算簇”,因为这样更直观。
你想想看,一个GPU里有几十上百个这样的簇,每个簇里又有几十上百个核心。这架构要是设计不好,性能直接崩盘。我在项目中遇到过好几次,因为簇内调度没做好,导致大量ALU空转,功耗上去了性能却没涨,那叫一个憋屈。
3.1 流处理器簇的整体架构
一个典型的SM/CU内部,大概包含这么几块:
- 指令缓存:存放要执行的指令,一般几十KB
- 线程调度器:负责把线程分配给执行单元,这是灵魂部件
- 标量单元:处理单个数据的运算,比如地址计算、控制流
- 向量单元:处理批量数据的运算,比如矩阵乘法、着色器计算
- 共享内存:簇内所有线程都能访问的高速存储
- 寄存器文件:每个线程的私有存储,容量巨大
嗯,这里要注意:共享内存和寄存器文件是性能的关键瓶颈。我曾经在一个项目里,因为寄存器分配策略没做好,导致线程数被砍了一半,性能直接腰斩。从那以后,我对寄存器文件的设计就特别敏感。
核心观点:SM/CU的设计本质是在“计算密度”和“调度灵活性”之间找平衡。计算单元堆多了,调度器忙不过来;调度器太复杂,面积和功耗又上去了。
3.2 标量单元设计
标量单元,说白了就是处理单个数据的。它不像向量单元那样一次处理一堆数,而是每次只处理一个。但别小看它,很多关键操作都靠它。
标量单元主要干这些活:
- 地址计算:计算内存访问地址,比如 load/store 指令的地址
- 控制流处理:if-else、循环跳转这些分支逻辑
- 整数运算:32位整数加减乘除,比较操作
- 特殊函数:比如求倒数、求平方根(用近似算法)
我个人习惯把标量单元设计成“轻量级”的。为什么?因为GPU里大部分计算是向量运算,标量只是辅助。你想想看,如果标量单元做得太重,面积和功耗都浪费了。
举个例子,一个典型的标量单元数据通路:
// 标量ALU的简化Verilog描述
module scalar_alu (
input [31:0] a, b,
input [3:0] opcode, // 操作码
output [31:0] result,
output zero, // 结果为零标志
output carry // 进位标志
);
always @(*) begin
case (opcode)
4'b0000: result = a + b; // 加法
4'b0001: result = a - b; // 减法
4'b0010: result = a & b; // 按位与
4'b0011: result = a | b; // 按位或
4'b0100: result = a ^ b; // 按位异或
4'b0101: result = a << b[4:0]; // 左移
4'b0110: result = a >> b[4:0]; // 右移
default: result = 32'b0;
endcase
end
assign zero = (result == 32'b0);
assign carry = (a + b) < a; // 加法进位检测
endmodule
你看,这个标量ALU很简单,就几个基本操作。我在实际项目中,还会加一个“乘加”操作,因为地址计算里经常用到 base + offset * stride 这种模式。
避坑指南:标量单元的延迟要尽量低。我曾经因为标量单元用了多级流水,导致地址计算延迟太大,向量单元的load/store指令被卡住,整体性能掉了15%。后来改成单周期标量ALU,问题就解决了。
3.3 向量单元设计
向量单元才是GPU的“重头戏”。它一次处理多个数据,比如16个、32个甚至64个。说白了,GPU能跑得快,全靠向量单元在“堆算力”。
向量单元的核心结构:
- ALU阵列:多个ALU并行排列,每个处理一个数据元素
- 寄存器堆:向量寄存器,每个寄存器存一组数据
- 数据通路:把数据从寄存器送到ALU,再把结果写回去
- 掩码逻辑:控制哪些ALU真正执行,哪些被跳过
我记得有一次做架构评估,对比了16宽和32宽的向量单元。16宽的面积小、频率高,但指令吞吐低;32宽的吞吐高,但面积大、频率上不去。最后选了24宽——折中方案,效果反而最好。
向量单元的数据流大概是这样的:
// 向量单元数据流示意(伪代码)
// 假设向量宽度为16
// 从向量寄存器读取数据
vec_reg_read(vreg_a, addr_a, data_a[0..15]);
vec_reg_read(vreg_b, addr_b, data_b[0..15]);
// 16个ALU并行计算
for (i = 0; i < 16; i++) {
if (mask[i]) { // 掩码控制
result[i] = alu_op(data_a[i], data_b[i], opcode);
} else {
result[i] = data_a[i]; // 跳过,保持原值
}
}
// 写回向量寄存器
vec_reg_write(vreg_dest, addr_dest, result[0..15]);
你想想看,这个循环在硬件里是并行执行的,16个ALU同时干活。这就是GPU比CPU快的原因——CPU一次只能处理1个数据,GPU一次处理16个甚至更多。
3.4 标量与向量的协同工作
标量单元和向量单元不是各干各的,它们需要紧密配合。我见过一些设计,标量和向量完全独立,结果调度起来乱七八糟。
典型的协同模式:
- 标量计算地址,向量搬运数据:标量算好内存地址,向量单元去load/store
- 标量处理控制流,向量执行计算:if条件由标量判断,向量单元根据结果执行或跳过
- 标量做归约,向量做并行:比如求和操作,向量单元并行算部分和,标量单元最后汇总
实际案例:我在一个图形渲染项目中,发现着色器里有很多“if (condition) { ... } else { ... }”的分支。标量单元先算出condition,然后生成掩码送给向量单元。向量单元根据掩码,一部分ALU执行if分支,另一部分执行else分支。这样虽然效率不是100%,但比全部串行快多了。
3.5 设计中的权衡与取舍
设计SM/CU时,有几个关键权衡:
| 设计维度 | 偏向标量 | 偏向向量 | 我的建议 |
|---|---|---|---|
| 寄存器分配 | 标量寄存器少,向量寄存器多 | 向量寄存器多,标量寄存器少 | 按应用场景定,图形偏向向量,计算偏向标量 |
| 调度策略 | 标量优先调度 | 向量优先调度 | 混合调度,标量指令插空执行 |
| 面积占比 | 标量占10%,向量占90% | 标量占5%,向量占95% | 标量别太少,否则地址计算成瓶颈 |
| 功耗优化 | 标量可关断 | 向量可关断 | 都做时钟门控,按需开启 |
嗯,这里要特别说一下寄存器文件。向量单元的寄存器文件是“吃面积大户”。一个32宽、32位深的向量寄存器文件,面积比标量的大几十倍。我见过一个设计,寄存器文件占了整个SM面积的40%以上。所以寄存器分配策略一定要优化,不然面积爆炸。
警告:不要盲目增加向量宽度。宽度翻倍,寄存器文件面积翻倍,数据通路布线复杂度翻倍,但性能可能只提升30-50%。因为很多应用的数据并行度没那么高。我建议先做应用分析,再定宽度。
3.6 总结与个人经验
好了,关于SM/CU的内部结构,我总结几点:
- 标量单元要轻量、低延迟,主要负责地址计算和控制流
- 向量单元要重、高吞吐,主要负责并行计算
- 两者要协同工作,不能各自为政
- 寄存器文件是面积和功耗的关键,设计时要精打细算
最后分享一个我踩过的坑。有一次设计向量单元,我把ALU的延迟设成了4个周期,想着频率能跑高一点。结果发现,因为数据依赖,很多指令要等前一条的结果,流水线经常stall。后来改成2周期ALU,频率降了5%,但性能反而提升了20%。
所以说,设计这东西,不能光看频率,要看实际吞吐。你想想看,是不是这个理?