此文已由做者余笑天受权网易云社区发布。
html
欢迎访问网易云社区,了解更多网易技术产品运营经验。nginx
本文主要是基于我以前学习《深刻理解计算机系统》(如下简称CSAPP)这本书第五章优化程序性能内容的回顾以及总结。主要内容并无从大而全的方面去阐述如何优化程序,而是从一些细节着手来看待优化代码质量这个大问题。因为我以前接触C/C++程序较多,所以示例代码都是用C++编写,可是我认为不管是什么语言,一些基本的优化原则是相通的。算法
1.程序优化原则django
在CSAPP做者看来性能好的程序要有如下几种特色:数组
(1)合适的数据结构和算法,都说程序=算法+数据结构,所以这两方面的优化是程序优化的基石。缓存
(2)尽可能的写出编译器能够有效优化的代码,现代编译器都会对源代码进行优化,以提升程序的性能。好比Linux下的GCC编译器就能控制优化的等级,优化等级高,对应的程序性能好。若是你的程序编译器并不能肯定是否能进行安全优化,那么对于一些的成熟的编译器而言,它并不会采用一些激进的优化方式,这部份内容在优化安全性会有具体介绍。安全
(3)对于处理运算量特别大的计算,能够将一个任务拆分为多个任务。甚至能够考虑到在多核和对处理器上进行并行计算,这部份内容在CSAPP中的12章会有详细叙述。bash
(4)在实现和维护代码的简单性和运行速度之间作出权衡,好比调用系统的排序算法能够知足平常大部分的排序需求,可是进行特殊的优化可能要针对排序的数据进行分析而后对应修改排序算法,这个过程耗费的时间和最后的优化结果以及优化后可能带来的可读性、模块性的下降须要做出权衡。数据结构
1.1优化的安全性数据结构和算法
对于C/C++程序,大多数的编译器会指定优化级别,以GCC为例子:gcc -o指令就能够设置优化级别:
-o0:关闭全部优化
-o1:最基本的优化级别,编译器试图以较少的时间生成更快以及体积更小的代码。
-o2:推荐的优化级别,o1的进阶。
-o3:较危险的优化等级,这个等级会延长编译时间,编译后会产生更大的二进制文件,会带来一些没法预知的问题。
-os:优化代码体积,一般适用于磁盘空间紧张或者CPU缓存较小的机器。
所谓优化的安全性,咱们不妨看如下一个栗子:
能够看出看上去以上两个函数实现的功能是一致的,都是将yp所指向的int值的两倍加到xp所指向的值。可是f2的性能要比f1更好一些,由于f2有3次引用,f1有6次引用(2次读xp,2次读yp,2次写xp)。咱们指望编译器会帮咱们进行以上优化,可是成熟的编译器不会这么作的,这是由于该程序存在内存别名使用(memory aliasing)的问题。就是说xp,yp可能指向同一位置:
能够看出当出现以上状况时,两个函数的行为并不一致,这类程序的编写就成为了编译器优化它的阻碍因素,对应到优化原则的第二条。
其次函数调用一样会阻碍编译器的优化,编译器是不会对函数内容做出假设,所以针对函数调用,编译器通常不会贸然进行优化,一样能够举出一个栗子:
能够看出f1调用了f()两次,而f2()只调用了一次,函数的调用涉及到栈帧的操做这须要消耗一些系统资源,所以按理来讲f2()的性能优于f1(),可是编译器针对这种状况一样不会进行优化,考虑到如下代码:
一样能够看出在这种状况下,两个函数行为一样会不一致。
2消除低效的循环
咱们编写了一个循环累加的程序来测试在不一样循环下,程序性能的开销,首先定义了这样一个数据结构:
typedef struct { long int len;
data_t *data;
}vec_rec, *vec_ptr;复制代码
vec_rec表示为data_t的数组,data_t表示为自定义的数据类型,len为该数组的长度。
原书中针对date_t进行了两种定义分别是:整数以及浮点数,并对各自的类型进行加法和乘法的操做,分别统计各自的性能状况,于此同时还定义了性能衡量标准CPE即每元素时钟周期,举个栗子:计算一个数组中全部元素之和,分别统计数组元素个数不一样的状况下该程序所用的时钟周期,而后得出每加入一个元素平均多耗费的时钟周期,这个值就是CPE。下面是该书的做者统计的CPE值,这部分因为本人并无作实验,所以只贴出做者的结果以供参考:
能够看出目前的CPU对于浮点操做的优化使其性能接近甚至略好于对整数的操做,同时对于程序至少进行o1级别的优化一样是有必要的。
下面贴出具体的循环调用代码:
#include"stdlib.h"#include"time.h"#include#ifndef _CLOCK_T_DEFINED
#define _CLOCK_T_DEFINED
#endif
typedef long clock_t;
using namespace std;
typedef int data_t;
typedef struct
{ long int len;
data_t *data;
}vec_rec, *vec_ptr;vec_ptr new_vec(long len){
vec_ptr res = (vec_ptr)malloc(sizeof(vec_rec));
data_t *data = NULL; if (!res) return NULL;
res->len = len; if (len > 0)
{
data = (data_t *)calloc(len, sizeof(data_t)); if (!data)
{
free((void*)res); return NULL;
}
}
res->data = data; return res;
}long vec_length(vec_ptr v){ return v->len;
}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;
}void combine1(vec_ptr v, data_t *dest) { long i;
*dest = 0; for (i = 0; i < vec_length(v); ++i)
{
data_t val;
get_vec_element(v, i, &val);
*dest = *dest + val;
}
}复制代码
该程序分别依次取数组元素的值而后加到dest所指的位置中去,这是通常的循环累加的写法,能够看到每次迭代求值都会对测试条件进行求值操做,另外一方面针对这种状况,数组的长度并不会随着循环而更改,所以咱们定义了combine2以下:
void combine2(vec_ptr v, data_t *dest){ long i; long len = vec_length(v);
*dest = 0; for (i = 0; i < len; ++i)
{
data_t val;
get_vec_element(v, i, &val);
*dest = *dest + val;
}
}复制代码
为了对比性能,我作了如下实验:
int main()
{
vec_ptr vec = new_vec(100000000); int* tmp = new int[100000000];
vec->data = (int *)tmp; int res = 0;
clock_t start, finish; double totaltime;
start = clock();
combine1(vec, &res);
finish = clock();
totaltime = (double)(finish - start) / CLOCKS_PER_SEC; cout << "\n此程序的运行时间为" << totaltime << "秒!" << endl;
start = clock();
combine2(vec, &res);
finish = clock();
totaltime = (double)(finish - start) / CLOCKS_PER_SEC; cout << "\n此程序的运行时间为" << totaltime << "秒!" << endl;
system("pause");
}复制代码
获得以下结果:
3减小过程调用
一个函数的调用基本过程大体以下:
一、调用者函数把被调函数所须要的参数按照与被调函数的形参顺序相反的顺序压入栈中
二、调用者函数使用call指令调用被调函数,并把call指令的下一条指令的地址当成返回地址压入栈中
三、在被调函数中,被调函数会先保存调用者函数的栈底地址(push ebp),而后再保存调用者函数的栈顶地址
四、在被调函数中,从ebp的位置处开始存放被调函数中的局部变量和临时变量,而且这些变量的地址按照定义时的顺序依次减少
能够看出在函数调用过程当中,须要作一些压栈出栈操做,同时须要一些寄存器帮助保存和恢复环境,这些都将带来系统开销。所以减小一些函数调用将会提升程序性能。以上面的程序为例,能够看到combine函数在循环中调用了get_vec_element操做,这部分操做能够移到循环内部而没必要调用函数,具体作法以下:
增长get_vec_start函数获取数组起始位置:
data_t *get_vec_start(vec_ptr v)
{ return v->data;
}复制代码
修改combine函数:
void combine3(vec_ptr v, data_t *dest){ long i; long len = vec_length(v);
data_t *data = get_vec_start(v);
*dest = 0; for (i = 0; i < len; ++i)
{
*dest = *dest + data[i];
}
}复制代码
修改后的程序性能对好比下:
4消除没必要要的引用
combine3将计算后的值累加在dest指针后,一下贴出段代码的汇编结果:
从这段代码能够看出dest指针放在寄存器rax中,每次迭代,data指针加1。每次迭代后。累积的数值从内存中读出再写入到内存中,这样频繁的读写内存将会影响程序的性能。
这类频繁的内存读写是能够避免的,能够引入一个临时变量存储*dest的值,循环中只取变量的值,直至循环结束将结果写到dest指针所指的位置中。代码以下:
void combine4(vec_ptr v, data_t *dest){ long i; long len = vec_length(v);
data_t *data = get_vec_start(v); long acc = 0; //*dest = 0;
for (i = 0; i < len; ++i)
{
acc = acc + data[i];
}
*dest = acc;
}复制代码
这段代码的汇编结果以下:
能够看出该部分汇编代码用rax保存累计值没有涉及到取内存的操做,所以在循环中的内存操做变成只有取data数组这一次。
如下贴出结果对比:
能够看出combine4在以前的基础上性能又稍有提升。
5循环展开
循环展开是一种程序变换,经过增长每次循环的计算量,减小循环次数从而改进程序性能。循环展开对程序性能的影响有两点,其一是它减小了循环中的辅助计算量例如循环索引和条件分支(该书5.7节详细介绍了条件分支对性能的影响)。第二它减小了关键路径的操做数量。下面给出循环展开的一个版本:
void combine5(vec_ptr v, data_t *dest){ long i=0; long len = vec_length(v); long limit = len - 1;
data_t *data = get_vec_start(v);
data_t acc = 0; for (int i = 0; i < limit; i += 3)
{
acc = (acc + data[i]) + data[i + 1];
} if (i < len)
{
acc = acc + data[i];
}
*dest = acc;
}复制代码
下面是循环展开后的程序性能:
该版本的循环展开将原有的循环次数减小了一半,延续这个思想,可将循环按任意因子k展开,下面是做者将改程序循环展开后屡次后性能表现状况:
能够看出对于该优化不会超过延迟界限值,查看循环展开操做的汇编代码:
能够看到该操做会致使两条vmulsd操做,一条将data[i]加到acc上,第二条将data[i+1]加到acc上。每条vmulsd被翻译成两个操做:一个操做是从内存中加载一个数组元素,另外一个是把这个值乘以已有的累计值。能够看到,循环的每次执行中,对寄存器%xmm0读和写两次。从中能够看到,迭代的次数减半了,可是每次迭代中仍是有两个顺序的乘法操做。这个关键路径是循环没有展开代码的性能制约因素。具体汇编代码过程图示以下:
至此,完成了该程序的初步优化,关于循环展开部分,该书第五章后半段有进阶的内容,有兴趣的同窗能够一块儿学习交流。
更多网易技术、产品、运营经验分享请点击。
相关文章:
【推荐】 django项目在uwsgi+nginx上部署遇到的坑
【推荐】 Memcached Hash算法
【推荐】 3分钟掌握一个有数小技能:回头客分析