[c] 정수 나누기를 구현할 때 GCC가 왜 이상한 수의 곱셈을 사용합니까?

나는에 대해 읽어 봤는데 divmul조립 작업, 나는 C에서 간단한 프로그램을 작성하여 행동을보기로 결정했다 :

파일 division.c

#include <stdlib.h>
#include <stdio.h>

int main()
{
    size_t i = 9;
    size_t j = i / 5;
    printf("%zu\n",j);
    return 0;
}

그리고 다음을 사용하여 어셈블리 언어 코드를 생성합니다.

gcc -S division.c -O0 -masm=intel

그러나 생성 된 division.s파일을 보면 div 작업이 포함되어 있지 않습니다! 대신 비트 이동 및 마법 번호로 일종의 흑 마법을 수행합니다. 다음은 계산하는 코드 스 니펫입니다 i/5.

mov     rax, QWORD PTR [rbp-16]   ; Move i (=9) to RAX
movabs  rdx, -3689348814741910323 ; Move some magic number to RDX (?)
mul     rdx                       ; Multiply 9 by magic number
mov     rax, rdx                  ; Take only the upper 64 bits of the result
shr     rax, 2                    ; Shift these bits 2 places to the right (?)
mov     QWORD PTR [rbp-8], rax    ; Magically, RAX contains 9/5=1 now,
                                  ; so we can assign it to j

무슨 일이야? 왜 GCC가 div를 전혀 사용하지 않습니까? 이 매직 넘버는 어떻게 생성되며 왜 모든 것이 작동합니까?



답변

정수 나누기는 최신 프로세서에서 수행 할 수있는 가장 느린 산술 연산 중 하나이며, 최대 수십주기의 지연 시간과 처리량이 나쁩니다. x86의 경우 Agner Fog의 명령어 표 및 마이크로 아치 안내서를 참조하십시오.

제수를 미리 알고 있다면, 그와 동등한 효과를 갖는 다른 연산 세트 (곱셈, 덧셈 및 시프트)로 나누면 나눗셈을 피할 수 있습니다. 여러 연산이 필요한 경우에도 여전히 정수 나누기 자체보다 훨씬 빠릅니다.

/다중 명령어 시퀀스 대신 C 연산자를 이런 식으로 구현 하는 div것은 상수로 나누는 GCC의 기본 방법입니다. 여러 작업을 최적화 할 필요가 없으며 디버깅을 위해서도 아무것도 변경하지 않습니다. ( -Os작은 코드 크기를 사용하면 GCC에서을 사용할 수 있습니다 div.) 나누기 대신 곱하기 역을 사용 lea하는 것은 muland 대신에add

결과적으로, 제곱자가 컴파일 타임에 알려지지 않은 경우에만 출력을 div보거나 idiv출력 하는 경향이 있습니다 .

컴파일러가 이러한 시퀀스를 생성하는 방법과 사용자가 직접 시퀀스를 생성 할 수있는 코드 ( 브레인 데드 컴파일러로 작업하지 않는 한 거의 필요하지 않음 )에 대한 정보는 libdivide를 참조하십시오 .


답변

5로 나누는 것은 1/5을 곱하는 것과 같으며, 다시 4/5를 곱하고 오른쪽으로 2 비트를 쉬프트하는 것과 같습니다. 관련 값은 CCCCCCCCCCCCCCCD16 진수로 16 진수 뒤에 넣을 경우 4/5의 이진 표현입니다 (예 : 4/5의 이진이 반복됨 0.110011001100-아래의 이유 참조). 여기에서 가져갈 수 있다고 생각합니다! 고정 소수점 산술 을 확인하고 싶을 수도 있습니다 (단, 정수로 반올림됨에 유의하십시오).

왜 곱셈이 나누기보다 빠르며, 제수가 고쳐지면 더 빠른 경로입니다.

작동 방식에 대한 자세한 설명은 고정 소수점으로 설명 하는 자습서 인 역수 ​​곱셈을 참조하십시오 . 역수를 구하는 알고리즘의 작동 방식과 부호있는 분할 및 모듈로 처리 방법을 보여줍니다.

0.CCCCCCCC...(16 진수) 또는 0.110011001100...이진수가 4/5 인지 잠시 생각해 봅시다 . 4 (우측 시프트 2 개소)에 의해 이진 표현을 나누고, 우리가 얻을 것이다 0.001100110011...얻을 수있는 원본을 추가 할 수 있습니다 사소한 검사로 어떤 0.111111111111...분명히 1과 동일하다, 같은 방식으로 0.9999999...진수 한 동일합니다. 따라서, 우리는 알고 x + x/4 = 1그래서 5x/4 = 1, x=4/5. 그런 다음 CCCCCCCCCCCCD반올림을 위해 16 진수 로 표시됩니다 (마지막으로 존재하는 이진수는 a 일 것입니다 1).


