程序性能优化
编写高性能程序需要满足:
1.选择适当的算法和数据结构
2.必须编写出变异其能够有效优化以转化成高效可执行代码的源代码
程序优化
程序优化的第一步就是消除不必要的工作:例如对同一个内存地址的反复读写我们要尽可能的减少,消除不必要的函数调用、条件测试和内存引用。这些都不依赖目标机器的任何具体属性而属于程序员可控范畴内的代码的改动。
为了使性能最大化,程序员和编译器都需要一个目标机器的模型,知名如何处理指令,以及哥哥操作的时序特性。
研究程序的汇编代码表示是理解编译器以及产生的代码会如何运行是进行程序优化的最有效手段之一。通过用汇编语言写代码,这种间接的方法具有的优点是:虽然性能并非最好的,但是能保证代码能够在其他机器上运行。
优化编译器的能力和局限性
现代编译器运用复杂精细的算法来确定一个程序中计算的是什么值,以及他们是如何使用的。编译器必须很小心地对程序只使用安全的优化,在C语言标准提供的保证下,优化后的得到的程序和未优化的版本有一样的行为,限制编译器只进行安全的优化,消除了造成不希望的运行时行为的一些可能的原因。
为了理解决定一种程序转换是否安全的难度,我们来看以下两个程序:
1 | void twiddle1(long *xp,long *yp) |
这两个函数有着看似相似的行为,他们都是将存储在指针yp位置的值两次相加到指针xp位置的值上。
一方面,函数twiddle2()效率更高,因为他只要求3次内存的引用(读xp,读yp,写*xp),相应的twiddle1()需要6次。
另一方面,当如果xp和yp指向同一位置的时候。
1 | //指针xp和yp相同(指向同一地址) |
twiddle1()的xp会变为原来的4倍。
1 | *xp += 2* *xp; |
twiddle2()的xp会变味原来的3倍。
因此,我们不能吧twiddle2()作为twiddle1()的优化版本。
这种两个指针指向相同内存的情况称之为内存别名使用。在执行优化过程中,编译器必须假设不同的指针可能会指向同一内存同一个位置的情况。
例如对于以下,一个使用指针变量q和p的程序:
1 | x=1000, y=3000; |
t1的值的情况是根据p和q的指向决定的。
当p,q指向同一内存位置,t1就等于1000;相反则为3000。
这造成了一个主要的妨碍优化的因素这也可能是严重限制编译器产生优化代码机会的程序的一个方面:如果不能确定指向,就必须假设所有情况,这就限制了优化策略。
表示程序性能
度量标准——每元素的周期数(Cycles Per Element, CPE)。
CPE作为知道我们改进代码的方法,帮助我们在更细节层次上理解迭代程序的循环性能。
处理器的活动顺序是用时钟控制的,时钟提供了某个频率的规律信号,通常用千兆赫兹(GHz),即十亿周期/秒来表示。
例如:4GHz——表示处理器时钟运行频率为4 X 10^9个周期。
许多过程含有在一组元素迭代的循环。
举一个例子:函数psum1()和psum2()计算都是一个长度为n的向量的前置和。
1 | void psum1(float a[],float p[],long n) |
函数psum1()每次迭代计算一个元素。
函数psum2()使用了循环展开技术,每次迭代计算两个元素。
很明显高psum2运行时间明显小于psum1(这个时间优势差距会在元素越多的情况下越拉越大);使用最小二乘拟合也得出一样结论:
psum1的运行时间(时钟周期为单位),近似等于368+9.0n
psum2的运行时间(时钟周期为单位),近似等于368+6.0n
对于较大的n值,运行时间就会由线性因子决定。
9,0和6.0称为线性因子
根据这种度量标准,psum2的CPE为6.0,优于psum1的CPE为psum1。
程序示例
为了说明一个抽象的程序是如何被系统专换为更有效的代码,我们使用基于下面的所示的向量的数据结构来做例子:
此向量数据结构将由两个内存块表示:头部和数据数组。
以下为头部结构:1
2
3
4typedef struct{
long len;
data_t *data;
}vec_rec, *vec_ptr;
接下来这是我们对向量元素数据数组的操作:
1 | int get_vec_element(vec_ptr v,long index, data_t *dest){ |
另外我们在接下来的程序中使用声明:
1 |
表示的是对向量的元素进行乘积。
或者:
1 |
表示的是对向量的元素进行求和。
首先是第一次编写的函数combin1():
1 | void combine1(vec_ptr v,data_t *dest){ |
《深入了解计算机系统》书上测试机器是一台具有Intel Core i7 Haswell处理器的机器上测试的,我们称其为参考机。
我们来将combine1()作为我们进行程序优化的起点。
详细的CPE数据在书上P349最下面。
我们在书上看到,如果使用GCC的命令行选项“-O1”,会进行一些基本的优化,在这个程序员不需要做任何事情的情况下,在这个程序上优化显著的提升了两个数量级,这也是优化的一个方法——使用-Og优化级别。
消除循环的低效率
可以观察到,combine1中在for循环中调用了vec_lengeth函数来取得数组长度。
这意味着每一次循环迭代,程序都要调用此函数。
但是数组长度在本函数是不会改变的。
这样我们有了一个优化的思路,用一个length的数据来保存vec_length返回的数组长度,而不是每一次都去调用它。
1 | void combine2(vec_ptr v,data_t *dest){ |
这个优化方法十分的常见,称之为代码移动。这类优化包括将多次识别的值(前提是此值不会改变)存放起来,就如上面,我们将vec_length的调用移动到循环外。
优化编译器会试着进行这样的代码移动,但是他并不能可靠的知道这样做是否会有副作用(如果值变化了那就有很大的影响了)。
因此,程序员通常要帮编译器显示地完成代码的移动。
举一个更加极端的例子:lower函数——对字符串中所有大写字母转化为小写字母。
1 | void lower1(char *s) |
其中,strlen函数是这样的:
1 | size_t strlen(const char *s) |
在C语言中,字符串的皆为必须是以NULL结尾的字符序列,strlen()必须一步步地检查当前位置的字符,直至遇到NULL。
回到编写的lower()函数:
基于strlen()的情况,对于lower1(),它的整体运行时间相当于O(n²)。
每一次运行时间对于lower1来说都是数组长度n的二次幂(在n越大的情况下,运行时间将会更加的长)。
例如:在n=1048576情况下,lower2比lower1快乐500 000多倍。
对于这种代码移动的优化,需要有非常成熟的完善的分析,这样的分析远超出了编译器的能力,需要程序员来进行这样的变换。
减少过程调用
过程调用也会带来很大的开销。
例如:combine2函数,get_vec_element的调用来获取下一个向量元素存放在val中。
1 | int get_vec_element(vec_ptr v,long index, data_t *dest){ |
对每个向量引用,这个函数要把向量索引i与循环向量作比较,会造成低效率,这种边界检查很必要,但是我们在分析后知道:对于combine2而言,所有的引用都是合法的。(因为我们在combine2函数内的for循环设置了(i<数组长度length)的边界)。
我们将对此进行优化:
假设为我们的抽象数据类型增加一个函数get_vec_start,此函数返回数组起始地址。
1 | data* get_vec_start(vec_ptr v) |
对此我们可以写出combine3()。1
2
3
4
5
6
7
8
9void combine3(vec_ptr v,data_t *dest){
long i;
int length = vec_length(v);
data_t *data = get_vec_start(v);
*dest = IDENT;
for(int i=0; i<length; i++){
*dest = *dest OP data[i];
}
}
在做完这一系列后,我们却发现性能并无更大提升,事实上整体求和性能甚至反降。
显然是内部循环中的其他操作限制了瓶颈,这个限制甚至于超过多次调用get_vec_element。
我们对数据类型为double(8),合并运算OP为乘法的x86-64代码进行分析:
1 | .L17: |
在comine3中,我们看到,dest指针的的地址存放在寄存器 %rbx中;
他还改变了代码,将第i个元素的指针存放到寄存器%rdx中,并且每次迭代,这个指针都+8。
循环的终止操作来根据%rdx的指针和%rax中的数值来判断。
从上面的分析可以看出,每次迭代,累积的变量的数值都要从内存读出再写入,因为每次迭代开始时从dest读出的值就是上次迭代写入的最后的值。
combine4的目的就是为了消除这种不必要的内存读写:引入临时变量acc来保存循环中累积计算出来的值。1
2
3
4
5
6
7
8
9
10void combine4(vec_ptr v,data_t *dest){
long i;
int length = vec_length(v);
data_t *data = get_vec_start(v);
data_t acc = IDENT;
for(int i=0; i<length; i++){
acc = acc OP data[i];
}
*dest = acc;
}
在这种情况下,我们再来看x86-64代码:
1 | .L25 |
combine4减少了对%rdx存储位置的内存的重复读写。
在combine4中,相较于combine3,程序性能有了更加明显的提高。
对于combine3和combine4,,这也引发了一个问题,回到之前的内存别名使用问题上,两个函数会有不同的行为。
1 | combine3(v,get_vec_start(v)+2); |
因为combine3是直接对内存上的数据进行多次的改动,combine4是用额外的acc来保存数据在最后才对%rdx内存位置上的数据进行更替,我们可以粗略的理解为:combine3的改变是实时的,而combin4不是。
函数 | 初始 | 循环前 | i=0 | i=1 | i=2 | 最后结果 |
---|---|---|---|---|---|---|
combine3 | [2,3,5] | [2,3,1] | [2,3,2] | [2,3,6] | [2,3,36] | [2,3,36] |
combine4 | [2,3,5] | [2,3,5] | [2,3,5] | [2,3,5] | [2,3,5] | [2,3,5] |
这种巧合的例子是我们人为设计出来的,但实际中,编译器不能判断函数会在什么情况下调用,以及程序员的本意是什么。取而代之,编译combine3时,保守的方法就是让程序不断地读写内存,即使这样做效率不高。
循环展开
上面提及到的循环展开是一种程序变换,通过增加每次迭代的计算的元素数量,减少循环迭代次数(上面的psum2函数例子)。
我们根据循环展开,可以对combine使用“2X1”循环展开版本:combine5每次循环处理数组两个元素,也就是每次迭代,索引i+2。(并且在当n为2的倍数时,在最后执行将剩余的元素进行处理)。
1 | void combine5(vec_ptr v,data_t *dest){ |
在书P367表中对combine4进行循环展开的CPE可以看出,对于OP为整数加法运算,CPE得到一定的提升,这得益于combine5减少了循环次数;但其他情况并没有提升。
这让我们再次去观察combine5的内循环机器代码。类型data_t为double,操作为乘法。
1 | .L35 |
与之前一样:%xmm0存放累积值acc,%rdx存放索引i,%rax存放data地址。
循环展开导致产生两条vmulsd指令:将data[i]乘以acc;将data[i+1]乘以acc。
每条vmulsd被翻译成两个操作:1.从内存中加载一个数组元素 2.将这个乘以已有累积值acc。
详细的数据流图在书P369
提高并行性
程序的性能是受运算单元的延迟限制的,但他们的执行加法和乘法的功能单元完全是流水线化的:这意味着他们可以每个是中周期开始一个操作,并且有些操作可以被多个功能单元执行。
多个累积变量
我们可以对combine5做出这样的改动:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16void combine6(vec_ptr v,data_t *dest){
long i;
int length = vec_length(v);
long limit = length-1;
data_t *data = get_vec_start(v);
data_t acc0 = IDENT;
data_t acc1 = IDENT;
for(int i=0; i<limit; i+=2){
acc0 = acc OP data[i];
acc1 = acc OP data[i+1];
}
for(; i<length;i++){
acc0 = acc OP data[i];
}
*dest = acc0 OP acc1;
}
使用了两次循环展开,也使用了两路并行,我们将combine6称为“2x2循环展开”。
并且这种改进方法,所有情况都有了提升。
数据流图在书P372,可以看到与combine5相比,comebine6会有两条关键路径来对data[n]数据数组进行访问,每天路径包含n/2个操作。
重新结合变换
这是一种打破顺序相关,从而使性能提高到延迟界限之外的方法。
combine5没有改变合并向量元素形成和或者乘积中执行的操作,不过我们可以这样改动,也可极大地提高性能:1
2
3
4
5
6
7
8
9
10
11
12
13
14void combine7(vec_ptr v,data_t *dest){
long i;
int length = vec_length(v);
long limit = length-1;
data_t *data = get_vec_start(v);
data_t acc = IDENT;
for(int i=0; i<limit; i+=2){
acc = acc OP (data[i] OP data[i+1]); //!!改变了这里,请注意对比
}
for(; i<length;i++){
acc = acc OP data[i];
}
*dest = acc;
}
combine5元素合并:
1 | acc = (acc OP data[i]) OP data[i+1]; |
combine7元素合并:
1 | acc = acc OP (data[i] OP data[i+1]); |
因为括号改变了向量元素与累积值acc的合并顺序,产生了称之为“2x1a”的循环展开形式。
图P374
对于combine4和combine7,有两个load和两个mul操作(load读取data[i]位置的数据,mul将数据相乘),但是combine7只有一个操作形成了循环寄存器间的数据相关链。我们可以在书P375的数据流图看到关键路径上只有n/2个操作,每次迭代内的第一个乘法都不需要等待前一次迭代的累积值就可执行(与combine5对比)。
结果小结 (性能提高技术)
高级设计。
为遇到的问题选择适当的算法和数据结构,避免使用会渐进地产生糟糕性能的算法或编码技术。
基本编码原则
避免限制优化的因素。
- 消除连续的函数使用,将计算移到循环外(如上面用len存储长度,而非在循环内调用函数)
- 消除不必要的内存引用,引入临时变量来保存中间结果(如combine函数中的acc),在最后才将结果存放到数组或全局变量中
低级优化
结构化代码以利用硬件功能 - 展开循环,降低开销,使进一步优化成为可能
- 通过使用例如多个累积变量(如上combine7的acc0和acc1存储临时数据)和重新结合等技术 ,找到方法提高指令集并行
- 用功能性的风格重写条件操作使得便已采用条件数据传送
确认和消除性能瓶颈
书P388开始的5.14小节讲述的是如何使用代码剖析程序(code profiler)的方法和介绍了程序性能分析工具,还展示了一个系统优化的通用原则。本人用得少,只做了解不作展开。