尾部呼叫优化似乎略微恶化了性能

时间:2017-10-18 14:24:25

标签: c++ algorithm optimization g++ compiler-optimization

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

clock()

以下是我的代码:

{{1}}

我听说{{1}}不是衡量时间的完美方式。但这种效果似乎是一致的。

编辑:作为对评论的回应,我想我的问题是:解释gcc的尾部调用优化器是如何工作的以及如何发生这种情况以及如何利用尾调用来加速我的程序?

1 个答案:

答案 0 :(得分:5)

速度:

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

但是,通常会有一个抵押品:程序变得更快,因为调用函数不需要开销。如果函数本身的工作量不是很大,那么这种增益最为突出,因此开销有一定的分量。

如果在函数调用期间完成了大量工作,则可以忽略开销并且没有明显的加速。

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

快速排序的情况并不是那么明确:有些调用工作负载很多,而且工作负载很小的调用很多。

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

我必须承认,我不能说,为什么仅通过assembly来看就是这种情况。我可以理解的是,生成的程序集非常不同,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 ++在beetween中发挥作用:编译器尽力而为(这非常好!),但你无法保证它会被取消,如果没有,它会默默地落入没有尾调用优化的版本。

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

//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);
}

这种经典的尾调用形式使编译器可以轻松优化:参见结果here - 没有递归调用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 manages也可以在这里执行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),或者#34;分段错误&#34;否则 - 因为递归深度导致堆栈溢出。

实际上,查看是否执行了尾调用优化是一个简单的测试:&#34;分段错误&#34;对于非优化版本和大递归深度。

<强>推论:

正如评论中已经指出的那样:只有quick-sort的第二次调用才能通过TLO进行优化。在您的实现中,如果您运气不好并且数组的后半部分始终只包含一个元素,则堆栈上将需要O(n)空间。

但是,如果第一次调用将始终使用较小的一半而第二次调用较大的一半是TLO,则最多需要O(log n)递归深度,因此仅需要O(log n)空间叠加。

这意味着您应首先检查您调用quicksort的数组的哪个部分,因为它起着重要作用。