深入理解操作系统(13)第五章:优化程序性能(1)表示程序性能+优化措施(包括:存储器別名使用/程序序性能表示:每元素的周期数CPE/降低循环低效率:(循环中)代码移动/循环中减少过程调用)
1. 前沿
1.1 高效程序的两类活动
编写高效程序需要两类活动:
第一,我们必须选择一组最好的算法和数据结构
第二,我们必须编写出编译器能够有效优化以转换成高效可执行代码的源代码
对于这第二部分,理解优化编译器的能力和局限性是很重要的。
就我们所知,编写程序方式中一点小小的变动,都会引起编译器优化方式很大的变化。
有些编程语言比其他语言容易优化得多。C的有些特性,例如执行指针运算和强制类型转换的能力,使得对它优化很困难。程序员经常能够以一种使编译器更容易产生高效代码的方式来编写他们的程序。
1.2 开发和优化过程中的关键因素
在开发和优化的过程中,我们必须考虑代码使用的方式,以及影响它的关键因素。
通常程序员必须在实现和维护程序的简单性与它的运行速度之间做出权衡折衷。
不同场景不同开发策略:
1. 在算法级上,几分钟就能编写一个简单的插入排序,
而一个高效的排序算法程序可能需要一天或更长的时间来实现和优化。
2. 在代码级上,许多低级别的优化往往会降低程序的可读性和模块性,
使得程序容易出错,更难以修改或扩展。
3. 对于一个只会运行一次以产生一组数据点的程序,
以一种尽量减少编程工作量并保证正确性的方式来编写程序就更为重要。
4. 对于会在性能非常重要的环境中反复执行的代码,
例如网络路由器,通常更广泛的优化会适当一些。
1.3 本章目标-提高代码性能
在本章中,我们描述许多提高代码性能的技术。
1.3.1 妨碍优化因素
理想的情况是,编译器能够接受我们编写的任何代码,并产生尽可能高效的、具有指定行为的机器级程序。
1.事实上,编译器只能执行有限的程序转换,而且妨碍优化的因素还会阻碍这种优化,
妨碍优化因素(optimization blocker)
2. 妨碍优化的因素就是:程序为中那些严重依赖于执行环境旳方面。
3. 所以,程序员必须编写容易优化的代码,以帮助编译器。
1.3.2 两种编译技术: 与机器无关 和 与机器有关
就编译器来说,编译技术被分为“与机器无关”和“与机器有关”两类。
1. “与机器无关”的意思是,使用这些技术时可以不考虑将执行代码的计算机的特性
2. “与机器有关”是指,这些技术是依赖于许多机器的低级细节的
我们的讲述也沿用了类似的顺序,先讲编写任何程序时都要执行的标准程序转换,然后讲效率依赖于目标机器和编译器特性的转换。这些转换通常还会降低代码的模块性和可读性。
因此,应该在获得最大性能是首要目标时,才使用这些技术。
为了使程序性能最大化,程序员和编译器需要一个目标机器的模型,指明如何处理指令,以及各个操作的时序特性。例如,编译器必须知道时序信息,才能够确定是需要一条乘法指令,还是移位和加法的某种组合。现代计算机用复杂的技术来处理机器级程序,并行执行许多指令,而且执行顺序还可能不同于它们在程序中出现的顺序。程序员必须理解为了获得最大的速度,这些处理器是如何工作来调整程序的。基于Intel处理器的最新模型,我们提出了一个这种机器的高级模型。我们还设计了一种图形表示法,可以用来使处理器执行指令形象化,并且还可以预测程序性能。
1.3.3 代码剖析程序(profilers)
我们以对优化大型程序的问题的讨论来结束这一章。
代码剖析程序:是测量程序各个部分性能的工具。
功能:这种分析能够帮助找到代码中低效率的地方并且确定程序中我们应该着重优化的部分。
最后,我们给出了一个重要的观察结论(称为Adahl定律),它量化了对系统某个部分进行优化所带来的整体效果。
在本章的描述中,我们使得代码优化看起来像按照某种特殊顺序,对代码进行一系列转换的简单线性过程。实际上,这项工作远非这么简单。需要相当多的试错法试验。当我们进行到后面的优化阶段时,这种方法尤其有用,到那时,看上去很小的变化会导致性能上很大的变化。相反一些很有希望的技术被证明是无效的。正如我们在后面的例子中看到的那样,要确切解释为什么某段代码序列有某个执行时间,是很困难的。性能可能依赖于处理器设计的许多详细特性,而对此我们所知甚少。这也是我们尝试各种技术的变形和组合的另一个原因。
1. 研究汇编代码是理解编译器以及产生的代码会如何运行的最有效的手段之一。
2. 仔细研究内循环的代码是一个很好的开端。
人们可以确认降低性能的属性,例如过多的存储器(memory)引用和对寄存器不正确的使用。从汇编代码开始,我们甚至可以预测什么操作会并行执行,以及它们使用处理器资源的效率如何。
2. 优化编译器的能力和局限性
现代编译器运用复杂精细的算法来确定一个程序中计算的是什么值,以及它们是被如何使用的。
然后它们会利用一些机会来简化表达式,也就是在几个不同的地方使用一个计算,以降低一个给定的计算必须被执行的次数。
2.1 编译器优化程序受限制的几个因素
编译器优化程序的能力受几个因素限制,包括:
1.要求它们绝不能改变正确的程序行为
2.它们对程序行为、对使用它们的环境了解有限
3.需要很快地完成编译工作
2.2 例子说明
编译器优化对用户来说应该是不可见的。
当程序员用优化选项(例如,使用-O命令行选项)编译代码时,代码的行为应该和不带优化编译得到的代码行为完全一样,除了它应该运行得更快一点以外。这样的要求使得编译器不能使用某些类型的优化。
2.2.1 例子
例如,考虑下面这两个过程
void twiddle1(int *xp, int *yp)
{
*xp += *yp;
*xp += *yp;
}
void twiddle2(int *xp, int *yp)
{
*xp += 2 * *yp;
}
乍一看,这两个过程似乎有相同的行为。它们都是将存储在由指针yp指示的位置处的值两次加到指针xp指示的位置处的值。
另一方面,函数 twiddle2 效率更高一些。它只要求三次存储器引用(读xp,读yp,写xp),
而twiddlel需要六次(两次读xp,两次读yp,两次写xp)。
因此,如果要编译器编译过程twiddle1,我们会认为基于 twiddle2执行的计算能产生更有效的代码。
不过,考虑一下xp等于yp的情况。此时,函数twiddle1会执行下面的计算
*xp += *xp; /* Double value at xp */
*xp += *xp; /* Double value at xp */
结果会是xp的值增加4倍。
另一方面,函数twiddle2会执行下面的计算
*xp += 2 * *xp; /* Triple value at xp */
结果会是xp的值增加3倍。
2.2.2 妨碍优化因素一:存储器別名使用
定义:
编译器不知道 twiddlel会被如何调用,因此它必须假设参数xp和yp可能会相等。因此,它不能产生twiddle2风格的代码作为twiddle 1的优化版本。这个现象称为存储器別名使用(memory aliasing)
编译器必须假设不同的指针可能会指向存储器中同一个位置。这造成了一个主要的妨碍优化的因素,这也是可能严重限制编译器产生优化代码机会的程序的一个方面。
2.2.3 妨碍优化因素二:函数调用
int f(int);
int func1(x)
{
return f(x)+ f(x)+ f(x)+f(x);
}
int func2(x)
{
return 4*f(x);
}
最初看上去两个过程计算的都是相同的结果,但是func2只调用f一次,而 funcl调用f四次。以func1作为源时,会很想产生func2风格的代码。
不过,考虑下面f的代码
int counter = 0;
int f(int)
{
return counter++;
}
副作用:
这个函数有个副作用——它修改了全局程序状态的一部分。改变调用它的次数会改变程序的行为。
特别地,假设开始时全局变量counter都设置为0
对func1的调用会返回0+1+2+3=6
而对func2的调用会返回4*0=0
大多数编译器不会试图判断一个函数是否没有副作用,因此任意函数都可能是优化的候选者例如func2中的做法。
相反,编译器会假设最糟的情况,并保持所有的函数调用不变在各种编译器中.
GNU编译器GCC被认为是胜任的,但是就它的优化能力来说,并不是特别突出。
它完成基本的优化,但是它不会对程序进行更加“有进取心的”编译器所做的那种激进变换。
3. 表示程序性能
3.1 每元素的周期数(CPE)
对许多程序都有用的度量标准是:每元素的周期数(cycles per element,CPE)。
它其实就是一个系数,如:a+bx。其中a和b为常数,x就是CPE。
详细解释看下面例子。
这种度量标准帮助我们在更详细的级别上理解迭代程序的循环性能。
同时,这样的度量标准对执行重复计算的程序来说也是很适当的。
3.2 电脑处理器的 3.4GHz 表示含义
例如处理图像中的像素,或是计算矩阵乘积中的元素处理器活动的顺序是由时钟控制的,时钟提供了某个频率的规律信号,要么用兆赫兹(MH即百万周每秒)来表示,要么用千兆赫兹(GHz,即吉周每秒)来表示。
例如一个系统有“1.4GHz”处理器,这表示处理器时钟运行频率为1400兆赫兹。每个时钟周期的时间是时钟频率的倒数,通常是用纳秒( nanosecond,十亿分之一秒)来表示的。
一个2GHz的时钟其周期为0.5纳秒,而500MHz的时钟,周期为2纳秒。
普及:
1. 频率:单位时间内完成振动或振荡的次数或周数
1赫兹就是每秒振动一次(1次/秒),或1周/秒
2. 时钟周期也称为振荡周期,定义为时钟频率的倒数。
含义:一个上升沿到下一个上升沿的时间间隔。
LINUX系统时钟频率是一个常数HZ来决定的,通常HZ=100,一秒振动100次。
那么他的精度度就是10ms(毫秒)。
也就是说每10ms一次中断。所以一般来说Linux的精确度是10毫秒
3. 1GHz等于十亿赫兹(1,000,000,000 Hz)
1GHz表示处理器时钟运行频率为1000兆赫兹,时钟周期是其到数,1ns(纳秒)
4. 我的台式机,我的电脑里面看到CPU频率是 3.4GHz,
表明:每秒振动34亿次,时钟周期就是0.29ns
时钟周期/指令周期/CPU周期)和 jiffies
https://blog.youkuaiyun.com/lqy971966/article/details/110234641
3.3 例子说明-循环展开
许多过程含有在一组元素上迭代1的循环。
如下的函数vsum1和vsum2计算的都是两个长度为n的向量之和。
第一个函数每次迭代计算目标向量的一个元素。
第二个函数使用称为循环展开(loop unrolling)的技术,每次迭代计算两个元素。
这个版本只对n为偶数值有效。
void vsum1(int n)
{
int i;
for(i=0: i<n; i++)
{
c[i]=a[i]+b[i];
}
}
void vsum2(int n)
{
int i;
for(i=0: i<n; i+2)
{
c[i]=a[i]+b[i];
c[i+1]=a[i+1]+b[i+1];
}
}
3.3.1 性能图 + 最小二乘方拟合
这样一个过程所需要的时间可以用一个常数加上一个与被处理元素个数成正比的因子来描述。
图5.2
例如,图5.2是这两个函数需要的每元素的周期数关于n值的取值范围图。
使用最小二乘方拟合(leassquares fit),
我们发现,两个函数的运行时间(用时钟周期表示)分别近似于表达式80+40n和83.5+35n的线条。
这两个表达式表明初始化过程、准备循环以及完成过程的开销为80~84个周期加上每个元素35或40周期的线性因子。对于较大的n的值(比如说,大于50),运行时间就会主要由线性因子来决定。
最小二乘方拟合
图5.2-1
3.3.2 每元素的周期数 CPE
我们称这些项中的系数(上面的40和35)为每元素的周期数(简称CPE)的有效数。
注意,我们更愿意用每个元素的周期数而不是每次循环的周期数来度量,这是因为像循环展开这样的技术使得我们能够用较少的循环完成计算,而我们最终关心的是,对于给定的向量长度,程序运行的速度如何。
我们将精力集中在减小我们计算的CPE上。
根据这种度量标准,vsum2的CPE为3.5,优于CPE为40的vsum2。
4. 程序示例
4.1 是否使用 -O2 优化比较
为了说明一个抽象的程序是如何被系统地转换成更有效的代码的,考虑下面简单向量数据结构。
vec_rec向量由两个存储器块表示。头部是一个声明如下的结构:
typedef struct{
int len;
data_t *data;
}vec_rec, *ver_ptr;
图5.3
程序略。
作为一个起点,下面是combinel的CPE度量值,它运行在 Intel Pentium3上,尝试了数据类型和合并运算的所有组合。
在我们的度量值中,我们发现单、双精度浮点数据的时间基本上是相等的。因此,我们只给出对单精度浮点数据的度量值
图:5.3-1
默认地,编译器产生适合于用符号调试器一步一步调试的代码。因为目的是使目标代码尽可能类似于源代码中表明的计算,所以几乎没有进行什么优化。简单地将命令行开关设置为“-O2”,我们就能进行优化了。正如看到的那样,这显著地提高了程序性能。通常,养成进行这一级优化的习惯是很好的,除非编译程序就是为了要调试它。
4.2 补充:GCC中 -O0 -O1 -O2 -O3 -Os 优化介绍
当优化标识被启用之后,gcc编译器将会试图改变程序的结构(当然会在保证变换之后的程序与源程序语义等价的前提之下),以满足某些目标,如:代码大小最小或运行速度更快(只不过通常来说,这两个目标是矛盾的,二者不可兼得)。
4.2.1 -O0: 不做任何优化,这是默认的编译选项。
一般来说,如果不指定优化标识的话,gcc就会产生可调试代码,每条指令之间将是独立的:可以在指令之间设置断点,使用gdb中的 p命令查看变量的值,改变变量的值等。并且把获取最快的编译速度作为它的目标。
4.2.2 -O,-O1: 对程序做部分编译优化
O1优化会消耗不少多的编译时间,它主要对代码的分支,常量以及表达式等进行优化。
这两个命令的效果是一样的,目的都是在不影响编译速度的前提下,尽量采用一些优化算法降低代码大小和可执行代码的运行速度。
4.2.3 -O2 是比O1更高级的选项,进行更多的优化。
O2会尝试更多的寄存器级的优化以及指令级的优化,
它会在编译期间占用更多的内存和编译时间。
该优化选项会牺牲部分编译速度,除了执行-O1所执行的所有优化之外,还会采用几乎所有的目标配置支持的优化算法,用以提高目标代码的运行速度。
与O1比较而言,O2优化增加了编译时间的基础上,提高了生成代码的执行效率。
4.2.4 -O3 采取很多向量化算法,提高代码的并行执行程度
O3在O2的基础上进行更多的优化,采取很多向量化算法
例如使用伪寄存器网络,普通函数的内联,以及针对循环的更多优化。
该选项除了执行-O2所有的优化选项之外,一般都是采取很多向量化算法,提高代码的并行执行程度,利用现代CPU中的流水线,Cache等。
4.2.5 -Os: 主要是对程序的尺寸进行优化。
Os主要是对代码大小的优化,我们基本不用做更多的关心。
打开了大部分O2优化中不会增加程序大小的优化选项,并对程序代码的大小做更深层的优化。(通常我们不需要这种优化)
4.2.6 优化代码有可能带来的问题
1. 调试问题
任何级别的优化都将带来代码结构的改变。
例如:对分支的合并和消除,
对公用子表达式的消除,
对循环内load/store操作的替换和更改等,
都将会使目标代码的执行顺序变得面目全非,导致调试信息严重不足。
2. 内存操作顺序改变所带来的问题
在O2优化后,编译器会对影响内存操作的执行顺序。
5. 消除循环的低效率-代码移动
5.1 例子说明
通过看下图5.5 中,每次循环迭代时都必须对测试条件vec_length求值。另一方面,向量的长度并不会
随着循环的进行而改变。因此,我们只需计算一次向量的长度,然后在我们的测试条件中使用这个
值。
图5.5
图5.5-1给出的是一个修改的版本,称为combine2,它在开始时调用 vec_length,并将结果赋值。
图5.5-1
优化结果:
图5.6
令人惊奇的是,这个小小的改动明显地影响了程序性能。
如下表所示,通过这个简单的变换,我们为每个向量元素消除了大概10个时钟周期。
5.2 代码移动概念
这个优化是一类常见的、称为代码移动(code motion)的优化实例。
这类优化包括识别出要执行多次(例如,在循环里)但是计算结果不会改变的计算,因而我们可以将计算移动到代码前面的会被多次求值的部分。
在本例中,我们将对 vec_length的调用从循环内部移动到循环的前面优化编译器会试着进行代码移动。
5.3 低效率的极端例子
5.3.1 lower1 lower2例子
优化编译器会试着进行代码移动。不幸的是,就像前面讨论过的那样,对于会改变在哪里调用函数或调用多少次的变换,编译器通常会非常小心。它们不能可靠地发现一个函数是否会有副作因而它们会假设函数会有副作用。
例如,如果 vec_length有某种副作用,那么 combine l和combine2可能就会有不同的行为。在这样的情况中,程序员必须帮助编译器显式地完成代码的移动。
作为 combine1 中看到的循环低效率的一个极端例子,考虑下图中所示的过程 lower1。
这个过程的目的是将一个字符串中所有大写字母转换成小写字母。这个过程一步一步地检查字符串,将每个大写字符转换成小写字符。
void lower1(char *s)
{
int i;
for(i=0; i<strlen(s); i++)
{
if((s[i] > 'A') && (s[i] < 'Z'))
{
s[i] -= 'A'-'a';
}
}
}
void lower2(char *s)
{
int i;
int len = strlen(s);
for(i=0; i<len; i++)
{
if((s[i] > 'A') && (s[i] < 'Z'))
{
s[i] -= 'A'-'a';
}
}
}
比较:
图:5.8
对于一个长度为262144的字符串, lower1 需要整整3.1分钟CPU时间,而lower2只需要0.006秒。比lowerl快了3000多倍。
字符串长度每增加一倍,运行时间也会增加一倍——很显然复杂度是线性的。
5.3.2 总结
这个示例说明了编程时一个常见的问题,一个看上去无足轻重的代码片断有隐藏的渐近低效率。
通常会在小数据集上测试和分析程序,对此, lower1的性能是足够的。不过,当程序最终部署好以后过程完全可能被应用到一个有100万个字符的串上,对此, lower从头至尾会需要1个小时的cPU时间。突然,这段无危险的代码变成了一个主要的性能瓶颈。
相比较而言, lower2会在:1秒之内完成。大型编程项目中会出现这样的问题,这样的故事比比皆是个有经验的程序员工作的一部分就是避免引入这样的渐近低效率
6. 减少过程调用
因为过程调用会带来相当大的开销,而且妨碍大多数形式的程序优化。
例子说明:
图5.9
图5.9中展示了,将数组替换函数调用的例子。性能提升很多。
1. 可以看出,每次循环选代都会调用 get_vec_element来获取下一个向量元素
这个过程开销特别大,因为它要进行边界检查。
2. 直接访问数组,替换函数调用
我们增加一个函数 get_vec_start.。这个函数返回数组的起始地址