Обработка нулей в _mm256_rsqrt_ps()

При условии _mm256_sqrt_ps() является относительно медленным, и что значения, которые я генерирую, немедленно усекаются с _mm256_floor_ps()Оглядываясь вокруг, кажется, что делаешь:

_mm256_mul_ps(_mm256_rsqrt_ps(eightFloats),
              eightFloats);

Это способ добиться дополнительной производительности и избежать остановки конвейера.

К сожалению, с нулевыми значениями, я, конечно, получаю сбой расчета 1/sqrt(0), Как лучше обойти это? Я пробовал это (что работает и быстрее), но есть ли лучший способ, или я столкнусь с проблемами при определенных условиях?

_mm256_mul_ps(_mm256_rsqrt_ps(_mm256_max_ps(eightFloats,
                                            _mm256_set1_ps(0.1))),
              eightFloats);

Мой код предназначен для вертикального приложения, поэтому я могу предположить, что оно будет работать на процессоре Haswell (i7-4810MQ), поэтому можно использовать FMA/AVX2. Оригинальный код примерно:

float vals[MAX];
int sum = 0;
for (int i = 0; i < MAX; i++)
{
    int thisSqrt = (int) floor(sqrt(vals[i]));
    sum += min(thisSqrt, 0x3F);
}

Все значения vals должны быть целочисленными значениями. (Почему все не просто int это другой вопрос...)

1 ответ

tl; dr: смотрите конец кода, который компилируется и должен работать.


Чтобы просто решить 0.0 проблема, вы могли бы также особые случаи 0.0 с FP сравнить источник с 0.0, Используйте результат сравнения в качестве маски для обнуления любых NaN, полученных в результате 0 * +Infinity в sqrt(x) = x * rsqrt(x)). Clang делает это при автовекторизации. (Но он использует blendps с нулевым вектором, вместо использования маски сравнения с andnps непосредственно к нулю или сохранить элементы.)

Также было бы возможно использовать sqrt(x) ~= recip(rsqrt(x)), как предложено njuffa. rsqrt(0) = +Inf, recip(+Inf) = 0, Однако использование двух приближений может составить относительную ошибку, что является проблемой.


То, что вам не хватает:

Усечение до целого числа (вместо округления) требует точного sqrt результат, когда вход является идеальным квадратом. Если результат для 25*rsqrt(25) равен 4.999999 или около того (вместо 5.00001), вы добавите 4 вместо 5,

Даже с итерацией Ньютона-Рафсона, rsqrtps не совсем точный способ sqrtps есть, так что еще может дать 5.0 - 1ulp, (1ulp = одна единица на последнем месте = самый младший бит мантиссы).

Также:


Может быть возможно убить 2 зайцев одним выстрелом, добавив небольшую константу перед выполнением (x+offset)*approx_rsqrt(x+offset) а затем обрезать до целого числа. Достаточно большой, чтобы преодолеть максимальную относительную погрешность 1,5 * 2 -12, но достаточно мал, чтобы не сталкиваться sqrt_approx(63*63-1+offset) вплоть до 63 (самый чувствительный случай).

63*1.5*2^(-12)         ==  0.023071...
approx_sqrt(63*63-1)   == 62.99206... +/- 0.023068..

На самом деле, мы облажались без итераций Ньютона, даже не добавляя ничего. approx_sqrt(63*63-1) мог выйти выше 63.0 сам по себе. n=36 это наибольшее значение, где относительная ошибка в sqrt(n*n-1) + error меньше чем sqrt(n*n), GNU Calc:

define f(n) { local x=sqrt(n*n-1); local e=x*1.5*2^(-12); print x; print e, x+e; }
; f(36)
35.98610843089316319413
~0.01317850650545403926 ~35.99928693739861723339
; f(37)
36.9864840178138587015
~0.01354485498699237990 ~37.00002887280085108140

Имеют ли ваши исходные данные какие-либо свойства, которые означают, что вам не нужно беспокоиться о том, что они находятся чуть ниже большого идеального квадрата? например, это всегда идеальные квадраты?

