【问题标题】:Tail call optimisation seems to slightly worsen performance尾调用优化似乎稍微恶化了性能
【发布时间】:2018-03-30 11:10:14
【问题描述】:

在快速排序实现中,左侧的数据是纯-O2 优化代码,右侧数据是-O2 优化代码,-fno-optimize-sibling-calls 标志打开,即尾调用优化关闭。这是 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 的尾调用优化器究竟是如何工作的,这是如何发生的,我应该如何利用尾调用来加速我的程序?

【问题讨论】:

  • 你的问题是什么? “解释 gcc 的优化器是如何工作的以及这是如何发生的”或“我如何避免这个问题”或“我怎样才能让代码运行得更快”?
  • 第一行数据应该是 35083,34051 吗?如果你把桌子变成一个固定宽度的桌子,中间有空格会很有帮助。
  • 警告:这是一个糟糕的实现,因为递归调用可以很好地爆炸堆栈。总是先处理最短的子数组!
  • @YvesDaoust :当然,这只有在编译器真正消除尾调用时才有用!
  • ShihabShahriar:正如@YvesDaoust 所说,快速排序中尾调用的主要目的不是加快快速排序,而是防止堆栈爆炸。通过在较短的分区上递归,然后在较长的分区上循环,可以保证递归深度小于元素数量的log2,可以认为是大多数实用硬件上的小常数。 (例如,远小于 64)

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


【解决方案1】:

开启速度:

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

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

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

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

您的快速排序的情况并不那么明确:有些调用的工作量很大,有些调用的工作量很小。

因此,对于 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.1 vs. 1.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),否则为“分段错误” - 因为递归深度导致堆栈溢出。

其实是一个简单的测试,看是否执行了尾调用优化:未优化版本和大递归深度的“分段错误”。


推论:

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

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

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

【讨论】:

  • 您没有回答 OP 提出的三个问题中的任何一个。您没有解释 gcc 的尾调用优化器是如何工作的。您没有解释为什么该优化会降低 qsort 的性能。您没有解释如何修复 qsort 以便优化对其进行更好的处理。您在这里所做的只是对尾调用优化的一般性、不精确的介绍。令人惊奇的是,尽管如此,OP 还是接受了你的回答,你得到了 5 个赞成票和 0 个反对票。真正能回答问题的人将不再有动力这样做,因为没人关心了。
  • @HadiBrais 很遗憾听到这个消息,我的回答毁了你这个问题,我已经尽力了(当然,有很多人可以做得更好),但我仍然希望不是对别人完全没用。如果您对更好的答案感兴趣,您可以开始赏金或发布您自己的更具体的问题(因为“解释 gcc 的工作原理”很可能不会触发很多精确的答案)。
  • @HadiBrais 或者如果你想给出更准确的答案,如果这能激励你,我很乐意开始赏金。
  • 你的回答不是没用的。只是这不是适合它的地方。我有兴趣给出答案。我不想要任何赏金。 OP接受了您的回答并继续前进。因此,再为此付出任何努力是没有意义的。
猜你喜欢
  • 2016-07-28
  • 2019-02-18
  • 2013-08-04
  • 2014-06-09
  • 2011-03-31
  • 2014-04-06
  • 1970-01-01
  • 2012-02-01
  • 2011-07-11
相关资源
最近更新 更多