✍️ 필사 모드: 부동소수점과 IEEE 754 완벽 해부 — 왜 0.1 + 0.2 ≠ 0.3 인가, NaN, 서브노멀, 통화 연산, bfloat16까지 (2025)
한국어들어가며 — "컴퓨터가 계산을 못 한다고요?"
> 0.1 + 0.2
0.30000000000000004
> 0.1 + 0.2 === 0.3
false
개발자 커뮤니티의 영원한 밈이다. 비전공자 친구가 옆에서 이걸 보면 "컴퓨터가 계산을 못 해?"라고 어리둥절해한다. 정답은 컴퓨터가 계산을 못 하는 게 아니라, 우리가 십진수로 생각하는데 컴퓨터는 이진수로 저장한다는 것이다. 하지만 이 설명은 표면일 뿐. 더 깊은 질문들이 기다린다:
- 왜
0.3은 저장 못 하면서0.5는 정확히 저장되는가? - NaN은 왜 자기 자신과도 같지 않은가? (
NaN === NaN→ false) - 왜
-0.0과+0.0이 따로 존재하는가? - 왜 서브노멀 숫자 연산이 100배 느려지는 CPU가 있는가?
- LLM이 쓰는 bfloat16과 FP8은 왜 필요한가?
- Intel Pentium FDIV 버그가 Intel에 $475M 손실을 입힌 이유는?
- 1996년 Ariane 5 로켓이 64비트 float을 16비트 int로 변환하다 폭발한 사건의 진짜 교훈은?
이 글은 IEEE 754 부동소수점 표준을 처음부터 해부한다. 비트 단위 구조, 반올림 모드, 특수값, 정밀도 함정, 금융·과학 계산에서의 대안(decimal, arbitrary precision), 그리고 AI 시대의 저정밀도 포맷(bfloat16, FP8, FP4)까지.
"부동소수점은 신비롭다"는 건 숫자를 모르는 게 아니라, 그 규칙이 너무 정교해서 일상적 직관과 충돌하기 때문이다. 이 글을 끝까지 읽으면 0.1 + 0.2 문제의 진짜 이유를 비트 레벨로 설명할 수 있게 될 것이다.
1. 왜 부동소수점인가 — 고정소수점과의 대결
1.1 정수만으로는 부족한 이유
64비트 signed int는 약 ±9.2 × 10^18까지. 크기는 충분해 보이지만 소수점이 없다. 1/3 같은 분수, 0.5 같은 소수, 1e-20 같은 미세 수를 표현 못 한다.
1.2 고정소수점 (Fixed-point)
"항상 소수점이 N번째 자리"라고 약속하는 방식. 예: 32비트 중 상위 16비트는 정수부, 하위 16비트는 소수부.
- 장점: 간단, 빠름 (정수 연산 그대로 사용)
- 단점: 범위/정밀도 trade-off가 고정. 매우 크거나 매우 작은 수 표현 불가
금융 시스템(돈 단위), 일부 임베디드/DSP에서 여전히 사용.
1.3 부동소수점 (Floating-point)
과학적 기수법에서 영감:
6.022 × 10^23 (아보가드로 수)
1.6 × 10^-19 (전자 전하)
즉 가수(significand, mantissa) × 밑수(base)^지수(exponent) 형태. 밑수를 2로 고정하면:
가수 × 2^지수
같은 저장 공간(32/64비트)로 엄청난 범위와 상대 정밀도를 동시에 얻는다. 이게 IEEE 754의 핵심 아이디어다.
2. IEEE 754 — 1985년의 혁명
2.1 표준 이전의 혼돈
1970년대 각 CPU 제조사가 독자적 부동소수점 포맷을 썼다. IBM 360의 16진수 기반 부동소수점은 Burroughs, DEC, Cray와 다 달랐다. 같은 FORTRAN 코드가 기계마다 다른 결과를 내는 악몽.
2.2 William Kahan의 영웅담
UC Berkeley의 수학자 William Kahan(2003년 튜링상 수상)이 1970년대 후반 Intel 8087 co-processor 설계 자문을 맡았다. 그는 "모든 CPU가 따라야 할 숫자 표준"을 주장했고, 1985년 IEEE 754-1985로 결실을 맺었다. 이후 2008년과 2019년에 갱신.
2.3 핵심 아이디어
모든 숫자는 다음 비트 배치로 표현:
┌─────┬──────────┬──────────────────┐
│ sign│ exponent │ mantissa/significand │
│ (S) │ (E) │ (M) │
└─────┴──────────┴──────────────────┘
- Single precision (float32): 1 + 8 + 23 = 32비트
- Double precision (float64): 1 + 11 + 52 = 64비트
- Half precision (float16): 1 + 5 + 10 = 16비트
- Quadruple (float128): 1 + 15 + 112 = 128비트
값 계산:
(-1)^S × (1.M) × 2^(E - bias)
1.M은 암묵적 선행 1. 정규화된 수는 항상 1.xxx... 형태라서 1을 저장하지 않고 가수 공간을 1비트 절약한다(숨은 비트).
2.4 float64 (double)의 숫자 0.1 예시
십진수 0.1은 이진수로 무한 반복:
0.1 = 0.0001100110011001100110011... (2진)
float64로 저장하면 52비트에서 잘린다:
sign=0, exponent=01111111011, mantissa=1001100110011001100110011001100110011001100110011010
이걸 다시 십진수로 풀면:
0.1000000000000000055511151231257827021181583404541015625
즉, JavaScript/Python/Java에서 0.1이라고 쓰는 순간 이미 이 숫자가 저장된다. 우리가 본 "0.1"은 출력 시 반올림된 표시다.
2.5 0.1 + 0.2의 진짜 결과
두 숫자 각각 약간씩 "진짜 0.1"보다 커서, 더하면 진짜 0.3보다 약간 큰 값이 된다. JS가 이를 "가장 짧게 십진수로 표시"하면 0.30000000000000004. 표현 가능한 double로 "정확한 0.3"에 가장 가까운 값은 0.299999...8 또는 0.300000...4이고, 더하기 결과는 후자에 더 가까웠다.
3. 비트를 직접 들여다보기
3.1 JavaScript로 float64 비트 보기
function floatToBits(x) {
const buf = new ArrayBuffer(8)
new Float64Array(buf)[0] = x
const view = new BigUint64Array(buf)
return view[0].toString(2).padStart(64, '0')
}
floatToBits(0.1)
// "0011111110111001100110011001100110011001100110011001100110011010"
// sign=0, exponent=01111111011, mantissa=1001...1010
3.2 float32로 0.1은?
0 01111011 10011001100110011001101
float32는 가수가 23비트뿐이라 더 많이 절단된다. 결과: 0.100000001490116119384....
3.3 Kahan의 교훈
"부동소수점은 십진수의 근사치다. 정확한 값을 기대하지 마라. 대신 상대 오차 bound를 보장하는 설계를 하라."
4. 특수값 — 0, Infinity, NaN
4.1 두 개의 0
부호 비트가 있으므로 +0과 -0이 따로 존재.
+0.0 === -0.0→true(수치 비교)1 / +0→Infinity1 / -0→-Infinity← 여기서 차이
일부 수학 함수에서 부호 0을 다르게 취급: atan2(+0, -0) = π, atan2(-0, -0) = -π.
4.2 Infinity
- 지수가 전부 1이고 가수가 0일 때 Infinity
1 / 0→Infinity(FE_DIVBYZERO 플래그)Infinity + 1→InfinityInfinity - Infinity→NaN
4.3 NaN — Not a Number
- 지수가 전부 1이고 가수가 0이 아닐 때
0 / 0,sqrt(-1),Infinity - Infinity,log(-1)등에서 생성- 자기 자신과도 같지 않음:
NaN === NaN→false - 유일한 탐지 방법:
Number.isNaN(x)또는x !== x
왜 그런가? IEEE 754의 설계 철학: NaN은 "이 계산은 무효"라는 신호. 두 무효한 계산의 결과가 같다고 말할 이유가 없다. 수식에 NaN이 전파되도록 해서 최종에 검사할 수 있게 했다.
4.4 NaN 비트 — Quiet vs Signaling
- qNaN (quiet): 대부분 라이브러리에서 쓰는 일반 NaN. 연산 전파.
- sNaN (signaling): 연산 시 CPU trap 발생. 디버깅용.
float64에서 NaN이 될 수 있는 비트 패턴은 수조 개. 임의 2비트로 뭘 뜻하는지 저장하는 "NaN boxing" 기법으로 JS 엔진이 타입과 값을 함께 인코딩하기도 한다(V8, LuaJIT).
5. Subnormal (denormal) — 느려지는 유령
5.1 정의
정규화된 수는 1.xxx × 2^e 형태(선행 1 암묵). 하지만 이 포맷으로는 0과 가장 작은 정규수(float64의 약 2.2 × 10^-308) 사이에 큰 공백이 생긴다.
Subnormal은 그 공백을 채우는 특수 표현:
- 지수 비트가 전부 0
- 가수가 0이 아닌 값
- 선행 1 없음, 즉
0.xxx × 2^-1022(float64)
덕분에 5 × 10^-324까지 점진적 근사가 가능(graceful underflow). 1985년 Kahan의 핵심 논쟁 포인트 중 하나였다.
5.2 왜 느린가
대부분의 CPU는 정규수만 빠른 hardware 경로로 처리하고, subnormal은 마이크로코드 또는 trap으로 처리한다.
- Intel Skylake: subnormal 연산이 정규수의 100배 이상 느릴 수 있음
- Apple M1/M2: 훨씬 개선되었지만 여전히 몇 배 차이
해결책: Flush-to-Zero (FTZ) / Denormals-Are-Zero (DAZ)
_mm_setcsr(_mm_getcsr() | 0x8040); // FTZ + DAZ 활성
작은 수를 바로 0으로 취급. 오디오 DSP, 게임, 물리 시뮬레이션에선 흔한 기본값. 단, 수학적 정확성은 약간 잃는다.
5.3 실전 사례
오디오 DSP에서 필터를 오래 돌리면 값이 자연스럽게 0에 수렴하는데, subnormal 영역에서 연산이 폭주해 audio glitch가 생긴다. 현대 DAW와 오디오 플러그인은 FTZ 필수.
6. 반올림 모드 — 5가지 선택
IEEE 754가 정한 반올림 모드:
| 모드 | 설명 |
|---|---|
| Round to Nearest, Ties to Even | 기본값. 0.5는 짝수 쪽으로. "Banker's rounding" |
| Round to Nearest, Ties Away | 0.5는 0에서 먼 쪽으로. 학교 수학 |
| Round toward +∞ | Ceiling |
| Round toward -∞ | Floor |
| Round toward 0 | Truncation |
Ties to Even이 기본인 이유: 여러 번 반올림해도 편향이 누적되지 않는다. "0.5 → 1"로만 올리면 평균이 커지는 경향이 생긴다.
6.1 언어별 기본
- C/C++, Java, JS: Ties to Even
- Python
round(): Python 3부터 Ties to Even (Python 2는 away from zero — 혼란의 원인) - 화면에
.toFixed(2): 언어마다 다름. 주의!
>>> round(0.5) # Python 3
0
>>> round(1.5)
2
>>> round(2.5)
2
처음 보면 이상해 보이지만, 통계적으로는 더 안전하다.
7. 결합 법칙이 깨진다
7.1 더하기의 비결합성
(0.1 + 0.2) + 0.3 // 0.6000000000000001
0.1 + (0.2 + 0.3) // 0.6
수학적으로는 (a+b)+c = a+(b+c)이지만, 부동소수점에서는 중간 반올림 때문에 결과가 달라진다.
7.2 컴파일러 최적화의 위험
-ffast-math (GCC/Clang) 또는 -Ofast는 결합 법칙을 가정해 재배치한다. 성능 5~30% 향상. 하지만:
- NaN/Inf 처리 생략 가능
- 결과가 미묘하게 달라짐
- 과학/금융 코드에는 위험
7.3 병렬 합산의 함정
# 단순 합산 O(N) 시간
s = 0
for x in data: s += x
# Kahan 보정 합산 — 오차 누적을 cancel
s = 0
c = 0
for x in data:
y = x - c
t = s + y
c = (t - s) - y
s = t
Kahan summation은 오차를 별도 변수에 저장해 다음 라운드에 보정. 백만 개 float32 합산 시 정확도가 훨씬 좋다. NumPy np.sum이 내부적으로 pairwise summation을 써서 비슷한 효과를 낸다.
8. 정수 ↔ float 변환의 함정
8.1 float64가 담을 수 있는 정수
2^53 = 9,007,199,254,740,992까지 모든 정수를 정확히 표현. 그 이상은 짝수만, 그 이상은 4의 배수만 ... JavaScript에서:
9007199254740992 + 1 // 9007199254740992 (같음!)
9007199254740993 // 9007199254740992 (저장 시점에 이미 절단)
그래서 JS는 64비트 정수를 안전하게 다루지 못한다. BigInt가 2020년에 표준화된 이유.
8.2 타임스탬프와 ID
- Unix ms 타임스탬프는 대략 1.7e12 → float64로 안전
- 하지만 나노초 타임스탬프는 1.7e18 → 2^53 초과 → 오차 발생
- Twitter snowflake ID(64비트) 등은 JS에서 반드시 BigInt 또는 문자열로 처리
8.3 Ariane 5 로켓 폭발 (1996)
유럽 Ariane 5 로켓이 발사 37초 만에 폭발. 원인: Ariane 4에서 재사용한 관성항법 소프트웨어가 horizontal velocity를 float64 → int16 변환하는데 Ariane 5의 실제 속도(더 빠름)가 int16 범위(±32768)를 초과. 오버플로우로 잘못된 값이 guidance computer에 전달되어 기체가 분해. $370M 손실.
교훈: 타입 변환 시 범위 검증 필수. 언어/타입 체크 수준을 넘어서는 수학적 도메인 체크가 필요.
8.4 Intel Pentium FDIV Bug (1994)
초기 Pentium의 FPU 분할 테이블에서 5개 항목이 누락되어 특정 입력에 대해 소수 5~10자리 이후 오류. 실세계 영향은 미미했으나 대중적 파장이 커져 Intel이 모든 CPU 교환 제공. $475M 회수 비용. 이후 Intel은 "Pentium Pro"부터 분할 알고리즘 재설계.
9. 비교와 동등성 — ==는 믿지 마라
9.1 근사 비교
// ❌
if (a === b) ...
// ✅ 절대 오차
if (Math.abs(a - b) < 1e-9) ...
// ✅ 상대 오차
if (Math.abs(a - b) < Math.abs(a) * 1e-9) ...
// ✅ ULP 기반 (가장 엄격)
// 두 수 사이 표현 가능한 float 개수로 비교
9.2 문제: "얼마나 가까우면 '같다'인가"
- 두 숫자가 크면 상대 오차가 맞음
- 두 숫자가 0 근처면 절대 오차가 맞음
- 둘 다 고려하는 "hybrid epsilon":
max(abs, rel × max(|a|, |b|))
9.3 ULP (Units in the Last Place)
float 두 값이 메모리상 인접한지로 측정.
int ulp_distance(double a, double b) {
if (signbit(a) != signbit(b)) return INT_MAX;
return abs(*(int64_t*)&a - *(int64_t*)&b);
}
ulp_distance < 4 같은 조건은 "정확히 같거나 거의 인접"을 의미. 테스트 코드에서 근사 비교 표준.
10. 금융/통화 — 절대 float을 쓰지 마라
10.1 왜 float이 금융에 부적합한가
- 0.1달러를 정확히 표현 못 함 → 1000번 더하면 센트 단위 오차
- 반올림이 banker's rounding인 경우 실무 규칙(round-half-up)과 다름
- 감사(audit)가 "이 오차는 버그가 아니라 IEEE 754입니다"를 받아들일 리 없음
10.2 대안 1: Integer cents
금액을 센트(100분의 1) 단위 정수로. 1달러 = 100 cents.
const price = 1999 // = $19.99
const tax = Math.round(price * 0.0825) // = 164 ($1.64)
const total = price + tax // = 2163 ($21.63)
장점: 단순, 빠름, 정확.
한계: 암호화폐처럼 소수점 8자리 정밀도가 필요하면 int64도 부족 (최대 ±9.2e18).
10.3 대안 2: Decimal (BCD, fixed decimal)
- Python:
from decimal import Decimal - Java:
BigDecimal - JavaScript:
decimal.js,big.js, nativeBigInt로 구현 - PostgreSQL:
NUMERIC(p, s) - C#:
decimal(128비트, 28자리 정밀도)
from decimal import Decimal, ROUND_HALF_UP
Decimal('0.1') + Decimal('0.2') # Decimal('0.3') ✅ 정확
주의: Decimal(0.1)은 float 0.1의 부정확한 값을 그대로 변환. 반드시 문자열로부터 만들어야 한다.
10.4 대안 3: Rational (분수)
Fraction(1, 3) 같이 정수/정수로 표현. Python fractions.Fraction. 완전 정확하지만 분모가 계속 커져서 메모리/성능 문제.
10.5 실전: 가상화폐 거래소
Binance, Upbit 등은 내부적으로 가장 작은 단위의 정수로 저장. 예: 사토시(1e-8 BTC). 표시만 8자리 소수로 변환. float 연산은 절대 쓰지 않는다.
11. 과학 계산 — NumPy, GPU의 세계
11.1 왜 과학 계산은 float을 쓰나
- 물리량(속도, 온도, 확률)은 연속값
- 10자리 정밀도면 충분
- SIMD/GPU 하드웨어 가속
11.2 Catastrophic Cancellation
# sqrt(x+1) - sqrt(x) for large x
import math
x = 1e16
a = math.sqrt(x + 1) - math.sqrt(x) # 0.0 ← 오차!
# 수식 변형: 분자·분모 × 공액
b = 1 / (math.sqrt(x + 1) + math.sqrt(x)) # 5e-9 ← 정확
비슷한 크기의 두 수를 뺄 때 유의미 자릿수가 소멸. 이차방정식의 판별식, 중심차분 미분 등에서 흔하다.
11.3 Numerical Recipes의 교훈
- 수식을 "수학적으로 예쁜" 형태가 아니라 수치적으로 안정한 형태로 변형
- 합산은 오름차순 정렬 후 수행
- 큰 수 / 작은 수로 나누기보다는 log domain에서 작업
11.4 Log-sum-exp 트릭
ML에서 softmax 계산 시:
# ❌ exp(1000) 오버플로우
p = exp(x_i) / sum(exp(x))
# ✅ 최대값 빼기
m = max(x)
p = exp(x_i - m) / sum(exp(x - m))
모든 exp 인자가 0 이하가 되어 수치 안정.
12. AI/ML — 저정밀도의 혁명
12.1 왜 저정밀도인가
- LLM 파라미터 수십억~수천억 개 → 메모리 병목
- 대부분 연산이 덧셈·곱셈 → 하드웨어가 저정밀도를 가속 가능
- 학습·추론에서 정밀도가 덜 중요 (노이즈로 작용)
12.2 float16 (FP16, half precision)
- 1 + 5 + 10 비트
- 범위:
6e-5 ~ 65,504 - 문제: gradient가 너무 작거나 너무 크면 underflow/overflow
- 해결: mixed precision (FP16 forward + FP32 loss scaling)
12.3 bfloat16 (Brain Float)
Google Brain이 TPU용으로 설계:
- 1 + 8 + 7 비트
- FP32와 같은 지수 범위 (지수 8비트) → underflow 문제 거의 없음
- 가수 7비트만 → 정밀도는 FP16보다 낮지만 학습 안정성은 훨씬 높음
현재 거의 모든 LLM 학습의 주력 포맷. NVIDIA A100/H100, Google TPU, AMD MI300 모두 네이티브 지원.
12.4 FP8 — NVIDIA Hopper 이후
- E4M3: 1 + 4 + 3 (범위 ±448, 추론 주력)
- E5M2: 1 + 5 + 2 (범위 ±57344, 학습)
- H100/B100에서 tensor core가 FP8 → FP32 누산 가속
- LLM 학습 처리량이 BF16 대비 2배 이상
12.5 FP4 — 2024 블랙웰 아키텍처
- NVIDIA Blackwell에서 FP4 지원
- 추론 전용, 메모리 대역폭 4배 절감
- LLM의 post-training quantization 주력
12.6 INT8 / INT4 양자화
float을 정수로 변환 + scale factor 저장. 학습 후 양자화(PTQ) 또는 학습 중 양자화(QAT). GPTQ, AWQ 같은 기법으로 정확도 유지하며 4비트까지 축소.
13. GPU와 SIMD — 병렬 부동소수점
13.1 Tensor Core
NVIDIA Volta(2017)부터 Tensor Core 도입: 4×4 matrix multiply-accumulate를 한 사이클에.
- Volta: FP16 multiply + FP32 accumulate
- Ampere: BF16, TF32, INT8 추가
- Hopper: FP8 + sparsity (50% weights가 0이면 2배 가속)
- Blackwell: FP4 + 전력 효율
13.2 TF32 — NVIDIA의 트릭
"TensorFloat-32"는 실제로 19비트. FP32와 같은 지수 8비트 + FP16과 같은 가수 10비트. FP32 연산을 TF32로 "조용히" 수행해 속도 향상. A100의 기본 모드.
13.3 SIMD (AVX, NEON)
CPU의 벡터 레지스터:
- SSE: 128비트 (4개 float32)
- AVX/AVX2: 256비트 (8개 float32)
- AVX-512: 512비트 (16개 float32)
- ARM NEON: 128비트, SVE: 가변
한 명령어로 여러 데이터 연산 → 2~16배 가속. NumPy, BLAS가 내부적으로 사용.
14. 언어별 부동소수점 세부
14.1 JavaScript
- 모든 number가 float64 (BigInt 제외)
- 정수도 float으로 저장 → 2^53 이상 정밀도 문제
Number.EPSILON = 2^-52 ≈ 2.22e-16Number.MAX_SAFE_INTEGER = 2^53 - 1Math.fround(x): float32로 반올림- ES2020
BigInt로 정수 안전
14.2 Python
- 기본 float = float64 (C double)
math.fsum(iterable): Shewchuk 알고리즘으로 정확한 합decimal.Decimal: 가변 정밀도 십진fractions.Fraction: 유리수- NumPy: float16/32/64/128(플랫폼별)
14.3 C/C++
float,double,long doublelong double은 플랫폼마다 다름:- x86 Linux/macOS: 80비트 확장 정밀도 (x87)
- x86 Windows (MSVC): 64비트 (= double)
- ARM: 128비트 quad 또는 64비트
- C99
<fenv.h>: 반올림 모드, 예외 플래그 <float.h>: DBL_EPSILON, DBL_MAX 등
14.4 Java
float,double엄격히 IEEE 754Math.*는 플랫폼 의존 (약간의 정확도 차이 허용)StrictMath.*는 비트 단위 정확 (느림)BigDecimal: 금융용
14.5 Go
float32,float64- 정수/float 간 명시적 변환 강제 (type safety)
math.IsNaN,math.IsInf별도 제공
14.6 Rust
f32,f64==허용이지만 clippy가 경고f64::EPSILON,f64::INFINITY,f64::NAN상수f64::total_cmp: NaN 포함 전체 순서 (2021 안정화)
15. 실전 함정 15선
15.1 스위프트/자바스크립트에서 JSON 정수 잘림
JSON 숫자를 JS가 float64로 파싱 → 큰 ID 잘림.
JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992 (잘림!)
대응: ID를 문자열로 주고받기 ("id": "9007199254740993") 또는 JSON 커스텀 파서.
15.2 tan(π/2)의 결과
수학적으로 무한대. 실제로는 π/2 자체가 float으로 근사되어 매우 큰 유한 값이 나옴.
Math.tan(Math.PI / 2) // 16331239353195370
절대 Infinity로 오지 않는다. 수학 공식을 코드에 옮길 때 항상 이런 경계 케이스 테스트.
15.3 이차방정식 판별식
# ❌ b=1e10, a=c=1 → b^2-4ac ≈ b^2이 정확
# 하지만 근 (-b + sqrt(D)) / 2a는 catastrophic cancellation
# ✅ Vieta's formula로 큰 근 먼저 계산
15.4 분산/표준편차 계산
"sum of squares - square of mean" 공식은 수치적으로 나쁘다. Welford's online algorithm 사용.
15.5 확률 곱셈
ML에서 likelihood를 곱하면 underflow. log probability 공간에서 덧셈.
15.6 평균을 "다 더한 뒤 나누기"
# ❌ 많이 더하면 오차 + overflow 가능
avg = sum(xs) / len(xs)
# ✅ Welford 스타일 점진 평균
mean = 0
for i, x in enumerate(xs, 1):
mean += (x - mean) / i
15.7 거리 계산
3D 벡터의 norm: sqrt(x^2 + y^2 + z^2). 하지만 x가 큰 경우 x^2이 overflow.
# ✅ 최대 성분으로 스케일
m = max(abs(x), abs(y), abs(z))
if m == 0: return 0
return m * sqrt((x/m)**2 + (y/m)**2 + (z/m)**2)
15.8 Hash map 키로 float
{0.1 + 0.2: 'a'} vs {0.3: 'b'} → 다른 키로 취급.
해결: 문자열로 변환 또는 정수로 양자화.
15.9 GUI 좌표 비교
픽셀 좌표 비교에 float === 쓰면 스냅 안 됨. Math.round 후 int 비교.
15.10 통계 라이브러리의 NaN propagation
sum([1, 2, NaN]) → NaN. Pandas 등은 skipna=True 기본이지만 언제 무엇을 무시하는지 정확히 알아야.
15.11 CSV 파싱의 "1e3" 해석
"1e3"을 파서가 숫자 1000으로 자동 변환. 원본이 문자열 의도였는데 스키마가 변형됨.
15.12 날짜 차이를 float 일수로
(date2 - date1) / (1000 * 60 * 60 * 24)는 DST로 23시간짜리 날이 있으면 부정확. ChronoUnit.DAYS.between 사용.
15.13 Python의 == with numpy
np.array([1, 2, 3]) == np.array([1, 2, 3])
# array([True, True, True]) ← 배열!
# if 조건에 쓰면 에러
15.14 SQL AVG(float_col) 정확도
대량 집계 시 DB 엔진마다 다름. PostgreSQL은 numeric으로 내부 승격 옵션. MySQL은 그냥 float 누적.
15.15 JavaScript의 Number 표시
10000000000000000000000000.0.toString() → 과학적 표기법 "1e+25". 사용자에게 보이면 당황.
16. 테스트 전략
16.1 Property-based testing
from hypothesis import given, strategies as st
@given(st.floats(min_value=0, max_value=1000))
def test_sqrt_sq(x):
assert abs(sqrt(x)**2 - x) < 1e-10 * max(abs(x), 1)
무작위 입력으로 수치 속성 검증. hypothesis(Python), QuickCheck(Haskell), fast-check(JS)가 표준.
16.2 골든 테스트
알려진 입력/출력 쌍을 저장하고 회귀 검사. "왜 이 값이 나오는지는 모르겠지만, 이전과 같다면 통과".
16.3 ULP 기반 assert
assert_float_near(a, b, 4); // 4 ULP 이내
각 언어의 테스트 프레임워크가 지원(pytest-approx, Catch2, JUnit에 내장).
16.4 수학 라이브러리 벤치
제곱근, 삼각함수 등이 플랫폼/컴파일러 버전마다 1~2 ULP 차이가 난다. 크로스플랫폼 테스트는 "정확히 같다"가 아니라 "가까움" 기준.
17. IEEE 754-2019 최신 기능
17.1 augmented arithmetic
기본 연산의 "두 부분" 버전: a + b = sum, error. 정밀 보정 합산을 빠르게.
17.2 minimum / maximum 개정
NaN 처리에 대한 더 명확한 규칙. minimumNumber(NaN, 1) = 1.
17.3 Decimal floating-point
IEEE 754는 binary뿐 아니라 decimal floating-point(십진 부동소수점)도 정의:
- decimal32, decimal64, decimal128
- 금융/통화에 적합 — "0.1"이 정확
- IBM POWER CPU 하드웨어 지원, x86은 소프트웨어 에뮬레이션
- Java
BigDecimal, Python 3.14+decimal내부 최적화
18. 체크리스트 — 부동소수점을 쓸 때
설계 단계
- 정말 float이 필요한가? (돈 → 정수 cent / Decimal)
- 어느 포맷? float32 / float64 / bfloat16 / decimal
- 오차 허용 범위 명시
코딩
-
==대신 근사 비교 - 합산은 Kahan 또는 pairwise
- catastrophic cancellation 있는 식 재작성
- NaN/Inf 체크 (
isFinite) - 정수 ↔ float 변환 범위 검증
ML/GPU
- mixed precision (BF16 forward, FP32 loss)
- tensor core 네이티브 포맷 사용
- Loss scaling (FP16인 경우)
테스트
- Property-based + boundary (0, ±Inf, NaN, subnormal)
- ULP 기반 assert
- 크로스 플랫폼 결과 비교
관찰
- FPU 예외 플래그 모니터링
- 프로덕션에서 NaN 발생 알림
- 대규모 합산 시 오차 누적 sanity check
마무리 — 부동소수점은 "근사의 예술"
0.1 + 0.2의 미스터리로 시작한 이 여정이 어디까지 왔는지 돌아보자. 비트 구조, 특수값, subnormal의 성능 함정, catastrophic cancellation, 금융의 Decimal, 과학의 수치 안정성, AI의 bfloat16/FP8까지. 부동소수점은 단일 주제가 아니라 수십 개 하위 분야의 교차로다.
Kahan이 IEEE 754를 설계하며 한 말을 음미해보자: "컴퓨터는 숫자를 정확히 다룰 수 없다. 우리가 할 수 있는 건 오차를 예측 가능하게 bound하는 것뿐이다." 그의 표준이 혁명적이었던 이유는 "어떤 CPU에서도 같은 결과"를 보장해서가 아니라, "어떤 계산이 어떤 오차를 가지는지 수학적으로 증명 가능하게" 만들었기 때문이다.
이 글을 끝까지 읽은 당신은 이제:
0.1 + 0.2 ≠ 0.3의 비트 수준 원인을 설명할 수 있다- NaN이 자기 자신과도 같지 않은 이유를 철학적으로 이해한다
- 금융 코드에 float을 쓰면 안 되는 구체적 이유를 안다
- bfloat16과 float16의 차이를 지수/가수 비트로 설명할 수 있다
- Ariane 5와 Pentium FDIV의 교훈을 자기 코드에 적용할 수 있다
다음 글에서는 GPU 아키텍처와 CUDA — SIMT 모델, warp scheduling, memory hierarchy, tensor core 깊이 분석으로 이어간다. 이번 글에서 본 저정밀도 포맷(FP8/BF16)이 실제 하드웨어에서 어떻게 가속되는지, Ampere → Hopper → Blackwell 세대 별 Tensor Core의 진화가 무엇을 바꿨는지 구체적으로 본다.
부동소수점을 다루는 건 엔지니어링인 동시에 철학적 겸손이다. 컴퓨터가 모든 실수를 담을 수 없다는 사실을 받아들이고, 그 근사의 규칙을 이해하고, 중요한 계산에서는 그 근사를 뛰어넘는 도구(Decimal, Rational, arbitrary precision)를 고르는 것. 이 겸손이 있는 한, 0.1 + 0.2는 더 이상 농담이 아니라 설계 원칙이 된다.
현재 단락 (1/311)
0.30000000000000004