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


228

나는에 대해 읽어 봤는데 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를 전혀 사용하지 않습니까? 이 매직 넘버는 어떻게 생성되며 왜 모든 것이 작동합니까?


29
gcc는 상수로 나누기를 최적화하고 2,3,4,5,6,7,8로 나누기를 시도하면 각 사례마다 매우 다른 코드를 볼 수 있습니다.
Jabberwocky

28
주 : 매직 넘버 -3689348814741910323로 변환 CCCCCCCCCCCCCCCDA와 uint64_t나 막 (2 ^ 64) * 5 / 5.
chux-Reinstate Monica

32
@qiubit : 최적화가 비활성화되어 있어도 컴파일러는 비효율적 인 코드를 생성하지도 않습니다. 코드 순서 변경이나 변수 제거를 포함하지 않는 간단한 "최적화"는 예를 들어 관계없이 수행됩니다. 기본적으로 단일 소스 문은 해당 작업을위한 가장 효율적인 코드로 분리됩니다. 컴파일러 최적화는 단일 명령문 대신 주변 코드를 고려합니다.
Clifford

20
이 멋진 기사를 읽으십시오 : Division of Labor
Jester

9
어떤 컴파일러는 실제로 것이다 최적화를 사용할 수 없기 때문에 심술 궂게 비효율적 인 코드를 생성합니다. 특히 개별 코드 줄에 중단 점을 설정하는 기능과 같이 디버깅을 쉽게하기 위해 수행합니다. GCC는 실제로 "최적화 없음"모드가 없다는 점에서 다소 이례적인데, 그 중 많은 최적화가 구성 적으로 켜져 있기 때문입니다. 이것은 GCC로 볼 수있는 예입니다. 반면 Clang과 MSVC 에서 div명령을 내 -O0립니다. (cc @ clifford)
코디 그레이

답변:


169

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

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

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

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

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


5
@fuz 속도 비교에서 FP와 정수 연산을 함께 묶는 것이 공정한지 확실하지 않습니다. 아마도 Sneftel은 분할 이 최신 프로세서에서 수행 할 수 있는 가장 느린 정수 연산 이라고 말해야 합니까? 또한이 "매직"에 대한 추가 설명으로 연결되는 링크도 주석으로 제공됩니다. 가시성에 대한 답변으로 수집하기에 적합하다고 생각하십니까? 1 , 2 , 3
코디 그레이

1
작업 순서는 기능적으로 동일하기 때문에 ... 에서도 항상 요구 사항 -O3입니다. 컴파일러는 가능한 모든 입력 값에 대해 올바른 결과를 제공하는 코드를 작성해야합니다. 이 경우 부동 소수점에 대해서만 변경 -ffast-math되며 AFAIK에는 "위험한"정수 최적화가 없습니다. (최적화를 활성화하면 컴파일러에서 음수가 아닌 부호있는 정수에만 사용할 수있는 값을 사용할 수있는 가능한 값 범위에 대해 무언가를 증명할 수 있습니다.)
Peter Cordes

6
실제 대답은 gcc -O0는 여전히 C를 기계어 코드로 전환하는 과정에서 내부 표현을 통해 코드를 변환 한다는 것 입니다 . 모듈 식 곱셈 역수가 기본적으로 활성화되어 -O0있지만 (으로 설정되지 않은 경우 -Os) 발생합니다. clang과 같은 다른 컴파일러는에서 2의 제곱이 아닌 상수에 DIV를 사용 -O0합니다. 관련 : 나는 이것에 대해 단락 포함 생각 내 Collatz-추측의 손으로 쓴 ASM 응답
피터 코르

6
@PeterCordes 그리고 그래, 나는 GCC (그리고 다른 많은 컴파일러들)가 "최적화가 비활성화 될 때 어떤 종류의 최적화가 적용되는지"에 대한 좋은 이론적 근거를 잊어 버렸다고 생각한다. 불분명 한 codegen 버그를 추적하는 데 하루 중 더 많은 시간을 보냈기 때문에 나는 지금 당장 그것에 대해 약간 화가났습니다.
Sneftel