답변

일반적으로 곱셈은 나누기보다 훨씬 빠릅니다. 따라서 우리가 역수를 곱하는 것을 피할 수 있다면 상수를 크게 나눌 수 있습니다.

주름은 우리가 역수를 정확하게 표현할 수 없다는 것입니다 (나눗셈이 2의 거듭 제곱에 의한 것이 아니라면 보통 나누기를 비트 시프트로 변환 할 수 있습니다). 따라서 정답을 얻으려면 상호의 오류가 최종 결과에서 오류를 유발하지 않도록주의해야합니다.

-3689348814741910323은 0xCCCCCCCCCCCCCCCCCD이며 0.64 고정 소수점으로 표현 된 4/5 이상의 값입니다.

64 비트 정수에 0.64 고정 소수점 수를 곱하면 64.64 결과가 나타납니다. 값을 64 비트 정수로 자르고 (효과적으로 0으로 반올림) 4로 나누고 다시 잘리는 추가 이동을 수행합니다. 비트 수준을 보면 두 가지 잘림을 단일 잘림으로 처리 할 수 ​​있습니다.

이것은 분명히 우리에게 적어도 5로 나누는 근사치를 제공하지만 정확하게 0으로 올림 된 정확한 대답을 제공합니까?

정확한 답을 얻으려면 오류가 반올림 경계를 넘지 않도록 충분히 작아야합니다.

5의 나눗셈에 대한 정확한 답은 항상 0, 1/5, 2/5, 3/5 또는 4/5의 소수 부분을 갖습니다. 따라서 곱하고 시프트 된 결과에서 1/5 미만의 양의 오류는 결과를 반올림 경계를 넘지 않습니다.

상수의 오차는 (1/5) * 2 -64 입니다. i 의 값 이 2 64 보다 작으므로 곱한 후의 오차가 1/5보다 작습니다. 4로 나누면 오류는 (1/5) * 2 −2 보다 작습니다 .

(1/5) * 2 −2 <1/5이므로 답은 항상 정확한 나눗셈을하고 0으로 반올림하는 것과 같습니다.


불행히도 이것은 모든 제수에서 작동하지 않습니다.

0에서 반올림하여 0.64 고정 소수점 숫자로 4/7을 나타내려고하면 (6/7) * 2 -64 오류가 발생 합니다. 2 64 미만의 i 값을 곱한 후 6/7 미만의 오류가 발생하고 4로 나눈 후 1.5 / 7 미만의 오류가 발생하며 이는 1/7보다 ​​큽니다.

divison을 7로 올바르게 구현하려면 0.65 고정 소수점 수를 곱해야합니다. 고정 소수점 수의 하위 64 비트를 곱한 다음 원래 수를 더한 다음 (캐리지 비트로 오버플로 될 수 있음) 캐리 스루를 통해 회전을 수행하여 구현할 수 있습니다.


답변

다음은 Visual Studio에서 볼 수있는 값과 코드를 생성하는 알고리즘 문서에 대한 링크입니다 (대부분의 경우). 변수 정수를 상수 정수로 나누기 위해 GCC에서 여전히 사용된다고 가정합니다.

http://gmplib.org/~tege/divcnst-pldi94.pdf

이 기사에서 uword에는 N 비트가 있고 udword에는 2N 비트가 있으며 n = 분자 = 피제수, d = 분모 = 제수, ℓ는 처음에 ceil (log2 (d))로 설정되고 shpre는 사전 이동 (곱하기 전에 사용됨) ) = e = d의 후행 0 비트 수, shpost는 이동 후 (곱셈 후 사용), prec는 정밀도 = N-e = N-shpre입니다. 목표는 프리 시프트, 곱셈 및 포스트 시프트를 사용하여 n / d 계산을 최적화하는 것입니다.

udword multiplier (최대 크기는 N + 1 비트)가 생성되는 방법을 정의하는 그림 6.2까지 아래로 스크롤하지만 프로세스를 명확하게 설명하지는 않습니다. 이것을 아래에서 설명하겠습니다.

그림 4.2와 그림 6.2는 대부분의 제수에 대해 승수를 N 비트 이하로 줄이는 방법을 보여줍니다. 식 4.5는 그림 4.1과 4.2에서 N + 1 비트 승수를 처리하는 데 사용 된 공식이 어떻게 도출되었는지를 설명합니다.

