你如何用 SSE2 处理 exp()?

How do you process exp() with SSE2?

我正在编写一个代码,基本上利用 SSE2 来优化此代码:

double *pA = a;
double *pB = b[voiceIndex];
double *pC = c[voiceIndex];

for (int sampleIndex = 0; sampleIndex < blockSize; sampleIndex++) {
    pC[sampleIndex] = exp((mMin + std::clamp(pA[sampleIndex] + pB[sampleIndex], 0.0, 1.0) * mRange) * ln2per12);
}

在此:

double *pA = a;
double *pB = b[voiceIndex];
double *pC = c[voiceIndex];

// SSE2
__m128d bound_lower = _mm_set1_pd(0.0);
__m128d bound_upper = _mm_set1_pd(1.0);
__m128d rangeLn2per12 = _mm_set1_pd(mRange * ln2per12);
__m128d minLn2per12 = _mm_set1_pd(mMin * ln2per12);

__m128d loaded_a = _mm_load_pd(pA);
__m128d loaded_b = _mm_load_pd(pB);
__m128d result = _mm_add_pd(loaded_a, loaded_b);
result = _mm_max_pd(bound_lower, result);
result = _mm_min_pd(bound_upper, result);
result = _mm_mul_pd(rangeLn2per12, result);
result = _mm_add_pd(minLn2per12, result);

double *pCEnd = pC + roundintup8(blockSize);
for (; pC < pCEnd; pA += 8, pB += 8, pC += 8) {
    _mm_store_pd(pC, result);

    loaded_a = _mm_load_pd(pA + 2);
    loaded_b = _mm_load_pd(pB + 2);
    result = _mm_add_pd(loaded_a, loaded_b);
    result = _mm_max_pd(bound_lower, result);
    result = _mm_min_pd(bound_upper, result);
    result = _mm_mul_pd(rangeLn2per12, result);
    result = _mm_add_pd(minLn2per12, result);
    _mm_store_pd(pC + 2, result);

    loaded_a = _mm_load_pd(pA + 4);
    loaded_b = _mm_load_pd(pB + 4);
    result = _mm_add_pd(loaded_a, loaded_b);
    result = _mm_max_pd(bound_lower, result);
    result = _mm_min_pd(bound_upper, result);
    result = _mm_mul_pd(rangeLn2per12, result);
    result = _mm_add_pd(minLn2per12, result);
    _mm_store_pd(pC + 4, result);

    loaded_a = _mm_load_pd(pA + 6);
    loaded_b = _mm_load_pd(pB + 6);
    result = _mm_add_pd(loaded_a, loaded_b);
    result = _mm_max_pd(bound_lower, result);
    result = _mm_min_pd(bound_upper, result);
    result = _mm_mul_pd(rangeLn2per12, result);
    result = _mm_add_pd(minLn2per12, result);
    _mm_store_pd(pC + 6, result);

    loaded_a = _mm_load_pd(pA + 8);
    loaded_b = _mm_load_pd(pB + 8);
    result = _mm_add_pd(loaded_a, loaded_b);
    result = _mm_max_pd(bound_lower, result);
    result = _mm_min_pd(bound_upper, result);
    result = _mm_mul_pd(rangeLn2per12, result);
    result = _mm_add_pd(minLn2per12, result);
}

而且我会说效果很好。但是,找不到 SSE2 的任何 exp 函数来完成操作链。

正在阅读 this,看来我需要从图书馆调用标准 exp()

真的吗?这不是惩罚吗?还有其他办法吗?不同的内置函数?

我正在 MSVC/arch:SSE2/O2,生成 32 位代码。

最简单的方法是使用指数近似。基于此限制的一种可能情况

对于n = 256 = 2^8

__m128d fastExp1(__m128d x)
{
   __m128d ret = _mm_mul_pd(_mm_set1_pd(1.0 / 256), x);
   ret = _mm_add_pd(_mm_set1_pd(1.0), ret);
   ret = _mm_mul_pd(ret, ret);
   ret = _mm_mul_pd(ret, ret);
   ret = _mm_mul_pd(ret, ret);
   ret = _mm_mul_pd(ret, ret);
   ret = _mm_mul_pd(ret, ret);
   ret = _mm_mul_pd(ret, ret);
   ret = _mm_mul_pd(ret, ret);
   ret = _mm_mul_pd(ret, ret);
   return ret;
}

另一个想法是多项式展开。特别是泰勒级数展开:

__m128d fastExp2(__m128d x)
{
   const __m128d a0 = _mm_set1_pd(1.0);
   const __m128d a1 = _mm_set1_pd(1.0);
   const __m128d a2 = _mm_set1_pd(1.0 / 2);
   const __m128d a3 = _mm_set1_pd(1.0 / 2 / 3);
   const __m128d a4 = _mm_set1_pd(1.0 / 2 / 3 / 4);
   const __m128d a5 = _mm_set1_pd(1.0 / 2 / 3 / 4 / 5);
   const __m128d a6 = _mm_set1_pd(1.0 / 2 / 3 / 4 / 5 / 6);
   const __m128d a7 = _mm_set1_pd(1.0 / 2 / 3 / 4 / 5 / 6 / 7);

   __m128d ret = _mm_fmadd_pd(a7, x, a6);
   ret = _mm_fmadd_pd(ret, x, a5); 
   // If fma extention is not present use
   // ret = _mm_add_pd(_mm_mul_pd(ret, x), a5);
   ret = _mm_fmadd_pd(ret, x, a4);
   ret = _mm_fmadd_pd(ret, x, a3);
   ret = _mm_fmadd_pd(ret, x, a2);
   ret = _mm_fmadd_pd(ret, x, a1);
   ret = _mm_fmadd_pd(ret, x, a0);
   return ret;
}

