Rust 생태계의 여러 decimal 크레이트를 고정소수점/부동소수점, 고정 크기/임의 정밀도 관점에서 비교하고 벤치마크 결과를 분석합니다.
제 영어는 썩 좋지 않아서, 이 글은 AI의 도움을 받아 번역되었습니다. 중국어 버전은 여기에서 볼 수 있습니다.
잘 알려져 있듯이, 2와 10은 같은 소인수를 공유하지 않기 때문에 이진 분수는 십진 분수를 정확하게 표현할 수 없습니다. 예를 들어 f64에는 고전적인 산술 오류가 있습니다: 0.1 + 0.2 != 0.3.
금융과 같은 일부 응용 시나리오에서는 십진 분수를 정확하게 표현해야 합니다. 이것이 decimal 크레이트가 필요한 이유입니다. 이들은 가수를 표현하기 위해 정수를 사용하고, 소수 자릿수를 나타내는 scale을 함께 사용합니다. 예를 들어 값 1.23은 정수 123과 scale = 2로 표현할 수 있습니다.
Rust 생태계에는 많은 decimal 크레이트가 있으며, 각각 서로 다른 설계와 절충점을 가지고 있습니다. 이들의 차이는 주로 두 가지 차원으로 나뉩니다:
이 글에서는 비교 및 벤치마킹을 위해 몇 가지 크레이트를 선택합니다.
목차:
처음 두 섹션(Fixed-point and Floating-point, Fixed-size and Arbitrary-precision)에서는 이러한 범주의 특성을 소개합니다. 특별히 새로운 내용은 없으므로, 경험 있는 독자는 건너뛰어도 됩니다.
다음 섹션(Choosing Crates)에서는 여러 decimal 크레이트를 소개합니다.
마지막 섹션(Benchmark Comparison)이 이 글의 핵심으로, 이들 크레이트를 벤치마킹하고 비교합니다.
고정소수점 산술에서는 scale이 고정되어 타입에 묶여 있습니다. 부동소수점 산술에서는 scale이 가변적이며 각 인스턴스에 저장됩니다.
코드로 이를 설명해 봅시다.
전형적인 고정소수점 타입 정의는 다음과 같을 수 있습니다:
struct FixedPoint<const SCALE: i32>(i128); // scale is bound to type
전형적인 부동소수점 decimal 타입은 다음과 같을 수 있습니다:
struct FloatingPoint {
mantissa: i128,
scale: i32, // scale is stored in each instance
}
이것은 고정소수점 수는 십진 정밀도가 고정되어 있고, 부동소수점 decimal은 가변 정밀도를 가진다는 점을 분명히 보여줍니다. 예를 들어 FixedPoint<2>는 항상 소수점 이하 2자리를 가지지만, FloatingPoint의 정밀도는 각 인스턴스의 scale에 따라 달라집니다.
이러한 구분 때문에 고정소수점과 부동소수점 타입은 다음과 같은 차이를 보입니다:
고정소수점 수는 표현 가능한 범위가 더 작고, 부동소수점 수는 훨씬 더 큰 범위를 표현할 수 있습니다. 이는 값이 커질수록 부동소수점 수가 십진 정밀도를 희생하기 때문입니다.
고정소수점 산술은 더 단순하고 빠르며, 부동소수점 산술은 더 복잡하고 느립니다. 예를 들어 고정소수점 수의 덧셈은 가수에 대한 정수 덧셈만 필요합니다. 부동소수점 덧셈은 먼저 scale이 같은지 확인해야 하며(이 확인 자체가 이미 덧셈보다 느릴 수 있습니다), 같지 않다면 곱셈을 통해 scale을 맞춰야 합니다. 이는 벤치마크 섹션에서 자세히 다룹니다.
고정소수점 산술은 사용이 다소 번거롭고, 부동소수점 산술은 더 편리합니다. 예를 들어 위의 FixedPoint 타입에서는 Balance나 Price가 소수 몇 자리를 가져야 하는지처럼 각 타입의 scale을 컴파일 타임에 결정해야 합니다. 부동소수점 decimal은 이런 고려가 필요 없습니다.
이 둘의 차이는 어느 정도 정적 타입 언어와 동적 타입 언어의 차이에 비유할 수 있습니다.
대부분의 응용 프로그램은 단지 십진 분수를 정확하게 표현하기 위해 decimal 크레이트를 사용하며, 성능이나 엄격한 십진 정밀도에 대한 요구가 특별히 높지 않습니다. 이런 경우에는 편의성을 위해 보통 부동소수점 decimal이 선호됩니다. 하지만 더 진지한 서비스, 특히 엄격한 십진 정밀도나 높은 성능이 필요한 많은 금융 시스템에서는 고정소수점 decimal을 권장합니다. 예를 들어 USD 자산은 정확히 소수점 이하 2자리를 가져야 하며, 그보다 많거나 적어서는 안 됩니다.
참고: 프로그래밍 언어의 내장 부동소수점 타입(C의 float, double 또는 Rust의 f32, f64)은 흔히 “부동소수점”이라고 불리고, 이 타입들은 십진 분수를 정확하게 표현할 수 없기 때문에 많은 사람들이 “부동소수점”은 본질적으로 십진 분수를 정확하게 표현할 수 없다고 오해합니다. 이것은 틀렸습니다! 더 정확히 말하면, 이들은 “이진 부동소수점” 수입니다. 십진 분수를 정확하게 표현할 수 없는 이유는 “부동소수점” 부분이 아니라 “이진” 부분에 있습니다. 사람들이 종종 “이진”이라는 단어를 생략하기 때문에, 부동소수점 산술이 부당하게 비난받습니다. 실제로 fixed 크레이트 같은 이진 고정소수점 타입도 십진 분수를 정확하게 표현할 수 없습니다. 크레이트가 십진 기반이기만 하면, 고정소수점이든 부동소수점이든 십진 분수를 정확하게 표현할 수 있습니다.
참고: 부동소수점 산술에는 IEEE 754라는 표준이 있으며, 여기에는 이진 부동소수점 형식(f32/f64에 사용됨)과 십진 부동소수점 형식이 모두 정의되어 있습니다. 하지만 이 표준은 부동소수점 산술의 하나의 구현 방식일 뿐, 부동소수점 산술 전체를 의미하지는 않습니다. 다른 구현도 가능합니다. 실제로 대부분의 decimal 크레이트는 IEEE 754 십진 형식을 따르지 않습니다.
Fixed-precision 대 Arbitrary-precision.
먼저 여기서 “precision”이라는 단어의 의미를 분명히 해봅시다. 이 용어에는 서로 충돌하는 두 가지 의미가 있습니다:
예를 들어 값 1.23은 소수 자릿수는 2개지만 유효 숫자는 3개입니다. 두 의미 모두 널리 사용됩니다. 예를 들어 std::fmt는 전자의 의미를 사용하지만, 여기서(Fixed-precision vs Arbitrary-precision)는 후자의 의미를 사용합니다. 이것이 표준 용어이긴 하지만, 쉽게 혼동을 일으킵니다. “Fixed-precision”은 종종 소수 자릿수가 고정된다는 뜻으로 오해되어 고정소수점 산술과 혼동됩니다.
모호함을 피하기 위해 이 글에서는 Fixed-precision 대신 _Fixed-size_라는 용어를 사용합니다.
이름에서 알 수 있듯이 Fixed-size 타입은 고정된 개수의 정수(하나 이상)를 사용합니다. Arbitrary-precision 타입은 필요한 만큼 정수를 사용합니다: 오버플로를 피하기 위해 왼쪽으로 확장하고, 정밀도 손실을 피하기 위해 오른쪽으로 확장합니다.
당연히 이것은 힙 할당을 필요로 하므로 타입은 Copy가 아니고, 크레이트도 no-alloc이 아닙니다. 모든 연산도 크게 느려집니다. 임의 정밀도에 대한 명확한 요구가 없다면, 일반적으로 Fixed-size 타입이 더 바람직합니다.
비교 및 벤치마킹을 위해 몇 가지 decimal 크레이트를 선택합니다:
부동소수점 임의 정밀도
현재 적극적으로 유지보수되는 유일한 임의 정밀도 decimal 크레이트입니다. 내부적으로는 Vec<u64> 또는 Vec<u32>를 사용해 가수를 표현합니다. 메모리 레이아웃은 다음과 같습니다:
+-u64----+--------+--------+--------+--------+
| sign | Vec<u64> | scale |
+--------+--+-----+--------+--------+--------+
|
+--------+--------+----
| u64 | … |
+--------+--------+----
메타데이터만으로도 5개의 머신 워드를 차지하여 총 40바이트가 되므로, 메모리 레이아웃이 비교적 느슨합니다. 생성과 확장 시 메모리 할당이 필요하고, 접근 시 포인터 역참조도 필요하므로 성능이 상대적으로 좋지 않으며, 이는 아래 벤치마크에서 분명히 드러납니다.
요약하면, 이 크레이트는 메모리 효율성과 성능을 희생하는 대신 임의 정밀도를 우선시합니다.
부동소수점 고정 크기
이 크레이트의 Decimal 정의는 다음과 같습니다:
struct Decimal<const N: usize>
여기서 N은 가수를 표현하는 데 사용되는 u64의 개수입니다. 예를 들어 Decimal<2>는 두 개의 u64를 사용하므로 128비트 가수를 갖습니다. 이것이 이 크레이트의 문서가 이를 Arbitrary-precision이라고도 설명하는 이유입니다. 차이점은 bigdecimal은 런타임에 정밀도를 조정하는 반면, fastnum은 컴파일 타임에 이를 결정한다는 점입니다.
메모리 레이아웃은 다음과 같습니다:
+-u64----+--------+...+--------+
| [u64; N] | CBlock |
+--------+--------+...+--------+
CBlock은 fastnum이 메타데이터를 저장하기 위해 사용하는 8바이트 ControlBlock입니다. 부호와 scale 외에도 추가 필드를 포함합니다. 자세한 내용은 문서를 참조하세요.
fastnum은 또한 sin, cos, sqrt, log처럼 보통 f32/f64에서 볼 수 있는 많은 과학 계산 함수를 제공합니다. 다른 decimal 크레이트는 이런 기능을 제공하지 않습니다. 개인적으로는 이러한 기능이 특별히 합리적이라고 생각하지 않습니다. 사람들은 십진 분수를 정확하게 표현하기 위해 decimal 산술을 사용하지만, 과학 계산은 보통 어차피 정확하게 표현할 수 없는 무리수를 만들어냅니다. 이런 연산이 필요한 시나리오(금융의 가격 모델 같은 경우를 포함해서도)는 훨씬 더 빠른 이진 부동소수점 타입(f32/f64)에 더 적합합니다.
문서에서는 이 크레이트가 매우 빠르다고 주장하지만, 그 벤치마크 비교 대상은 대부분 이미 느린 bigdecimal입니다. 아래 벤치마크에서는, 선택된 다른 크레이트들과 비교했을 때 fastnum이 오히려 가장 느린 것으로 나타납니다. 다만 이 크레이트는 자신을 Arbitrary-precision으로 간주하므로, 의도한 경쟁자는 아마 bigdecimal일 것입니다.
또한 문서가 대단히 자세합니다.
부동소수점 고정 크기
Rust 생태계에서 가장 인기 있는 decimal 크레이트입니다. 다운로드 수, 역의존성, 생태계 통합(serde, postgres 등)으로 볼 때 단연코 가장 널리 사용됩니다. 또한 가장 오래된 decimal 크레이트 중 하나로, 첫 릴리스는 2016년 말까지 거슬러 올라갑니다. 이 오랜 역사가 아마도 인기의 큰 이유일 것입니다.
이 크레이트는 128비트 부호 있는 decimal만 지원합니다. 메모리 레이아웃은 다음과 같습니다:
+-u32--+------+------+------+
| flag | high | mid | low |
+------+------+------+------+
가수는 세 개의 u32(high, mid, low)로 구성되어 총 96비트이며, 대략 28개의 십진 자릿수에 해당합니다. 산술 연산은 이 세 개의 u32를 순차적으로 모두 처리해야 하므로 성능에 악영향을 줍니다.
flag 필드는 다음을 저장합니다:
[0, 28])문서에서는 이 메모리 레이아웃이 성능 최적화를 위해 선택되었다고 주장합니다. 하지만 아래 벤치마크는 rust_decimal이 실제로 가장 빠르지는 않다는 점을 보여줍니다. 역사적으로 이 설계가 존재했던 이유는 Rust에 원래 안정적인 128비트 정수가 없었기 때문일 가능성이 큽니다.
API에도 i128 이전 시대의 흔적이 남아 있습니다. 예를 들어 i64에서 생성하는 생성자는 new라고 불리지만, 나중에 추가된 i128 생성자는 from_i128_with_scale라는 이름을 가집니다.
부동소수점 고정 크기
이 크레이트는 본질적으로 rust_decimal과 같은 틈새를 차지합니다.
장점:
단점:
rust_decimal이 가진 광범위한 생태계 통합이 부족합니다.이 크레이트가 선택된 이유 중 하나는 제가 그 작성자이기 때문입니다 :)
이 크레이트는 단일 정수 표현을 사용합니다. 128비트 부호 있는 타입의 메모리 레이아웃은 다음과 같습니다:
+-u128-----------------------+
|S|scale| mantissa |
+----------------------------+
부호(S)와 scale은 각각 1비트와 5비트를 차지하므로, 가수에는 122비트가 남고 이는 대략 36개의 십진 자릿수에 해당합니다 — rust_decimal의 28자리보다 상당히 많습니다.
산술에는 세 개의 u32 대신 단일 u128을 사용하므로 더 빠릅니다.
고정소수점 고정 크기
이 글에서 선택한 유일한 고정소수점 크레이트입니다. 다른 것들과의 주된 차이는 바로 고정소수점이라는 점이며, 이는 앞서 Fixed-point and Floating-point에서 논의했습니다.
다른 고정소수점 decimal 크레이트와 비교했을 때, 가장 큰 특징은 전형적인 FixedPoint 스타일(상수 제네릭을 사용해 컴파일 타임에 소수 자릿수를 고정함)뿐 아니라 Out-of-band scale 모드도 제공하여, 더 큰 유연성을 위해 런타임에 scale을 지정할 수 있다는 점입니다.
예를 들어 다중 통화 펀드 관리 시스템에서는 전형적인 FixedPoint 타입을 사용하면 모든 통화가 같은 십진 정밀도를 공유해야 합니다. 다음과 같이 정의하면:
type Balance = FixedPoint<2>
모든 통화가 소수점 이하 2자리로 제한된다는 뜻입니다.
이 크레이트의 Out-of-band scale 타입을 사용하면 각 통화가 자체적인 십진 정밀도를 정의할 수 있습니다. 자세한 내용은 Out-of-band documentation을 참조하세요.
scale이 타입에 묶여 있으므로(상수 제네릭 또는 Out-of-band 메타데이터를 통해), 인스턴스 자체에는 scale을 저장할 필요가 없습니다. 따라서 인스턴스는 가수만 저장합니다. 128비트 부호 있는 타입의 메모리 레이아웃은 다음과 같습니다:
+-i128-----------------------+
| signed-mantissa |
+----------------------------+
이 크레이트는 또 다른 구현 세부 사항에서도 차이가 있습니다: 다른 선택된 모든 크레이트가 부호와 가수를 분리해서 처리하는 반면, 이 크레이트는 부호 있는 가수를 사용합니다. 이 차이 또한 부동소수점과 고정소수점 산술의 차이에서 비롯되지만, 여기서는 자세히 다루지 않겠습니다. 언급할 만한 유일한 점은 이 때문에 가수가 128비트가 아니라 127비트가 된다는 것입니다.
메타데이터 크기를 보고 메모리 효율을 비교해 봅시다:
스포일러: 이 순위는 벤치마크 결과와 일치합니다.
이제 이 글의 핵심인 벤치마크 결과로 들어가겠습니다.
벤치마킹에는 criterion을 사용합니다. 프로젝트 소스 코드는 GitHub에서 볼 수 있습니다.
벤치마크는 세 대의 머신에서 실행했습니다:
결과는 환경에 따라 다소 달라집니다. 단순화를 위해 이 글에서는 첫 번째 머신(AMD EPYC)의 결과만 제시하고 분석합니다. 다른 환경에 관심 있는 독자는 전체 결과를 참고할 수 있습니다. 또한 자신의 머신에서 벤치마크를 실행해 보는 것도 환영하며, 방법은 프로젝트 페이지에 포함되어 있습니다.
위의 decimal 크레이트들 외에도, 비교를 위해 Rust 기본 f64도 포함했습니다. 안정적인 f128은 아직 제공되지 않으므로 벤치마크하지 않았습니다. 다만 제 개인 테스트에서는 f128이 f64와 거의 동일한 성능을 보였습니다.
주로 128비트 및 64비트 부호 있는 타입을 벤치마크합니다. 다만:
bigdecimal은 크기가 가변적이므로 비트 폭은 무의미합니다.fastnum은 훨씬 더 큰 크기를 지원하므로, 이 벤치마크는 다소 그 능력을 충분히 활용하지 못합니다.rust_decimal은 128비트만 지원하고 64비트는 지원하지 않습니다.벤치마크 케이스:
뺄셈은 덧셈과 유사하게 동작하므로 생략합니다.
피연산자 선택: 서로 다른 벤치마크 케이스는 시나리오에 따라 서로 다른 scale 구성을 사용합니다. 가수 자체는(더 정확히 말하면: 덧셈의 두 피연산자, 곱셈의 두 피연산자, 나눗셈의 피제수) 모두 10의 거듭제곱이며, 지수적으로 증가합니다. 예를 들어 차트에서 x = 3은 피연산자가 1e3이라는 뜻입니다.
서로 다른 크레이트가 지원하는 가수 크기가 다르기 때문에, 표현 가능한 범위도 달라지며, 그 결과 차트에서 선의 길이도 달라집니다:
bigdecimal은 임의 정밀도를 지원하지만, 여기서는 128비트에 해당하는 값, 즉 38개의 십진 자릿수로 제한했습니다.fastnum:128은 완전한 128비트 가수를 가지며, 역시 약 38자리입니다.prim-fpdec:128은 127비트 가수를 가지지만, 여전히 대략 38개의 십진 자릿수입니다.decimax:128은 122비트 가수를 가지며, 약 36자리입니다.rust_decimal은 96비트 가수만 가지므로, 약 28자리뿐입니다.다음 섹션에서 세부 사항을 설명합니다.
덧셈 과정은 다음과 같이 동작합니다:
이 섹션에서는 같은 scale인 경우를 벤치마크합니다. 다음 섹션에서는 scale이 다른 경우를 다룹니다.
단순화를 위해 동일한 피연산자를 사용합니다. scale은 벤치마크에 영향을 주지 않으므로 10으로 고정합니다. 가수는 크기가 증가하는 10의 거듭제곱입니다.
차트:
예상대로 bigdecimal은 다른 것들보다 훨씬 위에 있습니다. 나머지 크레이트는 아래쪽에 압축되어 있으므로, 잠시 bigdecimal을 제거해 보겠습니다:
이제 훨씬 분명해졌습니다.
128비트 타입의 경우:
fastnum:128이 가장 느립니다rust_decimaldecimaxprim-fpdec:128이 가장 빠릅니다앞의 세 가지는 모두 부동소수점 decimal이므로, 덧셈 전에 먼저 scale이 같은지 확인해야 합니다. 이 확인 자체가 비교적 비용이 크며 전체 연산을 느리게 만듭니다.
prim-fpdec:128은 고정소수점이므로, 이 연산은 본질적으로 정수 덧셈일 뿐이며 거의 단일 CPU 명령 하나에 가깝습니다.
64비트 타입의 경우:
fastnum:64는 fastnum:128보다 약간 빠릅니다decimax:64는 decimax:128과 비슷한 성능을 보입니다prim-fpdec:64는 prim-fpdec:128과 비슷한 성능을 보입니다대부분의 곡선은 안정적이지만, rust_decimal과 fastnum:64는 둘 다 눈에 띄는 점프를 보이며, 이유는 서로 다릅니다:
rust_decimal의 경우 점프가 발생하는 이유는 내부적으로 숫자를 세 개의 u32로 표현하기 때문입니다. 하나의 u32에 들어가는 작은 가수는 덧셈 한 번만 필요하지만, 더 큰 가수는 세 개의 u32 전체에 걸친 연산이 필요합니다. 그래서 x = 9 부근에서 점프가 발생합니다.
fastnum:64의 경우 점프는 64비트 가수가 최대 19개의 십진 자릿수를 표현할 수 있기 때문에 발생합니다. 우리의 벤치마크는 10의 거듭제곱을 사용하므로, 문제가 되는 경우는 1e19 부근에서 발생합니다. 이런 값을 둘 더하면 2e19가 되는데, 이는 64비트 범위(~1.84e19)를 초과합니다. 부동소수점 동작을 따르기 위해, 구현은 scale을 재조정해야 합니다: mantissa /= 10; scale += 1; . 나눗셈은 느리기 때문에 덧셈 연산이 갑자기 훨씬 느려집니다. 다른 부동소수점 크레이트도 비슷한 상황을 겪을 수 있지만, 이 벤치마크 범위 안에서는 발생하지 않습니다. 고정소수점 크레이트는 재조정을 할 수 없으므로, 단순히 오버플로를 일으키고 오류를 반환합니다.
이제 피연산자의 scale이 다른 덧셈을 살펴봅시다.
고정소수점 타입은 이 벤치마크에 참여할 수 없으므로, primitive_fixed_point_decimal은 제외됩니다.
가수를 더하기 전에, 부동소수점 decimal은 먼저 scale을 맞춰야 합니다. 알고리즘은 보통 다음과 같이 동작합니다:
이 벤치마크에서는 피연산자의 scale을 10과 0으로 고정하여 차이가 10이 되게 합니다. 따라서 정렬을 위해 1e10을 곱해야 합니다. 가수가 1e(MAX_SCALE - 10)을 넘어서면 곱셈이 오버플로를 일으키고, 그 결과 나눗셈이 포함된 더 느린 폴백 경로가 실행됩니다.
차트:
다시 bigdecimal이 차트를 지배하므로, 잠시 제거하겠습니다:
같은 scale에서의 덧셈과 비교하면, scale 정렬 때문에 절대 시간은 훨씬 느립니다.
앞서 설명했듯이, 모든 곡선은 결국 점프를 보입니다.
그중에서:
rust_decimal은 가장 큰 점프를 보이며, ~15ns에서 ~45ns로 3배가 되고 그 이후로도 불안정해집니다.fastnum:128은 중간 정도의 점프를 보입니다.decimax:128은 가장 작은 점프를 보입니다.성능 순위(느린 것부터):
점프 전:
fastnum:128>rust_decimal>decimax:128
점프 후:
rust_decimal>fastnum:128>decimax:128
이제 곱셈을 살펴보겠습니다.
decimal 곱셈은 두 부분으로 이루어집니다:
두 단계 모두 오버플로를 일으킬 수 있습니다. 둘 중 하나라도 오버플로하면 두 번째 단계가 실행되어, 오버플로를 피하기 위해 가수와 scale을 모두 줄입니다. 이 과정에는 나눗셈이 포함되므로 성능이 크게 저하됩니다.
여기서도 동일한 피연산자를 사용하며, 가수는 지수적으로 증가합니다. decimal 값 자체의 곱셈 오버플로(가수 곱셈이 아니라)를 피하기 위해, 실제 값이 1로 유지되도록 scale도 동시에 증가시킵니다.
가수가 표현 가능한 범위의 절반 정도에 도달하면, 가수 곱셈이 오버플로를 일으켜 두 번째 단계를 촉발합니다.
차트:
bigdecimal 외에도, 두 fastnum 곡선은 후반부에서 매우 커집니다. 다른 크레이트를 더 잘 보기 위해 bigdecimal 곡선 전체를 제거하고 fastnum 곡선을 잘라내겠습니다:
차트가 여전히 다소 복잡하므로, 차근차근 살펴보겠습니다.
가수 곱셈 오버플로 때문에 대부분의 곡선은 중간 지점 부근에서 점프를 보입니다.
먼저 128비트 타입의 점프 이후 동작을 봅시다:
fastnum:128은 점프 이후 매우 빠르게 느려집니다.rust_decimal은 여러 번 점프를 보이는데, 이는 아마 세 개의 u32 표현 때문일 것입니다.decimax와 prim-oob-fpdec:128은 훨씬 더 안정적이며 상당히 빠릅니다.이제 점프 이전 구간을 봅시다:
fastnum:128과 rust_decimal은 각각 점프 전(x=19, x=14)까지는 안정적이지만, fastnum이 더 오래 버팁니다.decimax와 prim-oob-fpdec:128은 점프 전 구간에서 안정적일 뿐 아니라 매우 빠릅니다.주의 깊은 독자는 primitive_fixed_point_decimal이 prim-oob-fpdec:128과 prim-const-fpdec:128이라는 두 변형으로 나타난다는 점을 눈치챌 수 있습니다. 앞서 논의한 것은 전자뿐이었습니다. 이 차이는 고정소수점 의미론에서 비롯됩니다. 앞서 설명한 곱셈 과정(가수 곱셈, scale 덧셈)은 부동소수점 decimal에 적용됩니다. 하지만 고정소수점 decimal에서는 결과 scale이 미리 정해져 있습니다. 피연산자의 scale을 더한 뒤, 구현은 목표 scale에 맞추기 위해 추가 조정을 해야 하며, 이는 오버플로 조정 단계와 비슷합니다. 다시 말해, 부동소수점 타입은 나중에야 들어가는 두 번째 단계가 고정소수점 타입에서는 항상 활성화되어 있습니다. 이는 고정소수점 산술에 다소 불공평합니다. 다행히 primitive_fixed_point_decimal은 더 유연한 Out-of-band Scale 모드를 제공하여 결과 scale이 피연산자 scale의 합과 같게 할 수 있습니다. 그러면 벤치마크 초반부에서는 두 번째 단계를 피할 수 있어, 부동소수점 타입과 더 공정하게 비교할 수 있습니다. 이것이 prim-oob-fpdec:128이 측정하는 것입니다.
하지만 이것은 고정소수점 산술의 실제 사용 사례는 아닙니다. Out-of-band Scale 기능은 이 벤치마크만을 위해 설계된 것이 아닙니다. 현실적인 고정소수점 사용을 반영하기 위해, 결과 scale이 고정된 prim-const-fpdec:128도 벤치마크합니다. 이 경우 두 번째 단계가 전체 벤치마크 동안 강제로 실행됩니다. 차트가 보여주듯이, prim-const-fpdec:128은 처음에는 가장 느리지만, 나중에는 가장 빠른 축에 속하게 되며 prim-oob-fpdec:128과 수렴합니다.
이것이 작은 가수에 대해서는 고정소수점 곱셈이 부동소수점 곱셈보다 느리다는 뜻일까요? 이 특정한 경우에는 그렇습니다. 하지만 더 긴 계산 사슬에서는 꼭 그렇지 않습니다. 부동소수점 곱셈이 더 빨라 보이는 이유는 scale 조정을 뒤로 미루어 scale과 가수가 모두 커지게 허용하기 때문입니다. 이 글 전체에서 보았듯이, 더 큰 scale과 가수는 이후 연산을 느리게 만드는 경향이 있습니다. 곱셈 결과가 최종값이고 다시는 사용되지 않는 경우(문자열로 포맷하는 경우조차 포함하지 않음)가 아니라면, 초기의 성능 이점은 나중에 다시 비용으로 돌아오는 경향이 있습니다.
64비트 결과도 비슷하게 동작하므로 여기서는 생략합니다.
나눗셈에는 몇 가지 눈에 띄는 특성이 있습니다:
전체적으로 보면, 실제 사용 비중은 비교적 작은데도 불구하고 나눗셈은 과도한 개발 및 벤치마킹 노력을 소모하는 경향이 있습니다. 따라서 이 글에서는 두 가지 단순한 경우만 벤치마크합니다:
그리고 포괄적이거나 완벽하게 공정한 비교를 시도하지는 않습니다.
이 섹션에서는 전자인 정확히 나누어떨어지는 나눗셈을 다룹니다.
정확히 나누어떨어지는 부동소수점 나눗셈에는 다시 두 가지 하위 경우가 있습니다:
200 / 25.2 / 25.두 번째 경우에는 2가 25로 깔끔하게 나누어지지 않지만, 200으로 재조정한 뒤에는 나눗셈이 성공합니다. 문제는 구현이 처음에는 얼마나 재조정이 필요한지, 혹은 정확한 나눗셈이 가능한지조차 모른다는 점입니다. 따라서 구현은 종종 먼저 공격적으로 scale을 키운 다음 나눗셈을 수행하고, 마지막에 뒤따르는 0을 제거합니다. 예를 들어 2가 먼저 20000000000이 되고, 여기서 800000000을 얻은 뒤, 마지막에 다시 8로 줄어들 수 있습니다. 0 제거 단계조차 반복적으로 찾아내야 하므로, 이 경로는 매우 느릴 수 있습니다.
두 경우를 모두 포함하기 위해, 벤치마크에서는 제수를 1e8로 고정하고, 피제수는 다시 10의 거듭제곱으로 증가시킵니다.
따라서:
x=8 이전에는 재조정이 필요합니다(느린 경로)x=8 이후에는 직접 나눗셈이 성공합니다(빠른 경로)고정소수점 타입은 몫의 scale이 미리 정해져 있으므로 이런 구분이 없습니다.
차트:
부동소수점 타입의 경우:
x=8 이전에는 모든 구현이 매우 느립니다.rust_decimal, fastnum:128, decimax가 훨씬 빨라지지만, bigdecimal은 여전히 느립니다.고정소수점의 경우:
prim-fpdec:128은 몫의 scale 결정 과정을 피하므로 처음에는 매우 빠릅니다. 이후에는 더 큰 가수 때문에 점차 느려집니다.이제 정확히 나누어떨어지지 않는 나눗셈 경우를 살펴봅시다.
앞서 설명했듯이, 정확성 여부는 부동소수점 decimal에만 중요합니다. 고정소수점의 동작은 변하지 않으므로, 여기서의 고정소수점 결과는 이전 벤치마크와 같아야 합니다.
차트:
다시 bigdecimal을 제거하면 비교가 더 분명해집니다:
정확히 나누어떨어지는 나눗셈과 비교하면:
bigdecimal, fastnum:128, rust_decimal은 전반적으로 훨씬 더 느립니다.decimax:128은 상당히 더 빨라지고 매우 안정적입니다.prim-fpdec:128은 정확히 나누어떨어지는 나눗셈 벤치마크와 동일하게 동작합니다.그 이유는 각 구현의 코드 수준 분석이 필요할 가능성이 높으며, 이는 이 글의 범위를 벗어납니다.
전반적으로, 몇몇 특별한 경우를 제외하면 대략적인 성능 순위는 다음과 같습니다:
bigdecimal << fastnum < rust_decimal < decimax < primitive_fixed_point_decimal
(더 왼쪽일수록 더 느립니다.)
부동소수점 산술 경로는 특정 피연산자에 크게 의존하므로 성능이 비교적 불안정합니다. 이에 비해 고정소수점 산술은 훨씬 더 예측 가능하며, 이는 위의 대부분 평평한 곡선에 반영되어 있습니다.
다시 말하지만, 이 크레이트들은 서로 다른 사용 사례를 목표로 하므로 순수한 성능 비교만으로는 완전히 공정하다고 할 수 없습니다.
이 글에서는 decimal 크레이트의 여러 범주를 소개하고, 몇 가지 대표적인 구현을 벤치마크했습니다.
결과를 바탕으로 다음과 같은 권장 사항을 제시할 수 있습니다:
동적인 임의 정밀도가 필요하다면 bigdecimal이 유일한 선택지이며, 그 대가로 Copy 의미론을 잃고 매우 좋지 않은 성능을 감수해야 합니다.
128비트보다 큰 타입이 필요하다면 fastnum이 유일한 선택입니다. 이 글은 128비트 초과 타입을 벤치마크하지 않지만, 성능이 뛰어날 가능성은 높지 않습니다. 관심 있는 독자는 벤치마크 프로젝트를 수정해 직접 테스트해 볼 수 있습니다.
고정된 십진 정밀도가 필요하다면 primitive_fixed_point_decimal이 유일하게 적합한 선택입니다. 부동소수점 타입보다 약간 덜 편리하지만, 더 높고 더 안정적인 성능을 제공합니다.
위 요구 사항 중 어느 것도 해당하지 않고 단지 정확한 십진 표현만 원한다면, rust_decimal과 decimax는 둘 다 좋은 선택입니다. 전자는 더 강한 생태계를 가지고 있고, 후자는 더 나은 성능을 제공합니다.