最终分配的 C 循环优化帮助(禁用编译器优化)

C loop optimization help for final assignment (with compiler optimization disabled)

因此,对于我在计算机系统 class 中的最终作业,我们需要优化这些 for 循环,使其比原来的循环更快。

使用我们的 linux 服务器,基本成绩不到 7 秒,完整成绩不到 5 秒。我这里的这段代码大约需要 5.6 秒。我在想我可能需要以某种方式使用指针来让它运行得更快,但我不太确定。谁能提供我的任何提示或选项?

文件必须保持在 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 ...
    register double sum1 = 0, sum2 = 0, sum3 = 0, sum4 = 0, sum5 = 0, sum6 = 0, sum7 = 0, sum8 = 0, sum9 = 0;
    register int j;
    // ... and this one.

    printf("CS201 - Asgmt 4 - \n");

    for (i = 0; i < N_TIMES; i++)
    {
        // You can change anything between this comment ...
        for (j = 0; j < ARRAY_SIZE; j += 10)
        {
            sum += array[j];
            sum1 += array[j + 1];
            sum2 += array[j + 2];
            sum3 += array[j + 3];
            sum4 += array[j + 4];
            sum5 += array[j + 5];
            sum6 += array[j + 6];
            sum7 += array[j + 7];
            sum8 += array[j + 8];
            sum9 += array[j + 9];
        }
        // ... 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 += sum1 + sum2 + sum3 + sum4 + sum5 + sum6 + sum7 + sum8 + sum9;
    // ... and this one.

    return 0;
}

可能走在正确的轨道上,尽管您需要对其进行衡量才能确定(我的一般建议是衡量,而不是猜测 在这里似乎有点多余,因为作业的整个 point 都是测量)。

优化编译器可能看不出有什么不同,因为它们对这类东西非常聪明,但是,由于我们不知道它将在什么优化级别进行编译,您可能会得到实质性的改进。

在内部循环中使用指针很简单,首先添加一个指针变量:

register double *pj;

然后将循环更改为:

for (pj = &(array[0]); pj < &(array[ARRAY_SIZE]); j++) {
        sum += *j++;
        sum1 += *j++;
        sum2 += *j++;
        sum3 += *j++;
        sum4 += *j++;
        sum5 += *j++;
        sum6 += *j++;
        sum7 += *j++;
        sum8 += *j++;
        sum9 += *j;
    }

这使循环内的加法量保持不变(当然,假设您将 +=++ 算作加法运算符)但基本上使用指针而不是数组索引。

在我的系统上没有优化1,它从 9.868 秒(CPU 时间)下降到 4.84 秒。您的里程可能会有所不同。


1 With 优化级别 -O3, both 被报告为采用0.001 秒所以,如前所述,优化器非常聪明。但是,如果您看到超过 5 秒,我建议它没有在优化的情况下编译。

顺便说一句,这就是为什么通常建议以可读的方式编写代码并让编译器更快 运行 处理代码的一个很好的理由。虽然我微不足道的优化尝试使速度大约翻了一番,但使用 -O3 使它 运行 快了 倍 :-)

Re-posting 我对 optimized sum of an array of doubles in C 的回答的修改版本,因为那个问题被投票降为 -5。另一个问题的 OP 更像是 "what else is possible",所以我相信他的话和 info-dumped 关于当前 CPU 硬件的矢量化和调整。 :)

那个问题的 OP 最终说他不允许使用高于 -O0 的编译器选项,我想这里也是如此。

总结:

  • 为什么使用 -O0 会扭曲事物(不公平地惩罚普通编译器在普通代码中没问题的事物)。 使用 -O0(gcc/clang 默认值)所以你的循环不会优化掉不是一个有效的借口,也不是一个有用的方法来找出启用正常优化会更快的方法。

  • 作业有问题。

  • 优化类型。 FP 延迟与吞吐量和依赖链。 Link 到 Agner Fog 的网站。 (优化必读)

  • 让编译器对其进行优化的实验(在修复它后不进行优化)。 auto-vectorization 的最佳结果(无源更改):gcc:速度是最佳矢量化循环的一半。 clang:与 hand-vectorized 循环速度相同。

  • 关于为什么更大的表达式仅在 -O0 下性能获胜的更多评论。

  • 源代码更改以获得良好的性能而无需 -ffast-math,使代码更接近我们希望编译器执行的操作。还有一些 rules-lawyering 想法在 real-world.

  • 中毫无用处
  • 使用 GCC architecture-neutral 向量对循环进行向量化,以查看 auto-vectorizing 编译器与理想 asm 代码的性能相匹配的程度(因为我检查了编译器输出) .


