【问题标题】:Optimise Simple Stencil Operation keeping variables into registers优化简单模板操作,将变量保存到寄存器中
【发布时间】:2019-02-23 17:05:24
【问题描述】:

我试图让下面的代码更快地将两个变量(我们需要重用的变量)保存在寄存器中或比缓存更近的任何位置。该代码将数组中位置idx 的三个相邻元素相加。

void stencil(double * input, double * output){

    unsigned int idx = 1;
    output[0] = input[0] + input[1];

    for(; idx < SIZE - 1; idx++){
        output[idx] = input[idx-1] + input[idx] + input[idx+1];
    }

    output[idx] = input[idx-1] + input[idx];
}

我的实现如下所示:

void stencil(double * input, double * output){

    unsigned int idx = 0;
    double x , y = 0, z;
    z = input[idx];

    for(; idx < SIZE - 1; idx++){
        x = y;
        y = z;
        z = input[idx + 1];
        output[idx] = x + y + z;
    }

    output[idx] = y + z;
}

想法是重用之前操作的变量,让程序更快。

但是,该程序在速度和性能方面似乎没有提高。我在AMD Opteron(tm) Processor 6320 CPU 上使用 gcc,并且正在使用以下标志编译代码:-march=native -O3 -Wall -std=c99

我尝试了使用和不使用本机,生成的程序集不同,但我无法获得更好的性能。生成的没有-march=native 标志的程序集如下所示:

stencil:
.LFB7:
        .cfi_startproc
        subl    $1, %edx
        movsd   (%rdi), %xmm1
        je      .L4
        movq    %rsi, %rcx
        xorpd   %xmm0, %xmm0
        xorl    %eax, %eax
        jmp     .L3
        .p2align 4,,10
        .p2align 3
.L6:
        movapd  %xmm1, %xmm0
        movapd  %xmm2, %xmm1
.L3:
        addl    $1, %eax
        addsd   %xmm1, %xmm0
        addq    $8, %rcx
        movl    %eax, %r8d
        movsd   (%rdi,%r8,8), %xmm2
        leaq    0(,%r8,8), %r9
        addsd   %xmm2, %xmm0
        movsd   %xmm0, -8(%rcx)
        cmpl    %edx, %eax
        jne     .L6
.L2:
        addsd   %xmm2, %xmm1
        movsd   %xmm1, (%rsi,%r9)
        ret
.L4:
        movapd  %xmm1, %xmm2
        xorl    %r9d, %r9d
        xorpd   %xmm1, %xmm1
        jmp     .L2

加上-march=native 标志看起来像这样

stencil:
.LFB20:
        .cfi_startproc
        vmovsd  (%rdi), %xmm1
        vxorpd  %xmm0, %xmm0, %xmm0
        leaq    144(%rdi), %rdx
        leaq    136(%rsi), %rax
        xorl    %ecx, %ecx
        .p2align 4,,10
        .p2align 3
.L2:
        vaddsd  %xmm1, %xmm0, %xmm0
        vmovsd  -136(%rdx), %xmm4
        prefetcht0      (%rdx)
        addl    $8, %ecx
        prefetchw       (%rax)
        addq    $64, %rdx
        addq    $64, %rax
        vaddsd  %xmm1, %xmm4, %xmm1
        vaddsd  %xmm4, %xmm0, %xmm0
        vmovsd  %xmm0, -200(%rax)
        vmovsd  -192(%rdx), %xmm3
        vaddsd  %xmm3, %xmm1, %xmm1
        vaddsd  %xmm3, %xmm4, %xmm4
        vmovsd  %xmm1, -192(%rax)
        vmovsd  -184(%rdx), %xmm2
        vaddsd  %xmm2, %xmm4, %xmm4
        vaddsd  %xmm2, %xmm3, %xmm3
        vmovsd  %xmm4, -184(%rax)
        vmovsd  %xmm4, -184(%rax)
        vmovsd  -176(%rdx), %xmm0
        vaddsd  %xmm0, %xmm3, %xmm3
        vaddsd  %xmm0, %xmm2, %xmm2
        vmovsd  %xmm3, -176(%rax)
        vmovsd  -168(%rdx), %xmm1
        vaddsd  %xmm1, %xmm2, %xmm2
        vaddsd  %xmm1, %xmm0, %xmm0
        vmovsd  %xmm2, -168(%rax)
        vmovsd  -160(%rdx), %xmm2
        vaddsd  %xmm2, %xmm0, %xmm0
        vaddsd  %xmm2, %xmm1, %xmm1
        vmovsd  %xmm0, -160(%rax)
        vmovsd  -152(%rdx), %xmm0
        vaddsd  %xmm0, %xmm1, %xmm1
        vaddsd  %xmm0, %xmm2, %xmm2
        vmovsd  %xmm1, -152(%rax)
        vmovsd  -144(%rdx), %xmm1
        vaddsd  %xmm1, %xmm2, %xmm2
        vmovsd  %xmm2, -144(%rax)
        cmpl    $1399999992, %ecx
        jne     .L2
        movabsq $11199999944, %rdx
        movabsq $11199999936, %rcx
        addq    %rdi, %rdx
        addq    %rsi, %rcx
        xorl    %eax, %eax
        jmp     .L3
        .p2align 4,,7
        .p2align 3
