<< >> 곱셈과 나눗셈의 속도


9

파이썬에서 숫자 <<를 곱하고 >>나누는 데 사용할 수 있습니다. 시간을 정할 때 바이너리 시프트 방법을 사용하면 정규 방법을 나누거나 곱하는 것보다 10 배 빠릅니다.

왜 사용 <<하고 >>많은보다 빨리 */?

비하인드 프로세스가 어떻게 진행 *되고 /너무 느려 집니까?


2
비트 시프트는 파이썬뿐만 아니라 모든 언어에서 더 빠릅니다. 많은 프로세서에는 하나 또는 두 개의 클록 사이클에서이를 수행하는 기본 비트 시프트 명령이 있습니다.
Robert Harvey

4
그러나 정규 나눗셈 연산자를 사용하는 대신 비트 시프 팅은 일반적으로 나쁜 습관이며 가독성을 방해 할 수 있다는 점을 명심해야합니다 .
Azar

6
@crizly 기껏해야 마이크로 최적화이기 때문에 컴파일러가 가능하면 바이트 코드의 시프트로 변경할 가능성이 높습니다. 코드의 성능이 매우 중요 할 때와 같이 예외가 있지만 대부분의 작업은 코드를 난독 처리하는 것입니다.
Azar

7
@Crizly : 알맞은 옵티마이 저가있는 컴파일러는 비트 시프트로 수행 할 수있는 곱셈과 나눗셈을 인식하고이를 사용하는 코드를 생성합니다. 컴파일러를 능가하기 위해 코드를 추악하게 만들지 마십시오.
Blrfl

2
대한 stackoverflow이 질문 마이크로 벤치 약간 발견 더 나은 정도로 작은 숫자, 동등한 왼쪽 Shift에 대한보다 2 곱셈 파이썬 3의 성능을. 작은 곱셈 (현재)이 비트 시프트와 다르게 최적화되는 이유를 추적했다고 생각합니다. 이론에 따라 더 빨리 실행되는 것을 당연히 받아 들일 수 없다는 것을 보여 주려고합니다.
Dan Getz

답변:


15

비트 시프트와 분할을 수행하는 두 개의 작은 C 프로그램을 살펴 보겠습니다.

#include <stdlib.h>

int main(int argc, char* argv[]) {
        int i = atoi(argv[0]);
        int b = i << 2;
}
#include <stdlib.h>

int main(int argc, char* argv[]) {
        int i = atoi(argv[0]);
        int d = i / 4;
}

그런 다음 gcc -S실제 어셈블리가 무엇인지 확인하기 위해 각각 컴파일 됩니다.

비트 시프트 버전을 사용하면 호출에서 다음 atoi으로 돌아갑니다.

    callq   _atoi
    movl    $0, %ecx
    movl    %eax, -20(%rbp)
    movl    -20(%rbp), %eax
    shll    $2, %eax
    movl    %eax, -24(%rbp)
    movl    %ecx, %eax
    addq    $32, %rsp
    popq    %rbp
    ret

나누기 버전 동안 :

    callq   _atoi
    movl    $0, %ecx
    movl    $4, %edx
    movl    %eax, -20(%rbp)
    movl    -20(%rbp), %eax
    movl    %edx, -28(%rbp)         ## 4-byte Spill
    cltd
    movl    -28(%rbp), %r8d         ## 4-byte Reload
    idivl   %r8d
    movl    %eax, -24(%rbp)
    movl    %ecx, %eax
    addq    $32, %rsp
    popq    %rbp
    ret

이것을 보면 비트 시프트에 비해 분할 버전에 몇 가지 명령이 더 있습니다.

열쇠는 무엇을 하는가?

비트 시프트 버전에서 핵심 명령은 shll $2, %eax왼쪽으로 이동하는 논리입니다. 나누기가 있으며 다른 모든 것은 값을 이동시킵니다.

나누기 버전에서 볼 수 idivl %r8d있지만-바로 위의 cltd(길이를 두 배로 변환) 및 유출 및 다시로드에 대한 추가 논리입니다. 비트 연산만으로 발생할 수있는 다양한 오류를 피하기 위해 비트가 아닌 연산을 처리한다는 것을 알고있는이 추가 작업은 종종 필요합니다.

빠른 곱셈을 할 수 있습니다.

#include <stdlib.h>

int main(int argc, char* argv[]) {
    int i = atoi(argv[0]);
    int b = i >> 2;
}
#include <stdlib.h>

int main(int argc, char* argv[]) {
    int i = atoi(argv[0]);
    int d = i * 4;
}

이 모든 과정을 거치는 대신 한 줄의 차이가 있습니다.

$ diff mult.s bit.s
24c24
> shll $ 2, % eax
---
<sarl $ 2, % eax

