C++尾调用优化
尾调用优化(Tail Call Optimization, 简称 TCO)是现代编译器中一项重要的优化技术,它能在某些条件下避免函数调用时的栈增长,从而减少运行时内存开销,提高程序性能。
本文回答了以下几个问题:
- 什么是尾调用?
- 尾调用和尾递归的区别?
- 为什么尾调用优化可以提高效率?
- 如何判断编译器是否做了尾调用优化?
【一句话】
函数调用有栈增长的开销,尾调用优化省去了函数调用入栈的开销。
什么是尾调用?
描述
尾调用是指:一个函数在"最后一步"调用另一个函数,并将其返回值直接返回。
【补充】
无返回值函数最后调用函数也可能做尾调用优化
如果函数A是无返回值,只要函数A在最后调用函数B,最后指的是调用函数B后没有其他操作,那编译器也是有可能会做尾调用优化的 。
因为尾调用的关键在于函数A调用函数B后,还需不需要用到函数A中的信息,如果不需要再用了,那么也就没有了将函数A相关信息入栈的必要 ,也就能直接复用当前的栈帧了。
例子
cpp
int foo(int x) {
return bar(x); // ← 这是一个尾调用
}
void foo_(int x) {
bar(x); // ← 这也是一个尾调用
}
关键特征(写法)
调用另一个函数之后,不再进行其他操作,直接返回。
尾调用和尾递归的区别?
尾递归就是尾调用中最后一个函数是调用自己,形成递归。
尾递归优化,编译器实际上可能把递归函数转换为循环实现。
cpp
// 原始尾递归
int sum(int n, int acc = 0) {
if (n == 0) return acc;
return sum(n - 1, acc + n); // 尾递归调用
}
// 优化后(编译器可能转为循环)
int sum(int n, int acc = 0) {
while (n > 0) {
acc += n;
n--;
}
return acc;
}
为什么尾调用优化可以提高效率?
通常的递归调用:
每调用一次函数,就在栈上分配一个新栈帧来保存局部变量和返回地址。
尾调用优化:
编译器可以直接复用当前栈帧来执行下一个函数调用,避免了栈帧的增长。
为什么栈帧复用就可以提高效率
首先我们需要明白函数调用时发生了什么,知道了栈帧生成的开销,才能知道为什么栈帧复用可以提高效率。
函数调用和尾调用优化避免的开销
栈空间分配
每次函数调用,系统都会为该调用分配一个新的栈帧(stack frame),用来保存局部变量、返回地址、参数、寄存器状态等信息。函数返回时,这个栈帧会被销毁。
【尾调用优化】
译器做了尾调用优化,就可以复用当前函数的栈帧,直接跳转到被调用函数,而不再分配新的栈帧。这样就避免了频繁分配和释放栈帧的开销。
栈帧入栈
栈空间分配后,需要把栈帧压入栈中,而在递归调用时,很容易出现深度过大导致的栈溢出。
【尾调用优化】
尾调用优化通过复用栈帧,使得递归调用不再增加栈深度,相当于变成了循环,极大降低了栈空间需求。
内存访问与缓存
栈帧的分配和释放涉及内存操作,虽然CPU有多级缓存,但频繁的内存访问仍然影响性能。
【尾调用优化】
栈帧频繁分配释放会带来内存操作,增加缓存失效风险,复用栈帧则降低了内存访问压力,有助于提升CPU缓存命中率,进一步提升性能。
如何判断编译器是否做了尾调用优化?
我们可以通过查看生成的汇编代码来判断是否进行了优化。
生成汇编的方法可以看看我的这篇博客C++中switch-case的性能优化策略详解
代码示例
cpp
int bar(int x) {
return x * 2156 + 15484;
}
int foo(int x) {
x++;
return bar(x * 5);
}
x86-64 gcc 编译,不开启优化
asm
"_Z3bari":
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov eax, DWORD PTR [rbp-4]
imul eax, eax, 2156
add eax, 15484
pop rbp
ret
"_Z3fooi":
push rbp
mov rbp, rsp
sub rsp, 8
mov DWORD PTR [rbp-4], edi
add DWORD PTR [rbp-4], 1
mov edx, DWORD PTR [rbp-4]
mov eax, edx
sal eax, 2
add eax, edx
mov edi, eax
call "_Z3bari" ; 注意在没有开启优化的情况下,是直接通过call指令调用函数,而这就会涉及到上一节讲的函数调用的开销。
leave
ret
开启-O2优化后
【注意】
这里有一点要提及,编译器有可能会做内联优化,这是另一种优化手段,但是本文想讨论的是尾调用优化,在函数体过于简单的情况下(例如本文提供的案例),编译器更倾向于使用内联优化,因此为了避免内联优化,我们必须对函数做一点修改,变成以下的样式,来明确规定不允许内联。
cpp// 增加__attribute__((noinline)) 明确告知编译器不用内联【注意,这个标记是GCC和Clang支持的,MSVC或者其他编译器可能有不一样的标记】 __attribute__((noinline)) int bar(int x) { return x * 2156 + 15484; } int foo(int x) { x++; return bar(x * 5); // 这是一个"尾调用"! }
asm
"_Z3bari":
imul eax, edi, 2156
add eax, 15484
ret
"_Z3fooi":
lea edi, [rdi+5+rdi*4]
jmp "_Z3bari" ; 直接跳转而非 call ⇒ 没有新栈帧产生
使用了jmp而不是call,说明这里栈帧复用,TCO 生效。