深入理解计算机系统第五章:优化程序性能

深入理解计算机系统第五章:优化程序性能

编写高效程序需要做到一下几步:第一,我们必须选择一组适当的算法和数据结构。第二,我们必须编写出编译器能够有效优化以转换成高效可执行代码的源代码。

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

​ 现代编译器运用复杂精细的算法来确定一个程序中计算的是什么值,以及他们是如何使用的。然后会利用一些机会来简化表达式。大多数编译器,包括 GCC,向用户提供了一些对它们所使用的优化的控制。这即最简单的控制方式:指定优化级别。在 GCC 中,命令行选项“-Og”调用 GCC 是让 GCC 使用一组最基本的优化。“-O2”、“-O3”调用 GCC 会让它使用更大量的优化,在提高程序性能的同时也可能增加程序的规模。
为了保证优化后的程序与优化前有一样的行为,编译器必须很小心地对程序只使用安全的优化,因为存在妨碍优化的因素。
第一个妨碍优化的因素是可能出现内存别名使用(memory aliasing)的情况。例如:

1
2
3
*q = y;
*p = x;
t1 = *q;

*p 和 *q 很明显互不相关,一个1000,一个3000,但如果p 和q 指向的是同一个地址,那么分开赋值将会变成对一个目标二次赋值,最后 t1 的结果当然也就不同了。
第二个妨碍优化的因素是函数调用:

1
2
3
4
5
6
7
8
9
long f();

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

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

看起来两段程序能实现同一个功能,但是明显的第一个函数性能会不如第二个函数,因为它对函数 f 调用了四次。但是我们考虑函数 f 的这种情况:

1
2
3
4
5
long counter = 0;
=
long f(){
return counter++;
}

​ 这个函数会修改全局程序状态的一部分,这样的话每一次调用的返回值都会发生变化,就和4 乘上第一次调用它的返回值大相径庭了。
​ 包含函数调用的代码可以用一个成为内联函数替换(inline subsitution)的过程进行,也就是将函数调用替换成函数体。在某些情况下,最好能阻止编译器执行内联替换,如果一个函数已经用内联替换优化过了,那么任何对这个调用进行追踪或设置断点的尝试都会失败,并且用内联替换消除的函数调用是无法被正确剖析的,也就无法正确评估程序性能。

5.2 表示程序性能

我们引入度量标准每元素的周期数(Cycles Per Element, CPE),作为一种表示程序性能并指导我们改进代码的方式。度量值表示的是执行了多少条指令,而不是时钟运行的有多快。
处理器活动的顺序是由时钟控制的,时钟提供了某个频率的规律信号,通常用千兆赫兹(GHz),即十亿周期每秒钟来表示。

5.3 程序示例

这里记录一块数据示例,接下来会对这段代码进行不断地优化改进。

我们首先声明如下结构:

1
2
3
4
5
6
/* Create abstract data type for vector */
typedef struct {
long len;
data
data_t *data;
}vec_rec, *vec_ptr;

接着给出的是一些生成向量、访问向量元素以及确定向量长度的基本过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/* Create vector of specified length */
vec_ptr new_vec(long len)
{
/* Allocate header structure */
vec_ptr result = (vec_ptr) malloc(sizeof(vec_rec));
data_t *data = NULL;
if (!result)
return NULL; /* Couldn't allocate storage */
result->len = len;
/*Allocate array*/
if(len > 0){
data = (data_t *)calloc(len, sizeof(data_t));
if(!data) {
free((void *) result);
return NULL;/* Couldn't allocate storage*/
}
}
/* Data will either be NULL or allocated array */
result->data = data;
return result;
}

/*
* Retrieve vector element and store at dest.
* Return 0 (out of bounds) or 1 (successful)
*/
int get_vec_element(vecÿptr v, long index, data_t *dest)
{
if (index <011 index >= v->len)
return 0;
*dest = v->data[index];
return 1;
}


/* Return length, of vector */
long vec_length(vec_ptr v)
{
return v->len;
}

最后是优化示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
/* Implementation with maximum use of data abstraction */
void combinel(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;
}
}

​ 它计算地是向量元素的乘积。我们使用 IDENT 和 OP 的宏定义来使得这段代码可以重新编译成对数据执行不同的运算。

5.4 消除循环的低效率

