Странное преобразование массива uint32_t в float
У меня есть следующий фрагмент кода:
#include <cstdio>
#include <cstdint>
static const size_t ARR_SIZE = 129;
int main()
{
uint32_t value = 2570980487;
uint32_t arr[ARR_SIZE];
for (int x = 0; x < ARR_SIZE; ++x)
arr[x] = value;
float arr_dst[ARR_SIZE];
for (int x = 0; x < ARR_SIZE; ++x)
{
arr_dst[x] = static_cast<float>(arr[x]);
}
printf("%s\n", arr_dst[ARR_SIZE - 1] == arr_dst[ARR_SIZE - 2] ? "OK" : "WTF??!!");
printf("magic = %0.10f\n", arr_dst[ARR_SIZE - 2]);
printf("magic = %0.10f\n", arr_dst[ARR_SIZE - 1]);
return 0;
}
Если я скомпилирую его под MS Visual Studio 2015, я вижу, что вывод:
WTF??!!
magic = 2570980352.0000000000
magic = 2570980608.0000000000
Итак последнее arr_dst
Элемент отличается от предыдущего, но эти два значения были получены путем преобразования одного и того же значения, которое заполняет массив arr! Это ошибка?
Я заметил, что если я изменю цикл преобразования следующим образом, я получу результат "ОК":
for (int x = 0; x < ARR_SIZE; ++x)
{
if (x == 0)
x = 0;
arr_dst[x] = static_cast<float>(arr[x]);
}
Так что это, вероятно, некоторая проблема с оптимизацией векторизации.
Такое поведение не воспроизводится на gcc 4.8. Есть идеи?
3 ответа
Я провел расследование по поводу реализации PowerPC (Freescale MCP7450), так как они ИМХО гораздо лучше задокументированы, чем любая вуду, которую придумывает Intel.
Как оказалось, модуль с плавающей запятой, FPU и векторный модуль могут иметь различное округление для операций с плавающей запятой. FPU может быть настроен на использование одного из четырех режимов округления; округление до ближайшего (по умолчанию) усечения до положительной бесконечности и отрицательной бесконечности. Тем не менее, векторный блок может округляться только до ближайшего, с несколькими инструкциями выбора, имеющими определенные правила округления. Внутренняя точность FPU составляет 106 бит. Векторное устройство соответствует стандарту IEEE-754, но в документации не указано намного больше.
Если посмотреть на ваш результат, преобразование 2570980608 ближе к исходному целому числу, что говорит о том, что FPU имеет лучшую внутреннюю точность, чем векторный блок ИЛИ другие режимы округления.
Преобразование между unsigned
а также float
не просто на x86; для него нет единой инструкции (до AVX512). Обычная техника - преобразовать как подписанный, а затем исправить результат. Есть несколько способов сделать это. (См. Этот раздел вопросов и ответов для некоторых векторизованных вручную методов с внутренними Си-кодами, не все из которых имеют идеально округленные результаты.)
MSVC векторизует первые 128 с одной стратегией, а затем использует другую стратегию (которая не будет векторизовать) для последнего скалярного элемента, что включает преобразование в double
а затем из double
в float
,
GCC и Clang производят 2570980608.0
результат их векторизованных и скалярных методов. 2570980608 - 2570980487 = 121
, а также 2570980487 - 2570980352 = 135
(без округления входов / выходов), поэтому gcc и clang дают правильно округленный результат в этом случае (менее 0,5 миль ошибки). IDK, если это так для каждого возможного uint32_t (но их всего 2^32, мы могли бы провести полную проверку). Конечный результат MSVC для векторизованного цикла имеет чуть более 0,5 пульса ошибки, но скалярный метод правильно округляется для этого ввода.
IEEE математика требует, чтобы +
-
*
/
а также sqrt
производить правильно округленные результаты (менее 0,5 погрешности), но другие функции (например, log
) нет такого строгого требования. IDK, каковы требования к округлению для int->float-преобразований, поэтому IDK, если MSVC делает, строго законно (если вы не использовали /fp:fast
или что-нибудь).
См. Также сообщение в блоге Брюса Доусона " Детерминизм с плавающей точкой" (часть его превосходной серии о математике FP), хотя он не упоминает целочисленные <-> преобразования FP.
В asm, связанном с OP, мы видим, что сделал MSVC (урезан только до интересных инструкций и прокомментирован вручную):
; Function compile flags: /Ogtp
# assembler macro constants
_arr_dst$ = -1040 ; size = 516
_arr$ = -520 ; size = 516
_main PROC ; COMDAT
00013 mov edx, 129
00018 mov eax, -1723986809 ; this is your unsigned 2570980487
0001d mov ecx, edx
00023 lea edi, DWORD PTR _arr$[esp+1088] ; edi=arr
0002a rep stosd ; memset in chunks of 4B
# arr[0..128] = 2570980487 at this point
0002c xor ecx, ecx ; i = 0
# xmm2 = 0.0 in each element (i.e. all-zero)
# xmm3 = __xmm@4f8000004f8000004f8000004f800000 (a constant repeated in each of 4 float elements)
####### The vectorized unsigned->float conversion strategy:
$LL7@main: ; do{
00030 movups xmm0, XMMWORD PTR _arr$[esp+ecx*4+1088] ; load 4 uint32_t
00038 cvtdq2ps xmm1, xmm0 ; SIGNED int to Single-precision float
0003b movaps xmm0, xmm1
0003e cmpltps xmm0, xmm2 ; xmm0 = (xmm0 < 0.0)
00042 andps xmm0, xmm3 ; mask the magic constant
00045 addps xmm0, xmm1 ; x += (x<0.0) ? magic_constant : 0.0f;
# There's no instruction for converting from unsigned to float, so compilers use inconvenient techniques like this to correct the result of converting as signed.
00048 movups XMMWORD PTR _arr_dst$[esp+ecx*4+1088], xmm0 ; store 4 floats to arr_dst
; and repeat the same thing again, with addresses that are 16B higher (+1104)
; i.e. this loop is unrolled by two
0006a add ecx, 8 ; i+=8 (two vectors of 4 elements)
0006d cmp ecx, 128
00073 jb SHORT $LL7@main ; }while(i<128)
#### End of vectorized loop
# and then IDK what MSVC smoking; both these values are known at compile time. Is /Ogtp not full optimization?
# I don't see a branch target that would let execution reach this code
# other than by falling out of the loop that ends with ecx=128
00075 cmp ecx, edx
00077 jae $LN21@main ; if(i>=129): always false
0007d sub edx, ecx ; edx = 129-128 = 1
... более смешные прыжки, известные во время компиляции позже...
######## The scalar unsigned->float conversion strategy for the last element
$LC15@main:
00140 mov eax, DWORD PTR _arr$[esp+ecx*4+1088]
00147 movd xmm0, eax
# eax = xmm0[0] = arr[128]
0014b cvtdq2pd xmm0, xmm0 ; convert the last element TO DOUBLE
0014f shr eax, 31 ; shift the sign bit to bit 1, so eax = 0 or 1
; then eax indexes a 16B constant, selecting either 0 or 0x41f0... (as whatever double that represents)
00152 addsd xmm0, QWORD PTR __xmm@41f00000000000000000000000000000[eax*8]
0015b cvtpd2ps xmm0, xmm0 ; double -> float
0015f movss DWORD PTR _arr_dst$[esp+ecx*4+1088], xmm0 ; and store it
00165 inc ecx ; ++i;
00166 cmp ecx, 129 ; } while(i<129)
0016c jb SHORT $LC15@main
# Yes, this is a loop, which always runs exactly once for the last element
Для сравнения, clang и gcc также не оптимизируют все это во время компиляции, но они понимают, что им не нужен цикл очистки, и просто делают одно скалярное хранилище или конвертируют после соответствующих циклов. (на самом деле лязг полностью разворачивает все, если только вы не скажете этого)
Смотрите код в проводнике компилятора Godbolt.
gcc просто конвертирует верхнюю и нижнюю половину 16b в плавающее отдельно и объединяет их с умножением на 65536 и сложением.
Clang-х unsigned
-> float
Стратегия конверсии интересна: она никогда не использует cvt
инструкция на всех. Я думаю, что он вставляет две 16-разрядные половины целого числа без знака в мантиссу двух чисел напрямую (с некоторыми приемами, чтобы установить показатели (побитовое булево и ADDPS)), а затем складывает половину младшего и старшего разрядов, как это делает gcc.
Конечно, если вы компилируете в 64-битный код, скалярное преобразование может просто увеличить нулю uint32_t
в 64-битный и преобразовать это как подписанное int64_t, чтобы плавать. Подписанное int64_t может представлять каждое значение uint32_t, а x86 может эффективно преобразовать 64-разрядное подписанное int в float. Но это не векторизация.
32-разрядный двоичный код IEEE-754, такой как MSVC++, обеспечивает точность только 6-7 десятичных цифр. Ваше начальное значение находится в пределах диапазона этого типа, но кажется, что оно не может быть точно представлено этим типом, как на самом деле имеет место для большинства значений типа uint32_t
,
В то же время модуль с плавающей запятой процессора x86 или x86_64 использует более широкое представление, чем 64-битный MSVC++ double
, Вероятно, что после выхода из цикла последний вычисленный элемент массива остается в регистре FPU в его расширенной форме точности. Затем программа может использовать это значение непосредственно из регистра, а не считывать его обратно из памяти, что она обязана делать с предыдущими элементами.
Если программа выполняет ==
Сравнение путем продвижения более узкого представления к более широкому, а не наоборот, тогда эти два значения могут действительно сравниваться неравно, как обратное путешествие от расширенной точности к float
и обратно теряет точность. В любом случае оба значения преобразуются в тип double
когда перешел к printf()
; если они действительно сравниваются неравно, то, вероятно, результаты этих преобразований также различаются.
Я не разбираюсь в опциях компиляции MSVC++, но, скорее всего, есть такой, который бы отменил это поведение. Такие параметры иногда называются "строгая математика" или "строгий фп". Имейте в виду, однако, что включение такой опции (или отключение ее противоположности) может быть очень дорогостоящим в программе с большим количеством FP.