9
@ Sneftel : 예상보다 빠르게 실행되는 코드에 대해 컴파일러 개발자 에게 적극적으로 불만제기 하는 응용 프로그램 개발자의 수가 상대적으로 적기 때문일 수 있습니다.
dan04

121

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
@ user2357112 자신의 답변을 자유롭게 게시 할 수 있지만 동의하지 않습니다. 곱하기는 64.0 비트 x 0.64 비트 곱셈으로 128 비트 고정 소수점 응답을 제공하며, 그 중 가장 낮은 64 비트는 버리고 그 다음 4로 나눕니다 (첫 번째 단락에서 지적한대로). 비트 이동을 동일하게 설명하는 대체 모듈 식 산술 답변을 얻을 수도 있지만 이것이 설명으로 작동한다고 확신합니다.
abligh

6
값은 실제로 "CCCCCCCCCCCCCCCCCD"입니다. 마지막 D가 중요합니다. 결과가 잘릴 때 정확한 나눗셈이 정답으로 나옵니다.
plugwash

4
신경 쓰지 마. 128 비트 곱셈 결과의 상위 64 비트를 가져 오는 것을 보지 못했습니다. 대부분의 언어로 할 수있는 일이 아니기 때문에 처음에는 그 일이 일어나고 있다는 것을 몰랐습니다. 이 답은 128 비트 결과의 상위 64 비트를 고정 소수점 수에 곱하고 반올림하는 것과 동등한 방법에 대한 명시적인 언급으로 훨씬 개선 될 것입니다. (또한 1/5 대신 4/5 여야하는 이유와 다운 대신 4/5를 반올림해야하는 이유를 설명하는 것이 좋습니다.)
user2357112는 Monica

2
반올림 경계를 가로 질러 5 분의 1 나누기를 던지기 위해 얼마나 큰 오류가 필요한지를 계산 한 다음이를 계산에서 최악의 오류와 비교해야합니다. 아마도 gcc 개발자들은 그렇게하고 항상 올바른 결과를 얻을 것이라고 결론을 내 렸습니다.
plugwash

3
실제로 probablly는 가능한 모든 둥근 값을 올바르게 반올림하면 가능한 가장 높은 5 개의 입력 값만 확인하면됩니다.
plugwash

60

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

주름은 우리가 역수를 정확하게 표현할 수 없다는 것입니다 (나눗셈이 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 비트를 곱한 다음 원래 수를 더한 다음 (캐리지 비트로 오버플로 될 수 있음) 캐리 스루를 통해 회전을 수행하여 구현할 수 있습니다.


8
이 답변은 "시간을 내고 싶어하는 것보다 더 복잡해 보이는 수학"에서 모듈 식 곱셈의 역수를 의미있는 것으로 바 꾸었습니다. 이해하기 쉬운 버전의 경우 +1 컴파일러에서 생성 한 상수를 사용하는 것 외에 다른 작업을 수행 할 필요가 없었으므로 수학을 설명하는 다른 기사 만 생략했습니다.
Peter Cordes

2
코드에서 모듈 식 산술과는 전혀 관련이 없습니다. 다른 주석가가 가져 오는 Dunno.
plugwash

3
레지스터의 모든 정수 수학처럼 모듈로 2 ^ n입니다. en.wikipedia.org/wiki/…
Peter Cordes

4
@PeterCordes 모듈 식 곱셈 역수는 정확한 나눗셈에 사용되며, 일반적인 나눗셈에는 유용하지 않습니다.
harold

4
고정 소수점 역수에 의한 @PeterCordes의 곱셈? 나는 모든 사람들이 그것을 무엇이라고 부르는지 모르지만 아마도 그것을 묘사 할 것입니다. 그것은 상당히 묘사 적입니다.
해롤드

12

다음은 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
;       ...

이 문서는 gcc에서 구현하는 방법을 설명하므로 동일한 알고리즘이 여전히 사용된다고 가정하는 것이 안전하다고 생각합니다.
Peter Cordes

1994 년에 발표 된이 백서에서는 gcc에서이를 구현하는 방법을 설명하고 있으므로 gcc가 알고리즘을 업데이트 할 때가왔다. 다른 사용자가 해당 URL의 94가 무엇을 의미하는지 확인할 시간이없는 경우를 대비하여.
Ed Grimm

0

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

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

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