这里优化程序性能的目的是通过手工改变代码结构,而不是算法效率和数据结构优化,有些编译器在某些优化选项下可能会做出类似的改动。
1. 优化编译器的能力和局限性
现代编译器大多数都向用户提供了对优化的控制,最简单的就是指定优化级别,如-O1(基本的优化),-O2,-O3(更全面的优化),这里主要考虑限制优化级别1编译出的代码,其实优化级别1不一定性能就不如2和3。编译器必须很小心地对程序进行优化,因为要保证程序的正确性,为了理解决定一种程序转换是否安全的难度,看一下下面的例子:
void twiddle1(int *xp,int *yp)
{
*xp+=*yp;
*xp+=*yp;
}
void twiddle2(int *xp,int *yp)
{
*xp+=2* *yp;
}
可以看出,这两个过程行为相同,twiddle2效率更高一些,因为它只要求3次存储器引用(读xp,读yp,写*xp),而twiddle1需要6次,如果我是编译器我肯定会选择把twiddle1转换为twiddle2的过程,但真的是这样吗?
如果xp等于yp,两个过程的计算结果就会不一样,twiddle1:
*xp+=*xp;//*xp变为2*xp
*xp+=*xp;//*xp变为4*xp
而twiddle2:
*xp+=2* *xp;//*xp变为3*xp
由于编译器不知道twiddle1会如何被调用,因此它必须考虑xp=yp的情况,所以不能优化为twiddle2的代码。
这种两个指针可能指向同一个存储器位置的情况叫存储器别名使用
,在只执行安全的优化中,编译器必须考虑这种情况。
还有一个妨碍优化的因素是函数调用
。看下面的例子:
int f();
int fun1()
{ return f1()+f1()+f1()+f1(); }
int fun2()
{ return 4*f1(); }
从数学的角度,fun1和fun2看上去会产生相同的结果,但fun2只调用了f一次,执行fun1时会很想产生fun2这样的代码。但是如果f操作了全局变量呢?
int count=0;
int f()
{ return count++; }
这样fun1会返回0+1+2+3=6,而fun2返回0 。编译器会假设最糟的情况,并保持所有的函数调用不变。
包含函数调用的代码可以用内联函数替换
的过程进行优化,比如刚刚的fun1,可以替换成:
int fun1()
{
int t=count++;
t+=count++;
t+=count++;
t+=count++;
t+=count++;
return t;
}
这样就减少了函数调用的开销。
2. 表示程序性能
引入每元素周期数 CPE
来度量程序性能
处理器活动的顺序是由时钟控制的,时钟提供了某个频率的规律信号,通常用千兆赫兹(GHz),即十亿周期每秒来表示。例如,当表明一个系统有“4GHz”处理器,这表示处理器时钟运行频率为 4*109千兆赫兹。每个时钟周期的时间是时钟频率的倒数。通常用纳秒(nanosecond,1 纳秒等于10-9秒),或者皮秒(picosecond,1 皮秒等于10-12秒)来表示,一个 4GHz 的十周周期为0.25纳秒,或者说250皮秒。从程序员的角度来看,用时钟周期来表示度量标准要比用纳秒或者皮秒来表示有用的多。
用时钟周期来表示,度量值表示的是执行了多少条指令,而不是时钟运行的有多快。
3. 优化方法
下面用一个向量的例子说明程序如何被系统转换为更有效的代码。
typedef struct {
long len;
data_t *data;//data_t分别声明为不同类型
} vec_rec, *vec_ptr;
//生成向量
vec_ptr new_vec(long len)
{
/* Allocate header structure */
vec_ptr result = (vec_ptr) malloc(sizeof(vec_rec));
if (!result)
return NULL; /* Couldn’t allocate storage */
result->len = len;
/* Allocate array */
if (len > 0) {
data_t *data = (data_t *)calloc(len, sizeof(data_t));
if (!data) {
free((void *) result);
return NULL; /* Couldn’t allocate storage */
}
result->data = data;
}
else
result->data = NULL;
return result;
}
//根据所以访问向量元素
int get_vec_element(vec_ptr v, long index, data_t *dest)
{
if (index<0||index >= v->len)
return 0;
*dest = v->data[index];
return 1;
}
//获取向量长度
long vec_length(vec_ptr v)
{
return v->len;
}
下面的代码使用某种运算,将一个向量中所有的元素合并成一个值,运算可以是#define IDENT 0 #define OP *
,#define IDENT 1 #define OP +
。
void combine1(vec_ptr v, data_t *dest)
{
long int i;
*dest = IDENT;
for (i = 0; i < vec_length(v); i++) {
data_t val;
get_vec_element(v, i, &val);
*dest = *dest OP val;
}
}
3.1 消除循环的低效率
下面来看看combine1存在什么性能上的问题。在combine1中,调用函数vec_length作为for循环的测试条件,每次循环都必须对测试条件求值,而且向量的长度并不会变,因此我们只需要计算一次向量的长度,然后在测试条件中都使用这个值。如combine2所示,这种例子叫做代码移动
,即将要执行多次,但计算结果不会改变的计算移到前面不会被多次求值的部分。编译器未必能做出这样的优化,因此需要程序员自己手动进行这样的变换。
void combine2(vec_ptr v, data_t *dest)
{
long int i;
long length = vec_length(v);
*dest = IDENT;
for (i = 0; i < length; i++) {
data_t val;
get_vec_element(v, i, &val);
*dest = *dest OP val;
}
}
3.2 减少过程调用
combine2中,每次循环都要调用get_vec_element获取下一个元素,这个函数要把i和循环边界做比较。作为替代,如果增加一个get_vec_start函数,它返回数组的起始地址,然后就能用下面combine3这样的过程,这样循环里就没有函数调用,而是直接访问数组。
data_t *get_vec_start(vec_ptr v)
{
return v->data;
}
void combine3(vec_ptr v, data_t *dest)
{
long int i;
long int length = vec_length(v);
data_t *data = get_vec_start(v);
*dest = IDENT;
for (i = 0; i < length; i++) {
*dest = *dest OP data[i];
}
}
3.3 消除不必要的存储器引用
在循环代码中,指针dest的地址存放在寄存器中,在第i次迭代中,程序读出这个位置处的值,乘以data[i],再将结果存回到dest,这样的读写很浪费,因为每次迭代开始从dest读出的值就是上次迭代最后写入的值。我们可以通过引入一个临时变量acc累积计算出来的值,在循环完成后再将acc的值写入dest。
void combine4(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
data_t *data = get_vec_start(v);
data_t acc = IDENT;
for (i = 0; i < length; i++) {
acc = acc OP data[i];
}
*dest = acc;
}
可能有人会以为编译器会做这样的优化,但想想之前讲过的存储器别名使用的问题。举个例子,IDENT=1,OP为*,v=[2,3,5],比较combine3和combine4的执行情况。
combine3(v,get_vec_start(v)+2);
combine4(v,get_vec_start(v)+2);
结果如下表所示。为什么呢?因为combine3的*dest,也就是v的最后一个元素,是一直在变化的,导致中间计算时的数也是变化的,并不是初始的那三个元素了;而combine4则是最后计算结束才改变*dest的值。
4. 循环展开
循环展开就是增加每次迭代计算的元素的数量,减少循环的迭代次数。
void combine5(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
long limit = length-1;
data_t *data = get_vec_start(v);
data_t acc = IDENT;
/* Combine 2 elements at a time */
for (i = 0; i < limit; i+=2) {
acc = (acc OP data[i]) OP data[i+1];
}
/* Finish any remaining elements */
for (; i < length; i++) {
acc = acc OP data[i];
}
*dest = acc;
}
这个优化措施利用了对CPU数据流的知识,比汇编代码更接近机器底层。简单地说是利用了CPU的并行性,将数据分成不相关的部分并行地处理。
5. 提高并行性
和版本5的思想类似,但由于并行化更高,性能更好一些,充分利用了向量中各个元素的不相关性。
版本6使用多个累积变量方法,将一运算分割成两个或更多的部分,在最后合并结果。
/* Unroll loop by 2, 2-way parallelism */
void combine6(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
long limit = length-1;
data_t *data = get_vec_start(v);
data_t acc0 = IDENT;
data_t acc1 = IDENT;
/* Combine 2 elements at a time */
for (i = 0; i < limit; i+=2) {
acc0 = acc0 OP data[i];
acc1 = acc1 OP data[i+1];
}
/* Finish any remaining elements */
for (; i < length; i++) {
acc0 = acc0 OP data[i];
}
*dest = acc0 OP acc1;
}
版本7是在版本5的基础上重新结合变换,打破顺序相关,改变了并行执行的操作数量。差别只在于两个括号是如何放置的
void combine7(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
long limit = length-1;
data_t *data = get_vec_start(v);
data_t acc = IDENT;
/* Combine 2 elements at a time */
for (i = 0; i < limit; i+=2) {
acc = acc OP (data[i] OP data[i+1]);
//in combine5:
//acc = ( acc OP data[i]) OP data[i+1];
}
/* Finish any remaining elements */
for (; i < length; i++) {
acc = acc OP data[i];
}
*dest = acc;
}
整理自书《深入理解计算机系统》