嘿,各位未来的 C++ 极客,还有那些正在被大模型推理折磨得死去活来的工程师们,大家好!
欢迎来到今天的“深度技术脱口秀”。我是你们的老朋友,那个在内存墙面前撞得头破血流但依然死磕性能的资深编程专家。
今天我们不聊那些虚头巴脑的理论,我们聊点硬核的。聊聊怎么让 C++ 像法拉利一样在内存的泥潭里飞驰。我们的主题是:在 C++ 推理引擎中,如何利用多线程分块(Tiling)算法,让你的大矩阵乘法(GEMM)跑出光速,同时让 CPU 缓存笑得合不拢嘴。
准备好了吗?让我们把那些繁琐的教科书扔进垃圾桶,直接进入实战模式。
第一章:矩阵乘法的“贫穷”与“饥饿”
首先,让我们看看我们要解决的问题。假设你要计算两个矩阵 $A$ 和 $B$ 的乘积 $C = A times B$。
如果你是刚入门的菜鸟,你会写出这样的代码:
// 糟糕透顶的代码,请勿模仿
void naive_gemm(const float* A, const float* B, float* C, int N) {
for (int i = 0; i < N; ++i) {
for (int j = 0; j < N; ++j) {
float sum = 0.0f;
for (int k = 0; k < N; ++k) {
sum += A[i * N + k] * B[k * N + j];
}
C[i * N + j] = sum;
}
}
}
看起来挺对吧?数学上完全正确。但在现代 CPU 上,这简直是犯罪。
为什么?因为 CPU 的速度太快了,而内存太慢了。这种慢,不是堵车那种慢,而是你开着法拉利(CPU),却只能走乡间土路(内存带宽)。
在这个三层循环里,$k$ 循环在最内层。这意味着什么?意味着对于每一个 $i$ 和 $j$,你都要反复地去读取 $A$ 的第 $i$ 行和 $B$ 的第 $j$ 列。内存控制器会气得吐血:“喂!你这家伙,刚才不是才读过这个地址吗?怎么又来了?!”
这就叫缓存未命中。CPU 的 L1 缓存就像你口袋里的零钱,只有 32KB。对于大矩阵,数据早就被踢出去了。CPU 每次都得去 L2(256KB)、L3(甚至几 MB)甚至内存(GB 级别)去取数据。这一来一回,计算时间早就飞到九霄云外去了。
所以,我们的目标只有一个:让 CPU 看到的数据都在它的口袋(L1 Cache)里,让它别跑断腿。
第二章:分块(Tiling)——把大象装进冰箱
为了解决这个问题,计算机科学家发明了一个伟大的算法,叫做分块。
想象一下,你要打扫一个巨大的仓库(矩阵)。如果你拿着扫把,试图一次扫过整个仓库,你会累死,而且效率极低。更好的方法是:把仓库划分成一个个小隔间(Tile,比如 16×16 或 32×32),每次只打扫一个小隔间,把垃圾扫干净,再换下一个。
在矩阵乘法里,我们也这么做。不要试图一次性计算整个矩阵,而是把矩阵切成小块。
分块的核心思想:
把 $C = A times B$ 的计算变成:
对于每一个 Tile $c{ij}$:
对于每一个 Tile $a{ik}$:
对于每一个 Tile $b{kj}$:
计算 $c{ij} += a{ik} times b{kj}$
注意到了吗?循环的顺序变了。我们引入了 $ikj$ 的顺序。这有什么用?这能保证我们在计算 $c{ij}$ 时,需要的 $a{ik}$ 和 $b_{kj}$ 数据正好在缓存里!
让我们来写一段单线程的分块代码,感受一下指针算术的魅力:
// 稍微有点用,但还不够快
void tiled_gemm(const float* A, const float* B, float* C, int N, int TILE_SIZE) {
for (int i = 0; i < N; i += TILE_SIZE) {
for (int j = 0; j < N; j += TILE_SIZE) {
// 显式声明局部变量,告诉编译器我们要用寄存器
float c[TILE_SIZE][TILE_SIZE] = {0.0f};
for (int k = 0; k < N; k += TILE_SIZE) {
for (int ii = i; ii < i + TILE_SIZE; ++ii) {
for (int jj = j; jj < j + TILE_SIZE; ++jj) {
float a_val = A[ii * N + k];
for (int kk = k; kk < k + TILE_SIZE; ++kk) {
c[jj - j][kk - k] += a_val * B[kk * N + jj];
}
}
}
}
// 把计算好的 Tile 写回全局内存
for (int ii = i; ii < i + TILE_SIZE; ++ii) {
for (int jj = j; jj < j + TILE_SIZE; ++jj) {
C[ii * N + jj] = c[jj - j][ii - i]; // 注意这里的索引修正
}
}
}
}
}
专家点评: 这段代码好多了!你把计算拆成了小块,CPU 每次只需要把 a_val 和一小块 B 加载到 L1 缓存,计算完就扔掉。这大大提高了缓存命中率。但是,这依然是单线程的。在现代服务器上,你只用了 1% 的性能,简直是暴殄天物!
第三章:多线程——让 CPU 的所有核心都动起来
既然分块能提高缓存利用率,那我们能不能把分块并行化?
答案是肯定的,但这就像分蛋糕一样,得切得均匀。我们不能让线程 1 只算左上角,线程 2 只算右下角,那样会导致严重的负载不均衡,而且数据竞争会锁死你的程序。
我们要把整个矩阵的 $i$ 和 $j$ 维度都分块,然后把分好的块分配给不同的线程。
这里我们需要用到 std::thread 或者 OpenMP。为了代码的简洁和演示效果,我们用 OpenMP,因为它能自动处理线程调度。
// 多线程分块 GEMM (OpenMP 版本)
void multithreaded_tiled_gemm(const float* A, const float* B, float* C, int N, int TILE_SIZE) {
// #pragma omp parallel for collapse(2) 意思是:把两层循环折叠成一个大的并行任务
// schedule(static) 意思是:静态调度,均匀分配块
#pragma omp parallel for collapse(2) schedule(static)
for (int i = 0; i < N; i += TILE_SIZE) {
for (int j = 0; j < N; j += TILE_SIZE) {
// 每个线程都有自己的局部缓存,不用担心数据冲突
float c_local[TILE_SIZE][TILE_SIZE] = {0.0f};
for (int k = 0; k < N; k += TILE_SIZE) {
for (int ii = i; ii < i + TILE_SIZE; ++ii) {
for (int jj = j; jj < j + TILE_SIZE; ++jj) {
float a_val = A[ii * N + k];
for (int kk = k; kk < k + TILE_SIZE; ++kk) {
c_local[jj - j][kk - k] += a_val * B[kk * N + jj];
}
}
}
}
// 写回结果,注意这里需要加锁吗?
// 在 OpenMP 中,如果不指定 private,默认是 shared,但这里我们操作的是局部变量 c_local
// 只有最后写回 C 的操作需要考虑线程安全。由于 i, j 是循环变量,每个线程处理不同的块,
// 所以 C 的写操作在空间上是安全的,不需要互斥锁!
for (int ii = i; ii < i + TILE_SIZE; ++ii) {
for (int jj = j; jj < j + TILE_SIZE; ++jj) {
C[ii * N + jj] = c_local[jj - j][ii - i];
}
}
}
}
}
等等,专家,这里有个坑!
看到最后写回 C 的代码了吗?虽然我们保证了每个线程处理不同的 i, j 块,但在多核环境下,缓存一致性协议(MESI)会疯狂地工作。线程 1 写了 C[0][0],线程 2 想读 C[0][0],总线会广播更新消息,导致缓存行失效。这种“伪共享”会极大地降低性能,甚至比单线程还慢!
解决方案:
在写回结果矩阵 $C$ 时,不要用 16×16 的小块写。你应该在 k 循环结束后,把 c_local 批量写入 C,并且确保写入的内存地址是连续的,或者至少跨缓存行对齐。
更高级的做法是:使用指针算术优化。我们不访问二维数组 C[i][j],而是直接操作一维指针。
// 优化版:指针算术 + 多线程
void optimized_gemm(const float* A, const float* B, float* C, int N, int TILE_SIZE) {
#pragma omp parallel for collapse(2) schedule(static)
for (int i = 0; i < N; i += TILE_SIZE) {
for (int j = 0; j < N; j += TILE_SIZE) {
// 局部缓存,这次我们用一维数组模拟二维,减少索引计算开销
float c_local[TILE_SIZE * TILE_SIZE] = {0.0f};
for (int k = 0; k < N; k += TILE_SIZE) {
for (int ii = i; ii < i + TILE_SIZE; ++ii) {
for (int kk = k; kk < k + TILE_SIZE; ++kk) {
float a_val = A[ii * N + kk];
// 预取 B 的一行,减少内存跳转
const float* b_row_ptr = &B[kk * N + j];
for (int jj = j; jj < j + TILE_SIZE; ++jj) {
c_local[(jj - j) * TILE_SIZE + (ii - i)] += a_val * b_row_ptr[jj - j];
}
}
}
}
// 批量写入 C
// 这里利用了局部性,一次性把一个 Tile 写入内存
float* c_ptr = &C[i * N + j];
for (int jj = 0; jj < TILE_SIZE; ++jj) {
for (int ii = 0; ii < TILE_SIZE; ++ii) {
c_ptr[jj * N + ii] = c_local[jj * TILE_SIZE + ii];
}
}
}
}
}
第四章:SIMD —— 给 CPU 插上翅膀
讲了这么多多线程和分块,我们其实还没解决核心问题:计算本身太慢了。
即便我们把数据都装进了缓存,CPU 每次还是只能一次加一个浮点数。而现代 CPU 的向量单元(AVX2, AVX-512)一次可以同时处理 4 个、8 个甚至 16 个浮点数!
这就需要 SIMD(单指令多数据流)。
想象一下,你有一个巨大的传送带,传送带上堆满了苹果(数据)。普通计算是“拿一个苹果,吃一口,再拿一个”。而 SIMD 是“拿这一堆苹果,一次性吃个饱”。
在 C++ 中,我们通常使用内联汇编或者编译器 intrinsic 函数。
让我们来看看如何把上面的 tiled_gemm 升级为 AVX2 版本。AVX2 一次能处理 8 个单精度浮点数(__m256)。
注意: SIMD 代码非常难写,而且非常依赖编译器优化。为了演示,我们只看一个片段:如何把一行数据加载到寄存器。
#include <immintrin.h> // AVX2 头文件
void simd_gemm_kernel(const float* a, const float* b, float* c, int lda, int ldb, int ldc, int k) {
// 这是一个简化的例子,假设 TILE_SIZE 是 8 的倍数
// 我们要计算 C[i][j] += A[i][k] * B[k][j]
// 1. 加载 A[i][k] 到寄存器
__m256 a_vec = _mm256_set1_ps(a[k * lda + i]);
// 2. 遍历 B 的列,一次处理 8 个元素
for (int j = 0; j < TILE_SIZE; j += 8) {
// 加载 B[k][j] 到寄存器
__m256 b_vec = _mm256_loadu_ps(&b[k * ldb + j]);
// 加载 C[i][j] 到寄存器
__m256 c_vec = _mm256_loadu_ps(&c[i * ldc + j]);
// 3. 矩阵乘法核心:向量乘法 + 累加
c_vec = _mm256_fmadd_ps(a_vec, b_vec, c_vec); // _mm256_fmadd_ps 是 (a*b)+c
// 4. 存回内存
_mm256_storeu_ps(&c[i * ldc + j], c_vec);
}
}
专家吐槽:
看到这段代码,是不是觉得既优雅又恐怖?_mm256_fmadd_ps 是个神器,它在一个时钟周期内完成了乘法和加法。但是,这段代码也有问题:它假设了连续内存。
如果你的矩阵不是 8 的倍数(比如最后几个元素),你就得写一堆 if-else 来处理剩余的元素。这就是为什么高性能库(如 Eigen, MKL, cuBLAS)的代码极其复杂,充满了大量的边缘情况处理。
第五章:推理引擎中的实战——KV Cache 的噩梦
好了,理论讲完了。现在我们回到你的需求:C++ 推理引擎。
在 LLM(大语言模型)推理中,最耗时的操作是什么?不是矩阵乘法,而是 Softmax 和 KV Cache 的读写。
但是,矩阵乘法依然是重头戏。Transformer 的每一层都有大量的 $W_q, W_k, W_v, W_o$ 矩阵乘法。
场景模拟:
假设你有一个 70B 的模型,你要推理一个长度为 2048 的序列。
这意味着你需要计算 $2048 times 4096 times 4096$ 的矩阵乘法。
如果用单线程分块,可能要跑 10 分钟。如果用 64 核的多线程 + AVX2,可能只要跑 10 秒。
如何组合这些技术?
- 数据布局: 推理引擎通常使用 Row-Major(行主序),因为 CPU 缓存对行主序友好。
- 内存对齐: 使用
alignas(64)确保数据在缓存行边界上。这能防止两个线程同时修改同一个缓存行(避免伪共享)。 - 流水线: 计算矩阵乘法时,不要等到所有 $k$ 循环都算完再写回 $C$。你可以计算完一部分 Tile,就写回一部分,或者直接写入全局内存,让下一层计算直接读。这就是计算与存储的流水线。
// 模拟推理引擎中的一个 Kernel
class TensorParallelEngine {
public:
void forward(const Matrix& A, const Matrix& B, Matrix& C) {
int N = A.rows;
int TILE = 64; // 64KB 的缓存大概能塞下一个 64x64 的 float 矩阵
// 多线程
#pragma omp parallel for collapse(2) schedule(static)
for (int i = 0; i < N; i += TILE) {
for (int j = 0; j < N; j += TILE) {
// 局部变量,确保每个线程独立,避免伪共享
// 注意:这里分配在栈上,访问速度极快
float local_c[TILE * TILE] = {0};
// K 循环
for (int k = 0; k < N; k += TILE) {
// 内层循环
// 这里可以插入 SIMD 指令
for (int ii = i; ii < i + TILE; ++ii) {
for (int kk = k; kk < k + TILE; ++kk) {
float a_val = A(ii, kk);
float* b_ptr = &B(kk, j);
for (int jj = j; jj < j + TILE; ++jj) {
local_c[(jj-j)*TILE + (ii-i)] += a_val * b_ptr[jj-j];
}
}
}
}
// 写回 C
// 使用 memcpy 代替循环写入,虽然对局部变量可能没差别,但习惯更好
// 或者手动写入,因为局部变量在栈上,直接操作
float* out_ptr = &C(i, j);
for(int ii=0; ii<TILE; ++ii) {
memcpy(out_ptr + ii * N, local_c + ii * TILE, TILE * sizeof(float));
}
}
}
}
};
第六章:那些年我们踩过的坑
在优化大矩阵乘法的过程中,你会遇到很多让你想摔键盘的坑。
1. 缓存行撕裂
如果你在多线程中,两个线程分别写 C[0] 和 C[64](假设缓存行是 64 字节),它们实际上在写同一个物理内存地址!
后果: CPU 会在两个线程之间疯狂切换,因为缓存行失效了。
解法: 在 OpenMP 中使用 #pragma omp parallel for schedule(static, TILE_SIZE)。确保每个线程处理的 i, j 块大小是缓存行的倍数(比如 64×64 的 float 矩阵正好 16KB,两个缓存行)。
2. 指令级并行 (ILP) 的丧失
如果你写了很多 if-else 或者复杂的跳转,CPU 的流水线就会断流。一定要保持循环的紧凑性。
解法: 尽量避免在热循环里做分支预测。如果你的 TILE_SIZE 不是 8 的倍数,可以用掩码操作(AVX 指令),或者干脆把矩阵补齐到 8 的倍数。
3. 线程创建开销
如果你在推理引擎的每一层都创建新线程,那性能会下降 50% 以上。
解法: 使用线程池。在初始化时创建 8 个或 16 个线程,一直复用它们。这就是为什么 OpenMP 在计算密集型任务中如此流行。
第七章:终极奥义——编译器优化开关
写完代码后,别忘了告诉编译器:“嘿,我是专家,你给我开最大马力!”
// 在函数定义前加上这个宏
__attribute__((optimize("O3")))
void my_super_fast_gemm(...) {
// 你的代码
}
或者使用 -O3 -march=native 编译。
结语:永远不要停止优化
好了,各位听众,今天的讲座接近尾声。
我们回顾了从最笨的朴素 GEMM,到单线程分块,再到多线程并行,最后加上 SIMD 指令的完整进化之路。
记住,分块 不仅仅是优化算法,它是一种思维方式。它教会我们在面对巨大的问题时,不要试图一口吞下,而是要拆解它,让每一口都吃得刚刚好。
在 C++ 推理引擎的开发中,每一个字节的缓存利用,每一次指令的流水线对齐,都是通往“秒级推理”的阶梯。
不要满足于“能跑”。你要追求的是“快到让对手绝望”。
如果你能把一个 10000×10000 的矩阵乘法从 10 秒优化到 0.1 秒,那你就是真正的专家。哪怕只是优化了 1%,在处理万亿参数模型时,那也是巨大的工程胜利。
现在,拿起你的键盘,打开你的编译器,去优化你的矩阵吧!别忘了,内存是新的硬盘,缓存是新的内存,而你的代码,就是控制它们的魔法!
谢谢大家!