【问题标题】:Intel assembly vs Intrinsics, AVX英特尔组装与内部,AVX
【发布时间】:2020-03-21 07:22:44
【问题描述】:

我有一个简单的向量-向量加法算法 (c = a + b * lambda),使用 AVX 指令用 intel 汇编语言编写。 这是我的代码:

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Dense to dense
;; Uses cache
;; AVX
;; Without tolerances
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

global _denseToDenseAddAVX_cache_64_linux
_denseToDenseAddAVX_cache_64_linux:

push    rbp
mov     rbp, rsp

; rdi: address1
; rsi: address2
; rdx: address3
; rcx: count
; xmm0: lambda

mov     rax, rcx
shr     rcx, 3
and     rax, 0x07

vzeroupper

vmovupd  ymm5, [abs_mask]

sub     rsp, 8
vmovlpd  [rbp - 8], xmm0
vbroadcastsd    ymm7, [rbp - 8]
vmovapd     ymm6, ymm7

cmp     rcx, 0
je      after_loop_denseToDenseAddAVX_cache_64_linux

start_denseToDenseAddAVX_cache_64_linux:

vmovapd  ymm0, [rdi] ; a
vmovapd  ymm1, ymm7
vmulpd   ymm1, [rsi] ; b
vaddpd   ymm0, ymm1  ; ymm0 = c = a + b * lambda
vmovapd  [rdx], ymm0

vmovapd  ymm2, [rdi + 32] ; a
vmovapd  ymm3, ymm6
vmulpd   ymm3, [rsi + 32] ; b
vaddpd   ymm2, ymm3  ; ymm2 = c = a + b * lambda
vmovapd  [rdx + 32], ymm2

add     rdi, 64
add     rsi, 64
add     rdx, 64

loop    start_denseToDenseAddAVX_cache_64_linux

after_loop_denseToDenseAddAVX_cache_64_linux:

cmp     rax, 0
je      end_denseToDenseAddAVX_cache_64_linux

mov     rcx, rax

last_loop_denseToDenseAddAVX_cache_64_linux:

vmovlpd  xmm0, [rdi] ; a
vmovapd  xmm1, xmm7
vmulsd   xmm1, [rsi] ; b
vaddsd   xmm0, xmm1  ; xmm0 = c = a + b * lambda
vmovlpd  [rdx], xmm0

add     rdi, 8
add     rsi, 8
add     rdx, 8

loop    last_loop_denseToDenseAddAVX_cache_64_linux

end_denseToDenseAddAVX_cache_64_linux:

mov     rsp, rbp
pop     rbp
ret

人们经常建议我使用 intel 内部函数,因为它更好、更安全。现在我已经实现了这个算法:

void denseToDenseAddAVX_cache(const double * __restrict__ a, 
                              const double * __restrict__ b, 
                              double * __restrict__ c, 
                              size_t count, double lambda) {
    const size_t firstCount = count / 8;
    const size_t rem1 = count % 8;
    int i;
    __m256d mul = _mm256_broadcast_sd(&lambda);
    for (i = 0; i < firstCount; i++) {
        // c = a + b * lambda
        __m256d dataA1 = _mm256_load_pd(&a[i * 8]);
        __m256d dataC1 = _mm256_add_pd(dataA1, _mm256_mul_pd(_mm256_load_pd(&b[i * 8]), mul  ));
        _mm256_store_pd(&c[i * 8], dataC1);

        __m256d dataA2 = _mm256_load_pd(&a[i * 8 + 4]);
        __m256d dataC2 = _mm256_add_pd(dataA2, _mm256_mul_pd(_mm256_load_pd(&b[i * 8 + 4]), mul  ));
        _mm256_store_pd(&c[i * 8 + 4], dataC2);
    }
    const size_t secondCount = rem1 / 4;
    const size_t rem2 = rem1 % 4;
    if (secondCount) {
        __m256d dataA = _mm256_load_pd(&a[i * 8]);
        __m256d dataC = _mm256_add_pd(dataA, _mm256_mul_pd(_mm256_load_pd(&b[i * 8]), mul  ));
        _mm256_store_pd(&c[i * 8], dataC);
        i += 4;
    }
    for (; i < count; i++) {
        c[i] = a[i] + b[i] * lambda;
    }
}

我的问题是汇编版本比第二个快两倍。 c++版本有什么问题?

