부동소수점은 어떻게 비교할까

구현을 통해 살펴보는 부동소수점의 비트 표현

프로그래밍을 하다보면, 0.1 + 0.20.3이 같지 않다는 얘기를 들어보게 됩니다. 대표적인 예시로 파이썬Python에서 확인해볼 수 있습니다.

$ python
>>> 0.1 + 0.2 == 0.3
False

자바스크립트 런타임인 노드Node.js에서도 그렇습니다.

$ node
> 0.1 + 0.2 === 0.3
false

추측해보자면 아마 유한한 개수의 비트로는 0.1 같은 숫자를 정확히 표현할 수 없어서 그런 것 같습니다.

그렇다면 0.2도 그럴까요? 0.3은 어떨까요? 나아가서 모든 숫자를 정확하게 표현할 수 없는 걸까요?

결론부터 얘기하자면 어떤 숫자는 정확하게 표현할 수 있습니다. 하지만 그렇지 않은 경우가 더 많습니다. 그리고 그런 부정확한 표현이 비교 연산을 예상치 못하게 만들 때도 있지만, 그렇지 않을 수도 있습니다.

0.1 + 0.2가 부정확하게 표현된다는 사실과, 이것이 0.3과 다른 숫자로 평가된다는 사실은 직접적인 인과관계가 없습니다. 왜냐면 0.3 또한 부정확하게 표현될지라도 마침 0.1 + 0.2의 결과와 같을지 다를지는 모르는 일이기 때문입니다.

0.1 + 0.2 == 0.3 문제를 들여다보기 전에, 먼저 소수점은 어떻게 비트로 표현되는지, 그리고 비교 연산자는 그런 비트를 어떻게 비교하는지 살펴볼 필요가 있습니다. 여기서는 직접 부동소수점 타입을 저수준에서 구현해보겠습니다. 이를 통해 숫자가 어떻게 비트로 기록되고 비교되는지 알 수 있게 됩니다.

숫자 해부하기

숫자 1.21.2을 어떻게 비트bit로 표현할 수 있을까요? 한 가지 예를 들면, 단순히 1212를 비트로 표현하되, 단위가 0.10.1인 것으로 해석할 수 있을 것입니다. 이런 식으로 소수점 뒤 몇 자리를 기억할지 정해두면, 정수integer를 기록하는 방법과 다를 바 없어집니다.

그러나 우리가 흔히 쓰는 하드웨어나 프로그래밍 언어는 IEEE 754라는 표준을 참고해서 부동소수점floating-point이라는 것을 구현합니다. 여기서는 소수점이 떠다니는floating 것처럼, 소수점 뒤 몇 자리를 기억할 것인지 미리 정해두지 않습니다. 따라서 그것을 그때그때 정하기 위해 별도의 비트가 필요하게 됩니다.

앞으로의 내용에서 개념적인 설명과 실제 구현을 통해 부동소수점이 숫자를 어떻게 비트로 기록하는지 이해할 수 있을 것입니다.

소수점 옮기기

숫자 102.3102.3를 표현하는 한 방법으로, 1.023×1021.023 \times 10^2으로 쓰는 방법이 있습니다. 과학적 표기법scientific notation이라고 부르는 이것은 항상 소수점 앞에 숫자를 하나 둡니다.

사실 같은 숫자를 10.23×10110.23 \times 10^1이나 0.1023×1030.1023 \times 10^3로도 표현할 수 있으므로 1010이 다양한 지수를 가질 수 있겠지만, 과학적 표기법으로 지수를 유일하게 결정할 수 있습니다. 이렇게 유일하게 결정되는 지수를 얻기 위해 고치는 과정을 노말라이즈normalize한다고 부를 것입니다.

한편, 십진법을 쓰면 맨 앞 숫자는 99까지 나올 수 있지만, 이진법을 쓴다면 11밖에 올 수 없습니다. 그렇기 때문에 이진법을 쓰면 맨 앞 숫자를 따로 기억할 필요가 없어집니다. 이렇게 비트 공간을 하나 벌게 됩니다.

세 부분으로 해부하기

부동소수점이란 숫자를 해부해서 세 가지 정보, 즉 부호sign SS, 지수exponent EE, 그리고 소수fraction FF를 기록하는 방법입니다. 메모리 공간이 32비트로 주어졌다고 치고, 이를 세 공간으로 나누어 담아보겠습니다. 실제로 32비트 부동소수점 표준이 이런 식으로 정보를 담습니다.

Memory diagram
그림 1. 메모리 다이어그램. 32비트를 세 부분으로 나누어 부호, 지수, 소수를 기록합니다.

이렇게 기록한 결과가 부동소수점 비트 표현bit representation이 됩니다.

