第二章 硬件基础与计算模型:CPU、GPU、NPU架构差异,SIMD/SIMT模型,内存层次结构
做算子优化,说白了就是跟硬件打交道。你写出来的代码跑得快不快,很大程度上取决于你懂不懂底层的硬件是怎么干活的。我见过不少同学,算法功底扎实,但一上板子就懵,性能怎么调都上不去。原因很简单——不了解硬件的脾气。
这一章,我们就把CPU、GPU、NPU这三兄弟的底裤扒开看看。顺便聊聊SIMD和SIMT这两个核心计算模型,以及那个让人又爱又恨的内存层次结构。
2.1 CPU、GPU、NPU:三兄弟的恩怨情仇
先问个问题:为什么同样的矩阵乘法,在GPU上跑得飞快,在CPU上就慢吞吞?
答案其实就四个字——设计目标不同。
2.1.1 CPU:全能选手
CPU的设计哲学是「低延迟」。它要处理各种乱七八糟的任务,从操作系统调度到浏览器渲染,什么活都得接。所以CPU有强大的控制单元、大容量的缓存、复杂的分支预测器。
我个人习惯把CPU比作一个「特种兵」——单兵作战能力极强,但数量有限。一个现代CPU核心,能同时执行的指令也就那么几条。你让它跑一个简单的加法,它可能花几十个周期;但让它处理一个if-else分支,它比谁都溜。
核心特点:
- 控制单元占比大(约40%面积)
- 缓存层级多(L1/L2/L3)
- 擅长串行任务和复杂控制流
- 并行能力有限(核心数通常4-64个)
2.1.2 GPU:并行怪兽
GPU的设计哲学是「高吞吐」。它不在乎单个任务跑得快不快,它在乎的是——我能不能同时处理一万个任务?
你想想看,渲染一张1920x1080的图片,需要处理超过200万个像素。每个像素的计算逻辑几乎一样,只是数据不同。这种场景,CPU来干就是杀鸡用牛刀。GPU就不一样了,它有成百上千个简单核心,每个核心只管埋头干活。
我在项目中遇到过一个问题:用CPU跑一个图像滤波算子,优化到极致也就30fps。换成GPU,随便写写就跑到120fps。差距就是这么夸张。
核心特点:
- 控制单元极简(约5%面积)
- 缓存相对较小,但带宽极高
- 擅长数据并行任务
- 核心数可达数千个
2.1.3 NPU:专用加速器
NPU是近几年才火起来的。它比GPU更极端——干脆把控制单元砍到几乎为零,只保留计算单元和本地存储。
说白了,NPU就是为神经网络量身定做的。它内部有大量的乘加阵列(MAC Array),专门做矩阵乘法。我记得第一次用NPU跑卷积时,那个速度让我倒吸一口凉气——同样的算子,比GPU还快3倍,功耗却只有十分之一。
但NPU有个致命弱点:灵活性差。你让它跑个非矩阵运算,它立马歇菜。所以NPU通常作为协处理器,跟CPU配合使用。
我的建议:做算子优化时,先搞清楚目标硬件是哪一类。CPU优化重点在指令级并行和缓存利用;GPU优化重点在线程调度和访存合并;NPU优化重点在数据排布和计算阵列利用率。
2.2 SIMD vs SIMT:两种并行哲学
这两个概念,我当年学的时候也绕了很久。咱们用大白话讲清楚。
2.2.1 SIMD:单指令多数据
SIMD的意思是:一条指令,同时操作多个数据。
CPU上的SIMD指令集,比如x86的AVX、ARM的NEON,就是典型代表。一条AVX-512指令,可以同时处理16个32位浮点数。你想想看,本来要循环16次的加法,现在一条指令搞定。
但SIMD有个坑:数据必须连续对齐。我曾经在优化一个音频处理算子时,数据没对齐,性能直接腰斩。后来排查了半天,才发现是内存地址没按16字节对齐。
// SIMD示例:使用AVX做向量加法
// 注意:数据必须16字节对齐
float a[8] __attribute__((aligned(32))) = {1,2,3,4,5,6,7,8};
float b[8] __attribute__((aligned(32))) = {8,7,6,5,4,3,2,1};
float c[8] __attribute__((aligned(32)));
__m256 va = _mm256_load_ps(a); // 加载8个float
__m256 vb = _mm256_load_ps(b);
__m256 vc = _mm256_add_ps(va, vb); // 一次完成8个加法
_mm256_store_ps(c, vc);
2.2.2 SIMT:单指令多线程
GPU用的是SIMT模型。它跟SIMD最大的区别是:每个线程有自己的程序计数器。
什么意思呢?在SIMD里,所有数据必须走同一个分支。但在SIMT里,每个线程可以独立跳转。当然,如果同一个warp(线程束)里的线程走了不同分支,性能就会下降——这叫「warp divergence」。
我记得有一次优化一个ReLU算子,在GPU上跑。ReLU有个if判断:大于0保留,小于0置0。这个if导致warp divergence,性能只有理论峰值的一半。后来改成用max函数替代if,问题就解决了。
避坑指南:我曾经在写GPU算子时,没注意warp大小(NVIDIA是32线程一个warp)。循环次数不是32的倍数,导致最后一个warp有空闲线程,性能损失了约15%。后来我学会了padding,把数据补齐到warp大小的整数倍。
2.3 内存层次结构:性能的隐形杀手
做算子优化,最容易被忽视的就是内存。我见过太多人,算法算得飞起,但数据搬运花了90%的时间。
2.3.1 金字塔模型
现代计算机的内存是个金字塔结构:
| 层级 | 容量 | 延迟 | 带宽 |
|---|---|---|---|
| 寄存器 | ~几百字节 | ~1 cycle | 极高 |
| L1缓存 | ~32KB | ~3 cycles | ~1TB/s |
| L2缓存 | ~256KB | ~10 cycles | ~500GB/s |
| L3缓存 | ~几MB | ~30 cycles | ~200GB/s |
| 主存 | ~几GB | ~100 cycles | ~50GB/s |
看到没?从主存取数据比从寄存器取数据慢了100倍。这就是为什么优化要尽量把数据留在缓存里。
2.3.2 缓存友好 vs 缓存不友好
我举个例子。两个矩阵相乘,有几种遍历方式:
// 缓存友好版本:按行访问
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
float sum = 0;
for (int k = 0; k < N; k++) {
sum += A[i][k] * B[k][j]; // B按列访问,缓存不友好
}
C[i][j] = sum;
}
}
// 优化版本:分块 + 转置
// 把B转置后,按行访问
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
float sum = 0;
for (int k = 0; k < N; k++) {
sum += A[i][k] * B_trans[j][k]; // 现在都是按行访问
}
C[i][j] = sum;
}
}
第一个版本,B矩阵按列访问,每次访问都会触发缓存缺失。第二个版本,先把B转置,然后全部按行访问,缓存命中率大幅提升。在我的测试中,N=1024时,性能差距能达到5倍。
小技巧:写算子时,尽量让数据访问模式是「空间局部性」的——也就是访问相邻地址。这样硬件会自动预取,把数据提前搬到缓存里。
2.4 知识体系总览
下面这张图,是我自己总结的算子优化知识体系。每次做新项目前,我都会对照着看一遍,确保没有遗漏。
嗯,这张图基本覆盖了算子优化的核心要点。你仔细看,三个分支虽然硬件不同,但底层逻辑是相通的——都是想办法让计算单元别闲着,让数据搬运别成为瓶颈。
2.5 本章小结
这一章我们聊了CPU、GPU、NPU的架构差异,SIMD和SIMT的计算模型,以及内存层次结构。说白了,做算子优化就是三件事:
- 理解硬件特性——知道它擅长什么,不擅长什么
- 利用并行能力——SIMD、SIMT、多核,能用的都用上
- 管理数据流动——让数据尽量待在离计算单元近的地方
下一章,我们会深入具体的算子实现,从最简单的向量加法开始,一步步优化到极致。到时候你会发现,今天讲的这些硬件知识,全都会用上。
想要更多资料,联系可可