循环拆分使代码变慢

Posted

技术标签:

【中文标题】循环拆分使代码变慢【英文标题】:Loop Splitting makes code slower 【发布时间】:2016-05-30 23:14:41 【问题描述】:

所以我正在优化一个循环(作为作业),将 10,000 个元素添加 600,000 次。没有优化的时间是23.34s~,我的目标是B达到7秒以内,A达到5秒以内。

所以我首先像这样展开循环来开始我的优化。

int     j;

        for (j = 0; j < ARRAY_SIZE; j += 8) 
            sum += array[j] + array[j+1] + array[j+2] + array[j+3] + array[j+4] + array[j+5] +  array[j+6] + array[j+7];

这将运行时间减少到大约 6.4~ 秒(如果我进一步展开,我可以达到大约 6)。

所以我想我会尝试添加子总和并在最后进行最终总和以节省读写依赖项的时间,我想出了看起来像这样的代码。

int     j;

    for (j = 0; j < ARRAY_SIZE; j += 8) 
        sum0 += array[j] + array[j+1]; 
        sum1 += array[j+2] + array[j+3];
        sum2 += array[j+4] + array[j+5]; 
        sum3 += array[j+6] + array[j+7];

但是这增加运行时间到大约 6.8 秒

我尝试了使用指针的类似技术,但我能做到的最好时间是 15 秒左右。

我只知道我运行它的机器(因为它是学校购买的一项服务)是一个 32 位、远程、基于 Intel 的 Linux 虚拟服务器,我相信它运行的是 Red Hat。

我已经尝试了所有我能想到的加快代码速度的技术,但它们似乎都产生了相反的效果。有人可以详细说明我做错了什么吗?或者我可以用来降低运行时间的另一种技术?老师能做的最好是大约 4.8 秒。

作为一个附加条件,我在完成的项目中不能有超过 50 行代码,所以做一些复杂的事情可能是不可能的。

这是两个来源的完整副本

    #include <stdio.h>
#include <stdlib.h>

// You are only allowed to make changes to this code as specified by the comments in it.

// The code you submit must have these two values.
#define N_TIMES     600000
#define ARRAY_SIZE   10000

int main(void)

    double  *array = calloc(ARRAY_SIZE, sizeof(double));
    double  sum = 0;
    int     i;

    // You can add variables between this comment ...

//  double sum0 = 0;
//  double sum1 = 0;
//  double sum2 = 0;
//  double sum3 = 0;

    // ... and this one.

    // Please change 'your name' to your actual name.
    printf("CS201 - Asgmt 4 - ACTUAL NAME\n");

    for (i = 0; i < N_TIMES; i++) 

        // You can change anything between this comment ...

        int     j;

        for (j = 0; j < ARRAY_SIZE; j += 8) 
            sum += array[j] + array[j+1] + array[j+2] + array[j+3] + array[j+4] + array[j+5] +  array[j+6] + array[j+7];
        

        // ... and this one. But your inner loop must do the same
        // number of additions as this one does.

        

    // You can add some final code between this comment ...
//  sum = sum0 + sum1 + sum2 + sum3;
    // ... and this one.

    return 0;

分解代码

    #include <stdio.h>
#include <stdlib.h>

// You are only allowed to make changes to this code as specified by the comments in it.

// The code you submit must have these two values.
#define N_TIMES     600000
#define ARRAY_SIZE   10000

int main(void)

    double  *array = calloc(ARRAY_SIZE, sizeof(double));
    double  sum = 0;
    int     i;

    // You can add variables between this comment ...

    double sum0 = 0;
    double sum1 = 0;
    double sum2 = 0;
    double sum3 = 0;

    // ... and this one.

    // Please change 'your name' to your actual name.
    printf("CS201 - Asgmt 4 - ACTUAL NAME\n");

    for (i = 0; i < N_TIMES; i++) 

        // You can change anything between this comment ...

        int     j;

        for (j = 0; j < ARRAY_SIZE; j += 8) 
            sum0 += array[j] + array[j+1]; 
            sum1 += array[j+2] + array[j+3];
            sum2 += array[j+4] + array[j+5]; 
            sum3 += array[j+6] + array[j+7];
        

        // ... and this one. But your inner loop must do the same
        // number of additions as this one does.

        

    // You can add some final code between this comment ...
    sum = sum0 + sum1 + sum2 + sum3;
    // ... and this one.

    return 0;

回答

我们用来判断成绩的“时间”应用程序有点偏离。我能做的最好的事情是 4.9~ 通过展开循环 50 次并像我在下面使用 TomKarzes 的基本格式那样对其进行分组。

