如何在Skylake架构上最大化SQRT-heavy-loop的指令级并行性?

5
为了介绍自己的x86内部函数(以及更次要的缓存友好性),我明确地将我用于基于径向基函数的网格变形的一些代码矢量化。 在发现vsqrtpd是主要瓶颈后,我想知道如何/是否可以进一步掩盖它的延迟。 这是标量计算核心代码:
for(size_t i=0; i<nPt; ++i)
{
    double xi = X[i], yi = X[i+nPt], zi = X[i+2*nPt];

   for(size_t j=0; j<nCP; ++j)
   {
        // compute distance from i to j
        double d = sqrt(pow(xi-Xcp[   j   ],2)+
                        pow(yi-Xcp[ j+nCP ],2)+
                        pow(zi-Xcp[j+2*nCP],2));

        // compute the RBF kernel coefficient
        double t = max(0.0,1.0-d);
        t = pow(t*t,2)*(1.0+4.0*d);

        // update coordinates
        for(size_t k=0; k<nDim; ++k) X[i+k*nPt] += t*Ucp[j+k*nCP];
    }
}

nPt是目标坐标的数量,远大于源坐标/位移的数量nCP。后者适合L3缓存,所以最内层循环始终在源点之间运行。

  • 第一个优化步骤是同时处理4个目标点。仍然通过标量加载后广播访问源点数据。
  • 第二个步骤是通过循环阻塞来针对L1缓存进行优化,阻塞i循环比阻塞j循环要重要得多,并且只带来了微小的改进。最内部循环仍然是j以减少负载/存储。
  • 第三步是加载4个控制点并使用混洗/置换来遍历i-j的4种组合,而不是使用广播。
  • 第四步,观察到忽略平方根会使速度提高1.5倍(相当于i7-7700上大型LLT的70%浮点性能),因此将4个寄存器用于计算4个平方根,以便允许某些其他计算...与第三步相比有1%的改善。

当前代码

void deform(size_t nPt, size_t nCP, const double* Xcp, const double* Ucp, double* X)
{
    const size_t SIMDLEN = 4;

    // tile ("cache block") sizes
    const size_t TILEH = 512;
    const size_t TILEW = 256;

    // fill two registers with the constants we need
    __m256d vone  = _mm256_set1_pd(1.0),
            vfour = _mm256_set1_pd(4.0);

    // explicitly vectorized (multiple i's at a time) and blocked
    // outer most loop over sets of #TILEH points
    for(size_t i0=0; i0<nPt; i0+=TILEH)
    {
        // displacement buffer, due to tiling, coordinates cannot be modified in-place
        alignas(64) double U[3*TILEH*sizeof(double)];

        // zero the tile displacements
        for(size_t k=0; k<3*TILEH; k+=SIMDLEN)
            _mm256_store_pd(&U[k], _mm256_setzero_pd());

        // stop point for inner i loop
        size_t iend = min(i0+TILEH,nPt);

        // second loop over sets of #TILEW control points
        for(size_t j0=0; j0<nCP; j0+=TILEW)
        {
            // stop point for inner j loop
            size_t jend = min(j0+TILEW,nCP);

            // inner i loop, over #TILEH points
            // vectorized, operate on #SIMDLEN points at a time
            for(size_t i=i0; i<iend; i+=SIMDLEN)
            {
                // coordinates and displacements of points i
                __m256d wi,
                xi = _mm256_load_pd(&X[   i   ]),
                yi = _mm256_load_pd(&X[ i+nPt ]),
                zi = _mm256_load_pd(&X[i+2*nPt]),
                ui = _mm256_load_pd(&U[    i-i0    ]),
                vi = _mm256_load_pd(&U[ i-i0+TILEH ]);
                wi = _mm256_load_pd(&U[i-i0+2*TILEH]);

                // inner j loop, over #TILEW control points, vectorized loads
                for(size_t j=j0; j<jend; j+=SIMDLEN)
                {
                    // coordinates of points j, and an aux var
                    __m256d t,
                    xj = _mm256_load_pd(&Xcp[   j   ]),
                    yj = _mm256_load_pd(&Xcp[ j+nCP ]),
                    zj = _mm256_load_pd(&Xcp[j+2*nCP]);

                    // compute the possible 4 distances from i to j...
                    #define COMPUTE_DIST(D) __m256d                         \
                    D = _mm256_sub_pd(xi,xj);  D = _mm256_mul_pd(D,D);      \
                    t = _mm256_sub_pd(yi,yj);  D = _mm256_fmadd_pd(t,t,D);  \
                    t = _mm256_sub_pd(zi,zj);  D = _mm256_fmadd_pd(t,t,D);  \
                    D = _mm256_sqrt_pd(D)

                    // ...by going through the different permutations
                    #define SHUFFLE(FUN,IMM8)   \
                    xj = FUN(xj,xj,IMM8);       \
                    yj = FUN(yj,yj,IMM8);       \
                    zj = FUN(zj,zj,IMM8)

                    COMPUTE_DIST(d0);

                    SHUFFLE(_mm256_shuffle_pd,0b0101);
                    COMPUTE_DIST(d1);

                    SHUFFLE(_mm256_permute2f128_pd,1);
                    COMPUTE_DIST(d2);

                    SHUFFLE(_mm256_shuffle_pd,0b0101);
                    COMPUTE_DIST(d3);

                    // coordinate registers now hold the displacements
                    xj = _mm256_load_pd(&Ucp[   j   ]),
                    yj = _mm256_load_pd(&Ucp[ j+nCP ]);
                    zj = _mm256_load_pd(&Ucp[j+2*nCP]);

                    // coefficients for each set of distances...
                    #define COMPUTE_COEFF(C)                                \
                    t = _mm256_min_pd(vone,C);  t = _mm256_sub_pd(vone,t);  \
                    t = _mm256_mul_pd(t,t);     t = _mm256_mul_pd(t,t);     \
                    C = _mm256_fmadd_pd(vfour,C,vone);                      \
                    C = _mm256_mul_pd(t,C)

                    // ...+ update i point displacements
                    #define UPDATE_DISP(C)          \
                    COMPUTE_COEFF(C);               \
                    ui = _mm256_fmadd_pd(C,xj,ui);  \
                    vi = _mm256_fmadd_pd(C,yj,vi);  \
                    wi = _mm256_fmadd_pd(C,zj,wi)

                    UPDATE_DISP(d0);

                    SHUFFLE(_mm256_shuffle_pd,0b0101);
                    UPDATE_DISP(d1);

                    SHUFFLE(_mm256_permute2f128_pd,1);
                    UPDATE_DISP(d2);

                    SHUFFLE(_mm256_shuffle_pd,0b0101);
                    UPDATE_DISP(d3);
                }

                // store updated displacements
                _mm256_store_pd(&U[    i-i0    ], ui);
                _mm256_store_pd(&U[ i-i0+TILEH ], vi);
                _mm256_store_pd(&U[i-i0+2*TILEH], wi);
            }
        }

        // add tile displacements to the coordinates
        for(size_t k=0; k<3; ++k)
        {
            for(size_t i=i0; i<iend; i+=SIMDLEN)
            {
                __m256d
                x = _mm256_load_pd(&X[i+k*nPt]),
                u = _mm256_load_pd(&U[i-i0+k*TILEH]);
                x = _mm256_add_pd(x,u);
                _mm256_stream_pd(&X[i+k*nPt], x);
            }
        }
    }
}

