如何有效地链接 avx2 内部函数以执行算术运算链?

How to chain avx2 intrinsics efficiently to perform chain of arithmetic operations?

提问人:Stef1611 提问时间:9/7/2023 更新时间:9/7/2023 访问量:117

问:

我写了一个大型程序来模拟分子系统。我在处理器是 Intel(R) Core(TM) i7-6700 CPU @ 3.40GHz 的台式计算机上运行它。大多数时间 (75%) 用于计算 4 个邻居的 Lennard Jones 电位。

为了优化它,我正在学习如何使用,我编写了一个简化版本,仅执行 LJ 计算:avx2 intrinsics(d_eq/d_cur)**12 - 2.0*(d_eq/d_cur)**6

这是没有内部函数的代码:

#include <stdio.h>
#include <stdlib.h>
#include <immintrin.h>

double lj_pot(double* d_cur) {
    const double d_eq = 1.0;
    double ratio, vpot1, eie;
        
    eie =0.0;
    for (int i=0; i<4; i++) {
        ratio = d_eq/d_cur[i] ;
        vpot1 = ratio*ratio*ratio ;
        vpot1 = vpot1*vpot1 ;
        eie += vpot1*(vpot1-2.0);
    }
    
    return eie;
}

int main()
{
    double d_cur[4];
    double eee;
    
    eee=0.0;
    
    for (int i=0; i<10000000; i++) {
        for (int j=0; j<4; j++) {
            d_cur[j] = 0.5+(double)(rand()) / (double)(RAND_MAX); // between 0.5 , 1.5
        }
        eee += lj_pot(d_cur);
    }
    printf("%f\n",eee);
    return 0;
}

这是 AVX2 内部函数的代码

#include <stdio.h>
#include <stdlib.h>
#include <immintrin.h>

static inline double hsum_double_avx(__m256d v) {
// From https://stackoverflow.com/a/49943540/7462275
    __m128d vlow  = _mm256_castpd256_pd128(v);
    __m128d vhigh = _mm256_extractf128_pd(v, 1); // high 128
            vlow  = _mm_add_pd(vlow, vhigh);     // reduce down to 128

    __m128d high64 = _mm_unpackhi_pd(vlow, vlow);
    return  _mm_cvtsd_f64(_mm_add_sd(vlow, high64));  // reduce to scalar
}

double lj_pot(double* d_cur) {
    
    
    const __m256d d_eq = _mm256_set1_pd(1.0);
    const __m256d two_dbl = _mm256_set1_pd(2.0);
    __m256d vec1, vec2;
        
    vec1 = _mm256_loadu_pd(d_cur);
    vec1 = _mm256_div_pd(d_eq,vec1);
    vec2 = _mm256_mul_pd(vec1,vec1); // (d_eq/d_cur)**2
    vec1 = _mm256_mul_pd(vec2,vec2); // (d_eq/d_cur)**4
    vec1 = _mm256_mul_pd(vec1,vec2); // (d_eq/d_cur)**6
    vec2 = _mm256_sub_pd(vec1,two_dbl); // (d_eq/d_cur)**6 - 2.0
    vec1 = _mm256_mul_pd(vec1,vec2); // (d_eq/d_cur)**12 -2.0*(d_eq/d_cur)**6

    return hsum_double_avx(vec1);
}

int main()
{
    double d_cur[4];
    double eee;
    
    eee=0.0;
    

    
    for (int i=0; i<10000000; i++) {
        for (int j=0; j<4; j++) {
            d_cur[j] = 0.5+(double)(rand()) / (double)(RAND_MAX); // between 0.5 , 1.5
        }
        eee += lj_pot(d_cur);
    }
    printf("%f\n",eee);
    return 0;
}

