정밀도(Precision) 향상을 위한 기법
부동소수점 연산에서의 근본 문제
컴퓨터에서 실수(real number)를 다룰 때에는 유한한 수의 비트(bit)로 수를 표현하기 때문에, 모든 실수를 정확하게 나타낼 수 없다. 즉, 대부분의 연산은 근사값을 취급한다. 부동소수점(floating-point) 표준인 IEEE 754는 실수를 2진 형태로 표현하는 규칙과 연산 방식을 정의하고 있으며, 단정도(single precision), 배정도(double precision) 등 여러 정밀도 수준을 제공한다. 단정도는 32비트, 배정도는 64비트로 수를 표현한다. 그러나 단정도, 배정도 모두 실제로 표현할 수 있는 수의 집합이 유한하기 때문에, 연산 과정에서 필연적으로 반올림(rounding) 오차와 자리수 손실(loss of significance)이 발생한다.
수치해석에서 정밀도가 중요한 이유는, 설계나 해석 과정에서 오차가 누적되거나 증폭될 수 있기 때문이다. 시스템 해석, 공학 시뮬레이션, 통계분석, 머신 러닝 등 광범위한 분야에서 부동소수점 연산이 활용되므로, 정밀도를 높이기 위한 다양한 기법이 제안되고 연구되어 왔다. 정밀도 문제는 연산 결과가 이론적으로 갖추어야 할 수렴 특성, 에너지 보존, 보수성(conservative property) 등에 직접적인 영향을 줄 수 있다.
단정도와 배정도의 차이
단정도는 32비트로 수를 표현한다. 여기서 지수부(exponent)는 8비트, 가수부(mantissa)는 23비트(실제로는 숨겨진(hidden) 비트까지 고려되면 24비트)로 이루어져 있다. 배정도는 64비트로 표현되며, 지수부는 11비트, 가수부는 52비트(숨겨진 비트 포함 시 53비트)로 이루어진다. 배정도가 단정도보다 훨씬 더 넓은 동적 범위(dynamic range)와 더 많은 유효 자릿수를 제공하므로, 계산 결과의 상대오차가 줄어든다.
단정도에서 표현할 수 없는 매우 작은 수나 매우 큰 수를 배정도에서는 표현 가능하다. 또한 동일한 연산에 대해 배정도가 좀 더 정확한 결과를 낼 확률이 높다. 다만, 배정도는 메모리 사용량이 크고, 연산 속도가 느려질 수 있다는 점에서 트레이드오프(trade-off)가 존재한다.
가수부가 큰 표현 방식
정밀도 향상을 위해서는 가수부의 비트 수를 크게 확장하는 방향도 고려된다. 예를 들어 배정도보다 더 큰 정밀도를 요구한다면, 배정도보다 가수부가 더 많은 80비트, 128비트 형태의 확장 정밀도(extended precision)를 소프트웨어적으로 구현하거나, 일부 하드웨어 플랫폼에서 지원되는 내부 레지스터를 통해 사용할 수도 있다. 예를 들어 x86 아키텍처의 80비트 정밀도를 활용하면 중간 연산에서의 반올림 오차를 추가로 완화할 수 있다.
반올림 모드(Rounding mode)
IEEE 754 표준은 부동소수점 연산 결과를 저장할 때 반올림 모드를 정할 수 있도록 정의한다. 대표적인 모드는 네 가지 정도가 주로 활용된다. 반올림 모드를 적절히 선택함으로써 오차가 특정 방향으로 치우치지 않도록 제어할 수 있다. 어떤 알고리즘은 반올림 모드 하나에 고정해도 무방하지만, 고정 소수점이나 혼합 정밀도(hybrid precision)를 다루거나 수치적으로 민감한 코드를 작성할 때에는 반올림 모드와 그 효과에 대한 이해가 필요하다.
오차 해석(Error Analysis)의 중요성
수치계산에서는 근삿값을 다루므로 항상 오차가 존재한다. 이 오차는 크게 절대오차(absolute error)와 상대오차(relative error)로 구분된다. 예를 들어 어떤 연산 결과의 실제값(이론적으로는 무한 정밀도) $x$와 부동소수점 연산 결과 $\tilde{x}$가 있을 때, 절대오차는
로 정의되고, 상대오차는
로 정의된다. 상대오차가 작다는 것은 실제값 대비 오차의 비율이 낮음을 의미하므로, 각종 수치 알고리즘에서는 주로 상대오차를 활용한다. 특정 응용 분야에서는 절대오차가 더 중요한 경우도 있다.
연산 과정에서 발생하는 오차에는 반올림 오차, 절단 오차(truncation error), 알고리즘적 오차(algorithmic error) 등이 있다. 이들이 서로 복합적으로 작용하여 최종 오차를 형성한다. 정밀도를 높이기 위해서는 이들 오차 요소를 분석하고, 각 단계에서 누적되는 양을 줄이는 기법을 활용해야 한다.
캐한(Kahan) 알고리즘
부동소수점 수의 합을 구할 때, 특히 작은 수를 큰 수에 더하는 경우 반올림 오차가 쉽게 누적될 수 있다. 예를 들어 매우 큰 값 $S$에 매우 작은 값 $\epsilon$을 더할 때, $\epsilon$이 가수부에 반영될 만큼 충분히 크지 않으면, $S + \epsilon$은 $S$와 거의 같게 된다. 이로 인해 반복적인 합산 과정에서 누적 오차가 극도로 커질 수 있다.
이를 완화하기 위한 대표적인 기법 중 하나가 캐한 알고리즘(Kahan summation algorithm)이다. 두 수를 단순히 더하는 대신, 잔여 오차를 보정하는 항을 별도로 추적하여 보정하는 방식이다. 간략히 표현하면, 매번 새로운 항을 더할 때마다 보정값을 갱신하여, 반올림으로 인해 잃어버린 작은 수들의 기여를 최대한 회복시킨다.
캐한 알고리즘의 핵심은 누적 합을 구할 때 중간의 잔차(residual)를 두고, 그 잔차를 다음 연산 때 재활용하는 것이다. 예를 들어 현재까지의 누적합을 $S$, 새로 더할 값(혹은 중간 연산 결과)을 $x$, 그 이전에 누적된 보정값을 $c$라고 하면, 다음과 같은 순서로 수행한다:
이 과정에서 $c$는 잔차를 나타낸다. $y$를 정의할 때 $c$를 빼서, 부동소수점 반올림이 발생해도 최대한 정확한 연산을 유도하는 방식이다. 이렇게 하면 단순 합산보다 훨씬 더 높은 정밀도로 합을 구할 수 있으며, 특히 작은 수가 큰 수에 더해지는 상황에서 효과가 크다.
혼합 정밀도(Hybrid Precision) 기법
혼합 정밀도 기법은 알고리즘의 어떤 부분은 낮은 정밀도(예: 단정도)를 쓰고, 어떤 부분은 높은 정밀도(예: 배정도)로 계산하여 전체적인 수행 시간을 단축하면서도 원하는 정확도를 확보하는 방법이다. 예를 들어 매트릭스-벡터 곱이나 합산 등 일부 연산은 단정도로 처리하고, 수렴 검증이나 반복 보정 과정에서는 배정도를 쓰는 식으로 구성할 수 있다.
이 기법을 구현할 때 핵심은, 어느 부분에서 단정도를 써도 결과 품질에 치명적 손실이 발생하지 않는가를 판단하는 것이다. 근사 연산에 민감한 부분, 예를 들어 조건수가 큰 시스템을 푸는 부분이나 직접 수렴 검사를 해야 하는 부분 등은 배정도로 처리를 하고, 나머지 연산은 단정도로 처리하는 식이다. 이는 CPU 또는 GPU 상에서 메모리 사용량과 연산량을 줄여주어, 일정 수준 이상의 오차 허용 범위에서 계산 효율을 크게 높일 수 있다.
정규화(Normalization)와 언더플로(Underflow), 오버플로(Overflow)
정규화된 부동소수점 수는 지수부를 조절하여 가수부가 특정 범위 안에 들도록 맞춘다. IEEE 754에서는 가수부가 1 이상 2 미만이 되도록 표현(단, 0을 포함하여 서브노멀(subnormal) 수가 존재하기도 한다)한다. 그러나 지수부가 표현할 수 있는 범위를 벗어나면 언더플로나 오버플로가 발생하여, 0 또는 무한대로 표현된다.
아주 작은 수를 반복해서 곱하거나, 매우 큰 수를 반복해서 곱하는 연산에서는 언더플로나 오버플로가 빨리 발생할 수 있다. 이 문제를 회피하거나 줄이기 위해서는 로그 스케일로 변환하여 연산을 수행하는 방식(예: 확률적 계산에서 로그 확률을 다루는 방식) 등을 쓸 수도 있다. 혹은 적절한 재배열과 스케일링(scaling)을 통해 수의 범위를 안전한 구간 안에 유지하는 방법도 사용된다.
스케일링(Scaling)과 조건수
행렬 방정식 $\mathbf{A}\mathbf{x}=\mathbf{b}$를 풀거나, 어떤 다항식을 평가할 때, 입력값이나 행렬의 크기에 따라 결과가 엄청나게 커지거나 작아지는 경우가 있다. 이 때 스케일링 기법을 적용하면, 계산 과정에서 언더플로 또는 오버플로가 발생할 위험을 줄이고, 연산의 안정성을 높일 수 있다. 스케일링을 할 때에는 조건수(condition number)를 함께 고려하는 편이 좋다.
조건수는 문제의 입력값 변화가 결과에 얼마나 민감하게 반영되는지 나타낸다. 어떤 문제의 조건수가 매우 크다는 것은, 약간의 부동소수점 오차가 최종 결과에 크게 증폭될 수 있음을 의미한다. 이러한 문제를 풀 때에는 문제 자체의 재구성이 필요할 수도 있고, 스케일링이나 정규화 등을 통해 오차 전파를 줄이는 방향으로 접근해야 한다.
예컨대 큰 스케일의 행렬 $\mathbf{A}$를 다룰 때, 행렬의 각 행 혹은 열을 적절히 나누어(또는 곱하여) 값의 절댓값 범위를 고르게 조절해두면, 연산 시 오버플로나 언더플로가 발생하지 않도록 만들 수 있으며, 부동소수점 연산에 의한 상대오차와 조건수의 결합 효과를 어느 정도 줄일 수 있다.
(예시) Python에서의 Kahan Summation 구현
아래는 Python으로 캐한 알고리즘을 구현한 아주 단순한 예시이다. 실무에서는 NumPy나 다른 라이브러리를 통해 벡터화하고, 배정도 이상의 정밀도(예: decimal 모듈)를 사용할 수도 있다.
단순 합산보다 오차 누적을 크게 줄일 수 있다. 배정도로 동작하는 Python의 float는 내부적으로 IEEE 754 배정도 방식을 채택한다.
고차 정밀도(Double-double, Quad-double) 기법
배정도(64비트)보다 더욱 높은 정밀도가 필요할 경우, 소프트웨어 차원에서 두 개 이상의 배정도 부동소수점을 조합하여 사용하는 방법이 있다. 대표적인 예로, 두 개의 배정도 부동소수점을 합쳐 하나의 고차 정밀도 수를 구성하는 double-double 표현이 존재한다. double-double 방식에서는 $a, b$라는 두 개의 배정도 수를 이용하여 하나의 고정밀 수를 나타내되, $|b|$가 $|a|$에 비해 매우 작도록 유지하여 반올림 오차를 최소화한다.
double-double 방식은 다음과 같이 두 수 $a, b$를 유지한다:
두 수 $a, b$의 합 $a + b$가 실제로는 단일 배정도로는 표현할 수 없는 정밀도를 갖는다. 덧셈, 뺄셈, 곱셈, 나눗셈 같은 연산은 이 $a, b$ 쌍을 이용하여 적절한 보정 과정을 거쳐 수행한다. quad-double은 이를 한 단계 더 확장하여, 네 개의 배정도 수(상위 비트, 그 다음 비트, …)를 조합해 훨씬 더 높은 정밀도를 확보한다.
에러-프리 변환(Error-free transformation) 기법
덧셈, 뺄셈, 곱셈 등에 대해 나름의 보정 과정을 통해 반올림 오차를 완벽하게 분리해내는 기법을 에러-프리 변환이라고 한다. 예를 들어 두 개의 부동소수점 수 $x, y$의 덧셈에 대해, $x + y$를 계산하면 부동소수점 반올림이 일어난다. 하지만 이 연산 결과와 함께, 잃어버린 부분(잔차)을 별도의 수로 보관하면, 이것들을 다시 재조합해서 원래 연산의 이상적인 합을 복원할 수 있다. double-double 연산의 기본 원리가 바로 이러한 에러-프리 변환이다.
다음은 단순화한 덧셈 예시다. 배정도 두 수 $x, y$를 더한다 했을 때,
여기서 $s$는 주 연산 결과(반올림된 값)이고, $r$은 잔차(residual)로, 실제 $x + y$를 $s + r$로 분해해 정확도를 유지한다. double-double 라이브러리 등은 이러한 에러-프리 변환을 자동으로 적용해, 배정도 두 개를 조합한 더 높은 정밀도의 연산을 제공한다.
반복 보정(Iterative refinement)
행렬 방정식 $\mathbf{A}\mathbf{x}=\mathbf{b}$를 수치적으로 풀 때, 해를 한 번 구하고 나서 이 해가 실제 방정식을 얼마나 만족하는지 다시 평가한 후, 오차를 보정하는 기법이 반복 보정이다. 예를 들어 배정도에서 $\mathbf{x}$를 구한 뒤에, 잔차(residual) $\mathbf{r}=\mathbf{b}-\mathbf{A}\mathbf{x}$를 계산한다. 이 잔차에 대해 또 다른 보정 방정식 $\mathbf{A}\mathbf{e}=\mathbf{r}$를 풀어서 오류 추정값 $\mathbf{e}$를 얻고, $\mathbf{x} \leftarrow \mathbf{x}+\mathbf{e}$로 갱신한다. 이 과정을 수 회 반복하면, 작은 오차나 반올림 오류가 누적되어 있던 부분을 어느 정도 정정할 수 있다.
반복 보정을 적용하면, 처음 해를 구할 때는 단정도를 써서 빠르게 근삿값을 구하고, 보정 단계에서 배정도를 사용해 잔차를 정확하게 계산하는 혼합 정밀도 전략도 가능하다. 실제로 GPU 기반 고성능 연산에서 이러한 방식이 널리 쓰이는데, 대규모 행렬 연산은 단정도로 빠르게 처리하고, 이후 소량의 배정도 연산으로 보정하는 형태다.
무한 정밀도 라이브러리와 다중 정밀도(Multi-precision) 연산
소프트웨어적으로 무한 정밀도(arbitrary precision)를 지원하는 라이브러리를 사용하면, 부동소수점의 한계를 넘어서 이론상 필요한 만큼의 비트를 사용하여 계산할 수 있다. 예를 들어 C++의 GNU MP(GMP), MPFR 라이브러리, Python의 decimal 모듈 등이 그것이다. 그러나 이러한 라이브러리는 일반적인 하드웨어 연산보다 훨씬 느리며, 메모리 사용량도 많아진다.
실제로 특정 구간에서만 높은 정밀도가 필요할 경우, 나머지 연산은 배정도로 처리하고, 민감한 부분만 다중 정밀도 라이브러리를 활용하는 하이브리드 기법이 개발되어 왔다. 예컨대 다항식 근 찾기, 수치 적분에서 특이점에 근접하는 구간 등, 불안정 구간만 골라서 고정밀 연산으로 대체하는 것이다.
부분합 보호와 중간 값의 재배열
연산 순서를 적절히 바꿔도, 실제 수학적 이론에서는 결과가 달라지지 않아야 한다. 하지만 부동소수점 연산에서는 연산 순서에 따라 반올림 오차가 누적되는 양이 달라진다. 이를 이용해 정밀도를 높이는 전형적인 방법 중 하나가, 비슷한 크기의 수끼리 먼저 더하는 것이다.
예컨대, 매우 큰 수와 매우 작은 수를 더하면 작은 수가 반올림으로 인해 소실되기 쉽다. 따라서 전체의 집합에서 비슷한 크기의 수들을 묶어 합산한 뒤, 그 결과들을 다시 더해 나가는 식으로 부분합을 보호할 수 있다. 이런 방식은 병렬 처리 관점에서 reduce 연산을 할 때에도 적용하여 오차 전파를 줄인다.
로그-도메인(Log-domain) 기법
확률이나 통계 계산, 지수함수적 증가·감소가 나타나는 문제에서는 매우 큰 수나 매우 작은 수가 자주 등장한다. 이때 덧셈, 곱셈을 직접 하게 되면 언더플로 또는 오버플로가 쉽게 발생할 수 있다. 이를 피하기 위해 자주 쓰이는 방법이 로그-도메인 기법이다.
예를 들어 어떤 확률 분포 $p(x)$가 극도로 작을 때, $p(x)$ 자체를 저장하는 대신 $\log(p(x))$를 저장해 두면, 실제로는 상당히 작은 수라도 로그 공간에서 표현이 가능하다. 곱셈 $p(x) p(y)$는 로그 공간에서 $\log(p(x)) + \log(p(y))$의 덧셈으로 치환된다. 마찬가지로 분수, 나눗셈, 지수함수 등도 로그·지수 변환을 통해 범위를 다룰 수 있어, 소수점 반올림 오차를 완화하고 언더플로·오버플로 발생을 줄일 수 있다.
로그-도메인 기법은 또한 수치 해석적 안정성이 뛰어나, 통계 모델, 머신 러닝 알고리즘, 베이즈 추론 등에서 많이 활용된다. 다만 지수 변환 단계(예: exp나 log 함수)를 자주 호출하기 때문에 연산 부담이 크고, 오히려 그 변환 과정에서 새로이 발생하는 반올림 오차를 평가해야 하는 단점도 있다.
구간 연산(Interval arithmetic)과 신뢰 구간
수치계산 과정에서 결과값의 범위를 보증하거나, 오차 제한을 엄밀히 제시해야 할 때는 구간 연산을 쓰기도 한다. 구간 연산에서는 모든 값이 $[a, b]$ 형태의 구간으로 표현된다. 예를 들어 $x$가 $[x_\text{low}, x_\text{high}]$, $y$가 $[y_\text{low}, y_\text{high}]$로 주어졌을 때, $x + y$는 $[x_\text{low} + y_\text{low},\ x_\text{high} + y_\text{high}]$, $x \times y$는 적절한 곱셈 규칙에 의해 구간이 확장된다. 이렇게 하면 부동소수점 반올림 오차가 발생해도 결과 구간이 그 오차를 포함할 수 있도록 계산할 수 있다.
구간 연산은 보수적이기 때문에 결과 구간이 불필요하게 커질 수도 있다. 그러나 일체의 결괏값이 오차 범위를 확실히 만족한다는 보증이 필요할 때는 유용하다. 또한 논문에서 이론적 해석을 뒷받침하거나, 안정성 검증, 정확성 증명 등에 활용될 수도 있다.
(예시) C++에서 double-double 구현 개념
아래는 double-double 방식의 기본 아이디어를 모사한 간단한 예시다. 실제로는 여러 에러-프리 변환 함수를 추가로 구현해야 한다.
배정도만 썼을 때에는 1.0e16 + 1.0 연산에서 1.0이 반올림되어 결과가 변하지 않을 수 있지만, double-double 방식으로 계산하면 잃어버린 잔차를 보관해둘 수 있어 미세한 오차를 줄일 수 있다.
FMA(Fused Multiply-Add) 연산
현대 CPU나 GPU의 부동소수점 연산 장치에서 점차 널리 지원되는 FMA(Fused Multiply-Add) 연산은, 곱셈과 덧셈을 분리하여 진행하지 않고 하나의 명령어로 융합하여 처리한다. 예를 들어 $a \times b + c$ 연산을 단일 연산으로 수행하며, 중간 곱셈 결과를 반올림하지 않고 내부 레지스터의 높은 정밀도로 바로 이어서 덧셈까지 진행한다. 이로 인해 곱셈 후 반올림, 그 다음 덧셈 후 반올림이라는 두 단계 반올림이 한 번으로 줄어들어, 결과 오차를 줄이는 효과가 있다.
FMA가 지원되지 않는 플랫폼에서는 $a \times b$를 구한 뒤 반올림된 결과에 $c$를 더해야 한다. 예컨대 $a, b$가 매우 큰 수이거나, $c$가 상대적으로 매우 작은 수일 때, 두 단계의 반올림이 누적될 수 있다. 반면 FMA를 사용하면 단 한 번의 반올림만 발생하여 수치적 정확도가 향상된다. 반복 보정(iterative refinement)이나 고정밀 알고리즘 설계 시 FMA의 적용 여부가 알고리즘의 정밀도와 성능 양쪽에 큰 영향을 주기도 한다.
고정소수점(Fixed-point) 연산
특정 임베디드 시스템이나 DSP(Digital Signal Processor)에서는 부동소수점 장치 대신 고정소수점 연산을 사용하기도 한다. 고정소수점 방식에서는 소수점의 위치를 미리 정해 두고 정수 연산을 통해 실수 연산을 흉내 낸다. 예를 들어 16비트 정수에서 하위 8비트를 소수부로 정해 두면, 1비트는 부호, 7비트는 정수부, 8비트는 소수부처럼 해석하여 연산한다.
고정소수점 연산의 장점은 하드웨어 구현이 단순하고, 연산 지연(latency)이 낮으며, 에너지도 적게 소모될 수 있다는 점이다. 하지만 동적 범위가 제한적이어서, 부동소수점보다 오버플로나 언더플로가 쉽게 일어날 수 있다. 고정소수점으로 정확히 표현할 수 없는 수를 다룰 때는, 미리 스케일링과 반올림 정책을 잘 설계해야 한다. 신호처리나 제어 분야처럼 특정 범위 내에서 신호가 제한적으로 변동하는 경우에는, 효율적인 고정소수점 기법으로도 충분한 정밀도를 확보 가능하다.
플랫폼별 차이와 정밀도 이식성
컴파일러나 프로세서 아키텍처에 따라, 같은 소스 코드라도 부동소수점 연산 결과가 달라질 수 있다. 예를 들어 x86 계열 CPU는 80비트 정밀도의 내부 레지스터를 사용하는 반면, 메모리에 쓸 때는 64비트로 반올림하여 저장한다. 어떤 컴파일러 설정에서는 중간 계산을 80비트로 유지하기도 하고, 다른 설정에서는 중간 결과를 곧바로 64비트에 반올림해버리기도 한다. 이런 차이는 알고리즘에 따라 상당히 큰 수치적 차이를 초래할 수 있다.
GPU 연산(예: NVIDIA CUDA)에서도, 단정도와 배정도 간 성능 차이가 크며, 선택 가능한 반올림 모드나 내부 레지스터 사양이 CPU와 다르다. 혼합 정밀도 기법을 쓸 때, CPU와 GPU 간에 알고리즘이 동일하게 구현되었더라도, 중간 반올림이나 스케일링 과정이 달라 결과가 약간 다를 수 있다. 이러한 이식성(portability) 문제를 최소화하기 위해서는, 표준을 준수하면서도 반올림 모드, 내부 레지스터 길이, 혼합 정밀도의 사용 여부 등을 명시적으로 제어할 필요가 있다.
수치적 안정성(Stability)과 감도 분석(Sensitivity Analysis)
수치적 안정성은 알고리즘이 입력값이나 중간 계산 시 발생하는 부동소수점 오차에 대해 얼마나 견고한지를 평가하는 개념이다. 안정적인 알고리즘은, 반올림 오차가 조금씩 누적되어도 최종 결과에 치명적인 영향을 주지 않는다. 반면 불안정한 알고리즘은, 극히 미미한 반올림 오차가 증폭되면서 결과를 심각하게 왜곡한다.
감도 분석(sensitivity analysis)은 입력값의 작은 변화가 결과값에 얼마나 큰 영향을 미치는지 살피는 과정을 말한다. 예를 들어 조건수가 높은 문제를 다룰 때, 입력 오차가 결과에서 극도로 증폭될 수 있음이 알려져 있다. 이러한 문제를 풀기 위해서는 반복 보정이나 적절한 전처리(스케일링, 행렬 분해 기법) 등으로 안정성을 개선해야 한다. 알고리즘 자체가 본질적으로 불안정할 수 있으므로, 대안적인 알고리즘을 모색하거나 문제 공식을 재설계하는 방안도 고려된다.
Shewchuk의 Robust Geometric Predicates
기하 알고리즘(예: 2차원이나 3차원에서 점들의 배치, 삼각분할, 볼록 껍질 등)에서, 정밀도 문제로 인해 유의미한 오류가 발생하기 쉽다. 예를 들어 어느 세 점이 일직선상에 있는지, 어떤 면의 한 점이 왼쪽에 있는지 오른쪽에 있는지 판별할 때, 부동소수점 오차 때문에 예기치 못한 결과가 발생한다. 이를 극복하기 위해 Shewchuk은 에러-프리 변환 기반의 정밀 기하 연산(robust geometric predicates)을 제안하였다.
Shewchuk의 알고리즘들은, 예를 들어 방향성 테스트(orientation test)와 원 내부성 테스트(incircle test) 등에 대해, 부동소수점 연산을 여러 단계로 나누어 에러-프리 변환을 사용한다. 필요하다면 확장 정밀도(double-double, 혹은 그 이상의) 연산까지 적용하여, 기하적 특성이 틀리지 않도록 엄밀히 판별한다. 기하 문제에서는 매우 작은 차이가 토폴로지(위상)적 결과까지 바꿔놓을 수 있기 때문에, 정밀도 향상을 위한 이런 기법이 매우 중요하다.
캐싱과 중간 정밀도 보존
수치 계산 과정에서 같은 연산이나 함수를 반복적으로 호출할 때, 매번 정확한 결과를 낼 수 있도록 내부적으로 중간 결과를 캐싱(cache)하거나, 반드시 배정도 이상으로 유지하는 방식이 쓰이기도 한다. 예를 들어, 어떤 환경에서 베셀함수나 감마함수 등 복잡한 특수함수를 반복 계산해야 한다고 하자. 이때 단순히 고정소수점 근사나 단정도 근사로 매번 계산하면, 미세 오차가 누적되거나, 특정 구간에서 수치적 불안정이 발생할 수 있다.
이를 방지하기 위해 중간 계산 결과를 높은 정밀도로 한 번 구해두고, 이후에는 해당 결과를 재활용할 수 있게 테이블이나 캐시에 저장한다. 이렇게 하면 반복 연산 시의 반올림 오차나 언더플로·오버플로 문제를 크게 줄일 수 있다. 다만 캐싱 역시 메모리 사용량과 검색 오버헤드가 증가하므로, 알고리즘 전체 성능이나 메모리 구성을 고려해야 한다.
분할정복(사후 정렬) 방식으로 오차 제어
크기가 매우 다른 여러 수를 한꺼번에 더하거나, 복잡한 행렬 연산을 순차적으로 진행할 때, 중간 오차가 조기에 누적되면 이후 단계에서 큰 편향이 발생할 수 있다. 이를 완화하기 위해 분할정복 방식이나 사후 정렬(post-sorting) 방식으로 연산 순서를 재조정하는 방법이 있다. 예를 들어 수의 절댓값을 기준으로 정렬한 뒤, 작은 값들부터 먼저 더해 나가면, 작은 값들이 반올림으로 인해 쉽게 소실되는 일을 줄일 수 있다.
행렬 연산에서도, 예를 들어 LU 분해나 QR 분해처럼 단계적으로 진행되는 알고리즘에서, 피벗팅(pivoting) 등을 통해 수의 크기 균형을 맞춰주면 정밀도 손실이 줄어든다. 이는 실질적으로 스케일링 전략과도 연계되어, 조건수 개선과 함께 오차 전파를 줄이는 핵심 기법 중 하나로 꼽힌다.
(예시) Octave에서의 고정소수점 흉내
다음 예시는 Octave에서, 고정소수점을 간단히 흉내 내는 코드를 보여준다. 실제 임베디드 시스템처럼 하드웨어 차원의 고정소수점 지원은 아니지만, 소수부를 미리 정하고 정수 연산을 통해 근사 계산한다.
frac_bits를 8 정도로 설정하면, 모든 실수를 정수에 256배 스케일링한 뒤 연산하고 다시 256으로 나눈다. 이런 식으로 고정소수점 연산을 간단히 시뮬레이션할 수 있다.
스토캐스틱 산술(Stochastic Arithmetic)
스토캐스틱 산술은 부동소수점 연산의 오차를 확률적으로 모델링하여, 동일한 연산을 여러 번 반복하면서 각각의 반올림 오차가 서로 다르게 발생하도록 의도적으로 변동을 주는 방법이다. CADNA(Calculation of Accuracy and Debugging for Numerical Applications) 라이브러리 등이 이런 방식의 구현을 제공한다. 스토캐스틱 산술을 사용하면, 수치 계산 중 발생하는 오차를 통계적으로 추정하여, 특정 결과에 대한 신뢰도를 평가할 수 있다.
예를 들어 어떤 덧셈 연산 $x + y$가 있을 때, 부동소수점 반올림 과정에서 발생하는 비트 단위 오차를 난수 기반으로 조금씩 변동시킨다. 이 과정을 여러 차례 반복 수행하면, 결과값의 분포를 얻을 수 있으며, 그 분포가 좁으면 해당 연산이 비교적 안정적이라는 의미가 된다. 반대로 결과값의 분산이 넓다면, 그 연산이 오차에 민감함을 뜻한다. 스토캐스틱 산술은 대규모 시뮬레이션이나 알고리즘 디버깅 과정에서 유용하지만, 연산 비용이 크게 늘어난다는 단점이 있다.
자동 분할정복 기반 알고리즘(Adaptive Algorithms)
수치 계산에서 어떤 영역에서는 정밀도가 크게 필요하지 않지만, 특정 영역에서는 미세한 차이가 결과를 크게 왜곡할 수 있다. 적분, 미분 방정식 풀이, 근 찾기 같은 문제를 예로 들면, 일반적으로는 표준 정밀도로 계산을 진행하다가, 에러 추정이나 반올림 오차 검사를 통해 민감한 구간을 판별한 뒤, 그 구간에만 더 높은 정밀도 또는 좀 더 세분화된 방법을 적용하는 방식으로 효율과 정확성을 모두 추구할 수 있다.
이러한 자동 분할정복(Adaptive) 알고리즘은, 예를 들어 적분 시 시뮬레이션 스텝 크기를 자동으로 줄이거나 늘리는 방식과 유사하다. 정확도를 높여야 할 구간에서 국소적으로 고차 정밀도 연산을 사용하면, 전체 연산량을 과도하게 증가시키지 않으면서도 결과 오차를 감소시킬 수 있다. 혼합 정밀도 기법, 반복 보정, 로그-도메인 기법 등과 결합하여 구현하면 높은 효율성을 낼 수 있다.
Rational Arithmetic과 심볼릭 기법
수학적 표현을 유리수(rational number) 형식으로 유지하며 연산을 진행하면, 이론적으로는 완전 정밀도를 얻을 수 있다. 예를 들어 $1/3$을 부동소수점에서 근사 대신 $p/q$ 형태로 내부 표현하면, 본질적으로 분수의 오차 없이 정확한 연산이 가능하다. 파이썬의 fractions 모듈, 일부 심볼릭 계산 시스템 등은 이러한 유리수 연산을 지원한다.
하지만 유리수 연산은 분모와 분자가 점차 커지면서 계산 복잡도가 매우 커지는 문제가 생긴다. 또한 곱셈, 덧셈, 최대공약수(GCD) 계산 등을 수행해야 하므로, 대규모 문제에서는 실시간 성능이 급격히 떨어질 수 있다. 그럼에도 불구하고, 오류 허용 범위가 극히 제한된 응용이나, 매우 작은 차이로 인해 기하적 위상이 변해버리는 문제 등에서는 유리수 연산과 심볼릭 기법이 강력한 대안이 될 수 있다.
Posit과 Unum 포맷
전통적인 IEEE 754 부동소수점 형식을 대체하기 위해 고안된 제안으로, 존 구스타프슨(John L. Gustafson)이 주장한 Unum(Universal Number)과 Posit 포맷이 있다. Posit는 정밀도와 범위를 동적으로 조정하면서, 0 주변과 매우 큰 수 주변 모두에서 효율적인 표현을 가능케 한다는 이론적 장점을 갖는다. 간단히 말해, Posit는 가수부와 지수부 분할을 고정하지 않고, 표현할 값에 따라 가변 길이로 지수와 가수 부분을 배분한다.
Posit나 Unum은 아직까지 표준 하드웨어 구현이 일반적이지 않고, 실무 적용이 제한적이다. 그러나 이론적으로는 IEEE 754 부동소수점보다 더 효율적이고 안정적인 수 표현을 제공할 가능성이 있다. 일부 연구용 라이브러리나 에뮬레이터가 Posit 연산을 지원하며, 혼합 정밀도 기법과 유사하게 어떤 부분만 Posit로 처리하는 시도도 있다.
다항식 평가(Polynomial Evaluation) 시 Horner 방식과 정밀도
다항식 $p(x) = a_n x^n + a_{n-1} x^{n-1} + \dots + a_1 x + a_0$를 계산할 때, 일반적으로 Horner 방식이 수치적으로 안정적이라고 알려져 있다. Horner 방식은
와 같은 형태로 연산을 재배치한다. 이 방식은 연산 횟수를 줄이면서, 불필요한 거듭제곱 연산과 큰 수의 곱셈을 피할 수 있어 반올림 오차를 줄이는 결과를 낳는다.
그 외에도 다항식 계수를 재배열하거나, 루트 근방에서 오차가 민감한 구간을 로그 변환 등을 통해 다루는 방법이 있다. 다항식 근 찾기에서 역시 정밀도 문제로 인해 뿌리가 잘못 계산될 수 있는데, 이때는 고정밀 라이브러리나 반복 보정 기법을 활용하여 실제 근에 가깝게 수렴시키는 방식이 일반적이다.
Interval Newton 방법
비선형 방정식을 풀 때, 뉴턴 방법을 구간 연산과 결합하는 Interval Newton 방식을 이용하면, 특정 구간에 해가 존재한다는 것을 보증하면서 탐색 범위를 점차 줄여나갈 수 있다. 구간 연산은 $f(x)$가 주어진 구간에서 어떤 최대·최소값을 가질지 보수적으로 평가하고, 그 정보를 토대로 다음 반복에서 해를 포함할 것으로 보이는 구간만 선택한다. 이 방법은 수치적 불안정성이 큰 문제에서, 근의 존재 여부와 위치를 엄밀히 판정할 때 활용 가치가 있다.
Interval Newton 알고리즘은 단일 뉴턴 방법보다 계산량이 훨씬 많고, 구간 폭이 좁아지는 속도가 문제별로 상이하다. 하지만 해를 포함하는 구간을 단계적으로 좁혀가면서, 최종적으로 매우 작은 구간 안에 해가 존재함을 보증할 수 있다는 장점이 있다. 일반 뉴턴 방법이 갖는 반올림 오차나 분기 문제를 좀 더 견고하게 다룰 수 있다.
정밀도와 성능의 균형 조절
실제 응용 환경에서는 최상의 정밀도를 얻기 위해 무제한 자원을 투자할 수 없으므로, 정밀도와 성능(또는 비용, 메모리 사용량) 사이의 균형을 모색해야 한다. 어떤 문제에서는 단정도 연산만으로도 충분한 정확도를 얻을 수 있지만, 어떤 문제에서는 배정도 이상의 확장 정밀도나 반복 보정이 요구된다. 문제의 크기, 조건수, 알고리즘의 구조, 결과에 대한 정확도 요구사항 등을 면밀히 평가하여, 특정 부분에만 고정밀을 적용하거나, 로그-도메인 전환, 혼합 정밀도 방법 등을 활용하여 효율성을 최대화한다.
성능 분석 도구나 수치 디버거(numerical debugger) 등을 통해, 어느 루틴에서 반올림 오차가 크게 발생하는지, 어디에서 메모리 병목이 있는지를 검토한다. 그 결과를 기반으로 알고리즘을 재설계하거나, 불필요하게 높은 정밀도를 요구하는 구간을 최적화할 수도 있다. 이런 작업은 HPC(High Performance Computing) 환경, 실시간 임베디드 제어, 대형 머신 러닝 학습 등 다양한 영역에서 공통적으로 중요해지고 있다.
(예시) Python에서 Fraction을 통한 유리수 연산
아래는 Python의 Fraction 클래스를 사용하여 유리수 연산을 시연하는 간단한 예시이다. 실제로는 분모·분자가 기하급수적으로 커질 수 있으므로, 대규모 계산에서는 주의해야 한다.
이와 같이 유리수로 표현하면, $1/3$을 0.3333... 같은 부동소수점 근사가 아니라 정확한 $\frac{1}{3}$으로 유지한다. 덧셈, 곱셈 결과도 유리수로 관리되어, 원칙적으로 반올림 오차가 발생하지 않는다. 그러나 분모·분자가 커질수록 연산 속도와 메모리 사용이 증가한다.
Last updated