《深入理解计算机系统/CSAPP》第五章 优化程序性能

第5章 优化程序性能

  • 编写高效程序需要做到如下三点:
    1. 必须选择一组合适的算法及数据结构;

    2. 必须编写出编译器可以有效优化以转化为高效可执行代码的源代码;

      在C语言中,指针运算和强制类型转换使得编译器很难对它进行优化

    3. 针对运算量特别大的计算,将一个任务分解为多个子任务,这些子任务可以利用多核或多处理器并行计算;

  • 阻碍优化的因素
    • 程序行为中那些严重依赖执行环境的方面

      例如两条语句的执行有依赖关系

  • 程序优化的步骤
    1. 消除不必要的工作,让代码尽可能有效地执行所期望的任务;

      例如不必要的函数调用,内存引用等。(这很好理解)

    2. 利用处理器提供的指令并行能力,同时执行多条指令;

      这就要求要了解处理器的运作。

  • 研究程序的汇编代码表示是理解编译器以及产生的代码会如何运行的最有效手段之一。

5.1 优化编译器的能力和局限性

  • 编译器必须很小心的对程序只使用安全的优化。

    即保证优化前后的程序具有相同行为。
    (但是很明显,编译器没有那么智能,有些情况无法分辨处理,所以我们应该主动写出让编译器好优化的代码)

  • 两种指针可能指向同一个内存位置的情况称为内存别名引用,在只执行安全的优化中,编译器必须假设不同的指针可能会指向内存的同一个位置。这样就限制了编译器的优化,如下面例子:
//原来的代码
void twiddle1(long *xp,long *yp)
{
	*xp +=*yp;
	*xp +=*yp;	
}
//优化后的代码
void twiddle1(long *xp,long *yp)
{
	*xp += 2*(*yp);
}

如果yp和xp指向同一个内存,原来的代码得到4倍结果,而优化后的代码却是3倍,故编译器这样优化会出问题

  • 函数调用产生的问题:
long counter = 0;
long f()
{
	return counter++;
}
//原始代码
long func1()
{
	return f()+f()+f()+f();
}
//优化后的代码
long func2()
{
	return 4*f();
}

由上图可见,虽然优化后只调用了一次f(),但是优化后得到的结果并不一样
解决办法:我们可以将f()声明为inline内敛函数 ,即调用的时候直接内部展开,这样既可以减少了函数调用的开销,也允许对展开的代码做进一步优化,适合一些经常被调用的少量代码

  • GCC只会尝试在单个文件中定义的函数内联。

5.2 表示程序性能

  • 每元素的周期数(Cycles Per Elment,CPE),作为一种表示程序性能并指导我们改进代码的方法

  • 处理器活动的顺序是由时钟控制的,时钟提供了某个频率的规律信号,通常用千兆赫兹(GHz)表示,每个时钟周期的时间是时钟频率的倒数。

    例如,当表明一个系统有4GHz处理器,表示处理器时钟运行频率为每秒 4*(10的9次方)个周期。

5.3 程序示例

后面会逐渐优化这个示例

typedef long data_t;
typedef IDENT 1 //或0
#define OP *    //或+

typedef struct{
	long len;
	data_t *data;
}vec_rec,*vec_ptr;

vec_ptr new_vec(long len)
{
	vec_ptr result = (vec_ptr)malloc(sizeof(vec_rec));
	data_t *data = NULL;
	if(!result)  //如果分配失败
		return NULL;
	if(len>0){
		//动态分配数组calloc,指定data_t大小的len个空间
		data = (data_t*)calloc(len,sizeof(data_t));
		if(!data){
			free((void *)result);
			return NULL;
		}
	}
	result->data = data;
	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;
}

void combine1(vec_ptr v,data_t *dest)
{
	long i;
	*dest = IDENT;
	for(i = 0;i<vec_length(v);i++){
		data_t val;
		get_vec_element(v,i,&val);
		*dest = *dest OP val;
	}
}

5.4 消除循环的低效率

  • 将循环中要执行多次但是计算结果不会改变的计算提出来。
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;
	}
}

5.5 减少过程调用

  • 减少循环中过程的调用,如get_vec_element每次都会进行边界检查,但是我们这里并不需要边界检查,因为简单分析之后,会发现所有索引都是合法的,所以我们直接获取首地址后使用data[i]。缺点是破坏了程序的模块性,因为我们并不应该知道他的元素到底是以数组存储还是链表之类的存储。
    但是此时的效率并没有显著提升(原因可以归结为后文会提到的分支预测)
data_t* get_vec_start(vec_ptr v)
{
	return v->data;
}
void combine3(vec_ptr v,data_t *dest)
{
	long i;
	long length = vec_length(v);
	data_t *data = get_vec_start(v);
	*dest = IDENT;
	for(i = 0;i<length;i++){
		*dest = *dest OP data[i];
	}
}

5.6 消除不必要的内存引用

  • 在循环中消除不必要的内存引用,像上述题目中的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;
}
  • 为啥编译器不能将combine3优化成combine4呢?
    考虑OP为乘法,及如下函数调用
combine3(v, get_rec_start(v) + 2);
combine4(v, get_rec_start(v) + 2);

5.7 理解现代处理器

