如何提高跟随循环的性能

Posted

技术标签:

【中文标题】如何提高跟随循环的性能【英文标题】:How to improve performance of following loop 【发布时间】:2013-08-14 10:17:48 【问题描述】:

我在C 中有一个简单的循环,我将magnitudeangle 转换为realimaginary 部分。我有两个版本的循环作为。 Version 1 是一个简单的 for 循环,我使用以下代码执行转换

for(k = 0; k < n; k++)
    xReal[k] = Mag[k] * cos(Angle[k]);
    xImag[k] = Mag[k] * sin(Angle[k]);

Version 2,其中Intrinsics 用于向量化循环。

__m256d cosVec, sinVec;
__m256d resultReal, resultImag;
__m256d angVec, voltVec;
for(k = 0; k < SysData->totNumOfBus; k+=4)

    voltVec = _mm256_loadu_pd(volt + k);
    angVec = _mm256_loadu_pd(theta + k);

    sinVec = _mm256_sincos_pd(&cosVec, angVec);

    resultImag = _mm256_mul_pd(voltVec, sinVec);
    resultReal = _mm256_mul_pd(voltVec, cosVec);

    _mm256_store_pd(xReal+k, resultReal);
    _mm256_store_pd(xImag+k, resultImag);


Core i7 2600k @3.4GHz 处理器上,这些循环给出以下结果:

Version 1: n = 18562320, Time: 0.2sec
Version 2: n = 18562320, Time: 0.16sec

使用这些值进行的简单计算表明,在version 1 中,每次迭代几乎需要36 个周期才能完成,而Version 2 需要117 个周期才能完成。考虑到 sinecosine 函数的计算自然是昂贵的,这些数字似乎并不可怕。然而,这个循环是我函数的一个严重瓶颈,因为分析表明几乎1/3 的时间都花在了循环内。所以,我想知道是否有任何方法可以加快这个循环(例如,以不同的方式计算 sinecosine 函数)。如果能帮助我解决这个问题并让我知道是否有改进此循环的性能的空间,我们将不胜感激。

提前感谢您的帮助

PS:我正在使用icc 编译代码。另外,我应该提到数据没有对齐(也不能对齐)。但是,对齐数据只会带来很小的性能提升(不到 1%)。

【问题讨论】:

您需要多准确的结果?如果您愿意接受一定程度的错误,您可以用查找表替换 sin 和 cos。这是加速三角函数的最常见(也是老派)方法之一。 看看这个问题Fast Sin/Cos using a pre computed translation array 如果您想以速度换取精度,请告知所需的精度。还有,Angle[k]的类型是什么? 您在使用-O3 吗?你也可以检查你的标量循环生成的代码,看看编译器是否在做一些自动向量化? 您可能在版本 2 中具有携带循环依赖项。尝试展开循环 【参考方案1】:

我建议制作基于 tayler 系列的 sin/cos 函数和 _mm256_stream_pd() 来存储数据。这是基本示例代码。

    __m256d sin_req[10];
    __m256d cos_req[10];
    __m256d one_pd =  _mm256_set1_pd(1.0);

    for(int i=0; i<10; ++i)
    
        sin_req[i] = i%2 == 0 ? _mm256_set1_pd(-1.0/Factorial((i+1)*2+1) ) : _mm256_set1_pd(+1.0/Factorial((i+1)*2+1) );
        cos_req[i] = i%2 == 0 ? _mm256_set1_pd(-1.0/Factorial((i+1)*2+0) ) : _mm256_set1_pd(+1.0/Factorial((i+1)*2+0) );
    

    for(int i=0; i<count; i+=4)
    
            __m256d voltVec = _mm256_load_pd(volt + i);
            __m256d angVec = _mm256_load_pd(theta + i);

            // sin/cos by taylor series
            __m256d angleSq = angVec * angVec;
            __m256d sinVec = angVec;
            __m256d cosVec = one_pd;
            __m256d sin_serise = sinVec;
            __m256d cos_serise = one_pd;
            for(int j=0; j<10; ++j)
            
                sin_serise = sin_serise * angleSq; // [1]
                cos_serise = cos_serise * angleSq;
                sinVec = sinVec + sin_serise * sin_req[j];
                cosVec = cosVec + cos_serise * cos_req[j];
            

            __m256d resultReal = voltVec * sinVec;
            __m256d resultImag = voltVec * cosVec;

            _mm256_store_pd(xReal + i, resultReal);
            _mm256_store_pd(xImag + i, resultImag );
    