那还能对它做些什么?或者说,我在做的事情有什么问题吗?

谢谢, P. Gomes


3
你是否检查过arith.divider_active与核心时钟周期的性能计数器?如果是这样,你正在饱和(不完全流水线化的)除法器吞吐量,除非你可以避免一些平方根操作,否则就没有太多可获得的了。 - Peter Cordes
1
你绝对需要双精度的准确性吗?如果浮点数足够好,那么这里有一个很棒的讨论:[https://dev59.com/aVwZ5IYBdhLWcg3wWfOl] - Mike Vine
2
@MikeVine:在这么多的平方根操作之间,使用硬件单精度平方根可能是最好的选择。仅使用Bare x * rsqrtps(x) 而不使用牛顿迭代法可能太不准确(并且在x==0时会出错),但NR迭代需要太多额外的FMA uops,不值得。Skylake具有惊人的单精度vsqrtps吞吐量:每6个周期一个(相对于每9到12个周期的vsqrtpd)。上次我调整了一个包括多项式逼近的平方根函数用于Skylake,快速逼近倒数并不值得。但是,将其转换为float可以使您在这里获得超过2倍的加速。 - Peter Cordes
1
小注:为了稍微提高准确性,我建议在内部循环开始时将 uiviwi 设置为零,并在循环结束时添加前一个值(不应该对性能造成任何影响)。 - chtz
1
@PeterCordes,谢谢!我太过于沉迷于微观优化,忘记了第一条规则...测量。98%的函数运行时间可以通过计算平方根数量和操作吞吐量来解释。 - P Gomes
显示剩余4条评论
1个回答

3

首先检查性能计数器中的arith.divider_active是否接近于内核时钟周期。

98%的函数运行时间可以通过平方根数量和操作吞吐量来解释。

如果是这种情况,你正在饱和(不完全流水线化的)除法器吞吐量,并且没有更多的增益可以从公开更多ILP中获得。


算法变化是唯一真正有机会获得任何东西的方法,例如避免一些sqrt操作或使用单精度。

单精度可免费为每个向量提供2倍的工作量。 但对于sqrt-heavy的工作负载,还有一个额外的收益:vsqrtps吞吐量每个向量通常优于vsqrtpd。 这在Skylake上是这种情况:每6个周期一个vsqrtps与每9到12个周期一个vsqrtpd。 这可能将瓶颈从sqrt /除法单元移开,可能移到前端或FMA单元。

评论中建议使用vrsqrtps。 这值得考虑(如果单精度是一个选项),但当需要Newton Raphson迭代才能获得足够的精度时,它并不是一个明确的优势。 没有Newton Raphson的裸x * rsqrtps(x)可能太不准确了(需要cmp / AND来解决x == 0.0 ),但NR迭代可能需要太多额外的FMA uops才值得。

(AVX512具有更多近似值中的vrsqrt14ps/pd,但通常仍然不足以在没有牛顿的情况下使用。 但有趣的是它存在于双精度中。 当然,如果你在Xeon Phi上sqrt非常慢,并且打算使用AVX512ER vrsqrt28pd +牛顿,或只使用vrsqrt28ps。)

上次我调整包括Skylake多项式逼近的sqrt函数时,快速逼近倒数不值得。 硬件单精度sqrt是提供所需精度的最佳选择(我们甚至没有考虑需要double )。 虽然 sqrt操作之间的工作量比你的更多。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接