​ 循环每一轮都在执行类似的步骤,但其实有些算式不一定要在每个循环中都使用。就比如循环条件是数组长度,但是如果你循环条件写的是I < len() 的话,那程序就会在每一轮循环执行对数据长度的计算函数,这样就降低了程序的性能,如果我们尝试将对数据长度的求值放在循环外面,就可以消除这样的低效率。

优化代码combine2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*Move call to vec_length out of loop */
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
get_vec_element(v, i, &val);
*dest = *dest OP val;
}
}

​ 这个优化是一类常见的优化的一个例子,成为代码移动(code motion)。这类优化包括识别要执行多次(例如在循环里)但是计算结果不会改变的计算。因而可以将计算移动到代码前面不会被多次求值的部分。

5.5 减少过程调用

​ 过程调用会带来开销,并且妨碍大多数形式的程序优化。 所以减少过程调用有可能可以帮助提升程序性能。

优化代码combine3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
data_t *get_vec_start(vec.ptr v)
{
return v->data;
}


/* Direct access to vector 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];
}
}

​ 之前的程序每一次循环都会调用get_vec_elemrnt来获取下一个元素,它实际上就是对向量边界检查然后指针赋值。由于我们对程序简单分析即可知道程序中的所有引用都是合法的。所以我们用函数get_vec_start来替换先前的函数,它直接进行指针赋值去省掉了边界检查的步骤。但是我们测试发现程序的性能没有明显的提升,甚至整数求和的性能还略有下降。显然,内循环中的其他操作形成了瓶颈,限制性能超过了边界检查函数,这里的原因在之后我们会提到。

5.6 消除不必要的内存引用

我们查看combine3的内循环部分编译出的汇编代码:

1
2
3
4
5
6
7
8
9
Inner loop of combine3. data_t = double, OP = *
dest in %rbx, data+i in %rdx, data+length in %rax
.L17: loop:
vmovsd (%rbx), %xmm0 Read product from dest
vmulsd (%rdx), %xmm0, %xmm0 Multiply product by data[i]
vmovsd %xmm0, (%rbx) Store product at dest
addq $8, %rdx Increment data+i
cmpq %rax, %rdx Compare to data+length
jne .L17 If !=, goto loop

​ 我们可以看到这个循环每次迭代时,积累的变量都要从内存读出再写入到内存。这样的读写十分浪费,因为每次迭代开始时从 dest 读出的值就是上次迭代最后写入的值。

优化代码combine4:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* Accumulate result in local variable */
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 = OP data[i];
}
*dest = acc;
}
1
2
3
4
5
6
7
Inner loop of combine4. data_t = double, OP = *
acc in %xmm0, data+i in %rdx, data+length in %rax
.L25: loop:
vmulsd (%rdx), %xmm0, %xmm0 Multiply acc by data[i]
addq $8, %rdx Increment data+i
cmpq %rax, %rdx Compare to data+length
jne .L25 If !=, goto loop

​ combine4 引入一个临时变量 acc,它在循环中用来累积计算出来的值。只有在循环完成的时候结果才存放进 dest 中。与 combine3 中的循环相比,我们将每次迭代的内存操作从两次读和一次写减少到只需要一次读。

5.7 理解现代处理器

​ 前面我们所运用的优化都不依赖于目标机器的任何特性,只是简单地降低了过程调用的开销,以及消除一些给优化编译器造成困难的因素。随着试图进一步提高性能,必须考虑利用处理器微体系结构的优化,也就是处理器用来执行指令的底层系统设计。
处理器看似是每次执行一条指令(从寄存器或内存中取值,执行操作,并把结果存回寄存器或者内存),但其实在实际的处理器操作中,是同时对多条指令求值的,这个现象称为指令级并行
​ 有两种下界描述了程序的最大性能。当一系列操作必须按照严格顺序执行时,就会遇到延迟界限(latency bound),因为在下一条指令开始之前,这条指令必须结束。当代码中的数据相关限制了处理器利用指令级并行的能力时,延迟界限能够限制程序性能。吞吐量界限(throughput bound)刻画了处理器功能单元的原始计算能力。这个界限是程序的终极限制。

