10.0에 0.1을 곱해서 1.0이 되는 경우는 거의 없다.
10.0 times 0.1 is hardly ever 1.0.
— 브라이언 커니핸Brian Kernighan (1974)
프로그래밍을 하다 보면 0.1 + 0.2
와 0.3
이 다른 현상을 마주치게 됩니다.
0.1 + 0.2 == 0.3 # False
대표적으로 위와 같은 파이썬 코드에서 볼 수 있는데, 그 이유는 부동소수점floating point이 그런 숫자를 정확하게 표현할 수 없기 때문이라고 보통 설명합니다.
실제로 0.1
은 이진법으로 쓰면 끝이 없습니다.
그래서 컴퓨터가 가진 유한개의 비트로는 근사값을 표현할 수밖에 없습니다.
하지만 표현의 부정확성이 곧바로 0.1 + 0.2
와 0.3
을 다르게 만드는 것은 아닙니다.
왜냐면 이 두 숫자를 부정확하게 표현한 결과가 우연히 같을 수도 있기 때문입니다.
실제로 32비트 부동소수점으로 계산하면 이 둘은 같습니다. 다음 C++ 코드에서 실제로 확인할 수 있습니다.
float f1 = 0.1;
float f2 = 0.2;
float f3 = 0.3;
std::cout << (f1 + f2 == f3) << "\n";
// result: 1 (true)
한편, 앞서 언급한 파이썬 예시처럼, 문제로 거론되는 것은 64비트 부동소수점의 경우입니다. 이 역시 C++로도 확인할 수 있습니다.
double f1 = 0.1;
double f2 = 0.2;
double f3 = 0.3;
std::cout << (f1 + f2 == f3) << "\n";
// result: 0 (false)
그러나 부동소수점이 항상 0.1 + 0.2
와 0.3
을 서로 다르게 표현하는 것은 아닙니다.
라운딩 룰rounding rule, 또는 라운딩 모드rounding mode라는 이름으로 오차를 처리하는 방법이 다양하게 있기 때문입니다.
예를 들어, 라운딩 룰로 버림truncate을 하면 결과는 달라집니다.
C++에서 라운딩 룰을 바꿀 수 있는 인터페이스인 fesetround()
함수를 이용해보면 확인해볼 수 있습니다.
fesetround(FE_DOWNWARD);
double f1 = 0.1;
double f2 = 0.2;
double f3 = 0.3;
std::cout << (f1 + f2 == f3) << "\n";
// result: 1 (true)
여기서 같다고 비교되는 이유는 근사값이 같았기 때문입니다.
따라서 0.1 + 0.2
와 0.3
이 64비트 부동소수점으로서 다른 이유는, 유한한 비트로 인한 부정확한 표현과 더불어, 라운딩 룰로 인해 서로 다르게 표현한 근사값 때문입니다.
이전 글에서 부동소수점 비교 연산을 만들어보았습니다.
여기에 앞으로의 내용을 통해, 0.1 + 0.2 == 0.3
이라는 코드가 실행될 때 일어나는 모든 과정을 이해할 수 있게 됩니다.
그 과정이란 0.1
과 같은 리터럴literal을 읽어서 부동소수점 비트 표현으로 바꾸고, 실제로 부동소수점을 더하는 것입니다.
리터럴 파싱
보통 0.1
이나 -2
로 표현하는 부동소수점 리터럴은 소스 코드 상 문자열에 불과합니다.
따라서 이것을 부동소수점으로 바꾸는 파싱parsing 과정이 필요합니다.
즉 파싱이란 리터럴이라는 문자열을 읽어서 숫자로 리턴하는 함수로 볼 수 있습니다.
그러면 파싱 함수를 수도코드로 만들어봅시다.
리터럴 파싱 () // 부호와 숫자를 리턴
// 스트링 읽기를 위한 시작 인덱스
// 부호 읽기
만약 이 이면
// 다음 문자로 인덱스 전진
리터럴에서는 부호가 첫 문자로 올 수 있으니, 이를 읽어서 변수에 기억해둡니다. 만약 부호가 없다면 자연스럽게 양수로 파싱합니다.
그 다음 숫자를 읽습니다. 파싱을 지엽적인 내용까지 다루고 싶지 않기 때문에, 편의상 리턴할 숫자 은 64비트 부동소수점이라고 가정하겠습니다. 여기서 구현하는 부동소수점은 32비트이므로, 리턴한 숫자의 오차는 안전하게 무시할 정도가 됩니다.
파싱 알고리즘은 십진법의 정의에서 자연스럽게 나옵니다.
예를 들어, 123
이라는 리터럴이 있다고 해봅시다.
12
까지 읽었다가, 그 다음에 3
을 만난다면, 이전에 읽었던 숫자에 을 곱해 으로 만들고 지금 읽은 숫자 을 더해 으로 파싱합니다.
다음 수도코드는 이 과정을 각 문자마다 반복해 파싱합니다.
다음을 .length 이고 이 숫자인 동안 반복
여기서 리터럴이 끝난다면, 소수점이 없는 것이므로 바로 리턴하면 됩니다. 소수점이 아닌 걸 만나도 부동소수점 리터럴이 끝난 것으로 처리하겠습니다.
이후 소수점 이하를 읽습니다.
그 과정은 정수 부분을 읽었을 때와 비슷하게 진행하겠지만, 무엇을 읽든 정수 부분은 이어야 할 것입니다.
예를 들어, 리터럴 12.34
이 주어졌다면, 34
를 읽었을 때 를 얻어야 하고, 이를 위해 를 으로 나눠야 합니다.
그런데 이라는 점을 떠올려 본다면, 일반적으로 자리를 읽었을 때 으로 나누면 된다는 것을 알 수 있습니다. 다음 수도코드에서 라는 변수가 바로 그 을 기억합니다.
// 소수점 문자 다음으로 인덱스 전진
다음을 .length 이고 이 숫자인 동안 반복
리턴
이렇게 완성한 수도코드는 파이썬과 같은 언어로 구현할 수 있습니다. 실제 구현은 직접 해보는 것으로 남기겠습니다. 구현 예시는 지스트Gist에서 확인할 수 있습니다.
비트 표현 만들기
본격적으로 부동소수점의 비트 표현을 구해야 하는데, 여기서 사용할 방법은 이렇습니다. 숫자 이 주어지면, 그 숫자보다 크지 않은 2의 제곱수 을 얻습니다. 그러면 의 이진법 표현에서 가 가장 큰 자릿수에 해당합니다. 이후 그 다음 자릿수는 단순 비교를 반복해가며 구할 수 있습니다.
예를 들어, 숫자 가 주어졌다고 해봅시다. 이보다 크지 않은 2의 제곱수로 를 얻습니다. 따라서 가장 큰 자릿수는 의 자리가 됩니다.
이제 다음 자릿수를 계속 구할 차례입니다. 만약 다음 자릿수가 이고 나머지 자릿수를 으로 채웠다고 해봅시다. 그러면 이 되어 보다 커지기 때문에, 이후 자릿수에 무엇이 오더라도 가 될 수 없습니다. 따라서 다음 자릿수는 이어야 합니다.
다음 자릿수를 로 한다면 이고, 보다 작으므로 이 자릿수는 이 되어야 합니다. 이런 식으로 반복하면 라는 이진수를 얻게 됩니다.
이 결과를 노말라이즈하면 가 되고, 이로부터 부호 , 지수 , 소수 의 비트 표현을 만들어낼 수 있습니다.
지수는 가장 큰 자릿수 의 지수 로부터 얻습니다.
다만 이전 글에서 언급했듯이, 지수는 127 만큼 바이어스biased 되어야 하고, 소수는 맨 앞의 비트 1
이 생략됩니다.
이 결과는 C++ 코드로 확인해볼 수 있습니다.
float f = 5.5;
string b = bitset<32>(*reinterpret_cast<int *>(&f)).to_string();
cout << "S: " << b[0] << ", "
<< "E: " << b.substr(1, 8) << ", "
<< "F: " << b.substr(9) << "\n";
// result: S: 1, E: 10000001, F: 01100000000000000000000
그런데 숫자 는 다행히 소수 부분이 3비트로 충분했지만, 과 같은 숫자는 무한히 길어지기 때문에 적당한 오차를 안고 근사값으로 표현해야 합니다. 이러한 부분은 이후 구현에서 살펴보겠습니다.
구현: 리터럴을 읽고 부동소수점 만들기
앞에서 만든 함수를 이용해, 리터럴로부터 부동소수점을 만드는 것을 파이썬으로 만들어봅시다. 이전에 만든 클래스의 정적 메소드로 구현해보겠습니다.
class Float:
@staticmethod
def fromLiteral(literal: str) -> "Float":
num = parseLiteral(literal)
return Float.fromNumber(num)
여기서 parseLiteral()
함수는 아까 소개했던 리터럴 파싱 함수 수도코드의 구현입니다.
이 메소드는 파싱된 숫자값을 읽고, 곧 구현할 fromNumber()
메소드에 부동소수점 인스턴스를 만드는 일을 맡깁니다.
방금 소개했던 이진수 변환 과정을 그대로 fromNumber()
메소드로 옮겨서 만듭니다.
먼저, 숫자를 부호와 절대값으로 분리하고, 0
인 경우를 예외적으로 처리하겠습니다.
@staticmethod
def fromNumber(num: float) -> "Float":
sign = 0 if copysign(1.0, num) == 1.0 else 1
absNum = abs(num)
if absNum == 0:
return Float(sign, 0, 0)
그 다음, 앞서 소개했던 대로 비트 표현을 얻기 위해 가장 큰 자릿수를 구합니다.
이 과정에서 가장 큰 자릿수의 지수 exp
를 얻는데, 덕분에 부동소수점 표현의 지수를 구하기 쉬워집니다.
# get most significant digit
exp = 0
digit = 1.0
if absNum > 1:
while digit < absNum/2:
exp += 1
digit *= 2
if absNum < 1:
while digit > absNum:
exp -= 1
digit /= 2
이제 모든 자릿수를 구합니다.
digit
은 가장 큰 자릿수부터 시작해 반씩 나눠지며 그 다음 자릿수를 나타내게 됩니다.
acc
은 여태까지 확정된 자릿수가 반영된 값이며, acc + digit
을 기준으로 다음 자릿수의 값이 1
인지 0
인지 결정합니다.
# get 27 digit bits
bits = []
acc = 0.0
for _ in range(1+23+3):
if acc + digit <= absNum:
acc += digit
bits.append(1)
else:
bits.append(0)
digit /= 2 # next digit
binaryStr = "".join(map(lambda n: str(n), bits[1:]))
binary = int(binaryStr, base=2)
rounded = roundToNearestEven(binary)
부동소수점이 실제로 가질 소수는 23비트지만, 실제로는 27비트를 구합니다.
부동소수점 소수 비트는 소수점 맨 앞의 1
을 생략해서 사실상 24비트의 정보를 담고, 근사값을 구하기 위한 3비트를 더 준비해야하기 때문입니다.
마지막 줄의 roundToNearestEven()
함수가 바로 근사값을 계산할 함수이고, 곧 구현할 것입니다.
이어서 다음 코드를 통해 완성합니다. 이 부분은 근사값을 구한 결과가 간혹 23비트에서 한 비트 넘을 수 있기 때문에, 그때 지수 부분을 하나 올려서 처리하는 것입니다.
# adjust exponent if overflow occurs
if rounded >= (1 << 23):
rounded -= (1 << 23)
exp += 1
return Float(sign, exp+127, rounded)
이제 마지막으로 반올림만 남았습니다.
라운딩 룰 구현하기
부동소수점에서는 기본적으로 ‘가까운 짝수로 반올림’round to nearest even하는 방법이 사용됩니다. 한편 앞에서 예시로 본 것처럼 버림을 사용할 수도 있습니다. 여기서는 이 두 가지를 만들어보겠습니다.
가까운 짝수로 반올림
먼저, 보통 때는 일반적인 반올림을 하면서, 정확히 반인 경우는 가까운 짝수로 만드는 방법입니다. 예를 들면 다음과 같이 처리합니다. (과 의 경우를 잘 보세요.)
여기서는 마지막 3개의 비트를 사용해 반올림을 할 것인데요.
즉 0101
() 같은 경우는 1
()로 만드는 식입니다.
이 3개의 비트는 큰 자릿수부터 각각 가드guard, 라운드round, 스티키sticky 비트라고도 부릅니다.
다음 함수는 비트 숫자를 받아 마지막 3개의 비트를 이용해 반올림하고 비트 숫자를 리턴합니다.
def roundToNearestEven(binary: int) -> int:
least = (binary & 0b1000) >> 3
last = binary & 0b111 # guard, round, sticky bits
if last < 0b100:
return binary >> 3
if last > 0b100:
return (binary >> 3) + 1
# round to nearest even
if least == 0:
return binary >> 3
else:
return (binary >> 3) + 1
이 함수는 다음과 같이 쓸 수 있습니다.
roundToNearestEven(0b0101) # == 1 (0.101 rounded to 1)
이제 리터럴로부터 부동소수점을 얻을 수 있습니다.
f = Float.fromLiteral("0.1")
assert f.sign == 0
assert f.exp == 0b01111011
assert f.frac == 0b10011001100110011001101
이 결과는 C++ 코드로 얻은 결과와도 일치합니다.
float f = 0.1;
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: 01111011, F: 10011001100110011001101
버림
버림은 마지막 세 개의 비트를 단순하게 버림으로써 구현할 수 있습니다. 이 함수 또한 비트 숫자를 받아 비트 숫자를 리턴합니다.
def truncate(binary: int) -> int:
return binary >> 3
여기서 만든 함수들은 다음 덧셈 구현에서 사용할 것입니다.
부동소수점 덧셈 구현
부동소수점은 덧셈 방법이 알려져 있습니다. 이를 간략하게 알아보고 코드로 옮겨보겠습니다.
덧셈 방법
먼저 두 수 중에 지수 부분이 큰 쪽을 기준으로 자리를 맞춰 이루어집니다.
0.1 + 0.2
를 예로 들어봅시다.
두 리터럴을 읽었을 때, 각각 다음과 같은 부동소수점으로 파싱하게 됩니다.
0.1:
sign: 0
exp: 01111011 (-4)
frac: 10011001100110011001101
0.2:
sign: 0
exp: 01111100 (-3)
frac: 10011001100110011001101
frac
앞에는 생략된 비트 1
이 있기 때문에, 이것까지 고려하면 소수 부분은 다음과 같은 이진수 근사값이 됩니다.
0.1 = 1.10011001100110011001101 * 2^(-4)
0.2 = 1.10011001100110011001101 * 2^(-3)
여기서 0.2
쪽의 지수가 하나 더 크기 때문에, 이를 기준으로 자리를 맞춰 덧셈합니다.
즉 아래 다이어그램처럼 0.1
의 소수 부분을 오른쪽으로 한 단계 시프트합니다.
0.2: 1.10011001100110011001101
+ 0.1: 0.110011001100110011001101
--------------------------
10.0110011001100110011001110
^^^
이 결과에서 25번째부터 세 개의 비트, 여기서는 110
이 반올림에 사용됩니다.
만약 가까운 짝수로 반올림하는 라운딩 룰을 적용한다면, 이 세 비트는 올림 처리되고 사라질 것입니다.
이제 결과를 노말라이즈하고, 소수점 앞 비트 1
을 생략하면 23개의 소수 비트를 다음과 같이 얻습니다.
10.0110011001100110011001110
rounded: 10.0110011001100110011010
normalized: 1.00110011001100110011010
result: 00110011001100110011010
이는 실제로 C++에서 계산한 결과와 같습니다.
float f1 = 0.1;
float f2 = 0.2;
float f = f1 + f2;
string b = bitset<32>(*reinterpret_cast<int *>(&f)).to_string();
cout << "F: " << b.substr(9) << "\n";
// F: 00110011001100110011010
구현하기
이제 부동소수점 클래스에 앞서 설명한 덧셈을 구현할 것입니다. 구현을 간단히 하기 위해 부호가 다른 경우는 제외하겠습니다.
def __add__(self, other) -> "Float":
if not isinstance(other, Float):
raise Exception("not comparable")
if self.sign != other.sign:
raise Exception("not implemented for different signs")
먼저 지수가 작은 쪽을 고른 다음, 0
과 같으면 큰 수를 새로 리턴합니다.
이 때 이전에 만든 isZero()
메소드를 사용합니다.
greater, smaller = (self, other) if self.exp >= other.exp else (other, self)
if smaller.isZero():
return Float(greater.sign, greater.exp, greater.frac)
맨 앞에 생략된 비트 1
을 고려해야 하고, 나중에 할 반올림을 위해 세 개의 비트를 뒤에 붙입니다.
# prepend omitted bit
greaterFrac = greater.frac | (1 << 23)
smallerFrac = smaller.frac | (1 << 23)
# append three bits
greaterFrac <<= 3
smallerFrac <<= 3
작은 쪽의 수를 자리에 맞추고 더합니다.
덧셈 결과를 노말라이즈 하기 위해, 24 비트가 넘어간 만큼 지수에 반영합니다.
마지막으로 맨 앞의 비트 1
을 생략하면, 부동소수점 비트 표현을 만들 수 있습니다.
# align by exp
smallerFrac >>= greater.exp - smaller.exp
# add and round
added = greaterFrac + smallerFrac
rounded = roundToNearestEven(added)
exp = greater.exp
large = 1 << 24
while rounded >= large:
exp += 1
rounded >>= 1
# drop the first bit
rounded &= ((1 << 23) - 1)
return Float(sign, exp, rounded)
이제 다음과 같이 덧셈이 가능합니다.
f = Float.fromLiteral("0.1") + Float.fromLiteral("0.2")
assert f.sign == 0
assert f.exp == 0b01111101
assert f.frac == 0b00110011001100110011010
이전에 만든 비교 연산으로, 0.3
과 같음을 알 수 있습니다.
이 결과는 앞에서 소개한 예시와 일치합니다.
assert f == Float.fromLiteral("0.3")
라운딩 모드 추가하기
라운딩 모드에는 한 가지 방법만 있는 것은 아닙니다. 앞에서 만든 버림도 사용해볼 수 있는데요. 이를 위해 클래스에 라운딩 모드를 추가합시다.
class Float:
roundMode = "NEAREST_EVEN"
기본값으로는 가까운 짝수로 반올림하도록 만들었습니다.
그리고 __add__()
메소드에서 라운딩 모드에 따라 다르게 처리하도록 바꿉시다.
# add and round
added = greaterFrac + smallerFrac
rounded = roundToNearestEven(added)
rounded = 0.0
if Float.roundMode == "NEAREST_EVEN":
rounded = roundToNearestEven(added)
elif Float.roundMode == "TRUNCATE":
rounded = truncate(added)
else:
raise Exception("bad rounding mode")
이렇게 버림을 이용해 아까와 같이 0.1 + 0.2
를 계산하면, 이번에는 0.3
과는 다른 결과가 나타납니다.
Float.roundMode = "TRUNCATE"
f = Float.fromLiteral("0.1") + Float.fromLiteral("0.2")
assert f != Float.fromLiteral("0.3")
이는 C++로 같은 결과를 구했을 때와 마찬가지입니다.
fesetround(FE_DOWNWARD);
float f1 = 0.1;
float f2 = 0.2;
float f3 = 0.3;
float f = f1 + f2;
std::cout << (f1 + f2 != f3) << "\n";
// result: 1 (true)
이렇게 라운딩 모드와 함께 덧셈 연산을 완성했습니다. 여기까지 작성한 코드는 지스트Gist에서 확인할 수 있습니다.
그 외의 것들
본문에서 생략했지만 언급할 만한 것들로 이런 것이 있습니다.
덧셈의 결합성
모든 숫자 에 대해, 다음을 만족하면 덧셈이 결합법칙을 만족한다associative고 합니다.
즉 어디서부터 더하든지 같은 결과를 내는 성질입니다.
부동소수점은 이런 간단한 성질도 만족시키지 않습니다.
(0.1+0.2)+0.3 == 0.1+(0.2+0.3) # False
이는 본문에서 구현했듯이 라운딩 룰 때문이라는 것을 알 수 있습니다.
실제 구현
여기서는 파이썬으로 만들어봤지만, 실제로는 대부분 CPU의 명령어 세트instruction set에서 부동소수점 연산을 지원합니다. 즉 하드웨어 수준에서 이미 구현되어 있습니다. 따라서 이렇게 소프트웨어 상에서 구현한 것보다 훨씬 빠르게 동작하게 됩니다.
특별한 숫자를 위한 비트 표현
부동소수점에는 무한과 ‘숫자가 아님’NaN, not-a-number을 표현하는 비트 표현도 있습니다. 본문에서는 간단한 구현을 위해 생략했지만, 부동소수점 사용 시 종종 마주칠 수 있는 것입니다. 특별한 비트 표현으로 예약된 값입니다.
마치며
32비트 부동소수점의 비교 연산과 덧셈 연산을 구현을 통해, 0.1 + 0.2 == 0.3
이 실행되는 과정을 따라가보았습니다.
부동소수점 리터럴로부터 비트 표현을 얻고, 이들끼리 덧셈하는 함수까지 만들어보았습니다.
그 과정에서 라운딩 모드에 따라 달라지는 비교 결과를 볼 수 있었습니다.
여기까지의 내용이 0.1 + 0.2
가 0.3
과 같을 수도 있고 다를 수도 있다는 사실을 이해하는데 도움이 되었기를 바랍니다.
레퍼런스
- Computer Organization and Design (David Patterson, John Hennessy, 2013), 또는 컴퓨터 구조 및 설계 (2015)