我可以获得 57~58 个 CPU 周期来计算 4 个组件。

我搜索了谷歌并运行了一些测试来确定我的 sin/cos 的准确性。有些文章说 10 次迭代是双精度精确的,而 -M_PI/2

不过,我会更深入地优化此代码。此代码有延迟问题计算 tayor 系列。 AVX 的乘法延迟是 5 个 CPU 周期,这意味着我们不能以比 5 个周期更快的速度运行一次迭代,因为 [1] 使用的是前一次迭代的结果。

我们可以像这样简单地展开它。

    for(int i=0; i<count; i+=8)
    
        __m256d voltVec0 = _mm256_load_pd(volt + i + 0);
        __m256d voltVec1 = _mm256_load_pd(volt + i + 4);
        __m256d angVec0  = _mm256_load_pd(theta + i + 0);
        __m256d angVec1  = _mm256_load_pd(theta + i + 4);
        __m256d sinVec0;
        __m256d sinVec1;
        __m256d cosVec0;
        __m256d cosVec1;

        __m256d angleSq0 = angVec0 * angVec0;
        __m256d angleSq1 = angVec1 * angVec1;
        sinVec0 = angVec0;
        sinVec1 = angVec1;
        cosVec0 = one_pd;
        cosVec1 = one_pd;
        __m256d sin_serise0 = sinVec0;
        __m256d sin_serise1 = sinVec1;
        __m256d cos_serise0 = one_pd;
        __m256d cos_serise1 = one_pd;

        for(int j=0; j<10; ++j)
        
            sin_serise0 = sin_serise0 * angleSq0;
            cos_serise0 = cos_serise0 * angleSq0;
            sin_serise1 = sin_serise1 * angleSq1;
            cos_serise1 = cos_serise1 * angleSq1;
            sinVec0 = sinVec0 + sin_serise0 * sin_req[j];
            cosVec0 = cosVec0 + cos_serise0 * cos_req[j];
            sinVec1 = sinVec1 + sin_serise1 * sin_req[j];
            cosVec1 = cosVec1 + cos_serise1 * cos_req[j];
        

        __m256d realResult0 = voltVec0 * sinVec0;
        __m256d imagResult0 = voltVec0 * cosVec0;
        __m256d realResult1 = voltVec1 * sinVec1;
        __m256d imagResult1 = voltVec1 * cosVec1;

        _mm256_store_pd(xReal + i + 0, realResult0);
        _mm256_store_pd(xImag + i + 0, imagResult0);
        _mm256_store_pd(xReal + i + 4, realResult1);
        _mm256_store_pd(xImag + i + 4, imagResult1);
    

这个结果 51~51.5 个周期为 4 个分量计算。 (102~103 循环为 8 个组件)

它消除了泰勒计算循环中的乘法延迟,并使用了 85% 的 AVX 乘法单元。展开将解决许多延迟问题,但它不会将寄存器交换到内存。编译时生成 asm 文件,看看你的编译器如何处理你的代码。我尝试展开更多,但结果很糟糕,因为它无法容纳 16 个 AVX 寄存器。

现在我们使用内存优化。将 _mm256_store_ps() 替换为 _mm256_stream_ps()。

    _mm256_stream_pd(xReal + i + 0, realResult0);
    _mm256_stream_pd(xImag + i + 0, imagResult0);
    _mm256_stream_pd(xReal + i + 4, realResult1);
    _mm256_stream_pd(xImag + i + 4, imagResult1);

替换内存写入代码结果 48 个周期进行 4 个分量计算。