여기서 컴파일러는 시프트로 수학을 수행 할 수 있음을 식별 할 수 있었지만 논리적 시프트 대신 산술 시프트를 수행했습니다. 우리가 이것을 실행하면 이것들 사이의 차이점은 분명 할 것 sarl입니다. 그래서 -2 * 4 = -8는 동안 shll하지 않습니다.

빠른 펄 스크립트에서 이것을 보자 :

#!/usr/bin/perl

$foo = 4;
print $foo << 2, "\n";
print $foo * 4, "\n";

$foo = -4;
print $foo << 2, "\n";
print $foo * 4, "\n";

산출:

16
16
18446744073709551600
-16

음 ... -4 << 2입니다 18446744073709551600곱셈과 나눗셈을 처리 할 때 가능성이 기대 정확히하지 않은. 맞지만 정수 곱셈은 아닙니다.

따라서 조기 최적화에주의하십시오. 컴파일러가 사용자를 위해 최적화하도록하십시오. 실제로 수행하려는 작업을 알고 버그를 줄이면서 더 나은 작업을 수행 할 수 있습니다.


12
페어링 명확 수 있습니다 << 2* 4>> 2/ 4각각의 예에서 시프트 방향을 동일하게 유지 할 수 있습니다.
Greg Hewgill

5

기존 답변은 실제로 하드웨어 측면을 다루지 않았으므로 여기에 약간의 각도가 있습니다. 기존의 지혜는 곱셈과 나눗셈이 변화하는 것보다 훨씬 느리지 만 오늘날의 실제 이야기는 더 미묘한 차이가 있습니다.

예를 들어, 곱셈이 하드웨어에서 구현하기 가 더 복잡한 작업 이라는 것은 분명 하지만 항상 느리게 끝나는 것은 아닙니다 . 그것이 나오는 것에 따라, add또한 훨씬 더 복잡한보다 구현하는 것입니다 xor(또는 일반적으로 어떤 비트 연산에서)하지만, add(그리고 sub보통 조작 전용으로 충분히 트랜지스터를 얻을 수)이를만큼 빠른 비트 연산자로 되 고. 따라서 하드웨어 구현의 복잡성을 속도 가이드로 볼 수는 없습니다.

따라서 곱셈 및 이동과 같은 "전체"연산자와 이동에 대해 자세히 살펴 보겠습니다.

이동

거의 모든 하드웨어에서 일정한 양 (즉, 컴파일러가 컴파일 타임에 결정할 수있는 양)만큼 이동하는 것이 빠릅니다 . 특히, 일반적으로 단일주기의 대기 시간과주기 당 1 이상의 처리량으로 발생합니다. 일부 하드웨어 (예 : 일부 Intel 및 ARM 칩)에서는 상수에 의한 특정 이동이 다른 명령 ( leaIntel에서는 ARM의 첫 번째 소스의 특수 이동 기능)에 내장 될 수 있기 때문에 "무료"일 수도 있습니다 .

가변적 인 양만큼 이동하는 것은 회색 영역에 가깝습니다. 구형 하드웨어에서는 이것이 매우 느 렸고 속도가 세대마다 바뀌 었습니다. 예를 들어, 인텔 P4의 초기 릴리스에서 가변 량만큼 이동하는 것이 느리게 진행되었습니다. 이동량에 비례하는 시간이 필요했습니다! 이 플랫폼에서 곱셈을 사용하여 교대 를 대체하는 것이 수익성이있을 수 있습니다 (즉, 세계는 거꾸로되어 있습니다). 이전 세대의 인텔 칩과 그 이후 세대에서는 가변적 인 양만큼 이동하는 것이 그리 고통스럽지 않았습니다.

현재 인텔 칩에서 가변 량만큼 이동하는 것은 특히 빠르지는 않지만 끔찍하지도 않습니다. x86 아키텍처는 변수 시프트와 관련하여 비정상적인 방식으로 작업을 정의했기 때문에 어려움을 겪습니다. 시프트 양이 0이면 조건 ​​플래그가 수정되지 않지만 다른 모든 시프트는 변경됩니다. 이는 후속 명령이 시프트에 의해 작성된 조건 코드를 읽어야하는지 또는 일부 이전 명령을 읽어야하는지 시프트가 실행될 때까지 플래그 레지스터의 효율적인 이름 변경을 금지합니다. 또한, 시프트는 플래그 레지스터의 일부에만 쓰기를 수행하므로 부분 플래그가 정지 될 수 있습니다.

결과는 최근 인텔 아키텍처에서 가변 량만큼 이동하는 데 세 개의 "마이크로 연산"이 필요하지만 대부분의 다른 간단한 연산 (추가, 비트 연산, 심지어 곱셈)은 1을 차지한다는 것입니다. .

곱셈