​ 近期的 Intel 处理器在工业界称为超标量(superscalar),意思是它可以在每个时钟周期执行多个操作,而且是乱序的(out-of-order),意思就是指令执行的顺序不一定要与它们在机器级程序中的顺序一致。本章我们的设计并不是严格地基于它,整个设计包括两个主要部分:指令控制单元(Instruction Control Unit

,ICU)执行单元(Execution Unit,EU)。前者负责从内存中读出指令序列,并根据这些指令序列生成一组针对程序数据地基本操作,而后者负责执行这些操作。

​ ICU 从指令高速缓存(instruction cache)中读取指令,指令高速缓存是一个特殊地高速存储器,包含最近访问的指令。当程序遇到分支时,现代处理器采用了一种称为分支预测(branch prediction)的技术,处理器会开始取出位于它预测分支的目标地址。使用投机执行(speculative execution)的技术,处理器会开始取出位于它预测的分支会跳转到的地方的指令,并对指令译码,甚至在它确定分支预测是否正确之前就开始执行这些操作。如果过后确定分支预测错误,会将状态重新设置到分支点的状态,并开始取出和执行另一个方向上的指令。标记为取值控制的块包括分支预测,以完成确定取哪些指令的任务。

指令译码逻辑接收实际的程序指令,并将它们转换成一组基本操作(有时称为微操作)。每个这样的指令都完成某个简单的计算任务,例如两数相加、从内存中读取数据,或是向内存中写入数据。具有复杂指令的机器,一条指令可以被译码成多个操作(例如:addq %rax, 8(%rdx)

​ EU 接收来自取指单元的操作。通常,每个时钟周期会接受多个操作。这些操作会被分派到一组功能单元中,它们会执行实际的操作。这些功能单元专门用来处理不同类型的操作。

​ 读写内存是由加载和存储单元实现的。加载单元处理从内存读取数据到处理器的操作。这个单元有一个加法器来完成地址计算。类似,存储单元处理从处理器写数据到内存的操作也有一个加法器来完成地址计算。加载和存储单元通过数据高速缓存(data cache)来访问内存。数据高速缓存是一个高速存储器,存放着最近访问的数据值。

​ 使用投机执行技术对操作求值,但最终的结果会知道处理器确定应该实际执行这些操作后,才会将结果存储到程序寄存器和数据内存中。分支操作会被送到 EU,不是确定分支该往哪里去,而是确定分支预测是否正确。

​ 在 ICU 中,退役单元(retirement unit)记录正在进行的处理,并确保它遵守机器级程序的顺序语义。一旦一条指令执行完毕且分支预测正确,那么退役单元就将其退役,若分支预测错误,那么退役单元则会将它清空,丢弃所有的计算的结果。任何对程序寄存器的更新都只会在指令退役时才会发生。为了加速一条指令到另一条指令的结果的传送,许多此类的信息是在执行单元间进行交换的。

​ 控制操作数在执行单元间传送的最常见的机制称为寄存器重命名(register renaming)。简单来说就是对于更新寄存器 a 的指令译码产生一个指向该操作结果的唯一标识符 t 。然后将(a,t)这样的条目加入到一个表中。指令执行产生用 t 标识的 b 结果。然后后续等待 a 作为源的操作都可以使用 b 作为源值。

​ 每个运算都是由以下数值来刻画的:一个是延迟(latebcy),它表示完成运算所需要的总时间;另一个时发射时间(issue time),它表示两个连续的同类型的运算之间需要的最小时钟周期数;还有一个是容量(capacity),它表示能够执行该运算的功能单元的数量。

​ 对于浮点数和整数的基本操作加法和乘法,他们的发射时间都为1,说明每个时钟周期都可以开始一条这样同类型的运算,这就得益于流水线的作用。流水线功能单元的实现为一系列的阶段,每个阶段完成一部分的运算。发射时间为 1 的功能单元被称为完全流水线化的(fully pipelined):每个时钟周期可以开始一个新的运算,而 {出现容量大于 1 的运算时由于有多个功能单元。

​ 除法器并不是完全流水线化的——它的发射时间等于它的延迟,即在开始一条新的运算之前,除法器必须完成整个除法。

​ 表达发射时间的一种更常见的方法是指明这个功能单元的最大吞吐量,定义为发射时间的倒数。一个完全流水线化的功能单元具有最大的吞吐量;发射时间越大,吞吐量越小;功能单元增加可以进一步提高吞吐量。对一个容量为 C,发射时间为 I 的操作来说,处理器可能获得的吞吐量为每时钟周期 C / I 个操作。

​ 我们使用延迟界限吞吐量界限来描述算术运算的延迟、发射时间和容量对合并函数的影响。延迟界限给出了任何必须按照严格顺序完成合并运算的函数所需要的最小 CPE 值。根据功能单元产生结果的最大速率,吞吐量界限给出了 CPE 的最小界限。

5.8 循环展开

​ 循环展开是一种程序变换,通过增加每次迭代计算的元素的数量,减少循环的迭代次数。

优化代码combine5:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* 2 x 1 loop unrolling */
void comnbine5(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
long limit = length-1;
data_t *data = get_vec_start(v);
data_t acc = IDENT;

/* Combine 2 elements at a time */
for (i = 0; i < limit; i+=2) {
acc = (acc OP data[i]) OP data[i+1]; /* 一次循环完成两次运算,即 result = result * a[i] * a[i+1] */
}

/* Finish any remaining elements */
for (; i < length; i++) {
acc = acc OP data[i];
}
*dest = acc;
}

​ 如上的例子即有效减小了循环开销,得到的延迟界限为 1.00。但是无论怎么增加展开次数,延迟界限都无法再突破。可以这样理解:循环展开实际上是使用 vmulsd 指令对 xmm0 寄存器每次进行一次读和一次写的操作。虽然我们增加循环展开次数,使得循环的次数以倍数折半,但是整个流程的关键路径实际上还是 n 个 vmulsd 操作——迭代次数减半了,但是每次迭代种还是有两个顺序的乘法操作(如 2 * 1 展开的情况)。

编译器可以很容易地执行循环展开。只要优化级别设置地足够高,许多编译器都能例行公事地做到这一点。用优化等级 3 或者更高等级调用 GCC,他就会循环展开。

5.9 提高并行性

之前所提到的种种可以发现程序的性能是受到运算单元的限制的。上面所说的诸如执行的加法和乘法的功能单元是完全流水线化的,每个时钟周期可以开始一个新的操作且有些操作可以被多个功能单元执行。硬件具有以高效率执行乘法和加法的潜力,但是代码不能利用这种能力。即使循环展开也不行,因为我们将累积值放在一个单独的变量 acc 中。在前面的计算完成之前,都不能计算 acc 的新值。虽然计算 acc 新值的功能单元能够每个时钟周期开始一个新的操作,但是它也只会每个合并操作的延迟个周期开始一条新的操作。所以我们需要打破这种顺序相关来获得比延迟界限更好的性能。

​ 对一个可结合和可变换的合并运算来说,可以通过将一组合并运算分割成两个或更多的部分,并且在最后合并来提高性能。最简单来说我们计算一串元素 a0 ~ an 的乘积 Pn。我们可以写成 Pn = PEn * POn,这里的 PEn 与 POn 即分别为索引值为奇数和偶数的乘积,我们将其称为 2 * 2 展开。

优化代码combine6:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* 2 x 2 loop unrolling*/
void combine6(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
long limit = length-1;
data_t *data = get_vec_start(v);
data_t *acc0 = IDENT;
data_t *acc1 = IDENT;

/* Combine 2 elements at a time */
for (i = 0; i < limit; i+=2) {
acc0 = acc0 OP data[i];
acc1 = acc1 OP data[i+1];
}

/* Finish any remaining elements */
for (; i < length; i++) {
acc0 = acc0 OP data[i];
}
*dest = acc0 OP acc1;
}

​ 虽然这个操作循环内都包括两个 vmulsd 操作,但是这些指令的读写使用到了不同的寄存器,相互之间没有数据相关,然后再将这个操作复制了 n/2 次,这样的我们观察整体操作就会发现有两条关键路径,所以延迟为 L 的操作实际上的 CPE 只有原来的一半,所以可以突破延迟界限获得更高的性能。在执行此等变换的时候,我们需要考虑两点,一个是是否要保留原始函数的功能,另一个是运算是否是可结合的,例如浮点乘法和加法就是不可结合的,可能会因为四舍五入或者溢出而产生不同的结果。大多数编译器不会尝试对浮点数代码进行这种变换,因为它们没有办法判断引入这种会改变程序行为的转换所带来的风险。

​ 在 combine5 中合并语句为 acc = (acc OP data[i]) OP data[i+1]; 在这里我们可以用另一种合并方式来极大的提高程序的性能:

优化代码combine7:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* 2 x 1a loop unrolling */
void combine7(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
long limit = length-1;
data_t *data = get_vec_start(v);
data_t acc = IDENT;

/* Combine 2 elements at a time */
for (i = 0; i < limit; i+=2) {
acc = acc OP (data[i] OP data[i+1]); /* 改变合并顺序 */
}

/* Finish any remaining elements */
for (; i < length; i++) {
acc = acc OP data[i];
}
*dest = acc;
}

​ 我们可以这样来理解这改变合并顺序前后程序性能的差异:虽然我们统计操作次数都是两个 vmulsd(两次读,两次写) 但是只有其中一个 vmulsd 操作去参与形成循环寄存器间的数据相关链。然后将这个操作模板复制 n/2 次。因此关键路径上只有 n/2 个操作。每次迭代内的第一个乘法都不需要等待前一次迭代的累积值就可以执行。因此,最小可能的 CPE 减小了 2 倍。我们增加 k x 1a 的 k 值的结果同之前增加 k x k 循环展开 相似,都接近了由功能单元造成的吞吐量界限。

​ 总的来说,重新结合变换能够减少计算中关键路径上操作的数量,通过更好地利用功能单元地流水线能力得到更好地性能。

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

​ 使用多项优化技术,我们获得的 CPE 已经接近于 0.50 和 1.00 的吞吐量界限,只受限于功能单元的容量。与原始代码相比提升了 10~20 倍,且使用普通的 C 代码和标准编译器就获得了所有这些改进。重写代码利用较新的 SIMD 指令得到了将近 4 倍或 8 倍的性能提升。比如单精度乘法,CPE 从初值 11.14 降到了 0.06, 整体性能提升超过 180 倍。 这个例子说明现代处理器具有相当的计算能力,但是我们可能需要按非常程式化的方式来编写程序以便将这些能力诱发出来。

5.11 一些限制因素

​ 我们已经看到在一个程序的数据流图表示中,关键路径指明了执行该程序所需时间的一个基本的下界。也就是说,如果程序中有某条数据相关链,这条链上的所有延迟之和等于 T,那么这个程序至少需要 T 个周期才能执行完。

​ 我们还看到功能单元的吞吐量界限也是程序执行时间的一个下界。也就是说,假设一 个程序一共需要 N 个某种运算的计算,而微处理器只有 C 个能执行这个操作的功能单元, 并且这些单元的发射时间为 7。那么,这个程序的执行至少需要 N * I/C 个周期。

​ 接下来,我们会考虑其他一些制约程序在实际机器上性能的因素。

​ 循环并行性的好处受汇编代码描述计算的能力限制。如果我们的并行度 p 超过了可用的寄存器数量,那么编译器会诉诸溢出(spilling),将某些临时值存放到内存中,通常是在运行时堆栈上分配空间。一旦编译器必须要诉诸寄存器溢出,那么维护多个累积变量的优势就很可能消失。幸运的是,X86-64 有足够多的寄存器,大多数循环在出现寄存器溢出之前就将达到吞吐量限制。

​ 当分支预测逻辑不能正确预测一个分支是否要跳转的时候,条件分支可能会招致很大的预测错误处罚。在一个使用投机执行(speculative execution)的处理器中,处理器会开始执行预测的分支目标处的指令。它会避免修改任何实际的寄存器或内存位置,直到确定了实际的结果。 如果预测正确,那么处理器就会”提交”投机执行的指令的结果,把它们存储到寄存器或内存。如果预测错误,处理器必须丢弃掉所有投机执行的结果,在正确的位置,重新开始取指令的过程。这样做会引起预测错误处罚,因为在产生有用的结果之前,必须重新填充指令流水线。

​ 有以下通用原则去处理分支:

  1. 不要过分关心可预测地分支

​ 错误的分支预测的影响可能非常大,但是这并不意味着所有的程序分支都会减缓程序的执行。实际上,现代处理器中的分支预测逻辑非常善于辨别不同的分支指令的有规律的模式和长期的趋势。而这些能被预测的分支求值都不会对形成程序执行中关键路径的指令的取指和处理产生太大的影响。

  1. 书写适合用条件传送实现的代码

​ 分支预测只对有规律的模式可行。程序中的许多测试是完全不可预测的,依赖于数据的任意特性,例如一个数是负数还是正数。对于这些测试,分支预测逻辑会处理得很糟糕。对于本质上无法预测的情况,如果编译器能够产生使用条件数据传送而不是使用条件控制转移的代码,可以极大地提高程序的性能。

5.12 理解内存性能

​ 在之前我们都没有讨论到关于内存性能方面的问题,在我们的程序中,内存性能集中体现在加载操作上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* Write to dest, read from src */
void write_read(long *src, long *dst, long n)
{
long cnt = n;
long val = 0;

while (cnt) {
*dst = val;
val = (*src)+1;
cnt--;
}
}

示例A:write_read(&a[0],&a[1],3)

事例B:write—read(&a[0],&a[0],3)

​ 示例 A 从 src 读出的结果不受对 dest 的写的影响。而示例 B 指针引用 src 的每次加载都会得到指针引用 *dest 的前次执行存储的值。这种现象我们称之为写/读相关(write/read dependency)—— 一个内存读的结果依赖于一个最近的内存写。除了由于写和读寄存器造成的操作之间的数据相关,其中还有许多隐含的相关来影响程序性能。比如地址计算必须在数值写入之前完成等。

​ 内存操作的实现包括许多细微之处。对于寄存器操作,在指令被译码成操作的时候,处理器就可以确定哪些指令会影响其他哪些指令。另一方面,对于内存 操作,只有到计算出加载和存储的地址被计算出来以后,处理器才能确定哪些指令会影响 其他的哪些。高效地处理内存操作对许多程序的性能来说至关重要。内存子系统使用了很多优化,例如当操作可以独立地进行时,就利用这种潜在的并行性。

5.13 应用:性能提高技术

优化程序性能的基本策略:

1)高级设计。为遇到的问题选择适当的算法和数据结构。要特别警觉,避免使用那些会渐进地产生糟糕性能的算法或编码技术。

2)基本编码原则。避免限制优化的因素,这样编译器就能产生高效的代码。

  • 消除连续的函数调用。在可能时,将计算移到循环外。考虑有选择地妥协程序的模块性以获得更大的效率。
  • 消除不必要的内存引用。引人临时变量来保存中间结果。只有在最后的值计算出 时,才将结果存放到数组或全局变量中。