_mm256_stream_pd() 如果您不打算读回它,它总是更快。它跳过缓存系统并将数据直接发送到内存控制器,并且不会污染您的缓存。通过使用 _mm256_stream_pd(),您将获得更多的数据总线/缓存空间来读取数据。

让我们尝试预取。

    for(int i=0; i<count; i+=8)
    
    _mm_prefetch((const CHAR *)(volt + i + 5 * 8), _MM_HINT_T0);
    _mm_prefetch((const CHAR *)(theta + i + 5 * 8), _MM_HINT_T0);

            // calculations here.
    

现在我每次计算得到 45.6~45.8 个 CPU 周期。 94% 的 AVX 乘法单元繁忙。

Prefech 提示缓存以加快读取速度。我建议根据物理内存的 RAS-CAS 延迟在 400~500 个 CPU 周期之前进行预取。在最坏的情况下,物理内存延迟最多可能需要 300 个周期。可能因硬件配置而异,即使使用昂贵的低 RAS-CAS 延迟内存也不会小于 200 个周期。

0.064 秒(计数 = 18562320)

sin/cos 优化结束。 :-)

【讨论】:

【参考方案2】:

请检查:

    数组的起始地址是否对齐到16byte。 i7 支持高延迟未对齐的 avx 加载而不会抱怨“总线错误”

    请使用分析工具检查缓存命中率和未命中率。看来内存访问是循环版本 2 的瓶颈

    您可以降低精度,或使用结果表进行 sin 和 cos 计算。

    请考虑您计划实现多少性能改进。由于循环的版本 1 只占用总运行时间的 1/3。如果将循环优化为零,性能仅提升 30%

【讨论】:

特别注意#4。根据该代码运行的时间有一个上限。 即使你什么都不做,只返回,你也只会看到 30% 的提升。不过,你在这里做的是实际工作——而且从表面上看已经相当有效——所以你将被进一步限制。【参考方案3】:

您列出的计时结果显示,与版本 1 相比,版本 2 的运行速度更快(提高了 20%)。

Version 1: n = 18562320, Time: 0.2sec
Version 2: n = 18562320, Time: 0.16sec

不确定您如何计算每个版本中使用的周期?处理器中正在进行大量工作,并且缓存提取可能会导致时间差异,即使 v1 使用的周期更少(同样不知道您如何计算周期)。

或者另一种解释方式是,通过矢量化,数据元素无需等待内存提取即可使用。

【讨论】:

v1 没有使用更少的周期。循环步骤是4 中的v2 (k+=4)1 中的v1 (k++)。所以,v2 确实更快。处理器运行在3.4GHz', so: number of cycles for each iteration is: (.2*3.4GHz)/18562320 = 36. For version 2: (.16*3.4GHz)/(18562320/4) = 117` @Pouya,你为什么要将 v2 乘以 4? @JackCCeman 因为迭代次数是 n/4。请注意,循环步骤是 4 而不是 1。 @Pouya,那么您的意思是向量化代码一次对 4 个数值进行操作?如果是这种情况,那么一个机器周期同时对四个数据元素运行一项操作!因此矢量化的效率很高,这就解释了为什么 v2 运行得更快。 是的,_mm256_sincos_pd 能够同时处理四个double 数字,就像_mm256_mul_pd 使用256-bit 寄存器执行乘法一样。我检查了version 1 的汇编代码,似乎编译器选择了XMM 寄存器,它们是128-bit 寄存器。

以上是关于如何提高跟随循环的性能的主要内容,如果未能解决你的问题,请参考以下文章

原创辟谣,实测MyBatisPlus批量新增/更新方法确实有效,且可单独使用无需跟随IService

原创辟谣,实测MyBatisPlus批量新增/更新方法确实有效,且可单独使用无需跟随IService

关于Unity3D中角色跟随移动的机关同时移动的问题

电压跟随器的作用,以及其中两个电阻的作用?

ReactJS - 获取两个 div 两个互相跟随滚动

bash 变量跟随#*=,##*=的含义