1 优化编译器的缺陷
编译器对程序的优化都是非常小心的,是安全优化,也就意味着某些情况下编译器会放弃对代码的优化,因为对这些代码段进行优化可能会带来灾难。
1.1 内存别名使用无法优化
假设编译器存在这样的优化:
那么若存在如下代码:
编译器会将他优化为:
这将造成优化后的程序和原程序执行结果不一样。
造成这种优化错误的原因是:编译器无法确定程序内的指针是否指向同一位置。
例如如下代码:
若指针q和p指向同一个内存,则t1为1000,否者为3000 。
指针名不同,但是实际上指向同一内存空间,这就叫做内存别名,编译器是无法判断代码中是否存在内存别名的,所以编译器会做出最坏的打算,认为代码中存在内存别名,编译器优化基于此2前提去优化程序。
1.2 函数调用无法优化
有如下代码,我们很容易的想对他进行如下优化:
但是f函数是有何能改变其他程序状态的,如:
导致优化后的代码与原代码执行结果不一样。
当编译器优化会修改函数调用次数时,编译器会非常小心处理这种情况,甚至是放弃优化。
(其实可以用内联函数方法进行优化,这里就不展开了,内联函数实际上就是用函数内容去替代函数调用的地方)
2 表示程序性能
想要分析程序的性能,我们要选择一个程序性能度量标准。
CPE : 每元素的周期数
这里我们认为要处理的元素数量和完成该任务所要的周期数成线性关系,y = a+bx , CPE就是这个线性关系中的系数b,b越小代表程序效率越高。
中文翻译很怪,但看到英文就全明白了,Cycles per Element == Cycles / Element == 周期 / 每元素,看到公式和图就豁然开朗了!
3 程序示例
给出了一个样本程序,后面会在这个程序的基础上进行优化。
4 程序级优化
我们通过对程序级别上的了解去优化我们代码,不涉及到计算机背后的工作原理。
4.1 消除低效率的循环
combine1:
优化后
combine2:
编译器不会主动帮我们进行这种优化,原因如1.2节所示,面对会改变函数调用次数的优化编译器会很小心甚至不优化。
那只能靠程序员在代码级别上的优化。
4.2 减少调用过程
其实这节和上节一样,都是减少函数的调用次数:
combine3 减少了get_vec_start() 的调用
但是这个优化没有明显的性能提升,说明程序到了瓶颈,这个瓶颈是什么,以及如何继续优化,请接着往下看。
4.3 消除不必要的内存引用
combine4:
使用局部变量对data[i]进行累积操作,后在赋值给*dest,我们需要翻译成汇编才能看到这样做的好处。
下面是combine3的汇编:
这是combine4的汇编:
对比可以发现,combine3比combine4每次循环多两次访存操作。
5 理解现代处理器
到目前为止,我们运用的优化都不依赖于机器的任何特性,这些优化只是简单的降低了过程调用的开销,以及消除了重大的"妨碍优化的因素",这些因素会给编译器优化带来困难。
吞吐量界线:
限制程序性能的指令级原因有:功能单元计算的时间,指令发射时间,功能单元的个数,内存
- ①计算延迟,功能单元的计算时间:例如加法功能单元,计算一个加法肯定需要时间的对吧,这个时间是必不可少的瓶颈。
- ②指令发射时间:一条指令到下一条指令之间也有一个时间间隙,如果①的时间等于②的时间就被成为“完全流水线化的”。
- ③功能单元个数:比如有4个加法功能单元,那么就能同时进行4个加法运算。
- ④往往内存也会影响到指令的效率,这是由内存访问规则决定的,后面会讲到。
算数运算受到延迟,发射时间和功能单元个数的影响,我们用CPE来描述这些运输的效能:
解释:也就是说对于加法,延迟给其带来了1CPE的损耗,吞吐量0.5CPE性能损耗,也就是程序中出现一个加法,那么该加法说带来的性能损耗是1.5,也就是整个算法CPE至少1.5起步,而且整个算法不可能有且仅有一个加法。(要注意CPE越高算法效率越低)
延迟界限是基本限制,决定我们合并运算最快能执行多快。
这一节还讲了数据流图,数据流图主要是呈现算法的数据相关性,从而减少算法中的数据相关来达到提高性能的效果,这里就不在复述了,书上大概看一下就清楚了。
6 机器级优化
6.1 循环展开
combine5:
循环展开:一次循环进行多次操作,一般算法一次循环只进行一次操作。
上面的被称为2 x 1循环展开。
将2推广到R:R x 1循环展开
- 上限设为 n - R + 1
- 循环内对i 到 i + R -1 的元素进行合并运算
- 每次迭代循环索引 i + R
2 x 1 循环展开后的运算效率:
combine4 数据流及关键路径
combine5数据流及关键路径:
对比两幅图可知combine5比起combine4减少了关键路径依赖,从而提高运算效率。
6.2 提高并行性
前面算法的实现只用了一个计算单元,这里使用2个计算单元进行并行计算,代码实现如下
combine6 — 2 x 2 循环展开:
将索引值为偶数的变量累积在acc0中,将索引值为奇数的变量累积在acc1中,最后结合起来。
先看效果:
很有效的提高了程序的效率,打破了单个计算单元下的性能界限,特别是浮点数运算效率达到了combine和界限的两倍。
效率提高的原因:
这里比起combine_6before 多使用了一个%xmm1寄存器,用这个寄存器进行并行乘计算,改寄存器没有数据依赖,这就是算法效率提升两倍的原因。
更直观一点:
可以看到,一个周期内就进行了两次乘法运算了。
注意:
有些同学会想如果我进行2 x R展开,是不是R越大越好,答案是否定的,R的大小要看你的机器最大支持几个并行运算单元,如果R超过了机器最大运行单元数,那么效率反而会大幅度降低,因为这种情况下会将超过的数据保存到内存中(栈),这就导致访问内存单元,从而带来性能损失。
combine7 还有一种循环展开方式:2 x 1a
它与combine5唯一的区别在于循环中元素的合并方式(注意是combine5)
在combine7:
算法效率:
可以看到combin7的效率几乎和combine6的并行优化差不多了,效果非常好。
分析:
(中文翻译书的这张图错了,下面这张是对的)
这样的重新组合变换能够减少计算中关键路径上的操作数量。
数据流图:
对比combine5的数据流图:
6.3 理解内存性能优化程序
本节通过初步了解计算机内存的加载/访问某些规则和原理,通过此来优化程序,从而提高程序效率。
在此之前我们对程序关于内存的优化的方法是尽量避免访问内存,但当我们不得不访问的时候,还有什么优化方法吗?
6.3.1 加载内存性能损耗
有如下代码:
该代码在循环中几乎只做了内存加载操作,所以它能很好的反应内存加载带来的性能损耗,这个算法的CPE为4.
其汇编如下:
ls in %rdx, len in %rax
.L3:
addq $1, %rax # Increment len
movq (%rdi), %rdi # ls = ls -> next
testq %rdi, %rdi
jne .L3
movq指令是这个循环中的关键瓶颈。
6.3.2 存储内存性能损耗
如下代码:
CPE为1,存储的性能损耗没有加载内存所带来的性能损耗大。
6.3.3 存储加载共存带来的性能损耗
当代码中有对内存进行加载和存储时,有一种情况会带来极大的性能损耗:即加载和存储间歇连续的访问同一个内存地址时。
如下例子:
*dst – 存储
*src – 加载
示例A,连续间歇的加载和存储不是同一地址,CPE为1.3
示例B,连续间歇的加载和存储是同一地址,CPE为7.3
可见对同一地址间歇连续的加载存储带来的性能损耗是多么大了。
想要理解出现这种情况的原因,我们要了解一部分内存访问机制:
如图所示,存储单元包含一个存储缓冲区,它包含已经被发射到存储单元而又还没有完成的存储操作的地址和数据,这里的完成包括更新数据高速缓存。
将该代码的关键部分翻译成汇编:
数据流图:
可以看到,movq %rax, (%rsi) 被翻译成两个操作,s_addr和s_data,前者在存储缓冲区创建一个条目,并设置该条目的地址字段,后者设置该条目的数据字段。这两个计算是独立执行的,如果存储和加载不是同一个地址的话后面的指令movq (%rdi),%rax就不用等s_data更新,否者需要等待s_data字段更新,这样就造成了一个数据相关,这就是加载存储同一地址性能下降的罪魁祸首。
所以在设计算法时最好不要连续间歇的对统一地址进行访问。