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

구현을 통해 살펴보는 부동소수점

프로그래밍을 하다보면 0.1 + 0.20.3이 같지 않다는 얘기를 들어보게 됩니다. 자바스크립트 런타임인 노드Node.js에서 확인해볼 수 있는데요.

$ node
> 0.1 + 0.2 === 0.3
false

파이썬Python에서도 확인해볼 수 있고요.

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

아마 유한한 자릿수의 비트로는 0.1 같은 숫자를 정확히 표현할 수 없어서 그런 것 같습니다. 그러면 0.2도 그럴까요? 0.3은 어떨까요? 모든 숫자는 정확하게 표현할 수 없는 걸까요?

어떤 숫자들은 정확하게 표현할 수 있지만, 그렇지 않은 경우가 더 많습니다. 그리고 그런 부정확한 표현이 비교 연산을 예상치 못하게 만들 때도 있지만, 그렇지 않을 수도 있습니다. 앞으로 그 이유를 알아보려고 합니다.

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

숫자 해부하기

0.1과 같은 숫자를 비트bit로 표현하기 위해, 우리가 흔히 쓰는 하드웨어나 프로그래밍 언어는 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 또한 같은 수를 표현할 수 있습니다. 즉 10이 다양한 지수를 가질 수 있는데요. 하지만 이렇게 과학적 표기법을 통해 지수를 유일하게 결정할 수 있습니다. 이렇게 유일하게 결정되는 지수를 얻기 위해 표기법을 고치는 과정을 나중에 노말라이즈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.00011(2)0.0\overline{0011}_{(2)}와 같이 순환소수가 되니까요.

비트로 인코딩하기

메모리의 소수 부분 FF는 23비트를 갖고 있는데요.

Fraction part
그림 2. 메모리 소수 부분. 23자리의 이진법 소수를 기록합니다.

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

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

지수의 바이어스

보통 정수는 2의 보수two’s complement로 인코딩하는 경우가 많은데요. 여기서는 숫자 00을 위해 모든 비트를 0으로 둡니다.

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

127:0 ... 000(E=0)126:0 ... 001(E=1)128:1 ... 111(E=255)\begin{align*} -127: \quad &\texttt{0 ... 000} \quad (E=0) \\ -126: \quad &\texttt{0 ... 001} \quad (E=1) \\ &\vdots \\ 128: \quad &\texttt{1 ... 111} \quad (E=255) \end{align*}

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

한편, 이로 인해 지수의 비교가 간단해집니다. 두 지수 부분 비트를 차례대로 읽기만하면 되는데요. 먼저 1이 나타나는 쪽이 큰 지수가 됩니다.

예시: 직접 바꿔보기

예를 들어 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.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)