방금의 예시 1.023×1021.023 \times 10^2를 다음과 같이 해부할 수 있습니다.

  • 부호 S=0S = 0: 양수이거나 음수이거나 둘 중 하나기 때문에, 비트 하나만 필요합니다. 여기서 양수라는 뜻으로 00을 둡니다.
  • 지수 E=2E = 2: 지수는 10210^2의 지수자리, 즉 22를 둡니다.
  • 소수 F=1.023F = 1.023: 이름 그대로, 1.0231.023를 둡니다.

이제 기록한 숫자를 알아내고 싶다면, (1)S×F×10E(-1)^S \times F \times 10^E를 계산해보면 됩니다. 그러면 원래 숫자인 102.3102.3이 될 것입니다. (확인해보세요.)

소수점을 비트로 표현하기

이제 비트로 기록하는 구체적인 방법을 얘기해봅시다. 일단 부호 SS0011 뿐이고, 이대로 비트에 기록하면 되기 때문에 간단합니다. 이어서 나머지를 알아봅시다.

이진수로 바꾸기

십진수를 비트로 담기 위해 이진수로 바꾸는 일은, 단순하게 이진법의 정의를 따르면 됩니다. 그런데 그 정의를 보면, 숫자를 2n2^n 꼴의 유한한 합으로 표현할 수 없을 때, 무한히 많은 자릿수가 필요하게 됩니다.

(b1b0.b1)(2)=nbn2n(bn=0,1)(\dots b_1 b_0 . b_{-1} \dots)_{(2)} = \sum_n b_n 2^n \quad (b_n = 0, 1)

따라서 0.5(10)0.5_{(10)}는 부동소수점으로 정확하게 표현할 수 있지만, 0.1(10)0.1_{(10)}은 그렇지 않게 됩니다.

0.1(10)=0.0001100110011(2)=0.00011(2)\begin{align*} 0.1_{(10)} &= 0.0 \thinspace 0011 \thinspace 0011 \thinspace 0011 \dots_{(2)} \\ &= 0.0 \thinspace \overline{0011}_{(2)} \end{align*}

계산해보면 위와 같이 순환소수가 되기 때문입니다. (이걸 어떻게 처리할 지는 곧 볼 것입니다.)

비트로 인코딩하기

메모리의 소수 부분 FF는 23비트를 갖고 있습니다. 그런데 맨 앞 숫자는 항상 11이라고 했던 것을 기억하시나요? 따라서 24자리의 이진수를 기록할 수 있게 됩니다. 그런데 모든 십진수를 24자리의 이진수로 정확하게 바꿀 수 없으니, 적당한 근사값을 여기에 담게 됩니다.

Fraction part
그림 2. 메모리 소수 부분. 23비트지만 24자리의 이진법 소수를 표현합니다.

그런 근사값은 올리기를 할 수도, 버리기를 할 수도 있습니다. 여기엔 사실 라운딩 룰rounding rules이라는 이름으로 다양한 방법이 존재합니다.

지수의 바이어스

보통 정수는 2의 보수two’s complement로 비트에 기록합니다. 여기서는 예를 들어 숫자 00을 위해 모든 비트를 0으로 둡니다.

그런데 다른 방법도 있는데요. 가장 작은 숫자를 위해 모든 비트를 0으로 둘 수 있습니다. 지수 부분은 8비트이므로, 127-127이 그 가장 작은 숫자에 해당합니다. 여기에 모든 비트가 0인 것에 대응시킨다면, 지수가 127127만큼 바이어스biased 되어있다고 부릅니다.

127:0 ... 000126:0 ... 001125:0 ... 010128:1 ... 111\begin{align*} -127: \quad &\texttt{0 ... 000} \\ -126: \quad &\texttt{0 ... 001} \\ -125: \quad &\texttt{0 ... 010} \\ &\vdots \\ 128: \quad &\texttt{1 ... 111} \end{align*}

여기까지 정리하면, 계산식은 (1)S×(1+F)×10(E127)(-1)^S \times (1+F) \times 10^{(E-127)}이 됩니다.

예시: 직접 바꿔보기

예를 들어 2.75(10)-2.75_{(10)}를 바꿔보며 정리해봅시다.

이진수로 고치고 노말라이즈하면 1.011(2)×21-1.011_{(2)} \times 2^{1}가 됩니다. 이로부터 세 부분을 얻을 수 있습니다.

S=1E=1(10)1000(2)(biased)F=.011(2)\begin{align*} S &= 1 \\ E &= 1_{(10)} \rightarrow 100 \dots 0_{(2)} \quad \text{\small (biased)} \\ F &= .011_{(2)} \end{align*}

이제 부동소수점을 위한 32비트 메모리는 다음과 같이 채워져 있을 것입니다.

Memory diagram example
그림 3. ‘-2.75’를 기록한 메모리 다이어그램. 맨 앞 1비트 부호 부분에는 음수의 의미로 1을, 8비트 지수 부분에는 바이어스된 값을, 23비트 소수 부분에는 이진법으로 기록합니다.