我认为作业的目的是教授 assembly-language 使用 C 进行性能优化而不进行编译器优化。这很愚蠢。它将编译器在现实生活中为您做的事情与 需要 source-level 更改的事情混为一谈。

Why does clang produce inefficient asm with -O0 (for this simple floating point sum)?

-O0 不只是 "not optimize",它使编译器在每个语句后将变量存储到内存中,而不是将它们保存在寄存器中。如果您使用 gdb 设置断点并 修改 C 变量的值(在内存中),它会这样做,因此您会得到 "expected" 结果。或者即使你 jump 到同一函数中的另一行。因此,每个 C 语句都必须编译为一个独立的 asm 块,该块以内存中的所有变量开始和结束。对于像 gcc 这样的现代便携式编译器,它已经 transforms through multiple internal representations of program flow on the way from source to asm -O0 的这一部分明确需要 de-optimizing 其数据流图回到单独的 C 语句。 这些 store/reloads 延长了每个 loop-carried 依赖链,因此如果循环计数器保存在内存中,对于微小循环来说是可怕的。 (例如,inc reg 的每次迭代 1 个周期与 inc [mem] 的 6c,在紧密循环中创建循环计数器更新的瓶颈)。

使用 gcc -O0register 关键字 让 gcc 将 var 保存在寄存器而不是内存中,因此可以在紧循环(Example on the Godbolt Compiler explorer). But that's only with -O0. In real code, register is meaningless: the compiler attempts to optimally use the available registers for variables and temporaries. register is already deprecated in ISO C++11 (but not C11), and there's a proposal to remove it from the language 以及其他过时的东西,如三字母。

由于涉及额外的变量,-O0 对数组索引的伤害比对指针递增的伤害要大一些。

数组索引通常使代码更易于阅读。编译器有时无法优化像 array[i*width + j*width*height] 这样的东西,因此最好更改源代码以执行 strength-reduction 优化,将乘法转换为 += 添加。

在 asm 级别,数组索引与指针递增的性能接近相同。 (例如,x86 具有 [rsi + rdx*4] 等寻址模式,其速度与 [rdi] 一样快。except on Sandybridge and later。)编译器的工作是通过使用指针递增来优化代码,即使源代码使用数组索引也是如此, 当那更快时。

为了获得良好的性能,您必须了解编译器可以做什么和不能做什么。一些优化是 "brittle",对源代码的一个小 seemingly-innocent 更改将阻止编译器进行优化,这对于某些代码快速 运行 是必不可少的。 (例如,从循环中提取常量计算,或证明不同分支条件如何相互关联,并进行简化。)


除此之外,它是一个垃圾示例,因为它没有任何东西可以阻止智能编译器优化整个东西。它甚至不打印总和。甚至 gcc -O1(而不是 -O3)也放弃了一些循环。

(您可以通过在末尾打印 sum 来解决此问题。gcc 和 clang 似乎没有意识到 calloc returns 清零内存,并将其优化为 0.0。请参阅下面的代码。)

通常您会将代码放在一个函数中,然后从另一个文件的 main() 循环调用它。并单独编译它们,没有 whole-program cross-file 优化,因此编译器无法根据您调用它的 compile-time 常量进行优化。 repeat-loop 被如此紧密地包裹在数组的实际循环周围导致 gcc 的优化器(见下文)造成严重破坏。

另外,这个问题的另一个版本有一个未初始化的变量。看起来 long int help 是由该问题的 OP 引入的,而不是教授。所以我将不得不将我的 "utter nonsense" 降级为 "silly",因为代码最后甚至没有打印结果。这是让编译器不优化微基准测试中所有内容的最常见方法。


我假设您的教授提到了一些关于性能的事情。有很多不同的东西可以在这里发挥作用,我认为其中许多在第二年的 CS class.

中没有被提及。

除了使用 openmp 的多线程之外,还有使用 SIMD 的矢量化。还有针对现代流水线 CPUs 的优化:具体来说,避免有一个长依赖链。

进一步阅读:

你的编译器手册也是必不可少的,尤其是。用于浮点代码。浮点数的精度有限,并且 关联。最后的总和 确实 取决于你做加法的顺序。通常舍入误差的差异很小,所以编译器可以通过 re-ordering 获得很大的加速,如果你使用 -ffast-math 允许它。