.L4:
        vmovaps %xmm2, %xmm1
.L3:
        vaddsd  %xmm0, %xmm1, %xmm0
        vmovsd  (%rdx,%rax), %xmm2
        vaddsd  %xmm2, %xmm0, %xmm0
        vmovsd  %xmm0, (%rcx,%rax)
        addq    $8, %rax
        vmovaps %xmm1, %xmm0
        cmpq    $56, %rax
        jne     .L4
        vaddsd  %xmm2, %xmm1, %xmm1
        movabsq $11199999992, %rax
        vmovsd  %xmm1, (%rsi,%rax)
        ret

有人对如何让 GCC 将变量保存到寄存器中以使代码更快有任何建议吗?或者任何其他方式让我的代码有效绕过缓存?

【问题讨论】:

  • @OliverCharlesworth 已编辑
  • 你试过restrict吗?
  • 限制 x、y 和 z?但它们应该是使用限制关键字的指针吗?我认为这会使整个事情变慢。 @OliverCharlesworth
  • 不,当然是double *restrict inputoutput,所以编译器知道输出不会与输入重叠,并且对output 的赋值不会修改input[idx-1]。除非调用者为输入和输出传递相同的指针,否则您的函数需要就地工作?但这似乎没有意义。
  • 对不起,这不是英特尔 CPU,我之前用过。我在大学集群上,cpu 是 AMD Opteron(tm) Processor 6320 。使用 GCC 编译器。我也会编辑这个问题。但是,如果可以帮助我进行优化,我也可以使用Intel(R) Xeon(R) CPU E5-2698 v4 @ 2.20GHz。 @PeterCordes

标签: c optimization x86 cpu-registers cpu-cache


【解决方案1】:

这是一个好主意,但如果编译器知道它是安全的,他们就会为您执行此操作。 使用 double *restrict outputconst double *restrict input 承诺存储到 output[] 的编译器不要' t 更改将从 input[] 读取的内容。

