矩阵乘法是科学计算、深度学习推理和图形渲染里最常见的计算密集型任务。两个1024阶的方阵相乘,朴素实现要执行超过10亿次乘法和同样数量的加法,而CPU的标量执行单元每个周期只能处理一两个浮点数,大量时间浪费在逐个数据的搬运和计算上。现代x86处理器内置的SIMD单元一次能对8个甚至16个浮点数并行执行同一条指令,把这部分闲置算力利用起来,往往能让矩阵运算性能翻上数倍。这篇文章从指令集原理讲到具体代码,完整梳理C++向量化优化矩阵运算的整条路径。

SIMD的工作原理:一条指令为什么能同时处理多个数据
SIMD是Single Instruction Multiple Data的缩写,即单指令多数据。传统标量指令一次只操作一个数据,而SIMD指令把多个数据打包进一个宽达128位、256位甚至512位的向量寄存器,让运算单元在一个周期内对所有通道并行执行同样的操作。以AVX2为例,一个256位的ymm寄存器能装下8个32位float,执行一次_mm256_mul_ps就等于同时完成了8次乘法。这种并行不需要多线程、不需要锁,纯粹是硬件层面的数据级并行,配合现代CPU每周期可发射两条FMA指令的能力,理论峰值一秒能完成几十亿次浮点乘加。
矩阵运算恰好是SIMD的理想应用场景。矩阵乘法里的乘加操作彼此独立、结构完全一致,天然适合打包成向量批量处理。不过不同代际的指令集向量宽度差别很大,动手写代码之前先弄清楚目标平台支持哪一档:
| 指令集 | 寄存器 | 向量位宽 | float通道数 | double通道数 |
|---|---|---|---|---|
| SSE2 | xmm | 128位 | 4 | 2 |
| AVX/AVX2 | ymm | 256位 | 8 | 4 |
| AVX-512 | zmm | 512位 | 16 | 8 |
在GCC和Clang下可以用__builtin_cpu_supports("avx2")在运行时检测CPU是否支持特定指令集,MSVC则提供__cpuidex函数。如果程序要在不同机器上分发,一种常见做法是准备多个版本的向量化函数,运行时检测后跳转到对应实现,OpenBLAS、Eigen这些库内部就是这么干的。检测代码本身很简单:
#include <cstdio>
#include <immintrin.h>
int main()
{
if (__builtin_cpu_supports("avx2")) {
std::printf("当前CPU支持AVX2\n");
}
if (__builtin_cpu_supports("fma")) {
std::printf("当前CPU支持FMA\n");
}
return 0;
}动手改造:把三重循环矩阵乘法向量化
先看最朴素的实现,这也是大多数人学矩阵乘法时写出的第一版代码:
// 朴素的三重循环实现:N阶方阵乘法
void matmul_scalar(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;
}
}
}这版代码有两个明显的性能问题。第一,内层循环里B[k*N+j]是按列访问的,相邻两次迭代在内存中相距N个float,cache line利用率极低,N较大时几乎每次访问都触发缓存缺失。第二,每次迭代只做一次乘法和一次加法,最内层的k维度上A连续而B跳跃,编译器想自动向量化也无从下手。解决办法是调整循环顺序为i-k-j:外两层固定A的一个元素,最内层沿j方向同时推进B的行和C的行,两者都变成连续访问,正好可以让AVX2一次搬8个数。
#include <immintrin.h>
// i-k-j循环顺序 + AVX2向量化,假设N为8的倍数
void matmul_avx2(const float* A, const float* B, float* C, int N)
{
// 先把结果矩阵清零,后面要分批累加
for (int i = 0; i < N * N; ++i) {
C[i] = 0.0f;
}
for (int i = 0; i < N; ++i) {
for (int k = 0; k < N; ++k) {
// 把标量A[i][k]广播到256位寄存器的8个通道
__m256 va = _mm256_broadcast_ss(&A[i * N + k]);
for (int j = 0; j < N; j += 8) {
__m256 vb = _mm256_loadu_ps(&B[k * N + j]); // B按行连续读取
__m256 vc = _mm256_loadu_ps(&C[i * N + j]); // 取出当前部分和
vc = _mm256_fmadd_ps(va, vb, vc); // 一条指令完成乘加
_mm256_storeu_ps(&C[i * N + j], vc); // 写回结果
}
}
}
}逐行拆解这段代码。_mm256_broadcast_ss把一个标量复制到256位寄存器的8个通道;_mm256_loadu_ps从内存加载8个连续的float,函数名里的u表示不要求地址对齐;_mm256_fmadd_ps是FMA融合乘加指令,一条指令完成va * vb + vc,中间结果不落地、延迟更低且数值精度更好;_mm256_storeu_ps负责把结果写回内存。编译时必须加上-mavx2 -mfma选项,否则这些函数会报未定义引用。
这里有两个容易踩的坑值得单独说。一是i-k-j顺序下C矩阵的每个元素被分N次累加,而不是一次性算完,所以开头必须先把C清零,漏掉这步结果会完全错乱。二是如果N不是8的倍数,j循环会越界读写,工程上要么把矩阵维度补齐到8的倍数,要么对尾部残留的几个元素用标量循环单独收尾。另外还有一种沿k方向向量化的思路,即对点积做向量化,但那样每算完一个元素都要做跨通道的水平求和,而AVX2没有单周期的水平加法指令,归约开销不小,所以工程上普遍推荐上面这种沿j方向向量化的写法。
进阶:缓存分块、内存对齐与性能实测
向量化解决的是计算吞吐,接下来要解决访存瓶颈。当N等于2048时,一个float矩阵占16MB,远超多数CPU的L2缓存,A、B、C三块数据在缓存里互相挤兑,大量时间耗在内存和缓存之间的来回搬运上。分块的思路是把大矩阵切成若干小块,让一个子块计算所需的数据整体驻留在缓存里,用完一块再换下一块,把缓存缺失集中控制在换块的边界处。一个64乘64的float块占16KB,正好能塞进多数处理器的L1数据缓存。
// 分块版本:BLOCK需结合目标CPU的缓存容量实测调整
// 假设N为8的倍数,且BLOCK为8的倍数
void matmul_blocked(const float* A, const float* B, float* C, int N)
{
const int BLOCK = 64;
for (int i = 0; i < N * N; ++i) {
C[i] = 0.0f;
}
for (int ii = 0; ii < N; ii += BLOCK) {
for (int kk = 0; kk < N; kk += BLOCK) {
for (int jj = 0; jj < N; jj += BLOCK) {
// 处理边界,防止块越过矩阵范围
int iend = (ii + BLOCK < N) ? (ii + BLOCK) : N;
int kend = (kk + BLOCK < N) ? (kk + BLOCK) : N;
int jend = (jj + BLOCK < N) ? (jj + BLOCK) : N;
for (int i = ii; i < iend; ++i) {
for (int k = kk; k < kend; ++k) {
__m256 va = _mm256_broadcast_ss(&A[i * N + k]);
for (int j = jj; j < jend; j += 8) {
__m256 vb = _mm256_loadu_ps(&B[k * N + j]);
__m256 vc = _mm256_loadu_ps(&C[i * N + j]);
vc = _mm256_fmadd_ps(va, vb, vc);
_mm256_storeu_ps(&C[i * N + j], vc);
}
}
}
}
}
}
}块大小不是拍脑袋定的,经验值在32到256之间浮动,需要针对具体CPU的L1、L2容量实测调整,块太小起不到复用效果,块太大又放不进缓存。除了分块,内存对齐也值得顺手处理:对齐版_mm256_load_ps在较老的CPU上比不对齐的_mm256_loadu_ps更快,新架构上两者差距虽然缩小了,但保证对齐至少没有坏处。C++里分配32字节对齐的内存有几种方式:
#include <immintrin.h>
// 方式一:C++17带对齐的new,释放时传相同的对齐参数
float* p1 = new (std::align_val_t(32)) float[N * N];
::operator delete[](p1, std::align_val_t(32));
// 方式二:_mm_malloc与_mm_free配套使用
float* p2 = static_cast<float*>(
_mm_malloc(N * N * sizeof(float), 32));
_mm_free(p2);
// 对齐内存上可以使用不带u后缀的对齐load/store
__m256 v = _mm256_load_ps(&p2[0]); // 要求地址按32字节对齐
_mm256_store_ps(&p2[8], v);把上述手段叠加起来,在一台支持AVX2的典型桌面CPU上跑1024阶方阵乘法,大致能拿到这样的量级对比。具体数字因机器和编译器版本而异,下表仅供感受优化空间:
| 实现方式 | 相对性能(以-O2标量为基准1) | 主要收益来源 |
|---|---|---|
| 标量三重循环 -O2 | 1.0 | 基准 |
| i-k-j重排后的标量版 | 1.5至2 | 访存连续性改善 |
| AVX2 intrinsics版 | 4至6 | 8路并行乘加 |
| AVX2加分块加FMA | 8至12 | 缓存命中率高,乘加零冗余 |
提醒一点:做性能测试时要多跑几轮取中位数,第一轮的冷缓存数据没有参考价值,也别在笔记本省电模式下测,频率漂移会把结论带偏。性能优化最忌讳凭感觉下结论,一切以实测数据为准。
不写intrinsics也能向量化:编译器自动向量化
手写intrinsics可控性强,但代码可读性差、平台绑定严重,换到ARM平台就要整套重写。其实现代编译器在满足条件时能把普通循环自动编译成SIMD指令,多数简单循环根本不需要手写。开启方式很朴素:
# 面向本机编译,自动启用当前CPU支持的全部指令集(含AVX2、FMA) g++ -O3 -march=native matmul.cpp -o matmul # 显式指定指令集,便于控制二进制能运行在哪些机器上 g++ -O3 -mavx2 -mfma matmul.cpp -o matmul
想让编译器顺利向量化,代码要配合几个条件:循环次数可预测、循环体内没有分支和函数调用、内存访问连续、多个指针之间没有别名。__restrict关键字告诉编译器几块内存互不重叠,否则编译器必须保守地假设写C可能改写A或B,从而放弃重排访问顺序。浮点加法不满足结合律,默认情况下编译器不敢改变求和次序,这也是大量循环无法自动向量化的根因,可以用-ffast-math或者#pragma omp simd显式放开这个限制:
// 满足条件的简单循环,编译器在-O3下可自动向量化
void vec_add(float* __restrict c,
const float* __restrict a,
const float* __restrict b,
int n)
{
#pragma omp simd
for (int i = 0; i < n; ++i) {
c[i] = a[i] + b[i];
}
}自动向量化的短板在于矩阵乘法这类复杂循环:多重数据依赖、访存模式跳跃,编译器的分析能力有限,实测往往只能拿到手写版本一半左右的性能。所以工程上的常见分工是:简单循环交给编译器,核心热点手写intrinsics,或者干脆直接用Eigen、OpenBLAS这类成熟库。Eigen的矩阵乘法在编译期就会根据目标指令集展开成向量化代码,OpenBLAS则内置了针对各代CPU手工调优的kernel,两者底层都大量使用了本文讲的这些技术。另外,如果目标平台是ARM手机或Apple Silicon,对应的技术叫NEON,intrinsics函数名以v开头,比如vmlaq_f32,寄存器是128位的,一次处理4个float,向量化思路与AVX完全一致,学会一套之后再迁移成本不高。
最后总结整条优化路径:先重排循环让访存连续,再用intrinsics把最内层循环向量化,然后通过分块和对齐榨取缓存红利,简单场景交给编译器自动处理。矩阵运算优化是典型的量变引起质变,每一步单独看提升有限,叠起来就是数倍到一个数量级的差距。动手之前记得先用性能分析工具确认矩阵乘法确实是瓶颈,再按需取用上面的手段,避免在非热点代码上过度优化。