부동소수점은 어떻게 더할까

구현으로 알아보는 오차의 이유

10.0에 0.1을 곱해서 1.0이 되는 경우는 거의 없다.

10.0 times 0.1 is hardly ever 1.0.

— 브라이언 커니핸Brian Kernighan (1974)

프로그래밍을 하다 보면 0.1 + 0.20.3이 다른 현상을 마주치게 됩니다.

0.1 + 0.2 == 0.3 # False

대표적으로 위와 같은 파이썬 코드에서 볼 수 있는데, 그 이유는 부동소수점floating point이 그런 숫자를 정확하게 표현할 수 없기 때문이라고 보통 설명합니다. 실제로 0.1은 이진법으로 쓰면 끝이 없습니다.

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

그래서 컴퓨터가 가진 유한개의 비트로는 근사값을 표현할 수밖에 없습니다.

하지만 표현의 부정확성이 곧바로 0.1 + 0.20.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.20.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.20.3이 64비트 부동소수점으로서 다른 이유는, 유한한 비트로 인한 부정확한 표현과 더불어, 라운딩 룰로 인해 서로 다르게 표현한 근사값 때문입니다.

이전 글에서 부동소수점 비교 연산을 만들어보았습니다. 여기에 앞으로의 내용을 통해, 0.1 + 0.2 == 0.3이라는 코드가 실행될 때 일어나는 모든 과정을 이해할 수 있게 됩니다. 그 과정이란 0.1과 같은 리터럴literal을 읽어서 부동소수점 비트 표현으로 바꾸고, 실제로 부동소수점을 더하는 것입니다.

리터럴 파싱

보통 0.1이나 -2로 표현하는 부동소수점 리터럴은 소스 코드 상 문자열에 불과합니다. 따라서 이것을 부동소수점으로 바꾸는 파싱parsing 과정이 필요합니다. 즉 파싱이란 리터럴이라는 문자열을 읽어서 숫자로 리턴하는 함수로 볼 수 있습니다.

그러면 파싱 함수를 수도코드로 만들어봅시다.

리터럴 파싱 (literal\textit{literal}\,) // 부호와 숫자를 리턴

ii \leftarrow 00 // 스트링 읽기를 위한 시작 인덱스

 

// 부호 읽기

sign\textit{sign} \leftarrow 11

만약 literal[i]\textit{literal}[i]-이면

sign\textit{sign} \leftarrow 1-1

만약 literal[i]\textit{literal}[i]-이거나 ++이면

ii \leftarrow i+1i+1 // 다음 문자로 인덱스 전진

리터럴에서는 부호가 첫 문자로 올 수 있으니, 이를 읽어서 sign\textit{sign} 변수에 기억해둡니다. 만약 부호가 없다면 자연스럽게 양수로 파싱합니다.

그 다음 숫자를 읽습니다. 파싱을 지엽적인 내용까지 다루고 싶지 않기 때문에, 편의상 리턴할 숫자 num\textit{num}은 64비트 부동소수점이라고 가정하겠습니다. 여기서 구현하는 부동소수점은 32비트이므로, 리턴한 숫자의 오차는 안전하게 무시할 정도가 됩니다.

파싱 알고리즘은 십진법의 정의에서 자연스럽게 나옵니다. 예를 들어, 123이라는 리터럴이 있다고 해봅시다. 12까지 읽었다가, 그 다음에 3을 만난다면, 이전에 읽었던 숫자에 1010을 곱해 120120으로 만들고 지금 읽은 숫자 33을 더해 123123으로 파싱합니다. 다음 수도코드는 이 과정을 각 문자마다 반복해 파싱합니다.

num\textit{num} \leftarrow 00

다음을 i<literali < \textit{literal}.length 이고 literal[i]\textit{literal}[i]이 숫자인 동안 반복

num\textit{num} \leftarrow num×10+literal[i]\textit{num} \times 10 + \textit{literal}[i]

ii \leftarrow i+1i+1

여기서 리터럴이 끝난다면, 소수점이 없는 것이므로 바로 리턴하면 됩니다. 소수점이 아닌 걸 만나도 부동소수점 리터럴이 끝난 것으로 처리하겠습니다.

