프로그래밍을 하다보면, 0.1 + 0.2
와 0.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
문제를 들여다보기 전에, 먼저 소수점은 어떻게 비트로 표현되는지, 그리고 비교 연산자는 그런 비트를 어떻게 비교하는지 살펴볼 필요가 있습니다.
여기서는 직접 부동소수점 타입을 저수준에서 구현해보겠습니다.
이를 통해 숫자가 어떻게 비트로 기록되고 비교되는지 알 수 있게 됩니다.
숫자 해부하기
숫자 을 어떻게 비트bit로 표현할 수 있을까요? 한 가지 예를 들면, 단순히 를 비트로 표현하되, 단위가 인 것으로 해석할 수 있을 것입니다. 이런 식으로 소수점 뒤 몇 자리를 기억할지 정해두면, 정수integer를 기록하는 방법과 다를 바 없어집니다.
그러나 우리가 흔히 쓰는 하드웨어나 프로그래밍 언어는 IEEE 754라는 표준을 참고해서 부동소수점floating-point이라는 것을 구현합니다. 여기서는 소수점이 떠다니는floating 것처럼, 소수점 뒤 몇 자리를 기억할 것인지 미리 정해두지 않습니다. 따라서 그것을 그때그때 정하기 위해 별도의 비트가 필요하게 됩니다.
앞으로의 내용에서 개념적인 설명과 실제 구현을 통해 부동소수점이 숫자를 어떻게 비트로 기록하는지 이해할 수 있을 것입니다.
소수점 옮기기
숫자 를 표현하는 한 방법으로, 으로 쓰는 방법이 있습니다. 과학적 표기법scientific notation이라고 부르는 이것은 항상 소수점 앞에 숫자를 하나 둡니다.
사실 같은 숫자를 이나 로도 표현할 수 있으므로 이 다양한 지수를 가질 수 있겠지만, 과학적 표기법으로 지수를 유일하게 결정할 수 있습니다. 이렇게 유일하게 결정되는 지수를 얻기 위해 고치는 과정을 노말라이즈normalize한다고 부를 것입니다.
한편, 십진법을 쓰면 맨 앞 숫자는 까지 나올 수 있지만, 이진법을 쓴다면 밖에 올 수 없습니다. 그렇기 때문에 이진법을 쓰면 맨 앞 숫자를 따로 기억할 필요가 없어집니다. 이렇게 비트 공간을 하나 벌게 됩니다.
세 부분으로 해부하기
부동소수점이란 숫자를 해부해서 세 가지 정보, 즉 부호sign , 지수exponent , 그리고 소수fraction 를 기록하는 방법입니다. 메모리 공간이 32비트로 주어졌다고 치고, 이를 세 공간으로 나누어 담아보겠습니다. 실제로 32비트 부동소수점 표준이 이런 식으로 정보를 담습니다.
이렇게 기록한 결과가 부동소수점 비트 표현bit representation이 됩니다.
방금의 예시 를 다음과 같이 해부할 수 있습니다.
- 부호 : 양수이거나 음수이거나 둘 중 하나기 때문에, 비트 하나만 필요합니다. 여기서 양수라는 뜻으로 을 둡니다.
- 지수 : 지수는 의 지수자리, 즉 를 둡니다.
- 소수 : 이름 그대로, 를 둡니다.
이제 기록한 숫자를 알아내고 싶다면, 를 계산해보면 됩니다. 그러면 원래 숫자인 이 될 것입니다. (확인해보세요.)
소수점을 비트로 표현하기
이제 비트로 기록하는 구체적인 방법을 얘기해봅시다. 일단 부호 는 과 뿐이고, 이대로 비트에 기록하면 되기 때문에 간단합니다. 이어서 나머지를 알아봅시다.
이진수로 바꾸기
십진수를 비트로 담기 위해 이진수로 바꾸는 일은, 단순하게 이진법의 정의를 따르면 됩니다. 그런데 그 정의를 보면, 숫자를 꼴의 유한한 합으로 표현할 수 없을 때, 무한히 많은 자릿수가 필요하게 됩니다.
따라서 는 부동소수점으로 정확하게 표현할 수 있지만, 은 그렇지 않게 됩니다.
계산해보면 위와 같이 순환소수가 되기 때문입니다. (이걸 어떻게 처리할 지는 곧 볼 것입니다.)
비트로 인코딩하기
메모리의 소수 부분 는 23비트를 갖고 있습니다. 그런데 맨 앞 숫자는 항상 이라고 했던 것을 기억하시나요? 따라서 24자리의 이진수를 기록할 수 있게 됩니다. 그런데 모든 십진수를 24자리의 이진수로 정확하게 바꿀 수 없으니, 적당한 근사값을 여기에 담게 됩니다.
그런 근사값은 올리기를 할 수도, 버리기를 할 수도 있습니다. 여기엔 사실 라운딩 룰rounding rules이라는 이름으로 다양한 방법이 존재합니다.
지수의 바이어스
보통 정수는 2의 보수two’s complement로 비트에 기록합니다.
여기서는 예를 들어 숫자 을 위해 모든 비트를 0
으로 둡니다.
그런데 다른 방법도 있는데요.
가장 작은 숫자를 위해 모든 비트를 0
으로 둘 수 있습니다.
지수 부분은 8비트이므로, 이 그 가장 작은 숫자에 해당합니다.
여기에 모든 비트가 0
인 것에 대응시킨다면, 지수가 만큼 바이어스biased 되어있다고 부릅니다.
여기까지 정리하면, 계산식은 이 됩니다.
예시: 직접 바꿔보기
예를 들어 를 바꿔보며 정리해봅시다.
이진수로 고치고 노말라이즈하면 가 됩니다. 이로부터 세 부분을 얻을 수 있습니다.
이제 부동소수점을 위한 32비트 메모리는 다음과 같이 채워져 있을 것입니다.
이는 다음 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을 가져왔습니다.
부동소수점 표현이 부호, 지수, 소수라는 점에서 한 가지 장점이 생깁니다. 이 순서대로만 비교하면 빨리 결과를 낼 수 있기 때문입니다. 즉 부호 부분 가 다르면 나머지는 볼 필요가 없습니다. 지수 부분 가 달라도 그렇습니다.
예를 들어 두 숫자 와 가 있다고 해봅시다. 그러면 부호만 보고도 무엇이 더 큰지 알 수 있습니다. 다른 예시로, 와 를 봅시다. 그러면 지수만 보고 이 더 큰 수임을 알게 됩니다. 어차피 소수점 앞은 이니까요.
이렇게 쉽게 비교할 수 있는 이유는, 어떤 숫자든지 소수점 앞에 항상 이 오기 때문입니다. 그렇죠?
사실 아닙니다. 은 그런식으로 표현할 수가 없습니다.
그래서 을 위한 특별한 방법으로, 지수와 소수 부분을 모두 0
으로 채워넣습니다.
그런데 부호 비트는 여전히 0
이거나 1
일 수 있으니, 을 표현하는 방법은 두 가지가 됩니다.
그러므로 비교 연산을 만들 때 이런 부분을 고려해야 합니다. 여기서는 두 가지의 을 같은 수로 처리합시다.
여기 두 수가 같은지 비교하는 수도코드입니다.
즉 부동소수점을 위한 ==
연산으로 볼 수 있습니다.
같은지 비교 (, ) // 같으면 true를 리턴하고, 아니면 false를 리턴
sign
이 다르면exponent
가 다르면fraction
이 다르면파이썬 구현 예시
먼저 부동소수점 타입을 클래스로 준비해봅시다. 부호와 지수, 소수 필드를 갖도록 만듭니다.
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)