第五章,优化程序性能
5.1 前言
编写高效程序要做到以下几点:
第一,我们必须选择一组适当的算法和数据结构。
第二,我们必须编写出编译器能够有效优化以转换成高效可执行代码的源代码。对于第二点,理解优化编译器的能力和局限性是很重要的。
第三,针对处理运算量特别大的计算,将一个任务分成多个部分,这些部分、以在多核和 多处理器的某种组合上并行地计算。我们会把这种性能改进的方法推迟到第12章中去讲。
5.2 表示程序性能
每元素周期数(CPE)用来表示性能,既处理一个元素所需要的处理器周期。为什么不用没循环周期数?因为循环展开这样的技术,能够使用较少循环完成任务,我们关心的是对向量的处理速度。
5.3 程序示例
vec.h
/* $begin adt */
/* Create abstract data type for vector */
typedef struct {
long len;
data_t *data;
/* $end adt */
long allocated_len; /* NOTE: we don't use this field in the book */
/* $begin adt */
} vec_rec, *vec_ptr;
vec.c
#include <stdlib.h>
#include "combine.h"
/* $begin vec */
/* 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;
/* $end vec */
/* We don't show this in the book */
result->allocated_len = len;
/* $begin vec */
/* 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;
}
/* Free storage used by vector */
void free_vec(vec_ptr v) {
if (v->data)
free(v->data);
free(v);
}
/*
* 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 < 0 || index >= v->len)
return 0;
*dest = v->data[index];
return 1;
}
/* Return length of vector */
long vec_length(vec_ptr v)
{
return v->len;
}
/* $end vec */
/* $begin get_vec_start */
data_t *get_vec_start(vec_ptr v)
{
return v->data;
}
/* $end get_vec_start */
/*
* Set vector element.
* Return 0 (out of bounds) or 1 (successful)
*/
int set_vec_element(vec_ptr v, long index, data_t val)
{
if (index < 0 || index >= v->len)
return 0;
v->data[index] = val;
return 1;
}
/* Set vector length. If >= allocated length, will reallocate */
void set_vec_length(vec_ptr v, long newlen)
{
if (newlen > v->allocated_len) {
free(v->data);
v->data = calloc(newlen, sizeof(data_t));
v->allocated_len = newlen;
}
v->len = newlen;
}
combine.h
定义运算类型,注册combine函数
#ifdef FLOAT
typedef float data_t;
#define DATA_NAME "Float"
#endif
#ifdef DOUBLE
typedef double data_t;
#define DATA_NAME "Double"
#endif
#ifdef EXTEND
typedef long double data_t;
#define DATA_NAME "Extended"
#endif
#ifdef INT
typedef int data_t;
#define DATA_NAME "Integer"
#endif
#ifdef LONG
/* $begin typedefint */
typedef long data_t;
/* $end typedefint */
#define DATA_NAME "Long"
#endif
#ifdef CHAR
typedef char data_t;
#define DATA_NAME "Char"
#endif
#ifdef PROD
/* $begin operprod */
#define IDENT 1
#define OP *
/* $end operprod */
#define OP_NAME "Product"
#else
#ifdef DIV
#define OP /
#define IDENT 1
#define OP_NAME "Divide"
#else
/* $begin operplus */
#define IDENT 0
#define OP +
/* $end operplus */
#define OP_NAME "Sum"
#endif /* DIV */
#endif /* PROD */
#include "vec.h"
/* Declaration of a combining routine */
/* Source vector, destination location */
typedef void (*combiner)(vec_ptr, data_t *);
/* Add combining routine to list of programs to measure */
void add_combiner(combiner f, combiner fc, char *description);
/* Flag combiner for logging, giving bounds for fast and slow cases */
/* Can only log one combiner at a time */
void log_combiner(combiner f, double fast_cpe, double slow_cpe);
/* Called by main to register the set of transposition routines to benchmark */
void register_combiners(void);
初始的合并程序
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;
}
}
初始的程序性能
使用未优化的,和“O1”优化级别的编译指令的CPE对比。
5.4 消除循环的低效率
基本没什么好说的,把重复计算的固定值放到循环体外面,避免重复计算,有点经验的程序员都知道的底线。
/* Move call to vec_length out of loop */
void combine2(vec_ptr v, data_t *dest) 3
{
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 减少过程调用
combine3以前,val值是通过get_vec_element(vec_ptr v, long index, data_t val)得到的,通过val传址,改变val值,函数内部对界限做检查,然后引用data[I];
所以,可以优化的点是:1,循环length已经检查过界限。2,可以直接引用内存,而不通过另一个函数。
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];
}
}
令人诧异的是:性能反而略微下降了。
5.6 消除不必要内存引用
我们来看一下combine3最内层循环*dest = *dest OP data[i];
生成的汇编
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内存地址,然后读取data[i]再乘以它,最后再写回dest地址处。data指针+1.
对内存一共是两次读,一次写。
combine4使用一种叫做变量积累的方式(老外发明名字真滴溜),实际上就是用局部变量暂存计算结果,最后一次写回内存。
combine4
/* 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 = acc OP data[i];
}
*dest = acc;
}
acc = acc OP data[i];
对应的汇编
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
对内存只有一次读,既读取data[i]
结果:
对性能提升很大
5.7 理解现代处理器
现代处理器模型
现代处理器都是流水线处理器。什么意思呢?
一条指令需要经历从内存取出,翻译,执行,读写内存,更新寄存器,确定下一条指令地址,再取指令的循环。
如图5-11,作者将其简化为两个单元,内存控制单元(ICU),执行单元(EU)。这两个大单元内又包含许多小单元,包括整数和浮点的四则运算的运算单元,从高速缓存加载和存储的单元,取址控制单元…主要的CPU时间消耗在这里。
参考机的功能单元
需要注意,整数运算指的是整数加法,位级运算,移位。
功能单元的性能
整数加法可以做到一个周期完成一条指令,乘法需要三个时钟周期;浮点加法的3个时钟周期来自于浮点运算的三段式(对阶,运算,取舍);除法的延迟等于发射周期,说明处理器必须处理完这条指令才能加载下一条指令。
吞吐量
吞吐量描述了一个处理器的CPE下界,越小说明一个元素需要的时钟周期越少,相应的处理起它的容器来就更快。
公式是发射/功能单元数量
。比方说整数加法每周期发射一条指令,功能单元有4个,那么吞吐量是1/4 。但是加载的单元只有2个,每时钟周期只能加载2条指令,所以整数加法受制于加载单元数量,吞吐量只有1/2.
处理器操作的抽象模型
刚刚学习了CPE和吞吐量的概念,回到combine4函数,测试得到数据如下:
除了整数加法稍高于延迟界限外,其他运算均等于延迟界限,说明我们只使用了一个功能单元
,这是后面需要优化的部分。
数据流图
为了弄清楚是什么决定了我们不能超越延迟界限,我们需要从汇编语言出发,看看数据流是怎么走的。
.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的汇编,其实已经可以看到,要读取%xmm0
的值依赖于上一个循环的写;而我们不可能预知上一步计算出来的结果,那么只能安静的等待上一条指令计算完毕。
再补充一点,现代处理器使用了转发机制,使得计算出来的值不必走完整个处理器流水线而可以直接转发到寄存器。否则,远不止3个时钟周期。
5.8 循环展开
循环展开就是一种程序变换,通过增加每次迭代计算元素的数量,减少循环迭代的次数。
void combine5(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;
}
combine5测试结果:
可惜,这种方式并没有提升CPE。可以通过看汇编,分析看看为什么。
i in %rdx, data %rax, limit in %rbx, acc in %xmm0
.L35:
loop:
vmulsd (%rax,%rdx,8), %xmm0, %xmm0
vmulsd 8(%rax,%rdx,8), %xmm0, %xmm0
addq $2, %rdx
cmpq %rdx, %rbp
jg .L35
可以看到,第二条指令仍然要读第一条指令计算出来的寄存器%xmm0
值。虽然总体的循环数变少了,可是总的指令数却没有变少,数据流也没有变化,自然,CPE就不会下降了。
数据流图也显示了这一点:
combine5数据流图
5.9 提高并行性
刚刚提到循环展开并不能提高性能,那么该怎么做才能充分利用多个功能单元呢?
答案就是解除数据相关,把数据积累在多个变量上。因为在程序中,一个本地变量一般对应着一个寄存器。
combine6
/* 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;
}
combine6
里,acc0
存放data[i]相乘的结果,acc1
存放data[i+1]相乘的结果。最后使用acc0*acc1
得到结果。原理就是那只有数学家才能想明白的极其复杂的乘法分配律。
这么做的测试结果:
各个运算性能都得到了提升。
老样子,上数据流图:
橙色和红色两条线代表了数据的依赖关系。
能被编译优化的循环展开
刚刚combine6
展示了解决循环展开不能解决数据依赖的一种办法,但如果能“正确”地重新结合变换,也能提供性能优化。
combine7
和combine5没有区别,除了内循环语句做了调整:
acc = acc OP(data[i] OP data[i + 1]);
测试数据:
对应的汇编和数据流图:
acc in % xmm1
性能瓶颈仍然是acc的数据相关。但是循环数减少了,利用的寄存器数量和功能单元数量增加了,结果就是性能上升了。
在这里解释下为什么浮点乘法延迟是5,即需要五个时钟周期才能完成一次运算,而对性能制约的是acc
的数据相关。
在第四章讲过处理器的流水线工作模式,即上一条指令还在处理中的时候,下一条指令就加载进来了,而data[i]数据是存储在内存当中的(准确的说,是存储在L1缓存之中),在第六章,我们会知道,对L1缓存读需要的时钟周期为1
,而参考机发射周期为1
,所以对data[i]*data[i+1]
的计算完全可以实现流水线化。这一指令需要的结果在运算前可能就准备好了。
所以combine7的制约就在于acc变量的数据依赖。
SIMD
SSE,是Stream SIMD Extensions
的缩写。SIMD是Single Instration,Multiple Data
的缩写,单指令多数据,支持用一条指令对多个数据进行计算。较新的版本为AVX,Advance Vector Extension
。
使用能被GCC编译为SIMD指令的代码,重写后测试:
标量10*10代表维护10个累积变量的10次循环展开(联乘),向量代表使用SIMD指令的代码。这是一个非常恐怖的性能提升。
5.11 限制因素
成为程序性能限制的因素还有:寄存器数量不是无限的,分支预测错误的惩罚,内存性能拿到下一个小标题和第六章中去说。
寄存器溢出
处理器的寄存器数量都是有限的,名字也有历史原因,这点在第三章讲的非常明白。我们也明白,当方法变量太多时,有一部分变量会被存储到内存中,如果acc
被存储到L2缓存,读取写入需要几个时钟周期,L3需要几十个,到主存则要几百个时钟周期。
分支预测错误
分之预测完全是处理器自动的行为,程序员无法干涉,但是可以写适用于条件传送而非条件跳转的代码,前者可以并行执行,不造成预测错误惩罚。