一、Roofline 性能分析模型
1.1 问题的提出:计算与访存的瓶颈
当设计一个 CPU 或 GPU 时,衡量其实际性能不能只看浮点计算能力。一个处理器的实际吞吐量受两个因素共同制约:
- 计算能力:每秒能执行多少浮点操作(FLOPS)
- 存储带宽:每秒能从主存中读取多少字节数据
如果数据读不进来,计算单元再快也只能等待。反过来,如果带宽足够但算力跟不上,也无法充分发挥硬件性能。
1.2 运算密度(Arithmetic Intensity)
Roofline 模型(Roofline Model)的核心概念是运算密度(Arithmetic Intensity):
\[\text{Arithmetic Intensity} = \frac{\text{执行的浮点操作数}}{\text{从主存访问的字节数}}\]通俗理解:从内存中拉一个字节数据进来,你对它做了多少次浮点运算。
- 如果拉一个字节做了 3 次浮点运算,运算密度就是 3
- 如果拉两个字节只做了一次运算,运算密度就是 0.5
这个图之所以叫”roofline”(屋檐模型),就是因为它的形状像房屋的屋顶——先是一个斜坡上升,到了顶点以后就平坦了。在斜坡区域,性能受带宽限制(数据读不进来);在平坦区域,性能受计算能力限制(算力已经拉满,再多的带宽也提不上去)。
1.3 Roofline 曲线的含义
在 Roofline 坐标系中:
- 横轴:运算密度(FLOPs / Byte)
- 纵轴:可达到的浮点性能(GFLOPS / 秒)
曲线的形态:
- 上升段(带宽受限区):运算密度较低时,每秒钟能处理多少浮点运算受到数据传入速度的限制。此时提升运算密度能直接带来性能提升。
- 平坦段(计算受限区):当运算密度足够大之后,性能达到计算能力的物理上限,不再增长。此时瓶颈在于 ALU 算力。
设计的 CPU 或 GPU 如果落在 roofline 曲线下方,说明要么带宽没达到算法要求,要么计算力没达到算法要求——总而言之是做得不够好。落在曲线上,才说明把硬件的实力发挥到了最大。
1.4 实际应用:神经网络中的运算密度
在深度学习中,运算密度又被称为权重重用(Weight Reuse):
- 拉入一个权重字节,需要做几次乘法和加法?
- 稠密矩阵(如全连接层):运算密度较高
- 稀疏矩阵:拉来的数据只做 O(1) 次操作,运算密度低
- FFT 等算法:运算密度为 $O(\log n)$
1.5 能效比(Performance per Watt)
在实际部署中,每瓦电能能产出多少算力至关重要。运行 AI 计算中心受限于电力供应,电费直接决定运营成本。TPU 等专用芯片相对 GPU/CPU 在能效比上有显著优势。
厂商提供的 benchmark 数据需要谨慎解读。公开测试集可能被编译器、运行时或硬件配置针对性优化,真实用户体验往往与跑分存在差距。
二、GPU 体系结构
2.1 CPU 与 GPU 的设计哲学对比
| 维度 | CPU | GPU |
|---|---|---|
| 核心数 | 少(2-8 个) | 极多(240 个以上) |
| 控制逻辑 | 复杂(乱序执行、分支预测、转发) | 极其简单 |
| Cache | L1 32KB + L2 256KB + L3 8MB | L1 64KB + L2 1MB(意思一下) |
| ALU 数量 | 少而精 | 大量浮点 ALU |
| 寄存器 | 适量 | 极多(1024),高带宽 |
| 线程切换开销 | 约 1000 个 cycle | 约 1 个 cycle |
| 设计目标 | 让每条指令执行得最快 | 让吞吐量最大 |
CPU 是把晶体管花在控制逻辑和 cache 上,让一条指令一条指令地跑得飞快。GPU 是把晶体管全部堆在 ALU 上,主打一个”量大”——一幅图像处理完就扔掉,下一帧来了继续,不需要复杂的 control 和巨大的 cache。
2.2 GPU 的层级组织
GPU 内部的层级结构类似于军队编制:
1
2
3
4
5
GPU (整体)
└── TPC (Thread Processing Cluster) × N
└── SM (Streaming Multiprocessor) × 3 (per TPC)
└── SP (Streaming Processor) × 8 (per SM)
└── ALU (FP Unit + INT Unit)
- TPC:Thread Processing Cluster,线程处理集群
- SM:Streaming Multiprocessor,流式多处理器,是 GPU 的基本调度单元
- SP:Streaming Processor,流式处理器,实际执行计算的单元
以 NVIDIA Tesla C1060(约 15 年前)为例:240 个 core,Core clock: 1.296 GHz,On-board memory: 4GB,Memory IO: 512-bit, 800MHz GDDR3。
GPU 架构就是”套娃”,一环套一环,但没有套出什么高精度的复杂逻辑。这些 SM、SP 之间的通信只能通过 interconnect network(互连网络)来进行,本质上是通过共享 memory 来实现协作。
2.3 SM 内部的 SP 连接
在一个 SM 内部有 8 个 SP。如果所有 SP 都通过共享 memory 来通信,意味着 8 个单元同时访问同一块 memory——总线仲裁的开销会非常大。
直观的想法:让 SP 之间直接通过寄存器互联。
- 全连接:每个 SP 和另外 7 个都连线——图太复杂,画不下
- 分组连接:分成两组,同一分组内的 SP 在一侧,对面的在另一侧,通过交叉连接通信
- 编译器负责把数据交互频繁的线程分配到能直接通信的 SP 上,减少跨越 memory 的访问
在芯片内部这个尺度上,cache 和 memory 本质上是用相同的晶体管设计的,叫什么都一样。关键在于数据通路的组织方式——寄存器间的直连是最快的通信方式。
2.4 GPU 上的并行层次
回顾在 CPU 上学过的并行方式:
- 流水线并行(Pipeline):指令级流水
- 指令级并行(ILP):多条独立指令同时发射执行
- 数据级并行(DLP / SIMD):一条指令同时处理多个数据
- 线程级并行(TLP / MIMD):多线程各自执行不同任务
GPU 在这些基础上发展出了 SIMT(Single Instruction Multiple Thread)模型。
三、SIMT 执行模型
3.1 从循环到线程:SIMT 的核心思想
在 GPU 编程中,一个 for 循环的每次迭代天然独立(如 C[i] = A[i] + B[i]),因此可以将每个迭代变成一个线程:
1
2
3
4
5
6
7
8
// CPU 上:一个 for 循环
for (int i = 0; i < N; i++) {
C[i] = A[i] + B[i];
}
// GPU 上:N 个线程,每个线程执行相同的代码
int tid = blockIdx.x * blockDim.x + threadIdx.x;
C[tid] = A[tid] + B[tid];
把 CPU 线程的传统概念扔掉!在 GPU 里,一个线程的代码可能就是 10-20 行,就是一个循环体而已。一万个线程,每个线程的代码完全一样,只是处理的数据不一样。
3.2 Warp:硬件动态分组的线程束
Warp 的定义:一组执行相同指令(same PC)的线程,由硬件动态划分为一个 Warp。
- 一个 Warp 通常包含 32 个线程
- Warp 内的所有线程在同一时刻执行同一条指令(PC 相同)
- 只要线程们还走在同一行代码上,它们就在同一个 Warp 里
GPU 会根据线程当前执行位置,把位于相同控制流路径上的线程动态组织成 Warp,使它们以相同 PC 执行同一条指令。
3.3 SIMT vs SIMD
| 维度 | SIMD | SIMT (SPMD on SIMD) |
|---|---|---|
| 指令粒度 | 一条向量指令显式操作多个数据 | 一个 program,硬件将其化为多个 thread |
| 数据组织 | 通过向量寄存器 VLD/VADD/VST | 每个 thread 有自己的 load/add/store |
| 线程概念 | 不暴露线程 | thread 是一等公民 |
| 灵活性 | 数据必须是连续对齐的 | 线程可以各自独立执行(有分支也可以) |
SIMT 的全称是 Single Instruction Multiple Thread,也叫 SPMD(Single Program Multiple Data)在 SIMD 硬件上的映射。
SIMT 的关键好处有两个——第一,把代码拆成很多独立的线程,大家一人一个 PC,互不影响;第二,因为灵活了,可以随时把同路的线程重新打包成 Warp 一起干活。就像”先分家过日子,再随时根据需要组团”。
3.4 为什么需要这么多线程
GPU 的线程切换极其轻量——理论上一个 cycle 就能完成切换(CPU 需要约 1000 个 cycle)。但这也意味着:线程数必须大于 1000 时,GPU 才能被有效利用。如果只有几十个线程在 GPU 上跑,频繁的切换开销会严重拖慢性能。
比较:
- CPU:起 4-5 个线程就差不多了,写太多控制不了
- GPU:轻轻松松几千几万个线程,主打一个”量大管饱”
3.5 线程的层级组织(CUDA 中的 thread/block/grid)
CUDA 将线程组织为三级:
1
2
3
Grid(一组 block)
└── Block(互相协作的线程组)
└── Thread(基本执行单元)
- Thread:最基本的执行单元,代码只有十几二十行
- Block:可以互相通信、同步的线程组,同一 block 内的线程可以通过 shared memory 共享数据
- Grid:多个 block 组成的整体。不同 block 之间不能直接通信,只能通过 global memory 交换数据
Block 之间有共享 memory,Grid 之间通过 global memory。这就像领导常说的——”有问题先内部沟通、内部解决,实在不行了再上报”。因为上报一次代价非常大(访问 global memory 很慢)。
四、CUDA 软件生态
4.1 NVIDIA 的垄断优势
NVIDIA 真正不可替代的竞争壁垒不是它的 GPU 硬件(硬件谁都可以生产),而是 CUDA 软件生态:
- CUDA 驱动(Driver):底层,面向硬件
- CUDA 运行时(Runtime):类似 Java 虚拟机的中间层
- CUDA 库(Libraries):cuBLAS、cuDNN 等上层数学库
国产 GPU 最大的问题不是芯片做不出来,而是用户用惯了 CUDA,让人家重新用你的编程框架写代码——”我一个普通用户,凭什么让我干这种事?”
4.2 二进制翻译(Binary Translation)
为了使 CUDA 程序能在国产 GPU 上运行,一种直接的方案是:
- 用户仍然用 CUDA 编写和编译代码
- 编译出的二进制代码本来只能在 NVIDIA GPU 上执行
- GPU 厂商在底层做一个二进制翻译器,将 NVIDIA GPU 的二进制指令翻译成自己 GPU 的指令
- 用户无感知,仍然跑原来的 CUDA 代码
这个想法很直接,在 CPU 领域已经用得很成熟了——国产 CPU 要运行 Windows 的应用程序,很多也走二进制翻译这条路。
但 NVIDIA 从 2024 年开始,在知识产权条款中明确禁止了对 CUDA 程序做二进制翻译——”你用我的环境编译,但不买我的 GPU——没毛病,但不许这么干。”
4.3 CUDA 编程的基本模式
1
2
3
4
5
6
CPU 端(Host) GPU 端(Device)
──────────── ────────────
1. 分配 GPU 内存
2. CPU→GPU 数据拷贝 → 3. 启动 kernel(成千上万线程并发)
4. GPU→CPU 结果拷贝 ←
5. 释放 GPU 内存
原来的 for 循环:
1
2
for (int i = 0; i < N; i++)
C[i] = A[i] + B[i];
变成 CUDA kernel 后,把 i 替换为 threadIdx.x + blockIdx.x * blockDim.x,循环被”展开”为 N 个线程并行执行。
五、CPU-GPU 统一内存寻址
5.1 独立 vs 统一地址空间
当多块 GPU 各自拥有独立显存时,每个 GPU 的地址空间都从 0x00 到 0xFF…FF,编程时就需要显式管理:数据在哪个 GPU 上?CPU 如何给指定 GPU 传数据?GPU 之间如何交换数据?
从程序员的角度,统一地址空间最简单——不管数据放在 CPU 还是 GPU,只管申请内存、填指针、做 memory copy,底层怎么走不关心。但分离的地址空间虽然对系统设计者来说最”直接”,却给编程带来了极大的心智负担。
5.2 统一虚拟寻址(Unified Virtual Addressing)
2013 年,NVIDIA 宣布统一内存(Unified Memory)有”光明的未来”(a bright future of unified memory)。
核心思想:CPU 和 GPU 共享同一个 64 位的虚拟地址空间。
- CPU 的 16GB 内存和 GPU 的 16GB 显存在同一个地址空间中各占一段
- 程序员只需调用
cudaMallocManaged()分配统一内存 - 数据迁移由驱动和硬件自动完成
2013 年的时候大家说”有个 bright future”,十多年后回头看,现在所有买到的 GPU 都是这个 bright future 了。CPU 的虚拟地址空间都搞了多少年了,GPU 这边确实晚了很久,但一旦做出来,就让人觉得”早干什么去了”——做出来之后多有趣。
5.3 GPU 内存层级
| 层级 | 访问速度 | 共享范围 | 说明 |
|---|---|---|---|
| 寄存器(Register) | 最快 | Thread 私有 | 每个线程自己用 |
| 共享内存(Shared Memory) | 很快 | Block 内共享 | 同一 block 的线程协作 |
| 全局内存(Global Memory) | 较慢 | 所有线程 | 跨 block/跨 grid 通信 |
| 主机内存(Host Memory) | 最慢 | CPU 端 | 需要通过 PCIe 传输 |
等真做设计的时候,这些需求会一个一个自己冒出来——一开始忘了放共享 memory,写着写着发现不对,”来,加一块共享 memory”;后来又发现大家都需要跨组访问,”再加个 global memory”。硬件设计的需求就是这样一层一层自然涌现的。
六、GPU 互连:PCIe 与 NVLink
6.1 PCIe 的局限性
在一台服务器上,CPU 通过 PCIe 总线连接多块 GPU。PCIe 是一个通用总线:可以插 GPU(高速设备),也可以插声卡、打印机控制器(低速设备)。为了兼容各种速度的设备,PCIe 协议设计得比较复杂,这反过来限制了它在高速设备上的性能。
6.2 NVLink:NVIDIA 的高速互连
NVIDIA 设计了自己的专用互连协议 NVLink:不需要兼容低速设备,专门为高速 GPU-GPU 通信设计,速度比 PCIe 快很多。每一个想法都”不过分”——GPU 之间拉一条直连线不过分吧?拉四条也不过份吧?用 NVLink 连接 CPU 和 GPU 也不过份吧?这些需求都是实际使用中自然产生的。
6.3 多 GPU 的连接拓扑
4 个 GPU 的情况:每个 GPU 有 4 个 NVLink 端口。可以做到全连接(每个 GPU 与其他 3 个 GPU 都直连),同时留一个端口连接 CPU。拓扑越密集,通信越快,但成本越高。
8 个 GPU 的情况:每个 GPU 只有 4 个端口,必须设计拓扑,无法做到全互联。矩阵型连接:GPU 排列成网格。重要原则:尽量减少”跳数”(hop count)——从 GPU A 到 GPU B 最好只需一跳,不要经过中间 GPU 转发。
现在大模型时代,”一张嘴就是 1 万个 GPU”。1 万个 GPU 怎么连?这就是专门一节”互连网络”要讨论的内容。
七、SIMT 中的分支处理
7.1 分支发散问题
在 SIMT 模型中,所有线程执行相同的程序。但当程序中有 if-else 分支时,不同线程可能走向不同路径:
1
2
3
4
if (x[tid] != 0)
y[tid] = x[tid] - y[tid]; // 路径 A
else
y[tid] = z[tid]; // 路径 B
对于一个 32 线程的 Warp:某些线程走路径 A,另一些走路径 B——PC 不再相同,Warp 分裂了。
7.2 通过 Mask(谓词/屏蔽位)处理分支
GPU 处理分支的方法是:
- 汇集阶段:分支前,所有线程 PC 相同,是一个完整 Warp
- 分支阶段:PC 不同了,按照分支方向重新组合 Warp
- 执行阶段:在每个分支路径内部,线程又形成新的 Warp,用 mask(屏蔽位)标记该路径中哪些线程是活跃的
- 汇聚阶段:分支结束后,控制流重新汇聚,所有线程再次合并为统一的 Warp
7.3 SIMT Stack
GPU 使用 SIMT 栈(SIMT Stack)来管理分支:
- Control Flow Stack(控制流栈):保存分支后的返回地址(next PC)
- Active Mask(活跃屏蔽字):记录当前路径中有哪些线程是活跃的
- 进入分支时 push,退出分支时 pop
- 最终所有路径汇聚到同一条指令时,所有线程重新合并
分支不能预测怎么办?那就别预测了,不要硬来。GPU 的策略是——谁走 A 路就一起走 A,谁走 B 路就一起走 B,到汇聚点大家重新汇合。本质上就是不停地”分家-重组-再分家-再重组”。
八、Fermi 架构要点
Fermi(费米)架构是 NVIDIA 在 GPU 发展史上的重要里程碑,主要创新包括:
- SM 内部包含 32 个 core,分为两组(便于内部管理和共享 memory)
- 快速双精度浮点(Fast Double-Precision FP)
- GPU 存储器 64 位寻址和统一地址空间(与 CPU 统一地址空间)——这是 Fermi 架构最被称道的特性
- L1 cache 64KB 可配置(可部分配置为 shared memory)
- 支持 ECC 内存(错误校验)
- 每 SM 有 4 个 Special Function Unit(特殊函数单元)