최신 X86 및 기타 프로세서의 경우 곱하기 시간이 고정되어 있으므로 프리 시프트는 이러한 프로세서에서 도움이되지 않지만 승수를 N + 1 비트에서 N 비트로 줄이는 데 여전히 도움이됩니다. GCC 또는 Visual Studio가 X86 대상에 대한 프리 시프트를 제거했는지 여부를 모르겠습니다.

그림 6.2로 돌아 가기 mlow 및 mhigh에 대한 분자 (배당)는 분모 (제수)> 2 ^ (N-1) (ℓ == N => mlow = 2 ^ (2N) 인 경우)에만 udword보다 클 수 있습니다. n / d의 최적화 된 대체는 비교 (n> = d, q = 1이면 q = 0 인 경우)이므로 승수가 생성되지 않습니다. mlow 및 mhigh의 초기 값은 N + 1 비트이며, 2 개의 udword / uword 나누기를 사용하여 각 N + 1 비트 값 (mlow 또는 mhigh)을 생성 할 수 있습니다. 64 비트 모드에서 X86을 예로 사용 :

; upper 8 bytes of dividend = 2^(ℓ) = (upper part of 2^(N+ℓ))
; lower 8 bytes of dividend for mlow  = 0
; lower 8 bytes of dividend for mhigh = 2^(N+ℓ-prec) = 2^(ℓ+shpre) = 2^(ℓ+e)
dividend  dq    2 dup(?)        ;16 byte dividend
divisor   dq    1 dup(?)        ; 8 byte divisor

; ...
        mov     rcx,divisor
        mov     rdx,0
        mov     rax,dividend+8     ;upper 8 bytes of dividend
        div     rcx                ;after div, rax == 1
        mov     rax,dividend       ;lower 8 bytes of dividend
        div     rcx
        mov     rdx,1              ;rdx:rax = N+1 bit value = 65 bit value

GCC로 테스트 할 수 있습니다. j = i / 5가 어떻게 처리되는지 이미 보았습니다. j = i / 7이 처리되는 방법을 살펴보십시오 (N + 1 비트 승수의 경우 여야 함).

대부분의 최신 프로세서에서는 곱하기 타이밍이 고정되어 있으므로 프리 시프트가 필요하지 않습니다. X86의 경우, 최종 결과는 대부분의 제수에 대한 2 개의 명령어 시퀀스와 7과 같은 제수에 대한 5 개의 명령어 시퀀스입니다 (pdf 파일의 식 4.5 및 그림 4.2에 표시된대로 N + 1 비트 승수를 에뮬레이션하기 위해). 예제 X86-64 코드 :

;       rax = dividend, rbx = 64 bit (or less) multiplier, rcx = post shift count
;       two instruction sequence for most divisors:

        mul     rbx                     ;rdx = upper 64 bits of product
        shr     rdx,cl                  ;rdx = quotient
;
;       five instruction sequence for divisors like 7
;       to emulate 65 bit multiplier (rbx = lower 64 bits of multiplier)

        mul     rbx                     ;rdx = upper 64 bits of product
        sub     rbx,rdx                 ;rbx -= rdx
        shr     rbx,1                   ;rbx >>= 1
        add     rdx,rbx                 ;rdx = upper 64 bits of corrected product
        shr     rdx,cl                  ;rdx = quotient
;       ...


답변

나는 약간 다른 각도에서 대답 할 것입니다 : 그것이 가능하기 때문에.

C 및 C ++는 추상 시스템에 대해 정의됩니다. 컴파일러는 as-if 규칙 에 따라이 프로그램을 추상 기계 측면에서 콘크리트 기계로 변환합니다 .

  • 컴파일러는 추상 시스템에 의해 지정된 관찰 가능한 동작을 변경하지 않는 한 모든 변경을 수행 할 수 있습니다. 컴파일러가 코드를 가장 간단한 방식으로 변환 할 것이라는 기대는 없습니다 (많은 C 프로그래머가이를 가정하더라도). 일반적으로 컴파일러는 간단한 접근 방식과 비교하여 성능을 최적화하려고합니다 (다른 답변에서 자세히 설명 함).
  • 어떤 상황에서도 컴파일러가 다른 관찰 가능한 동작을 가진 프로그램에 올바른 프로그램을 “최적화”하면 컴파일러 버그입니다.
  • 코드에서 정의되지 않은 동작 (부호있는 정수 오버플로는 전형적인 예)이며이 계약은 무효입니다.

답변