Bootstrap

【深入理解计算机系统】CHAPATER5 优化程序性能

前言

本章我们学习的代码性能优化方式,包括内存别名使用和过程调用、循环展开、创建多个累计变量和重新结合、指令级并行、存储和加载操作,以及在Linux上的性能剖析工具GPROF。


一、优化程序性能的基本策略

程序性能度量标准:周期数(Cycles Per Element, CPE),一个4GHz的处理器表示处理器时钟运行频率为每秒4*109个周期,即1时钟周期T = 1/f = 0.25纳秒。

  1. 高级设计,为遇到的问题选择适当的算法与数据结构。
  2. 基本编码原则,避免限制优化的因素。
    • 消除连续的函数调用,将计算移到循环外。
    • 消除不必要的内存引用,引入临时变量来保存中间结果,只有在最后的值计算出来时,才将结果存放到数组或全局辩论中。
  3. 低级优化
    • 展开循环,降低开销。
    • 使用多个累积变量和重新结合等技术,提高指令级并行。

二、优化编译器的能力与局限性

编写高效程序需要做到:

  1. 选择合适的算法与数据结构
  2. 编写出编译器能够有效优化以转换成高效可执行代码的源代码

针对GCC编译器优化控制,以命令行选项-Og调用GCC是让GCC使用一组基本的优化,除此之外,还有-O1-O2-O3,数字越大,级别越高,程序的性能提升越高,但是编译时间会大幅增加,也更难调试。目前-O2是可以被接受的标准,但大多数项目还是以-O1优化级别进行优化。


三、优化方法

3.1 内存别名使用

内存别名(memory aliasing):两个指针指向同一个内存位置的情况。
对于以下代码:

void twiddle1(long *xp, long *yp)
{
	*xp += *yp;
	*xp += *yp;
}

void twiddle2(long *xp, long *yp)
{
	*xp += 2* *yp;
}

这两个函数看似实现的功能一样,且twiddle2只需要3次内存引用(读*xp、读*yp、写*xp),而twiddle1需要6次内存引用(2次读*xp、2次读*yp、2次写*yp),似乎性能更差,可以优化。但如果*xp和*yp相等,即两个指针指向同一个内存地址,存在内存别名使用的情况,此时函数twidlle1等于:

*xp += *xp; 
*xp += *xp;

则*xp比原来增加了4倍,而twiddle2比原来增加了3倍。只有在不存在内存别名使用的情况下,才能进行此优化。


3.2 内联函数替换

long f()

long fun1(){
	return f() + f() + f() + f();
}

long fun2(){
	return 4 * f();
}

对于函数fun1()和函数fun2(),似乎可以替换,但如果f()函数定义为:

long count = 0;   // 全局变量

long f(){
	return count ++;
}

此时f()每次调用都会改变全局变量的值,则不能直接替换,可以用内联函数替换 的方式进行优化,最终代码:

long function(){
	long t = count++;
	t += count++;
	t += count++;
	t += count++;

	return t;

这样的转换既减少了函数调用的开销,也允许对展开的代码做进一步的优化。

  • gcc只尝试在单个文件中定义的函数内联
  • 如果一个函数已经用内联替换优化过了,那么任何对这个调用进行追踪或设置断点的尝试都会失败。
    (这些性质和上家的热补丁特性很像,内补丁的制作就是进行函数内联替换。)

3.3 代码移动(code motion)

这类优化包括识别要执行多次(例如在循环里)但是计算结果不会改变的计算,因而可以将计算移动到代码前面不会被多次求值的部分。如可将for循环里strlen()函数求字符串放到for循环 前面,如下面例子将combine()函数优化为combine2()

void combine(vec_ptr v, data_t *dest)
{
	long i;
	*dest = IDENT;
	
	for(i = 0; i < vec_length(v); i++)
	{
		data_t val;
		...
	}
}

void combine2(vec_ptr v, data_t *dest)
{
	long 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.4 减少不必要的内存调用(难)

combine2()函数中,每次循环调用都荟调用get_vec_element()来获取下一个向量元素,过程调用会带来开销。在保证获取每个元素偏移量相同的前提下,反汇编代码分析得知:累计变量的数值在循环时每次都要从内存读出再写入到内存,造成内存读写浪费,因为每次迭代开始时从dest读出的值就是上次迭代最后写入的值。
把结果累积在临时变量中,将累计值放在局部变量acc中,可以消除每次循环迭代中从内存中读出并将更新值写回的需要。

void combine4(vec_ptr v, data_t *dest)
{
	long i;
	long length = vec_length(v);
	// *dest = IDENT;
	data_t * data = get_vec_start(v);
	data_t acc = IDENT;         //   what is IDENT???
	

	for(i = 0; i < length; i++)
	{
		acc = acc OP data[i];
	}
	*dest = acc;
}

3.5 循环展开

  • 循环展开通过增加每次迭代计算的元素数量,减少循环的迭代次数。
void combine5(vec_ptr v, data_t *dest)
{
	long i;
	longt length = vec_length(v);
	long liimit = length - 1;
	data_t *data = get_vec_start(v);
	data_t acc = IDENT;
	
	/* 同时处理两个元素 */
	for(i = 0; i < limitl; i+=2){
		acc = (acc OP data[i]) OP data[i+1];
		}
	/* 最后一个元素处理 */
	for(; i < length; i++){
		acc = acc OP data[i];
	}
	*dest = acc;
}

combine5是一个“2x1”循环展开’版本,第一个循环每次处理数组的两个元素,对i和i+1进行合并运算,循环索引i加2,第二次迭代对非2的整数倍元素进行合并运算。

对于长度为n的向量,我们将循环界限设为n-1(从0开始)。然后,保证只有当循环索引i满足i<n-1时才会执行这个循环,因此最大数组索引i+1满足i+1<(n-1)+1=n。把这个思想归纳为对一个循环按任意循环因子k进行展开,由此产生k*1循环展开。因此循环展开的上限为n-(k-1),在循环内对元素i到i+(k-1)应用合并运算,每次迭代,循环索引i都加k。剩余元素以第二个循环进行处理。


3.6 指令级并行提高并行性(处理器优化)

  • 定义:同时对多条指令求值。

程序的性能是受运算单元的延迟限制的。执行加法和乘法的功能单元是完全流水线化的,这意味着它们可以每个时钟周期开始一个新操作,并且有些操作可以被多个功能单元执行。

对于一个可结合和可交换的合并运算来说,可以通过将一组合并运算分割成两个或更多的部分,并在最后合并结果来提高性能。例如:Pn表示元素a0,a1,…,an-1的乘积: P n = ∑ i = 0 n − 1 a i P_n = \sum_{i=0}^{n-1}a_i Pn=i=0n1ai
假设n为偶数,则公式可以写成: P n = P E n ∗ P O n P_n = PE_n * PO_n Pn=PEnPOn
其中,PEn是索引值为偶数的元素元素乘积,POn为奇数元素乘积。则有代码:

void combine6(vec_ptr v, data_t *dest)
{
	long i;
	longt length = vec_length(v);
	long liimit = length - 1;
	data_t *data = get_vec_start(v);
	data_t acc0 = IDENT;
	data_t acc1 = IDENT;
	
	/* 同时处理两个元素 */
	for(i = 0; i < limitl; i+=2){
		acc0 = acc0 OP data[i];
		acc1 = acc1 OP data[i+1];
		}
	/* 剩余元素处理 */
	for(; i < length; i++){
		acc0 = acc0 OP data[i];
	}
	*dest = acc0 OP acc1;
}

conbine6既使用了两次循环展开,也使用了两路并行,将索引值为偶数的元素累积在变量acc0中,将索引值为奇数的元素累积在变量acc1中,处理器可以同时分别处理acc0和acc1,打破了延迟界限限制,大大提升运行性能。称作"2*2循环展开"。


3.7 重新结合变换

combine5改动得到函数combine7

void combine7(vec_ptr v, data_t *dest)
{
	long i;
	longt length = vec_length(v);
	long liimit = length - 1;
	data_t *data = get_vec_start(v);
	data_t acc = IDENT;
	
	/* 同时处理两个元素 */
	for(i = 0; i < limitl; i+=2){
		acc = acc OP (data[i] OP data[i+1]);  // 与combine5结合顺序不同
		}
	/* 最后一个元素处理 */
	for(; i < length; i++){
		acc = acc OP data[i];
	}
	*dest = acc;
}

这种改动在整数加的场景下性能与“k1”(combine5)相同,但是对整数乘、浮点数加和乘性能提升巨大,约等于“22”(combine6)性能。

这是因为对于combine5combine7的模版,有两个load和两个mul操作,但是只有一个mul操作形成了循环寄存期间的数据相关链。对于combine7关键路径只有n/2个操作,每次迭代内的第一个乘法不需要等待前一次迭代的累计值就可以进行。因此性能得以提升。


3.8 优化注意点

  • 循环变量的数量不能超过可用寄存器的数量,否则程序必须在栈上分配一些变量,导致优化效果适得其反。
  • 大多数编译器不会对浮点数运算做重新结合,因为这些运算不保证是可结合的。

四、程序剖析

Unix系统提供GPROF进行剖析,功能包括:

  • 确定程序中每个函数花费了多少CPU时间。
  • 计算每个函数被调用的次数,以执行调用的函数来分类。

4.1 使用步骤:

  1. 程序必须为剖析而编译和链接。就是在命令行上包括运行时标志-pg,执行命令gcc -Og -pg prog.c -o prog在这里插入图片描述

  2. 执行程序./profg file.txt,运行完之后当前文件夹下会生成一个文件gmou.out
    在这里插入图片描述

  3. 调用GPROF分析:grpof prog
    在这里插入图片描述
    每一行代表某个函数的所有调用所花费的时间。每列含义简单清晰,下列也有文档说明,在此不再赘述。


后记

以上是本人学习《深入理解计算机系统第3版》第五章的总结,学习如何优化程序性能,其中描述不到位之处可查阅原书,也可私信我。
最后如果文章帮助到你了,可以点个赞让我知道,我会很快乐~加油!

;