부동소수점 합계 오차, NaN, -0.0 실측
숫자 강좌 6. 1000만 개 합계는 396 ulp 어긋났고 Python 3.12 sum() 은 오차 0. -ffast-math 는 Kahan 보정을 지우고 0/0 을 1 로 만들었다. NaN 정렬 붕괴와 시리즈 총정리.
시리즈: 숫자·금액 계산 강좌 6/6 (고급). 부동소수점 수를 많이 더하는 사람을 위한 마지막 강좌입니다. 로그 집계, 센서 데이터, 점수, 통계, 게임 물리, 지난달 숫자와 맞아야 하는 월간 리포트. 다 읽으면 합계 오차가 얼마나 빨리 커지는지, 어떤 알고리즘이 그것을 없애고 비용은 얼마인지, NaN 과 음의 0 이 정렬·max·JSON 에 무슨 짓을 하는지, -ffast-math 가 C 코드에서 무엇을 조용히 지우는지 알 수 있습니다. 끝에 시리즈 전체 체크리스트를 붙였습니다.
1강에서 0.1 + 0.2 가 0.30000000000000004 가 되는 것을 봤습니다. 17번째 자리 하나쯤은 무해해 보입니다. 이번에는 그것을 1000만 번 반복하면 어떻게 되는지, 그리고 비교 방식이 특이해서 코드 리뷰를 빠져나가는 두 특수값 NaN 과 -0.0 을 다룹니다. 국내 서비스는 원을 정수로 들고 있어서 금액 자체는 안전한 경우가 많지만, 포인트 적립률, 할인 안분, 환율 환산, 평균·분산처럼 중간에 소수가 되는 값을 집계할 때 같은 문제가 나옵니다. Apple Silicon Mac (M1 Pro) 에서 Python 3.9.6 과 3.12.14, Apple clang 21, macOS 의 JavaScriptCore 셸 jsc, PHP 8.5.6, Java 26, .NET 10.0.14, Swift 6.4.2, SQLite 3.51.0 을 돌렸습니다. 난수는 random.Random(42) 라서 모든 숫자를 재현할 수 있습니다.
0.1 을 1000만 번 더하기
가장 단순한 실험입니다. 0.1 을 10,000,000 번 더합니다. 정답은 1,000,000 입니다.
| 처리계 | 결과 |
|---|---|
Python 3.9.6 루프와 sum() |
999999.9998389754 |
Python 3.12.14 sum() |
1000000.0 |
Python math.fsum (두 버전) |
1000000.0 |
jsc for 루프 |
999999.9998389754 |
PHP 8.5.6 루프 (var_export) |
999999.9998389754 |
C# double 루프 |
999999.9998389754 |
C# decimal (0.1m) 루프 |
1000000.0 |
SQLite 3.51.0 에서 10^7 행 sum(0.1) |
1000000.0 |
오차는 0.00016 으로, 리포트 합계를 검산하는 사람 눈에 보이는 크기입니다. 조용한 변화에 주의하세요. Python 3.12 의 내장 sum() 은 보정 합산(Neumaier 방식)으로 바뀌어서, 같은 스크립트라도 Python 만 올리면 합계가 달라집니다. SQLite sum() 도 여기서는 정확했습니다. 최근 SQLite 는 같은 계열의 보정 합산(Kahan-Babuska-Neumaier)을 씁니다. PHP echo 는 999999.99983898 로 세 자리를 숨겼고 var_export 에서 전체 값이 보였습니다.
또 하나의 단골 예제는 math.fsum([1e100, 1.0, -1e100]) 입니다. fsum 은 1.0, 단순 루프와 Python 3.9 sum() 은 0.0 이었습니다. 1e100 에 더하는 순간 1.0 이 사라지기 때문입니다. Python 3.12 sum() 은 1.0 을 돌려줬습니다.
개수 N 에 따라 오차가 커지는 모습
+ 할 때마다 결과가 가장 가까운 double 로 반올림되므로, 단순 루프는 원소 하나당 한 번의 반올림 오차를 만듭니다. 오차끼리 일부 상쇄돼서 N 에 비례할 만큼은 아니지만, 커지기는 합니다. 오차는 ulp(unit in the last place: 그 크기에서 이웃한 double 사이 간격) 단위로 쟀고, 올바르게 반올림된 합을 돌려주는 math.fsum 을 기준으로 했습니다. 확인 삼아 앞 10^6 개를 Fraction 으로 정확히 더한 값이 fsum 과 비트 단위로 같다는 것도 확인했습니다.
def naive(xs):
s = 0.0
for x in xs: s += x
return s
def kahan(xs):
s = c = 0.0
for x in xs:
y = x - c; t = s + y; c = (t - s) - y; s = t
return s
def neumaier(xs):
s = c = 0.0
for x in xs:
t = s + x
if abs(s) >= abs(x): c += (s - t) + x
else: c += (x - t) + s
s = t
return s + c
[0, 1) 균등 난수 (fsum 과의 차이, ulp):
| N | 단순 루프 | 오름차순 정렬 후 | 쌍별 합산 | Kahan | Neumaier |
|---|---|---|---|---|---|
| 10^3 | 1 | 2 | 0 | 0 | 0 |
| 10^4 | 0 | 5 | 0 | 0 | 0 |
| 10^5 | 54 | 50 | 0 | 0 | 0 |
| 10^6 | 33 | 102 | 0 | 0 | 0 |
| 10^7 | 396 | 64 | 1 | 0 | 0 |
부호와 크기가 섞인 값 (uniform(-1, 1) * 10**randint(-8, 8)):
| N | 단순 루프 | 오름차순 정렬 후 | 절댓값 정렬 | 쌍별 합산 | Kahan | Neumaier |
|---|---|---|---|---|---|---|
| 10^3 | 15 | 372 | 4 | 8 | 1 | 0 |
| 10^5 | 64 | 5,155 | 3 | 2 | 0 | 0 |
| 10^6 | 129 | 3,912 | 55 | 0 | 0 | 0 |
| 10^7 | 532 | 54,006 | 44 | 1 | 0 | 0 |
교훈은 둘입니다. 첫째, 오차는 N 에 대해 단조 증가하지 않습니다(10^5 에서 54, 10^6 에서 33). 무작위 걸음이라서 어떤 데이터에서 통과한 테스트가 다른 데이터에서 실패합니다. 둘째, "작은 것부터 정렬해서 더하라"는 통념은 양수에서는 통했지만 부호가 섞이자 100배 나빠졌습니다. 큰 음수가 먼저 더해져 합계가 커지고, 그 큰 수가 작은 값들을 삼키기 때문입니다. 절댓값으로 정렬하면 나아졌지만 그래도 44 ulp 가 남았습니다. 보정 합산은 둘 다 이깁니다.
정확함의 비용
10^6 개, 5회 중앙값, Apple Silicon Mac (M1 Pro):
| 방법 | Python 3.9.6 | Python 3.12.14 |
|---|---|---|
내장 sum() |
2.5 ms (보정 없음) | 2.5 ms (보정 있음) |
단순 for 루프 |
19.2 ms | 11.8 ms |
math.fsum |
16.6 ms | 7.3 ms |
Kahan for 루프 |
59.8 ms | 32.5 ms |
sum(sorted(xs)) |
169.0 ms | 153.4 ms |
Decimal 값의 sum() |
39.2 ms | 41.7 ms |
Python 에서는 답이 간단합니다. 3.12 라면 sum() 이 가장 빠르면서 보정까지 하고, 그 전 버전이라면 math.fsum 이 정확하면서도 직접 짠 루프보다 빠릅니다. 정렬은 가장 느리고 가장 정확하지도 않습니다.
C 에서 double 10^7 개(clang -O2, 5회 중 최고)는 단순 루프 9.71 ms, Kahan 37.37 ms 였습니다. Kahan 은 매 단계가 앞 단계에 의존해서 CPU 가 덧셈을 겹쳐 실행하지 못하므로 약 4배 걸립니다. 정산 집계라면 싼 값이고, 게임 프레임 처리라면 비싼 값입니다.
float32 와 float64
float(32비트)는 십진 유효숫자 약 7자리, double(64비트)은 약 16자리입니다. 같은 10^7 개를 C 로 더했습니다.
double naive sum=4998269.0432556057 abs err=-3.69e-07 9.71 ms
double kahan sum=4998269.0432559745 abs err=0 37.37 ms
float naive sum=4998096.5 abs err=-173 9.32 ms
float data, double acc sum=4998269.0432599643 abs err=3.99e-06 9.33 ms
float: add 1.0f twenty million times -> 16777216.0
float 변수에 누적하자 500만 중 173 이 어긋났습니다. 4,194,304(2^22)를 넘으면 이웃한 float 사이 간격이 0.5 가 되어, [0, 1) 의 값이 0, 0.5, 1 중 하나로 반올림된 뒤 더해지기 때문입니다. 데이터는 float 로 두고 합계만 double 로 내면 시간은 그대로이고 오차는 저장값 자체의 반올림 몫인 4e-6 까지 내려갔습니다. 마지막 줄이 가장 극적입니다. float 카운터는 2^24 = 16,777,216 에서 멈춥니다. 16,777,216 + 1 이 반올림되어 다시 16,777,216 이 되기 때문입니다. Java 의 같은 루프는 1.6777216E7, jsc Math.fround(16777217) 은 16777216 이었습니다.
원칙: float32 는 큰 배열 저장(머신러닝 가중치, 정점 데이터)과 GPU 연산에는 충분합니다. 합계·평균·분산은 float64 로 냅니다.
-ffast-math: 빨라지고, 결과가 바뀌고, NaN 이 안 보인다
-ffast-math 는 clang 에게 "부동소수점을 실수 대수처럼 다뤄도 된다"고 알립니다. 덧셈 순서를 바꾸고, NaN 과 무한대는 없다고 가정하고, 0 의 부호를 무시합니다. 같은 파일을 두 가지로 컴파일했습니다.
| 출력 | -O2 |
-O2 -ffast-math |
|---|---|---|
| double 단순 합계 (10^7) | 오차 -3.69e-07, 9.71 ms | 오차 3.45e-08, 1.75 ms |
| double Kahan 합계 | 오차 0, 37.37 ms | 오차 3.45e-08, 1.58 ms |
| float 단순 합계 | 오차 -173 | 오차 18 |
1.0f 를 2×10^7 번 더하기 |
16777216.0 | 20000000.0 |
z = atof("0") 일 때 z/z |
nan |
1.000000 |
v = strtod("nan") 일 때 v != v |
1 | 0 |
무슨 일이 일어났을까요. 순서 바꾸기가 허용되자 clang 은 단순 루프를 여러 부분합으로 벡터화했습니다. 5.5배 빨라졌고, 우연히 정확도도 올라갔습니다(얕은 쌍별 합산과 같은 꼴이 되므로). 반면 Kahan 의 보정 (t - s) - y 는 대수적으로 0 이라고 증명하고 지워 버렸습니다. Kahan 함수가 단순 함수와 완전히 같은 값을 냈습니다. float 카운터 루프는 20,000,000 이라는 계산된 값으로 바뀌었습니다. 엄격한 IEEE 연산으로는 나올 수 없는 "정답"입니다. 그리고 NaN 이 없다고 가정하므로 실행 중 0/0 이 1.0 이 되었고, 자기 자신과 비교하는 NaN 검사는 거짓이 됐습니다. noinline 도우미 함수 안의 라이브러리 호출 isnan() 만 여전히 동작했고, NAN 매크로를 쓴 줄에는 clang 이 warning: use of NaN is undefined behavior due to the currently enabled floating-point options [-Wnan-infinity-disabled] 를 냈습니다.
보정 합산, 구간 연산, NaN 검사가 있는 코드는 -ffast-math 로 컴파일하지 마세요. 속도가 필요하면 누적 변수를 여러 개 두거나 쌍별 합산을 직접 쓰면, 컴파일러에게 거짓말하지 않고도 벡터화됩니다. 최적화가 가정을 이용하는 이야기는 C 의 정의되지 않은 동작과 최적화를 참고하세요.
NaN: 비교, 정렬, max, JSON
NaN(Not a Number)은 0/0, inf - inf, 0 * inf, 파싱 실패, 결측 데이터에서 생깁니다. 결정적인 성질은 NaN 과의 비교는 모두 거짓, NaN == NaN 도 거짓이라는 점입니다. 정렬과 max 는 일관된 순서를 전제로 하므로, NaN 위치에 따라 망가지는 모양이 달라집니다.
Python sorted([3, nan, 1, 2]) -> [3, nan, 1, 2] (정렬 안 됨)
Python sorted([nan, 3, 1, 2]) -> [nan, 1, 2, 3]
Python max([nan, 1, 2]) / max([1, 2, nan]) -> nan / 2
Python statistics.median([1, nan, 3, 2, 5]) -> 2
Swift [3, .nan, 1, 2].sorted() -> [3.0, nan, 1.0, 2.0]
jsc [3, NaN, 1, 10, 2].sort((a, b) => a - b) -> 3,NaN,1,2,10
jsc [3, NaN, 1, 10, 2].sort() -> 1,10,2,3,NaN (문자열 순)
PHP sort([3, NAN, 1, 2]) -> [NAN, 1, 2, 3]
PHP max(NAN, 1) / max(1, NAN) -> 1 / NAN
Java Arrays.sort(double[]) -> [-0.0, 0.0, 1.0, 2.0, 3.0, NaN]
C# Array.Sort(double[]) -> NaN, 0, -0, 1, 2, 3
C# Math.Max(NaN, 1) / LINQ Max([1, NaN, 2]) -> NaN / 2
Java 와 C# 은 정렬용 전순서를 정의하고 있어서(Java 는 NaN 을 맨 뒤, C# 은 맨 앞) 제대로 정렬됩니다. 나머지는 오류 없이 엉뚱한 결과를 냅니다. median 은 2 라는 그럴듯한 값까지 돌려줬습니다. 무서운 것은 눈에 보이는 NaN 이 아니라 그 옆의 틀린 답입니다.
포함 여부도 이상합니다. Python 에서 nan in [nan] 은 True(같은 객체라 동일성으로 찾음)지만 float('nan') in [float('nan')] 은 False 이고, {float('nan'), float('nan')} 은 원소가 2개입니다. jsc 에서는 [NaN].includes(NaN) 이 true 인데 [NaN].indexOf(NaN) 은 -1, isNaN("abc") 는 true 이고 Number.isNaN("abc") 는 false 였습니다.
JSON 에는 NaN 이 없습니다. 언어별 반응입니다.
| 호출 | 결과 |
|---|---|
Python json.dumps([nan, inf, -0.0]) |
[NaN, Infinity, -0.0] (올바른 JSON 이 아님) |
Python json.dumps(nan, allow_nan=False) |
ValueError: Out of range float values are not JSON compliant |
jsc JSON.stringify([NaN, Infinity, -0]) |
[null,null,0] |
jsc JSON.parse('[NaN]') |
SyntaxError: JSON Parse error: Unexpected identifier "NaN" |
PHP json_encode(NAN) |
false, Inf and NaN cannot be JSON encoded |
.NET JsonSerializer.Serialize(double.NaN) |
ArgumentException: .NET number values such as positive and negative infinity cannot be written as valid JSON. |
SQLite INSERT … VALUES (9e999 - 9e999) |
NULL 로 저장 |
Python 서비스가 NaN 을 내보내면 JavaScript 쪽 JSON.parse 가 실패하고, JavaScript 쪽은 NaN 을 조용히 null 로 바꾸고, SQLite 는 NULL 로 바꿔서 sum() 과 avg() 가 건너뜁니다. 경계에서 정하세요. 입력에서 NaN 을 거부하고, 값이 없을 때는 의도적으로 null 을 보냅니다. 큰 정수 ID 와 JSON 문제는 2강에서, PHP 사례는 prepared statement 가 JSON 숫자 타입을 바꾸는 이야기에 있습니다.
음의 0
IEEE 754 에는 0 이 두 개 있습니다. -0.0 == 0.0 은 시험한 모든 언어에서 참이지만, 부호는 새어 나옵니다.
1 / -0.0은 C 에서-inf, jsc 에서-Infinity.- Python
repr(-0.0)은-0.0.max(0.0, -0.0)은0.0,max(-0.0, 0.0)은-0.0(동점이면 앞 인자가 이김). - 작은 음수를 포맷하면 마이너스가 붙습니다. Python
f"{-0.004:.2f}", jsc(-0.004).toFixed(2), JavaString.format("%.2f", -0.004), .NETToString("F2")모두-0.00. jscIntl.NumberFormat("en-US", {maximumFractionDigits: 2})는-0, PHPnumber_format(-0.004, 2)는0.00이었습니다. - Java
Double.valueOf(0.0).equals(-0.0)은 false 라서HashMap<Double, …>은 둘을 다른 키로 가집니다. JSON.stringify(-0)은0, PHPjson_encode(-0.0)은-0, SQLite 는-0.0을0.0으로 저장했습니다.
환율 환산 차액 0.004 달러가 정산 화면에 -0.00 으로 찍히는 것이 현장에서 가장 흔한 민원입니다. 먼저 통화의 최소 단위로 반올림하고(4강), 표시 전에 0 인지 검사해서 정규화하세요.
흔한 실수 → 실제 결과 → 고치는 법
| 실수 | 실제 결과 | 고치는 법 |
|---|---|---|
| 수백만 개 float 을 루프로 합산 | 10^7 개에서 396 ulp 어긋남 | math.fsum, Neumaier, Python 3.12+ sum() |
| "정렬하고 더하기" | 부호가 섞이면 54,006 ulp | 보정 합산 사용 |
| float32 누적 변수 | 500만 중 173 어긋남 | float64 로 누적 |
| float 카운터 | 16,777,216 에서 멈춤 | 정수 카운터 |
Kahan + -ffast-math |
보정이 지워짐 | 그 파일은 fast-math 없이 |
NaN 섞인 sorted() / max() |
정렬 안 됨, 순서 따라 바뀌는 max | 먼저 isnan 으로 제거 |
NaN 을 json.dumps |
다른 언어가 못 읽는 JSON | allow_nan=False, null 전송 |
| 작은 음수를 반올림해 표시 | -0.00 |
반올림 후 0 정규화 |
합계를 == 로 비교 |
Python 3.9 와 3.12 합계가 다름 | 정수 원 단위, 또는 math.isclose |
int, Decimal, float 고르기
- 돈, 세금, 영수증과 맞아야 하는 값: 최소 단위 정수(원이라면 그대로 정수), 비율이나 1원 미만 단수가 필요하면 Decimal(4강). C#
decimal은 0.1 을 1000만 번 더해 정확히 1000000.0 이었습니다. - 개수와 ID: 64비트 정수. 2^53 을 넘을 수 있으면 JSON 에서는 문자열(2강).
- 측정값, 통계, 머신러닝, 그래픽스: float64. float32 는 저장이나 GPU 용으로만, 누적은 float64. N 이 크거나 실행 간 결과를 비교한다면
fsum이나 보정 합산으로 더합니다.
시리즈 총정리 체크리스트
- 타입을 안다:
0.1같은 리터럴은 처음부터 2진수로 반올림돼 있다(1강). float 은 허용 오차로 비교하고==를 쓰지 않는다. - 한계를 안다: 2^31, 2^63, 그리고 JavaScript 숫자와 JSON 의 2^53(2강).
- 반올림 방식을 의도적으로 고르고(짝수 반올림인지 사사오입인지), 마지막에 한 번만 반올림한다. 10원 단위 절사 같은 규칙과 부가세 계산은 관련 기관 안내나 세무사에게 확인한다(3강).
- 돈은 최소 단위 정수나 Decimal 로 두고 float 로 두지 않는다(4강).
- 포맷한 숫자를 저장하지 않는다. 입력은 로캘을 명시하고 엄격한 패턴으로 읽는다(5강).
- 보정 합산으로 더하고, NaN 은 경계에서 막고, -0.0 은 표시 전에 정규화하고, 숫자의 정확성이 중요한 코드에
-ffast-math를 쓰지 않는다(이번 강좌). - 경계값 테스트를 쓴다: 0.1, 2.5, -0.0, NaN, 2^53 + 1, 빈 입력. 첫 단위 테스트도 참고.
솔직한 한계와 버전 메모
- Python
sum()이 float 에 보정 합산(Neumaier)을 쓰는 것은 3.12 부터입니다. 3.9.6(보정 없음)과 3.12.14(보정 있음)에서 확인했습니다. - SQLite
sum(0.1)이 정확했던 것은 3.51.0 에서의 측정입니다. 오래된 SQLite 는 단순 합계였으니 쓰는 버전을 확인하세요. -ffast-math결과는 arm64 의 Apple clang 21 기준입니다. GCC 나 다른 타깃은 재배열 방식이 다릅니다. Kahan 보정 삭제와 NaN 접기는 흔한 경향이지만 숫자 자체는 이식되지 않습니다. 리눅스에서는 돌리지 않았습니다.- ulp 단위 오차는 데이터에 따라 다릅니다. 두 데이터는
random.Random(42)로 만들었습니다. 다른 분포에서는 숫자가 바뀌지만 방법들의 순위는 같았습니다. - 시간은 한 대(M1 Pro)에서 5회 중 최고값 또는 중앙값이며, 그 이상의 워밍업 제어는 하지 않았습니다.
연습 문제
1. 대시보드가 Python 3.9 로 주문 금액(float) 200만 건을 합산하고, Python 3.12 작업이 낸 어제 숫자와 비교합니다. 10번째 자리가 다릅니다. 원인과 해결책 두 가지를 말해 보세요.
2. 실행하지 말고 답하세요. Python 에서 max([float("nan"), 5.0]) 와 max([5.0, float("nan")]) 는 무엇을 출력할까요? 대신 어떻게 쓰겠습니까?
정답
1. Python 3.12 의 sum() 은 보정 합산이고 3.9 는 아니라서 아래 자리가 어긋납니다(0.1 실험에서 999999.9998389754 대 1000000.0). 해결책은 두 작업 모두 math.fsum 을 쓰는 것. 매출이라면 더 좋은 방법은 원을 정수로 저장해 정수를 더하는 것으로, 어느 버전에서나 정확합니다.
2. 차례로 nan 과 5.0 입니다. max 는 첫 원소를 들고 있다가 x > 현재값 이 참일 때만 바꾸는데, NaN 이 어느 쪽에 있든 그 비교는 참이 되지 않습니다. max(x for x in xs if not math.isnan(x)) 로 쓰고, 입력의 NaN 이 무엇을 뜻하는지(대개는 더 앞에서 거부해야 함) 명시적으로 정합니다.
이어서
- 이전: 숫자·금액 5/6: 로캘별 숫자 표시와 입력
- 시리즈 처음부터: 1/6: 0.1 + 0.2 가 0.3 이 아닌 이유 · 2/6: 정수 오버플로와 안전한 한계 · 3/6: 반올림 방식과 부가세 · 4/6: 원을 정수로, Decimal 로
- JavaScript 엔진의 숫자: JS 성능·메모리·숫자
- C 의 타입과 printf: C 기초: 타입·printf·컴파일
- SQL 집계: SQL 기초: SELECT·WHERE·ORDER BY
- 전체 강좌: 프로그래밍 학습 로드맵
자주 묻는 질문
작은 수부터 정렬해서 더하면 오차가 줄어드나요?
항상 그렇지는 않습니다. [0,1) 의 양수 1000만 개에서는 오름차순 정렬로 오차가 396 ulp 에서 64 ulp 로 줄었지만, 부호와 크기가 섞인 데이터에서는 532 ulp 에서 54,006 ulp 로 오히려 나빠졌습니다. Kahan, Neumaier, math.fsum, Python 3.12 sum() 은 시험한 모든 경우에 0~1 ulp 였습니다.
NaN 이 섞이면 Python sorted() 는 어떻게 되나요?
NaN 과의 비교는 모두 False 라서 정렬 알고리즘이 모순된 답을 받습니다. sorted([3, nan, 1, 2]) 는 [3, nan, 1, 2] 그대로 돌아왔고, max([nan, 1, 2]) 는 nan, max([1, 2, nan]) 은 2, statistics.median([1, nan, 3, 2, 5]) 는 2 였습니다. Swift 와 jsc 도 같은 식으로 망가집니다. 정렬 전에 math.isnan 으로 걸러 내세요.
-ffast-math 를 켜도 괜찮을까요?
정확한 반올림이나 NaN 에 기대지 않는 코드에서만 그렇습니다. clang -O2 -ffast-math 에서 Kahan 합산은 단순 루프와 같은 결과가 됐고, 실행 중 계산한 0.0/0.0 은 nan 이 아니라 1.000000 으로 찍혔으며, 입력에서 읽은 NaN 에 대해 v != v 가 0 이었습니다. 단순 합계는 5.5배 빨라졌고 우연히 더 정확해졌습니다.