原帖已经被锁定。我知道我的认识不一定完全正确,如果大家有不一样的看法,欢迎在这里友善交流(讨论区的表达能力更强)。
“inline 关键字是否可以促进编译器的内联优化”,是一个争议较大的话题。经过查询、讨论和实验,我得出了以下结论。
inline
关键字对性能优化没有帮助,但是也不会降低性能。inline
关键字可以明显促进编译器的内联优化,但是也有其他的替代方案。此处所说的“部分编译环境”,指包含编译选项 -fPIC
、且开启 O2 及以上优化的环境,例如洛谷的编译环境。其他 OJ 请查询对应的编译选项说明。
这些结论都是我自己的看法,不一定保证正确。如果你有其他的观点,欢迎交流。
inline
函数具有以下的两种语义:
实际上,C++ 标准已经不再承认“建议内联优化”的语义,并且实际上,编译器通常也不会听取这个建议。
尽管标准是这么说,但是很多情况下我们会发现给小函数加上 inline
关键字,仍然可以大幅提升调用速度。似乎标准说的话在“骗人”。
其实并非如此,接下来我讲一下自己的理解。
根据相关公示,洛谷启用了一个名为 -fPIC
的编译选项。
这个编译选项要求编译器生成“位置无关”的代码来提升安全性,同时使得外部链接的函数(普通函数)可以被运行时替换。这需要通过一种名为 PLT 的机制来支持。
具体来讲,需要先记录对应函数的真实地址,每一次函数调用都需要替换为先获取真实地址,再进行寻址和调用。这也同时让编译器无法进行内联优化。
例如以下代码:
auto f(int x) -> int {
return x - 1;
}
auto g(int x) -> int {
return f(x) - 1;
}
在开启 -fPIC
编译选项之后,编译结果如下:
f(int):
lea eax, -1[rdi]
ret
g(int):
sub rsp, 8
call f(int)@PLT
add rsp, 8
sub eax, 1
ret
即使是这样简单的函数,也进行了一次函数调用。而没有这个编译选项,编译结果如下:
f(int):
lea eax, [rdi-1]
ret
g(int):
lea eax, [rdi-2]
ret
这就很符合直觉了,所以我认为,-fPIC
是导致编译器内联优化失效的“罪魁祸首”。这甚至还会影响到全局变量和数组的访问效率,因为多了一次间接寻址。
为了解决这个性能问题,主要的思路是避免全局函数、变量作为外部链接。有几种大体思路:
static
函数/变量,仅在当前文件中可见,禁止外部链接。inline
函数,这种函数由于需要允许重定义,属于一种特殊的“弱符号”,也不需要支持相关机制。这里体现的仍然不是 inline
作为“内联建议”的语义。具体来讲,可以选择以下几种方案。
为全部的全局函数/变量标注 static
,函数也可以选择标注 inline
。二者效果在开启 O2 优化之后是相同的。
static int a[10];
inline void f() {}
static int g() { return 0; }
不同于普通的具名命名空间,匿名命名空间中的所有元素相当于自动添加了一个 static
,仅在当前文件可见。
namespace {
int a[10];
void f() {}
}
// 接下来可以直接使用 f(), a[i] 使用,无需特殊语法
类的成员函数会自动添加 inline
,和显式添加 inline
的函数具有相同效果。
class Solution {
int a[100];
void f() {}
public:
void solve() {}
};
int main() {
Solution s{}; // 自动清零数组
s.solve();
}
lambda 函数本质上是一个成员函数,也可以解决这个问题。
平衡性能和代码简洁性,建议采用“匿名命名空间”方案。
namespace {
// 全局变量、数组、函数等,均包裹在匿名命名空间中
int a[10], n;
void f() {}
void solve() {}
}
int main() {
// 在主函数中直接使用
solve();
}
如果希望兼顾“多测清空”的需求,全部使用类封装,也是一个不错的选择。
有些情况下,可能会发现编译器进行内联优化反而变慢。有两个可能的原因:
函数内联带来的优化效果不大,而评测机波动抵消了这部分优化效果。
一些情况下,确实可能由于内联优化导致性能损失。参考以下示例:
void err() {
// 假设是特别冗长的错误处理代码
std::cout << "error" << std::endl;
}
int f(int x) {
if (x < 0) return x >> 1;
else err();
return 0;
}
编译结果:
f(int):
test edi, edi
js .L23
; 大量的错误处理代码
.L23:
mov eax, edi
sar eax
ret
此时的 err 函数被内联展开,但是实际上,如果我们每次传入的参数都不会导致出错,那么每一次都会跳过大量的代码块,造成额外开销。编译器也会根据某些特征,来尽可能猜测哪个分支可能是“热分支”。例如按照以下的常见写法,编译器生成的代码就是高效的。
int f(int x) {
if (x < 0) return err(), 0;
else return x >> 1;
}
编译结果:
f(int):
mov eax, edi
sar eax
test edi, edi
js .L18
ret
.L18:
sub rsp, 8
call err() ; 正确将 err 分支识别为冷代码
xor eax, eax
add rsp, 8
ret
这种优化通常较为“玄学”,以及多数情况下,这种性能差异都是可以接受的。极端卡常场景,可以使用 __builtin_expect
或者 [[likely]]
提示编译器热分支,或者使用 __attribute__((noinline))
禁用内联优化,减小跳过的代码块大小。