Операции FPU, генерируемые GCC во время преобразования целого числа в число с плавающей запятой

Я хочу выполнить деление на FPU в C (используя целочисленные значения):

float foo;
uint32_t *ptr1, *ptr2;
foo = (float)*(ptr1) / (float)*(ptr2);

А в NASM (из объекта, скомпилированного через GCC) он имеет следующее представление:

    mov     rax, QWORD [ptr1]
    mov     eax, DWORD [rax]
    mov     eax, eax
    test    rax, rax
    js      ?_001
    pxor    xmm0, xmm0
    cvtsi2ss xmm0, rax
    jmp     ?_002

?_001:
    mov     rdx, rax
    shr     rdx, 1
    and     eax, 01H
    or      rdx, rax
    pxor    xmm0, xmm0
    cvtsi2ss xmm0, rdx
    addss   xmm0, xmm0
?_002:
    mov     rax, QWORD [ptr2]

; ... for ptr2 pattern repeats

Что означает эта "черная магия" под ?_001? Разве не достаточно cvtsi2ss для преобразования целого числа в число с плавающей запятой?


person saleph    schedule 05.01.2017    source источник
comment
cvtsi2ss — это целочисленное преобразование со знаком. Нет беззнакового эквивалента. Так что компилятору нужно обойти это.   -  person Mysticial    schedule 06.01.2017


Ответы (2)


В общем, cvtsi2ss делает свое дело - преобразует скалярное целое число (другие источники называют его целым числом двойного слова в одиночное скалярное, но мое наименование согласуется с другими векторными входами) в скалярное одиночное (с плавающей запятой). Но он ожидает целое число со знаком.

Итак, этот код

mov     rdx, rax                                
shr     rdx, 1                                  
and     eax, 01H                                
or      rdx, rax                                
pxor    xmm0, xmm0                              
cvtsi2ss xmm0, rdx                              
addss   xmm0, xmm0  

помогите преобразовать неподписанное в подписанное (обратите внимание на js jump — если установлен знаковый бит, этот код выполняется, в противном случае он пропускается). Знак устанавливается, когда значение больше 0x7FFFFFFF для uint32_t.

Итак, «магический» код делает:

mov     rdx, rax       ; move value from ptr1 to edx                         
shr     rdx, 1         ; div by 2 - logic shift not arithmetic because ptr1 is unsigned
and     eax, 01H       ; save least significant bit                          
or      rdx, rax       ; move this bit to divided value to someway fix rounding errors                         
pxor    xmm0, xmm0                              
cvtsi2ss xmm0, rdx                              
addss   xmm0, xmm0     ; add to itself = multiply by 2

Я не уверен, какой компилятор и какие параметры компиляции вы используете - GCC делает просто

cvtsi2ssq       xmm0, rbx
cvtsi2ssq       xmm1, rax
divss   xmm0, xmm1

Я надеюсь, что это помогает.

person Anty    schedule 05.01.2017
comment
отличное объяснение! Теперь все ясно. Я скомпилировал это только с флагом -g - person saleph; 06.01.2017
comment
Это похоже на безмозглый компилятор, расширяющий ноль 32->64-бит, а затем использующий общий шаблон преобразования uint64_t -> float, который работает, даже если установлен старший бит! Я могу воспроизвести пропущенную оптимизацию с помощью GCC10 -O0 godbolt.org/z/KMs97r. -O0 по умолчанию. При любом уровне оптимизации GCC, конечно же, использует 64-битное преобразование со знаком в число с плавающей запятой после нулевого расширения. (Или с AVX512 доступно беззнаковое 32-битное преобразование в плавающее, vcvtusi2ss. Не имеет большого значения для uint32_t в 64-битном режиме, но очень удобно для uint64_t и для упакованного преобразования) - person Peter Cordes; 18.10.2020

Вы, должно быть, видите неоптимизированный код. Это пустая трата времени. Когда оптимизатор отключен, компиляторы генерируют кучу бессмысленного кода по разным причинам — для достижения более высокой скорости компиляции, для упрощения установки точек останова на строках исходного кода, для облегчения отлова ошибок и т. д.

