프로그래밍을 하다보면 0.1 + 0.2
와 0.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 것으로 볼 수 있습니다.
그러면 구체적으로 어떻게 숫자가 비트로 표현되는지 보겠습니다.
소수점 옮기기
숫자 를 다르게 써봅시다. 로요. 과학적 표기법scientific notation이라고 불리는 이 방식은 숫자 하나를 항상 소수점 앞에 둡니다.
이나 또한 같은 수를 표현할 수 있습니다. 즉 10이 다양한 지수를 가질 수 있는데요. 하지만 이렇게 과학적 표기법을 통해 지수를 유일하게 결정할 수 있습니다. 이렇게 유일하게 결정되는 지수를 얻기 위해 표기법을 고치는 과정을 나중에 노말라이즈normalize한다고 부를 것입니다.
한편, 십진법을 쓰면 맨 앞 숫자는 까지 나올 수 있지만, 이진법을 쓴다면 밖에 올 수 없습니다. 그렇기 때문에 이진법을 쓰면 맨 앞 숫자를 따로 기억할 필요가 없어집니다. 이렇게 비트 공간을 하나 벌게 됩니다.
세 부분으로 해부하기
하나의 숫자에서 세 가지의 정보가 비롯됩니다. 즉 부호sign , 지수exponent , 그리고 소수fraction 인데요. 메모리 공간이 32비트로 주어졌다고 치고, 이를 세 공간으로 나누어 담을 것입니다. 실제로 32비트 부동소수점이 이런 식으로 정보를 담습니다.
이렇게 기록한 결과가 부동소수점 비트 표현bit representation이 됩니다.
방금의 예시 를 다음과 같이 해부할 수 있습니다.
- 부호 : 양수이거나 음수이거나 둘 중 하나기 때문에, 비트 하나만 필요한데요. 여기서 양수라는 뜻으로 을 둡니다.
- 지수 : 지수는 의 지수자리를 담을 건데요. 따라서 를 둡니다.
- 소수 : 이름 그대로, 를 둡니다.
이제 저장한 숫자를 알아내고 싶다면, 를 계산해보면 됩니다. 그러면 원래 숫자인 이 될 것입니다. (확인해보세요.)
소수점을 비트로 표현하기
세 부분으로 기억한다고는 했지만, 구체적으로 어떻게 비트로 기록하는지는 얘기하지 않았는데요. 일단 부호 는 과 뿐이고, 이대로 비트에 기록하면 되기 때문에 간단합니다. 나머지는 어떻게 기록하는지 봅시다.
이진수로 바꾸기
십진수를 비트로 담기 위해 이진수로 바꾸려는데요. 이는 단순하게 이진법의 정의를 따르면 됩니다.
그런데 그 정의를 보세요. 숫자를 꼴의 유한한 합으로 표현할 수 없다면, 무한히 많은 자릿수가 필요하게 됩니다.
따라서 는 부동소수점으로 정확하게 표현할 수 있지만, 은 그렇지 않게 됩니다. 와 같이 순환소수가 되니까요.
비트로 인코딩하기
메모리의 소수 부분 는 23비트를 갖고 있는데요.
맨 앞 숫자는 항상 이라고 했던 것을 기억하시나요? 따라서 24자리의 이진수를 기록할 수 있게 됩니다. 그런데 모든 십진수를 24자리의 이진수로 정확하게 바꿀 수 없으니, 적당한 근사값을 여기에 담게 됩니다.
그런 근사값은 올리기를 할 수도, 버리기를 할 수도 있습니다. 사실 라운딩 룰rounding rules이라는 이름으로 다양한 방법이 존재합니다.
지수의 바이어스
보통 정수는 2의 보수two’s complement로 인코딩하는 경우가 많은데요.
여기서는 숫자 을 위해 모든 비트를 0
으로 둡니다.
그런데 다른 방법도 있는데요.
가장 작은 숫자를 위해 모든 비트를 0
으로 둘 수 있습니다.
지수 부분은 8비트이므로, 이 그 가장 작은 숫자에 해당하는데요.
이를 모든 비트가 0
인 것에 대응시킬 수 있습니다.
이때 지수가 만큼 바이어스biased 되어있다고 부릅니다.
여기까지 정리하면, 계산식은 이 됩니다.
한편, 이로 인해 지수의 비교가 간단해집니다.
두 지수 부분 비트를 차례대로 읽기만하면 되는데요.
먼저 1
이 나타나는 쪽이 큰 지수가 됩니다.
예시: 직접 바꿔보기
예를 들어 를 바꿔보며 정리해봅시다.
이진수로 고치고 노말라이즈하면 가 됩니다. 이로부터 세 부분을 얻을 수 있습니다.
이제 부동소수점을 위한 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.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)