优化程序性能(3)——提高并行性

在之前的学习中,程序的性能是受运算单元的延迟限制的。正如我们表明的,执行加法和乘法的功能单元是完全流水线化的,这意味着它们可以每个时钟周期开始一个新操作,并且有些操作可以被多个功能单元执行。硬件具有以更高速率执行乘法和加法的潜力,但是代码不能利用这种能力,即使是使用循环展开也不能,这是因为我们将积累值放在一个单独的变量acc中,在前面的计算完成之前,都不能计算acc的新值(顺序依赖)。虽然计算acc值的功能单元能够每个时钟周期开始一个新操作,但是它只会每L(L是合并操作的延迟)个周期开始一条新操作。
为了打破这种顺序相关,得到比延迟界限更好性能的方法我们可以考虑设置多个积累变量。
对于一个可结合和可交换的合并运算来说,比如说整数加法或乘法,我们可以通过将一组合并运算分割成两个或更多的部分,并在最后合并结果来提高性能。

/* 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 < limit ; i++){
acc0 = acc0 OP data[i];
}
*dest = acc0 OP acc1;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
上述代码既使用了两次循环展开,以使每次迭代合并更多的元素,也是用了两路并行,将索引值为偶数的元素累积在变量acc0中,而索引值为奇数的元素累积在变量acc1中。我们将它称为“2x2循环展开”。比较只做循环展开和既做循环展开同时也使用两路并行这两种方法,我们得到下面的性能:

我们看到所有情况都得到了改进,整数乘、浮点加、浮点乘改进了约2倍,而整数加也有所改进。最棒的是,我们打破了由延迟界限设下的限制。处理器不再需要延迟一个加法或乘法操作以待前一个操作完成。

实际上,程序正在利用功能单元的流水线能力,将利用率提高到两倍。唯一的例外是整数加。我们已将CPE降低到1.0以下,但是还是有太多的循环开销,而无法达到理论界限0.50。

通常,只有保持能够执行该操作的所有功能单元的流水线都是满的,程序才能达到这个操作的吞吐量界限。对延迟为L,容量为C的操作而言,这就要求循环展开因子k≥C·L。比如,浮点乘由C=2,L=5,循环展开因子就必须为k≥10.浮点加有C=1,L=3,则在k≥3时达到最大吞吐量。

现在来探讨另一种打破顺序相关从而使性能提高到延迟界限之外的方法——重新结合变换。我们看到过做kx1循环展开的combine5 没有改变合并向量元素形成和或者乘积中执行的操作。对代码做很少的改动,我们可以从根本上改变合并执行的方式,也极大地提高程序的性能。下面给出一个函数combine7,它与combine5的展开代码的唯一区别在于内循环中元素合并的方式。在combine5中,合并使以下面这条语句来实现的:
acc = (acc OP data[i]) OP data[i+1];
而在combine7中,合并是以这条语句来实现的
acc = acc OP (data[i] OP data[i+1] );
差别仅仅在于两个括号是如何放置的。我们称之为重新结合变换,因为括号改变了向量元素与累计值acc的合并顺序,产生了我们称为“2x1a”的循环展开形式。

/* 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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
再来测试CPE时,我们得到令人吃惊的结果:

整数加的性能几乎与使用kx1展开的版本(combine5)的性能相同,而其他三种情况则与使用并行累计变量的版本(combine6)相同,是kx1扩展的性能的两倍。这已经突破了延迟界限造成的限制。
这是因为关键路径上只有n/2个操作。每次迭代内的第一个乘法都不需要等待前一次迭代的累计值就可以执行。因此,最小可能的CPE减少了两倍。
注意:对于整数加法和乘法,这些运算是可结合的,这表示这种重新变换顺序对结果没有影响。对于浮点数情况,必须再次评估这种重新结合是否有可能严重影响结果。

总的来说,重新结合变换能够减少关键路径上操作的数量,通过更好地利用功能单元的流水线能力可以得到更好的性能。大多数编译器不会尝试对浮点运算做重新结合,因为这些运算不保证是可结合的。
--------------------- 

posted on 2019-06-11 01:35  激流勇进1  阅读(518)  评论(0编辑  收藏  举报