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 标量与向量的协同工作

标量单元和向量单元不是各干各的,它们需要紧密配合。我见过一些设计,标量和向量完全独立,结果调度起来乱七八糟。

典型的协同模式:

  1. 标量计算地址,向量搬运数据:标量算好内存地址,向量单元去load/store
  2. 标量处理控制流,向量执行计算:if条件由标量判断,向量单元根据结果执行或跳过
  3. 标量做归约,向量做并行:比如求和操作,向量单元并行算部分和,标量单元最后汇总

实际案例:我在一个图形渲染项目中,发现着色器里有很多“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%。

所以说,设计这东西,不能光看频率,要看实际吞吐。你想想看,是不是这个理?