【问题标题】:Efficient implementation of log2(__m256d) in AVX2在 AVX2 中高效实现 log2(__m256d)
【发布时间】:2018-01-27 22:24:22
【问题描述】:

SVML 的__m256d _mm256_log2_pd (__m256d a) 在 Intel 以外的其他编译器上不可用,并且他们说它的性能在 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 个double 数字的向量实现log2() 操作吗? IE。就像 __m256d _mm256_log2_pd (__m256d a) 所做的那样,但可用于其他编译器,并且对于 AMD 和 Intel 处理器都相当有效。

编辑:在我目前的具体情况下,数字是 0 和 1 之间的概率,对数用于熵计算:对所有 iP[i]*log(P[i]) 求和的否定。 P[i] 的浮点指数范围很大,因此数字可能接近 0。我不确定准确性,因此会考虑以 30 位尾数开头的任何解决方案,尤其是可调整的解决方案是首选。

EDIT2:这是我目前的实现,基于来自 https://en.wikipedia.org/wiki/Logarithm#Power_series 的“更高效系列”。如何改进? (性能和准确性都需要提高)

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;
}

到目前为止,我的实现每秒提供 405 268 490 次操作,并且直到第 8 位为止似乎都是精确的。使用以下函数测量性能:

#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);
}

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

具体使用如下近似公式:

【问题讨论】:

  • 你不能只使用avx_mathfun中的log函数,然后将结果乘以所需的常量吗?
  • @PaulR,这是给float,而不是double。至少我不知道如何获得像 cephes_log_p0 这样的常量来表示双数:github.com/reyoung/avx_mathfun/blob/master/avx_mathfun.h
  • 啊 - 没有注意到 - 在这种情况下,我建议寻找 SSE 解决方案(例如,参见 this question and its answers) - 将 SSE 实现扩展到 AVX 应该很容易。
  • @SergeRogatch 您可以使用您最喜欢的多项式拟合方法来生成这些常数。
  • 英特尔最近将 AVX2 和 FMA 优化的数学函数添加到 glibc phoronix.com/…

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


【解决方案1】:

通常的策略是基于身份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 of log_d() 的目标是非常高的准确度,使用技巧来避免舍入误差,尽可能避免可能导致添加小数和大数的事情。这在一定程度上掩盖了基本设计。


对于更快更近似的floatlog(),请参阅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) 实现 log() 的一个很好的起点。

即使它的最高精度版本也不是完整的float 精度,更不用说double,但是您可以拟合具有更多项的多项式。或者显然两个多项式的比率效果很好;这就是 VCL 用于double

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

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


Agner Fog 的 VCL 现在已获得 Apache 许可,因此任何项目都可以直接包含它。如果你想要高精度,你应该直接使用VCL。它只是标题,只是内联函数,所以它不会让你的二进制文件膨胀。

VCL 的log float 和 double 函数在vectormath_exp.h 中。该算法有两个主要部分:

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

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

    if(mantissa &lt;= SQRT2*0.5) { mantissa += mantissa; exponent++;} 进一步调整它,然后用mantissa -= 1.0

    log(x) 使用多项式近似值,该近似值在 x=1.0 附近是准确的。 (对于double,VCL 的log_d() 使用两个 5 阶多项式的比率。@harold says this is often good for precision。一个除法与许多 FMA 混合通常不会影响吞吐量,但它确实比 FMA 具有更高的延迟。在现代硬件上使用vrcpps + Newton-Raphson 迭代通常比仅使用vdivps 慢。使用比率还可以通过并行评估两个低阶多项式而不是一个高阶多项式来创建更多 ILP,并且可能与高阶多项式的一个长 dep 链相比,整体延迟更低(这也会沿着该长链累积显着的舍入误差)。

然后加上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),您可以放弃 2 步 ln2 内容并使用 VM_LN2

x - 0.5*x2 部分确实是一个额外的多项式项,我猜。这就是我所说的 log base e 被烘焙的意思:你需要一个关于这些项的系数,或者摆脱那条线并重新拟合 log2 的多项式系数。您不能只将所有多项式系数乘以一个常数。

之后,它会检查是否存在下溢、上溢或非正规,并在向量中的任何元素需要特殊处理以产生适当的 NaN 或 -Inf 而不是我们从多项式 + 指数中得到的任何垃圾时进行分支。 如果已知您的值是有限且正的,则可以注释掉这部分并获得显着的加速(即使在分支之前进行检查也需要多条指令)。


