X86 Tail Call 汇编
阶乘的 C 语言实现(尾优化版本):
// fac.c
int __attribute__((noinline)) fac_iter(int n, int acc) {
if (n <= 1)
return acc;
else
return fac_iter(n - 1, acc * n);
}
int fac(int n) { return fac_iter(n, 1); }
代码中的 __attribute__((noinline)) 是告诉编译器在 fac 中调用 fac_iter 时不要内联函数,以便观察 tail call 对应的汇编代码。
clang 编译选项:
-mno-sse: 关闭 SIMD 优化-fno-asynchronous-unwind-tables: 关闭.cfi
-O0
clang -c -mno-sse -fno-asynchronous-unwind-tables -S -O0 fac.c 的结果没有尾优化,所有的函数调用都是使用 callq:
_fac_iter: ## @fac_iter
movl %esi, %eax
cmpl $2, %edi
jl LBB0_2
pushq %rbp
movq %rsp, %rbp
imull %edi, %eax
decl %edi
movl %eax, %esi
callq _fac_iter
popq %rbp
LBB0_2:
retq
_fac: ## @fac
pushq %rbp
movq %rsp, %rbp
movl $1, %esi
callq _fac_iter
popq %rbp
retq
-O2
clang -c -mno-sse -fno-asynchronous-unwind-tables -S -O2 fac.c 的结果已有尾优化:
_fac_iter: ## @fac_iter
pushq %rbp
movq %rsp, %rbp
movq %rsi, %rax
cmpq $2, %rdi
jl LBB0_2
LBB0_1: ## =>This Inner Loop Header: Depth=1
imulq %rdi, %rax
leaq -1(%rdi), %rcx
cmpq $2, %rdi
movq %rcx, %rdi
jg LBB0_1
LBB0_2:
popq %rbp
retq
_fac: ## @fac
pushq %rbp
movq %rsp, %rbp
movl $1, %esi
popq %rbp
jmp _fac_iter ## TAILCALL
在 fac_iter 对应的汇编代码中,递归函数调用被直接优化成了循环的实现。
而在 fac 函数中,我们可以看到调用 fac_iter 的地方使用的是 jmp,而不是 callq。fac 中首先把 %esi (也就是 fac_iter 的第二个参数)设置为 1,然后直接 jump 到 fac_iter 执行。这也意味着,fac_iter 函数栈帧之上的栈帧是调用 fac 的函数,而不是 fac。fac_iter 函数返回时,直接返回调用 fac 函数的地方,而不是 fac 内部。
Tail call 优化的好处在于可以节省栈空间,也为较深的调用链和尾递归提供了可能。
Tail Call 优化的递归调用
值得注意的是,在上面的 fac_iter 的汇编中,尾递归并没有使用 tail call 的优化,而是直接优化成了函数内部的循环。事实上我们可以把 tail recursion 看做一种特殊的 tail call,它可以被优化成函数内部的循环,也可以使用一般的 tail call 优化。下面的代码是我从 -O2 的汇编代码修改而来,把 fac_iter 中的循环修改为 tail call:
_fac_iter: ## @fac_iter
pushq %rbp
movq %rsp, %rbp
movq %rsi, %rax
cmpq $2, %rdi
jl LBB0_2
+ imulq %rdi, %rsi ## %rsi *= %rdi (acc * n)
+ leaq -1(%rdi), %rdi ## %rdi -= 1 (n-1)
+ popq %rbp
+ jmp _fac_iter
LBB0_2:
popq %rbp
retq
## -- End function
_fac: ## @fac
pushq %rbp
movq %rsp, %rbp
movl $1, %esi
popq %rbp
jmp _fac_iter ## TAILCALL
如果简单分析汇编代码,我们可以发现,tail call 的版本不会因为递归调用产生新的栈帧,大致等价于循环。但是相比与普通的循环版本,tail call 版本中多了许多的对栈帧指针 %rbp 的操作。
性能比较
我们把上面三段代码分别编译并链接相同的测试代码,得到的程序分别称为 fac-O0、fac-O2、fac-tail-call。我使用的测试环境是 MacBook Pro (16-inch, 2019) Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz。测试结果如下(macrosconds):
fac-O0 时间 |
fac-O2 时间 |
fac-tail-call 时间 |
|
|---|---|---|---|
| 5! | 5 | 5 | 4 |
| 10! | 5 | 5 | 5 |
| 100000! | 2,703 | 117 | 142 |
| 1000000! | 29,670 | 1,032 | 1,385 |
| 10000000! | stack overflow | 10,095 | 13,650 |
| 100000000! | - | 84,169 | 103,362 |
可以看到,fac-O2 和 fac-tail-call 的性能相差不是特别明显,fac-O2 所用的时间大概是 fac-tail-call 的 80% 左右。