请注意,对于相同数量的展开项,如果针对特定的 x 范围对函数进行近似,例如使用最小二乘法,您可以获得更好的近似值。

所有这些方法都适用于非常有限的 x 范围,但连续导数在某些情况下可能很重要。

有一个技巧可以在 非常宽的范围 但具有明显的 分段线性 区域中近似指数。它基于将整数重新解释为浮点数。为了更准确的描述,我推荐这个参考:

Piecewise linear approximation to exponential and logarithm

A Fast, Compact Approximation of the Exponential Function

这种方法的可能实现方式:

__m128d fastExp3(__m128d x)
{
   const __m128d a = _mm_set1_pd(1.0 / M_LN2);
   const __m128d b = _mm_set1_pd(3 * 1024.0 - 1.05);
   __m128d t = _mm_fmadd_pd(x, a, b);
   return _mm_castsi128_pd(_mm_slli_epi64(_mm_castpd_si128(t), 11));
}

尽管此方法简单且范围广泛 x,但在数学中使用时要小心。在小范围内,它会给出分段近似,这会破坏敏感算法,尤其是那些使用微分的算法。

要比较不同方法的准确性,请查看图形。第一张图是针对 x = [0..1) 范围制作的。如您所见,这种情况下的最佳近似值由方法 fastExp2(x) 给出,稍差但可以接受的是 fastExp1(x)fastExp3(x) 提供的最差近似值 - 分段结构很明显,存在一阶导数的不连续性。

在范围 x = [0..10) fastExp3(x) 方法提供了最好的近似值,更差的是 fastExp1(x) 给出的近似值 - 在相同的计算次数下,它提供了比 [=16 更多的顺序=].

下一步是提高 fastExp3(x) 算法的准确性。最简单的显着提高精度的方法是使用相等exp(x) = exp(x/2)/exp(-x/2)虽然增加了计算量,但是大大减少了除法时由于相互误差补偿造成的误差。

__m128d fastExp5(__m128d x)
{
   const __m128d ap = _mm_set1_pd(0.5 / M_LN2);
   const __m128d an = _mm_set1_pd(-0.5 / M_LN2);
   const __m128d b = _mm_set1_pd(3 * 1024.0 - 1.05);
   __m128d tp = _mm_fmadd_pd(x, ap, b);
   __m128d tn = _mm_fmadd_pd(x, an, b);
   tp = _mm_castsi128_pd(_mm_slli_epi64(_mm_castpd_si128(tp), 11));
   tn = _mm_castsi128_pd(_mm_slli_epi64(_mm_castpd_si128(tn), 11));
   return _mm_div_pd(tp, tn);
}

通过使用等式 exp(x+dx) = exp(x) *exp(dx) 组合 fastExp1(x)fastExp2(x)fastExp3(x) 算法的方法,可以实现更高的准确性。如上所示,第一个乘数可以用类似fastExp3(x)的方法计算,对于第二个乘数可以使用fastExp1(x)fastExp2(x)方法。在这种情况下找到最佳解决方案是一项艰巨的任务,我建议查看答案中提出的库中的实现。

有几个库提供向量化指数,或多或少的准确性。

  • SVML,随英特尔编译器提供(它也提供内在函数,所以如果你有许可证,你可以使用它们),具有不同级别的精度(和速度)
  • 您提到 IPP,同样来自 Intel,它也提供一些功能
  • MKL 也为这个计算提供了一些接口(对于这个,修复 ISA 可以通过宏来完成,例如,如果你需要再现性或精度)
  • fmath 是另一种选择,您可以从矢量化 exp 中提取代码以将其集成到循环中。

根据经验,所有这些都比自定义 padde 近似更快更精确(甚至没有谈论不稳定的泰勒展开,它会很快给你负数)。

对于 SVML、IPP 和 MKL,我会检查哪一个更好:从循环内部调用还是通过对整个数组的一次调用调用 exp(因为库可以使用 AVX512 而不仅仅是 SSE2)。

没有 exp 的 SSE2 实现,所以如果您不想按照上面的建议自行实现,一种选择是在某些支持 ERI(指数和倒数指令)的硬件上使用 AVX512 指令。参见 https://en.wikipedia.org/wiki/AVX-512#New_instructions_in_AVX-512_exponential_and_reciprocal

我认为目前您只能使用 Xeon phi(正如 Peter Cordes 所指出的 - 我确实发现了一个关于它在 Skylake 和 Cannonlake 上的说法,但无法证实),同时请记住该代码在其他架构上根本不起作用(即会崩溃)。