尾部调用优化似乎略微降低了性能
Tail call optimisation seems to slightly worsen performance
在快速排序实现中,左边的数据是纯-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.1
与1.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
的数组的哪一部分,因为它发挥着巨大的作用。
- 调用不在基类中的派生类函数而不进行动态强制转换,以最大程度地提高性能
- 虚拟函数调用的性能作为 for 循环中的上限
- 性能函数调用与乘以 1
- if-else 与三元函数调用性能
- 在类实例或方法的上下文中调用函数以进行性能分析
- 当我将 c++ DLL(我从 c# 调用)重新生成为 /CLR(最初是本机)时,性能下降了一半
- 在X64模式下从C /CLI调用MASM PROC会产生意外的性能问题
- constexpr vs重复函数调用性能
- C++按字符串调用函数,比较PHP的性能,如何在C++中优化代码
- 递归可变参数函数调用对简单 if.else 语句的性能
- 尾部调用优化似乎略微降低了性能
- C /C#Interop中的内存映射和P/调用性能
- 运算符重载与函数调用的性能
- 奇怪的OpenCL调用C++上的副作用来提高循环性能
- 渲染调用性能消耗
- 对同一对象进行x调用的c++虚拟函数性能
- C++纯虚拟函数调用的性能可变性
- 调用GPU内核后,CPU性能下降
- 嵌套函数或方法调用会降低应用程序的性能
- 可以通过减少多线程中系统调用(互斥/信号量)的开销来真正提高性能::atomic