Strange uint32_t для преобразования массива с плавающей запятой

У меня есть следующий фрагмент кода:

#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. Любые идеи?

Ответ 1

Я провел исследование по внедрению PowerPC (Freescale MCP7450), поскольку они IMHO гораздо лучше документированы, чем любой вуду Intel.

Как оказалось, блок с плавающей точкой, FPU и векторный блок могут иметь различное округление для операций с плавающей запятой. FPU может быть настроен на использование одного из четырех режимов округления; округление до ближайшего (по умолчанию), усечение, к положительной бесконечности и к отрицательной бесконечности. Однако векторная единица может только округлять до ближайшего, с несколькими инструкциями выбора, имеющими конкретные правила округления. Внутренняя точность FPU составляет 106 бит. Единица вектора соответствует IEEE-754, но документация не указала намного больше.

Глядя на ваш результат, преобразование 2570980608 ближе к исходному целому числу, предполагая, что FPU имеет лучшую внутреннюю точность, чем векторная единица ИЛИ различные режимы округления.

Ответ 2

32-битный двоичный float IEEE-754, такой как MSVС++, использует только 6-7 десятичных цифр точности. Ваше начальное значение находится в пределах диапазона этого типа, но похоже, что он не может быть точно представлен этим типом, как это действительно имеет место для большинства значений типа uint32_t.

В то же время блок с плавающей точкой процессора x86 или x86_64 использует более широкое представление, отличное от MSVС++ 64-бит double. Кажется вероятным, что после выхода цикла, последний вычисленный элемент массива остается в регистре FPU в форме расширенной точности. Затем программа может использовать это значение непосредственно из регистра вместо того, чтобы читать его обратно из памяти, которое оно обязано делать с предыдущими элементами.

Если программа выполняет сравнение ==, продвигая более узкое представление к более широкому, а не наоборот, то эти два значения могут действительно сравнивать неравные, так как округление от расширенной точности до float и обратно теряет точность. В любом случае оба значения преобразуются в тип double при передаче на printf(); если действительно они сравнивают неравные, то, скорее всего, результаты этих преобразований тоже отличаются.

Я не использую параметры компиляции MSVС++, но, скорее всего, есть тот, который отменяет это поведение. Такие варианты иногда бывают такими именами, как "строгая математика" или "строгое fp". Однако имейте в виду, что включение такой опции (или отключение ее противоположности) может оказаться очень дорогостоящим в программе FP-heavy.

Ответ 3

Преобразование между unsigned и float не просто на x86; для него нет единой инструкции (до AVX512). Общим методом является преобразование как подписанное, а затем исправление результата. Существует несколько способов сделать это. (См. этот Q & A для некоторых рутинных векторизованных методов с использованием C intrinsics, не все из которых имеют идеально округленные результаты.)

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 = [email protected]  (a constant repeated in each of 4 float elements)


  ####### The vectorized unsigned->float conversion strategy:
  [email protected]:                                       ; 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 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 [email protected]    ; }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     [email protected]     ; if(i>=129): always false

  0007d      sub     edx, ecx       ; edx = 129-128 = 1

... несколько более смешных, известных в момент компиляции, время спустя...

 ######## The scalar unsigned->float conversion strategy for the last element
[email protected]:
  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 [email protected][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 [email protected]
  # Yes, this is a loop, which always runs exactly once for the last element

Для сравнения, clang и gcc также не оптимизируют всю вещь во время компиляции, но они понимают, что они не нуждаются в цикле очистки, и просто делают одно скалярное хранилище или конвертируют после соответствующего петли. (clang фактически полностью разворачивает все, пока вы не скажете об этом не.)

См. код в проводник компилятора Godbolt.

gcc только преобразует верхнюю и нижнюю половинки 16b для поплавка отдельно и объединяет их с умножением на 65536 и добавляет.

Стратегия преобразования Clang unsignedfloat интересна: она никогда не использует инструкцию cvt вообще. Я думаю, что это заставляет две 16-разрядные половинки беззнакового целого в мантиссу двух поплавков напрямую (с некоторыми трюками, чтобы установить экспоненты (побитовые булевы и ADDPS), а затем добавляет низкую и высокую половину вместе, как gcc.

Конечно, если вы скомпилируете 64-битный код, скалярное преобразование может только нулевое расширение uint32_t до 64-битного и преобразовать его как подписанный int64_t для float. Подписанный int64_t может представлять каждое значение uint32_t, а x86 может конвертировать 64-разрядный подписанный int, чтобы плавать эффективно. Но это не векторизация.