Когда вы создаете оптимизированный код на компиляторе, ориентированном на x86-64, весь этот шум исчезает, код становится намного эффективнее и, как следствие, его намного легче интерпретировать/понимать.

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

float DivideAsFloat(uint32_t *ptr1, uint32_t *ptr2)
{
    return (float)(*ptr1) / (float)(*ptr2);
}

Вот объектный код, который все версии GCC (начиная с 4.9.0) генерируют для этой функции:

DivideAsFloat(unsigned int*, unsigned int*):
    mov        eax, DWORD PTR [rdi]   ; retrieve value of 'ptr1' parameter
    pxor       xmm0, xmm0             ; zero-out xmm0 register
    pxor       xmm1, xmm1             ; zero-out xmm1 register
    cvtsi2ssq  xmm0, rax              ; convert *ptr1 into a floating-point value in XMM0
    mov        eax, DWORD PTR [rsi]   ; retrieve value of 'ptr2' parameter
    cvtsi2ssq  xmm1, rax              ; convert *ptr2 into a floating-point value in XMM1
    divss      xmm0, xmm1             ; divide the two floating-point values
    ret

Это почти то, что вы ожидаете увидеть. Единственная «черная магия» здесь — это PXOR инструкции. Почему компилятор утруждает себя предварительным обнулением регистров XMM перед выполнением инструкции CVTSI2SS, которая все равно их уничтожит? Ну, потому что CVTSI2SS только частично затирает регистр назначения. В частности, он затирает только младшие биты, оставляя верхние биты нетронутыми. Это приводит к ложной зависимости от старших битов, что приводит к остановке выполнения. Эту зависимость можно разорвать, предварительно обнулив регистр, что предотвратит возможность зависаний и ускорит выполнение. Инструкция PXOR — это быстрый и эффективный способ очистки регистра. (Я недавно говорил об этом точно таком же явлении здесь—см. последний абзац.)

Фактически, более старые версии GCC (до 4.9.0) не выполняли эту оптимизацию и, таким образом, генерировали код, который не включал инструкции PXOR. Он выглядит более эффективным, но на самом деле работает медленнее.

DivideAsFloat(unsigned int*, unsigned int*):
    mov        eax, DWORD PTR [rdi]   ; retrieve value of 'ptr1' parameter
    cvtsi2ssq  xmm0, rax              ; convert *ptr1 into a floating-point value in XMM0
    mov        eax, DWORD PTR [rsi]   ; retrieve value of 'ptr2' parameter
    cvtsi2ssq  xmm1, rax              ; convert *ptr2 into a floating-point value in XMM1
    divss      xmm0, xmm1             ; divide the two floating-point values
    ret

Clang 3.9 выдает тот же код, что и эти старые версии GCC. Он также не знает об оптимизации. MSVC знает об этом (начиная с VS 2010), как и современные версии ICC (проверено на ICC 16 и более поздних версиях; отсутствует в ICC 13).

Однако это не означает, что ответ Энтикомментарий Mystical) совершенно неверен. CVTSI2SS действительно предназначен для преобразования целого числа со знаком в скалярное число с плавающей запятой одинарной точности, а не целое число unsigned, как здесь. Так что дает? Ну, 64-битный процессор имеет доступные 64-битные регистры, поэтому 32-битные входные значения без знака могут храниться как 64-битные промежуточные значения со знаком, что, в конце концов, позволяет использовать CVTSI2SS.

Компиляторы делают это, когда оптимизация включена, потому что это приводит к более эффективному коду. Если, с другой стороны, вы ориентируетесь на 32-разрядную архитектуру x86 и не имеете доступных 64-разрядных регистров, компилятору придется решать проблему со знаком и без знака. Вот как с этим справляется GCC 6.3:

