Эффективная реализация log2 (__ m256d) в AVX2

SVML __m256d _mm256_log2_pd (__m256d a) недоступен для других компиляторов, чем Intel, и говорят, что его производительность ограничена на процессорах AMD. Есть несколько реализаций в Интернете, упомянутых в AVN log intrinsics (_mm256_log_ps), отсутствующих в g++ - 4.8? и SIMD-математических библиотеках для SSE и AVX, однако они, похоже, больше SSE, чем AVX2. Там также векторная библиотека Agner Fog, однако это большая библиотека, имеющая гораздо больше материала, всего лишь вектор log2, поэтому от реализации в ней трудно выясните основные части только для операции векторного log2.

Так может кто-нибудь объяснить, как эффективно реализовать операцию log2() для вектора из 4 double чисел? То есть как то, что делает __m256d _mm256_log2_pd (__m256d a), но доступно для других компиляторов и достаточно эффективно для процессоров AMD и Intel.

EDIT: в моем текущем конкретном случае числа - это вероятности между 0 и 1, а логарифм используется для вычисления энтропии: отрицание суммы по всем i из P[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 операций в секунду, и она кажется точной до восьмой цифры. Производительность измеряется с помощью следующей функции:

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

По сравнению с результатами Логарифма в С++ и сборке, реализация текущего вектора в 4 раза быстрее, чем std::log2() и в 2,5 раза быстрее, чем std::log().

В частности, используется следующая формула приближения: Условия приближения - визуальные введите описание изображения здесь

Ответ 1

Наконец, вот мой лучший результат, который на Ryzen 1800X @3.6GHz дает около 0,8 миллиарда логарифмов в секунду (200 миллионов векторов по 4 логарифма в каждом) в одном потоке и является точным до нескольких последних бит в мантиссе, Спойлер: см. в конце, как увеличить производительность до 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-й степени, в основном описанного в Википедии (ссылка находится в комментариях к коду). Я могу позволить себе выделить 8 Кбайт кэша L1 здесь (это половина кэша L1 на 16 Кбайт, доступная для каждого логического ядра), потому что вычисление логарифмов на самом деле является узким местом для меня, и нет ничего более необходимого для кэша 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. Но я не делал таких измерений, вам нужно поэкспериментировать с тем, что вам подходит.

Чем меньше полиномиальных терминов вы вычисляете, тем быстрее вычисляются.


EDIT: этот вопрос В какой ситуации команды сбора AVX2 будут быстрее, чем индивидуальная загрузка данных? предполагает, что вы можете получить улучшение производительности, если

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, таким образом, производительность повышается до 0,87 миллиарда логарифмов в секунду. Я оставляю текущую реализацию так же, как и потому, что она более идиоматична и ускоряется после того, как процессоры начнут выполнять операции gather вправо (с коалесцированием, например, с графическими процессорами).

EDIT2: на процессоре Ryzen (но не на 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)));

Ответ 2

Обычная стратегия основана на тождестве 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 VCL нацелено на очень высокую точность, используя трюки, чтобы избежать ошибки округления, избегая вещей, которые могут привести к добавлению небольшого и много если возможно. Это немного смущает базовый дизайн.

(У Agner нет официального репо для VCL, но это регулярно обновляется с выпусков релизов в верхней части).


Для более быстрого приближения 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) реализаций log().

Даже версия с самой высокой точностью не является полной точностью float, не говоря уже о double, но вы можете поместить полином с большим количеством терминов. Или, по-видимому, соотношение двух многочленов хорошо работает; что VCL использует для double.

Я получил отличные результаты от портирования функции JRF SSE2 до AVX2 + FMA (и особенно AVX512 с _mm512_getexp_ps и _mm512_getmant_ps), как только я тщательно ее настроил. (Это было частью коммерческого проекта, поэтому я не думаю, что могу опубликовать код.) Быстрая приблизительная реализация для float была именно тем, что я хотел.

В моем случае использования каждый jrf_fastlog() был независимым, поэтому выполнение программы прекрасно скрывало задержку FMA, и даже не стоило использовать метод полиномиальной оценки с более высокой задержкой ILP, VCL polynomial_5() function использует (схема Эстрина, которая делает некоторые не-FMA умножается до FMA, что приводит к более полным инструкциям).


Если ваш проект может использовать GPL-лицензированный код, и вы хотите получить высокую точность, вы должны просто использовать VCL напрямую. Это только заголовки, поэтому только части, которые вы фактически используете, будут частью ваших двоичных файлов.

VCL log float и двойные функции находятся в vectormath_exp.h. В алгоритме есть две основные части:

  • извлечь биты экспоненты и преобразовать это целое обратно в float (после настройки для смещения, который использует IEEE FP).

  • извлеките мантиссы и OR в некоторых битах экспоненты, чтобы получить вектор значений double в диапазоне [0.5, 1.0). (Или (0.5, 1.0], я забыл).

    Далее отрегулируйте это с помощью if(mantissa <= SQRT2*0.5) { mantissa += mantissa; exponent++;}, а затем mantissa -= 1.0.

    Используйте полиномиальное приближение к log(x), точное с точностью до x = 1.0. (Для double, VCL log_d() использует отношение двух полиномов 5-го порядка. @harold говорит, что это часто хорошо для точности, Одно подразделение, смешанное с множеством FMA, обычно не повреждает пропускную способность, но имеет более высокую задержку, чем FMA. Использование vrcpps + итерация Newton-Raphson обычно медленнее, чем просто использование vdivps на современном оборудовании. Использование отношения также создает больше ИЛП путем оценки двух многочленов более низкого порядка параллельно, вместо одного многочлена высокого порядка, и может снизить общую латентность по сравнению с одной длинной отрезной цепью для многочлена высокого порядка (что также будет накапливать значительную ошибку округления вдоль этой длинной цепи).

Затем добавьте exponent + polynomial_approx_log(mantissa), чтобы получить окончательный результат журнала(). 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;

Вы можете отказаться от двухэтапного материала ln2 и просто использовать VM_LN2, если вы не нацелены на точность 0,5 или 1 ulp (или что бы эта функция фактически не предоставляла; IDK.)

Часть x - 0.5*x2 действительно является дополнительным полиномиальным термином, я думаю. Это то, что я имел в виду, когда вы заработали логарифмическую базу: вам понадобится коэффициент на этих терминах или избавиться от этой строки и перестроить коэффициенты полинома для 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. Поскольку float IEEE binary32 хранит экспоненту в более высоких битах, чем мантисса, результирующий float в основном представляет значение экспоненты, масштабируемое на 1 << 23, но также содержащее информацию от мантиссы.

    Затем он использует выражение с парами коэффициентов, чтобы исправить вещи и получить приближение log(). Он включает в себя деление на (constant + mantissa) для исправления загрязнения мантиссы при преобразовании битовой диаграммы с поплавком в float. Я обнаружил, что векторизованная версия была медленнее и менее точной с AVX2 на HSW и SKL, чем JRF fastlog с многочленами 4-го порядка. (Особенно при использовании его как части быстрого arcsinh, который также использует блок разделения для vsqrtps.)