【问题标题】:gcc -O0 outperforming -O3 on matrix sizes that are powers of 2 (matrix transpositions)gcc -O0 在矩阵大小为 2 的幂(矩阵转置)上优于 -O3
【发布时间】:2014-03-17 12:50:58
【问题描述】:

(出于测试目的)我写了一个简单的方法来计算 nxn 矩阵的转置

void transpose(const size_t _n, double* _A) {
    for(uint i=0; i < _n; ++i) {
        for(uint j=i+1; j < _n; ++j) {
            double tmp  = _A[i*_n+j];
            _A[i*_n+j] = _A[j*_n+i];
            _A[j*_n+i] = tmp;
        }
    }
}

当使用优化级别 O3 或 Ofast 时,我希望编译器展开一些循环,这将导致更高的性能,尤其是当矩阵大小是 2 的倍数时(即,每次迭代都可以执行双循环体)或类似情况。相反,我测量的结果恰恰相反。 2 的幂实际上显示了执行时间的显着峰值。

这些尖峰也以 64 为固定间隔,以 128 为间隔更明显,依此类推。每个尖峰都延伸到相邻的矩阵大小,如下表所示

size n  time(us)
1020    2649
1021    2815
1022    3100
1023    5428
1024    15791
1025    6778
1026    3106
1027    2847
1028    2660
1029    3038
1030    2613

我使用 gcc 版本 4.8.2 编译,但同样的事情发生在 clang 3.5 上,所以这可能是一些通用的事情?

所以我的问题基本上是:为什么执行时间会周期性增加?任何优化选项都带有一些通用的东西(就像clang和gcc一样)?如果是这样,哪个优化选项会导致这种情况?

这怎么会如此重要,以至于程序的 O0 版本甚至以 512 的倍数胜过 03 版本?


编辑:注意此(对数)图中尖峰的幅度。转置带优化的 1024x1024 矩阵实际上与转置 1300x1300 矩阵没有优化所花费的时间一样多。如果这是一个缓存错误/页面错误问题,那么有人需要向我解释为什么程序的优化版本的内存布局如此显着不同,它失败了 2 次方,只是为了恢复高性能稍大的矩阵。缓存错误不应该创建更多类似步骤的模式吗?为什么执行时间再次下降? (为什么优化应该创建以前不存在的缓存错误?)


编辑:以下应该是 gcc 生成的汇编代码

没有优化(O0):

_Z9transposemRPd:
.LFB0:
    .cfi_startproc
    push    rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    mov rbp, rsp
    .cfi_def_cfa_register 6
    mov QWORD PTR [rbp-24], rdi
    mov QWORD PTR [rbp-32], rsi
    mov DWORD PTR [rbp-4], 0
    jmp .L2
.L5:
    mov eax, DWORD PTR [rbp-4]
    add eax, 1
    mov DWORD PTR [rbp-8], eax
    jmp .L3
.L4:
    mov rax, QWORD PTR [rbp-32]
    mov rdx, QWORD PTR [rax]
    mov eax, DWORD PTR [rbp-4]
    imul    rax, QWORD PTR [rbp-24]
    mov rcx, rax
    mov eax, DWORD PTR [rbp-8]
    add rax, rcx
    sal rax, 3
    add rax, rdx
    mov rax, QWORD PTR [rax]
    mov QWORD PTR [rbp-16], rax
    mov rax, QWORD PTR [rbp-32]
    mov rdx, QWORD PTR [rax]
    mov eax, DWORD PTR [rbp-4]
    imul    rax, QWORD PTR [rbp-24]
    mov rcx, rax
    mov eax, DWORD PTR [rbp-8]
    add rax, rcx
    sal rax, 3
    add rdx, rax
    mov rax, QWORD PTR [rbp-32]
    mov rcx, QWORD PTR [rax]
    mov eax, DWORD PTR [rbp-8]
    imul    rax, QWORD PTR [rbp-24]
    mov rsi, rax
    mov eax, DWORD PTR [rbp-4]
    add rax, rsi
    sal rax, 3
    add rax, rcx
    mov rax, QWORD PTR [rax]
    mov QWORD PTR [rdx], rax
    mov rax, QWORD PTR [rbp-32]
    mov rdx, QWORD PTR [rax]
    mov eax, DWORD PTR [rbp-8]
    imul    rax, QWORD PTR [rbp-24]
    mov rcx, rax
    mov eax, DWORD PTR [rbp-4]
    add rax, rcx
    sal rax, 3
    add rdx, rax
    mov rax, QWORD PTR [rbp-16]
    mov QWORD PTR [rdx], rax
    add DWORD PTR [rbp-8], 1
.L3:
    mov eax, DWORD PTR [rbp-8]
    cmp rax, QWORD PTR [rbp-24]
    jb  .L4
    add DWORD PTR [rbp-4], 1