이는 다음 C++ 코드처럼 확인해볼 수 있습니다.

float f = -2.75;
string b = bitset<32>(*reinterpret_cast<int *>(&f)).to_string();
cout << "S: " << b[0] << ", "
     << "E: " << b.substr(1, 8) << ", "
     << "F: " << b.substr(9) << "\n";
// S: 1, E: 10000000, F: 01100000000000000000000

비교 연산 만들기

나눴던 세 부분의 순서를 보기 위해 다시 그림 1을 가져왔습니다.

Memory diagram
그림 1. 메모리 다이어그램. (이전과 같음)

부동소수점 표현이 부호, 지수, 소수라는 점에서 한 가지 장점이 생깁니다. 이 순서대로만 비교하면 빨리 결과를 낼 수 있기 때문입니다. 즉 부호 부분 SS가 다르면 나머지는 볼 필요가 없습니다. 지수 부분 EE가 달라도 그렇습니다.

예를 들어 두 숫자 1.x×1021.x \times 10^21.y×102-1.y \times 10^2가 있다고 해봅시다. 그러면 부호만 보고도 무엇이 더 큰지 알 수 있습니다. 다른 예시로, 1.x×1021.x \times 10^21.y×1031.y \times 10^3를 봅시다. 그러면 지수만 보고 1.y×1031.y \times 10^3이 더 큰 수임을 알게 됩니다. 어차피 소수점 앞은 11이니까요.

이렇게 쉽게 비교할 수 있는 이유는, 어떤 숫자든지 소수점 앞에 항상 11이 오기 때문입니다. 그렇죠?

사실 아닙니다. 00은 그런식으로 표현할 수가 없습니다.

그래서 00을 위한 특별한 방법으로, 지수와 소수 부분을 모두 0으로 채워넣습니다. 그런데 부호 비트는 여전히 0이거나 1일 수 있으니, 00을 표현하는 방법은 두 가지가 됩니다.

+0:00000000000000:1000000000000\begin{align*} +0: \quad &\texttt{0\,00000000\,000\dots0} \\ -0: \quad &\texttt{1\,00000000\,000\dots0} \end{align*}

그러므로 비교 연산을 만들 때 이런 부분을 고려해야 합니다. 여기서는 두 가지의 00을 같은 수로 처리합시다.

여기 두 수가 같은지 비교하는 수도코드입니다. 즉 부동소수점을 위한 == 연산으로 볼 수 있습니다.

같은지 비교 (aa, bb) // 같으면 true를 리턴하고, 아니면 false를 리턴

만약 aabb 둘 다 00이면
리턴 true
만약 aabbsign이 다르면
리턴 false
만약 aabbexponent가 다르면
리턴 false
만약 aabbfraction이 다르면
리턴 false
리턴 true

파이썬 구현 예시

먼저 부동소수점 타입을 클래스로 준비해봅시다. 부호와 지수, 소수 필드를 갖도록 만듭니다.

class Float:
    def __init__(self, sign: int, exp: int, frac: int):
        self.sign = sign
        self.exp = exp
        self.frac = frac

그리고 Float 클래스에 == 연산을 오버로딩 해봅시다. 이 부분은 수도코드의 구현입니다.

    def __eq__(self, other) -> bool:
        if not isinstance(other, Float):
            raise Exception("not comparable")

        if self.isZero() and other.isZero():
            return True
        if self.sign != other.sign:
            return False
        if self.exp != other.exp:
            return False
        if self.frac != other.frac:
            return False
        return True

여기서 쓰인 보조 메소드 isZero()는 지수와 소수가 둘 다 0인지 검사하는 것으로 만듭니다.

    def isZero(self) -> bool:
        return self.exp == 0 and self.frac == 0

이제 다음과 같이 Float 타입의 숫자를 비교할 수 있습니다.

positive_zero = Float(0, 0, 0) # +0
negative_zero = Float(1, 0, 0) # -0
positive_zero == negative_zero # True

two_point_seven_five = Float(0, 1 << 7, 0b011 << 20) # -2.75
two_point_seven_five != positive_zero # True

여기까지의 코드는 지스트Gist에서 확인할 수 있습니다.

마치며

부동소수점 타입과 비교 연산을 구현해봤습니다. 여기서 0.1 + 0.2 == 0.3 문제를 다루려면 두 가지를 더 만들어야 하는데, 하나는 부동소수점 리터럴literal, 즉 문자열로 표현된 숫자를 부동소수점으로 바꾸는 것이고, 또 하나는 덧셈 연산을 구현하는 것입니다. 둘 다 비교적 간단한 일은 아니므로 나중에 다뤄보도록 하겠습니다.

레퍼런스

  • Computer Organization and Design (David Patterson, John Hennessy, 2013), 또는 컴퓨터 구조 및 설계 (2015)