尾部调用优化似乎略微降低了性能

Tail call optimisation seems to slightly worsen performance

本文关键字:性能 调用 优化 尾部      更新时间:2023-10-16

在快速排序实现中,左边的数据是纯-O2优化代码,右边的数据是打开-fno-optimize-sibling-calls标志的-O2优化代码,即关闭尾调用优化。这是3次不同运行的平均值,变化似乎可以忽略不计。数值范围为1-1000,时间单位为毫秒。编译器是MinGW g++,版本6.3.0。

size of array     with TLO(ms)    without TLO(ms)
8M                35,083           34,051 
4M                 8,952            8,627
1M                   613              609

以下是我的代码:

#include <bits/stdc++.h>
using namespace std;
int N = 4000000;
void qsort(int* arr,int start=0,int finish=N-1){
if(start>=finish) return ;
int i=start+1,j = finish,temp;
auto pivot = arr[start];
while(i!=j){
while (arr[j]>=pivot && j>i) --j;
while (arr[i]<pivot && i<j) ++i;
if(i==j) break;
temp=arr[i];arr[i]=arr[j];arr[j]=temp; //swap big guy to right side
}
if(arr[i]>=arr[start]) --i;
temp = arr[start];arr[start]=arr[i];arr[i]=temp; //swap pivot
qsort(arr,start,i-1);
qsort(arr,i+1,finish);
}
int main(){
srand(time(NULL));
int* arr = new int[N];
for(int i=0;i<N;i++) {arr[i] = rand()%1000+1;}
auto start = clock();
qsort(arr);
cout<<(clock()-start)<<endl;
return 0;
}

我听说clock()不是测量时间的完美方法。但这种效果似乎是一致的。

编辑:作为对一条评论的回应,我想我的问题是:解释gcc的尾调用优化器到底是如何工作的,这是如何发生的,以及我应该如何利用尾调用来加快我的程序?

开启速度:

正如评论中已经指出的,尾调用优化的主要目标是减少堆栈的使用。

然而,通常有一个附带条件:程序变得更快,因为调用函数不需要开销。如果函数本身的功没有那么大,那么这种增益是最显著的,因此开销有一定的权重。

如果在函数调用过程中完成了大量工作,则可以忽略开销,并且不会出现明显的加速。

另一方面,如果完成了尾调用优化,则意味着可能无法进行其他优化,否则可能会加速代码。

快速排序的情况并不清楚:有些调用工作量很大,而很多调用工作量很小。

因此,对于1M元素,尾调用优化的缺点更多。在我的机器上,对于小于50000元素的数组,尾调用优化函数变得比未优化函数更快。

我必须承认,我不能说,从大会的角度来看,为什么会出现这种情况。我所能理解的是,生成的程序集非常不同,并且对于优化版本,quicksort实际上只调用了一次。

有一个明确的例子,尾调用优化要快得多(因为函数本身没有发生太多事情,开销很明显):

//fib.cpp
#include <iostream>
unsigned long long int fib(unsigned long long int n){
if (n==0 || n==1)
return 1;
return fib(n-1)+fib(n-2);
}
int main(){
unsigned long long int N;
std::cin >> N;
std::cout << fib(N);
}

运行time echo "40" | ./fib,尾调用优化版本与非优化版本的1.11.6秒。事实上,我印象深刻的是,编译器能够在这里使用尾调用优化——但它确实做到了,正如在godbolt.org上看到的那样——fib的第二次调用得到了优化。


尾部呼叫优化:

通常,如果递归调用是函数中的最后一个操作(在return之前),则可以进行尾调用优化-堆栈上的变量可以重复用于下一个调用,即函数的形式应该是

ResType f( InputType input){
//do work
InputType new_input = ...;
return f(new_input);
}

有些语言根本不做尾调用优化(例如python),有些语言你可以明确要求编译器做尾调用,如果编译器不能做,它就会失败(例如clojure)。c++在这方面做得很好:编译器尽了最大努力(这真是太好了!),但你不能保证它会成功,如果不能成功,它就会默默地陷入一个没有尾调用优化的版本。

让我们来看看这个简单而标准的尾调用递归实现:

//should be called fac(n,1)
unsigned long long int 
fac(unsigned long long int n, unsigned long long int res_so_far){
if (n==0)
return res_so_far;
return fac(n-1, res_so_far*n);
}

这种经典形式的尾部调用使编译器可以轻松地进行优化:请参阅此处的结果-没有对fac的递归调用!

然而,对于不太明显的情况,gcc编译器也能够执行TCO:

unsigned long long int 
fac(unsigned long long int n){
if (n==0)
return 1;
return n*fac(n-1);
}

对我们人类来说,读写更容易,但对编译器来说,优化更难(有趣的事实:如果返回类型是int而不是unsigned long long int,则不执行TCO):毕竟,递归调用的所有结果在返回之前都用于进一步的计算(乘法)。但是gcc也能在这里执行TCO!

在这个例子的手边,我们可以看到TCO在工作的结果:

//factorial.cpp
#include <iostream>
unsigned long long int 
fac(unsigned long long int n){
if (n==0)
return 1;
return n*fac(n-1);
}
int main(){
unsigned long long int N;
std::cin >> N;
std::cout << fac(N);
}

如果尾调用优化处于启用状态,则运行time echo "40000000" | ./factorial会很快得到结果(0),否则会出现"分段错误",因为递归深度导致堆栈溢出。

实际上,这是一个简单的测试,看看是否执行了尾调用优化:对于未优化的版本和较大的递归深度,"分段错误"。


推论:

正如评论中已经指出的:只有quick-sort的第二个调用是通过TLO优化的。在您的实现中,如果您运气不好,并且数组的后半部分总是由一个元素组成,那么您将需要堆栈上的O(n)空间。

但是,如果第一个调用总是使用较小的一半,而第二个调用使用较大的一半是TLO,则最多需要O(log n)递归深度,因此堆栈上只有O(log n)空间。

这意味着您应该检查首先调用quicksort的数组的哪一部分,因为它发挥着巨大的作用。