Rust 생태계의 여러 decimal 크레이트를 정밀도, 메모리 구조, 성능 관점에서 비교하고 벤치마크한 글입니다.
잘 알려져 있듯이, 2와 10의 소인수가 서로 다르기 때문에 이진 소수는 십진 소수를 정확하게 표현할 수 없습니다. 예를 들어 f64에는 잘 알려진 산술 오차가 있습니다: 0.1+0.2!=0.3.
금융처럼 십진 소수를 정확하게 표현해야 하는 응용 분야도 있습니다. 이런 경우 decimal 라이브러리, 즉 decimal crate가 필요합니다. 기본 원리는 유효숫자 mantissa를 정수로 표현하고, scale로 십진 소수 자릿수를 나타내는 것입니다. 예를 들어 값 1.23은 정수 123과 scale=2로 표현할 수 있습니다.
Rust 생태계에는 많은 decimal crate가 있고, 설계도 다르며, 각자 중점을 두는 부분도 다릅니다. 차이는 주로 두 가지입니다.
scale이 고정인지 가변인지. 이는 고정소수점 Fixed-point 과 부동소수점 Floating-point 에 대응합니다.
정수의 개수가 고정인지 임의인지. 이는 고정 정밀도 Fixed-precision 와 임의 정밀도 Arbitrary-precision 에 대응합니다.
이 글에서는 몇 가지 유형의 crate를 골라 비교하고 테스트합니다.
이 글의 목차:
Fixed-point vs Floating-point.
고정소수점의 scale은 고정되어 변하지 않으며 데이터 타입 type에 바인딩됩니다. 반면 부동소수점의 scale은 가변적이며 계산에 따라 바뀌고 각 인스턴스에 저장됩니다.
코드로 보면, 전형적인 고정소수점 수 의 정의는 다음과 같을 수 있습니다.
struct FixedPoint<const SCALE: i32>(i128); // scale is bound to type
반면 전형적인 부동소수점 수 의 정의는 다음과 같을 수 있습니다.
struct FloatingPoint {
mantissa: i128,
scale: i32, // scale is stored in each instance
}
이렇게 보면 고정소수점 수의 소수 정밀도는 고정되어 있고, 부동소수점 수의 소수 정밀도는 고정되어 있지 않다는 점이 분명해집니다. 예를 들어 위의 FixedPoint<2> 의 소수 정밀도는 2입니다. 반면 FloatingPoint 의 소수 정밀도는 각 인스턴스의 scale에 달려 있습니다. 이 차이 때문에 고정소수점 수와 부동소수점 수는 다음과 같은 차이를 보입니다.
고정소수점 수는 표현 가능한 범위가 더 작고, 부동소수점 수는 더 큽니다. 값이 매우 커질 때 소수 정밀도가 낮아지기 때문입니다.
고정소수점 수는 더 단순하고 더 빠르며, 부동소수점 수는 복잡하고 느립니다. 예를 들어 덧셈에서 고정소수점 수는 mantissa를 나타내는 정수끼리 더하면 되지만, 부동소수점 수는 먼저 두 피연산자의 scale이 같은지 판단해야 합니다. 이 판단 자체가 덧셈보다 더 느릴 수 있으며, 같지 않으면 곱셈으로 정렬도 해야 합니다. 아래 벤치마크 절에서 자세히 소개합니다.
고정소수점 수는 사용이 약간 번거롭고, 부동소수점 수는 더 편리합니다. 예를 들어 위의 FixedPoint 는 각 타입의 scale을 컴파일 시점에 미리 정해야 합니다. 예를 들어 Balance 는 소수 몇 자리인지, Price 는 몇 자리인지 정해야 합니다. 반면 부동소수점 수는 이런 점을 고려할 필요가 없습니다.
이 둘의 차이는 정적 타입 언어와 동적 타입 언어의 차이와 비슷합니다.
대부분의 응용 프로그램은 decimal crate를 사용하는 이유가 십진 소수를 정확하게 표현하기 위해서이지, 성능이나 소수 정밀도에 아주 높은 요구가 있는 것은 아닙니다. 이런 경우에는 사용 편의성을 위해 부동소수점 수를 우선 선택하면 됩니다. 하지만 더 엄격한 서비스, 예를 들어 많은 금융 응용에서는 확정된 소수 정밀도나 고성능이 요구되므로 고정소수점 수를 권장합니다. 예를 들어 USD 자산의 소수 정밀도는 2여야 하며, 더 많아도 더 적어도 안 됩니다.
NOTE: 프로그래밍 언어가 기본 제공하는 소수 타입(예: C의 float 와 double, Rust의 f32 와 f64)은 일반적으로 “부동소수점 수”라고 불리지만, 이 타입들은 십진 소수를 정확하게 표현할 수 없기 때문에 많은 사람이 “부동소수점 수”가 곧 이런 타입이며 “부동소수점 수”는 십진 소수를 정확히 표현할 수 없다고 생각합니다. 이 관점은 틀렸습니다. 정확히 말하면 이 타입들은 “이진” 부동소수점, 즉 Binary Floating-point입니다. 이들이 십진 소수를 정확히 표현할 수 없는 이유는 “부동소수점 Floating-point”이 아니라 “이진 Binary”에 있습니다. 다만 보통 “이진 Binary”를 생략하고 그냥 “부동소수점 Floating-point”라고 부르기 때문에 “floating-point”가 억울하게 책임을 떠안은 셈입니다. 사실 “이진 고정소수점” Binary Fixed-point, 예를 들어 fixed crate 역시 십진 소수를 정확히 표현할 수 없습니다. 반대로 십진 decimal crate라면 고정소수점이든 부동소수점이든 모두 십진 소수를 정확히 표현할 수 있습니다.
NOTE: 부동소수점 수에는 IEEE 754 라는 표준이 있으며, 여기에는 이진 부동소수점 수(즉 f32/f64가 따르는 표준)와 십진 부동소수점 수가 정의되어 있습니다. 하지만 이 표준은 부동소수점 수의 한 가지 구현 방식일 뿐, 모든 부동소수점 수를 뜻하지는 않습니다. 부동소수점 수는 다른 구현 방식을 선택할 수도 있습니다. 예를 들어 이 표준에 정의된 십진 형식은 그다지 적합하지 않아서, 대부분의 decimal crate는 이 표준을 따르지 않습니다.
Fixed-precision vs Arbitrary-precision.
먼저 여기서 “precision”이라는 단어의 의미를 분명히 해둘 필요가 있습니다. 이 단어에는 서로 충돌하는 두 가지 의미가 있습니다. 소수 자릿수와 유효숫자 자릿수입니다. 예를 들어 값 1.23은 소수 자릿수는 2이고 유효숫자는 3입니다. 이 두 의미는 모두 널리 사용됩니다. 예를 들어 std::fmt 에서는 전자의 의미를 사용합니다. 반면 여기(Fixed-precision vs Arbitrary-precision)에서는 후자의 의미를 사용합니다. 이것이 표준적인 명칭 이기는 하지만, 매우 쉽게 모호함을 일으킵니다. Fixed-precision은 자주 소수 정밀도로 오해되어 Fixed-point와 혼동됩니다. 모호함을 피하기 위해 여기서는 Fixed-precision 대신 Fixed-size라는 표현을 사용하겠습니다.
이름 그대로 Fixed-size는 고정된 개수의 integer(하나 또는 여러 개)를 사용합니다. 반면 Arbitrary-precision은 필요에 따라 임의 개수의 integer를 사용하여, 한편으로는 왼쪽으로 확장해 오버플로를 피하고, 다른 한편으로는 오른쪽으로 확장해 정밀도 손실을 피합니다. 분명히 이렇게 하려면 힙 메모리 할당이 필요하므로 타입은 Copy 가 아니고, crate도 no-alloc 이 아닙니다. 또한 모든 연산도 매우 느립니다. 명확한 요구가 없다면 일반적으로 Fixed-size를 우선 선택하는 것이 좋습니다.
비교와 벤치마크를 위해 몇 가지 decimal crate를 선택합니다.
Floating-point Arbitrary-precision
활발히 유지되는 유일한 Arbitrary-precision crate입니다. 내부적으로 mantissa를 표현하기 위해 u64 또는 u32의 Vector를 사용합니다. 메모리 레이아웃은 다음과 같습니다.
+-u64----+--------+--------+--------+--------+
| sign | Vec<u64> | scale |
+--------+--+-----+--------+--------+--------+
|
+--------+--------+----
| u64 | … |
+--------+--------+----
메타 정보만으로 5 word, 총 40바이트를 차지하므로 메모리 배치가 꽤 느슨합니다. 생성 시와 필요에 따른 확장 시 메모리 할당이 필요하고, 읽기/쓰기 시 포인터를 따라가야 하므로 성능도 좋지 않습니다. 아래 벤치마크에서 이를 분명히 볼 수 있습니다.
즉 이 crate는 Arbitrary-precision을 추구하는 대신 메모리와 성능을 포기한 셈입니다.
Floating-point Fixed-size
Decimal 정의는 다음과 같습니다: struct Decimal<const N: usize> . 여기서 N은 mantissa를 표현하는 u64의 개수입니다. 예를 들어 Decimal<2> 는 u64 두 개, 즉 총 128bit의 mantissa를 가집니다. 그래서 문서에서도 자신을 Arbitrary-precision 이라고 말합니다. 차이는 bigdecimal은 런타임에 조정하고, fastnum은 컴파일 시점에 지정한다는 점입니다.
Decimal의 메모리 레이아웃은 다음과 같습니다.
+-u64----+--------+...+--------+
| [u64; N] | CBlock |
+--------+--------+...+--------+
여기서 CBlock 은 fastnum 이 메타 정보를 저장하기 위해 사용하는 ControlBlock 으로, 8바이트입니다. 기본적인 sign과 scale 외에도 다른 필드가 있으며, 자세한 내용은 문서 를 참고하면 됩니다.
또한 fastnum 은 f32/f64 가 제공하는 과학 계산 메서드들, 예를 들어 sin/cos, sqrt, log 등을 많이 제공합니다. 이것도 다른 decimal crate에는 없는 기능입니다. 다만 개인적으로는 이런 기능이 그다지 타당하지 않다고 생각합니다. 사람들이 decimal을 쓰는 이유는 십진 소수를 정확하게 표현하기 위해서입니다. 그런데 이런 과학 계산의 결과는 대개 무리수이므로 정확히 표현할 수 없습니다. 따라서 이런 연산이 필요한 경우(금융 분야라도, 예를 들어 가격 예측에 복잡한 수식이 필요한 경우)는 decimal이 적합하지 않고, 훨씬 빠른 binary, 즉 f32/f64 를 써야 합니다.
문서에서는 blazing fast 라고 주장하지만, 제시한 벤치마크 비교 대상은 원래부터 매우 느린 bigdecimal 입니다. 이 글 아래의 벤치마크에서는 선택된 다른 crate들과 비교할 때 가장 느립니다. 다만 fastnum 스스로도 자신을 Arbitrary-precision 계열로 본다면 비교 대상이 bigdecimal 인 셈입니다.
그리고 문서는 정말 자세합니다.
Floating-point Fixed-size
가장 인기 있는 decimal crate입니다. 다운로드 수, 의존하는 crate 수, 생태계 연동(예: serde, postgres 등)의 풍부함 어느 쪽으로 보아도 가장 대중적입니다. 또한 가장 오래된 decimal crate 중 하나로, 첫 버전은 2016년 말에 공개되었습니다. 오래되었다는 점 자체가 인기를 얻은 큰 이유일 수도 있습니다.
128-bit 부호 있는 decimal만 지원합니다. 메모리 레이아웃은 다음과 같습니다.
+-u32--+------+------+------+
| flag | high | mid | low |
+------+------+------+
mantissa는 3개의 u32(위 그림의 high, mid, low)로 구성되어 총 96bit이며, 대략 28자리 십진수를 표현합니다. 연산 시에는 이 3개의 u32를 순서대로 처리해야 하므로 속도가 아주 빠르지는 않습니다. 메타 정보인 flag에는 1bit의 sign과 5bit의 scale이 들어 있으며, scale 범위는 [0-28] 입니다. flag의 나머지 bit는 예약되어 있습니다.
문서에서는 이 메모리 레이아웃이 성능 최적화 를 위한 것이라고 말합니다. 하지만 아래 벤치마크 비교에서 rust_decimal 의 성능은 최고가 아닙니다. 당시 이런 메모리 구조를 택한 것은, 아마도 그 시절 Rust가 128-bit 정수를 지원하지 않았기 때문일 것입니다.
rust_decimal 의 API에서도 초기에 i128 지원이 없던 흔적을 볼 수 있습니다. 예를 들어 i64 로부터의 생성 함수는 new 라고 부르지만, i128 로부터의 생성 함수는 from_i128_with_scale 라고 하며, 이는 나중에 추가된 것으로 보입니다.
Floating-point Fixed-size
rust_decimal 과 완전히 같은 포지션입니다. 장점은 다음과 같습니다. 1. 더 빠릅니다. 아래 벤치마크 결과를 참고하세요. 2. 타입이 더 많습니다. 128/64/32-bit, 부호 있음/없음 모두 제공합니다. 3. 메모리가 더 촘촘하고, 유효숫자도 더 많습니다.
단점은 아주 새로운 crate라서 rust_decimal 만큼 생태계 연동이 많지 않다는 점입니다.
이 crate를 선택한 이유 중 하나는 제가 그 작성자이기 때문입니다.
이 crate는 단일 integer로 표현합니다. 128-bit 부호 있는 타입을 예로 들면 메모리 레이아웃은 다음과 같습니다.
+-u128-----------------------+
|S|scale| mantissa |
+----------------------------+
부호(S)와 scale은 각각 1bit와 5bit를 차지하므로 mantissa는 122bit, 즉 대략 36자리 십진 유효숫자를 가질 수 있습니다. 이는 rust_decimal 의 28자리보다 큽니다. 연산도 3개의 u32가 아니라 1개의 u128을 사용하므로 더 빠릅니다.
Fixed-point Fixed-size
이 글에서 선택한 유일한 Fixed-point crate입니다. 위의 몇 가지 crate와 가장 큰 차이도 바로 Fixed-point라는 점이며, 이에 대해서는 위의 고정소수점과 부동소수점 절에서 이미 설명했습니다.
다른 Fixed-point decimal crate와 비교했을 때, 이 crate의 가장 큰 특징은 위에서 본 전형적인 FixedPoint 타입 정의 방식(상수 제네릭 const generics를 사용해 컴파일 시점에 소수 자릿수를 고정) 외에도 Out-of-band scale 방식을 제공해 런타임에 타입의 scale을 지정할 수 있다는 점입니다. 이를 통해 더 큰 유연성을 제공합니다. 예를 들어 여러 통화를 다루는 자금 관리 시스템에서, 위의 전형적인 FixedPoint 타입을 사용하면 모든 통화가 같은 소수 정밀도로 제한됩니다. 예를 들어 type Balance = FixedPoint<2> 로 정의하면 모든 통화의 소수 정밀도가 2자리가 됩니다. 반면 이 crate의 Out-of-band scale 타입을 사용하면 각 통화마다 서로 다른 소수 정밀도를 정의할 수 있습니다. 자세한 내용은 Out-of-band 문서 를 참고하세요.
scale은 데이터 타입과 바인딩되므로(상수 제네릭 const generics를 쓰든, Out-of-band를 쓰든), 인스턴스에 scale을 저장할 필요가 없습니다. 따라서 인스턴스에는 mantissa만 저장됩니다. 128-bit 부호 있는 타입을 예로 들면 메모리 레이아웃은 다음과 같습니다.
+-i128-----------------------+
| signed-mantissa |
+----------------------------+
이 crate에는 구현 세부 사항에서도 차이가 하나 더 있는데, 부호 있는 mantissa를 사용합니다. 반면 이 글에서 선택한 다른 crate들은 모두 부호 비트와 mantissa를 분리해 처리합니다. 이 차이 역시 부동소수점과 고정소수점의 차이에서 비롯되지만, 여기서는 자세히 설명하지 않겠습니다. 여기서 알아둘 점은 이 crate의 mantissa가 1bit 적어 127-bit라는 것입니다.
이제 메모리 사용 효율의 관점에서도 비교해보겠습니다. mantissa 외의 정보를 메타 정보라고 부르면, 위 crate들의 메타 정보 크기는 다음과 같습니다.
스포일러를 하자면, 이 순위는 아래 벤치마크 순위와 일치합니다.
이제 이 글의 핵심인 벤치마크 비교 결과로 넘어가겠습니다. 벤치마크에는 criterion 을 사용합니다. 프로젝트 소스 코드는 GitHub 에 있습니다.
벤치마크는 3대의 머신에서 수행했습니다. 운영체제와 CPU는 각각 다음과 같습니다.
벤치마크 결과는 환경에 따라 다소 다릅니다. 단순화를 위해 이 글에서는 첫 번째 환경(AMD EPYC CPU)만 골라 보여주고 설명합니다. 다른 테스트 결과에 관심이 있다면 전체 결과 를 참고하세요. 독자가 자신의 머신에서 직접 벤치마크를 실행해보는 것도 환영합니다. 실행 방법은 프로젝트 설명을 참고하면 됩니다.
위의 crate들 외에도 Rust 내장 f64 를 비교 대상으로 추가했습니다. f128 은 아직 안정화되지 않았기 때문에 벤치마크에는 포함하지 않았습니다. 다만 개인적인 테스트에서는 f128 이 f64 와 거의 같은 속도를 보였습니다.
주로 128-bit와 64-bit 부호 있는 타입을 테스트합니다. 다만 다음 사항은 설명이 필요합니다. bigdecimal 은 가변 길이이므로 길이 개념이 사실상 의미 없고, fastnum 은 더 긴 타입도 지원할 수 있는데 여기서는 그것을 충분히 활용하지 않았으며, rust_decimal 은 128-bit만 지원하고 64-bit는 지원하지 않습니다.
선택한 벤치마크 케이스는 다음과 같습니다.
뺄셈은 덧셈과 동일하므로 여기서는 반복해서 테스트하지 않습니다.
두 피연산자의 선택에 대해 설명하겠습니다. 서로 다른 벤치마크 케이스에서는 각각의 요구에 따라 두 피연산자의 scale을 다르게 선택하며, 자세한 내용은 아래에서 설명합니다. 반면 피연산자의 mantissa는 통일되어 있습니다(정확히 말하면 덧셈의 두 피연산자, 곱셈의 두 피연산자, 나눗셈의 피제수는 통일됨). 모두 10의 거듭제곱이며, 지수적으로 증가합니다. 예를 들어 그래프의 가로축 3은 피연산자가 1e3임을 뜻합니다.
crate마다 mantissa 비트 수가 달라 표현 가능한 숫자 범위도 다르고, 따라서 그래프에서 각 선의 길이도 다릅니다. 구체적으로는 다음과 같습니다.
bigdecimal 은 임의 정밀도지만, 여기서는 겸손하게 128bit만 사용하여 38자리 십진수에 해당합니다.fastnum:128 은 완전한 128bit mantissa를 가지므로 역시 38자리입니다.prim-fpdec:128 은 127bit mantissa지만 십진수로 바꾸면 역시 38자리이므로 위와 같은 길이입니다.decimax:128 은 122bit mantissa로 36자리에 해당합니다.rust_decimal 은 96bit mantissa를 가져 128-bit 타입 중 가장 짧으며, 28자리뿐입니다.아래에서 자세히 살펴보겠습니다.
덧셈 연산의 흐름은 다음과 같습니다. 먼저 두 피연산자의 scale이 같은지 판단합니다. 같으면 mantissa를 바로 더하고, 다르면 먼저 scale을 정렬한 뒤 더합니다.
이 절에서는 먼저 scale이 같은 경우를 봅니다. 다음 절에서는 scale이 다른 경우를 봅니다.
단순화를 위해 두 피연산자는 같은 값으로 선택합니다. 피연산자의 scale은 테스트에 영향을 주지 않으므로 10으로 고정합니다. 피연산자의 mantissa는 작은 값에서 큰 값까지 10의 거듭제곱입니다.
그래프는 다음과 같습니다.
예상대로 bigdecimal 은 훨씬 위에 있습니다. 다른 crate들은 전부 아래쪽에 몰려 잘 보이지 않습니다. 그래서 여기서는 일단 bigdecimal 을 빼고 나머지만 보겠습니다.
이제 훨씬 선명해졌습니다.
먼저 128-bit를 보면, fastnum:128 이 가장 느리고, 그다음이 rust_decimal, 그다음이 decimax, 그리고 prim-fpdec:128 이 가장 빠릅니다. 앞의 3개는 부동소수점이므로 덧셈 전에 두 피연산자의 scale이 같은지 먼저 판단해야 합니다. 이 판단 자체가 매우 느려 전체 덧셈 속도를 끌어내립니다. 반면 prim-fpdec:128 은 고정소수점이므로 흐름이 정수 두 개의 덧셈뿐이고, 거의 CPU 명령 하나에 가깝기 때문에 매우 빠릅니다.
64-bit를 보면 fastnum:64 는 fastnum:128 보다 약간 빠르고, decimax:64 는 decimax:128 과 비슷하며, prim-fpdec:64 도 prim-fpdec:128 과 비슷합니다.
또한 대부분의 선은 안정적인 반면, rust_decimal 과 fastnum:64 에만 뚜렷한 점프가 두 번 보입니다. 다만 원인은 서로 다릅니다.
rust_decimal 의 점프는 내부적으로 3개의 u32 로 숫자를 표현하기 때문입니다. 작은 mantissa(1개의 u32, 9자리 숫자)에서는 덧셈 1번이면 되지만, 큰 mantissa는 3번의 덧셈이 필요하므로 가로축 9 위치에서 점프가 발생합니다.
fastnum:64 의 점프는 64bit mantissa로 최대 19자리 십진 유효숫자만 표현할 수 있기 때문입니다. 테스트에 사용한 값들은 모두 10의 거듭제곱이므로, 여기서는 1e19에 해당합니다. 이를 더하면 2e19가 되어 64bit 표현 범위(약 1.84e19)를 넘습니다. 부동소수점의 특성상 이 경우 부동소수점 값을 조정해야 하며, 구체적으로는 mantissa /= 10; scale += 1; 을 수행합니다. 그런데 매우 느린 나눗셈을 사용하므로 전체 덧셈이 매우 느려져 큰 점프가 나타납니다. 다른 부동소수점 crate에서도 같은 일이 생길 수 있지만, 이 테스트에서는 마주치지 않았을 뿐입니다. 반면 고정소수점 crate에서는 scale을 조정할 수 없으므로 이런 경우 오버플로 오류가 납니다.
이제 두 피연산자의 scale이 다른 경우를 보겠습니다.
여기서는 고정소수점 타입을 테스트할 수 없으므로 primitive_fixed_point_decimal 은 제외합니다.
mantissa를 더하기 전에 먼저 두 피연산자의 scale을 정렬해야 합니다. 먼저 scale이 더 작은 쪽을 조정하려 시도하며, 그 mantissa에 10의 어떤 거듭제곱을 곱합니다. 이 곱셈이 오버플로되지 않으면 끝입니다. 그렇지 않으면 절충된 scale을 선택해 두 scale을 모두 그 값으로 조정해야 합니다. 하나는 키우고 하나는 줄여야 하며, 키우는 쪽은 mantissa 곱셈이 필요하고, 줄이는 쪽은 mantissa 나눗셈이 필요합니다.
테스트에서는 두 피연산자의 scale을 각각 10과 0으로 설정하여 차이를 10으로 두었습니다. 따라서 위 정렬의 첫 단계에서 mantissa는 1e10을 곱해야 합니다. 즉 mantissa가 1e(MAX_SCALE - 10)까지 커지면 이 곱셈이 오버플로되어 두 번째 단계가 발동하고, 나눗셈이 들어가면서 더 느려집니다.
그래프는 다음과 같습니다.
bigdecimal 은 여전히 매우 높습니다. 다시 일단 제외하고 다른 crate들만 보겠습니다.
위 테스트 케이스(같은 scale의 덧셈)와 비교하면, 여기서는 절대 시간 자체가 훨씬 느립니다. 이유는 scale 정렬 때문입니다. 또한 앞서 설명했듯 모든 선이 후반부에서 점프를 보입니다. 이 가운데 rust_decimal 의 점프 폭이 가장 크며, 시간이 이전의 3배(15ns->45ns)까지 증가하고, 점프 이후에는 매우 불안정해집니다. fastnum:128 의 점프 폭은 중간 정도이고, decimax:128 이 가장 작습니다.
소요 시간 순위(앞에 있을수록 더 느림):
fastnum:128>rust_decimal>decimax:128rust_decimal>fastnum:128>decimax:128이제 곱셈을 보겠습니다.
곱셈은 두 부분으로 이루어집니다. 1. mantissa 곱셈, 2. scale 덧셈입니다. 이 두 단계 모두에서 오버플로가 발생할 수 있습니다. 어느 쪽에서든 오버플로가 나면 두 번째 단계로 들어가 mantissa와 scale을 함께 줄여 오버플로를 피해야 합니다. 여기에는 나눗셈이 포함되므로 전체 연산이 훨씬 느려집니다.
테스트에서는 두 피연산자를 같은 값으로 두고, mantissa는 여전히 지수적으로 증가시킵니다. decimal 곱셈 자체의 오버플로를 피하기 위해 scale도 함께 증가시켜 피연산자의 값은 항상 1이 되도록 했습니다.
mantissa가 절반 정도 커지면 mantissa 곱셈 오버플로가 발생하고, 두 번째 단계 즉 mantissa와 scale을 함께 줄이는 단계가 발동합니다.
그래프는 다음과 같습니다.
bigdecimal 이 여전히 매우 높은 것 외에도, fastnum 의 두 선 역시 후반부에서 매우 높아집니다. 다른 crate들의 상황을 더 잘 보기 위해 여기서는 bigdecimal 전체 선과 fastnum 두 선의 후반부를 제거합니다. 그러면 다음 그림이 됩니다.
이 그래프도 여전히 다소 복잡합니다. 차근차근 정리해보겠습니다.
위에서 설명한 이유(즉 mantissa 곱셈 오버플로) 때문에 대부분의 선은 각자 중간쯤에서 점프를 보입니다. 먼저 128-bit 타입의 점프 이후 구간을 봅시다.
fastnum:128 은 방금 fastnum 두 선의 후반부를 제거했지만, 위의 전체 그래프에서 보면 점프 이후 시간 증가 속도가 매우 빠릅니다. 즉 매우 빠른 속도로 더 느려집니다.rust_decimal 은 여러 번 점프하는데, 이것도 내부적으로 3개의 u32 로 mantissa를 표현하기 때문으로 보입니다.decimax 와 prim-oob-fpdec:128 은 훨씬 안정적이고, 위의 두 crate보다 훨씬 빠릅니다.이제 점프 이전 구간을 보겠습니다. 여기서는 조금 더 주의 깊게 봐야 합니다.
fastnum:128 과 rust_decimal 은 점프 이전(각각 x=19와 x=14)까지는 둘 다 매우 안정적이며, 다만 전자가 더 늦게 점프합니다.decimax 와 prim-oob-fpdec:128 은 점프 이전(각각 x=15와 x=19)에도 안정적일 뿐 아니라 매우 빠릅니다.자세히 보면 여기서 primitive_fixed_point_decimal crate가 두 타입, 즉 prim-oob-fpdec:128 과 prim-const-fpdec:128 으로 나뉘어 있고, 위에서는 전자만 소개했지 후자는 소개하지 않았다는 점을 알 수 있습니다. 이것 역시 Fixed-point 때문입니다. 이 절 처음에서 소개한 곱셈 흐름(mantissa 곱셈, scale 덧셈)은 부동소수점 타입을 기준으로 한 것입니다. 하지만 고정소수점 타입에서는 곱셈 결과의 scale이 프로그래밍 시점에 지정되어 있으므로, 두 피연산자의 scale을 더한 뒤 다시 목표 scale로 조정해야 합니다. 이는 위에서 말한 scale 덧셈 오버플로 상황과 유사합니다. 다시 말해, 부동소수점에서는 후반부에서야 발동하는 두 번째 단계가 고정소수점에서는 전 구간에서 발동합니다. 이는 고정소수점 타입에 다소 불공평합니다. 다행히 primitive_fixed_point_decimal crate는 더 유연한 Out-of-band Scale 타입을 제공하여 런타임에 목표 scale을 지정할 수 있습니다. 곱셈 결과의 scale을 두 피연산자의 scale 합으로 지정하면 전반부에서는 두 번째 단계를 피할 수 있어 부동소수점과 공정하게 비교할 수 있습니다. 이것이 바로 위의 prim-oob-fpdec:128 입니다.
하지만 이것은 고정소수점의 실제 사용 시나리오가 아닙니다. Out-of-band Scale 타입도 이 테스트를 위해 설계된 것이 아닙니다. 일단 공정성은 잠시 내려놓고, 고정소수점의 실제 사용 방식, 즉 결과 scale이 고정되어 전 구간에서 두 번째 단계가 발동하는 조건으로 prim-const-fpdec:128 테스트 케이스를 설정했습니다. 그래프를 보면 prim-const-fpdec:128 의 전반부는 여기서 가장 느리지만, 후반부에 들어가면 공정해지고 prim-oob-fpdec:128 과 같아지며 가장 빨라집니다.
그렇다면 mantissa가 작은 경우(전반부)의 곱셈에서는 고정소수점이 부동소수점보다 느리다는 뜻일까요? 이 케이스만 놓고 보면 실제로 그렇습니다. 하지만 조금 더 긴 관점에서 보면 그렇지 않습니다. 부동소수점이 더 빠른 이유는 scale과 mantissa를 조정하지 않았기 때문이고, 그 결과 scale과 mantissa가 더 커집니다. 이 글의 대부분 테스트에서 같은 연산이라도 scale과 mantissa가 커질수록 더 느려진다는 점을 볼 수 있습니다. 따라서 이 곱셈 결과가 최종 결과라서 더 이상 어떤 연산도 하지 않고(심지어 문자열로 출력도 하지 않고) 끝나는 경우가 아니라면, 여기서 부동소수점이 빠른 만큼의 이득은 다른 곳에서 다시 상쇄됩니다.
위에서는 128-bit 타입을 소개했고, 64-bit 타입도 비슷하므로 여기서는 생략합니다.
나눗셈에는 몇 가지 특징이 있습니다.
전반적인 느낌은, 전체 응용의 5%에 해당하는 시나리오를 위해 95%의 노력을 들이지만 그래도 잘 되지 않는다는 것입니다. 개발에서도, 벤치마크에서도 그렇습니다. 그래서 이 글에서는 가장 단순한 두 경우(나누어떨어짐 / 나누어떨어지지 않음)만 골라 벤치마크와 비교를 하며, 완전하거나 완벽하게 공정한 비교는 추구하지 않습니다.
Floating-point의 경우 몫의 scale은 고정되지 않고 상황에 따라 달라집니다. 이 상황은 두 가지로 나눌 수 있습니다. 나누어떨어지는 경우와 그렇지 않은 경우입니다. 이 절에서는 전자를 다루고, 다음 절에서 후자를 다룹니다.
나누어떨어지는 경우를 부동소수점 기준으로 다시 나누면 두 가지입니다.
200 과 25 라면 바로 나눌 수 있습니다. 이것이 가장 단순합니다.2 와 25 라면 바로는 나누어떨어지지 않지만, 피제수 mantissa를 200 으로 조정하면 나눌 수 있습니다. 문제는 처음부터 몇 자리를 조정해야 하는지(즉 10의 몇 제곱을 곱해야 하는지), 심지어 최종적으로 나누어떨어질 수 있는지조차 알 수 없다는 점입니다. 그래서 일반적인 방법은 먼저 최대한 조정해보고, 나눗셈 후 다시 줄이는 것입니다. 예를 들어 위의 경우 2 는 20000000000 으로 조정될 수 있고(구체적인 조정은 scale에 따라 달라짐), 그 뒤 25 로 나누면 800000000 이 되고, 마지막에 뒤의 0을 제거해 8 로 조정합니다. 이 뒤의 0 제거 작업 역시 미리 결정할 수 없어서 단계적으로 시도해야 합니다. 따라서 이 케이스는 매우 느릴 수 있습니다.이 두 경우를 모두 포함하기 위해 테스트에서는 제수를 1e8로 고정하고, 피제수는 여전히 10의 거듭제곱으로 증가시켰습니다. 그러면 가로축 x=8 이전에서는 피제수가 작아서 조정 후에야 나누어떨어지므로 case2에 해당해 매우 느리고, 그 이후에는 case1이 되어 직접 나누어집니다.
반면 Fixed-point의 경우 몫의 scale이 지정되어 있으므로 위와 같은 상황 구분이 없습니다. 따라서 위 Floating-point 테스트 케이스에 그냥 맞춰 함께 측정하면 됩니다.
그래프는 다음과 같습니다.
먼저 Floating-point를 보면 가로축 x=8 이전까지는 모두 매우 느립니다. 이후에는 bigdecimal 이 여전히 느린 반면, rust_decimal, fastnum:128, decimax 는 훨씬 빨라집니다. 이들 사이의 상대적인 빠르기 관계는 여기서 자세히 분석하지 않겠습니다.
이제 Fixed-point를 보면, prim-fpdec:128 은 몫 정밀도를 판단할 필요가 없기 때문에 초반에는 빠릅니다. 후반에는 mantissa가 커지면서 느려집니다.
이제 나누어떨어지지 않는 경우를 보겠습니다. 앞서 말했듯 나누어떨어지는지 여부는 Floating-point에서만 의미가 있으며, Fixed-point에서는 차이가 없습니다. 따라서 이 테스트 케이스에서 Fixed-point 결과는 위와 같아야 합니다.
그래프는 다음과 같습니다.
bigdecimal 은 여전히 매우 높습니다. 다시 일단 빼고 다른 crate의 결과만 보겠습니다.
각 crate의 나누어떨어지는 경우와 비교해도 양상이 서로 다릅니다.
bigdecimal, fastnum:128, rust_decimal 은 전 구간에서 나누어떨어지는 경우보다 훨씬 느립니다.decimax:128 은 전 구간에서 훨씬 빠르고, 매우 안정적입니다.prim-fpdec:128 은 Fixed-point이므로 나누어떨어지는 경우와 같습니다.각각의 이유는 해당 crate의 코드를 분석해야 알 수 있을 것 같습니다. 여기서는 더 논의하지 않겠습니다.
일부 예외를 제외하면, 전반적으로 이들 crate의 속도 비교는 다음과 같습니다(앞에 있을수록 더 느림).
bigdecimal «fastnum<rust_decimal<decimax<primitive_fixed_point_decimal
또한 Floating-point의 연산 경로는 구체적인 피연산자에 따라 달라지므로 성능도 안정적이지 않습니다. 반면 Fixed-point는 상대적으로 고정적이고 예측 가능하며, 위 그래프에서도 대부분 수평선에 가까운 형태를 보입니다.
다시 강조하지만, 이들 crate는 각자 중점을 두는 부분이 다르므로 성능만 비교하는 것은 공정하지 않습니다.
이 글에서는 decimal crate의 몇 가지 유형을 소개하고, 몇몇 crate를 골라 벤치마크와 비교를 수행했습니다.
이를 바탕으로 다음과 같이 추천할 수 있습니다.
bigdecimal 만 선택할 수 있으며, 그 대가로 Copy 가 불가능하고 매우 느립니다.fastnum 만 선택할 수 있습니다. 여기서는 128-bit를 넘는 타입의 성능은 테스트하지 않았지만, 아마 아주 빠르지는 않을 것입니다. 관심 있는 독자는 이 프로젝트 코드를 수정해 벤치마크해볼 수 있습니다.primitive_fixed_point_decimal 만 선택할 수 있습니다. 부동소수점 타입보다 사용은 약간 복잡하지만, 더 높고 더 안정적인 성능을 제공합니다.rust_decimal 또는 decimax 를 선택할 수 있습니다. 간단하고 편리합니다. 전자는 더 나은 생태계를, 후자는 더 빠른 속도를 제공합니다.