在AVX2中高效实现log2(__ m256d)

时间:2017-08-19 09:50:24

标签: c++ algorithm floating-point logarithm avx2

SVML的__m256d _mm256_log2_pd (__m256d a)在英特尔以外的其他编译器上不可用,他们表示其性能在AMD处理器上有所不足。在AVX log intrinsics (_mm256_log_ps) missing in g++-4.8?SIMD math libraries for SSE and AVX中引用了互联网上的一些实现,但它们似乎比AVX2更多SSE。还有Agner Fog's vector library,但它是一个大型库,有更多的东西只是向量log2,所以从它的实现中很难找出只是向量log2操作的基本部分。

那么有人可以解释如何有效地为4 log2()个数字的向量实现double操作吗?即就像__m256d _mm256_log2_pd (__m256d a)所做的那样,但可用于其他编译器,对AMD和Intel处理器来说都是合理有效的。

编辑:在我当前的具体情况中,数字是0到1之间的概率,对数用于熵计算:i P[i]*log(P[i])的所有P[i]的和的否定。 namespace { const __m256i gDoubleExpMask = _mm256_set1_epi64x(0x7ffULL << 52); const __m256i gDoubleExp0 = _mm256_set1_epi64x(1023ULL << 52); const __m256i gTo32bitExp = _mm256_set_epi32(0, 0, 0, 0, 6, 4, 2, 0); const __m128i gExpNormalizer = _mm_set1_epi32(1023); //TODO: some 128-bit variable or two 64-bit variables here? const __m256d gCommMul = _mm256_set1_pd(2.0 / 0.693147180559945309417); // 2.0/ln(2) const __m256d gCoeff1 = _mm256_set1_pd(1.0 / 3); const __m256d gCoeff2 = _mm256_set1_pd(1.0 / 5); const __m256d gCoeff3 = _mm256_set1_pd(1.0 / 7); const __m256d gCoeff4 = _mm256_set1_pd(1.0 / 9); const __m256d gVect1 = _mm256_set1_pd(1.0); } __m256d __vectorcall Log2(__m256d x) { const __m256i exps64 = _mm256_srli_epi64(_mm256_and_si256(gDoubleExpMask, _mm256_castpd_si256(x)), 52); const __m256i exps32_avx = _mm256_permutevar8x32_epi32(exps64, gTo32bitExp); const __m128i exps32_sse = _mm256_castsi256_si128(exps32_avx); const __m128i normExps = _mm_sub_epi32(exps32_sse, gExpNormalizer); const __m256d expsPD = _mm256_cvtepi32_pd(normExps); const __m256d y = _mm256_or_pd(_mm256_castsi256_pd(gDoubleExp0), _mm256_andnot_pd(_mm256_castsi256_pd(gDoubleExpMask), x)); // Calculate t=(y-1)/(y+1) and t**2 const __m256d tNum = _mm256_sub_pd(y, gVect1); const __m256d tDen = _mm256_add_pd(y, gVect1); const __m256d t = _mm256_div_pd(tNum, tDen); const __m256d t2 = _mm256_mul_pd(t, t); // t**2 const __m256d t3 = _mm256_mul_pd(t, t2); // t**3 const __m256d terms01 = _mm256_fmadd_pd(gCoeff1, t3, t); const __m256d t5 = _mm256_mul_pd(t3, t2); // t**5 const __m256d terms012 = _mm256_fmadd_pd(gCoeff2, t5, terms01); const __m256d t7 = _mm256_mul_pd(t5, t2); // t**7 const __m256d terms0123 = _mm256_fmadd_pd(gCoeff3, t7, terms012); const __m256d t9 = _mm256_mul_pd(t7, t2); // t**9 const __m256d terms01234 = _mm256_fmadd_pd(gCoeff4, t9, terms0123); const __m256d log2_y = _mm256_mul_pd(terms01234, gCommMul); const __m256d log2_x = _mm256_add_pd(log2_y, expsPD); return log2_x; } 的浮点指数范围很大,所以数字可以接近0.我不确定准确性,所以会考虑以30位尾数开头的任何解决方案,特别是可调解的方案是优选的。

EDIT2:这是我到目前为止的实施,基于https://en.wikipedia.org/wiki/Logarithm#Power_series的“更高效系列”。怎么改进? (需要提高性能和准确度)

#include <chrono>
#include <cmath>
#include <cstdio>
#include <immintrin.h>

// ... Log2() implementation here

const int64_t cnLogs = 100 * 1000 * 1000;

void BenchmarkLog2Vect() {
  __m256d sums = _mm256_setzero_pd();
  auto start = std::chrono::high_resolution_clock::now();
  for (int64_t i = 1; i <= cnLogs; i += 4) {
    const __m256d x = _mm256_set_pd(double(i+3), double(i+2), double(i+1), double(i));
    const __m256d logs = Log2(x);
    sums = _mm256_add_pd(sums, logs);
  }
  auto elapsed = std::chrono::high_resolution_clock::now() - start;
  double nSec = 1e-6 * std::chrono::duration_cast<std::chrono::microseconds>(elapsed).count();
  double sum = sums.m256d_f64[0] + sums.m256d_f64[1] + sums.m256d_f64[2] + sums.m256d_f64[3];
  printf("Vect Log2: %.3lf Ops/sec calculated %.3lf\n", cnLogs / nSec, sum);
}

到目前为止,我的实现每秒提供405 268 490次操作,看起来精确到第8位。使用以下函数测量性能:

std::log2()

Logarithm in C++ and assembly的结果相比,当前的矢量实现速度比std::log()快4倍,比vec2快2.5倍。

具体而言,使用以下近似公式: Approximation terms - visual enter image description here

2 个答案:

答案 0 :(得分:12)

通常的策略基于身份log(a*b) = log(a) + log(b),或者本案例log2( 2^exponent * mantissa) ) = log2( 2^exponent ) + log2(mantissa)。或者简化exponent + log2(mantissa)。尾数具有非常有限的范围,1.0到2.0,因此log2(mantissa)的多项式只需要适应非常有限的范围。 (或等效地,尾数= 0.5到1.0,并将指数偏差校正常数改为1)。

