同一个「数组逐元素相加」的循环,-O2下编译器一次只加一个 int,-O3下它会把循环「展开」成一次处理 4 个 int 的 SIMD 指令,-O3 -march=native下更进一步用 256 位的 AVX2 一次处理 8 个。这不是手写 intrinsics,而是编译器的自动向量化。你要做的只是打开正确的开关、并别挡它的路。这篇用汇编把这件事坐实。
1. 引子:为什么 -O2 和 -O3 差出一个量级
先看一个可向量化的循环(数组逐元素相加):
#include<cstdio>#include<cstddef>voidadd_arrays(int*__restrict a,constint*__restrict b,constint*__restrict c,std::size_t n){for(std::size_t i=0;i<n;++i)a[i]=b[i]+c[i];}intmain(){inta[8]={};intb[8]={1,2,3,4,5,6,7,8};intc[8]={10,20,30,40,50,60,70,80};add_arrays(a,b,c,8);std::printf("%d %d %d\n",a[0],a[3],a[7]);}11 44 88结果是对的(1+10=11、4+40=44、8+80=88),但这个循环在-O2下是标量循环、在-O3下才被向量化。汇编对比(godbolt,gcc 13.2):
-O2:标量循环(每次只加一个 int) add_arrays(...): xor eax, eax .L3: mov r8d, DWORD PTR [rdx+rax*4] ← 一次读 4 字节 add r8d, DWORD PTR [rsi+rax*4] mov DWORD PTR [rdi+rax*4], r8d add rax, 1 cmp rcx, rax jne .L3 ret -O3:自动向量化(xmm,一次 4 个 int = 128 位) .L4: movdqu xmm0, XMMWORD PTR [rsi+rax] ← 一次读 16 字节 movdqu xmm2, XMMWORD PTR [rdx+rax] paddd xmm0, xmm2 ← 打包整数加法(4 路并行) movups XMMWORD PTR [rdi+rax], xmm0 add rax, 16 cmp rax, r8 jne .L4movdqu(一次加载 128 位)、paddd(packed add,4 个 int 一起加)、XMMWORD PTR(16 字节内存操作数)。这三样就是「向量化发生了」的铁证。用-fopt-info-vec还能让编译器亲口告诉你:
$ g++ -O3 -fopt-info-vec demo.cpp demo.cpp:4:31: optimized: loop vectorized using 16 byte vectors官方文档:as-if 规则——自动向量化合法,是因为逐元素相加的结果与标量循环「可观测行为」一致,编译器才敢并行化。
2. -march=native:榨出 AVX2 的 256 位
-O3默认用最保守的 SSE2(128 位,xmm)。-march=native让编译器针对当前 CPU生成指令,能用到 AVX2 的 256 位寄存器(ymm):
-O3 -march=native:升级到 256 位 AVX2(ymm,一次 8 个 int) .L4: vmovdqu ymm1, YMMWORD PTR [rsi+rax] ← 一次读 32 字节 vpaddd ymm0, ymm1, YMMWORD PTR [rdx+rax] ← 8 路并行加法 vmovdqu YMMWORD PTR [rdi+rax], ymm0 add rax, 32 cmp rax, r8 jne .L4ymm(256 位)比xmm(128 位)宽一倍,一次处理的元素从 4 个变成 8 个。三种配置的确定性差异:
| 编译选项 | 寄存器宽度 | 一次处理元素数 | 关键指令 |
|---|---|---|---|
-O2 | 标量 | 1 个 int | add(普通整数加) |
-O3 | 128 位(xmm) | 4 个 int | paddd+movdqu |
-O3 -march=native | 256 位(ymm) | 8 个 int | vpaddd+vmovdqu |
官方文档:GCC x86 Options——
-march=native的含义与代价(产物只能在本机/同类 CPU 上跑)。
一个必须记住的坑:-march=native编出来的二进制绑定了编译机的 CPU,换台老机器可能直接illegal instruction。发布给多机部署的产物,要么用保守的-march=x86-64-v2/v3,要么做运行时特性分发。
3. __restrict:帮编译器排除别名,免去 versioning
向量化的前提是「a、b、c三块内存不重叠」:如果写a[i]会改到b[j],那逐元素的顺序就变得可观测,编译器不敢并行。不声明时,编译器只能生成两个版本 + 运行时检查(versioning):
无 __restrict:编译器加运行时别名检查(代码更长) add_no_restrict(...): ... lea rdx, [rsi+4] mov rax, rdi sub rax, rdx cmp rax, 8 jbe .L12 ← 若 a 与 b 重叠(距离 ≤ 8 字节),走标量回退 ... ← 不重叠才走向量循环 .L12: 标量循环(回退路径) 有 __restrict:直接向量化,零别名检查 add_restrict(...): test rcx, rcx je .L35 ... .L38: movdqu xmm0, XMMWORD PTR [rsi+rax] paddd xmm0, XMMWORD PTR [rdx+rax] movups XMMWORD PTR [rdi+rax], xmm0 ...__restrict(C99 引入,C++ 里是 GCC/Clang 扩展)是你对编译器的一个承诺:这些指针指向的内存互不重叠。编译器据此省掉运行时检查、直接向量化。承诺是单方面的。如果实际传入重叠的指针,就是未定义行为,结果会悄悄算错。
官方文档:GCC Restricted Pointers——
__restrict的语义边界。
4. __builtin_expect 与 C++20 的 [[likely]] / [[unlikely]]
自动向量化管的是「循环怎么算」,分支预测提示管的是「分支怎么排布」。__builtin_expect(x, 0)告诉编译器「x 大概率是 0」,让热路径连续、罕见分支被丢到冷区,减少跳转、提升指令缓存命中。
C++20 把它标准化成了属性[[likely]]/[[unlikely]]:
// verify: std=c++20#include<cstdio>intmain(){intcount=0;constexprintn=10000;for(inti=0;i<n;++i){if(i%100==0)[[unlikely]]{// 罕见分支:每 100 次才命中一次++count;}}std::printf("rare branch taken = %d\n",count);}rare branch taken = 100__builtin_expect的等价写法(C++17 就能用):
#include<cstdio>intmain(){intcount=0;constexprintn=10000;for(inti=0;i<n;++i){// __builtin_expect(x, 0):告诉编译器 x 大概率是 0(假)if(__builtin_expect(i%100==0,0)){++count;}}std::printf("rare branch taken = %d\n",count);}rare branch taken = 100两者对比:
| 写法 | 标准 | 语义 | 注意 |
|---|---|---|---|
__builtin_expect(x, 0) | GCC/Clang 扩展 | x 大概率是 0 | 只能在 if 条件里用,不跨平台 |
[[likely]]/[[unlikely]] | C++20 | 标注分支概率 | 标准化、可移植,优先用 |
官方文档:C++ 属性 likely/unlikely
这里要泼盆冷水:[[likely]]是提示不是命令,对短分支、已能预测好的分支,效果微乎其微。它的价值在「热循环里的冷分支」——比如一个循环里每 1000 次才进一次的错误处理,把它[[unlikely]]出去,能让主循环的指令更紧凑。
5. std::execution::unseq:显式请求向量化
除了靠编译器自动判断,C++17/20 的并行算法还能通过执行策略显式要求 SIMD。unseq(unsequenced)策略允许算法在单线程内向量化执行:
// verify: std=c++20#include<cstdio>#include<vector>#include<numeric>#include<execution>intmain(){std::vector<int>v(1000,2);intsum=std::reduce(std::execution::unseq,v.begin(),v.end(),0);std::printf("sum = %d\n",sum);}sum = 2000unseq和par的区别:par是多线程并行,unseq是单线程内向量化。两者可以组合成par_unseq(既多线程又向量化)。注意:执行策略只是「允许」,实际是否向量化仍取决于标准库实现和编译选项。它不会凭空产生 SIMD,而是把决定权交给实现。
官方文档:std::execution::unsequenced_policy
6. 完整示例:一次看清向量化全貌
自动向量化决策链 ═══════════════════════════════════════════════════════════════ 循环体无副作用、可并行 ──▶ 编译器尝试向量化 │ ├─ 指针是否无别名?──▶ 无 __restrict 就加 versioning 检查 │ 有 __restrict 就直接向量化 │ └─ 用哪个宽度? ──▶ -O3 默认 SSE2(128 位/xmm) -march=native 用 AVX2(256 位/ymm) ═══════════════════════════════════════════════════════════════把三个手段合进一个程序,跑出确定结果:
#include<cstdio>#include<cstddef>// 手段 1:__restrict 免除别名检查voidadd_arrays(int*__restrict a,constint*__restrict b,constint*__restrict c,std::size_t n){for(std::size_t i=0;i<n;++i)a[i]=b[i]+c[i];}intmain(){inta[8]={};intb[8]={1,2,3,4,5,6,7,8};intc[8]={10,20,30,40,50,60,70,80};add_arrays(a,b,c,8);// 手段 2:分支提示(用 __builtin_expect,C++17 可用)intrare=0;constexprintn=10000;for(inti=0;i<n;++i){if(__builtin_expect(i%100==0,0))++rare;}std::printf("a=[%d,%d,%d] rare=%d\n",a[0],a[3],a[7],rare);}a=[11,44,88] rare=1007. 延伸阅读
- as-if 规则:向量化合法性的理论基础
- C++ 属性 likely/unlikely:分支提示的标准化语法
- std::execution::unsequenced_policy:显式向量化执行策略
- GCC x86 Options:
-march=native的代价与替代(x86-64-v3) - GCC Restricted Pointers:
__restrict的语义边界 - Compiler Explorer:写完循环先看汇编,确认
paddd/vpaddd真的出现了
本知识库内的相关篇目:
- 《PGO 与 LTO:profile-guided 与链接时优化》 —— LTO(链接时优化)把「跨编译单元」的优化推迟到链接期
- 《基准测试的陷阱:优化掉、频率漂移、缓存预热》 —— 基准测试(benchmark)的三个经典陷阱
- 《C++ 缓存预热与 prefetch:什么时候该手写》 —— C++ 缓存预热与 prefetch,同体系里最该一起看的另一篇。
8. 一句话总结
自动向量化是「开对开关 + 别挡路」的活:-O3打开向量化、-march=native升级到 256 位 AVX2(代价是绑定本机 CPU);__restrict承诺指针无别名、帮编译器免去 versioning 检查;[[likely]]/[[unlikely]](或__builtin_expect)把冷分支挪出热路径;std::execution::unseq显式请求向量化。判断是否生效只有一个可靠标准——看汇编里有没有movdqu/paddd(128 位)或vmovdqu/vpaddd(256 位)。