而不是仅仅展开,,就像您对 sum0..sum9 unroll-by-10 所做的那样。 FP 指令具有中等延迟但高吞吐量,因此您需要保持多个 FP 操作在运行中以保持浮点执行单元饱和。

如果您需要在下一个操作开始之前完成上一个操作的结果,您会受到延迟的限制。对于 FP add,这是每 3 个周期一个。在 Intel Sandybridge、IvB、Haswell 和 Broadwell 中,FP add 的吞吐量是每个周期一个。因此,您需要保留至少 3 个可以同时运行的独立操作以使机器饱和。 For Skylake, it's 2 per cycle with latency of 4 clocks。 (对 Skylake 有利的一面是,FMA 的延迟降至 4 个周期。)

在这种情况下,还有一些基本的东西,比如把东西从循环中拉出来,例如help += ARRAY_SIZE.

编译器选项

让我们先看看编译器能为我们做什么。

我从最初的内部循环开始,只取出 help += ARRAY_SIZE,并在末尾添加一个 printf,因此 gcc 不会优化所有内容。让我们尝试一些编译器选项,看看我们可以用 gcc 4.9.2 实现什么(在我的 i5 2500k Sandybridge。3.8GHz 最大涡轮增压(轻微 OC),3.3GHz 持续(与这个简短的基准无关):

  • gcc -O0 fast-loop-cs201.c -o fl: 16.43s 的表现完全是个笑话。变量在每次操作后存储到内存中,并在下一次之前存储 re-loaded 。这是一个瓶颈,并增加了很多延迟。更不用说失去实际的优化了。 带有-O0的定时/调整代码没有用。
  • -O1: 4.87s
  • -O2: 4.89s
  • -O3:2.453s(用SSE一次做2,我当然用的是64位系统,所以对-msse2的硬件支持是baseline。)
  • -O3 -ffast-math -funroll-loops: 2.439s
  • -O3 -march=sandybridge -ffast-math -funroll-loops: 1.275s(使用AVX一次做4个。)
  • -Ofast ...: 没有收获
  • -O3 -ftree-parallelize-loops=4 -march=sandybridge -ffast-math -funroll-loops: 0m2.375s 真实,0m8.500s 用户。看起来锁定头顶杀死了它。它总共只生成 4 个线程,但内循环太短以至于无法获胜:它每次都收集总和,而不是给每个线程 1/4 的外循环迭代。
  • -Ofast -fprofile-generate -march=sandybridge -ffast-math,运行它,然后
    -Ofast -fprofile-use -march=sandybridge -ffast-math1.275sprofile-guided 优化是个好主意 当你可以练习所有相关的 code-paths 时,编译器可以做出更好的展开/内联决策。

  • clang-3.5 -Ofast -march=native -ffast-math: 1.070s。 (clang 3.5 太旧,无法支持 -march=sandybridge。您应该更喜欢使用足够新的编译器版本,以了解您正在调整的目标架构,尤其是如果使用 -march 来制作代码在旧架构上不需要 运行。)

gcc -O3 以一种有趣的方式进行矢量化:内循环通过将一个数组元素广播到 xmm(或 ymm)寄存器的所有元素来并行执行外循环的 2(或 4)次迭代,并在上面做一个 addpd。所以它看到重复添加相同的值,但即使 -ffast-math 也不会让 gcc 将它变成乘法。或者切换循环。

clang-3.5 向量化得更好:它向量化内部循环,而不是外部循环,因此它不需要广播。它甚至使用 4 个向量寄存器作为 4 个独立的累加器。但是,它不假设 calloc returns 对齐内存,并且由于某种原因它很薄最好的选择是一对 128b 载荷。

vmovupd -0x60(%rbx,%rcx,8),%xmm4`
vinsertf128 [=10=]x1,-0x50(%rbx,%rcx,8),%ymm4,%ymm4

当我告诉它数组对齐时,它实际上更慢。 (像 array = (double*)((ptrdiff_t)array & ~31); 这样的愚蠢 hack 实际上会生成一条指令来屏蔽低 5 位,因为 clang-3.5 不支持 gcc 的 __builtin_assume_aligned。)我认为 4x vaddpd mem, %ymmX,%ymmX 对齐放置 cmp [=72=]x271c,%rcx 跨越 32B 边界,因此它不能 macro-fuse 与 jne。不过,uop 吞吐量应该不是问题,因为根据 perf.

,此代码每个周期仅获得 0.65insns(和 0.93 uops / 周期)

啊,我用调试器检查过,calloc 只返回一个 16B 对齐的指针。所以 32B 内存访问中有一半是跨越高速缓存线的,导致速度大幅下降。当您的指针在 Sandybridge 上是 16B 对齐而不是 32B 对齐时, 执行两个单独的 16B 加载稍微快一些。 (gcc 为 -march=sandybridge 启用 -mavx256-split-unaligned-load...-store,也为带有 -mavx 的默认 tune=generic 启用 not so good,特别是对于 Haswell 或内存通常由编译器对齐并不知道。)

源级别更改

从clang打败gcc可以看出,多重累加器非常优秀。最明显的方法是:

for (j = 0; j < ARRAY_SIZE; j+=4) {  // unroll 4 times
    sum0 += array[j];
    sum1 += array[j+1];
    sum2 += array[j+2];
    sum3 += array[j+3];
}

然后直到外循环结束后才将4个累加器合二为一

你的(来自另一个问题的)来源变更

sum += j[0]+j[1]+j[2]+j[3]+j[4]+j[5]+j[6]+j[7]+j[8]+j[9];

实际上有类似的效果,感谢out-of-order执行。每组 10 个是一个单独的依赖链。 order-of-operations 规则说 j 值先加在一起,然后加到 sum。所以 loop-carried 依赖链仍然只有一个 FP 添加的延迟,并且每组 10 个都有很多独立的工作。每个组都是一个独立的依赖链,由 9 个添加,并且需要足够少的指令来 out-of-order 执行硬件以查看下一个链的开始,并找到并行性以保持那些中等延迟、高吞吐量的 FP 执行单元。

使用 -O0,正如您愚蠢的分配显然需要的那样,值在每个语句结束时存储到 RAM 中。编写更长的表达式而不更新任何变量,即使是临时变量,也会使 -O0 运行 更快,但这不是一个有用的优化。不要将时间浪费在 -O0 有帮助的更改上,尤其是。不以牺牲可读性为代价。


使用 4 个累加器变量,直到外循环结束才将它们相加,从而击败了 clang 的 auto-vectorizer。它仍然只用了 1.66 秒 运行s(相比之下,gcc 的 non-vectorized -O2 使用一个累加器为 4.89)。即使没有 -ffast-mathgcc -O2 也会因为此源更改而获得 1.66s。请注意,已知 ARRAY_SIZE 是 4 的倍数,因此我没有包含任何清理代码来处理最后的 up-to-3 元素(或避免读取数组末尾,这会像现在写的那样发生)。执行此操作时很容易出错并读取数组末尾。

另一方面,

gcc 确实对此进行了向量化,但它也将内部循环悲观化 (un-optimises) 为单个依赖链。我认为它再次对外部循环进行多次迭代。


使用 gcc 的 platform-independent 向量扩展 ,我写了一个编译成 apparently-optimal 代码的版本:

// compile with gcc -g -Wall -std=gnu11 -Ofast -fno-tree-vectorize -march=native fast-loop-cs201.vec.c -o fl3-vec

#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>
#include <assert.h>
#include <string.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 ...
    long int help = 0;

    typedef double v4df __attribute__ ((vector_size (8*4)));
    v4df sum0={0}, sum1={0}, sum2={0}, sum3={0};

    const size_t array_bytes = ARRAY_SIZE*sizeof(double);
    double *aligned_array = NULL;

    // this more-than-declaration could go in an if(i == 0) block for strict compliance with the rules
    if ( posix_memalign((void**)&aligned_array, 32, array_bytes) ) {
        exit (1);
    }
    memcpy(aligned_array, array, array_bytes);  // In this one case: faster to align once and have no extra overhead for N_TIMES through the loop

    // ... and this one.

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

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

        // You can change anything between this comment ...
    /*
    #if defined(__GNUC__) && (__GNUC__ * 100 + __GNUC_MINOR__) >= 407 // GCC 4.7 or later.
        array = __builtin_assume_aligned(array, 32);
    #else
        // force-align for other compilers.  This loop-invariant will be done outside the loop.
        array = (double*) ((ptrdiff_t)array & ~31);
    #endif
    */

        assert ( ARRAY_SIZE / (4*4) == (ARRAY_SIZE+15) / (4*4) );  // We don't have a cleanup loop to handle where the array size isn't a multiple of 16


        // incrementing pointers can be more efficient than indexing arrays
        // esp. on recent Intel where micro-fusion only works with one-register addressing modes
        // of course, the compiler can always generate pointer-incrementing asm from array-indexing source
        const double *start = aligned_array;

        while ( (ptrdiff_t)start & 31 ) {
            // annoying loops like this are the reason people use aligned buffers
            sum += *start++;        // scalar until we reach 32B alignment
            // in practice, this loop doesn't run, because we copy into an aligned buffer
            // This will also require a cleanup loop, and break our multiple-of-16 doubles assumption.
        }

        const v4df *end = (v4df *)(aligned_array+ARRAY_SIZE);
        for (const v4df *p = (v4df *)start ; p+3 < end; p+=4) {
            sum0 += p[0];   // p+=4 increments the pointer by 4 * 4 * 8 bytes
            sum1 += p[1];       // make sure you keep track of what you're incrementing
            sum2 += p[2];
            sum3 += p[3];

        }

        // the compiler might be smart enough to pull this out of the inner loop
        // in fact, gcc turns this into a 64bit movabs outside of both loops :P
        help+= ARRAY_SIZE;

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

        /* You could argue legalese and say that
         if (i == 0) {
             for (j ...)
                 sum += array[j];
             sum *= N_TIMES;
         }
         * still does as many adds in its *INNER LOOP*, but it just doesn't run it as often
         */
    }

    // You can add some final code between this comment ...
    sum0 = (sum0 + sum1) + (sum2 + sum3);
    sum += sum0[0] + sum0[1] + sum0[2] + sum0[3];
    printf("sum = %g; help=%ld\n", sum, help);  // defeat the compiler.

    free (aligned_array);
    free (array);  // not strictly necessary, because this is the end of main().  Leaving it out for this special case is a bad example for a CS class, though.
    // ... and this one.

    return 0;
}

内部循环编译为:

  4007c0:       c5 e5 58 19             vaddpd (%rcx),%ymm3,%ymm3
  4007c4:       48 83 e9 80             sub    [=14=]xffffffffffffff80,%rcx   # subtract -128, because -128 fits in imm8 instead of requiring an imm32 to encode add 8, %rcx
  4007c8:       c5 f5 58 49 a0          vaddpd -0x60(%rcx),%ymm1,%ymm1   # one-register addressing mode can micro-fuse
  4007cd:       c5 ed 58 51 c0          vaddpd -0x40(%rcx),%ymm2,%ymm2
  4007d2:       c5 fd 58 41 e0          vaddpd -0x20(%rcx),%ymm0,%ymm0
  4007d7:       4c 39 c1                cmp    %r8,%rcx  # compare with end with p
  4007da:       75 e4                   jne    4007c0 <main+0xb0>

(有关更多信息,see online compiler output at the godbolt compiler explorer. The -xc compiler option compiles as C, not C++. The inner loop is from .L3 to jne .L3. See the tag wiki for x86 asm links. See also this q&a about micro-fusion not happening on SnB-family,Agner Fog 的指南未涵盖)。

性能:

$ perf stat -e task-clock,cycles,instructions,r1b1,r10e,stalled-cycles-frontend,stalled-cycles-backend,L1-dcache-load-misses,cache-misses ./fl3-vec 
CS201 - Asgmt 4 - I. Forgot
sum = 0; help=6000000000

 Performance counter stats for './fl3-vec':

       1086.571078      task-clock (msec)         #    1.000 CPUs utilized          
     4,072,679,849      cycles                    #    3.748 GHz                    
     2,629,419,883      instructions              #    0.65  insns per cycle        
                                                  #    1.27  stalled cycles per insn
     4,028,715,968      r1b1                      # 3707.733 M/sec  # unfused uops
     2,257,875,023      r10e                      # 2077.982 M/sec  # fused uops.  lower than insns because of macro-fusion
     3,328,275,626      stalled-cycles-frontend   #   81.72% frontend cycles idle   
     1,648,011,059      stalled-cycles-backend    #   40.47% backend  cycles idle   
       751,736,741      L1-dcache-load-misses     #  691.843 M/sec                  
            18,772      cache-misses              #    0.017 M/sec                  

       1.086925466 seconds time elapsed

我仍然不知道为什么每个周期的指令数如此之少。内部循环使用 4 个独立的累加器,我用 gdb 检查指针是否对齐。所以 cache-bank 冲突不应该是问题所在。 Sandybridge L2 缓存每个周期可以维持一个 32B 传输,这应该跟上每个周期一个 32B FP 矢量添加。

L1 的 32B 加载需要 2 个周期(直到 Haswell Intel 才使 32B 加载成为 single-cycle 操作)。但是,有 2 个加载端口,因此持续吞吐量为每个周期 32B(我们尚未达到)。

也许加载需要在使用之前进行流水线处理,以尽量减少加载停止时 ROB(re-order 缓冲区)被填满?但是性能计数器表明 L1 缓存命中率相当高,因此从 L2 到 L1 的硬件预取似乎正在完成它的工作。

每个周期 0.65 条指令只是使矢量 FP 加法器饱和的大约一半。这令人沮丧。甚至 IACA 也表示循环应该 运行 每次迭代 4 个周期。 (即使加载端口和端口 1(FP 加法器所在的位置)饱和):/

更新:我想 L2 带宽毕竟是问题。没有足够的 line-fill 缓冲区来保持足够的飞行中未命中以维持每个周期的峰值吞吐量。 L2 持续带宽小于 Intel SnB / Haswell / Skylake 上的峰值 CPUs.

另见 Single Threaded Memory Bandwidth on Sandy Bridge (Intel forum thread, with much discussion about what limits throughput, and how latency * max_concurrency is one possible bottleneck. See also the "Latency Bound Platforms" part of the answer to ; limited memory concurrency is a bottleneck for loads as well as stores, but for loads prefetch into L2 does mean you might not be limited purely by Line Fill buffers for outstanding L1D misses

将 ARRAY_SIZE 减少到 1008(16 的倍数),并将 N_TIMES 增加 10 倍,使 运行 时间减少到 0.5 秒。那是每个周期 1.68 insns。 (内部循环总共有 7 条指令用于 4 个 FP 加法,因此我们最终使向量 FP 加法单元和加载端口饱和。)循环平铺是一个更好的解决方案,见下文。

Intel CPU 的 L1 数据和 L1 指令缓存各只有 32k。我认为您的阵列几乎不适合 64kiB L1D on an AMD K10 (Istanbul) CPU, but not Bulldozer-family (16kiB L1D) 或 Ryzen (32kiB L1D)。

Gcc 通过将相同的值广播到并行加法中来进行矢量化的尝试似乎并不那么疯狂。如果它设法做到这一点(使用多个累加器来隐藏延迟),那将允许它仅用一半的内存带宽就可以使向量 FP 加法器饱和。 As-is,这几乎是一次洗礼,可能是因为广播的开销。

此外,这很愚蠢。 N_TIMES 只是一个 make-work 重复。我们实际上并不想针对多次执行相同的工作进行优化。除非我们想在这样愚蠢的任务中获胜。 source-level 方法是在允许修改的代码部分增加 i

for (...) {
    sum += a[j] + a[j] + a[j] + a[j];
}
i += 3;  // The inner loop does 4 total iterations of the outer loop

更现实地说,要处理这个问题,您可以交换循环(遍历数组一次,将每个值相加 N_TIMES 次)。我想我已经读过英特尔的编译器有时会为您做这件事。


一种更通用的技术称为缓存阻塞或循环平铺。这个想法是在适合缓存的小块中处理您的输入数据。根据您的算法,可以在一个块上执行不同阶段的操作,然后对下一个块重复,而不是让每个阶段在整个输入上循环。与往常一样,一旦您知道技巧的正确名称(并且它确实存在),您就可以 google 获取大量信息。

您可以 rules-lawyer 在允许修改的代码部分的 if (i == 0) 块中放置一个互换循环。它仍然会执行相同数量的加法,但顺序更多 cache-optimal。

首先,尝试更改编译器设置以生成更快的代码。有一般优化,编译器可能会进行自动矢量化。

您总是会尝试多种方法并检查哪种方法最快。作为目标,尝试每次添加达到一个循环或更好。

每个循环的迭代次数:您同时将 10 个和相加。可能是您的处理器没有足够的寄存器,或者它有更多。我会测量 4、5、6、7、8、9、10、11、12、13、14 的时间……每个循环的总和。

总和数:拥有多个总和意味着延迟不会影响您,影响的只是吞吐量。但是超过四六个可能没有帮助。尝试四个总和,每个循环有 4、8、12、16 次迭代。或者六个总和,迭代 6、12、18 次。

缓存:您正在 运行 通过一个 80,000 字节的数组。可能超过 L1 缓存。将数组拆分为 2 或 4 个部分。在两个或四个子数组上执行一个外循环,下一个循环从 0 到 N_TIMES - 1,然后内循环将值相加。

然后您可以尝试使用矢量运算,或对您的代码进行多线程处理,或使用 GPU 来完成工作。

如果您被迫不使用任何优化,那么 "register" 关键字可能确实有效。