5.7.1 整体操作
  • 指令级并行:在代码级上,看上去似乎是一次执行一条指令,每条指令都包括从寄存器或内存取值,执行一个操作,并把结果存回到一个寄存器或内存位置。在实际的处理器中,是同时对多条指令求值的。
  • 描述程序性能的两个下界:
    • 延迟界限:一系列操作必须按照严格顺序执行,因为在下一条指令开始之前,这条指令必须结束。主要是代码中的数据相关限制了处理器利用指令级并行的能力
    • 吞吐量界限:刻画了处理器功能单元的原始计算能力。这个界限是程序性能的终极限制。
  • 控制单元ICU:从内存中读出指令序列,并根据这些指令序列生成一组针对程序数据的基本操作
  • 执行单元EU:执行ICU产生的基本操作
  • 分支预测
  • 投机执行
5.7.2 功能单元的性能
  • 延迟:表示完成运算所需要的总时间。表明执行实际运算所需要的时钟周期总数
  • 发射时间:表示两个连续的同类型的运算之间需要的最小时间。表明两次运算之间间隔的最小周期数
  • 容量:表示能够执行该运算的功能单元的数量。表明同时能发射多少个这样的操作
  • 最大吞吐量:定义为发射时间的倒数
  • 延迟界限给出了任何必须按照严格顺序完成合并运算的函数所需的最小CPE值
  • 吞吐量界限给出了CPE的最小界限
5.7.3 处理操作的抽象类型
  • 数据流:略
  • 关键路径:略

5.8 循环展开

  • 循环展开是一种程序变换,通过增加每次迭代计算的元素的数量,减少循环的迭代次数
  • 两方面改善性能
    1. 它减少了不直接有助于程序结果的操作的数量,例如循环索引计算和条件分支
    2. 提供了一些方法,可以进一步变化代码,减少整个计算中关键路径上的操作数量
void combine5(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-1;i+=2){
		acc = acc OP data[i] OP data[i+1];
	}
	//完成剩下的索引
	for(;i<length;i++){
		acc = acc OP data[i];
	}
    *dest = acc;
}
  • GCC优化器产生一个函数的多个版本,并从中选择它预测会获得最佳性能和最小代码量的那一个。其结果就是,源代码中微小的变化就会生成各种不同形式的机器码。
  • 用优化等级3或者更高等级调用GCC,它就会执行循环展开。

5.9 提高并行性

  • 执行加法和乘法的功能单元是完全流水化的。所以对于一个可结合和可交换的合并运算来说,比如说整数加法或乘法,我们可以通过将一组合并运算分割成两个或更多的部分,并在最后合并结果来提高性能。
void combine6(vec_ptr v,data_t *dest)
{
	long i;
	long length = vec_length(v);
	data_t *data = get_vec_start(v);
	data_t acc1 = IDENT;
	data_t acc2 = IDENT;
	for(i = 0;i<length-1;i+=2){
		//两个运算几乎并行运行,流水线
		acc1 = acc1 OP data[i] 
		acc2 = acc2 OP data[i+1];
	}
	//完成剩下的索引
	for(;i<length;i++){
		acc1 = acc1 OP data[i];
	}
    *dest = acc1 OP acc2;
}
  • k x k展开:将多个累计变量变换归纳为将循环展开k次,以及并行累计k个值
  • 重新结合。执行结果趋近于combine6!
void combine7(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-1;i+=2){
		acc = acc OP (data[i] OP data[i+1]);
	}
	//完成剩下的索引
	for(;i<length;i++){
		acc = acc OP data[i];
	}
    *dest = acc;
}

5.10 优化合并代码的结果小结

5.11 一些限制因素

  • 寄存器溢出
    循环并行性的好处受汇编代码描述计算的能力限制。如果我们的并行度p超过了可用的寄存器数量,那么编译器会诉诸溢出。
  • 分支预测和预测错误处罚
    当分支预测逻辑不能正确一个分支是否要跳转的时候,条件分支可会招致很大的预测错误处罚。要求程序员尽量写出适合用条件传送语句的程序
  • 条件传送
    基本思想是计算出一个条件表达式或语句两个方向的值,然后用条件传送选择期望的值。
  • 如何保证分支预测处罚不会阻碍程序的效率呢?
    • 原则1:不要过分关心可预测的分支
    • 原则2:书写适合用条件传送实现的代码
      • 分支预测只对有规律的模式可行。对于本质上无法预测的情况,如果编译器能够产生使用条件数据传送而不是使用条件控制转移的代码,可以极大地提高程序的性能

5.12 理解内存性能

5.12.1 加载的性能

5.12.2 存储的性能

5.13 应用:性能提高的技术

  • 高级设计
    • 为遇到的问题选择合适的算法和数据结构。
  • 基本编码原则
    • 消除连续的函数调用。在可能时,将计算移到循环外,考虑有选择得妥协程序的模块性以获得更大的效率
    • 消除不必要的内存引用。如引入临时变量。
  • 低级优化。结构化代码以利用硬件
    • 展开循环,降低开销
    • 通过使用例如多个累计变量和重新结合等技术,找到方法提高指令并行性
    • 用功能性的风格重写条件操作,使的编译采用条件数据传送

5.14 确认和消除性能瓶颈

  • 代码剖析程序
    • Unix系统的剖析程GPROF
    • Intel的VTUNE程序开发系统
    • Linux的VALGRIND
  • 1
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值