3、性能的起点:算子实现方式对芯片利用率的影响
聊到芯片性能,很多人第一反应是看算力——比如TOPS、TFLOPS这些数字。但我得说句实话:标称算力只是纸面数据,真正决定你能跑出多少性能的,是算子怎么实现的。
拿矩阵乘法来说。这是深度学习里最核心的算子,没有之一。卷积可以拆成矩阵乘,全连接层本质就是矩阵乘,甚至Transformer里的注意力机制也离不开它。所以,咱们就用矩阵乘法当例子,把算子实现方式对芯片利用率的影响讲透。
3.1 最直观的写法:三重循环
先看一个最朴素的矩阵乘法实现。假设我们要计算 C = A × B,其中 A 是 M×K,B 是 K×N,C 是 M×N。
// naive 实现:三重循环
for (int i = 0; i < M; i++) {
for (int j = 0; j < N; j++) {
float sum = 0.0f;
for (int k = 0; k < K; k++) {
sum += A[i * K + k] * B[k * N + j];
}
C[i * N + j] = sum;
}
}
这段代码,数学上完全正确。但你要是真拿它去跑大矩阵,比如 1024×1024,我保证你会被性能吓到——可能连芯片理论算力的 1% 都跑不出来。
为什么会这样?
问题出在内存访问模式上。你看内层循环,每次都要从 A 和 B 里取数据。A 是行连续访问,还行。但 B 是按列访问的——每次 k 变化,B 的地址就跳 N 个元素。这在内存里是跳跃的,缓存根本没法好好工作。
核心问题:naive 实现让计算单元大部分时间在等数据,而不是真正干活。芯片利用率极低。
我在项目中遇到过类似情况。有一次团队用 naive 实现跑一个 2048×2048 的矩阵乘,在理论算力 10 TFLOPS 的 GPU 上,实际只跑出 0.3 TFLOPS。当时大家还以为是硬件有问题,查了半天才发现是代码写法太"老实"了。
3.2 优化实现:让数据流动起来
那优化实现长什么样?核心思路就一句话:让数据尽量待在缓存里,别老往内存跑。
具体怎么做?分块(tiling)。把大矩阵切成小块,让每一块都能塞进缓存。计算完一块再换下一块。
// 优化实现:分块 + 循环重排
#define BLOCK_SIZE 32
for (int i = 0; i < M; i += BLOCK_SIZE) {
for (int j = 0; j < N; j += BLOCK_SIZE) {
for (int k = 0; k < K; k += BLOCK_SIZE) {
// 计算子块 C[i:i+BLOCK_SIZE][j:j+BLOCK_SIZE]
for (int ii = i; ii < i + BLOCK_SIZE; ii++) {
for (int jj = j; jj < j + BLOCK_SIZE; jj++) {
float sum = 0.0f;
for (int kk = k; kk < k + BLOCK_SIZE; kk++) {
sum += A[ii * K + kk] * B[kk * N + jj];
}
C[ii * N + jj] += sum;
}
}
}
}
}
这段代码看起来多了两层循环,但性能可能提升 10 倍以上。为什么?
- 数据局部性好了:A 和 B 的子块都能放进 L1 缓存,反复使用
- 内存访问连续了:内层循环里,A 和 B 都是连续访问
- 计算密度高了:每次取数据都能做多次计算,而不是取一次算一次
我的经验:分块大小不是随便选的。我一般先看芯片的 L1 缓存大小,再算一下能放下多大的子块。比如 L1 是 32KB,float 占 4 字节,那 BLOCK_SIZE 取 32 左右比较合适——32×32×4 = 4KB,两块就是 8KB,还有余量给其他数据。
3.3 两种实现的芯片利用率对比
咱们用数据说话。假设芯片理论算力 10 TFLOPS,内存带宽 1 TB/s,计算一个 1024×1024 的矩阵乘。
| 指标 | Naive 实现 | 优化实现 | 提升倍数 |
|---|---|---|---|
| 实际算力 (GFLOPS) | 120 | 8,500 | ~70x |
| 芯片利用率 | 1.2% | 85% | ~70x |
| 内存访问量 (GB) | 24 | 0.75 | ~32x |
| 执行时间 (ms) | 1,800 | 25 | ~72x |
看到没?同样的硬件,同样的数学运算,只是换了个实现方式,性能差了 70 倍。这就是算子实现的意义——它决定了芯片到底是在"全力工作"还是在"摸鱼等数据"。
我曾经踩过的坑:有次做嵌入式芯片的算子库,我直接用了 naive 实现去跑 512×512 的矩阵乘。结果芯片发热严重,但性能就是上不去。后来用 profiler 一看,计算单元利用率不到 5%,大部分时间都在 stall。从那以后,我写任何算子都会先想清楚数据流,再动手写代码。
3.4 从矩阵乘看算子优化的通用原则
矩阵乘的优化思路,其实可以推广到几乎所有算子。我总结了几条通用原则:
- 减少内存访问:能复用就别重复加载,能合并就别分开访问
- 提高计算密度:每次取数据尽量多做几次运算,别取一次算一次
- 匹配硬件特性:了解芯片的缓存大小、向量化宽度、内存带宽,然后针对性地设计算法
- 利用并行性:不管是多核、SIMD 还是流水线,尽量让所有计算单元同时工作
说白了,算子优化的本质就是让数据流动的速度跟上计算的速度。硬件算力再高,数据送不进去也是白搭。
3.5 本章知识体系
下面这张图,把本章的核心逻辑串起来了:
从这张图可以看得很清楚:同样的硬件,不同的实现方式,芯片利用率可以差一个数量级。naive 实现让芯片"吃不饱",优化实现让芯片"跑满"。这就是为什么说算子库是连接算法和硬件的桥梁——桥修得好不好,直接决定了车能跑多快。
好了,这一章就聊到这儿。矩阵乘法的例子虽然简单,但它背后的优化思想——数据复用、分块、缓存友好——是所有高性能算子的基础。下一章咱们会深入看看,除了分块之外,还有哪些更高级的优化技巧。