首页 / 资讯中心 / 文章详情

SIMD 与自动向量化:march=native、__restrict 与 likely

SIMD 与自动向量化:march=native、__restrict 与 likely ★ FEATURED ARTICLE
同一个「数组逐元素相加」的循环-O2下编译器一次只加一个 int-O3下它会把循环「展开」成一次处理 4 个 int 的 SIMD 指令-O3 -marchnative下更进一步用 256 位的 AVX2 一次处理 8 个。这不是手写 intrinsics而是编译器的自动向量化。你要做的只是打开正确的开关、并别挡它的路。这篇用汇编把这件事坐实。1. 引子为什么 -O2 和 -O3 差出一个量级先看一个可向量化的循环数组逐元素相加#includecstdio#includecstddefvoidadd_arrays(int*__restrict a,constint*__restrict b,constint*__restrict c,std::size_t n){for(std::size_t i0;in;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结果是对的11011、44044、88088但这个循环在-O2下是标量循环、在-O3下才被向量化。汇编对比godboltgcc 13.2-O2标量循环每次只加一个 int add_arrays(...): xor eax, eax .L3: mov r8d, DWORD PTR [rdxrax*4] ← 一次读 4 字节 add r8d, DWORD PTR [rsirax*4] mov DWORD PTR [rdirax*4], r8d add rax, 1 cmp rcx, rax jne .L3 ret -O3自动向量化xmm一次 4 个 int 128 位 .L4: movdqu xmm0, XMMWORD PTR [rsirax] ← 一次读 16 字节 movdqu xmm2, XMMWORD PTR [rdxrax] paddd xmm0, xmm2 ← 打包整数加法4 路并行 movups XMMWORD PTR [rdirax], xmm0 add rax, 16 cmp rax, r8 jne .L4movdqu一次加载 128 位、padddpacked add4 个 int 一起加、XMMWORD PTR16 字节内存操作数。这三样就是「向量化发生了」的铁证。用-fopt-info-vec还能让编译器亲口告诉你$ g -O3 -fopt-info-vec demo.cpp demo.cpp:4:31: optimized: loop vectorized using 16 byte vectors官方文档as-if 规则——自动向量化合法是因为逐元素相加的结果与标量循环「可观测行为」一致编译器才敢并行化。2. -marchnative榨出 AVX2 的 256 位-O3默认用最保守的 SSE2128 位xmm。-marchnative让编译器针对当前 CPU生成指令能用到 AVX2 的 256 位寄存器ymm-O3 -marchnative升级到 256 位 AVX2ymm一次 8 个 int .L4: vmovdqu ymm1, YMMWORD PTR [rsirax] ← 一次读 32 字节 vpaddd ymm0, ymm1, YMMWORD PTR [rdxrax] ← 8 路并行加法 vmovdqu YMMWORD PTR [rdirax], ymm0 add rax, 32 cmp rax, r8 jne .L4ymm256 位比xmm128 位宽一倍一次处理的元素从 4 个变成 8 个。三种配置的确定性差异编译选项寄存器宽度一次处理元素数关键指令-O2标量1 个 intadd普通整数加-O3128 位xmm4 个 intpadddmovdqu-O3 -marchnative256 位ymm8 个 intvpadddvmovdqu官方文档GCC x86 Options——-marchnative的含义与代价产物只能在本机/同类 CPU 上跑。一个必须记住的坑-marchnative编出来的二进制绑定了编译机的 CPU换台老机器可能直接illegal instruction。发布给多机部署的产物要么用保守的-marchx86-64-v2/v3要么做运行时特性分发。3. __restrict帮编译器排除别名免去 versioning向量化的前提是「a、b、c三块内存不重叠」如果写a[i]会改到b[j]那逐元素的顺序就变得可观测编译器不敢并行。不声明时编译器只能生成两个版本 运行时检查versioning无 __restrict编译器加运行时别名检查代码更长 add_no_restrict(...): ... lea rdx, [rsi4] 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 [rsirax] paddd xmm0, XMMWORD PTR [rdxrax] movups XMMWORD PTR [rdirax], xmm0 ...__restrictC99 引入C 里是 GCC/Clang 扩展是你对编译器的一个承诺这些指针指向的内存互不重叠。编译器据此省掉运行时检查、直接向量化。承诺是单方面的。如果实际传入重叠的指针就是未定义行为结果会悄悄算错。官方文档GCC Restricted Pointers——__restrict的语义边界。4. __builtin_expect 与 C20 的 [[likely]] / [[unlikely]]自动向量化管的是「循环怎么算」分支预测提示管的是「分支怎么排布」。__builtin_expect(x, 0)告诉编译器「x 大概率是 0」让热路径连续、罕见分支被丢到冷区减少跳转、提升指令缓存命中。C20 把它标准化成了属性[[likely]]/[[unlikely]]// verify: stdc20#includecstdiointmain(){intcount0;constexprintn10000;for(inti0;in;i){if(i%1000)[[unlikely]]{// 罕见分支每 100 次才命中一次count;}}std::printf(rare branch taken %d\n,count);}rare branch taken 100__builtin_expect的等价写法C17 就能用#includecstdiointmain(){intcount0;constexprintn10000;for(inti0;in;i){// __builtin_expect(x, 0)告诉编译器 x 大概率是 0假if(__builtin_expect(i%1000,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]]C20标注分支概率标准化、可移植优先用官方文档C 属性 likely/unlikely这里要泼盆冷水[[likely]]是提示不是命令对短分支、已能预测好的分支效果微乎其微。它的价值在「热循环里的冷分支」——比如一个循环里每 1000 次才进一次的错误处理把它[[unlikely]]出去能让主循环的指令更紧凑。5. std::execution::unseq显式请求向量化除了靠编译器自动判断C17/20 的并行算法还能通过执行策略显式要求 SIMD。unsequnsequenced策略允许算法在单线程内向量化执行// verify: stdc20#includecstdio#includevector#includenumeric#includeexecutionintmain(){std::vectorintv(1000,2);intsumstd::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_policy6. 完整示例一次看清向量化全貌自动向量化决策链 ═══════════════════════════════════════════════════════════════ 循环体无副作用、可并行 ──▶ 编译器尝试向量化 │ ├─ 指针是否无别名──▶ 无 __restrict 就加 versioning 检查 │ 有 __restrict 就直接向量化 │ └─ 用哪个宽度 ──▶ -O3 默认 SSE2128 位/xmm -marchnative 用 AVX2256 位/ymm ═══════════════════════════════════════════════════════════════把三个手段合进一个程序跑出确定结果#includecstdio#includecstddef// 手段 1__restrict 免除别名检查voidadd_arrays(int*__restrict a,constint*__restrict b,constint*__restrict c,std::size_t n){for(std::size_t i0;in;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_expectC17 可用intrare0;constexprintn10000;for(inti0;in;i){if(__builtin_expect(i%1000,0))rare;}std::printf(a[%d,%d,%d] rare%d\n,a[0],a[3],a[7],rare);}a[11,44,88] rare1007. 延伸阅读as-if 规则向量化合法性的理论基础C 属性 likely/unlikely分支提示的标准化语法std::execution::unsequenced_policy显式向量化执行策略GCC x86 Options-marchnative的代价与替代x86-64-v3GCC Restricted Pointers__restrict的语义边界Compiler Explorer写完循环先看汇编确认paddd/vpaddd真的出现了本知识库内的相关篇目《PGO 与 LTOprofile-guided 与链接时优化》 —— LTO链接时优化把「跨编译单元」的优化推迟到链接期《基准测试的陷阱优化掉、频率漂移、缓存预热》 —— 基准测试benchmark的三个经典陷阱《C 缓存预热与 prefetch什么时候该手写》 —— C 缓存预热与 prefetch同体系里最该一起看的另一篇。8. 一句话总结自动向量化是「开对开关 别挡路」的活-O3打开向量化、-marchnative升级到 256 位 AVX2代价是绑定本机 CPU__restrict承诺指针无别名、帮编译器免去 versioning 检查[[likely]]/[[unlikely]]或__builtin_expect把冷分支挪出热路径std::execution::unseq显式请求向量化。判断是否生效只有一个可靠标准——看汇编里有没有movdqu/paddd128 位或vmovdqu/vpaddd256 位。
阅读完成 · 觉得有帮助?
咨询建站