3)低级优化。结构化代码以利用硬件功能。

  • 展开循环,降低开销,并且使得进一步的优化成为可能。
  • 通过使用例如多个累积变量和重新结合等技术,找到方法提高指令级并行。
  • 用功能性的风格重写条件操作,使得编译采用条件数据传送。

5.14 确认和消除性能瓶颈

5.15 小结

​ 虽然关于代码优化的大多数论述都描述了编译器是如何能生成髙效代码的,但是应用程序员有很多方法来协助编译器完成这项任务。没有任何编译器能用一个好的算法或数据结构代替低效率的算法或数据结构,因此程序设计的这些方面仍然应该是程序员主要关心的。我们还看到妨碍优化的因素,例如内存别名 使用和过程调用,严重限制了编译器执行大量优化的能力。同样,程序员必须对消除这些妨碍优化的因素负主要的责任。这些应该被看作好的编程习惯的一部分,因为它们可以用来消除不必要的工作。

​ 基本级别之外调整性能需要一些对处理器微体系结构的理解,描述处理器用来实现它的指令集体系 结构的底层机制。对于乱序处理器的情况,只需要知道一些关于操作、容量、延迟和功能单元发射时间的信息,就能够基本地预测程序的性能了。

​ 我们研究了一系列技术,包括循环展开、创建多个累积变量和重新结合,它们可以利用现代处理器 提供的指令级并行。随着对优化的深人,研究产生的汇编代码以及试着理解机器如何执行计算变得重要 起来。确认由程序中的数据相关决定的关键路径,尤其是循环的不同迭代之间的数据相关,会收获良多。 我们还可以根据必须要计算的操作数量以及执行这些操作的功能单元的数量和发射时间,计算一个计算的吞吐量界限。

​ 包含条件分支或与内存系统复杂交互的程序,比我们最开始考虑的简单循环程序,更难以分析和优 化。基本策略是使分支更容易预测,或者使它们很容易用条件数据传送来实现。我们还必须注意存储和 加载操作。将数值保存在局部变量中,使得它们可以存放在寄存器中,这会很有帮助。

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!

扫一扫,分享到微信

微信分享二维码
  • Copyrights © 2022-2023 Syclover.Kama
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信