SungJin Kang

SungJin Kang

hour30000@gmail.com

© 2024

Dark Mode

나누기, 나머지 연산이 느린 이유

본격적으로 나누기 연산이 느린 이유에 대해 알아보기 전에 컴퓨터가 곱하기와 나누기 연산을 어떻게 하는지를 알아보자.

우선 컴퓨터는 2진수 체계이기 때문에 곱하기 연산을 할 때 이 2진수라는 것을 잘 활용한다. 그래서 2의 거듭제곱을 곱할 때 비트를 Shift하여 매우 빠르게 곱하기를 할 수 있다.

2 ( 10진수 ) = 00010 ( 2진수 ) 에서

2를 곱한다 ( 왼쪽으로 1만큼 shift )
->
4 ( 10진수 ) = 00100 ( 2진수 )

->
8를 곱한다 ( 왼쪽으로 3만큼 shift )
16 ( 10진수 ) = 10000 ( 2진수 )

---------------------------------

3 ( 10진수 ) = 00011 ( 2진수 ) 에서

4를 곱한다 ( 왼쪽으로 2만큼 shift )
->
12 ( 10진수 ) = 01100 ( 2진수 )

그럼 2의 거듭제곱이 아닌 수를 곱할 때는 어떻게 할까??
이 또한 매우 빠르게 할 수 있다.

11 ( 10진수 ) = 1011 ( 2진수 = 1 x 2^3 + 0 x 2^2 + 1 x 2^1 + 1 x 2^0) 에서

9를 곱한다 ( 2의 거듭제곱 아님 ) ( 2진수 01001 =  1 x 2^3 + 0 x 2^2 + 0 x 2^1 + 1 x 2^0 )
->

11 x ( 8 + 1 )
->
11 x ( 1 x 2^3 + 0 x 2^2 + 0 x 2^1 + 1 x 2^0 )
->
11 x 2^3 + 11 x 2^0
->
Shift 연산으로 빠르게 계산
->
88 + 11
->
99

위에서 보이 듯이 2의 거듭제곱이 아닌 수를 곱할 때도 결국 모든 수는 2진수로 이루어진 수들로 바꿀 수 있기 때문에 2진수로 변환 후 각각 곱한 후 다시 더해주면 빠르게 연산을 수행할 수 있다.

이를 조금 다르게 나타내 보면

     1011   (this is binary for decimal 11)
   x 1110   (this is binary for decimal 14)
   ======
     0000   (this is 1011 x 0)
    1011    (this is 1011 x 1, shifted one position to the left)
   1011     (this is 1011 x 1, shifted two positions to the left)
+ 1011      (this is 1011 x 1, shifted three positions to the left)
=========
 10011010   (this is binary for decimal 154)

위의 형태는 사람들이 일반적으로 곱하기를 할 때 보이는 한 자리씩 곱해서 더하는 것과 똑같은 형태인데 이는 위에서 설명한 2의 거듭제곱들을 곱한 후 다 더하는 형태와 같다.

여기서 각 Shift 연산의 결과를 모두 더하는 과정이 느린데 ( Shift 연산보다 덧셈이 더 느리다 ) ( 32비트 숫자 두개를 곱하는 경우 32번의 덧셈이 필요하다 ) 컴퓨터는 Parallel하게 덧셈들을 처리(덧셈 여러개를 하나의 명령어로 한번에 처리 ( SIMD ) )하여 이를 빠르게 처리한다. ( 곱하기 연산을 빠르게 만들기 위한 각종 하드웨어 장치들은 모두 이 Shift 연산의 결과를 더하는 과정을 빠르게 하기 위한 것들이다. )

( 여기에 더해 부호가 있는 수를 처리할 때는 2의 보수를 처리하기 위해 추가적인 연산이 필요한데 자세한 것은 이 글을 참고하기 바란다 )


곱하기 연산과 달리 나머지 연산 하나의 과정이 더 필요하다. 바로 나누려는 수의 역수를 구하는 과정이다.
그럼 그 역수를 가지고 위에서 본 곱하기 연산을 해주면 결과적으로는 나머지 연산이 된다.
이때 나누려는 수의 역수를 구하기 위해 뉴톤-랍손(Newton-Raphson) 알고리즘이 사용된다.
이 뉴톤-랍손 방법을 통해 나누려는 수의 역수의 근사치 값을 구해 곱하기 연산을 해주면 된다.
기존의 곱하기 연산과 비교하여 역수를 구하는 과정이 추가되었기 때문에 나머지 연산이 더 느린 것이다.
( 뉴톤-랍손 방법이 어떻게 동작하는지에 대해서는 이 글을 읽기 바란다. 여기서 자세히 다룰 내용은 아니다. 간단히 설명하면 역수를 구하기 위해 방정식의 해를 때려 넣으면서 해가 맞는지를 확인하는데 이 해를 맞는지 확인하는 과정이 반복적이기 때문에 연산량이 많아 느리다. )

위와는 별개로 컴파일러는 나머지 연산을 빠르게 하기 위한 최적화를 수행하는데 그것은 분모가 2의 거듭 제곱인 경우에는 컴파일러단에서 나누려는 수를 그 수의 역수로 바꾸고 나누기를 곱하기로 바꾸는 것이다.

a *= 0.1f;

vs

a /= 10.0f;

위의 두 곱하기와 나누기 연산의 성능 차이는 크다.

반면

a *= 0.5f;

vs

a /= 2.0f;

위의 두 연산의 연산 속도는 거의 차이가 없다.
이는 /= 2.0의 2.0f이 2진수로 2^-1이기 때문에 컴파일러가 컴파일을 할 때 자동으로 /= 2.0f를 *= 0.5f로 바꾸는 최적화를 해주기 때문에 두 연산의 속도가 차이가 없는 것이다.

어셈블리에서도 바로 이를 확인할 수 있다.

float a(float num) {
    float result = num / 2.0f;
    return result;
}

a(float):
        mulss   xmm0, DWORD PTR .LC0[rip]
        ret

.LC0:
.long   1056964608

num / 2.0f가 곱하기 연산 ( mulss )로 바뀐 것을 알 수 있는 데 곱하는 .LC0의 값은 1056964608, 이진수로 바꾸면 00111111000000000000000000000000 부동소수점으로 0.5를 나타내는 값임을 알 수 있다.
즉 컴파일러가 num / 2.0f를 num * 0.5f로 바꾼 것이다.

그래서 만약 어느 정도의 반올림 오류를 프로그래머가 인지를 하고 용인을 할 수 있다고 판단을 한다면 분모가 2의 거듭 제곱이 아니더라도 ( 컴파일러가 자동으로 나누기를 곱셈으로 최적화할 수 없는 경우 ) 임의로 가장 가까운 2의 거듭 제곱으로 바꾸어서 곱하기 연산으로 만들어 프로그래밍을 하여 최적화하는 방법도 있다.

reference : https://scicomp.stackexchange.com/questions/187/why-is-division-so-much-more-complex-than-other-arithmetic-operations, https://blog.naver.com/PostView.nhn?blogId=fah204&logNo=221573584390, https://www.quora.com/How-do-computers-perform-multiplication-division-operations-internally-by-additive-approaches