泰勒级数展开是系数的一个很好的起点,但是你通常希望最小化该特定范围内的最大绝对误差(或相对误差),并且泰勒级数系数可能离开具有更低或更高的异常值在该范围内,而不是使最大正误差几乎与最大负误差匹配。所以你可以做一些所谓的系数极小极大拟合。

如果您的函数评估log2(1.0)0.0非常重要,那么您可以通过实际使用mantissa-1.0作为多项式来安排这一点,并且没有常数系数。 0.0 ^ n = 0.0。即使绝对误差仍然很小,这也大大改善了1.0附近输入的相对误差。

您需要多高的准确度以及输入的范围?像往常一样,在准确性和速度之间进行权衡,但幸运的是,通过例如这种规模的移动很容易。添加一个多项式项(并重新拟合系数),或者通过丢弃一些舍入误差避免。

Agner Fog's VCL implementation旨在获得非常高的准确性,使用技巧避免舍入错误,避免在可能的情况下可能导致添加小数量的事物。这在一定程度上掩盖了基本设计。

(Agner没有为VCL提供正式的回购,但是那个定期从上游tarball版本更新)。

要获得更快的近似float log(),请参阅http://jrfonseca.blogspot.ca/2008/09/fast-sse2-pow-tables-or-polynomials.html上的多项式实现。它省去了VCL使用的许多额外的精确获取技巧,因此它更容易理解。它使用1.0到2.0范围内的尾数的多项式近似。

(这是log()实现的真正诀窍:你只需要一个在很小范围内工作的多项式。)

它已经只是log2而不是log,与VCL不同,其中log-base-e被烘焙到常量以及它如何使用它们。阅读它可能是理解exponent + polynomial(mantissa)的{​​{1}}实现的一个很好的起点。

即使是精度最高的版本也不是log()精度float,更不用说double了,但你可以使用更多项来拟合多项式。或者显然两个多项式的比率效果很好;这是VCL用于double的内容。

我将JRF的SSE2功能移植到AVX2 + FMA(特别是带_mm512_getexp_ps_mm512_getmant_ps的AVX512)后得到了很好的结果,我仔细调了一下。 (这是商业项目的一部分,所以我不认为我可以发布代码。)float的快速近似实现正是我想要的。