延伸阅读:

  • http://gallium.inria.fr/blog/fast-vectorizable-math-approx/ 一些关于如何评估多项式逼近中的相对和绝对误差,以及对系数进行极小化修正而不是仅仅使用泰勒级数展开的内容。

  • http://www.machinedlearnings.com/2011/06/fast-approximate-logarithm-exponential.html 一个有趣的方法:它将float 键入双关语到uint32_t,然后将该整数转换为float。由于 IEEE binary32 浮点数将指数存储在比尾数更高的位中,因此生成的 float 主要表示指数的值,按 1 &lt;&lt; 23 缩放,但也包含来自尾数的信息。

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

【讨论】:

  • 我已经澄清了问题的范围和准确性。
  • @SergeRogatch:如果您不需要接近完整的 53 位精度,一个简单的多项式(或两个多项式的比率)应该可以很好地工作。您可能会忽略 VCL 使用的所有加法顺序技巧。选择类似 JRF float 的版本,但具有两个 4 阶或 5 阶多项式的比率。 (并且可能仍然在最后使用 poly * (mantissa - 1.0) 以确保它在应该时归零)。
【解决方案2】:

最后,这是我最好的结果,在 Ryzen 1800X @3.6GHz 上,在单个线程中每秒提供约 8 亿个对数(每个 4 个对数的 2 亿个向量),并且在尾数的最后几位之前都是准确的. 剧透:看看到底如何将性能提高到每秒 8.7 亿对数。

特殊情况: 负数、负无穷大和带有负号位的NaNs 被视为非常接近 0(导致一些垃圾大的负“对数”值)。正无穷大和带有正符号位的NaNs 会产生大约 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 次多项式的组合,主要在 Wikipedia 上进行了描述(链接在代码 cmets 中)。我可以在这里分配 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);
}

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

  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 个周期,将计算 4 个对数的总周期数从 18 减少到 16.5,因此性能提高到每秒 8.7 亿对数。我将保留当前的实现,因为一旦 CPU 开始正确执行 gather 操作(像 GPU 那样进行合并),它就更符合习惯并且应该更快。

EDIT2on 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)));

【讨论】:

  • Skylake 已经有高效的聚集(vgatherdpd ymm 每 4 个周期,而 Ryzen 的 12c 吞吐量)。我很惊讶表查找比更大的多项式更好。我想这只是在微基准测试中,或者在很长时间内主要进行log() 计算的循环中。而double 确实需要更大的多项式,所以也许它并不那么疯狂。此外,Ryzen 的 FMA 吞吐量约为英特尔的一半。
  • 对于 Intel CPU,使用 i64gather_pd 和 256b 向量会更有效,而不是为 i32gather_pd 打包到 __m128i。例如使用exps32 = _mm_srli_epi64(x, 20 + 32);。 (你为什么使用算术移位而不是逻辑移位?你需要符号位广播吗)?不过,提取 high32 可能对 Ryzen 有好处,因为 256b 向量指令是 2 微指令而不是 1。因此,您花费 2 微指令提取以在 __m128i 指令上节省 4 微指令而不是 __m256i
  • 使用全局const __m256d 常量可能没有帮助。你会认为这样会更好,但实际上你最终得到了一个“构造函数”,它从.rodata 复制到那些const __m256d 变量的存储中。即它们具有非常量初始化器,因为_mm_set 不会在全局范围内优化:( 请参阅godbolt.org/g/x8aW62 中的_GLOBAL__sub_I__Z13InitLog2Tablev。通常最好在函数中编写向量常量,并让编译器以同样的方式处理它们它跨多个函数处理字符串文字的方式,例如"hello"
  • 算术和逻辑性能相同(当然,根据Agner Fog's tables)。但是没有VPSRAQ,只有算术的 W/D 大小。在 Intel CPU 上,256b 矢量运算的速度与 128b 相同。但你是对的,128b 在 Ryzen 上更快。无论如何,_mm256_cvtepi32_pd 在某些时候需要打包到 128b 是对的。使用 AVX512,您可以将其保持在通道内并使用 cvtepi64_pd
  • re: 常量:如果你在一个循环中测试,一个好的编译器会提升大部分的负载(到循环外的寄存器中),所以它们是否靠近并不重要表与否。如果它们不完全适合,那么有些需要在缓存中保持热状态。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-12-21
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2010-11-07
相关资源
最近更新 更多