int     j;
        for (j = 0; j < ARRAY_SIZE; j += 50) 
            sum +=(((((((array[j] + array[j+1]) + (array[j+2] + array[j+3])) +
                    ((array[j+4] + array[j+5]) + (array[j+6] + array[j+7]))) + 
                    (((array[j+8] + array[j+9]) + (array[j+10] + array[j+11])) +
                    ((array[j+12] + array[j+13]) + (array[j+14] + array[j+15])))) +
                    ((((array[j+16] + array[j+17]) + (array[j+18] + array[j+19]))))) +
                    (((((array[j+20] + array[j+21]) + (array[j+22] + array[j+23])) +
                    ((array[j+24] + array[j+25]) + (array[j+26] + array[j+27]))) + 
                    (((array[j+28] + array[j+29]) + (array[j+30] + array[j+31])) +
                    ((array[j+32] + array[j+33]) + (array[j+34] + array[j+35])))) +
                    ((((array[j+36] + array[j+37]) + (array[j+38] + array[j+39])))))) + 
                    ((((array[j+40] + array[j+41]) + (array[j+42] + array[j+43])) +
                    ((array[j+44] + array[j+45]) + (array[j+46] + array[j+47]))) + 
                    (array[j+48] + array[j+49])));
        

【问题讨论】:

这可能是由于现代 CPU 优化向量操作的方式。 尝试将-O3 添加到您的编译器选项中。这将实现更多的编译时优化。 @pvg 即使使用默认优化级别,我仍然希望sum0sum1 等被保存在寄存器中,所以我不相信你的论点。执行的转换是有意义的,只要将这些变量保存在寄存器中,我就不会期望它会使事情变得更糟。事实上,它应该减少循环中的依赖。 哇,这个作业真可怕。他们基本上是在强迫你做你不应该做的事情。如果他们要求使用-O3,然后说“现在看看你是否可以进行一些代码更改以进一步改进”,那会更有意义。 提示:强制将数组分配在高速缓存行(x86 上的 IIRC 64 字节)。最差的演员手动对齐它。太糟糕了,您不允许使用 SSE/AVX,因为这涉及某种汇编指令(尽管它们可作为内在函数使用)。 【参考方案1】:

我对分组进行了一些实验。在我的机器上,使用我的gcc,我发现以下方法效果最好:

    for (j = 0; j < ARRAY_SIZE; j += 16) 
        sum = sum +
              (array[j   ] + array[j+ 1]) +
              (array[j+ 2] + array[j+ 3]) +
              (array[j+ 4] + array[j+ 5]) +
              (array[j+ 6] + array[j+ 7]) +
              (array[j+ 8] + array[j+ 9]) +
              (array[j+10] + array[j+11]) +
              (array[j+12] + array[j+13]) +
              (array[j+14] + array[j+15]);
    

换句话说,它展开了 16 次,它将总和分组为对,然后将这些对线性相加。我还删除了 += 运算符,这会影响 sum 在添加中首次使用的时间。

我发现测量的时间从一次运行到下一次变化很大,即使没有改变任何东西,所以我建议在对时间是改善还是恶化做出任何结论之前对每个版本计时几次。

我很想知道使用这个版本的内循环在你的机器上得到什么数字。

更新:这是我目前最快的版本(在我的机器上,使用我的编译器):

    int     j1, j2;

    j1 = 0;
    do 
        j2 = j1 + 20;
        sum = sum +
              (array[j1   ] + array[j1+ 1]) +
              (array[j1+ 2] + array[j1+ 3]) +
              (array[j1+ 4] + array[j1+ 5]) +
              (array[j1+ 6] + array[j1+ 7]) +
              (array[j1+ 8] + array[j1+ 9]) +
              (array[j1+10] + array[j1+11]) +
              (array[j1+12] + array[j1+13]) +
              (array[j1+14] + array[j1+15]) +
              (array[j1+16] + array[j1+17]) +
              (array[j1+18] + array[j1+19]);
        j1 = j2 + 20;
        sum = sum +
              (array[j2   ] + array[j2+ 1]) +
              (array[j2+ 2] + array[j2+ 3]) +
              (array[j2+ 4] + array[j2+ 5]) +
              (array[j2+ 6] + array[j2+ 7]) +
              (array[j2+ 8] + array[j2+ 9]) +
              (array[j2+10] + array[j2+11]) +
              (array[j2+12] + array[j2+13]) +
              (array[j2+14] + array[j2+15]) +
              (array[j2+16] + array[j2+17]) +
              (array[j2+18] + array[j2+19]);
    
    while (j1 < ARRAY_SIZE);