최신 데스크톱랩톱 하드웨어 의 추세는 곱셈을 빠르게 수행하는 것입니다. 최근의 인텔 및 AMD 칩에서는 실제로주기마다 하나의 곱셈이 발행 될 수 있습니다 (이러한 상호 처리량 이라고합니다 ). 그러나 곱셈 의 대기 시간 은 3주기입니다. 따라서 곱셈을 시작한 후 주어진 3 곱셈 의 결과 를 얻을 수 있지만 매 사이클마다 새 곱셈을 시작할 수 있습니다. 더 중요한 값 (1주기 또는 3주기)은 알고리즘의 구조에 따라 다릅니다. 곱셈이 중요한 종속성 체인의 일부인 경우 대기 시간이 중요합니다. 그렇지 않은 경우 상호 처리량 또는 기타 요인이 더 중요 할 수 있습니다.

중요한 점은 최신 랩탑 칩 (또는 그 이상)에서는 곱셈이 빠른 연산이며 컴파일러가 강도 감소를 위해 "반올림"을하기 위해 발행하는 3-4 개의 명령 시퀀스보다 빠를 것입니다. 가변 시프트의 경우, 인텔에서는 일반적으로 위에서 언급 한 문제로 인해 곱셈이 선호됩니다.

보다 작은 폼 팩터 플랫폼에서는 완전하고 빠른 32 비트 또는 특히 64 비트 멀티 플라이어를 구축하는 데 많은 트랜지스터와 전력이 필요하므로 곱셈이 여전히 느릴 수 있습니다. 누군가가 최근 모바일 칩에서 곱셈의 성능에 대한 세부 정보를 채울 수 있다면 대단히 감사하겠습니다.

나누기

나누기는 곱셈보다 하드웨어 측면에서 더 복잡한 작업이며 실제 코드에서는 훨씬 덜 일반적입니다. 즉, 할당되는 리소스가 적다는 의미입니다. 현대 칩의 추세는 여전히 빠른 디바이더로 향하고 있지만 현대의 최고 칩조차도 나누기 위해 10-40 사이클이 걸리며 부분적으로 만 파이프 라인입니다. 일반적으로 64 비트 나누기는 32 비트 나누기보다 느립니다. 대부분의 다른 연산과 달리 나누기는 인수에 따라 다양한주기를 취할 수 있습니다.

가능한 경우 나누기를 피하고 시프트로 바꿉니다 (또는 컴파일러가 수행하도록하지만 어셈블리를 확인해야 할 수도 있습니다)!


2

BINARY_LSHIFT 및 BINARY_RSHIFT는 BINARY_MULTIPLY 및 BINARY_FLOOR_DIVIDE보다 알고리즘 적으로 간단한 프로세스이며 더 적은 클럭 사이클이 걸릴 수 있습니다. 즉, 이진수가 있고 N만큼 비트 시프트해야하는 경우, 그 많은 공간에서 숫자를 이동하고 0으로 바꾸면됩니다. Dadda multiplier 와 같은 기술은 매우 빠르지 만 이진 곱셈은 일반적으로 더 복잡 합니다.

물론, 최적화 컴파일러가 2의 거듭 제곱을 곱하거나 나눌 때 사례를 인식하고 적절한 왼쪽 / 오른쪽 시프트로 대체 할 수 있습니다. 디스 어셈블 된 바이트 코드를 보면 파이썬은 분명히 이것을하지 않습니다.

>>> dis.dis(lambda x: x*4)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (4)
              6 BINARY_MULTIPLY     
              7 RETURN_VALUE        

>>> dis.dis(lambda x: x<<2)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (2)
              6 BINARY_LSHIFT       
              7 RETURN_VALUE        


>>> dis.dis(lambda x: x//2)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (2)
              6 BINARY_FLOOR_DIVIDE 
              7 RETURN_VALUE        

>>> dis.dis(lambda x: x>>1)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (1)
              6 BINARY_RSHIFT       
              7 RETURN_VALUE        

그러나 프로세서에서 곱셈과 왼쪽 / 오른쪽 이동의 타이밍이 비슷하며 층 나누기 (2의 거듭 제곱)가 약 25 % 느립니다.

>>> import timeit

>>> timeit.repeat("z=a + 4", setup="a = 37")
[0.03717184066772461, 0.03291916847229004, 0.03287005424499512]

>>> timeit.repeat("z=a - 4", setup="a = 37")
[0.03534698486328125, 0.03207516670227051, 0.03196907043457031]

>>> timeit.repeat("z=a * 4", setup="a = 37")
[0.04594111442565918, 0.0408930778503418, 0.045324087142944336]

>>> timeit.repeat("z=a // 4", setup="a = 37")
[0.05412912368774414, 0.05091404914855957, 0.04910898208618164]

>>> timeit.repeat("z=a << 2", setup="a = 37")
[0.04751706123352051, 0.04259490966796875, 0.041903018951416016]

>>> timeit.repeat("z=a >> 2", setup="a = 37")
[0.04719185829162598, 0.04201006889343262, 0.042105913162231445]
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.