Эффективная целочисленная функция пола в С++
Я хочу определить эффективную целочисленную функцию пола, то есть преобразование из числа с плавающей запятой или двойного, которое выполняет усечение до минус бесконечности.
Можно предположить, что значения таковы, что целочисленное переполнение не происходит. Пока у меня есть несколько вариантов
-
приведение к int; это требует специальной обработки отрицательных значений, поскольку приведение усекается до нуля;
I= int(F); if (I < 0 && I != F) I--;
-
приведение результата пола к int;
int(floor(F));
-
приведение к int с большим сдвигом для получения положительных результатов (это может вернуть неправильные результаты для больших значений);
int(F + double(0x7fffffff)) - 0x7fffffff;
Преобразование в int заведомо медленное. Так и есть, если тесты. Я не приурочил функцию пола, но видел сообщения, утверждающие, что это также медленно.
Можете ли вы придумать лучшие альтернативы с точки зрения скорости, точности или допустимого диапазона? Это не должно быть портативным. Цели - последние архитектуры x86/x64.
Ответы
Ответ 1
Посмотрите на магические числа. Алгоритм, предложенный на веб-странице, должен быть гораздо более эффективным, чем простое приведение. Я никогда не использовал его сам, но вот сравнение производительности, которое они предлагают на сайте (xs_ToInt и xs_CRoundToInt - предлагаемые функции):
Performing 10000000 times:
simple cast 2819 ms i.e. i = (long)f;
xs_ToInt 1242 ms i.e. i = xs_ToInt(f); //numerically same as above
bit-twiddle(full) 1093 ms i.e. i = BitConvertToInt(f); //rounding from Fluid
fistp 676 ms i.e. i = FISTToInt(f); //Herf, et al x86 Assembly rounding
bit-twiddle(limited) 623 ms i.e. i = FloatTo23Bits(f); //Herf, rounding only in the range (0...1]
xs_CRoundToInt 609 ms i.e. i = xs_CRoundToInt(f); //rounding with "magic" numbers
Кроме того, xs_ToInt, видимо, модифицируется, чтобы улучшить производительность:
Performing 10000000 times:
simple cast convert 3186 ms i.e. fi = (f*65536);
fistp convert 3031 ms i.e. fi = FISTToInt(f*65536);
xs_ToFix 622 ms i.e. fi = xs_Fix<16>::ToFix(f);
Краткое объяснение того, как работает метод "магических чисел":
"По сути, чтобы добавить два числа с плавающей запятой, ваш процессор" выстраивает "десятичные точки чисел так, чтобы он мог легко добавлять биты. Он делает это путем" нормализации "чисел таким образом, чтобы сохранялись старшие значащие биты. т.е. меньшее число "нормализуется", чтобы соответствовать большему. Таким образом, принцип преобразования "магического числа", которое использует xs_CRoundToInt(), заключается в следующем: мы добавляем достаточно большое число с плавающей запятой (число, которое настолько велико, что существует значащие цифры только до десятичной точки и ни одной после нее) до той, которую вы преобразуете так, что: (a) число нормализуется процессором до его целочисленного эквивалента и (b) добавление двух не стирает интеграл значащие биты в числе, которое вы пытались преобразовать (т.е. XX00 + 00YY = XXYY). "
Цитата взята с той же веб-страницы.
Ответ 2
Преобразование в int заведомо медленное.
Может быть, вы жили в безвыходном положении с x86-64 или иначе упустили из виду, что это не было верно в течение некоторого времени на x86. :)
SSE/SSE2 имеют инструкцию для преобразования с усечением (вместо режима округления по умолчанию). ISA эффективно поддерживает эту операцию именно потому, что преобразование с семантикой C не редкость в реальных кодовых базах. Код x86-64 использует регистры XMM SSE/SSE2 для скалярной математики FP, а не x87, из-за этого и других факторов, делающих его более эффективным. Даже современный 32-битный код использует XMM-регистры для скалярной математики.
При компиляции для x87 (без SSE3 fisttp
) компиляторам приходилось менять режим округления x87 на усечение, сохранять FP в памяти, а затем снова изменять режим округления. (А затем перезагрузите целое число из памяти, как правило, из локального стека, если с ним что-то делать.) X87 был ужасен для этого.
Да, это было ужасно медленно, например, в 2006 году, когда была написана ссылка в ответе @Kirjain, если у вас все еще был 32-разрядный процессор или вы использовали процессор x86-64 для запуска 32-разрядного кода.
Конвертация с использованием режима округления, отличного от усечения или по умолчанию (ближайшего), напрямую не поддерживается, и до тех пор, пока в roundps
/roundpd
SSE4.1 не roundps
roundpd
всего использовать трюки с магическим числом, как в ссылке 2006 года из ответа @Kirjain.
Там есть несколько приятных трюков, но только для double
→ 32-разрядного целого числа. Вряд ли стоит расширяться в double
если у вас есть float
.
Или, как правило, просто добавьте число большой величины для запуска округления, а затем вычтите его снова, чтобы вернуться к исходному диапазону. Это может работать для float
без расширения до double
, но я не уверен, насколько легко сделать работу floor
.
В любом случае, очевидным решением здесь являются _mm256_floor_ps()
и _mm256_cvtps_epi32
(vroundps
и vcvtps2dq
). Не-AVX версия этого может работать с SSE4.1.
Я не уверен, сможем ли мы сделать еще лучше; Если у вас был огромный массив для обработки (и вам не удавалось чередовать эту работу с другой работой), вы могли бы установить режим округления MXCSR на "в сторону -Inf" (этаж) и просто использовать vcvtps2dq
(который использует текущее округление Режим). Затем установите его обратно. Но, вероятно, лучше кешировать блокировку вашего преобразования или делать это на лету, когда вы генерируете данные, предположительно из других вычислений FP, для которых режим округления FP должен быть установлен в значение по умолчанию Ближайший.
roundps
/pd/ss/sd - 2 моп на процессорах Intel, но только 1 моп (на 128-битную дорожку) на AMD Ryzen. cvtps2dq
также 1 моп. Пакетное double-> int преобразование также включает случайное перемешивание. Скалярное преобразование FP-> int (которое копирует в регистр целых чисел) обычно также стоит для этого дополнительного мопа.
Так что в некоторых случаях есть вероятность того, что трюки с магическими числами будут выигрышем; возможно, стоит разобраться, являются ли _mm256_floor_ps()
+ cvt частью критического узкого места (или, более вероятно, если у вас есть double и вы хотите int32).
@Cássio Renan int foo = floorf(f)
будет фактически векторизоваться, если скомпилирован с помощью gcc -O3 -fno-trapping-math
(или -ffast-math
), с -march=
чем-то, что имеет SSE4.1 или AVX. https://godbolt.org/z/ae_KPv
Это может быть полезно, если вы используете это с другим скалярным кодом, который не векторизован вручную. Особенно, если вы надеетесь, что компилятор будет автоматически векторизовать все это.
Ответ 3
Если вы делаете это в пакетном режиме, компилятор может автоматически векторизовать его, если вы знаете, что делаете. Например, вот небольшая реализация, которая автоматически векторизует преобразование чисел с плавающей точкой в GCC:
#include <cmath>
// Compile with -O3 and -march=native to see autovectorization
__attribute__((optimize("-fno-trapping-math")))
void testFunction(float* input, int* output, int length) {
// Assume the input and output are aligned on a 32-bit boundary.
// Of course, you have to ensure this when calling testFunction, or else
// you will have problems.
input = static_cast<float*>(__builtin_assume_aligned(input, 32));
output = static_cast<int*>(__builtin_assume_aligned(output, 32));
// Also assume the length is a multiple of 32.
if (length & 31) __builtin_unreachable();
// Do the conversion
for (int i = 0; i < length; ++i) {
output[i] = floor(input[i]);
}
}
Это сгенерированная сборка для x86-64 (с инструкциями AVX512):
testFunction(float*, int*, int):
test edx, edx
jle .L5
lea ecx, [rdx-1]
xor eax, eax
.L3:
# you can see here that the conversion was vectorized
# to a vrndscaleps (that will round the float appropriately)
# and a vcvttps2dq (thal will perform the conversion)
vrndscaleps ymm0, YMMWORD PTR [rdi+rax], 1
vcvttps2dq ymm0, ymm0
vmovdqa64 YMMWORD PTR [rsi+rax], ymm0
add rax, 32
cmp rax, rdx
jne .L3
vzeroupper
.L5:
ret
Если ваша цель не поддерживает AVX512, она все равно будет автоматически выполнять векторизацию с использованием инструкций SSE4.1, если они у вас есть. Это вывод с -O3 -msse4.1
:
testFunction(float*, int*, int):
test edx, edx
jle .L1
shr edx, 2
xor eax, eax
sal rdx, 4
.L3:
roundps xmm0, XMMWORD PTR [rdi+rax], 1
cvttps2dq xmm0, xmm0
movaps XMMWORD PTR [rsi+rax], xmm0
add rax, 16
cmp rax, rdx
jne .L3
.L1:
ret
Ответ 4
Почему бы просто не использовать это:
#include <cmath>
auto floor_(float const x) noexcept
{
int const t(x);
return t - (t > x);
}
Ответ 5
Вот модификация Кассио Ренанс отличный ответ. Он заменяет все специфичные для компилятора расширения стандартным C++ и теоретически переносим на любой соответствующий компилятор. Кроме того, он проверяет, что аргументы правильно выровнены, а не предполагают это. Это оптимизирует к тому же коду.
#include <assert.h>
#include <cmath>
#include <stddef.h>
#include <stdint.h>
#define ALIGNMENT alignof(max_align_t)
using std::floor;
// Compiled with: -std=c++17 -Wall -Wextra -Wpedantic -Wconversion -fno-trapping-math -O -march=cannonlake -mprefer-vector-width=512
void testFunction(const float in[], int32_t out[], const ptrdiff_t length)
{
static_assert(sizeof(float) == sizeof(int32_t), "");
assert((uintptr_t)(void*)in % ALIGNMENT == 0);
assert((uintptr_t)(void*)out % ALIGNMENT == 0);
assert((size_t)length % (ALIGNMENT/sizeof(int32_t)) == 0);
alignas(ALIGNMENT) const float* const input = in;
alignas(ALIGNMENT) int32_t* const output = out;
// Do the conversion
for (int i = 0; i < length; ++i) {
output[i] = static_cast<int32_t>(floor(input[i]));
}
}
Это не так хорошо оптимизирует GCC, как оригинал, который использовал непереносимые расширения. Стандарт C++ поддерживает спецификатор alignas
, ссылки на выровненные массивы и функцию std::align
которая возвращает выровненный диапазон в буфере. Однако ни один из них не позволяет любому протестированному компилятору генерировать выровненные, а не выровненные векторные нагрузки и хранилища.
Хотя alignof(max_align_t)
составляет только 16 для x86_64, и можно определить ALIGNMENT
как константу 64, это не поможет любому компилятору генерировать лучший код, поэтому я остановился на переносимости. Наиболее близким к переносимому способу заставить компилятор предположить, что poitner выровнен, будет использование типов из <immintrin.h>
, которые большинство компиляторов для x86 поддерживают, или определение struct
с помощью alignas
. Проверяя предопределенные макросы, вы также можете расширить макрос до __attribute__ ((aligned (ALIGNMENT)))
в компиляторах Linux или __declspec (align (ALIGNMENT))
в компиляторах Windows, и что-то более безопасное для компилятора, о котором мы не знаем, но GCC нуждается в атрибуте типа для фактического создания выровненных загрузок и хранилищ.
Кроме того, в оригинальном примере вызывался скрытый вход, чтобы сообщить GCC, что length
не может быть кратна 32. Если вы assert()
это или вызываете стандартную функцию, такую как abort()
, ни GCC, ни Clang, ни ICC сделает такой же вывод. Таким образом, большая часть кода, который они генерируют, будет обрабатывать случай, когда length
не является кратным кратным векторной ширине.
Вероятная причина этого заключается в том, что ни одна оптимизация не дает вам такой большой скорости: инструкции невыровненной памяти с выровненными адресами быстры на процессорах Intel, а код для обработки случая, когда length
не является хорошим круглым числом, имеет длину несколько байтов и выполняется в постоянное время
В качестве сноски GCC может оптимизировать встроенные функции из <cmath>
лучше, чем макросы, реализованные в <math.c>
.
GCC 9.1 требуется определенный набор опций для генерации кода AVX512. По умолчанию, даже с -march=cannonlake
, он предпочтет 256-битные векторы. Для генерации 512-битного кода требуется параметр -mprefer-vector-width=512
. (Спасибо Peter Cordes за указание на это.) Далее следует векторизованный цикл с развернутым кодом для преобразования любых оставшихся элементов массива.
Вот векторизованный основной цикл, за исключением некоторой инициализации с постоянным временем, проверки ошибок и кода очистки, который будет выполняться только один раз:
.L7:
vrndscaleps zmm0, ZMMWORD PTR [rdi+rax], 1
vcvttps2dq zmm0, zmm0
vmovdqu32 ZMMWORD PTR [rsi+rax], zmm0
add rax, 64
cmp rax, rcx
jne .L7
Eagle-eyed заметит два отличия от кода, сгенерированного программой Cássio Renans: он использует% zmm вместо регистров% ymm и сохраняет результаты с выровненным vmovdqu32
а не с выровненным vmovdqa64
.
Clang 8.0.0 с одинаковыми флагами делает разные варианты развертывания циклов. Каждая итерация работает с восемью 512-битными векторами (то есть 128 плавающими одинарной точности), но код для сбора остатков не разворачивается. Если после этого осталось не менее 64 операций с плавающей запятой, он использует для них еще четыре инструкции AVX512, а затем очищает все дополнения с помощью невекторизованного цикла.
Если вы скомпилируете исходную программу в Clang++, она примет ее без жалоб, но не будет выполнять те же оптимизации: она по-прежнему не будет предполагать, что length
кратна ширине вектора или что указатели выровнены.
Он предпочитает код AVX512 AVX256, даже без -mprefer-vector-width=512
.
test rdx, rdx
jle .LBB0_14
cmp rdx, 63
ja .LBB0_6
xor eax, eax
jmp .LBB0_13
.LBB0_6:
mov rax, rdx
and rax, -64
lea r9, [rax - 64]
mov r10, r9
shr r10, 6
add r10, 1
mov r8d, r10d
and r8d, 1
test r9, r9
je .LBB0_7
mov ecx, 1
sub rcx, r10
lea r9, [r8 + rcx]
add r9, -1
xor ecx, ecx
.LBB0_9: # =>This Inner Loop Header: Depth=1
vrndscaleps zmm0, zmmword ptr [rdi + 4*rcx], 9
vrndscaleps zmm1, zmmword ptr [rdi + 4*rcx + 64], 9
vrndscaleps zmm2, zmmword ptr [rdi + 4*rcx + 128], 9
vrndscaleps zmm3, zmmword ptr [rdi + 4*rcx + 192], 9
vcvttps2dq zmm0, zmm0
vcvttps2dq zmm1, zmm1
vcvttps2dq zmm2, zmm2
vmovups zmmword ptr [rsi + 4*rcx], zmm0
vmovups zmmword ptr [rsi + 4*rcx + 64], zmm1
vmovups zmmword ptr [rsi + 4*rcx + 128], zmm2
vcvttps2dq zmm0, zmm3
vmovups zmmword ptr [rsi + 4*rcx + 192], zmm0
vrndscaleps zmm0, zmmword ptr [rdi + 4*rcx + 256], 9
vrndscaleps zmm1, zmmword ptr [rdi + 4*rcx + 320], 9
vrndscaleps zmm2, zmmword ptr [rdi + 4*rcx + 384], 9
vrndscaleps zmm3, zmmword ptr [rdi + 4*rcx + 448], 9
vcvttps2dq zmm0, zmm0
vcvttps2dq zmm1, zmm1
vcvttps2dq zmm2, zmm2
vcvttps2dq zmm3, zmm3
vmovups zmmword ptr [rsi + 4*rcx + 256], zmm0
vmovups zmmword ptr [rsi + 4*rcx + 320], zmm1
vmovups zmmword ptr [rsi + 4*rcx + 384], zmm2
vmovups zmmword ptr [rsi + 4*rcx + 448], zmm3
sub rcx, -128
add r9, 2
jne .LBB0_9
test r8, r8
je .LBB0_12
.LBB0_11:
vrndscaleps zmm0, zmmword ptr [rdi + 4*rcx], 9
vrndscaleps zmm1, zmmword ptr [rdi + 4*rcx + 64], 9
vrndscaleps zmm2, zmmword ptr [rdi + 4*rcx + 128], 9
vrndscaleps zmm3, zmmword ptr [rdi + 4*rcx + 192], 9
vcvttps2dq zmm0, zmm0
vcvttps2dq zmm1, zmm1
vcvttps2dq zmm2, zmm2
vcvttps2dq zmm3, zmm3
vmovups zmmword ptr [rsi + 4*rcx], zmm0
vmovups zmmword ptr [rsi + 4*rcx + 64], zmm1
vmovups zmmword ptr [rsi + 4*rcx + 128], zmm2
vmovups zmmword ptr [rsi + 4*rcx + 192], zmm3
.LBB0_12:
cmp rax, rdx
je .LBB0_14
.LBB0_13: # =>This Inner Loop Header: Depth=1
vmovss xmm0, dword ptr [rdi + 4*rax] # xmm0 = mem[0],zero,zero,zero
vroundss xmm0, xmm0, xmm0, 9
vcvttss2si ecx, xmm0
mov dword ptr [rsi + 4*rax], ecx
add rax, 1
cmp rdx, rax
jne .LBB0_13
.LBB0_14:
pop rax
vzeroupper
ret
.LBB0_7:
xor ecx, ecx
test r8, r8
jne .LBB0_11
jmp .LBB0_12
ICC 19 также генерирует инструкции AVX512, но сильно отличается от clang
. Он больше настраивается с помощью магических констант, но не разворачивает никаких циклов, работая вместо этого на 512-битных векторах.
Этот код также работает на других компиляторах и архитектурах. (Хотя MSVC поддерживает только ISA вплоть до AVX2 и не может автоматически векторизовать цикл.) Например, в ARM с -march=armv8-a+simd
он генерирует векторизованный цикл с frintm v0.4s, v0.4s
и fcvtzs v0.4s, v0.4s
.
Попробуйте сами.