.L2:
    mov eax, DWORD PTR [rbp-4]
    cmp rax, QWORD PTR [rbp-24]
    jb  .L5
    pop rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size   _Z9transposemRPd, .-_Z9transposemRPd
    .ident  "GCC: (Debian 4.8.2-15) 4.8.2"
    .section    .note.GNU-stack,"",@progbits

优化 (O3)

_Z9transposemRPd:
.LFB0:
    .cfi_startproc
    push    rbx
    .cfi_def_cfa_offset 16
    .cfi_offset 3, -16
    xor r11d, r11d
    xor ebx, ebx
.L2:
    cmp r11, rdi
    mov r9, r11
    jae .L10
    .p2align 4,,10
    .p2align 3
.L7:
    add ebx, 1
    mov r11d, ebx
    cmp rdi, r11
    mov rax, r11
    jbe .L2
    mov r10, r9
    mov r8, QWORD PTR [rsi]
    mov edx, ebx
    imul    r10, rdi
    .p2align 4,,10
    .p2align 3
.L6:
    lea rcx, [rax+r10]
    add edx, 1
    imul    rax, rdi
    lea rcx, [r8+rcx*8]
    movsd   xmm0, QWORD PTR [rcx]
    add rax, r9
    lea rax, [r8+rax*8]
    movsd   xmm1, QWORD PTR [rax]
    movsd   QWORD PTR [rcx], xmm1
    movsd   QWORD PTR [rax], xmm0
    mov eax, edx
    cmp rdi, rax
    ja  .L6
    cmp r11, rdi
    mov r9, r11
    jb  .L7
.L10:
    pop rbx
    .cfi_def_cfa_offset 8
    ret
    .cfi_endproc
.LFE0:
    .size   _Z9transposemRPd, .-_Z9transposemRPd
    .ident  "GCC: (Debian 4.8.2-15) 4.8.2"
    .section    .note.GNU-stack,"",@progbits

【问题讨论】:

  • 该函数的大小是标称的,我非常好奇在每种情况下生成的 asm 代码是什么样的。如果您可以提供 as-was-tested 和 production 的具体 asm您的结果,它将为您的问题提供很好的补充。
  • @WhozCraig 添加了汇编代码。它们是从一个新的单独文件生成的,但具有相同的编译器调用,因此它们应该与我用于绘图的代码相同。
  • 如果用#pragma GCC ivdep 限定循环会发生什么?与其说是 SIMD 优化,不如说是告诉编译器每个元素转置都是独立的。
  • 您已将其标记为“c++”,那么为什么不将循环的内部部分替换为“std::swap”呢? 2 的幂可能是缓存或页面错误。有关矩阵转置的更优化方法,请参阅stackoverflow.com/a/16743203/257645
  • 二次方的超级对齐减速是众所周知的:stackoverflow.com/q/12264970/922184, stackoverflow.com/q/8547778/922184 我不确定它对这里有什么影响。也许没有优化的人正在以一种远离超级对齐的方式进行改进。

标签: c++ gcc linear-algebra compiler-optimization execution-time


【解决方案1】:

执行时间的周期性增加一定是由于缓存只是 N 路关联而不是完全关联。您正在目睹与缓存行选择算法相关的哈希冲突。

最快的 L1 缓存具有比下一级 L2 更少的缓存行数。在每个级别中,每个缓存行只能从一组有限的来源中填充。

高速缓存行选择算法的典型硬件实现将仅使用内存地址中的几个位来确定数据应写入哪个高速缓存槽——在硬件中位移位是免费的。

这会导致内存范围之间的竞争,例如地址 0x300010 和 0x341010 之间。 在完全顺序算法中,这无关紧要 - N 足够大,几乎可以满足以下形式的所有算法:

 for (i=0;i<1000;i++) a[i] += b[i] * c[i] + d[i];

但是当输入(或输出)的数量变大时(在优化算法时内部发生这种情况),缓存中的一个输入会迫使另一个输入从缓存中移出。

 // one possible method of optimization with 2 outputs and 6 inputs
 // with two unrelated execution paths -- should be faster, but maybe it isn't
 for (i=0;i<500;i++) { 
       a[i]     += b[i]     * c[i]     + d[i];
       a[i+500] += b[i+500] * c[i+500] + d[i+500];
 }

Example 5: Cache Associativity 中的图表说明了矩阵行之间的 512 字节偏移,这是特定系统的全局最坏情况维度。当知道这一点时,一个有效的缓解措施是将矩阵水平过度分配到某个其他维度char matrix[512][512 + 64]

【讨论】:

  • 对抗这种行为可以做的就是为数组分配超过必要数量的内存——特别是A[n][n+w],以强制后续行不相互竞争。或者,您可以将转换拆分为(通常)8x8 子矩阵的块。
  • 使用的源数量不会随着矩阵大小的增加而改变,所以我假设只有使用的简单哈希函数会导致冲突,从而过早地删除缓存数据。这些散列函数是否为公众所知,以便我可以解决它们?对于不同的 cpu(供应商),它们是否不同?也许我应该在另一个架构上检查相同的代码......
