double의 52비트, bias 1023, 17자리는 어디서 나오는가
전제
부동소수점 — 0.1 + 0.2가 0.3이 아닌 이유에서 출발한다. double이 부호·지수·가수로 쪼개져 저장된다는 것과, 정규수의 값이 (-1)^부호 × 1.가수 × 2^(지수 - bias)라는 것을 안다고 본다.
풀리지 않는 질문
부동소수점 스펙 표는 대개 이렇게 생겼다.
| 항목 | float (32비트) |
double (64비트) |
|---|---|---|
| 가수(저장) | 23비트 | 52비트 |
| 가수(유효) | 24비트 | 53비트 |
| 지수 bias | 127 | 1023 |
| 십진 → 실수형 → 십진 변환에서 원래 숫자가 그대로 돌아오는 자릿수 |
6자리 | 15자리 |
| 실수형 → 십진 → 실수형 변환에서 같은 값으로 돌아오려면 필요한 자릿수 |
9자리 | 17자리 |
외우면 그만이지만 숫자마다 이유가 있다. 저장은 52비트인데 유효는 왜 53비트인가. bias는 왜 하필 1023인가. 왕복 자릿수는 왜 15와 17로 갈리는가. 셋 다 double 64비트를 어떻게 쪼갰는지에서 나온다.
구조
64비트가 세 조각으로 나뉜다.
1 11 52
+-+-------------+----------------------------------------+
|S| exponent | fraction |
+-+-------------+----------------------------------------+
63 62 52 51 0
- S (1비트) — 부호. 0이면 양수, 1이면 음수.
- exponent (11비트) — 지수 필드. 부호 없는 정수로 담고, 읽을 때 bias 1023을 뺀다.
- fraction (52비트) — 가수의 소수부. 정수부는 적히지 않는다.
float는 같은 배치를 1 / 8 / 23으로 나눈다. 아래 설명은 double 기준이고, float는 비트 수만 바꿔 읽으면 된다.
동작 원리
가수는 왜 저장보다 한 비트 많은가
메모리에 적는 칸은 52비트인데 계산에 쓰이는 자릿수는 53비트다. 한 비트가 공짜로 생기는 게 아니라, 값이 항상 같아서 적지 않을 뿐이다.
십진 과학적 표기법에서 0.00314를 3.14 × 10^-3으로 적듯, 이진에서도 소수점 앞이 한 자리가 되게 맞춘다. 이것이 정규화다. 십진에서 맨 앞자리는 1부터 9 중 하나지만, 이진에서 쓸 수 있는 숫자는 0과 1뿐이고 정규화한 수의 맨 앞은 0일 수 없다. 그래서 무조건 1이다.
0.1 = 0.0001100110011... (이진)
= 1.100110011... × 2^-4 ← 정규화
↑ ~~~~~~~~~~~
│ 이 52비트만 fraction 칸에 적는다
└ 항상 1이라 적지 않는다
읽을 때 규격이 앞에 1.을 도로 붙인다. 적힌 것은 52비트, 값을 이루는 것은 53비트다. 이 생략한 비트를 hidden bit라고 부른다.
hidden bit의 대가
한 비트를 아낀 값은 따로 치른다. 앞자리를 1로 못 박으면 1.xxx × 2^e 꼴이 아닌 수를 적을 방법이 없어진다. 두 가지가 걸린다.
0을 적을 수 없다. 앞자리가 1인데 지수를 아무리 낮춰도 0이 되지 않는다.
가장 작은 수 아래가 통째로 비어 버린다. 지수 필드는 밑이 있어서 double이 갈 수 있는 최저는 1.000...0 × 2^-1022, 약 2.2e-308이다. 가수로 더 줄이고 싶어도 앞자리 1이 버티고 있어 못 줄인다. 2.2e-308과 0 사이에 표현할 수 있는 값이 하나도 없다는 뜻이다.
그래서 지수 필드가 전부 0인 한 칸을 규칙에서 빼낸다. 이 칸에서만 앞자리를 1.이 아니라 0.으로 읽는다. 앞자리가 풀리면 가수의 앞쪽 0으로 값을 더 줄일 수 있다.
지수 필드 1~2046 (정규수) 1.xxxxx × 2^(지수 - 1023)
지수 필드 0 (예외) 0.xxxxx × 2^-1022
↑ 가수도 전부 0이면 값은 0
아니면 비정규수
이 칸의 지수가 0 - 1023 = -1023이 아니라 -1022인 것이 헷갈리는 지점이다. 정규수 구간과 눈금 간격을 맞추려고 일부러 한 칸 올려 잡았다. 두 구간 다 지수가 -1022면 가수 최하위 비트의 무게가 2^-52 × 2^-1022 = 2^-1074로 같아진다. 그래서 경계에서 간격이 끊기지 않는다.
System.out.println(Double.MIN_NORMAL); // 2.2250738585072014E-308 = 2^-1022
System.out.println(Math.nextDown(Double.MIN_NORMAL)); // 2.225073858507201E-308 최대 비정규수
System.out.println(Double.MIN_NORMAL - Math.nextDown(Double.MIN_NORMAL)); // 4.9E-324 = 2^-1074
System.out.println(Double.MIN_VALUE); // 4.9E-324 = 2^-1074
최소 정규수와 그 바로 아래 비정규수의 차이가 2^-1074이고, 이것이 Double.MIN_VALUE와 같다. 비정규수 구간 전체가 2^-1074 간격으로 촘촘히 0까지 이어진다. -1023으로 잡았다면 최대 비정규수가 2^-1023 근처에 멈춰, 2^-1023과 2^-1022 사이에 구멍이 났다.
비정규수는 이렇게 얻은 값들이다. 남은 예외인 지수 필드 전부 1은 Infinity와 NaN에 쓴다.
지수 bias가 2^(지수 비트 수 - 1) - 1인 이유
네 단계로 정해진다.
1. 지수는 음수여야 하는데 지수 필드는 부호 없는 정수다. 0.1의 지수는 -4지만 필드에는 음수를 담을 방법이 없다. 그래서 실제 지수에 bias를 더해 저장하고 읽을 때 뺀다. 2의 보수 대신 이 방식을 쓰면 두 양수 double의 대소를 비트 패턴 그대로 정수 비교해도 답이 맞는다.
2. 지수 필드 11비트는 0~2047을 담는데, 양 끝 두 개는 예약돼 있다. 위에서 본 대로 전부 0이면 비정규수, 전부 1이면 Infinity나 NaN이다. 실제로 쓰는 필드 값은 1 ~ 2046, 2046칸이다.
3. bias는 이 2046칸을 수직선 어디에 세울지 정하는 값이다. bias가 1023이면 지수 범위는 -1022 ~ +1023, 1024면 -1023 ~ +1022가 된다. 칸 수는 그대로고 위치만 밀린다. 0을 가운데 두고 반씩 나누면 딱 떨어지지 않으므로 한쪽이 한 칸 넓어진다.
4. IEEE 754는 위쪽이 넓은 쪽을 골랐다. 가장 작은 정규수의 역수가 오버플로하지 않게 하기 위해서다. bias 1023에서 최소 정규수는 2^-1022이고 그 역수 2^1022는 지수 상한 1023 안쪽이다. bias 1024였다면 최소 정규수가 2^-1023이 되고, 역수 2^1023은 지수 상한 1022를 넘어 Infinity가 된다.
Goldberg가 같은 이유를 든다.
The reason for having |emin| < emax is so that the reciprocal of the smallest number will not overflow.
역수가 언더플로하는 것은 오버플로만큼 심각하지 않다는 판단이다. 언더플로는 0에 가까운 값으로 뭉개지지만 오버플로는 Infinity가 되어 이후 계산을 전부 삼킨다.
그래서 bias = 2^(11-1) - 1 = 1023이다. float는 지수 8비트라 2^7 - 1 = 127이다.
15자리와 17자리는 방향이 다른 왕복이다
둘 다 비트가 아니라 십진 자릿수를 센다. 헷갈리는 이유는 이름이 둘 다 “왕복”인데 출발점이 반대라서다.
**십진 → double → 십진 (15자리)**는 사람이 적은 숫자가 기준이다. 유효숫자 15자리 이내의 십진수는 double에 넣었다 꺼내도 원래 숫자가 그대로 돌아온다. 16자리부터는 보장이 깨진다.
**double → 십진 → double (17자리)**는 메모리에 있는 비트가 기준이다. 임의의 double을 십진수로 적었다가 다시 읽어 같은 비트로 돌아오려면 최대 17자리가 필요하다. 16자리에서 자르면 이웃한 double과 뭉개져 다시 읽을 때 옆 값이 될 수 있다.
두 숫자 모두 유효 가수 비트 수 p에서 계산된다. 이진 한 자리는 십진으로 log10 2 ≈ 0.30103자리어치다.
십진 → 실수형 → 십진 ⌊(p - 1) × log10 2⌋
실수형 → 십진 → 실수형 ⌈p × log10 2 + 1⌉
double은 p = 53이라 ⌊52 × 0.30103⌋ = ⌊15.65⌋ = 15, ⌈53 × 0.30103 + 1⌉ = ⌈16.95⌉ = 17이다. float는 p = 24라 같은 식에서 6과 9가 나온다.
조건은 어느 쪽 눈금을 덮느냐로 갈린다. 앞 식은 십진 눈금 하나 안에 double 눈금이 최소 하나는 들어가야 한다는 조건이고, 뒤 식은 반대로 double 눈금 하나마다 십진 표기가 최소 하나씩 배정돼야 한다는 조건이다.
- 1과 + 1은 같은 현상의 양면이다. 두 눈금 다 상대 간격이 구간 안에서 일정하지 않다. 지수가 같은 double 구간 안에서 2배까지, 자릿수가 같은 십진수 구간 안에서 10배까지 벌어진다. 두 식 모두 덮어야 하는 쪽이 가장 거친 지점을 기준으로 잡으므로, double 쪽은 한 비트를 손해 봐 p - 1이 되고 십진 쪽은 한 자리를 더 써야 해서 + 1이 붙는다.
p - 1의 - 1은 hidden bit 때문이 아니다. 가수 64비트를 hidden bit 없이 전부 저장하는 x87 80비트 확장 정밀도에서도 LDBL_DIG는 ⌊64 × log10 2⌋ = 19가 아니라 ⌊63 × log10 2⌋ = 18이다. 기준은 저장 비트 수가 아니라 p - 1이다.
C 표준이 두 식을 그대로 적어 뒀다. N1570 §5.2.4.2.2의 DBL_DIG가 ⌊(p − 1) log10 b⌋, DBL_DECIMAL_DIG가 ⌈1 + p log10 b⌉다. 이름이 비슷한 DECIMAL_DIG는 double이 아니라 그 환경에서 가장 넓은 타입(p_max) 기준이라, long double이 80비트인 x86-64 리눅스에서는 17이 아니라 21이다.
double 하나가 늘 17자리를 차지한다는 뜻은 아니다. 최악의 경우에 17자리까지 필요하다는 뜻이고, 실제로는 값마다 다르다. println(0.1)이 0.1로 찍히는 이유다.
직접 확인
Java 26에서 실행했다.
비트를 꺼내 본다
static String bits(double d) {
String s = Long.toBinaryString(Double.doubleToLongBits(d));
s = "0".repeat(64 - s.length()) + s;
return s.charAt(0) + " " + s.substring(1, 12) + " " + s.substring(12);
}
bits(1.0) 0 01111111111 0000000000000000000000000000000000000000000000000000
bits(-1.0) 1 01111111111 0000000000000000000000000000000000000000000000000000
bits(0.5) 0 01111111110 0000000000000000000000000000000000000000000000000000
bits(0.1) 0 01111111011 1001100110011001100110011001100110011001100110011010
1.0의 지수 필드는 01111111111 = 1023이다. 1023 - 1023 = 0이므로 1.0 × 2^0이다. 0.5는 1022 - 1023 = -1, 0.1은 1019 - 1023 = -4다. 0.5와 1.0은 2의 거듭제곱이라 fraction이 전부 0이다.
hidden bit를 손으로 붙여 본다
long b = Double.doubleToLongBits(0.1);
int exp = (int) ((b >> 52) & 0x7FF); // 지수 필드
long frac = b & ((1L << 52) - 1); // 저장된 52비트
System.out.println(exp + " -> " + (exp - 1023));
System.out.println(Long.toBinaryString(frac | (1L << 52))); // 앞에 1을 붙인 53비트
1019 -> -4
11001100110011001100110011001100110011001100110011010
두 번째 줄이 53자다. 맨 앞 1이 메모리 어디에도 없던 hidden bit다. 나머지 52자는 bits(0.1)의 fraction과 같고, 1001이 반복되다 끝에서 1010으로 올림됐다. 이 올림이 0.1의 오차다.
bias를 값으로 확인한다
System.out.println(Double.MIN_EXPONENT); // -1022
System.out.println(Double.MAX_EXPONENT); // 1023
System.out.println(Double.MIN_NORMAL); // 2.2250738585072014E-308 = 2^-1022
System.out.println(1.0 / Double.MIN_NORMAL); // 4.49423283715579E307 = 2^1022
지수 범위가 -1022 ~ +1023으로 위쪽이 한 칸 넓다. 최소 정규수의 역수 2^1022는 Infinity가 아니라 유한한 값으로 나온다. bias를 1024로 잡았다면 이 자리에서 오버플로했다.
15자리와 17자리를 재현한다
double → 십진 → double 방향부터.
double d = 0.1 + 0.2;
System.out.println(d); // 0.30000000000000004
System.out.println(Double.parseDouble("0.3000000000000000") == d); // false (16자리)
System.out.println(Double.parseDouble("0.30000000000000004") == d); // true (17자리)
16자리에서 자른 0.3000000000000000은 파싱하면 0.3이 되어 원래 비트로 돌아오지 못한다.
반대 방향은 무작위 십진수로 센다. [1, 10) 구간에서 자릿수별로 50만 개를 만들어, double에 넣었다 꺼낸 뒤 원래 숫자와 값이 같은지 본다.
import java.math.BigDecimal;
import java.util.Random;
static int scan(int digits, long seed, int trials) {
Random r = new Random(seed);
long lo = (long) Math.pow(10, digits - 1);
int bad = 0, shown = 0;
for (int i = 0; i < trials; i++) {
long m = lo + (long) (r.nextDouble() * lo * 9);
String s = (m / lo) + "." + String.valueOf(m).substring(1);
String rt = Double.toString(Double.parseDouble(s));
if (new BigDecimal(s).compareTo(new BigDecimal(rt)) != 0) {
bad++;
if (shown++ < 3) System.out.println(" " + s + " -> " + rt);
}
}
return bad;
}
// digits가 17 이상이면 lo * 9가 2^53을 넘어 표본 분포가 틀어진다
System.out.println("scan(15, 7, 500000) -> " + scan(15, 7, 500000));
System.out.println("scan(16, 7, 500000) -> " + scan(16, 7, 500000));
scan(15, 7, 500000) -> 0
9.075494284678942 -> 9.075494284678943
9.358732655402671 -> 9.35873265540267
8.799971329562946 -> 8.799971329562945
scan(16, 7, 500000) -> 47097
15자리는 50만 개 전부 돌아왔다. 16자리는 47,097개, 9.4%가 다른 숫자가 되어 나왔다.
9.4%는 이 구간에 딸린 수치다. 실패는 전부 [8, 10)에 있다. 그 구간의 ulp 1.78e-15가 16자리 십진 눈금 1e-15보다 커서, 서로 다른 두 십진수가 같은 double로 뭉개진다. 8 미만에서는 ulp가 눈금보다 좁아 이 일이 일어나지 않는다. 구간을 바꾸면 비율이 달라지지만 15자리 결과 0은 구간과 무관하다.
문자열 비교 대신 BigDecimal로 값을 견준다. Double.toString이 뒤에 붙은 0을 떼기 때문이다. 3.817123436608480이 3.81712343660848로 나오는 것은 왕복 실패가 아니라 표기 차이다.