c - 优化简单的模具操作,将变量保存在寄存器中

标签 c optimization x86 cpu-registers cpu-cache

我试图使下面的代码更快地将两个变量(我们需要重用的变量)保留在寄存器中或比高速缓存更近的任何位置。该代码在位置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

无论是否使用本机,我都尝试过,生成的程序集有所不同,但无法获得更好的性能。生成的程序集WITHOUT -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将变量保存到寄存器中以使代码更快提供任何建议?还是任何其他使我的代码有效绕过缓存的方法?

最佳答案

这是个好主意,但是如果编译器知道它的安全性,它们已经为您完成了。使用double *restrict outputconst double *restrict input保证存储到output[]的编译器不会更改将从input[]读取的内容。

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

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



看起来gcc在原始版本和-O3 -march=native上做得非常好(尤其是在针对Intel进行调优时,值得使用AVX的更宽的矢量。)我从3个未对齐的负载和2个并行计算4个double结果vaddpd ymm

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



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

如果使用128位向量进行向量化,则将in[idx+1..2]向量保留为下一次迭代用作in[idx+ -1..1]向量是有意义的。海湾合作委员会实际上就是这样做的。

但是,当每条指令产生4个结果时,一次迭代的3个输入向量都不会对下一次迭代直接有用。不过,通过改组节省一些加载端口带宽以从加载结果中创建3个向量之一可能会很有用。如果尝试使用__m256d内在函数进行向量化,我会尝试一下。或带有128位float向量的__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 oups的分层。 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次迭代。

Skylake之前的英特尔[v]addpd只能在端口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个负载。我不知道是否需要重播存储地址。可能不是;只是重要的是数据从存储缓冲区提交到L1d的时间,与存储地址或存储数据位无关。 (只要不跨越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个周期,并假设完美的HW预取能够以每种方式(加载和存储)保持约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(例如,对打桩机进行调优),可以避免单独的movupd xmm0加载并使用vaddpd xmm0, xmm1, [r10+rdx]

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



在Skylake上进行的实际性能实验表明,如果数据适合L1d缓存,则实际性能与我的预期相当接近。

有趣的事实:使用诸如全局double in[SIZE+10];之类的静态缓冲区,gcc会使用非索引寻址模式创建一个循环版本。这样可以使它多次循环运行,从〜800ms加速到〜700ms,SIZE = 1000。稍后将更新更多详细信息。

关于c - 优化简单的模具操作,将变量保存在寄存器中,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/54843964/

相关文章:

上下文切换会导致堆栈溢出吗?

c - C 中合法的数组赋值语句

c - 使用数据结构来处理内存的个人 malloc 函数

mysql - 如何优化我的查询?

c - 哪些嵌入式处理器最接近多核

.net - Linq 查询从该 XML 解析/获取内容 (WSDL)

python - 以下哪一个是余弦衰减(神经网络的学习率重新加权)的正确实现?

c# - 在 C# 中编写 [0..100] 的最佳方法是什么?

将 C 变量的内容复制到寄存器 (GCC)

c - 在汇编中实现返回最大数和最小数之差的 C 函数