DivideAsFloat(unsigned int*, unsigned int*):
    sub       esp,  4                 
    pxor      xmm0, xmm0              
    mov       eax,  DWORD PTR [esp+8] 
    pxor      xmm1, xmm1              
    movss     xmm3, 1199570944        
    pxor      xmm2, xmm2              
    mov       eax,  DWORD PTR [eax]   
    movzx     edx,  ax                
    shr       eax,  16                
    cvtsi2ss  xmm0, eax               
    mov       eax,  DWORD PTR [esp+12]
    cvtsi2ss  xmm1, edx               
    mov       eax,  DWORD PTR [eax]   
    movzx     edx,  ax                
    shr       eax,  16                
    cvtsi2ss  xmm2, edx               
    mulss     xmm0, xmm3              
    addss     xmm0, xmm1              
    pxor      xmm1, xmm1              
    cvtsi2ss  xmm1, eax               
    mulss     xmm1, xmm3
    addss     xmm1, xmm2
    divss     xmm0, xmm1
    movss     DWORD PTR [esp], xmm0
    fld       DWORD PTR [esp]
    add       esp,  4
    ret

Это немного сложно понять из-за того, как оптимизатор переупорядочивает и чередует инструкции. Здесь я «неоптимизировал» его, переупорядочив инструкции и разбив их на более логические группы в надежде упростить отслеживание потока выполнения. (Единственной инструкцией, которую я удалил, была PXOR, разрушающая зависимости — остальная часть кода осталась прежней, просто перегруппирована.)

DivideAsFloat(unsigned int*, unsigned int*):
  ;;; Initialization ;;;
  sub       esp,  4           ; reserve 4 bytes on the stack

  pxor      xmm0, xmm0        ; zero-out XMM0
  pxor      xmm1, xmm1        ; zero-out XMM1
  pxor      xmm2, xmm2        ; zero-out XMM2
  movss     xmm3, 1199570944  ; load a constant into XMM3


  ;;; Deal with the first value ('ptr1') ;;;
  mov       eax,  DWORD PTR [esp+8]  ; get the pointer specified in 'ptr1'
  mov       eax,  DWORD PTR [eax]    ; dereference the pointer specified by 'ptr1'
  movzx     edx,  ax                 ; put the lower 16 bits of *ptr1 in EDX
  shr       eax,  16                 ; move the upper 16 bits of *ptr1 down to the lower 16 bits in EAX
  cvtsi2ss  xmm0, eax                ; convert the upper 16 bits of *ptr1 to a float
  cvtsi2ss  xmm1, edx                ; convert the lower 16 bits of *ptr1 (now in EDX) to a float

  mulss     xmm0, xmm3               ; multiply FP-representation of upper 16 bits of *ptr1 by magic number
  addss     xmm0, xmm1               ; add the result to the FP-representation of *ptr1's lower 16 bits


  ;;; Deal with the second value ('ptr2') ;;;
  mov       eax,  DWORD PTR [esp+12] ; get the pointer specified in 'ptr2'
  mov       eax,  DWORD PTR [eax]    ; dereference the pointer specified by 'ptr2'
  movzx     edx,  ax                 ; put the lower 16 bits of *ptr2 in EDX
  shr       eax,  16                 ; move the upper 16 bits of *ptr2 down to the lower 16 bits in EAX
  cvtsi2ss  xmm2, edx                ; convert the lower 16 bits of *ptr2 (now in EDX) to a float
  cvtsi2ss  xmm1, eax                ; convert the upper 16 bits of *ptr2 to a float

  mulss     xmm1, xmm3               ; multiply FP-representation of upper 16 bits of *ptr2 by magic number
  addss     xmm1, xmm2               ; add the result to the FP-representation of *ptr2's lower 16 bits


  ;;; Do the division, and return the result ;;;
  divss     xmm0, xmm1               ; FINALLY, divide the FP-representation of *ptr1 by *ptr2
  movss     DWORD PTR [esp], xmm0    ; store this result onto the stack, in the memory we reserved
  fld       DWORD PTR [esp]          ; load this result onto the top of the x87 FPU
                                     ;  (the 32-bit calling convention requires floating-point values be returned this way)

  add       esp,  4                  ; clean up the space we allocated on the stack
  ret