在我的用例中,每个jrf_fastlog()都是独立的,因此OOO执行很好地隐藏了FMA延迟,并且甚至不值得使用更高的ILP更短延迟多项式评估方法{{ 3}}使用(VCL's polynomial_5() function,它在FMA之前执行一些非FMA乘法,从而产生更多的总指令。)

如果您的项目可以使用GPL许可代码并且您需要高精度,则应该直接使用VCL。它是唯一的标题,因此只有您实际使用的部分才会成为二进制文件的一部分。

VCL&#39; log浮动和双重功能在vectormath_exp.h中。该算法有两个主要部分:

  • 提取指数位并将该整数转换回浮点数(在调整IEEE FP使用的偏差之后)。

  • 在一些指数位中提取尾数和OR,以获得double范围内[0.5, 1.0)值的向量。 (或(0.5, 1.0],我忘了)。

    使用if(mantissa <= SQRT2*0.5) { mantissa += mantissa; exponent++;},然后mantissa -= 1.0进一步调整此项。

    使用x {1.0左右准确的log(x)多项式近似。 (对于double,VCL&#39; log_d()使用两个五阶多项式的比率。"Estrin's scheme"。与大量FMA混合的一个分区通常不会受到伤害吞吐量,但它确实具有比FMA更高的延迟。使用vrcpps + Newton-Raphson迭代通常比在现代硬件上使用vdivps慢。使用比率也可以通过评估两个更低的ILP来创建更多的ILP并行的多项式,而不是一个高阶多项式,并且可以降低高阶多项式的一个长子段的总延迟(这也会沿着那条长链累积显着的舍入误差)。

然后添加exponent + polynomial_approx_log(mantissa)以获取最终的log()结果。 VCL通过多个步骤执行此操作以减少舍入误差。 ln2_lo + ln2_hi = ln(2)。它被分成一个小的和一个大的常数来减少舍入误差。

// res is the polynomial(adjusted_mantissa) result
// fe is the float exponent
// x is the adjusted_mantissa.  x2 = x*x;
res  = mul_add(fe, ln2_lo, res);             // res += fe * ln2_lo;
res += nmul_add(x2, 0.5, x);                 // res += x  - 0.5 * x2;
res  = mul_add(fe, ln2_hi, res);             // res += fe * ln2_hi;

如果您没有达到0.5或1 ulp的准确度(或者此功能实际提供的任何内容; IDK。),您可以放弃两步ln2内容并使用VM_LN2。 / p>

我想,x - 0.5*x2部分实际上是一个额外的多项式项。这就是我所记录的基数e的含义:你需要对这些术语进行系数处理,或者去掉那一行并重新拟合log2的多项式系数。您不能将所有多项式系数乘以常数。

之后,它检查下溢,溢出或非正规,并且如果向量中的任何元素需要特殊处理以产生正确的NaN或-Inf而不是我们从多项式+指数得到的任何垃圾,则进行分支。 如果您的值已知是有限且积极的,那么您可以注释掉这部分并获得显着的加速(甚至在分支采取多条指令之前进行检查)。

进一步阅读:

  • @harold says this is often good for precision关于如何评估多项式近似中的相对和绝对误差,以及对系数进行极小极大修正而不仅仅使用泰勒级数展开的一些内容。

  • http://gallium.inria.fr/blog/fast-vectorizable-math-approx/一种有趣的方法:输入floatuint32_t将该整数转换为float 。由于IEEE binary32浮点数将指数存储在比尾数更高的位中,因此得到的float主要表示指数的值,由1 << 23缩放,但也包含来自尾数的信息。

    然后它使用具有几个系数的表达式来修复问题并获得log()近似值。它包括(constant + mantissa)除以在将浮点位模式转换为float时纠正尾数污染。我发现使用AVX2在HSW和SKL上的矢量化版本比使用4阶多项式的JRF fastlog更慢且更不准确。 (特别是当它作为快速arcsinh的一部分使用时,它也使用vsqrtps的除法单位。)

答案 1 :(得分:2)

最后,这是我最好的结果,在Ryzen 1800X @ 3.6GHz上,在单个线程中每秒提供大约8亿对数(每个对数2亿个2亿个向量),并且在尾数中的最后几位是准确的。 Spoiler :最后看看如何将性能提升到每秒0.87亿对数。

特殊情况: 具有负号位的负数,负无穷大和NaN被视为非常接近0(导致一些垃圾大的负“对数”值)。具有正符号位的正无穷大和NaN导致1024左右的对数。如果您不喜欢特殊情况的处理方式,一种选择是添加检查它们的代码并做更适合您的方法。这会使计算速度变慢。

namespace {
  // The limit is 19 because we process only high 32 bits of doubles, and out of
  //   20 bits of mantissa there, 1 bit is used for rounding.
  constexpr uint8_t cnLog2TblBits = 10; // 1024 numbers times 8 bytes = 8KB.
  constexpr uint16_t cZeroExp = 1023;
  const __m256i gDoubleNotExp = _mm256_set1_epi64x(~(0x7ffULL << 52));
  const __m256d gDoubleExp0 = _mm256_castsi256_pd(_mm256_set1_epi64x(1023ULL << 52));
  const __m256i cAvxExp2YMask = _mm256_set1_epi64x(
    ~((1ULL << (52-cnLog2TblBits)) - 1) );
  const __m256d cPlusBit = _mm256_castsi256_pd(_mm256_set1_epi64x(
    1ULL << (52 - cnLog2TblBits - 1)));
  const __m256d gCommMul1 = _mm256_set1_pd(2.0 / 0.693147180559945309417); // 2.0/ln(2)
  const __m256i gHigh32Permute = _mm256_set_epi32(0, 0, 0, 0, 7, 5, 3, 1);
  const __m128i cSseMantTblMask = _mm_set1_epi32((1 << cnLog2TblBits) - 1);
  const __m128i gExpNorm0 = _mm_set1_epi32(1023);
  // plus |cnLog2TblBits|th highest mantissa bit
  double gPlusLog2Table[1 << cnLog2TblBits];
} // anonymous namespace

void InitLog2Table() {
  for(uint32_t i=0; i<(1<<cnLog2TblBits); i++) {
    const uint64_t iZp = (uint64_t(cZeroExp) << 52)
      | (uint64_t(i) << (52 - cnLog2TblBits)) | (1ULL << (52 - cnLog2TblBits - 1));
    const double zp = *reinterpret_cast<const double*>(&iZp);
    const double l2zp = std::log2(zp);
    gPlusLog2Table[i] = l2zp;
  }
}

__m256d __vectorcall Log2TblPlus(__m256d x) {
  const __m256d zClearExp = _mm256_and_pd(_mm256_castsi256_pd(gDoubleNotExp), x);
  const __m256d z = _mm256_or_pd(zClearExp, gDoubleExp0);

  const __m128i high32 = _mm256_castsi256_si128(_mm256_permutevar8x32_epi32(
    _mm256_castpd_si256(x), gHigh32Permute));
  // This requires that x is non-negative, because the sign bit is not cleared before
  //   computing the exponent.
  const __m128i exps32 = _mm_srai_epi32(high32, 20);
  const __m128i normExps = _mm_sub_epi32(exps32, gExpNorm0);

  // Compute y as approximately equal to log2(z)
  const __m128i indexes = _mm_and_si128(cSseMantTblMask,
    _mm_srai_epi32(high32, 20 - cnLog2TblBits));
  const __m256d y = _mm256_i32gather_pd(gPlusLog2Table, indexes,
    /*number of bytes per item*/ 8);
  // Compute A as z/exp2(y)
  const __m256d exp2_Y = _mm256_or_pd(
    cPlusBit, _mm256_and_pd(z, _mm256_castsi256_pd(cAvxExp2YMask)));

  // Calculate t=(A-1)/(A+1). Both numerator and denominator would be divided by exp2_Y
  const __m256d tNum = _mm256_sub_pd(z, exp2_Y);
  const __m256d tDen = _mm256_add_pd(z, exp2_Y);

  // Compute the first polynomial term from "More efficient series" of https://en.wikipedia.org/wiki/Logarithm#Power_series
  const __m256d t = _mm256_div_pd(tNum, tDen);

  const __m256d log2_z = _mm256_fmadd_pd(t, gCommMul1, y);

  // Leading integer part for the logarithm
  const __m256d leading = _mm256_cvtepi32_pd(normExps);

  const __m256d log2_x = _mm256_add_pd(log2_z, leading);
  return log2_x;
}

它使用查找表方法和1度多项式的组合,主要在维基百科上描述(链接在代码注释中)。我可以在这里分配8KB的L1缓存(这是每个逻辑核心可用的16KB L1缓存的一半),因为对数计算确实是我的瓶颈,并且没有更多需要L1缓存的东西。

但是,如果您需要更多L1缓存以满足其他需求,则可以通过将cnLog2TblBits减少到例如来减少对数算法使用的缓存量。 5是以降低对数计算精度为代价的。

或者为了保持高精度,可以通过添加:

来增加多项式项的数量
namespace {
  // ...
  const __m256d gCoeff1 = _mm256_set1_pd(1.0 / 3);
  const __m256d gCoeff2 = _mm256_set1_pd(1.0 / 5);
  const __m256d gCoeff3 = _mm256_set1_pd(1.0 / 7);
  const __m256d gCoeff4 = _mm256_set1_pd(1.0 / 9);
  const __m256d gCoeff5 = _mm256_set1_pd(1.0 / 11);
}

然后在Log2TblPlus()行之后更改const __m256d t = _mm256_div_pd(tNum, tDen);的尾部:

  const __m256d t2 = _mm256_mul_pd(t, t); // t**2

  const __m256d t3 = _mm256_mul_pd(t, t2); // t**3
  const __m256d terms01 = _mm256_fmadd_pd(gCoeff1, t3, t);
  const __m256d t5 = _mm256_mul_pd(t3, t2); // t**5
  const __m256d terms012 = _mm256_fmadd_pd(gCoeff2, t5, terms01);
  const __m256d t7 = _mm256_mul_pd(t5, t2); // t**7
  const __m256d terms0123 = _mm256_fmadd_pd(gCoeff3, t7, terms012);
  const __m256d t9 = _mm256_mul_pd(t7, t2); // t**9
  const __m256d terms01234 = _mm256_fmadd_pd(gCoeff4, t9, terms0123);
  const __m256d t11 = _mm256_mul_pd(t9, t2); // t**11
  const __m256d terms012345 = _mm256_fmadd_pd(gCoeff5, t11, terms01234);

  const __m256d log2_z = _mm256_fmadd_pd(terms012345, gCommMul1, y);

然后评论// Leading integer part for the logarithm,其余的保持不变。

通常你不需要那么多术语,即使对于几位表,我只是提供系数和计算以供参考。如果cnLog2TblBits==5,您可能不需要terms012以外的任何内容。但是我没有做过这样的测量,你需要尝试一下适合你的需求。

您计算的多项式项越少,显然计算越快。

编辑:此问题In what situation would the AVX2 gather instructions be faster than individually loading the data?表明,如果

,您可能会获得性能提升
const __m256d y = _mm256_i32gather_pd(gPlusLog2Table, indexes,
  /*number of bytes per item*/ 8);

替换为

const __m256d y = _mm256_set_pd(gPlusLog2Table[indexes.m128i_u32[3]],
  gPlusLog2Table[indexes.m128i_u32[2]],
  gPlusLog2Table[indexes.m128i_u32[1]],
  gPlusLog2Table[indexes.m128i_u32[0]]);

对于我的实现,它节省了大约1.5个周期,减少了总周期数,从18到16.5计算4个对数,因此性能上升到每秒8.87亿个对数。我将离开当前的实现,因为它更加惯用,并且一旦CPU开始正确执行gather操作(与GPU一样合并),它就会更快。

EDIT2 on Ryzen CPU (but not on Intel)您可以通过替换

获得更多的加速(大约0.5个周期)
const __m128i high32 = _mm256_castsi256_si128(_mm256_permutevar8x32_epi32(
  _mm256_castpd_si256(x), gHigh32Permute));

  const __m128 hiLane = _mm_castpd_ps(_mm256_extractf128_pd(x, 1));
  const __m128 loLane = _mm_castpd_ps(_mm256_castpd256_pd128(x));
  const __m128i high32 = _mm_castps_si128(_mm_shuffle_ps(loLane, hiLane,
    _MM_SHUFFLE(3, 1, 3, 1)));