参考数据:《深入理解计算机系统》
程序示例介绍
以下是一个向量数据结构,包含头部和数据数组。
typedef struct {
long len;
data_t *data;
} vec_rec, *vec_ptr;
data_t是基本数据类型,可以是被声明成int、long、float或double。
typedef long data_t;
下面给出一些函数,其作用是:
- 创建指定长度的向量
- 检索矢量元素并存储在
dest
,越界返回0,成功返回1 - 返回向量的长度
- 返回数组的起始地址
vec_ptr new_vec(long len) { ... }
int get_vec_element(vec_ptr v, long index, data_t *dest) { ... }
long vec_length(vec_ptr v) { ... }
data_t *get_vec_start(vec_ptr v) { ... }
基于上述内容,我们可以实现一个合并运算。
void combine1(vec_ptr v, data_t *dest)
{
long i;
*dest = INENT;
for (i = 0; i < vec_length(v); i++) {
data_t val;
get_vec_element(v, i, &val);
*dest = *dest OP val;
}
}
通过使用编译时常数INENT
和OP
的不同定义,这段代码可以重编译成对数据执行不同的运算。如果我们想对向量元素进行求和,则声明:
#define INENT 0
#define OP +
如果我们想对向量元素进行乘积,则声明:
#define INENT 1
#define OP *
优化策略
消除循环的低效率
代码移动(code motion):识别要执行多次但是计算结果不会改变的计算,因而可以将计算移动到代码前面不会被多次求值的部分。
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;
}
}
减少过程调用
过程调用会带来开销,而且妨碍大多数形式的程序优化。为此,我们可以用函数get_vec_start
来获取数组的起始地址,从而取代原来用来获取下一个向量元素的get_vec_element
。
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]
}
}
消除不必要的内存引用
在上面的代码中,我们可以看到,在每次循环迭代时,累积变量dest
的数值都要从内存读出再写入到内存。这样的读写很浪费,因为每次迭代开始时从dest
读出的值就是上次迭代最后写入的值。
为了消除这种不必要的内存读写,我们引入一个临时变量acc
,它在循环中用来累积计算出来的值,只有在循环完成之后结果才存放在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;
}
循环展开
循环展开是一种程序变换,通过增加每次迭代计算的元素的数量,来减少循环的迭代次数。循环展开能够从两个方面改进程序的性能:
- 减少了不直接有助于程序结果的操作的数量,例如循环索引计算和条件分支;
- 提供了一些方法,可以进一步变化代码,减少整个计算中关键路径上的操作数量。
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;
for (i = 0; i < limit; i += 2) {
acc = (acc OP data[i]) OP data[i + 1];
}
for (; i < length; i++) {
acc = acc OP data[i];
}
*dest = acc;
}
提高并行性
执行加法和乘法的功能单元是完全流水化的,但由于我们将累积值放在一个单独的变量acc
中,在前面的计算完成之前,我们不能计算acc
的新值。下面是两种打破这种顺序相关,得到比延迟界限(当一系列操作必须按照严格顺序执行时,就会遇到延迟界限,因为在下一条指令开始之前,这条指令必须结束)更好性能的方法。
多个累积变量
对于一个可结合和可交换的合并运算(比如整数加法或乘法)来说,我们可以通过将一组合并运算分割成两个或更多的部分,并在最后合并结果来提高性能。
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;
for (i = 0; i < limit; i += 2) {
acc0 = acc0 OP data[i];
acc1 = acc1 OP data[i + 1]
}
for (; i < length; i++) {
acc0 = acc0 OP data[i];
}
*dest = acc0 OP acc1;
}
重新结合变换
重新结合变换通过改变向量元素与累计值acc
的合并顺序,产生了另外一种能突破延迟界限的循环展开形式。
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;
for (i = 0; i < limit; i += 2) {
acc = acc OP (data[i] OP data[i + 1]);
}
for (; i < length; i++) {
acc = acc OP data[i];
}
*dest = acc;
}
限制因素
寄存器溢出
循环并行性的好处受汇编代码描述计算的能力限制,如果我们的并行度超过了可用寄存器的数量,那么编译器会诉诸溢出(spilling),将某些临时值存放到内存中,通常是在运行时堆栈上分配空间。
适当的循环展开以及累积变量能够让CPE(每元素的周期数)更加接近吞吐量界限(处理器功能单元的原始计算能力,这是程序性能的终极限制),但过度展开会恶化CPE。
分支预测和预测错误处罚
当分支预测逻辑不能正确预测一个分支是否要跳转的时候,条件分支可能会招致很大的预测错误惩罚。因此,对于本质上无法预测的情况,如果编译器能够产生使用条件数据传送而不是使用条件控制转移的代码,可以极大地提高程序的性能,这要求程序员要善于书写适合用条件传送实现的代码,比如多使用三目运算符而非分支语句。