Обратите внимание, что стратегия здесь состоит в том, чтобы разбить каждое из 32-битных целых чисел без знака на две его 16-битные половины. Верхняя половина преобразуется в представление с плавающей запятой и умножается на магическое число (для компенсации знаковости). Затем нижняя половина преобразуется в представление с плавающей запятой, и эти два представления с плавающей запятой (каждая 16-битная половина исходного 32-битного значения) складываются вместе. Это делается дважды — по одному разу для каждого 32-битного входного значения (см. две «группы» инструкций). Затем, наконец, два результирующих представления с плавающей запятой делятся и возвращается результат.

Логика аналогична тому, что делает неоптимизированный код, но более оптимальна. В частности, удаляются избыточные инструкции, а алгоритм обобщается таким образом, что отпадает необходимость в ветвлении по подписке. Это ускоряет работу, потому что неправильно предсказанные ветки выполняются медленно.

Обратите внимание, что Clang использует немного другую стратегию и может генерировать здесь даже более оптимальный код, чем это делает GCC:

DivideAsFloat(unsigned int*, unsigned int*):
   push     eax                       ; reserve 4 bytes on the stack

   mov      eax,  DWORD PTR [esp+12]  ; get the pointer specified in 'ptr2'
   mov      ecx,  DWORD PTR [esp+8]   ; get the pointer specified in 'ptr1'
   movsd    xmm1, QWORD PTR 4841369599423283200 ; load a constant into XMM1

   movd     xmm0, DWORD PTR [ecx]     ; dereference the pointer specified by 'ptr1',
                                      ;  and load the bits directly into XMM0
   movd     xmm2, DWORD PTR [eax]     ; dereference the pointer specified by 'ptr2'
                                      ;  and load the bits directly into XMM2

   orpd     xmm0, xmm1                ; bitwise-OR *ptr1's raw bits with the magic number
   orpd     xmm2, xmm1                ; bitwise-OR *ptr2's raw bits with the magic number

   subsd    xmm0, xmm1                ; subtract the magic number from the result of the OR
   subsd    xmm2, xmm1                ; subtract the magic number from the result of the OR

   cvtsd2ss xmm0, xmm0                ; convert *ptr1 from single-precision to double-precision in place
   xorps    xmm1, xmm1                ; zero register to break dependencies
   cvtsd2ss xmm1, xmm2                ; convert *ptr2 from single-precision to double-precision, putting result in XMM1

   divss    xmm0, xmm1                ; FINALLY, do the division on the single-precision FP values
   movss    DWORD PTR [esp], xmm0     ; store this result onto the stack, in the memory we reserved
   fld       DWORD PTR [esp]          ; load this result onto the top of the x87 FPU
                                      ;  (the 32-bit calling convention requires floating-point values be returned this way)

   pop      eax                       ; clean up the space we allocated on the stack
   ret

Он даже не использует инструкцию CVTSI2SS! Вместо этого он загружает целые биты и манипулирует ими с помощью некоторого волшебного преобразования битов, поэтому он может рассматривать его как значение с плавающей запятой двойной точности. Немного поработав немного позже, он использует CVTSD2SS для преобразования каждого из этих значений с плавающей запятой двойной точности в значения с плавающей запятой одинарной точности. Наконец, он делит два значения с плавающей запятой одинарной точности и организует возврат значения.

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

person Cody Gray    schedule 06.01.2017
comment
Забавный факт: с GCC9 и более поздними версиями, ориентированными на AVX, он может повторно использовать один и тот же обнуленный регистр в качестве цели слияния для нескольких преобразований из скаляра, избегая ложных зависимостей. godbolt.org/z/c378df. (Поэтому ошибка GCC 80571 в основном исправлена, хотя бывают случаи, когда вместо этого можно использовать ненулевой холодный регистр, который, я не думаю, что GCC находит.) - person Peter Cordes; 18.10.2020