Rust Decimal 库的比较与基准测试
摘要
一篇详细的技术文章,比较和基准测试了多种 Rust Decimal 库,涵盖了定点数与浮点数、固定精度与任意精度设计。
<p><a href="https://lobste.rs/s/odh8si/comparison_benchmarking_rust_decimal">评论</a></p>
查看缓存全文
缓存时间: 2026/06/15 09:06
# Rust Decimal 库对比与基准测试
来源:https://wubingzheng.github.io/en/Decimal-Crates-Comparison.html
## WuBingzheng (https://wubingzheng.github.io/)
> 我的英语不太好,所以这篇文章是由 AI 辅助翻译的。中文版在此:https://wubingzheng.github.io/zh/Decimal-Crates-Comparison.html
众所周知,由于 2 和 10 没有相同的质因数,二进制小数无法精确表示十进制小数。例如,`f64` 有一个经典的算术错误:`0.1 + 0.2 != 0.3`。某些应用场景(如金融)需要精确表示十进制小数,因此需要 decimal 库。它们用整数表示尾数,并附带一个表示小数位数的 scale。例如,值 `1.23` 可以用整数 `123` 和 `scale = 2` 来表示。
Rust 生态中有许多 decimal 库,各自设计不同,存在各种权衡。它们的差异主要体现在两个维度:
1. scale 是固定的还是可变的——对应**定点算术 (Fixed-point)** (https://en.wikipedia.org/wiki/Fixed-point_arithmetic) 与**浮点算术 (Floating-point)** (https://en.wikipedia.org/wiki/Floating-point_arithmetic)。
2. 整数的个数是固定的还是任意的——对应**固定精度 (Fixed-precision)** (https://en.wikipedia.org/wiki/Fixed-precision_arithmetic) 与**任意精度 (Arbitrary-precision)** (https://en.wikipedia.org/wiki/Arbitrary-precision_arithmetic)。
本文选择了一些库进行对比和基准测试。
目录:
- 前两节(定点与浮点 (https://wubingzheng.github.io/en/Decimal-Crates-Comparison.html#fixed-point-and-floating-point)、固定大小与任意精度 (https://wubingzheng.github.io/en/Decimal-Crates-Comparison.html#fixed-size-and-arbitrary-precision))介绍这些类别的特点。其中没有特别新的内容,有经验的读者可以跳过。
- 接下来一节(选择库 (https://wubingzheng.github.io/en/Decimal-Crates-Comparison.html#choosing-crates))介绍几个 decimal 库。
- 最后一节(基准比较 (https://wubingzheng.github.io/en/Decimal-Crates-Comparison.html#benchmark-comparison))是本文的重点,对这些库进行基准测试和对比。
## 定点与浮点
**定点算术 (Fixed-point)** (https://en.wikipedia.org/wiki/Fixed-point_arithmetic) 与 **浮点算术 (Floating-point)** (https://en.wikipedia.org/wiki/Floating-point_arithmetic)。在定点算术中,scale 是固定的,并且绑定在类型上。在浮点算术中,scale 是可变的,并存储在每个实例中。
用代码来说明。典型的定点类型定义可能如下:
```rust
struct FixedPoint(i128); // scale 绑定在类型上
```
典型的浮点 decimal 类型可能如下:
```rust
struct FloatingPoint {
mantissa: i128,
scale: i32, // scale 存储在每个实例中
}
```
这清楚地表明,定点数具有固定的小数位数,而浮点 decimal 具有可变的小数位数。例如,`FixedPoint<2>` 总是有 2 位小数,而 `FloatingPoint` 的精度取决于每个实例的 scale。
由于这一区别,定点数和浮点数表现出以下不同:
1. 定点数的表示范围较小,而浮点数可以表示更大的范围。这是因为浮点数在数值变大时会牺牲小数精度。
2. 定点算术更简单、更快,而浮点算术更复杂、更慢。例如,定点加法只需对尾数进行整数加法。浮点加法必须先检查两个数的 scale 是否相等(这个检查本身可能就已经比加法更慢),如果不相等,则通过乘法对齐 scale。这一点将在基准测试部分详细讨论。
3. 定点算术用起来有些麻烦,而浮点算术更方便。例如,上述 `FixedPoint` 类型必须在编译时为每种类型确定 scale,例如 `Balance` 或 `Price` 应该有多少位小数。浮点 decimal 则不需要考虑这一点。两者之间的区别有点像静态类型语言与动态类型语言的区别。
大多数应用使用 decimal 库只是为了精确表示十进制小数,对性能或严格的小数精度要求并不特别高。在这种情况下,通常为了便利而选择浮点 decimal。然而,对于更为严肃的服务(尤其是许多金融系统),需要严格的小数精度或高性能时,推荐使用定点 decimal。例如,美元资产应恰好有 2 位小数,不多也不少。
注意:由于编程语言中的内置浮点类型(如 C 的 `float` 和 `double`,或 Rust 的 `f32` 和 `f64`)通常被称为“浮点”,而这些类型无法精确表示十进制小数,许多人错误地认为“浮点”天生就无法精确表示十进制小数。这是错误的!更准确地说,这些是“二进制浮点数”。不能精确表示十进制小数是因为“二进制”部分,而不是“浮点”部分。由于人们常常省略“二进制”这个词,浮点算术不公平地背了锅。实际上,即使是**二进制定点**类型(如 `fixed` (https://docs.rs/fixed/latest/fixed/) 库)也无法精确表示十进制小数。只要一个库是基于十进制的,无论是定点还是浮点,它都能精确表示十进制小数。
注意:浮点算术有一个名为 IEEE 754 (https://en.wikipedia.org/wiki/IEEE_754) 的标准,它定义了二进制浮点格式(由 `f32`/`f64` 使用)和十进制浮点格式。然而,这个标准只是浮点算术的**一种**实现方式,而不是浮点算术的全部。也可能有其他实现。实际上,大多数 decimal 库并没有遵循 IEEE 754 的十进制格式。
## 固定大小与任意精度
**固定精度 (Fixed-precision)** (https://en.wikipedia.org/wiki/Fixed-precision_arithmetic) 与 **任意精度 (Arbitrary-precision)** (https://en.wikipedia.org/wiki/Arbitrary-precision_arithmetic)。
首先,需要澄清这里“精度”一词的含义。该词有两种矛盾的含义:
- 小数位数
- 有效数字位数
例如,值 `1.23` 有 2 位小数,但有 3 位有效数字。两种含义都在广泛使用。例如,`std::fmt` (https://doc.rust-lang.org/std/fmt/index.html#precision) 使用前一种含义,而此处(固定精度 vs 任意精度)使用后一种含义。这是**标准术语** (https://en.wikipedia.org/wiki/Fixed-precision_arithmetic),但容易引起混淆。“固定精度”常被误解为固定小数位数,从而与定点算术混淆。为了避免歧义,本文使用术语 **固定大小 (Fixed-size)** 来代替 **固定精度 (Fixed-precision)**。
顾名思义,固定大小类型使用固定数量的整数(一个或多个)。任意精度类型则使用尽可能多的整数:向左扩展以避免溢出,向右扩展以避免精度损失。自然,这需要堆分配,意味着该类型不是 `Copy`,且库不是 `no-alloc`。所有操作也会显著变慢。除非有明确的任意精度需求,否则通常更推荐固定大小类型。
## 选择库
我们选择几个 decimal 库进行对比和基准测试:
- bigdecimal
- fastnum
- rust_decimal
- decimax
- primitive_fixed_point_decimal
### 库:bigdecimal
浮点 · 任意精度
这是目前唯一一个活跃维护的任意精度 decimal 库。内部使用 `Vec<u64>` 或 `Vec<i8>` 来表示尾数。其内存布局如下:
```
+-u64----+--------+--------+--------+--------+
| sign | Vec | scale |
+--------+--+-----+--------+--------+--------+
|
+--------+--------+----
| u64 | ... |
+--------+--------+----
```
元数据本身就占用了 5 个机器字,总共 40 字节,使得内存布局比较松散。由于创建和扩展时需要内存分配,访问时需要指针解引用,因此性能相对较差,这将在下文的基准测试中明显体现。总之,该库优先考虑任意精度,但牺牲了内存效率和性能。
### 库:fastnum
浮点 · 固定大小
其 `Decimal` 定义如下:
```rust
struct Decimal<const N: usize>([u64; N], ControlBlock);
```
这里 `N` 是用于表示尾数的 `u64` 数量。例如,`Decimal<2>` 使用两个 `u64`,即 128 位尾数。这就是为什么它的文档也称其为**任意精度 (Arbitrary-precision)** (https://crates.io/crates/fastnum/0.7.4)。区别在于,`bigdecimal` 在运行时调整精度,而 `fastnum` 在编译时确定精度。
内存布局如下:
```
+-u64----+--------+...+--------+
| [u64; N] | CBlock |
+--------+--------+...+--------+
```
`CBlock` 是一个 8 字节的 `ControlBlock`,`fastnum` 用它来存储元数据。除了符号和 scale 外,它还包含其他字段。详情请参考**文档** (https://docs.rs/fastnum/0.7.4/fastnum/#memory-layout)。
`fastnum` 还提供了许多通常出现在 `f32`/`f64` 中的科学函数,例如 `sin`、`cos`、`sqrt` 和 `log`。其他 decimal 库都不提供此类功能。我个人认为这些特性并不特别合理。人们使用 decimal 算术是为了精确表示十进制小数,而科学计算通常会产生无理数,无论如何都不能精确表示。需要这类操作(即使在金融领域,如定价模型)的场景,更适合使用快得多的二进制浮点类型(`f32`/`f64`)。
文档声称该库**速度快得惊人 (blazing fast)** (https://docs.rs/fastnum/0.7.4/fastnum/#why-fastnum),但其基准对比主要针对已经比较慢的 `bigdecimal`。在下文的基准测试中,与所选的其他库相比,`fastnum` 实际上是最慢的。不过,由于它自认为是任意精度,其预想对手可能确实是 `bigdecimal`。此外,它的文档极其详细。
### 库:rust_decimal
浮点 · 固定大小
Rust 生态中最流行的 decimal 库。从下载量、反向依赖以及生态系统集成(serde、postgres 等)来看,它是迄今为止使用最广泛的。它也是最古老的 decimal 库之一,最早发布于 2016 年底。古老的历史可能是它流行的主要原因。它只支持 128 位有符号小数。
内存布局:
```
+-u32--+------+------+------+
| flag | high | mid | low |
+------+------+------+------+
```
尾数由三个 `u32`(`high`、`mid`、`low`)组成,总共 96 位,大约相当于 28 个十进制数字。算术运算必须依次处理三个 `u32`,这会影响性能。`flag` 字段存储:
- 1 位符号
- 5 位 scale(范围 `[0, 28]`)
- 其余保留位
文档声称这种内存布局是出于**性能优化** (https://docs.rs/rust_decimal/1.41.0/rust_decimal/#comparison-to-other-decimal-implementations)。然而,下文的基准测试显示,`rust_decimal` 实际上并不是最快的。从历史上看,这种设计可能是因为 Rust 最初没有稳定的 128 位整数。API 也显示出 `i128` 时代之前的痕迹。例如,从 `i64` 构造的函数叫做 `new` (https://docs.rs/rust_decimal/latest/rust_decimal/struct.Decimal.html#method.new),而后来添加的 `i128` 构造函数则叫做 `from_i128_with_scale` (https://docs.rs/rust_decimal/latest/rust_decimal/struct.Decimal.html#method.from_i128_with_scale)。
### 库:decimax
浮点 · 固定大小
该库与 `rust_decimal` 本质上占据相同的生态位。优点:
- 更快(见下方基准测试)
- 更多类型:128/64/32 位,有符号和无符号
- 更紧凑的内存布局和更多的有效数字
缺点:
- 它是一个较新的库,缺乏 `rust_decimal` 那样广泛的生态系统集成。
选择该库的一个原因是,我是它的作者 :)
它使用单个整数表示。对于 128 位有符号类型,内存布局如下:
```
+-u128-----------------------+
|S|scale| mantissa |
+----------------------------+
```
符号 (`S`) 和 scale 分别占用 1 位和 5 位,剩下 122 位用于尾数,大约相当于 36 个十进制数字,显著多于 `rust_decimal` 的 28 位。算术运算使用单个 `u128` 而不是三个 `u32`,因此更快。
### 库:primitive_fixed_point_decimal
这是本文中唯一选择的定点库。它与其他库的主要区别正是它是定点类型的,这在前文**定点与浮点** (https://wubingzheng.github.io/en/Decimal-Crates-Comparison.html#fixed-point-and-floating-point) 中已经讨论过。
与其他定点 decimal 库相比,它最大的特点是,除了典型的 `FixedPoint` 风格(使用 const generics 在编译时固定小数位数)之外,它还提供了一种**带外 scale (Out-of-band scale)** 模式,允许在运行时指定 scale,从而获得更大的灵活性。例如,在一个多币种基金管理系统中,如果使用典型的 `FixedPoint` 类型,会迫使所有币种共享相同的小数精度。定义:
```rust
type Balance = FixedPoint<2>;
```
意味着所有币种都被限制为 2 位小数。而使用该库的“带外 scale”类型,每个币种可以定义自己的小数精度。详情请参阅**带外文档** (https://docs.rs/primitive_fixed_point_decimal/latest/primitive_fixed_point_decimal/#specify-scale)。
由于 scale 绑定在类型上(通过 const generics 或带外元数据),实例本身不需要存储 scale。因此,实例只存储尾数。对于 128 位有符号类型,内存布局如下:
```
+-i128-----------------------+
| signed-mantissa |
+----------------------------+
```
该库在另一个实现细节上也存在差异:它使用有符号尾数,而其他所有选定的库都分离了符号和尾数的处理。这种区别也源于定点算术与浮点算术之间的差异,但这里不再赘述。唯一值得注意的是,这使得尾数只有 127 位而不是 128 位。
### 内存对比
让我们通过查看元数据的大小来比较内存效率:
- bigdecimal:5 个机器字,40 字节
- fastnum:8 字节
- rust_decimal:4 字节
- decimax:6 位(对于 128 位有符号类型)
- primitive_fixed_point_decimal:1 位
剧透:这个排名与基准测试结果一致。
## 基准比较
现在我们来到本文的核心:基准测试结果。我们使用 **criterion** (https://crates.io/crates/criterion) 进行基准测试。项目源代码可在 **GitHub** (https://github.com/WuBingzheng/decimal-crates-comparison) 上获取。
基准测试在三台机器上运行:
- Ubuntu 22.04 @ AMD EPYC 9754
- Ubuntu 16.04 @ Intel Xeon, 2500 MHz
- macOS 13.5 @ Apple M1
不同环境下的结果略有不同。为简洁起见,本文只展示和分析第一台机器(AMD EPYC)的结果。对其他环境感兴趣的读者可以参考
相似文章
借助 Rust 新 API 实现更快的浮点运算
Rust 1.98 引入了一个新 API,通过允许更激进的编译器优化来加速浮点运算,同时仍让开发者控制舍入误差。
关于整数的思考 (2023)
一篇博客文章,讨论了各种编程语言中整数类型的设计,认为 Rust 强制要求显式指定大小和符号的做法优于那些有默认 `int` 类型的语言。
中间浮点精度
本文探讨了C++代码中的中间浮点精度如何依赖于编译器设置、CPU标志和架构,尤其是在x87 FPU上,以及这如何影响性能和计算结果。
Rust语言的性能
本次演讲分析了Rust相较于C++的性能优势与劣势,提供了基准测试和最佳实践。附有幻灯片和阅读材料。
RISC-V 与浮点运算
关于 RISC-V 架构浮点功能及更新的报告。