此代码输出的结果与第一个代码相同。但它的速度较慢。 ( 用于编译这两个程序。gcc -O3 -mavx -o lj_pot lj_pot.c

当我检查汇编代码时,我想(也许这不是很好的理由)我没有正确使用 AVX2 内部函数来执行算术运算链。我必须更改什么才能更快地拥有程序?

谢谢你的回答

GCC 优化 矢量化 内部函数 AVX2

评论


答:

3赞 John Zwinck 9/7/2023 #1

GCC 会自动将 SIMD 用于原始代码。你的手动优化只是抑制了编译器的优化。使用以下命令查看生成的程序集-O3 -march=skylake -funsafe-math-optimizations

lj_pot(double*):
    vbroadcastsd    ymm1, QWORD PTR .LC1[rip]
    vdivpd  ymm1, ymm1, YMMWORD PTR [rdi]
    vmulpd  ymm0, ymm1, ymm1
    vmulpd  ymm0, ymm0, ymm1
    vbroadcastsd    ymm1, QWORD PTR .LC3[rip]
    vmulpd  ymm0, ymm0, ymm0
    vaddpd  ymm1, ymm0, ymm1
    vmulpd  ymm0, ymm1, ymm0
    vextractf128    xmm1, ymm0, 0x1
    vaddpd  xmm1, xmm1, xmm0
    vunpckhpd       xmm0, xmm1, xmm1
    vaddpd  xmm0, xmm0, xmm1
    vzeroupper
    ret

如果你想优化这一点,首先要看看编译器在做什么。这很聪明。

评论

0赞 Jérôme Richard 9/7/2023
你知道为什么编译器在这里不使用 FMA 指令吗?AFAIK,它们在 Skylake 上可用,并且比单独执行 mul+add 更快。
1赞 njuffa 9/7/2023
@JérômeRichard 这很奇怪。直接编码的另一个原因。_mm256_fmadd_ps
1赞 Peter Cordes 9/7/2023
@njuffa 和 Jérôme:请注意,此功能需要每个产品,因此与 FMA 签约不会节省总指令;为了馈送 的最终值,它需要一个 FMA 和一个 mul(平行用于平方 vs.),而不是 mul 然后添加。因此,这将是延迟的胜利,但功耗会更高一些,并且假设 OoO exec 可以隐藏延迟,那么 Skylake 上的吞吐量也不会更好。(累加到是一个水平和,除非它提前提取了两个操作数并做了 128 位 mul + FMA?但这需要 2 个提取物)。mulvpot1*(vpot1-2.0)vpot1*vpot1vpot1*vpot1 -2eie +=...
1赞 Peter Cordes 9/7/2023
Zen 3 和 4 上的吞吐量会更糟,它们在端口 FP0,1 上有 2 个 mul/FMA 单元,在端口 FP2,3 上有 2 个 FP 添加单元。(uops.info)。如果成本模型不是专门针对 SKL 与 Zen3 的,这实际上是吞吐量(和功率,因此是涡轮频率)的正确决策。
2赞 John Zwinck 9/9/2023
@Stef1611:SIMD 内部函数在您需要时是很好的,但您应该始终首先检查编译器可以自动为您做什么。至于“直接在汇编中编写代码”,我不建议这样做。我认为你是在“低级代码很快”的假设下操作的,这是不正确的。
2赞 harold 9/7/2023 #2

重要的是要注意,我认为(至少我编译源代码的方式),独立副本是自动矢量化的,而不是内联副本,后者是实际运行的副本。独立副本不是从 调用的。因此,自动矢量化在这里并没有真正为您做任何事情,除非您获得的汇编代码与我编译您的程序时发生的情况有质的不同。lj_potmain

关于矢量化代码,我注意到了两件不理想的事情:

  • 随机数生成仍然使用标量完成,标量本身已经是一个值得注意的项目(尽管它本身不是回归),并且还意味着向量必须由标量组装。计算一些标量,然后将它们组合成一个向量通常是要避免的。将标量存储到内存中,然后执行向量加载(这是在代码中发生的情况)具有显着的延迟损失(这在这里可能不应该很重要,因为该延迟不在循环携带的依赖链中),并且使用shuffles/inserts/etc在不通过内存的情况下构造向量(这通常是由于滥用具有非常量参数的宏所致)会花费大量指令。_mm(256)_set_***
  • 水平加法在该循环中执行 10000000 次,而不是将向量相加并在循环后执行一次水平加法。

随着这两个项目的改进,因此在主循环之后生成矢量化随机数和水平求和,导致循环中基本上只有向量算术(好吧,并增加循环计数器),我希望代码应该变得比原来更快。

评论

0赞 Stef1611 9/8/2023
非常感谢您的回答。10000000 时间循环只是为了有一个我可以用“时间”来衡量的执行时间。我使用随机数生成在每次函数调用时具有不同的值。否则,使用常量值和 O3 优化时,该函数仅调用一次。我还在约翰的回答中添加了一条评论。
0赞 harold 9/8/2023
@Stef1611好吧,这变化很大。令人讨厌的是,这个程序最终成为函数可能取决于调用它的上下文的时间示例(影响矢量化代码的矢量加载延迟,影响它是否在依赖自动矢量化时被矢量化)。因此,如何编写一个时序测试工具,以准确反映函数的实际执行方式,取决于它的实际使用方式。这也会影响您是否应该测量延迟或吞吐量,这两者在此规模下通常非常不同。
0赞 Stef1611 9/8/2023
也许,我在介绍我的问题时不够清楚。我最初的目标是优化另一个程序来检测它。当我寻找优化它的解决方案时,在阅读了很多东西之后,我决定同时进行所有 4 个计算,我使用了内部函数,因为我发现它们使用起来非常简单。我对执行时间的差异感到非常惊讶。所以,我问了这个问题。(a large one). In this program, 75% of the time is used to calculate a LJ potential on 4 neigbourgs. I used
0赞 Stef1611 9/8/2023
你在约翰的回答中看到我的评论了吗?...即使由于我的组装技能低下,我不太了解一切。我是否应该得出结论,使用 C 内部函数不是执行算术运算链的好主意?最好的办法是测试编译选项或直接在汇编程序的关键部分时编写代码?
0赞 harold 9/9/2023
@Stef1611它们很好用。比依赖自动矢量化更安全,因为自动矢量化是不可靠的,因为这个测试也意外地表明了这一点。可能比原始汇编更容易使用(但这是有争议的),但调用原始汇编函数也会阻止某些编译器优化(函数不会内联,没有常量传播等)。当然,性能取决于代码到底是什么,所以没有一些大的一般性声明可以做。