最近看了《深入理解计算机系统》这本书,对其中程序优化这一章节进行了深入学习,以此博客作为学习记录。
先总结几点程序优化的原则:
1. 代码正确问题,一个运行很快但是给出了错误结果的程序没有任何用处
2. 代码清晰简洁,这样不仅是为了自己看懂代码,也是为了检查代码和今后需要修改时,其他人能够读懂和理解代码
3. 考虑代码的使用方式和影响它的关键因素,低级别的代码优化没有带来较大的性能提高反而使得程序的可读性和拓展性下降,更容易出错了,这就不是一个理想的代码优化。我们应当选择那些性能重要的环境下反复执行的代码进行大量的优化。
程序高效程序可以从以下几个方向出发:
1、 选择一组适当的算法和数据结构。
2、 编写出编译器能有效转化成高效可执行代码的源代码。这对理解优化编译器的能力和局限性要求比较高
优化编译器的能力和局限性。
了解编译系统如何工作是大有益处的;
1、优化程序性能。(为了我们在C程序中做出好的代码选择我们确实需要对汇编语言以及编译器如何将不同的C语句转化为汇编语言有一些基本的了解)
2、理解连接时出现的错误。(一些最令人困扰的程序错误往往都与链接器操作有关,尤其是当你试图建立大型的软件系统时)
3、避免安全漏洞。(近年来,缓冲区溢出错误造成大多数网络和Internet服务器上的安全漏洞,这些而错误的存在时因为太多的程序员忽视了编译器用来为函数产生代码的堆栈规则)
首先在学习控制之前先要了解编译器,现代编译器运用复杂精细的算法来确定一个程序的计算值,以及它们是如何被使用的,大多数编译包括GCC向用户提供了一些对它们所使用的优化的控制。最简单的控制就是指定优化级别,例如用命令“-Og”调用GCC就是让GCC只做基本的优化,而-O1或者更好的优化级别都是可以支持的。虽然对于大多数GCC软件项目来说-O2已经成为了可以被接收的标准,但是主要还是考虑以优化级别为-O1编译出的代码。在以此为基础下,我们做了接下来的对比测试。
举一个简单的例子,考虑以下代码
void test1(long *xp,long *yp)
{
*xp+= *yp;
*xp+= *yp;
}
void test2(long *xp,long *yp)
{
*xp+= *yp * 2;
}
我们能知道test1,使用6次内存读写,而test2只用了三次内存读写,因为我们会以为test1会优化为test2版本,然而实际上并没有,考虑到当xp=yp的时候,test1计算结果了4倍*xp,而test2只计算出3倍*xp,而编译器在编译时不知道该函数会被如何调用故不会产生test2的优化。
(结论:在只执行安全的优化中,编译器必须假设不同的指针可能指向同一位置)
函数调用会妨碍优化,考虑以下代码
long f();
long test1()
{
return f() + f() + f() + f();
}
long test2()
{
return 4 * f();
}
看上去两个过程会产生相同结果,但是test2只调用一次f(),而test1调用了4次,这让我们很容易联想到test2作为test1的优化,但是如果考虑f()为以下情况
int g_count = 0;
long f()
{
return g_count++;
}
这是两个函数就会产生不同的全局变量值,所有编译器不会进行优化,大多数编译器不会试图判断一个函数有没有副作用,如果没有则进行优化,而相反的,编译器会假设最糟的情况,保持所有函数调用不变。在各种编译器中,就优化能力来说,GCC是认为胜任的,但是不是特别突出,它只带基本的优化,而不会对程序进行更加“有进取心的”编译器所做的那种激进变换,因为使用GCC的程序员需要花费更多精力以简化编译器生成搞笑代码的任务的方式来编写程序。
在了解了编译器的局限性之后,我们学会来表示程序的性能
再次我们引入度量标准,每周期的元素数(Cycles Per Element ,CPE)作为一种表示程序性能并指导我们改进代码的方法。通过最小二乘拟合的方法来计算CPE。接下来我们以一段短小代码为例,演示如何程序的优化过程:
先定义一个向量数据结构,内存由头部和数据数组构成,以下声明一个头部结构。
typedef struct {
long len;
data_t * data;
}vec_rec, *vec_ptr;
Data_t代码基本数据类型,我们可以声明不同基本类型进行测试
typedef long data_t;
如上设置long类型,还会分配一个len个data_t类型对象的数组,存放实际元素。
首先编写类型申请函数
vec_ptr new_vec(longlen)
{
vec_ptr result =(vec_ptr)malloc(sizeof(vec_rec));
data_t*data = nullptr;
if(!result)
return nullptr;
result->len= len;
if(len>0)
{
data= (data_t *)calloc(len,sizeof(data_t));
if(!data)
free((void*)result);
}
result->data= data;
return result;
}
然后我们考虑这个向量数据结构的基本功能函数包括下标访问和长度取出。
intget_vec_element(vec_ptr v, long index, data_t*dest)
{
if(index<0||index>=v->len)
{
return -1;
}
*dest= v->data[index];
return 0;
}
longvec_length(vec_ptr v)
{
return v->len;
}
然后我们对数据进行运算,声明常数BEGIN和运算符OP
#define IDENT 0
#define OP +
void combine1(vec_ptrv, data_t *dest)
{
*dest= IDENT;
for(int i = 0; i < vec_length(v);i++)
{
data_tval;
get_vec_element(v,i, &val);
*dest= *dest OP val;
}
}
可以观察到 combine1中每次调用vec_length作为测试条件,因为函数中向量长度不会随着循环的进行二改变,因此只需要计算一次向量长度,用临时变量记录即可,然后再测试条件中都可以使用该值,
void combine2(vec_ptrv, data_t *dest)
{
*dest= IDENT;
intsize = vec_length(v);
for(int i = 0; i < size; i++)
{
data_tval;
get_vec_element(v,i, &val);
*dest= *dest OP val;
}
}
这就是一个典型的以代码移动为基础的优化。然后我们考虑减少过程调用,每次循环都会调用get_vec_element这个函数,每次引用都会边界检查,很容易造成低效率,我们明显能知道边界是合法的,于是我们去除边界检查。
data_t*get_vec_start(vec_ptr v)
{
returnv->data;
}
void combine3(vec_ptrv, data_t *dest)
{
intsize = vec_length(v);
data_t*data = get_vec_start(v);
*dest= IDENT;
for(int i = 0; i < size; i++)
{
*dest= *dest OP data[i];
}
}
最后我们来消除不必要的内存引用,因为每次迭代的时候第i个元素都会保存指针在寄存器中,下一个循环又从寄存器取出放入内存中,这样读写浪费我们的性能,于是我们通过临时变量来在循环中累积计算出来的值。
void combine4(vec_ptrv, data_t *dest)
{
intsize = vec_length(v);
data_t*data = get_vec_start(v);
*dest= IDENT;
data_tval;
for(int i = 0; i < size; i++)
{
val= val OP data[i];
}
*dest= val;
}
至此我们基本完成这段程序的优化。可能有人认为combine3到combine4这个过程编译器能够实现,但是实际上,由于内存别名的使用,这两个函数会有不同的行为,假设
combine3(v,get_vec_start(v) + 2);
combine4(v,get_vec_start(v) + 2)
其中combine3在循环过程中,dest的地址问题,导致v的第三个元素不断发生变换,执行结果大相径庭,所以考虑到这个情况,编译器不能判断函数会在什么情况下被调用,也就不会进行优化,这需要我们来认为设计函数的优化程度了。
参考资料:《深入理解计算机系统》
主要内容大概就是以上,通过对编译器的局限了解,我们试着利用机器的思维来优化我们的程序,多想想看是什么制约的程序的性能,这样我们的代码就能越写越好,下期再见-。-
---来自新年新气象的Racoon