编译优化基础:编译器优化选项、内联与循环展开、向量化
说到编译优化,很多同学觉得这是编译器自动干的事,跟自己关系不大。其实不然。我做了这么多年算子优化,可以负责任地告诉你:不懂编译优化,你写的代码可能连一半的性能都发挥不出来。
今天我们就来聊聊编译器到底帮我们做了什么,以及我们怎么配合它,让推理速度再上一个台阶。
一、编译器优化选项:-O2 和 -O3 到底差在哪?
先问个问题:你平时编译代码用哪个优化级别?我猜大部分人会直接上 -O3,觉得越高越好。嗯,这里要注意,-O3 并不总是最优选择。
咱们先看看这几个常用选项的区别:
| 优化选项 | 核心行为 | 编译时间 | 典型性能 |
|---|---|---|---|
| -O0 | 不做优化,方便调试 | 最快 | 最慢 |
| -O1 | 基础优化,减少代码体积 | 较快 | 中等 |
| -O2 | 开启大部分常用优化, 包括指令调度、常量传播等 |
较慢 | 高 |
| -O3 | 在 -O2 基础上, 开启更激进的循环优化和向量化 |
最慢 | 可能更高,也可能更差 |
我个人习惯是:先用 -O2 做基准测试,再尝试 -O3。为什么?因为 -O3 会做一些激进的循环展开和向量化,有时候反而导致指令缓存溢出,性能不升反降。
核心观点:-O2 是「稳妥的激进」,-O3 是「激进的激进」。对于算子优化,我建议从 -O2 起步,逐层调优。
我在项目中遇到过这样一个案例:一个矩阵乘算子,用 -O3 编译后性能反而比 -O2 慢了 12%。排查后发现,-O3 把一个小循环展开了 32 倍,导致代码体积暴增,L1 指令缓存根本装不下。后来我们手动控制了展开因子,性能才恢复正常。
二、内联与循环展开:手动控制 vs 编译器自动
内联和循环展开,这两个是编译器最常用的优化手段。说白了,就是用代码体积换执行速度。
2.1 函数内联
函数调用是有开销的——压栈、跳转、返回。对于频繁调用的小函数,这个开销占比很大。
// 不内联的情况
int add(int a, int b) { return a + b; }
for (int i = 0; i < 1000000; i++) {
result[i] = add(x[i], y[i]); // 每次都要调用
}
// 内联后(编译器自动或手动)
for (int i = 0; i < 1000000; i++) {
result[i] = x[i] + y[i]; // 直接展开
}
编译器在 -O2 以上会自动判断哪些函数值得内联。但有时候它判断不准。我建议:对于热点路径上的小函数(一般 10 行以内),用 inline 关键字显式提示编译器。
小技巧:用 __attribute__((always_inline)) 强制内联,但别滥用。我曾经在一个算子中强制内联了 20 多个函数,结果编译出来的二进制大了 3 倍,性能反而下降了。
2.2 循环展开
循环展开的原理很简单:减少循环控制指令的执行次数,增加每次迭代的计算量。
// 原始循环
for (int i = 0; i < 1024; i++) {
a[i] = b[i] + c[i];
}
// 手动展开 4 次
for (int i = 0; i < 1024; i += 4) {
a[i] = b[i] + c[i];
a[i+1] = b[i+1] + c[i+1];
a[i+2] = b[i+2] + c[i+2];
a[i+3] = b[i+3] + c[i+3];
}
展开后,循环次数从 1024 次减少到 256 次。但代价是代码体积变大了。编译器在 -O3 下会自动做这件事,但展开因子不一定适合你的场景。
避坑指南:我曾经在 ARM 平台上把一个循环展开 8 次,结果寄存器不够用,编译器不得不频繁 spill/reload,性能反而下降了 20%。后来改成展开 4 次,效果最好。
三、向量化:让 CPU 一次干四份活
向量化是编译器优化里最「性感」的部分。说白了,就是让 CPU 用一条指令同时处理多个数据。
举个例子,你想对 4 个浮点数做加法:
// 标量方式(一次加一个)
float a[4], b[4], c[4];
for (int i = 0; i < 4; i++) {
c[i] = a[i] + b[i];
}
// 向量化后(一次加四个)
// 编译器自动生成类似这样的指令:
// vaddps ymm0, ymm1, ymm2 ; AVX 指令,一次加 8 个 float
编译器在 -O3 下会自动尝试向量化。但前提是你的代码要写得「友好」。我总结了几个关键点:
- 内存对齐:向量化指令通常要求数据 16 字节或 32 字节对齐。用
alignas(32)或__attribute__((aligned(32)))显式声明。 - 避免数据依赖:比如
a[i] = a[i-1] + 1这种就无法向量化,因为每次计算依赖前一次结果。 - 循环次数是向量宽度的整数倍:如果不是,编译器会生成「标量尾部处理」代码,影响性能。
核心观点:向量化不是编译器的独角戏。你写代码的方式,直接决定了编译器能不能成功向量化。说白了,你要给编译器「喂」对的数据结构。
我记得有一次优化一个卷积算子的前向推理。原始代码用了一个嵌套循环,编译器死活不肯向量化。后来我把内层循环改成连续内存访问,加上 #pragma GCC ivdep 告诉编译器「这里没有数据依赖」,向量化就成功了,性能提升了 2.3 倍。
四、知识体系总览
下面这张图是我自己整理的编译优化知识框架,帮你快速建立全局认知:
五、实战建议:怎么用好这些优化?
说了这么多,最后给几条我自己的实战经验:
- 先写对,再写快。不要一开始就想着用各种优化技巧。先把功能跑通,然后用 perf 工具看热点在哪里。
- 用
-fopt-info-vec看编译器是否成功向量化。如果没成功,它会告诉你原因。我经常用这个来调整代码。 - 对于关键循环,手动控制展开因子。用
#pragma unroll N告诉编译器展开几次,而不是完全交给它。 - 内存布局是第一位的。向量化要求连续内存访问。如果你的数据是 AoS(Array of Structs)结构,考虑改成 SoA(Struct of Arrays)。
最后一个小提醒:编译优化不是银弹。有时候你花了两天调优,性能只提升了 5%。这时候要问自己:是不是算法层面有更大的优化空间?我见过太多人沉迷于微优化,却忽略了更上层的设计问题。
好了,编译优化的基础就聊到这里。记住:编译器是你最得力的助手,但不是你的替身。你写得越好,它优化得越漂亮。
想要更多资料,联系可可