만약 i=literali = \textit{literal}.length 또는 literal[i]\text{literal}[i] \ne 소수점이면
리턴 num×sign\textit{num} \times \textit{sign}

이후 소수점 이하를 읽습니다. 그 과정은 정수 부분을 읽었을 때와 비슷하게 진행하겠지만, 무엇을 읽든 정수 부분은 00이어야 할 것입니다. 예를 들어, 리터럴 12.34이 주어졌다면, 34를 읽었을 때 0.340.34를 얻어야 하고, 이를 위해 3434100100으로 나눠야 합니다.

그런데 100=102100=10^{2}이라는 점을 떠올려 본다면, 일반적으로 nn자리를 읽었을 때 10n10^n으로 나누면 된다는 것을 알 수 있습니다. 다음 수도코드에서 factor\textit{factor}\,라는 변수가 바로 그 10n10^n을 기억합니다.

ii \leftarrow i+1i+1 // 소수점 문자 다음으로 인덱스 전진

factor\textit{factor} \leftarrow 11

 

다음을 i<literali < \textit{literal}.length 이고 literal[i]\textit{literal}[i]이 숫자인 동안 반복

num\textit{num} \leftarrow num×10+literal[i]\textit{num} \times 10 + \textit{literal}[i]

ii \leftarrow i+1i+1

factor\textit{factor} \leftarrow factor×10\textit{factor} \times 10

 

리턴 num/factor×sign\textit{num} / \textit{factor} \times \textit{sign}

이렇게 완성한 수도코드는 파이썬과 같은 언어로 구현할 수 있습니다. 실제 구현은 직접 해보는 것으로 남기겠습니다. 구현 예시는 지스트Gist에서 확인할 수 있습니다.

비트 표현 만들기

본격적으로 부동소수점의 비트 표현을 구해야 하는데, 여기서 사용할 방법은 이렇습니다. 숫자 nn이 주어지면, 그 숫자보다 크지 않은 2의 제곱수 2m2^m을 얻습니다. 그러면 nn의 이진법 표현에서 2m2^m가 가장 큰 자릿수에 해당합니다. 이후 그 다음 자릿수는 단순 비교를 반복해가며 구할 수 있습니다.

예를 들어, 숫자 5.55.5가 주어졌다고 해봅시다. 이보다 크지 않은 2의 제곱수로 22=42^2=4를 얻습니다. 따라서 가장 큰 자릿수는 222^2의 자리가 됩니다.