Вы можете проверить все возможные входные значения, так как важный домен очень мал (целочисленные значения FP от 0,63*63), чтобы увидеть, достаточно ли мала практическая ошибка на Intel Haswell, но это будет хрупкая оптимизация, которая может сломайте ваш код на процессорах AMD или даже на будущих процессорах Intel. К сожалению, простое кодирование в ISA-спецификации гарантирует, что относительная ошибка до 1,5 * 2 -12 требует большего количества инструкций. Я не вижу никаких хитростей итераций NR.

Если бы ваш верхний предел был меньше (например, 20), вы могли бы просто сделать isqrt = static_cast<int> ((x+0.5)*approx_rsqrt(x+0.5)), Вы получите 20 за 20*20, но всегда 19 за 20*20-1,

; define test_approx_sqrt(x, off) { local s=x*x+off; local sq=s/sqrt(s); local sq_1=(s-1)/sqrt(s-1); local e=1.5*2^(-12); print sq, sq_1; print sq*e, sq_1*e; }
; test_approx_sqrt(20, 0.5)
~20.01249609618950056874 ~19.98749609130668473087   # (x+0.5)/sqrt(x+0.5)
 ~0.00732879495710064718  ~0.00731963968187500662   # relative error

Обратите внимание, что val * (x +/- err) = val*x +/- val*err, IEEE FP mul производит результаты, которые правильно округлены до 0.5ulp, поэтому это должно работать для относительных ошибок FP.


В любом случае, я думаю, вам нужна одна итерация Ньютона-Рафсона.

Лучше всего добавить 0.5 на ваш вход, прежде чем делать прибл. rsqrt, Это обходит проблему 0/0 = NaN и сдвигает диапазон ошибки +/- к одной стороне от точки отсечения целого числа (для чисел в диапазоне, который нас интересует).

Инструкции FP min/max имеют ту же производительность, что и FP add, и в любом случае будут на критическом пути. Использование add вместо max также решает проблему результатов для идеальных квадратов, потенциально находящихся на несколько ступеней ниже правильного результата.


Выход компилятора: достойная отправная точка

Я получаю довольно хорошие результаты автовекторизации из Clang 3.7.1 с sum_int, с -fno-math-errno -funsafe-math-optimizations, -ffinite-math-only не требуется (но даже при полной -ffast-math, лязг избегает sqrt(0) = NaN когда используешь rsqrtps).

sum_fp не авто-векторизация, даже с полным -ffast-math,

