Rust 1.98의 새 대수 연산자를 사용해 부동소수점 수치 코드의 성능을 높이면서 반올림 오차를 제어하는 방법을 알아봅니다.
부동소수점 연산은 컴파일러가 코드 최적화에 보수적으로 접근하기 때문에 정수 연산보다 흔히 느립니다. 일부 프로그래밍 언어에는 어느 정도 해결책이 이미 있었지만, 지금까지 Rust에는 이 한계를 다룰 좋은 안정적 방법이 없었습니다. 하지만 이제 Rust 1.98부터는 컴파일러가 코드를 더 최적화해도 된다고 알려줄 수 있습니다. 동시에 추가 제어 기능도 제공하므로, 반올림 오차를 최소화하는 수치 알고리즘을 계속 작성할 수 있습니다.
이 글에서 알아볼 내용은 다음과 같습니다.
가능한 성능의 기준선을 살펴보기 위해 정수를 사용하는 예제부터 시작하겠습니다.
가장 빠른 코드 생성을 위해 Rust에 이제는 2004년이 아니며, 최근 10년 정도의 x86-64 머신, 즉 현대 하드웨어가 필요한 CPU 명령어를 생성해도 된다고 알려주겠습니다. 구체적으로 이 글의 모든 코드는 RUSTFLAGS="-C target-cpu=x86-64-v3"로 컴파일합니다. (최대한의 호환성을 위해 실제 사용에서는 더 오래된 컴퓨터용 대체 구현을 제공할 수 있습니다.)
다음은 int64 숫자 슬라이스의 합을 구하는 Rust 함수입니다.
fn naive_sum_i64(values: &[i64]) -> i64 {
let mut total = 0;
for value in values {
total += value;
}
total
}
이를 Python에 노출하는 코드는 생략하겠지만, 이전 글의 Rust/Python 코드 변형입니다.
벤치마크를 위해 NumPy에서 정수 배열을 만들겠습니다.
import numpy as np
DATA_INT = np.ones((1_000_000,), dtype=np.int64)
assert naive_sum_i64(DATA_INT) == 1_000_000
이제 이 배열의 합을 구하는 속도를 측정할 수 있습니다.
| 코드 | ➘ 경과 마이크로초 | ➘ 값당 CPU 명령어 수 | |
|---|---|---|---|
naive_sum_i64(DATA_INT) | 168.1 | 0.5 |
➘ 낮을수록 좋음
값 하나당 CPU 명령어가 0.5개입니다! 이게 어떻게 가능한 걸까요?
아마 컴파일러가 여러 값을 한꺼번에 일괄 처리하는 특수한 단일 명령어 다중 데이터(SIMD) CPU 명령어를 사용하고 있을 것입니다. 여기서 사용하는 i7-12700K CPU에는 256비트 SIMD 명령어가 있으므로, 특정 연산을 한 번에 64비트 정수 네 개에 수행할 수 있습니다. 특수한 SIMD 합산 CPU 명령어가 있다면 CPU는 250,000번만 반복하고 각 반복에서 정수 네 개를 더하면 됩니다.
실제로 그렇습니다.
| 코드 | ➘ 경과 마이크로초 | ➘ CPU 명령어 수 | 256비트 SIMD 정수 명령어 | |
|---|---|---|---|---|
naive_sum_i64(DATA_INT) | 156.8 | 521,280 | 250,003 |
➘ 낮을수록 좋음
요약하면, 특수한 SIMD 명령어를 사용해 CPU가 정수를 매우 빠르게 더할 수 있습니다.
그렇다면 부동소수점도 빠를까요?
다시 100만 개의 부동소수점 값을 만들겠습니다.
# Array of 1M float64 values between 0 and 1.
DATA = np.random.random((1_000_000,))
간단한 부동소수점 합산 함수를 구현하겠습니다.
fn naive_sum(values: &[f64]) -> f64 {
let mut total = 0.0;
for value in values {
total += value;
}
total
}
그리고 정수와 부동소수점 합산의 성능을 비교하겠습니다.
| 코드 | ➘ 경과 마이크로초 | ➘ CPU 명령어 수 | 256비트 SIMD 정수 명령어 | 256비트 SIMD 부동소수점 명령어 |
|---|---|---|---|---|
naive_sum_i64(DATA_INT) | 151.9 | 521,214 | 250,003 | 0 |
naive_sum(DATA) | 595.2 | 1,458,269 | 0 | 0 |
➘ 낮을수록 좋음
부동소수점 합산은 정수 합산보다 훨씬 느리고, 컴파일러는 SIMD 부동소수점 연산을 사용하지 않았습니다. 왜 차이가 날까요?
대부분의 컴파일러처럼 Rust도 릴리스 모드에서 코드를 컴파일할 때 코드를 최적화하여, 더 빨라지도록 다양한 방식으로 변환합니다. 하지만 컴파일러는 그렇게 할 때 한 가지 약속을 합니다. 최적화된 코드는 최적화되지 않은 코드와 정확히 동일하게 동작한다는 약속입니다.
정수 a, b, c 세 개를 더하면 a + (b + c) == (a + b) + c입니다. 따라서 컴파일러는 실행 방식을 최적화할 여지가 충분합니다. 예를 들어 덧셈 순서가 약간 바뀔 수 있는 SIMD 연산을 사용할 수 있습니다.
부동소수점 숫자는 다릅니다. 예를 들어 부동소수점 숫자는 매우 작은 값부터 매우 큰 값까지 넓은 범위를 포괄하므로, 충분히 큰 숫자에 충분히 작은 숫자를 더하면 결과는 원래의 큰 숫자와 같습니다.
print(
"Does adding a small number do nothing?",
1e16 + 1.0 == 1e16
)
Does adding a small number do nothing? True
더 일반적으로 부동소수점 숫자에서는, 적어도 여러 숫자를 연속해서 더할 때 a + (b + c)가 항상 (a + b) + c와 같지는 않습니다. 1e16으로 시작하고 그 뒤에 많은 1.0 값이 있는 배열과, 그 순서가 반대인 다른 배열이 있다고 해보겠습니다. 이 배열들의 합은 서로 다른 결과를 냅니다.
import math
HIGH_VALUE_FIRST = np.ones((1_000_000,), dtype=np.float64)
HIGH_VALUE_FIRST[0] = 1e16
HIGH_VALUE_LAST = np.ones((1_000_000,), dtype=np.float64)
HIGH_VALUE_LAST[-1] = 1e16
print(
"Is the sum the same?",
naive_sum(HIGH_VALUE_FIRST) == naive_sum(HIGH_VALUE_LAST)
)
Is the sum the same? False
합산 순서가 결과에 영향을 미치므로, 컴파일러는 당연히 내가 이유가 있어서 이 특정 순서를 요청했다고 가정합니다. 그 결과 컴파일러는 이 연산들의 순서를 바꾸지 않습니다. 또한 결과를 바꿀 수 있는 다른 최적화도 적용하지 않습니다. 그로 인해 코드가 더 느려지더라도 마찬가지입니다.
컴파일러의 보수성은 올바른 기본값이지만, 프로그래머인 여러분은 연산 순서를 바꿔도 문제가 없다는 것을 아는 경우가 있습니다. 그런 상황에서는 컴파일러에게, 여기서는 연산 순서를 바꾸면 안 되지만 저기서는 괜찮다고 알릴 수 있으면 좋습니다.
Rust 1.98부터는 정확히 이를 가능하게 하는 새 기능이 있습니다. 부동소수점 숫자에 수행할 수 있는 일반 산술 연산 외에도, 문서에서 "컴파일러가 실수의 모든 일반적인 대수적 성질을 사용하여 부동소수점 연산을 최적화할 수 있게" 한다고 설명하는, 연산 순서 변경을 포함한 새로운 이른바 “대수적” 산술 연산자 집합이 있습니다.
Rust 1.98의 출시일은 2026년 8월 20일입니다. 그 전에 이 글을 작성했으므로, 이 글의 코드는 동일한 기능을 포함하는
"beta"채널로 실행했습니다.
이 연산자들이 실제로 어떻게 동작하며 빠른 코드를 가능하게 하는지 살펴보겠습니다.
부동소수점 합산은 예상 밖의 동작을 보일 수 있습니다. 1e16 + 1.0 == 1e16였던 것을 기억하세요. 따라서 많은 부동소수점 숫자의 합을 구하려면 결과적인 반올림 오차를 최소화하기 위해 여러 알고리즘을 사용할 수 있습니다. 이들 사이의 절충점은 대개 속도와 정확도입니다. numpy.sum()은 대부분 쌍별 합산이라는 알고리즘을 사용하며, 이는 속도와 누적 오차 감소 사이에서 좋은 균형을 이룹니다. 적어도 위의 naive_sum()처럼 한 번에 하나의 부동소수점을 순서대로 더하는 것보다 오차 누적이 적습니다.
쌍별 합산의 기본 아이디어는 배열을 둘로 나누고, 각 쪽을 같은 알고리즘으로 재귀적으로 합산한 다음, 그 결과인 두 부동소수점 숫자를 더하는 것입니다. 배열 크기가 특정 임곗값보다 작아지면(NumPy의 경우 128) 합산은 일반적인 방식으로 수행합니다. 그리고 그 합산은 어떤 순서로든 할 수 있습니다.
이 알고리즘을 Rust로 구현해 보겠습니다. 맨 위의 두 부동소수점 숫자를 더할 때는 컴파일러가 연산 순서를 바꾸거나 출력을 변경할 다른 일을 할 수 없는 일반 덧셈을 사용하겠습니다. 함수가 일반 합산을 수행하는 임곗값에 도달하면 대수적 덧셈으로 전환하겠습니다. 이 시점에서는 덧셈 순서에 상관없고 속도만 원하기 때문입니다.
fn pairwise_sum(values: &[f64]) -> f64 {
let n = values.len();
if n > 128 {
// Precise addition of two recursive applications
// of the algorithm:
let half = n / 2;
pairwise_sum(&values[0..half])
+ pairwise_sum(&values[half..n])
} else {
// 😎 Normal addition, where the order doesn't matter
// as far as the algorithm is concerned. Thus using a
// algebraic add is fine—and that gives the compiler
// permission to optimize aggressively.
let mut total: f64 = 0.0;
for value in values {
total = total.algebraic_add(*value);
}
total
}
}
이 쌍별 합산 구현은 빠릅니다. 실제로 NumPy 구현보다 빠릅니다.
| 코드 | ➘ 경과 마이크로초 | ➘ CPU 명령어 수 | 256비트 SIMD 부동소수점 명령어 | |
|---|---|---|---|---|
naive_sum(DATA) | 563.1 | 1,458,279 | 0 | |
np.sum(DATA) | 190.7 | 2,191,767 | 0 | |
pairwise_sum(DATA) | 144.5 | 🏆 | 1,298,028 | 270,336 |
➘ 낮을수록 좋음
같은 알고리즘을 구현하므로 NumPy 구현과 같은 (일반적인) 정밀도도 갖습니다.
from math import fsum
# fsum() uses an accurate summation algorithm that gets rid of any
# avoidable floating-point error.
assert fsum(HIGH_VALUE_FIRST) == fsum(HIGH_VALUE_LAST)
correct_sum = fsum(HIGH_VALUE_FIRST)
print(
"naive_sum() error: ",
naive_sum(HIGH_VALUE_FIRST) - correct_sum
)
print(
"np.sum() error: ",
np.sum(HIGH_VALUE_FIRST) - correct_sum
)
print(
"pairwise_sum() error:",
pairwise_sum(HIGH_VALUE_FIRST) - correct_sum
)
naive_sum() error: -1000000.0
np.sum() error: -14.0
pairwise_sum() error: -6.0
np.sum()과pairwise_sum()의 오차 차이는 의미가 없습니다. 그저 우연입니다. 핵심은 둘 다naive_sum()과 비교해 비슷하게 낮은 자릿수 규모의 오차를 보인다는 점입니다.
추가 이점으로, SIMD 코드 생성을 유도하기 위해 pairwise_sum()이 사용하는 코드 구조는 NumPy의 구조보다 훨씬 더 간결합니다.
대수 연산자로 할 수 있는 일은 숫자 덧셈만이 아닙니다. 다음 예제에서는 두 배열 사이 제곱 차이의 합을 계산하겠습니다.
배열 두 개가 필요합니다.
DATA1 = np.random.random((1_000_000,))
DATA2 = np.random.random((1_000_000,))
다음은 일반 산술 연산자를 사용한 구현입니다.
fn ssd_normal(arr1: &[f64], arr2: &[f64]) -> f64 {
assert_eq!(arr1.len(), arr2.len());
let mut total = 0.0;
for (val1, val2) in arr1.iter().zip(arr2) {
total += (val1 - val2).powi(2);
}
total
}
다음은 대수 연산자를 사용한 모습입니다. f64.powi()가 대수 연산자를 사용할지는 확실하지 않으므로, 명시적으로 사용했습니다.
fn ssd_optimized(arr1: &[f64], arr2: &[f64]) -> f64 {
assert_eq!(arr1.len(), arr2.len());
let mut total: f64 = 0.0;
for (val1, val2) in arr1.iter().zip(arr2) {
// 😎 Algebraic operations to allow more compiler
// optimizations:
let diff = val1.algebraic_sub(*val2);
let squared_diff = diff.algebraic_mul(diff);
total = total.algebraic_add(squared_diff);
}
total
}
먼저 결과가 비슷한지 확인하는 간단한 테스트를 하겠습니다.
print(ssd_normal(DATA1, DATA2))
print(ssd_optimized(DATA1, DATA2))
166770.0055951995
166770.00559520238
다음으로 성능을 측정하겠습니다.
| 코드 | ➘ 경과 마이크로초 | ➘ 값당 CPU 명령어 수 | |
|---|---|---|---|
ssd_normal(DATA1, DATA2) | 628.7 | 4.5 | |
ssd_optimized(DATA1, DATA2) | 371.1 | 🏆 | 1.0 |
➘ 낮을수록 좋음
대수 연산을 사용하면 컴파일러가 내 컴퓨터에서 두 배 빠르게 실행되는 코드를 생성할 수 있습니다.
쌍별 합산 알고리즘은 엄격한 연산자와 느슨한 연산자가 모두 필요한 이유를 잘 보여주는 훌륭한 예입니다.
위에서 보여 준 구현은 알고리즘의 서로 다른 부분에서 정확성을 위한 일반 덧셈과 속도를 위한 대수적 덧셈을 모두 사용함으로써 이점을 얻습니다.
Rust로 수치 코드를 작성하고 있다면 여러분의 코드도 이점을 얻을 수 있습니다. Rust 1.98을 사용할 수 있게 되면 시도해 보세요. 아직 Rust를 사용하지 않는다면, 이것도 전환할 좋은 이유 하나입니다.