22(2120215.5=1??.?(2)\begin{align*} &2^{2\mathstrut} &&2^1 &&2^0 &&{} &&2^{-1} \\ 5.5 ={} &1 &&\text{?} &&\text{?} &&. &&\text{?} \thinspace {}_{(2)} \end{align*}

이제 다음 자릿수를 계속 구할 차례입니다. 만약 다음 자릿수가 11이고 나머지 자릿수를 00으로 채웠다고 해봅시다. 그러면 110(2)=6110_{(2)} = 6이 되어 5.55.5보다 커지기 때문에, 이후 자릿수에 무엇이 오더라도 5.55.5가 될 수 없습니다. 따라서 다음 자릿수는 00이어야 합니다.

22(2120215.5=10?.?(2)\begin{align*} &2^{2\mathstrut} &&2^1 &&2^0 &&{} &&2^{-1} \\ 5.5 ={} &1 &&0 &&\text{?} &&. &&\text{?} \thinspace {}_{(2)} \end{align*}

다음 자릿수를 11로 한다면 101(2)=5101_{(2)} = 5이고, 5.55.5보다 작으므로 이 자릿수는 11이 되어야 합니다. 이런 식으로 반복하면 101.1(2)101.1_{(2)}라는 이진수를 얻게 됩니다.

22(2120.215.5=101.1(2)\begin{align*} &2^{2\mathstrut} &&2^1 &&2^0 &&. &&2^{-1} \\ 5.5 ={} &1 &&0 &&1 &&. &&1 \thinspace {}_{(2)} \end{align*}

이 결과를 노말라이즈하면 1.011×221.011 \times 2^2가 되고, 이로부터 부호 SS, 지수 EE, 소수 FF의 비트 표현을 만들어낼 수 있습니다. 지수는 가장 큰 자릿수 222^2의 지수 22로부터 얻습니다. 다만 이전 글에서 언급했듯이, 지수는 127 만큼 바이어스biased 되어야 하고, 소수는 맨 앞의 비트 1이 생략됩니다.

S=0E=10000001F=0110000\begin{align*} & S = \texttt{0} \\ & E = \texttt{10000001} \\ & F = \texttt{011\medspace{}000\dots0} \end{align*}

이 결과는 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

그런데 숫자 5.55.5는 다행히 소수 부분이 3비트로 충분했지만, 0.1=0.00011(2)0.1 = 0.0\thinspace\overline{0011}_{(2)}과 같은 숫자는 무한히 길어지기 때문에 적당한 오차를 안고 근사값으로 표현해야 합니다. 이러한 부분은 이후 구현에서 살펴보겠습니다.

구현: 리터럴을 읽고 부동소수점 만들기

앞에서 만든 함수를 이용해, 리터럴로부터 부동소수점을 만드는 것을 파이썬으로 만들어봅시다. 이전에 만든 클래스의 정적 메소드로 구현해보겠습니다.

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하는 방법이 사용됩니다. 한편 앞에서 예시로 본 것처럼 버림을 사용할 수도 있습니다. 여기서는 이 두 가지를 만들어보겠습니다.

가까운 짝수로 반올림

먼저, 보통 때는 일반적인 반올림을 하면서, 정확히 반인 경우는 가까운 짝수로 만드는 방법입니다. 예를 들면 다음과 같이 처리합니다. (0.1(2)0.1_{(2)}1.1(2)1.1_{(2)}의 경우를 잘 보세요.)

0.01(2)0(2)0.1(2)0(2)(to even)0.11(2)1(2)1.01(2)1(2)1.1(2)10(2)(to even)1.11(2)10(2)\begin{align*} &0.01_{(2)} &\rightarrow{} &&0_{(2)} &{} \\ &0.1_{(2)} &\rightarrow{} &&0_{(2)} &{} \quad \text{(to even)} \\ &0.11_{(2)} &\rightarrow{} &&1_{(2)} &{} \\ &1.01_{(2)} &\rightarrow{} &&1_{(2)} &{} \\ &1.1_{(2)} &\rightarrow{} &&10_{(2)} &{} \quad \text{(to even)} \\ &1.11_{(2)} &\rightarrow{} &&10_{(2)} &{} \end{align*}

여기서는 마지막 3개의 비트를 사용해 반올림을 할 것인데요. 즉 0101 (0.101(2)0.101_{(2)}) 같은 경우는 1 (1(2)1_{(2)})로 만드는 식입니다. 이 3개의 비트는 큰 자릿수부터 각각 가드guard, 라운드round, 스티키sticky 비트라고도 부릅니다.

다음 함수는 nn 비트 숫자를 받아 마지막 3개의 비트를 이용해 반올림하고 n3n-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

버림

버림은 마지막 세 개의 비트를 단순하게 버림으로써 구현할 수 있습니다. 이 함수 또한 nn 비트 숫자를 받아 n3n-3 비트 숫자를 리턴합니다.

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에서 확인할 수 있습니다.

그 외의 것들

본문에서 생략했지만 언급할 만한 것들로 이런 것이 있습니다.

덧셈의 결합성

모든 숫자 a,b,ca, b, c에 대해, 다음을 만족하면 덧셈이 결합법칙을 만족한다associative고 합니다.

(a+b)+c=a+(b+c) (a + b) + c = a + (b + c)

즉 어디서부터 더하든지 같은 결과를 내는 성질입니다.

부동소수점은 이런 간단한 성질도 만족시키지 않습니다.

(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.20.3과 같을 수도 있고 다를 수도 있다는 사실을 이해하는데 도움이 되었기를 바랍니다.

레퍼런스

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