тем не мение clang Версия имеет ту же проблему, что и ваша идея: обрезать неточный результат из rsqrt + NR, потенциально давая неправильное целое число. IDK, если по этой причине gcc не выполняет авто-векторизацию, потому что он мог бы использовать sqrtps для большого ускорения без изменения результатов. (По крайней мере, пока все значения с плавающей точкой находятся между 0 и INT_MAX 2, в противном случае обратное преобразование в целое число даст "неопределенный" результат INT_MIN. (Установленный бит установлен, все остальные биты очищены). Это тот случай, когда -ffast-math ломает вашу программу, если вы не используете -mrecip=none или что-то.

Смотрите вывод asm на Godbolt от:

// autovectorizes with clang, but has rounding problems.
// Note the use of sqrtf, and that floorf before truncating to int is redundant. (removed because clang doesn't optimize away the roundps)
int sum_int(float vals[]){
  int sum = 0;
  for (int i = 0; i < MAX; i++) {
    int thisSqrt = (int) sqrtf(vals[i]);
    sum += std::min(thisSqrt, 0x3F);
  }
  return sum;
}

Чтобы вручную векторизовать с помощью встроенных функций, мы можем посмотреть на вывод asm из -fno-unroll-loops (чтобы все было просто). Я собирался включить это в ответ, но потом понял, что у него есть проблемы.


положить это вместе:

Я думаю, что преобразование в int внутри цикла лучше, чем использование floorf а потом addps, roundps является инструкцией по 2 мопа (задержка 6 с) в Haswell (1 моп в SnB/IvB). Хуже того, оба мопа требуют port1, поэтому они конкурируют с FP add / mul. cvttps2dq это инструкция 1-моп для port1 с задержкой 3c, а затем мы можем использовать целое число min и добавить для ограничения и накопления, так что port5 получает что-то делать. Использование целочисленного векторного аккумулятора также означает, что цепочка зависимостей, переносимых циклами, составляет 1 цикл, поэтому нам не нужно развертывать или использовать несколько аккумуляторов, чтобы сохранить несколько итераций в полете. Меньший код всегда лучше для общей картины (кэш Uop, I-кэш L1, предикторы ветвления).

Пока нам не грозит переполнение 32-битных аккумуляторов, это, кажется, лучший выбор. (Не проверяя ничего и даже не проверяя это).

Я не пользуюсь sqrt(x) ~= approx_recip(approx_sqrt(x)) метод, потому что я не знаю, как сделать итерацию Ньютона, чтобы уточнить его (вероятно, это будет включать в себя деление). И потому что сложная ошибка больше.

Горизонтальная сумма из этого ответа.

Полная, но не проверенная версия:

#include <immintrin.h>
#define MAX 4096

// 2*sqrt(x) ~= 2*x*approx_rsqrt(x), with a Newton-Raphson iteration
// dividing by 2 is faster in the integer domain, so we don't do it
__m256 approx_2sqrt_ps256(__m256 x) {
    // clang / gcc usually use -3.0 and -0.5.  We could do the same by using fnmsub_ps (add 3 = subtract -3), so we can share constants
    __m256 three = _mm256_set1_ps(3.0f);
    //__m256 half = _mm256_set1_ps(0.5f);  // we omit the *0.5 step

    __m256 nr  = _mm256_rsqrt_ps( x );  // initial approximation for Newton-Raphson
    //           1/sqrt(x) ~=    nr  * (3 - x*nr * nr) * 0.5 = nr*(1.5 - x*0.5*nr*nr)
    // sqrt(x) = x/sqrt(x) ~= (x*nr) * (3 - x*nr * nr) * 0.5
    // 2*sqrt(x) ~= (x*nr) * (3 - x*nr * nr)
    __m256 xnr = _mm256_mul_ps( x, nr );

    __m256 three_minus_muls = _mm256_fnmadd_ps( xnr, nr, three );  // -(xnr*nr) + 3
    return _mm256_mul_ps( xnr, three_minus_muls );
}

// packed int32_t: correct results for inputs from 0 to well above 63*63
__m256i isqrt256_ps(__m256 x) {
    __m256 offset = _mm256_set1_ps(0.5f);    // or subtract -0.5, to maybe share constants with compiler-generated Newton iterations.
    __m256 xoff = _mm256_add_ps(x, offset);  // avoids 0*Inf = NaN, and rounding error before truncation
    __m256 approx_2sqrt_xoff = approx_2sqrt_ps256(xoff);
    __m256i i2sqrtx = _mm256_cvttps_epi32(approx_2sqrt_xoff);
    return _mm256_srli_epi32(i2sqrtx, 1);     // divide by 2 with truncation
    // alternatively, we could mask the low bit to zero and divide by two outside the loop, but that has no advantage unless port0 turns out to be the bottleneck
}

__m256i isqrt256_ps_simple_exact(__m256 x) {
    __m256 sqrt_x = _mm256_sqrt_ps(x);
    __m256i isqrtx = _mm256_cvttps_epi32(sqrt_x);
    return isqrtx;
}

int hsum_epi32_avx(__m256i x256){
    __m128i xhi = _mm256_extracti128_si256(x256, 1);
    __m128i xlo = _mm256_castsi256_si128(x256);
    __m128i x  = _mm_add_epi32(xlo, xhi);
    __m128i hl = _mm_shuffle_epi32(x, _MM_SHUFFLE(1, 0, 3, 2));
    hl  = _mm_add_epi32(hl, x);
    x   = _mm_shuffle_epi32(hl, _MM_SHUFFLE(2, 3, 0, 1));
    hl  = _mm_add_epi32(hl, x);
    return _mm_cvtsi128_si32(hl);
}

int sum_int_avx(float vals[]){
  __m256i sum = _mm256_setzero_si256();
  __m256i upperlimit = _mm256_set1_epi32(0x3F);

  for (int i = 0; i < MAX; i+=8) {
    __m256 v = _mm256_loadu_ps(vals+i);
    __m256i visqrt = isqrt256_ps(v);
    // assert visqrt == isqrt256_ps_simple_exact(v) or something
    visqrt = _mm256_min_epi32(visqrt, upperlimit);
    sum = _mm256_add_epi32(sum, visqrt);
  }
  return hsum_epi32_avx(sum);
}

Компилирует Godbolt в хороший код, но я его не проверял. clang делает немного более приятный код, чем gcc: clang использует широковещательную загрузку из 4B-местоположений для констант set1 вместо того, чтобы повторять их во время компиляции в 32B-константы. У gcc также есть причудливая movdqa для копирования регистра.

В любом случае, весь цикл получается только из 9 векторных инструкций, по сравнению с 12 для сгенерированного компилятором sum_int версия. Это, вероятно, не заметил x*initial_guess(x) общие подвыражения, которые встречаются в итерационной формуле Ньютона-Рафсона, когда вы умножаете результат на x, или что-то типа того. Он также делает дополнительные MULPS вместо psrld, потому что он делает *0.5 перед преобразованием в int. Так вот откуда берутся две дополнительные инструкции mulps, и есть cmpps / blendvps.

sum_int_avx(float*):
    vpxor   ymm3, ymm3, ymm3
    xor     eax, eax
    vbroadcastss    ymm0, dword ptr [rip + .LCPI4_0]  ; set1(0.5)
    vbroadcastss    ymm1, dword ptr [rip + .LCPI4_1]  ; set1(3.0)
    vpbroadcastd    ymm2, dword ptr [rip + .LCPI4_2]  ; set1(63)
LBB4_1:                                               ; latencies
    vaddps      ymm4, ymm0, ymmword ptr [rdi + 4*rax] ; 3c
    vrsqrtps    ymm5, ymm4                            ; 7c
    vmulps      ymm4, ymm4, ymm5   ; x*nr             ; 5c
    vfnmadd213ps ymm5, ymm4, ymm1                     ; 5c
    vmulps      ymm4, ymm4, ymm5                      ; 5c
    vcvttps2dq  ymm4, ymm4                            ; 3c
    vpsrld      ymm4, ymm4, 1      ; 1c this would be a mulps (but not on the critical path) if we did this in the FP domain
    vpminsd     ymm4, ymm4, ymm2                      ; 1c
    vpaddd      ymm3, ymm4, ymm3                      ; 1c
    ; ... (those 9 insns repeated: loop unrolling)
    add     rax, 16
    cmp     rax, 4096
    jl      .LBB4_1
    ;... horizontal sum

IACA считает, что без развертывания Haswell может поддерживать пропускную способность в одну итерацию на 4,15 цикла, что является узким местом на портах 0 и 1. Таким образом, потенциально вы можете сократить цикл, накапливая sqrt(x)*2 (с усечением до четных чисел с использованием _mm256_and_si256), и делим только на два вне цикла.

Также согласно IACA, задержка одной итерации составляет 38 циклов в Haswell. Я получаю только 31с, так что, вероятно, он включает задержку загрузки L1 или что-то в этом роде. В любом случае это означает, что для насыщения исполнительных блоков операции из 8 итераций должны выполняться сразу. Это 8 * ~14 unops-uops = 112 unsused-uops (или меньше с развертыванием clang), которые должны быть в полете сразу. Планировщик Haswell на самом деле только 60 записей, но ROB составляет 192 записи. Ранние мопы из ранних итераций уже будут выполнены, поэтому их нужно отслеживать только в ROB, а не в планировщике. Однако, многие из медленных мопов находятся в начале каждой итерации. Тем не менее, есть основания надеяться, что это приблизится к насыщению портов 0 и 1. Если данные не нагреваются в кеше L1, пропускная способность кеша / памяти, вероятно, станет узким местом.

Чередование операций из нескольких цепочек dep также будет лучше. Когда clang развертывается, он помещает все 9 инструкций для одной итерации перед всеми 9 инструкциями для другой итерации. В нем используется удивительно небольшое количество регистров, поэтому было бы возможно смешать инструкции для 2 или 4 итераций. Такого рода вещи должны быть хороши для компиляторов, но это неудобно для людей.:/

Также было бы немного более эффективно, если бы компилятор выбирал режим адресации с одним регистром, чтобы загрузка могла микросинхронизироваться с vaddps, GCC делает это.

Другие вопросы по тегам