【问题讨论】:

  • 至于任何与性能相关的问题:您是否正在优化 (-O3) 您的 C++ 代码?
  • 您在测试哪个编译器、什么选项以及什么硬件?我假设与同一个来电者?如果两个调用者都在同一个测试程序中,您是否首先进行了不定时的预热运行以消除页面错误,并使 CPU 达到最大 turbo?
  • 如果您的编译器无法击败这个 asm,您可能忘记启用优化或测试错误。不需要vmovapd ymm1, ymm7,使用像vmulpd ymm1, ymm7, [rsi] 这样的三操作数AVX 指令。另外,您使用慢速英特尔loop 指令,每 7 个时钟周期 1 次迭代(2 个向量)使该循环成为瓶颈。 agner.org/optimize。我认为即使编译器没有展开,并使用索引寻址模式来击败微融合和英特尔上的端口 7 存储 AGU,它仍然至少和这个一样好。像 GCC 一样:godbolt.org/z/MHgtfa
  • 顺便说一句,您可以通过使用未对齐的向量加载可能与您已经加载的数据重叠的最后最多 8 个元素来处理不均匀的计数。 (在数组末尾结束)。至少如果您的输入大小已知为 >=4 个元素。
  • 另外,不要使用vmovlpd 作为负载,除非您想要合并到现有向量的低元素中。您希望vmovsd 避免错误的依赖和额外的 ALU uop。

标签: c++ performance compiler-optimization intrinsics avx


【解决方案1】:

一些事情。

  1. 我认为这是最重要的一个。汇编代码使用指针算法。您的 C++ 代码没有,您首先计算索引,然后获取地址。编译器经常针对指针数学进行优化,但这并不可靠,您最好在 C++ 中使用相同的指针数学。更糟糕的是,像 &a[i * 8 + 4] 这样的东西需要多个整数指令。以字节为单位的结果是 a+i*64+32,而 x86 指令只能将整数免费缩放 2、4 或 8 倍。因此编译器必须发出左移然后加法来计算地址。这个问题使循环体中的指令数量增加了一倍。

  2. C++ 使用带符号的 32 位整数作为循环计数器,汇编代码使用无符号的 64 位整数。对于性能关键的代码,在 C++ 中使用 size_t 作为循环计数器通常是个好主意。顺便说一句,如果您在 C++ 编译器中设置了“警告为错误”设置,它会拒绝编译,并说诸如“有符号/无符号不匹配”之类的内容。

  3. C++ 中有冗余负载。 CPU可以用一条指令做数学+一个负载。与汇编一样,不要使用_mm256_load_pd,将指针从const double * 转换为const __m256d*

这里是稍微简化的例子:

void denseToDenseAddAVX( const double *a, const double *b, double *c, size_t count, double lambda )
{
    assert( 0 == (size_t)( a ) % 32 );
    assert( 0 == (size_t)( b ) % 32 );
    assert( 0 == (size_t)( c ) % 32 );

    const double* const aEnd = a + count;
    const double* const aEndAligned = a + ( ( count / 4 ) * 4 );
    const __m256d mul = _mm256_set1_pd( lambda );
    while( a < aEndAligned )
    {
        const __m256d* const av = ( const __m256d* )a;
        const __m256d* const bv = ( const __m256d* )b;
        const __m256d cv = _mm256_add_pd( *av, _mm256_mul_pd( *bv, mul ) );
        _mm256_store_pd( c, cv );
        a += 4;
        b += 4;
        c += 4;
    }
    while( a < aEnd )
    {
        *c = ( *a ) + ( *b ) * lambda;
        a++;
        b++;
        c++;
    }
}

【讨论】:

  • _mm256_load_pd 的优化与取消引用 __m256d* 相同(至少对于 GCC 和 clang)。内在函数不是 asm; _mm_load 内在函数可以折叠成内存源操作数。回复:您关于没有很好地优化的其他说法:您基于一些完全未经验证的关于编译器将变得多么愚蠢的假设,对诸如“两倍指令”之类的事情非常具体。 godbolt.org/z/z8t8Vg 显示了 OP 声称他们运行的内容 (g++4.9.2 -O3)。是的,索引寻址模式使它们不分层,但字节偏移量不是脑死的重新缩放。
  • 所有负载实际上都折叠到 ALU 指令的内存操作数中。 (但就像我说的那样,使用索引寻址模式,破坏了 uop 问题吞吐量的好处,只在 OP 的 SnB 系列 CPU 的前端有所帮助。)
  • 顺便说一句,您对 OP 清理循环的更改导致清理循环 auto-vectorizing 具有 256 位向量!!! godbolt.org/z/FFRceU 当然,这完全是脑残;它只运行不到 32 个字节。 (因为您删除了 OP 的手动展开,破坏了指针增量而不是索引寻址模式的大部分好处。尽管您确实实现了您设定的目标。)其中一些可能有助于 clang(默认情况下展开) ,或使用 GCC PGO 启用 -funroll-loops
猜你喜欢
  • 2013-10-21
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2015-02-24
  • 2011-02-12
  • 2018-10-10
  • 2018-08-03
相关资源
最近更新 更多