这使用了 40 的总展开量,分成两组,每组 20 个,交替的归纳变量预先增加以打破依赖关系,以及一个后测试循环。同样,您可以尝试使用括号分组来针对您的编译器和平台进行微调。

【讨论】:

我运行了您的代码(以及使用不同分组的版本),每个测试版本(包括那个版本)的平均时间约为 5.5 秒。我当前的版本展开到 20,然后像这样将它们全部与他们的邻居分组,然后一次又一次地与他们的邻居分组,直到剩下一个操作到达大约 5.4-5.1。 听起来你已经快要尽可能多地挤出它了。 20 的展开量很有趣。通常我会考虑以 2 的幂次方展开,但在这种情况下,确实没有理由不以构成总行程数因素的其他金额展开。 我开始想知道你的老师做了什么让他们可靠地降到 5 岁以下,尽管摆弄了这么多,但你似乎无法达到。看起来我们几乎是在猜测稍微不同的平台上略有不同版本的 gcc 的代码生成。 我有一个新的循环展开,这似乎有很大的不同。我已经更新了我的帖子以包含它。请查看我帖子底部的新循环。我建议尝试一下,然后尝试添加括号,看看是否会有所不同。【参考方案2】:

我使用以下方法尝试了您的代码:

无优化,整数索引为 1 的 for 循环,简单 sum +=。这在我的 64 位 2011 MacBook Pro 上花费了 16.4 秒。

gcc -O2,相同的代码,缩短到 5.46 秒。

gcc -O3,相同的代码,缩短到 5.45 秒。

我尝试使用您的代码将 8 路加法添加到 sum 变量中。这将其缩短到 2.03 秒。

我将其加倍到 sum 变量的 16 路加法,这将其缩短到 1.91 秒。

我将它加倍到 sum 变量的 32 路加法。时间上升到 2.08 秒。

按照@kcraigie 的建议,我切换到了指针方法。使用 -O3,时间为 6.01 秒。 (我很惊讶!)

register double * p;
for (p = array; p < array + ARRAY_SIZE; ++p) 
    sum += *p;

我将 for 循环更改为 while 循环,使用 sum += *p++ 并将时间缩短到 5.64 秒。

我将 while 循环改为倒计时而不是倒计时,时间增加到 5.88 秒。

我改回了一个以 8 为增量整数索引的 for 循环,添加了 8 个寄存器双精度和 [0-7] 变量,并将 _array[j+N] 添加到 sumN for [0, 7]。 _array 声明为寄存器 double *const 初始化为 array,这很重要。这将时间缩短到 1.86 秒。

我更改为一个宏,该宏扩展为 10,000 个 +_array[n] 副本,其中 N 为常数。然后我做了sum = tnKX(addsum),编译器因分段错误而崩溃。所以纯粹的内联方法是行不通的。

我切换到一个宏,该宏扩展为 10,000 个 sum += _array[n] 副本,其中 N 为常数。运行时间为 6.63 秒!!显然,加载所有代码的开销降低了内联的有效性。

我尝试声明static double _array[ARRAY_SIZE];,然后使用__builtin_memcpy 在第一个循环之前复制它。使用 8 路并行加法,结果为 2.96 秒。我不认为静态数组是要走的路。 (可悲 - 我希望常量地址能成为赢家。)

从这一切看来,16 路内联或 8 路并行变量似乎应该是要走的路。您必须在自己的平台上进行尝试以确保 - 我不知道更广泛的架构会对数字产生什么影响。

编辑:

根据@pvg 的建议,我添加了以下代码:

int ntimes = 0;

// ... and this one.
...
    // You can change anything between this comment ...

            if (ntimes++ == 0) 

这将运行时间减少到

【讨论】:

这是一个严格的 -O0 分配,正如它在 cmets 中所说的那样。 当我开始写这篇文章时,还没有说过。 ;-) 对我的测试表明 16 路加法似乎是目前最好的结果。我只需要将它 .3~ish 推入目标区域。

以上是关于循环拆分使代码变慢的主要内容,如果未能解决你的问题,请参考以下文章

函数指针是否使程序变慢?

当我使用 std::algorithms 而不是普通循环时,为啥这段代码会变慢?

使用 AsyncTask 会使应用程序变慢

动画停止时如何使动画速度变慢

在实体框架中 EntityState.Detached 使操作变慢

启用 arch:SSE2 使程序变慢