C++的高效从何而来

前一段时间,实验室的一哥们突然跑过来跟我说,“我自己写了个C的快速排序,排了一个10000000个int的数组,貌似比C库中是qsort算法要快,咋回事?C++的STL中快排(quick sort)算法的效率如何?”。

听他这么一说,我就立即做了个实验,写了如下代码:

#include <iostream>
#include <algorithm>
#include <time.h>

using namespace std;

#define MAX_LEN 10000000

int arri[MAX_LEN];

int compare(const void* i, const void* j)
{
  return (*(int*)i - *(int*)j);
}

int main()
{
  for (int i = 0; i < MAX_LEN; i++) {
    arri[i] = rand() % MAX_LEN;
  }

  ::clock_t start, finish;

  //STL sort
  start = ::clock();
  sort(arri, arri + MAX_LEN);
  finish = ::clock();

  cout << "STL sort:\t" << finish - start << "ms" << endl;

  for (int i = 0; i < MAX_LEN; i++) {
    arri[i] = rand() % MAX_LEN;
  }

  //C qsort
  start = ::clock();
  qsort(arri, MAX_LEN, sizeof(arri[0]), compare);
  finish = ::clock();

  cout << "C qsort:\t\t" << finish - start << "ms" << endl;

  return 0;
}

我机器上装的是CodeBlocks(10.05)+MinGW(4.7.2)的环境。

首先,在debug模式下,CodeBlocks显示出当前的编译器命令:

mingw32-g++.exe -Wall -fexceptions  -g  -std=c++0x    -c D:\Workspaces\CodeBlocks\TestSortQ\main.cpp -o obj\Debug\main.o
mingw32-g++.exe  -o bin\Debug\TestSortQ.exe obj\Debug\main.o

运行结果:

image

运行结果让我大吃一惊,STL不可能这么慢啊!后来仔细一想,这是debug模式,没有加任何优化,加上优化看看什么结果:

mingw32-g++.exe -Wall -fexceptions  -O2  -std=c++0x    -c D:\Workspaces\CodeBlocks\TestSortQ\main.cpp -o obj\Release\main.o
mingw32-g++.exe  -o bin\Release\TestSortQ.exe obj\Release\main.o   -s

运行结果:

image

果然,O2选项一加上,STL sort瞬间完成了逆袭,运行时间优化了75%,而C qsort优化前后变化不是很明显,大概减少了10%。

 

问题来了,为什么C++标准库的快排的优化效果如此明显,而C库的快排优化不是很明显呢?

答案是inline

我们知道,STL是泛型编程的杰出成果,里面的容器、迭代器、算法几乎都是通过泛型实现的,使得STL的通用性很强。泛型编程的一个负面效果就是破坏了接口与实现的分离,即头文件中声明,源文件中实现,源文件单独编译成库,用户只需要拿到头文件和库就可以使用了,看不到具体实现,这就是所谓的ABI,也是C的传统做法。有人会问,为什么不能做到接口与实现的分离,因为泛型编程中的函数和类,在没有接受一个模版参数之前,是没办法实例化的,只有当用户给定了模版参数的时候,编译器才会去实例化一个具体的类或函数。

如,C++ STL中的快速排序算法定义:

template< class RandomIt >
void sort( RandomIt first, RandomIt last );
只有RandomIt这个类型真正确定了,编译器才会去实例化这个方法。在上面的代码中,我这么写:
sort(arri, arri + MAX_LEN);

编译器通过自动类型推导,知道了RandomIt其实是一个int*,于是产生这个函数:

sort(int*, int*);

具体实现中的RandomIt已经都替换成相应的int*,一个完整的函数就产生了。

关键的问题出现了!编译器在实例化一个函数(类也一样)的时候,它必须知道具体的实现代码,才能够产生完整的函数。这也是为什么如果大家自己写模版的时候,.h文件和.cpp文件的关系变得十分奇怪的原因,一般做饭是在.h文件末尾,#include xx.cpp,其中xx.cpp中实现了.h文件中的函数声明,或者干脆直接在.h文件中写实现。否则,如果按照一般的.h和.cpp的关系,编译器会报错,说找不到函数的实现。写过模版的程序猿应该都知道这个。

事实上,C++标准委员会为了解决这个问题,曾经引入了export关键字,来试图解决这个问题,但很少有编译器实现了(估计是实现难度较大,且增加了复杂度,得不偿失),所以这个关键字后来基本上费了。

 

大家或许会问,你是不是走题了,刚开始不是讨论C++的效率问题吗?怎么说了半天泛型和模版的事情了?

答案是,真是由于泛型的这个“副作用”,使得编译器可以做更多的优化!

 

既然编译器知道具体的实现,那么inline是编译器可以在优化上大显身手的一个手段,sort函数中需要一个compare函数(在C++中还可以通过函数对象或者操作符重载实现)来知道如何比较两个元素的大小,sort函数每次比较的时候,都会调用这个函数。对于一个10000000个元素的数组,一共会调用多少次这个compare函数是可想而知的(具体数目可以算出来),而一次函数调用的开销比较大,如栈的分配等等,这就很大程度上限制了C库中的qsort的威力,因为qsort的实现是在编译在库中的,它所调用的函数就没法inline到qsort函数里面去。但是STL是可以做到的,所以它的优化效果非常明显。

 

另外一个STL sort效率高的原因,在于算法的实现,不仅仅是快速排序算法,估计这也是为什么名字叫sort而不叫qsort的原因吧。在SGI STL(GNU所使用的STL)的实现中,sort函数一共采用了三种排序算法,分别是quick sort,heap sort和insert sort。使用策略如下:

1、函数主体为quick sort,但是在递归调用的时候,加上了一个参数记录迭代层数,如果迭代次数超过一定数目,转而采用heap sort。原因是如果迭代次数过多,很可能意味着quick sort落入了最坏情况(O(n2)),而heap sort的最坏情况依然是O(nlogn)。

2、当数组划分到很小的一段时,采用insert sort。原因是对于小数据量采用quick sort有些不划算(quick sort适合处理大量数据),因为quick sort本身是递归的,递归就是一次函数调用,开销较大。而insert sort在较小数据量的情况下,表现很好。

具体算法可参考SGI STL和侯捷的《STL源码剖析》。

 

说了这么一大堆,其实想表达的一点就是,C++为了提高效率,可以说无所不用其极,无论是STL算法实现,还是编译器的优化(语言本身为了编译器能做优化也下了很多功夫),都体现了C++的三大设计思想(或者叫做约束,可参考孟岩《关于C++复杂性的零碎思考》)之一:最高性能。

 

回到那位哥们的问题,为什么他的快排效率比C库的要高?我不否认他的水平,但是我感觉最大的原因还是,自己写的函数,编译器可以将compare的功能内敛到函数里面去,所以效率比C库的qsort效率要高。

 

关于C++的高效,我还想继续写一些文章,这篇博客且当作一个开始吧。

posted @ 2012-11-25 16:47  tangzhnju  阅读(2452)  评论(8编辑  收藏  举报