但使用 SIMD 进行自动矢量化是一项更重要的优化,每条指令会产生 2 或 4 个 double 结果。在检查重叠后,GCC 和 ICC 已经在-O3 上执行此操作。 (但 clang 无法自动矢量化它,只是使用标量 [v]addsd 展开以避免不必要的重新加载。

不幸的是,您的优化版本无法自动矢量化!(这是编译器的错误,即错过优化错误,当它知道输出不重叠时,因此从内存中重新读取源或not 是等价的)。


看起来 gcc 与 -O3 -march=native 的原始版本做得相当好(特别是在为 Intel 进行调整时,使用 AVX 更宽的向量是值得的。)我从 3 个未对齐的并行计算 4 个 double 结果加载和 2 个vaddpd ymm

它在使用矢量化循环之前检查重叠。您可以使用double *restrict outputinput 保证指针不会重叠,因此它不需要回退循环。


L1d 高速缓存带宽在现代 CPU 上非常出色;重新加载相同的数据不是什么大问题(每个时钟加载 2 次)。指令吞吐量是一个更大的问题。内存源addsd 的成本并不比将数据保存在寄存器中高多少。

如果使用 128 位向量进行向量化,则保留 in[idx+1..2] 向量以用作下次迭代的 in[idx+ -1..1] 向量是有意义的。 GCC 实际上就是这样做的。

但是,当您为每条指令生成 4 个结果时,一次迭代的 3 个输入向量中没有一个直接用于下一次迭代。不过,使用 shuffle 节省一些加载端口带宽以从加载结果创建 3 个向量之一可能会很有用。如果我使用__m256d 内在函数手动矢量化,我会尝试这样做。或者使用 float 和 128 位 __m128 向量。


#define SIZE 1000000

void stencil_restrict(double *restrict input, double *restrict output)
{
    int idx = 1;
    output[0] = input[0] + input[1];

    for(; idx < SIZE - 1; idx++){
        output[idx] = input[idx-1] + input[idx] + input[idx+1];
    }

    output[idx] = input[idx-1] + input[idx];
}

使用 gcc8.3 -O3 -Wall -std=c99 -march=broadwell -masm=intelfrom the Godbolt compiler explorer 编译到这个 asm(在这种情况下,-ffast-math 不是必需的,并且对内部循环没有影响。)

stencil_restrict:
    vmovsd  xmm0, QWORD PTR [rdi]
    vaddsd  xmm0, xmm0, QWORD PTR [rdi+8]
    xor     eax, eax
    vmovsd  QWORD PTR [rsi], xmm0           # first iteration

### Main loop
.L12:
    vmovupd ymm2, YMMWORD PTR [rdi+8+rax]         # idx +0 .. +3
    vaddpd  ymm0, ymm2, YMMWORD PTR [rdi+rax]     # idx -1 .. +2
    vaddpd  ymm0, ymm0, YMMWORD PTR [rdi+16+rax]  # idx +1 .. +4
    vmovupd YMMWORD PTR [rsi+8+rax], ymm0         # store idx +0 .. +3
    add     rax, 32                             # byte offset += 32
    cmp     rax, 7999968
    jne     .L12

  # cleanup of last few elements
    vmovsd  xmm1, QWORD PTR [rdi+7999976]
    vaddsd  xmm0, xmm1, QWORD PTR [rdi+7999968]
    vaddsd  xmm1, xmm1, QWORD PTR [rdi+7999984]
    vunpcklpd       xmm0, xmm0, xmm1
    vaddpd  xmm0, xmm0, XMMWORD PTR [rdi+7999984]
    vmovups XMMWORD PTR [rsi+7999976], xmm0
    vmovsd  xmm0, QWORD PTR [rdi+7999984]
    vaddsd  xmm0, xmm0, QWORD PTR [rdi+7999992]
    vmovsd  QWORD PTR [rsi+7999992], xmm0
    vzeroupper
    ret

不幸的是,gcc 正在使用索引寻址模式,因此带有内存源的 vaddpd 指令在 SnB 系列(包括您的 Broadwell Xeon E5-2698 v4)的前端被分解为 2 个微指令。 Micro fusion and addressing modes

    vmovupd ymm2, YMMWORD PTR [rdi+8+rax]         # 1 uop, no micro-fusion
    vaddpd  ymm0, ymm2, YMMWORD PTR [rdi+rax]     # 2 uops.  (micro-fused in decoders/uop cache, unlaminates)
    vaddpd  ymm0, ymm0, YMMWORD PTR [rdi+16+rax]  # 2 uops.  (ditto)
    vmovupd YMMWORD PTR [rsi+8+rax], ymm0         # 1 uop (stays micro-fused, but can't use the port 7 store AGU)
    add     rax, 32                             # 1 uop
    cmp     rax, 7999968                         # 0 uops, macro-fuses with JNE
    jne     .L12                                 # 1 uop

吞吐量分析,见https://agner.org/optimize/What considerations go into predicting latency for operations on modern superscalar processors and how can I calculate them by hand?

GCC 的循环是 8 个融合域微指令,用于前端问题/重命名阶段发送到乱序后端。这意味着前端的最大吞吐量是每 2 个周期 1 次迭代。

[v]addpd 在 Intel Skylake 之前只能在端口 1 上运行,而 [v]mulpd 或 FMA 的吞吐量是其两倍。 (Skylake 放弃了专用的 FP add 单元,并以与 mul 和 fma 相同的方式运行 FP add。)所以这也是每次迭代 2 个周期的瓶颈。

我们有 3 个负载 + 1 个存储,所有这些都需要端口 2 或 3 之一。(索引寻址模式存储不能使用端口 7 上的专用存储 AGU)。因此,每个迭代瓶颈又是 2 个周期。但不是真的; 未对齐 跨缓存线边界的负载更昂贵。实验表明,英特尔 Skylake(可能还有 Broadwell)重放被发现是缓存行拆分的负载微指令,因此它们再次运行以从第二个缓存行获取数据。 How can I accurately benchmark unaligned access speed on x86_64.

我们的数据是 8 字节对齐的,但我们会在 64 字节行内的所有 8 字节偏移上平均分配 32 字节加载。在这 8 个起始元素中的 5 个中,没有缓存行拆分。在其他 3 处,有。所以平均成本实际上是每次迭代调度的3 * (8+3)/8 = 4.125 load uops。我不知道 store-address uops 是否需要重播。可能不是;只是当数据从存储缓冲区提交到 L1d 时才重要,而不是存储地址或存储数据 uops。 (只要它不跨越 4k 边界,发生未对齐的输出)。

假设除output[1] 之外的任何输出对齐为 32 字节对齐。 asm 将output[0] 存储在循环之外,然后有效地执行output[i*4 + 1],因此所有其他存储都将是一个缓存行拆分。

在这种情况下,最好达到输出数组的对齐边界。 gcc7 和更早的版本喜欢将其中一个指针与循环序言对齐,但不幸的是,它们选择了我们从所有对齐方式加载的输入。

无论如何,GCC 的实际瓶颈是端口 2 / 端口 3 的吞吐量。 这 2 个端口的平均每次迭代 5.125 微指令 = 每 2.5625 1 次迭代的理论最大平均吞吐量(4 双倍)循环

使用非索引存储可以减少这个瓶颈。

但这忽略了 4k 拆分惩罚,在 Broadwell 上约为 100 个周期,并假设完美的硬件预取可以跟上每路(加载和存储)约 12.5 字节/周期的速度。 因此,除非数据在 L2 缓存中已经很热,否则这很可能会限制内存带宽。 L1d 可以吸收相同字节的冗余负载,但仍然存在大量非冗余带宽。


一点展开可以让乱序执行进一步向前看,并有助于在硬件预取跟不上时吸收缓存未命中的气泡。如果它对存储使用非索引寻址模式,它可以使用端口 7,从而减少端口 2/3 的压力。这会让负载跑在添加之前,希望在交叉时吸收气泡


128 位向量寄存器中的数据重用

来自gcc8.3 -O3 -Wall -std=c99 -march=broadwell -mno-avx的内循环

 # prologue to reach an alignment boundary somewhere?
.L12:
    movupd  xmm2, XMMWORD PTR [rdi+rax]
    movupd  xmm1, XMMWORD PTR [rdi+8+rax]
    addpd   xmm0, xmm2
    addpd   xmm0, xmm1
    movups  XMMWORD PTR [rsi+rax], xmm0
    add     rax, 16
    movapd  xmm0, xmm1                   # x = z
    cmp     rax, 7999992
    jne     .L12

这是与 gcc7.4 的回归,它避免了寄存器复制。 (但 gcc7 在与数组索引分开的计数器上浪费了循环开销。)

 # prologue to reach an alignment boundary so one load can be aligned.

# r10=input and r9=input+8  or something like that
# r8=output
.L18:                                       # do {
    movupd  xmm0, XMMWORD PTR [r10+rdx]
    add     ecx, 1
    addpd   xmm0, xmm1                        # x+y
    movapd  xmm1, XMMWORD PTR [r9+rdx]      # z for this iteration, x for next
    addpd   xmm0, xmm1                        # (x+y) + z
    movups  XMMWORD PTR [r8+rdx], xmm0
    add     rdx, 16
    cmp     ecx, r11d
    jb      .L18                            # } while(i < max);

平均而言,这仍然可能比 AVX 256 位向量慢。

使用 128 位向量的 AVX(例如,为 Piledriver 调整),它可以避免单独的 movupd xmm0 负载,并使用 vaddpd xmm0, xmm1, [r10+rdx]

它们都无法使用对齐存储,但也无法利用在input 中找到已知对齐方式后将负载折叠到addpd 的内存操作数中:/


Skylake 的实际性能实验表明,如果数据适合 L1d 缓存,实际性能与我的预测相当接近。

有趣的事实:使用像全局double in[SIZE+10]; 这样的静态缓冲区,gcc 将创建一个使用非索引寻址模式的循环版本。这使得在循环中多次运行它的速度从~800ms 提高到~700ms,SIZE=1000。稍后会更新更多细节。

【讨论】:

    【解决方案2】:

    使用寄存器轮换时,展开循环通常是个好主意。除非明确要求,否则 gcc 不会这样做。

    这是一个 4 级循环展开的示例。

    void stencil(double * input, double * output){
    
        double x, y, z, w, u, v ;
        x=0.0;
        y=input[0];
        int idx=0;
        for(; idx < SIZE - 5; idx+=4){
          z=input[idx+1];
          w=input[idx+2];
          u=input[idx+3];
          v=input[idx+4];
    
          output[idx]  =x+y+z;
          output[idx+1]=y+z+w;
          output[idx+2]=z+w+u;
          output[idx+3]=w+u+v;
    
          x=u;
          y=v;
        }
        z=input[idx+1];
        w=input[idx+2];
        u=input[idx+3];
    
        output[idx]  =x+y+z;
        output[idx+1]=y+z+w;
        output[idx+2]=z+w+u;
        output[idx+3]=w+u;
    }
    

    通过 idx 值读取和写入一个内存,每两个 idx 值有 1 个寄存器副本。

    可以尝试不同的展开级别,但每次迭代总是有 2 个寄存器副本,而 4 个似乎是一个很好的折衷方案。

    如果 size 不是 4 的倍数,则需要序言。

    void stencil(double * input, double * output){
    
        double x, y, z, w, u, v ;
        int idx=0;
        int remain=SIZE%4;
    
        x=0.0;y=input[0]
        switch (remain) {
        case 3: z=input[++idx]; output[idx-1]=x+y+z; x=y; y=z;
        case 2: z=input[++idx]; output[idx-1]=x+y+z; x=y; y=z;
        case 1: z=input[++idx]; output[idx-1]=x+y+z; x=y; y=z;
        }
    
        for(; idx < SIZE - 5; idx+=4){
          z=input[idx+1];
          ....
    

    正如预期的那样,asm 相当复杂,很难说会有什么收获。

    您也可以尝试在您的原始代码上使用-funroll-loops。编译器非常好,可能会提供更好的解决方案。

    【讨论】:

    • 看起来 OP 的编译器确实展开了。他们可能在 OS X 上,gcc 实际上是 clang/LLVM。
    • 使用 gcc7.4 godbolt.org/z/6Xudt3,这种手动重用会破坏自动矢量化。 asm 中没有 vaddpd 指令供您或 OP 重用,但 asm 中有一些 vaddpd 指令用于函数的普通版本(重叠未对齐的负载)。我使用了-xc -O3 -march=skylake -ffast-math -Wall -std=c99 -funroll-loops,但是没有-funroll-loops 仍然有自动矢量化。我仍然不确定 OP 实际上有哪个编译器;编译器生成的prefetchw 是不寻常的。也许 Apple clang 做到了。
    • 所以无论如何,你的答案在理论上是好的,并且没有自动矢量化或-funroll-loops,但在实践中要么手动矢量化,要么使用正确的编译器 + 选项来让普通版本自动矢量化看起来最好。或者,如果您不需要严格的 FP,则可能会将 y+z 用作下一个窗口的 x+y 重用。 (但不是一个完整的滑动窗口;这将创建一个循环携带的 add/sub 的 dep 链。只需在小的独立块中重用一两个添加。)
    • 我现在正在使用 gcc 尝试英特尔处理器,您对用于自动矢量化代码的选项有什么建议吗?或者我将如何手动矢量化代码? @PeterCordes
    • 我尝试根据 Peter Cordes 的建议手动改进代码生成。但是没有成功得到一个好的向量化。手动矢量化需要使用intrinsics,这有点复杂。但是使用 Peter Cordes 选项生成的代码很好。基本选项是 -O3 -ffast-math-funroll-loops -O3 是最高优化级别,-ffastmath 允许重新安排 fp 计算,-funroll-loops 执行自动循环展开。
    猜你喜欢
    • 1970-01-01
    • 2019-08-14
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多