【解决方案2】:

性能的提升可能与 CPU/RAM 缓存有关。

当数据不是 2 的幂时,高速缓存行加载(如 16、32 或 64 个字)传输的数据量超过占用总线所需的数据量 — 结果证明是无用的。对于 2 的幂的数据集,使用所有预取的数据。

我敢打赌,如果您禁用 L1 和 L2 缓存,性能将完全流畅且可预测。但它会慢得多。缓存确实有助于提高性能!

【讨论】:

  • "对于 2 的幂的数据集,使用所有预取数据。" - 我认为二的幂更快的另一个原因。但相反,它们要慢得多!缓存错误也不会创建更多类似步骤的模式吗?为什么在这些邪恶力量之后性能再次提高?
【解决方案3】:

用代码注释:在 -O3 的情况下,用

#include <cstdlib>

extern void transpose(const size_t n, double* a)
{
    for (size_t i = 0; i < n; ++i) {
        for (size_t j = i + 1; j < n; ++j) {
            std::swap(a[i * n + j], a[j * n + i]); // or your expanded version.
        }
    }
}

编译

$ g++ --version
g++ (Ubuntu/Linaro 4.8.1-10ubuntu9) 4.8.1
...
$ g++ -g1 -std=c++11 -Wall -o test.S -S test.cpp -O3

我明白了

_Z9transposemPd:
.LFB68:
    .cfi_startproc
.LBB2:
    testq   %rdi, %rdi
    je  .L1
    leaq    8(,%rdi,8), %r10
    xorl    %r8d, %r8d
.LBB3:
    addq    $1, %r8
    leaq    -8(%r10), %rcx
    cmpq    %rdi, %r8
    leaq    (%rsi,%rcx), %r9
    je  .L1
    .p2align 4,,10
    .p2align 3
.L10:
    movq    %r9, %rdx
    movq    %r8, %rax
    .p2align 4,,10
    .p2align 3
.L5:
.LBB4:
    movsd   (%rdx), %xmm1
    movsd   (%rsi,%rax,8), %xmm0
    movsd   %xmm1, (%rsi,%rax,8)
.LBE4:
    addq    $1, %rax
.LBB5:
    movsd   %xmm0, (%rdx)
    addq    %rcx, %rdx
.LBE5:
    cmpq    %rdi, %rax
    jne .L5
    addq    $1, %r8
    addq    %r10, %r9
    addq    %rcx, %rsi
    cmpq    %rdi, %r8
    jne .L10
.L1:
    rep ret
.LBE3:
.LBE2:
    .cfi_endproc

如果我添加 -m32 会完全不同。

(注意:无论我使用 std::swap 还是您的变体,这对程序集都没有影响)

不过,为了了解导致峰值的原因,您可能希望可视化正在进行的内存操作。

【讨论】:

    【解决方案4】:

    要添加到其他人:g++ -std=c++11 -march=core2 -O3 -c -S - gcc 版本 4.8.2 (MacPorts gcc48 4.8.2_0) - x86_64-apple-darwin13.0.0 :

    __Z9transposemPd:
    LFB0:
            testq   %rdi, %rdi
            je      L1
            leaq    8(,%rdi,8), %r10
            xorl    %r8d, %r8d
            leaq    -8(%r10), %rcx
            addq    $1, %r8
            leaq    (%rsi,%rcx), %r9
            cmpq    %rdi, %r8
            je      L1
            .align 4,0x90
    L10:
            movq    %r9, %rdx
            movq    %r8, %rax
            .align 4,0x90
    L5:
            movsd   (%rdx), %xmm0
            movsd   (%rsi,%rax,8), %xmm1
            movsd   %xmm0, (%rsi,%rax,8)
            addq    $1, %rax
            movsd   %xmm1, (%rdx)
            addq    %rcx, %rdx
            cmpq    %rdi, %rax
            jne     L5
            addq    $1, %r8
            addq    %r10, %r9
            addq    %rcx, %rsi
            cmpq    %rdi, %r8
            jne     L10
    L1:
            rep; ret
    

    与@ksfone的代码基本相同,为:

    #include <cstddef>
    
    void transpose(const size_t _n, double* _A) {
        for(size_t i=0; i < _n; ++i) {
            for(size_t j=i+1; j < _n; ++j) {
                double tmp  = _A[i*_n+j];
                _A[i*_n+j] = _A[j*_n+i];
                _A[j*_n+i] = tmp;
            }
        }
    }
    

    除了 Mach-O 'as' 的差异(额外的下划线、对齐和 DWARF 位置)之外,它是相同的。但与 OP 的汇编输出有很大不同。一个“更紧密”的内循环。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2015-12-09
      • 2013-12-20
      • 2015-04-26
      • 2013-04-28
      • 2013-09-24
      • 2021-05-11
      • 1970-01-01
      相关资源
      最近更新 更多