Introduction
ddust is a Rust crate of fixed-point decimals. Its Decimal, a Decimal<I, S>, is a whole number of steps of , held in one of Rust’s ten
integers, at a scale the compiler knows or one carried at run time. It is for
code in which a lost digit is a bug: prices, quantities, balances, fees and
rates, and the messages and files that carry them as text.
A binary float cannot hold 0.1. A sum of prices in f64 drifts, and writing one
back as text means guessing which decimal it was. A decimal holds 0.1 exactly,
and ddust’s keeps three promises:
- Nothing loses a digit unless a rounding mode says so.
a * bis exact, its scale the sum of the factors’;a / btruncates toward zero, as the integer’s does; and every other operation that can lose a digit takes a rounding mode as its last argument. - Scales never mix by accident. Two static scales are two types, so a
D64<2>plus aD64<4>does not compile. Two scales known only at run time line up exactly at the finer one, as SQL’sDECIMALdoes. - Every operator behaves as the integer’s. A
D64<2>is ani64and adds as one. Overflow panics with overflow checks on and wraps otherwise, and each operator has the integer’schecked_*,saturating_*,wrapping_*andoverflowing_*methods.
What It Holds
- The decimal,
Decimal, with an alias for each integer at a static scale:D8toD128andUD8toUD128.dec!writes a literal that is checked when the constant is evaluated. - Scales:
Fixed<D>, known to the compiler and zero-sized, andDynamic, carried at run time. - Rounding: nine modes in
round, each a zero-sized type, andRounding, a mode chosen at run time.Divisorprepares one divisor for many divisions. - Text: reading that is exact or refused, from a slice or in place inside a
message, and writing through
Displayor into a byte buffer, with no formatter. - Floats: the nearest
f64, correctly rounded, and a double to a decimal by a rounding mode. - Integrations, each behind a feature: serde, num-traits, bytemuck, zerocopy, rand, proptest, arbitrary and defmt.
The crate is #![no_std], allocates nothing, and has no dependencies unless a
feature asks for one.
What It Leaves Out
- Arbitrary precision. A decimal holds at most 38 decimals and its integer’s
range: about ±9.2 × 10¹⁶ at two decimals in an
i64, and about ±1.7 × 10²⁰ at eighteen in ani128. - A scale that each operation chooses. rust_decimal’s values carry their own scale, up to 28 decimals, and a quotient takes as many decimals as fit. In ddust a scale is chosen by the caller; a quotient is at the dividend’s scale, or at one its rounding names.
- NaN, the infinities and negative zero, which a float has and a decimal does not need.
- Square roots, logarithms, powers and trigonometry.
- Stable Rust. ddust builds on nightly, from
nightly-2026-09-28: its arithmetic, rounding and literals areconst fnthrough const traits, which are not stable yet.
Compared with Others says when another crate is the better choice.
This Book
The guide comes first: Getting Started, then a chapter for each idea in the order a program meets it, from Decimals and Scales to Serialization, and Choosing a Decimal. The reference follows: what each operation costs, the platforms and features, the crates ddust works with, testing code that uses it, and how it compares with the other decimal crates.
Getting Started
Add the crate:
cargo add ddust
ddust builds on nightly Rust, from nightly-2026-09-28, since its arithmetic is
const fn through const traits, which are not stable yet. A
rust-toolchain.toml beside the project’s Cargo.toml pins it:
[toolchain]
channel = "nightly-2026-09-28"
It needs no feature, and builds without std or an allocator.
Platforms and Features lists what each feature
adds.
A First Program
A price read exactly from its text and a quantity written as a literal make a notional, first exactly and then rounded to the cent:
fn a_notional_is_exact_and_rounds_only_when_asked() {
use ddust::round::HalfEven;
use ddust::{D64, dec};
let price: D64<2> = "64250.75".parse().expect("a price to the cent");
let quantity: D64<4> = dec!(0.0153);
let notional: D64<6> = (price * quantity).into();
assert_eq!(notional.to_string(), "983.036475", "exact: two decimals and four make six");
let billed = price.mul_round(quantity, HalfEven);
assert_eq!(billed, dec!(983.04), "to the cent, as asked");
}
Each type names its scale: the price has two decimals, the quantity four, and their product six, so the product is exact. Rounding happens only where a mode is named, here half to even: 983.036475 is billed as 983.04. Decimals and Scales shows what a scale is, and Rounding each mode.
Every listing in this book runs as a test:
cargo test -p ddust --all-features --test book
From there, the crate’s page, ddust, lists every type.
Decimals and Scales
A Decimal<I, S> is a whole number of steps, held in an
integer I, at a scale S that says how many decimals a step is. 12.34 at two
decimals is 1,234 steps of 0.01:
fn a_decimal_is_a_whole_number_of_steps() {
use ddust::{D64, Fixed, dec};
let price: D64<2> = dec!(12.34);
assert_eq!(price.steps(), 1_234, "1,234 steps of 0.01");
assert_eq!(price.decimals(), 2);
assert_eq!(D64::from_steps(1_234, Fixed::<2>), price);
assert_eq!(size_of::<D64<2>>(), size_of::<i64>(), "the scale takes no room");
}
The steps are the decimal’s whole state. Adding two values of one scale adds their steps, comparing them compares their steps, and nothing is normalized or rescaled behind the caller’s back.
The Integer
I is one of Rust’s ten integers, which Int names; the five signed ones are
Signed too. Each has an alias at a static scale of D decimals:
| Signed | Unsigned | Bytes | Digits every value has room for |
|---|---|---|---|
D8<D> | UD8<D> | 1 | 2 |
D16<D> | UD16<D> | 2 | 4 |
D32<D> | UD32<D> | 4 | 9 |
D64<D> | UD64<D> | 8 | 18, and 19 unsigned |
D128<D> | UD128<D> | 16 | 38 |
The scale splits those digits between the whole part and the fraction: a
D64<8> has room for ten whole digits and eight decimals, and a D128<18> for
twenty and eighteen. The integer’s range is the decimal’s, so a D64<2> runs
from −92,233,720,368,547,758.08 to 92,233,720,368,547,758.07.
The Scale
Fixed<D> is a scale the compiler knows: zero-sized, so a
decimal of it is its integer alone, and two values of it never differ in scale.
D is at most MAX_DECIMALS, 38, since
is the largest power of ten a 128-bit integer holds; a Fixed<39> fails the
build. Dynamic carries its scale at run time, in a byte beside the steps,
and has a chapter of its own, Run-Time Scales.
Two static scales are two types, so their values never meet until one is converted:
//! Two static scales are two types: their values never add.
use ddust::{D64, dec};
fn main() {
let (tenths, hundredths): (D64<1>, D64<2>) = (dec!(1.5), dec!(1.25));
let _sum = tenths + hundredths;
}
error[E0308]: mismatched types
--> tests/compile_fail/two_static_scales_never_mix.rs:7:25
|
7 | let _sum = tenths + hundredths;
| ^^^^^^^^^^ expected `1`, found `2`
|
= note: expected struct `Decimal<i64, Fixed<1>>`
found struct `Decimal<i64, Fixed<2>>`
Making a Decimal
A program reads a decimal from text, as Text shows, or writes it as a
literal. dec! takes its type from where it goes, or from a type named after
a colon, and is checked when the constant is evaluated: a literal with more
decimals than its scale, past its range, or negative for an unsigned decimal
fails the build. The value is the literal exactly, never rounded.
fn a_literal_is_checked_when_the_constant_is_evaluated() {
use ddust::{D64, D128, dec};
const FEE: D64<4> = dec!(0.0025);
const RATE: D128<18> = dec!(1.2e-5);
assert_eq!(FEE.steps(), 25);
assert_eq!(RATE.to_string(), "0.000012");
assert_eq!(dec!(-7.5: D64<1>).steps(), -75, "a type named after a colon");
}
From digits known at run time, new takes the digits,
their decimals and the scale, and makes
exactly, or refuses it with a
ConvertError: TooManyDecimals for a non-zero digit past the scale,
PosOverflow or NegOverflow past the range.
new_round rounds where new refuses, and
to_digits goes back:
fn digits_and_decimals_make_a_decimal_exactly() {
use ddust::round::HalfEven;
use ddust::{ConvertErrorKind, D64, Fixed, dec};
let half: D64<1> = D64::new(600_005, 1, Fixed).expect("one decimal fits one");
assert_eq!(half, dec!(60_000.5));
let refused = D64::new(600_005, 1, Fixed::<0>).expect_err("a decimal past the scale");
assert_eq!(refused.kind(), ConvertErrorKind::TooManyDecimals);
assert_eq!(D64::new_round(600_005, 1, Fixed::<0>, HalfEven), Ok(dec!(60_000: D64<0>)));
assert_eq!(half.to_digits(4), Ok(600_005_000), "back to digits, at four decimals");
}
from_steps and steps
are the raw representation, for storage and for types built on a decimal.
from_int makes a whole number, and
from_f64 reads a double, as Floats
shows.
Widths
A value moves to a wider integer at the same scale by From, since every value
fits, and back by TryFrom, which refuses one that does not.
widen moves to the integer twice as wide, from D8
to D16 up to D64 to D128, and the unsigned alike, where a product or more
digits have room:
fn a_value_moves_to_a_wider_integer_freely_and_back_by_a_check() {
use ddust::{ConvertErrorKind, D32, D64, dec};
let small: D32<2> = dec!(19.99);
let wide: D64<2> = small.into();
assert_eq!(D32::try_from(wide), Ok(small), "it fits again");
let large: D64<2> = dec!(30_000_000);
let refused = D32::<2>::try_from(large).expect_err("past an i32's 21,474,836.47");
assert_eq!(refused.kind(), ConvertErrorKind::PosOverflow);
}
Values of two widths never mix, as two integers do not:
//! Two integers are two types: values of two widths never add until one is converted.
use ddust::{D32, D64, dec};
fn main() {
let (wide, narrow): (D64<2>, D32<2>) = (dec!(1.5), dec!(1.25));
let _sum = wide + narrow;
}
error[E0308]: mismatched types
--> tests/compile_fail/two_widths_never_mix.rs:7:23
|
7 | let _sum = wide + narrow;
| ^^^^^^ expected `Decimal<i64, Fixed<2>>`, found `Decimal<i32, Fixed<2>>`
|
= note: expected struct `Decimal<i64, Fixed<2>>`
found struct `Decimal<i32, Fixed<2>>`
help: call `Into::into` on this expression to convert `Decimal<i32, Fixed<2>>` into `Decimal<i64, Fixed<2>>`
|
7 | let _sum = wide + narrow.into();
| +++++++
Products
A product’s scale is the sum of its factors’ decimals, so a product of two
decimals is exact. Its type is a Sum of the two scales,
which converts into the Fixed of their sum with its steps unchanged; a Fixed
of any other decimals fails the build:
fn a_product_takes_the_sum_of_its_factors_scales() {
use ddust::{D64, dec};
let (price, quantity): (D64<2>, D64<4>) = (dec!(1.25), dec!(0.0003));
let notional: D64<6> = (price * quantity).into();
assert_eq!(notional.steps(), 375, "1.25 × 0.0003 is 0.000375, exactly");
}
A product’s scale could be written in the type, Fixed<{ A + B }>, only with
the unstable and incomplete generic_const_exprs; Sum gives the same result
without it.
Arithmetic and Overflow
Values of one scale add, subtract, compare and take a remainder as their steps
do, so + on two D64<2>s is the i64’s add and nothing more. A value times
an integer, or divided by one, is a count of it:
fn values_of_one_scale_add_as_their_steps_do() {
use ddust::{D64, dec};
let (bid, ask): (D64<2>, D64<2>) = (dec!(99.98), dec!(100.02));
assert_eq!(ask - bid, dec!(0.04));
assert_eq!((bid + ask) / 2, dec!(100), "a mean, by a count");
assert_eq!(bid * 3, dec!(299.94));
assert_eq!(dec!(10.00: D64<2>) % dec!(3), dec!(1), "the remainder after a whole quotient");
assert!(bid < ask);
let fills: [D64<2>; 3] = [dec!(1.25), dec!(2.50), dec!(0.75)];
assert_eq!(fills.iter().sum::<D64<2>>(), dec!(4.5));
}
| Operation | Its scale | Exact |
|---|---|---|
a + b, a - b, a % b, -a | the operands’ | yes |
a * n and n * a, n an I | a’s | yes |
a * b | the sum of the factors’ | yes |
a / n, n an I | a’s | truncated toward zero |
a / b | the dividend’s | truncated toward zero |
==, < and the other compares | yes |
Each operator has its assigning form, += and the rest, and a decimal sums from
an iterator, starting from zero at its scale.
Division Truncates
A quotient is at the dividend’s scale, truncated toward zero as the integer’s
/ is:
fn a_quotient_keeps_the_dividends_scale_and_truncates() {
use ddust::{D64, dec};
let (budget, price): (D64<2>, D64<2>) = (dec!(100), dec!(3));
assert_eq!(budget / price, dec!(33.33), "toward zero, as the integer's `/`");
assert_eq!(-budget / price, dec!(-33.33));
assert_eq!(dec!(1: D64<2>) / 3, dec!(0.33), "by a count, likewise");
}
The operators never take a rounding mode, since they behave as the integer’s. To
round a quotient, or to take it at another scale, call
div_round or
div_round_to with a mode, as
Rounding shows. A quotient by zero panics in every build, as the
integer’s does.
Overflow
Past the range, an operator does what the integer’s does: it panics with
overflow checks on, as in a debug build, and wraps otherwise, as in a release
build. The profile’s overflow-checks decides, never debug-assertions:
fn past_the_range_an_operator_panics_with_overflow_checks_on() {
use ddust::{D64, dec};
let max: D64<2> = D64::MAX;
let _ = max + dec!(0.01);
}
A release build that should panic instead sets overflow-checks = true in its
profile. Where the range can be passed in either build, the integer’s four
families say what happens instead:
fn each_operator_has_the_integers_four_families() {
use ddust::{D64, dec};
let (max, cent): (D64<2>, D64<2>) = (D64::MAX, dec!(0.01));
assert_eq!(max.checked_add(cent), None);
assert_eq!(max.saturating_add(cent), D64::MAX);
assert_eq!(max.wrapping_add(cent), D64::MIN);
assert_eq!(max.overflowing_add(cent), (D64::MIN, true));
assert_eq!(cent.checked_div(D64::<2>::ZERO), None, "never by zero");
}
| Family | Past the range |
|---|---|
checked_* | None |
saturating_* | the end of the range it passes |
wrapping_* | wrapped around the range |
overflowing_* | wrapped around the range, and true |
Each family has add, sub, mul (the exact product), div, mul_int and
div_int (by a count), neg and abs; rem has checked_rem, wrapping_rem
and overflowing_rem, as the integer has. A checked_* method returns None
for a zero divisor too, where its operator panics.
Unsigned Decimals
An unsigned decimal, UD64 and its siblings, stops at zero as its integer
does:
fn an_unsigned_decimal_stops_at_zero() {
use ddust::{UD64, dec};
let (balance, withdrawal): (UD64<2>, UD64<2>) = (dec!(10), dec!(12.50));
assert_eq!(balance.checked_sub(withdrawal), None);
assert_eq!(balance.saturating_sub(withdrawal), UD64::ZERO);
}
It has no negation, which the compiler refuses:
//! An unsigned decimal has no negation.
use ddust::{UD64, dec};
fn main() {
let amount: UD64<2> = dec!(1.5);
let _negated = -amount;
}
error[E0600]: cannot apply unary operator `-` to type `Decimal<u64, Fixed<2>>`
--> tests/compile_fail/an_unsigned_decimal_never_negates.rs:7:20
|
7 | let _negated = -amount;
| ^^^^^^^ cannot apply unary operator `-`
unsigned_abs takes a signed value’s magnitude
as an unsigned decimal, exact for every value, the minimum included.
Rounding
An operation that can lose a digit takes a rounding mode as its last argument,
so it never rounds unless asked to. A mode is one of nine zero-sized types in
round, which the compiler resolves where the operation is inlined, or a
Rounding, the same nine as a value chosen at run
time.
The Nine Modes
| Mode | Rounds |
|---|---|
Floor | toward −∞ |
Ceil | toward +∞ |
Trunc | toward zero |
Expand | away from zero |
HalfFloor | to the nearer step, and a tie toward −∞ |
HalfCeil | to the nearer step, and a tie toward +∞ |
HalfTrunc | to the nearer step, and a tie toward zero |
HalfExpand | to the nearer step, and a tie away from zero |
HalfEven | to the nearer step, and a tie to the even step |
Each takes six values to a whole number here:
fn each_mode_settles_the_digits_that_do_not_fit() {
use ddust::round::Rounding;
use ddust::{D64, dec};
let values: [D64<2>; 6] = [dec!(-1.5), dec!(-1.25), dec!(-0.5), dec!(0.5), dec!(1.25), dec!(1.5)];
let whole = |mode| values.map(|value| value.to_int(mode));
assert_eq!(whole(Rounding::Floor), [-2, -2, -1, 0, 1, 1]);
assert_eq!(whole(Rounding::Ceil), [-1, -1, 0, 1, 2, 2]);
assert_eq!(whole(Rounding::Trunc), [-1, -1, 0, 0, 1, 1]);
assert_eq!(whole(Rounding::Expand), [-2, -2, -1, 1, 2, 2]);
assert_eq!(whole(Rounding::HalfFloor), [-2, -1, -1, 0, 1, 1]);
assert_eq!(whole(Rounding::HalfCeil), [-1, -1, 0, 1, 1, 2]);
assert_eq!(whole(Rounding::HalfTrunc), [-1, -1, 0, 0, 1, 1]);
assert_eq!(whole(Rounding::HalfExpand), [-2, -1, -1, 1, 1, 2]);
assert_eq!(whole(Rounding::HalfEven), [-2, -1, 0, 0, 1, 2]);
}
HalfExpand is the rounding taught at school, and HalfEven, banker’s
rounding, is IEEE 754’s default and the one that does not drift up over many
ties.
What Takes a Mode
| Method | What it rounds |
|---|---|
mul_round | a product, at this value’s scale |
mul_round_to | a product, at a scale named |
div_round | a quotient by a decimal or a Divisor, at this value’s scale |
div_round_to | a quotient, at a scale named |
div_int_round | one of n equal parts |
mul_div_round | self × numerator / denominator, rounded once |
round_to | to a multiple of a step: a tick, a lot, a coin |
round | to a whole number, at this value’s scale |
to_int | to a whole number, as an integer |
rescale_round | to another scale |
new_round, to_digits_round | digits, to and from a decimal |
from_ascii_round | text with more decimals than the scale |
from_f64 | a double |
Each of the arithmetic ones has a checked_* twin that returns None past the
range, or for a zero divisor. trunc,
floor and ceil are the
whole-number roundings with their modes named, and
fract is what trunc leaves:
fn what_can_lose_a_digit_takes_a_mode() {
use ddust::round::{Ceil, Floor, HalfEven, Trunc};
use ddust::{D64, dec};
let (amount, fee): (D64<2>, D64<4>) = (dec!(100.10), dec!(0.0025));
assert_eq!(amount.mul_round(fee, Ceil), dec!(0.26), "0.25025, up to the cent");
assert_eq!(amount.div_int_round(3, HalfEven), dec!(33.37), "a third, to the cent");
assert_eq!(amount.div_round(dec!(0.07: D64<2>), Floor), dec!(1430));
let share = amount.mul_div_round(dec!(2: D64<0>), dec!(3), HalfEven);
assert_eq!(share, dec!(66.73), "two thirds, rounded once");
assert_eq!(dec!(19.97: D64<2>).round_to(dec!(0.05), Floor), dec!(19.95), "to a step");
assert_eq!(dec!(2.5: D64<1>).round(HalfEven), dec!(2), "to a whole number");
assert_eq!(dec!(-2.5: D64<1>).to_int(Trunc), -2, "to an integer");
}
Every operation rounds once. Its exact result is computed in an integer wide
enough to hold it, twice the decimal’s or 256 bits, and the mode settles it from
the remainder: mul_div_round takes two thirds of 100.10 with no rounding
between the multiplication and the division.
The compiler refuses an operation that can lose a digit without a mode:
//! An operation that can lose digits takes a rounding mode, so it never rounds unasked.
use ddust::{D64, dec};
fn main() {
let (amount, rate): (D64<2>, D64<4>) = (dec!(100.10), dec!(0.0025));
let _rounded = amount.mul_round(rate);
}
error[E0061]: this method takes 2 arguments but 1 argument was supplied
--> tests/compile_fail/a_lossy_operation_takes_a_mode.rs:7:27
|
7 | let _rounded = amount.mul_round(rate);
| ^^^^^^^^^------ argument #2 is missing
|
note: method defined here
--> src/rounded.rs
|
| pub const fn mul_round<T, R>(self, rhs: Decimal<I, T>, mode: R) -> Self
| ^^^^^^^^^
help: provide the argument
|
7 | let _rounded = amount.mul_round(rate, /* mode */);
| ++++++++++++
A Mode Chosen at Run Time
A venue’s rules, a configuration or a protocol may choose the mode. A
Rounding is that choice as a value, and every
operation takes it where it takes a mode type:
fn a_mode_read_at_run_time_is_a_rounding() {
use ddust::round::{HalfExpand, Rounding};
use ddust::{D64, Fixed, dec};
let venue_rounds_half_up = true;
let mode = if venue_rounds_half_up { Rounding::from(HalfExpand) } else { Rounding::HalfEven };
let fee: D64<2> = dec!(0.125: D64<3>).rescale_round(Fixed, mode).expect("in range");
assert_eq!(fee, dec!(0.13));
}
Each mode is a table of sixteen bits, one for each way a division can end: the
result’s sign, whether its quotient is odd, and whether the remainder is zero,
below half the divisor, at half or above it. Rounding reads one bit of the
table, so a Rounding costs no match: a mode is data, whether it is a type, a
constant or a value read at run time. The trait every mode implements,
RoundingMode, is sealed, so the nine are all
there are:
//! `RoundingMode` is sealed: a mode outside the nine would bypass their tables.
use ddust::round::RoundingMode;
#[derive(Clone, Copy)]
struct Nearest;
impl RoundingMode for Nearest {
fn table(self) -> u16 {
0
}
}
fn main() {}
error[E0277]: the trait bound `Nearest: round::sealed::Sealed` is not satisfied
--> tests/compile_fail/a_rounding_mode_is_one_of_the_nine.rs:8:23
|
8 | impl RoundingMode for Nearest {
| ^^^^^^^ unsatisfied trait bound
|
help: the trait `round::sealed::Sealed` is not implemented for `Nearest`
--> tests/compile_fail/a_rounding_mode_is_one_of_the_nine.rs:6:1
|
6 | struct Nearest;
| ^^^^^^^^^^^^^^
= help: the following other types implement trait `round::sealed::Sealed`:
Rounding
ddust::round::Ceil
ddust::round::Expand
ddust::round::Floor
ddust::round::HalfCeil
ddust::round::HalfEven
ddust::round::HalfExpand
ddust::round::HalfFloor
and $N others
note: required by a bound in `RoundingMode`
--> src/round.rs
|
| pub const trait RoundingMode: sealed::Sealed + Copy {
| ^^^^^^^^^^^^^^ required by this bound in `RoundingMode`
= note: `RoundingMode` is a "sealed trait", because to implement it you also need to implement `ddust::round::sealed::Sealed`, which is not accessible; this is usually done to force you to use one of the provided types that already implement it
= help: the following types implement the trait:
ddust::round::Floor
ddust::round::Ceil
ddust::round::Trunc
ddust::round::Expand
ddust::round::HalfFloor
ddust::round::HalfCeil
ddust::round::HalfTrunc
ddust::round::HalfExpand
and $N others
Many Values by One Divisor
A Divisor is a divisor prepared once, for dividing many values by it: a
book’s notionals by one price, or amounts by one rate.
div_round and
checked_div_round take it where they take
a decimal, and give the same quotients:
fn a_divisor_prepared_once_divides_many_values() {
use ddust::round::HalfEven;
use ddust::{D64, Divisor, dec};
let euros_per_dollar: D64<4> = dec!(0.9234);
let rate = Divisor::new(euros_per_dollar).expect("not zero");
let euros: [D64<2>; 3] = [dec!(100), dec!(250), dec!(19.99)];
let dollars = euros.map(|amount| amount.div_round(rate, HalfEven));
assert_eq!(dollars, [dec!(108.30), dec!(270.74), dec!(21.65)]);
assert_eq!(dollars[2], euros[2].div_round(euros_per_dollar, HalfEven), "as by the decimal");
}
Preparing it costs a division; each quotient then costs two multiplications and a comparison. Performance measures both.
Run-Time Scales
Dynamic is a scale known only at run time: read from a venue’s rules, a
database column’s type or the text of a number. A decimal of it carries its
scale in a byte beside its steps, so a Decimal<i64, Dynamic> is 16 bytes where
a D64<2> is 8. Text read into one takes the scale the text spells:
fn a_run_time_scale_is_the_one_its_text_spells() {
use ddust::{Decimal, Dynamic};
let price: Decimal<i64, Dynamic> = "1.50".parse().expect("a decimal");
assert_eq!((price.steps(), price.decimals()), (150, 2));
assert_eq!(size_of::<Decimal<i64, Dynamic>>(), 16, "the steps, and a byte of scale");
}
Dynamic::new makes a scale of up to 38 decimals, and
returns None past them.
Lining Up
Two values at different run-time scales line up exactly at the finer one, as
SQL’s DECIMAL does: the coarser is multiplied by a power of ten, and the
operation is done at the finer scale. A sum, a difference, a remainder, a
comparison and equality all line up, so 1.5 == 1.50, and the two hash alike. A
product takes the sum of the factors’ decimals, as a static one does:
fn two_run_time_scales_line_up_at_the_finer() {
use ddust::{Decimal, Dynamic};
let read = |text: &str| -> Decimal<i64, Dynamic> { text.parse().expect("a decimal") };
let sum = read("1.5") + read("1.25");
assert_eq!((sum.to_string(), sum.decimals()), ("2.75".into(), 2));
assert_eq!(read("1.5"), read("1.50"), "equal, whatever their scales");
}
fn a_run_time_product_takes_the_sum_of_its_factors_decimals() {
use ddust::{Decimal, Dynamic};
let read = |text: &str| -> Decimal<i64, Dynamic> { text.parse().expect("a decimal") };
let product = read("1.5") * read("1.25");
assert_eq!((product.to_string(), product.decimals()), ("1.875".into(), 3));
}
Lining up never rounds. A comparison is exact whatever the two scales are; a sum
or a difference that does not fit at the finer scale overflows as any other
does, panicking with overflow checks on and wrapping otherwise, and its
checked_* method returns None. A product past 38 decimals, a scale no
integer holds a step of, panics in every build, and
checked_mul returns None for it. A quotient
is at the dividend’s scale, as a static one is.
Rescaling
rescale moves a value to another scale exactly, and
refuses a non-zero digit past it with TooManyDecimals;
rescale_round rounds instead. A size read at
five decimals, for a venue whose step is a thousandth:
fn a_scale_read_from_a_venue_rounds_on_purpose() {
use ddust::round::Floor;
use ddust::{ConvertErrorKind, Decimal, Dynamic};
let size_step = Dynamic::new(3).expect("at most 38 decimals");
let wanted =
Decimal::<i64, Dynamic>::from_ascii(b"0.12345", Dynamic::new(5).expect("at most 38"))
.expect("a decimal");
let refused = wanted.rescale(size_step).expect_err("two decimals past the venue's");
assert_eq!(refused.kind(), ConvertErrorKind::TooManyDecimals);
let traded = wanted.rescale_round(size_step, Floor).expect("in range");
assert_eq!(traded.to_string(), "0.123", "down to the venue's step");
}
Static and Run-Time Scales
A value of a static scale converts into one of a run-time scale by From, at
the same decimals, and back by TryFrom, exactly or refused:
fn a_static_scale_converts_to_a_run_time_one_and_back() {
use ddust::{ConvertError, ConvertErrorKind, D64, Decimal, Dynamic, dec};
let price: D64<2> = dec!(1.25);
let carried: Decimal<i64, Dynamic> = price.into();
assert_eq!(carried.decimals(), 2);
assert_eq!(D64::<2>::try_from(carried), Ok(price));
assert_eq!(D64::<4>::try_from(carried), Ok(dec!(1.25)), "zeros added");
let refused = D64::<1>::try_from(carried).map_err(ConvertError::kind);
assert_eq!(refused, Err(ConvertErrorKind::TooManyDecimals), "a digit past one decimal");
}
A product of a static scale and a run-time one is at a run-time scale. A decimal of a static scale is the faster and smaller of the two, and a run-time one is for the values whose scale the program learns as it runs; Choosing a Decimal weighs them.
Another Crate’s Scale
Scale is a trait a crate built on ddust can implement, for a scale its own
types give, such as a price’s scale that its assets decide.
LINES_UP says what two of its values at different
decimals do: line up at the finer one, as Dynamic’s do, or never mix, in which
case an operator panics in every build and a checked_* method returns None.
Text
Every value has one spelling. Display writes its shortest exact decimal, and
the readers take it back exactly or refuse it. Nothing on that path goes through
a float.
Reading
FromStr, from_ascii for bytes, and the other
readers take what f64::from_str takes, without its infinities and NaN: a sign,
digits with an optional point, and an optional exponent. Zeros past the scale’s
decimals are allowed; a non-zero digit there is refused, never rounded:
fn text_is_read_exactly_or_refused() {
use ddust::{D64, ParseError, ParseErrorKind, dec};
let read = |text: &str| text.parse::<D64<2>>().map_err(ParseError::kind);
assert_eq!(read("12.30"), Ok(dec!(12.3)));
assert_eq!(read("1.2300"), read("1.23"), "zeros past the scale are no digits");
assert_eq!(read("-.5"), read("-0.50"));
assert_eq!(read("1.5e3"), read("1500"), "an exponent, as Rust's floats are written");
assert_eq!(read("1.234"), Err(ParseErrorKind::TooManyDecimals), "never rounded unasked");
assert_eq!(read("1,000"), Err(ParseErrorKind::InvalidDigit));
assert_eq!(read("NaN"), Err(ParseErrorKind::InvalidDigit), "a decimal has no NaN");
assert_eq!(read("1e17"), Err(ParseErrorKind::PosOverflow));
assert_eq!(read(""), Err(ParseErrorKind::Empty));
}
A refusal is a ParseError, whose kind says why:
ParseErrorKind | The text |
|---|---|
Empty | is empty |
InvalidDigit | is not a number: a sign, digits, a point, an exponent |
PosOverflow | is above the decimal’s range |
NegOverflow | is below it |
TooManyDecimals | has a non-zero digit past the decimal’s scale |
RangeOutsideBuffer | was asked for at a range its buffer does not hold |
A decimal of a run-time scale reads at the scale its text spells, so "1.50" is
1.50 at two decimals; from_ascii takes a scale to read at instead.
Text with more decimals than the scale is rounded only when asked, by
from_ascii_round:
fn text_with_more_decimals_rounds_by_a_mode_when_asked() {
use ddust::round::HalfEven;
use ddust::{D64, Fixed, dec};
let price = D64::from_ascii_round(b"64250.745", Fixed::<2>, HalfEven).expect("in range");
assert_eq!(price, dec!(64250.74), "a tie, to the even cent");
}
Reading Inside a Message
A number inside a message is read where it lies, with no copy.
from_ascii_at takes the buffer and the range
the number fills, and from_ascii_prefix
reads the number at the front of a slice and says how many bytes it took, for a
reader that has not found where it ends:
fn a_number_is_read_in_place_from_a_message() {
use ddust::{D64, Fixed, dec};
let message = br#"{"p":"64250.75","q":"0.0153"}"#;
let price = D64::from_ascii_at(message, 6..14, Fixed::<2>).expect("a price");
assert_eq!(price, dec!(64250.75));
let (quantity, length) = D64::from_ascii_prefix(&message[21..], Fixed::<4>).expect("a size");
assert_eq!((quantity, length), (dec!(0.0153), 6), "the number, and the bytes it took");
}
On a build with a vector unit, a number of up to 32 bytes is read in 32 lanes at
once, NEON’s on aarch64 and SSSE3’s on x86_64. from_ascii reads only its
slice: the first 16 bytes and the last 16 of a number that long, and of a
shorter one its first eight and last eight, or the one word it fits in.
from_ascii_at reads the 16 bytes from the number’s start and the 16 before its
end wherever the buffer holds them, so it reads every number the same way,
whatever its length. Platforms and Features says
which builds have the lanes.
Writing
Display writes the shortest exact decimal. {:#} writes every decimal of the
scale, {:.N} exactly N decimals, a tie rounded to even as f64’s is, and
width, fill and sign work as an integer’s do. Debug writes as {:#}, so a
run-time value’s scale shows, and LowerExp and UpperExp write scientific
notation:
fn a_value_is_written_as_its_format_asks() {
use ddust::{D64, dec};
let price: D64<4> = dec!(1.2500);
assert_eq!(format!("{price}"), "1.25", "the shortest exact decimal");
assert_eq!(format!("{price:#}"), "1.2500", "every decimal of the scale");
assert_eq!(format!("{price:.1}"), "1.2", "a tie, to the even digit");
assert_eq!(format!("{price:>+8}"), " +1.25", "width, fill and sign as an integer's");
assert_eq!(format!("{price:e}"), "1.25e0");
assert_eq!(format!("{price:?}"), "1.2500");
}
write_ascii writes the same text as Display
into a byte buffer, with no formatter, and returns its length, or None when
the buffer is too short. It touches no byte past the text, and MAX_ASCII_LEN
bytes, 41, hold every value’s: a sign, 39 digits and a point.
fn a_value_is_written_into_bytes_with_no_formatter() {
use ddust::{D128, MAX_ASCII_LEN, dec};
let mut buffer = [0_u8; MAX_ASCII_LEN];
let value: D128<18> = dec!(-0.000000000000000001);
let length = value.write_ascii(&mut buffer).expect("room for every value");
assert_eq!(&buffer[..length], b"-0.000000000000000001");
assert_eq!(value.write_ascii(&mut [0; 8]), None, "a buffer too short is refused");
}
Floats
A double is a binary fraction, so most decimals fall between two doubles: the
double nearest 0.1 is 0.1000000000000000055511151231257827…, and Rust reads and
writes it as 0.1 only because no shorter decimal is nearer to it. A decimal
converts to and from a double at the edges of a program, where a library, a
chart or a model speaks in floats, and each way says how it rounds.
To a Double
to_f64 gives the nearest double, a tie to the even
one: exact whenever the value is, and correctly rounded otherwise, as a decimal
read from text is, so it is the double Rust reads the decimal’s text as:
fn a_decimal_becomes_its_nearest_double() {
use ddust::{D64, D128, dec};
let tenth: D64<1> = dec!(0.1);
assert_eq!(tenth.to_f64().to_bits(), 0.1_f64.to_bits(), "the double Rust reads 0.1 as");
let long: D128<18> = dec!(0.123456789012345678);
assert_eq!(long.to_f64().to_string(), "0.12345678901234568", "correctly rounded");
}
From a Double
from_f64 takes the double’s exact binary value to
the nearest step at a scale by a rounding mode, or returns None for NaN, an
infinity, or a value past the range:
fn a_double_becomes_a_decimal_by_a_mode() {
use ddust::round::{Ceil, HalfEven};
use ddust::{D64, Fixed, dec};
let price = D64::from_f64(0.1, Fixed::<2>, HalfEven);
assert_eq!(price, Some(dec!(0.10)), "the nearest cent");
let up = D64::from_f64(0.1, Fixed::<2>, Ceil);
assert_eq!(up, Some(dec!(0.11)), "0.1000000000000000055…, up to the cent");
assert_eq!(D64::from_f64(f64::NAN, Fixed::<2>, HalfEven), None);
assert_eq!(D64::from_f64(1e17, Fixed::<2>, HalfEven), None, "past the range");
}
Rounded half to even, the nearest step of a double is the decimal it was written
from, while the steps stay below : the double 0.1 is 0.10 at two
decimals. A directed mode sees the double’s binary digits past the decimal ones:
rounded up by Ceil, the double 0.1 is 0.11 at two decimals.
from_f64 is a const fn. Serialization reads and writes a
decimal as a double through ddust::serde::float, and num-traits’
FromPrimitive reads one, both at the nearest step, a tie to the even one.
Serialization
With the serde feature, a decimal implements serde’s Serialize and
Deserialize. Where a person reads the format, as in JSON, TOML or YAML, a
decimal is its text; where none does, as in bincode or postcard, it is its
steps. Text is read exactly, as Text reads it, and a float is
refused, since it has already lost the digits a decimal keeps:
fn a_decimal_is_its_text_where_a_person_reads_the_format() {
use ddust::{D64, dec};
use serde_json::{from_str, to_string};
let price: D64<2> = dec!(64250.75);
assert_eq!(to_string(&price).expect("a decimal writes as a JSON string"), r#""64250.75""#);
let read = |json: &str| from_str::<D64<2>>(json).map_err(|error| error.to_string());
assert_eq!(read(r#""64250.75""#), Ok(price));
assert_eq!(read("64250"), Ok(dec!(64250)), "a whole number, exactly");
let float = r#"invalid type: floating point `64250.75`, expected a decimal as a string, as "60000.5", or a whole number at line 1 column 8"#;
assert_eq!(
read("64250.75"),
Err(float.into()),
"a float, which has lost the digits, is refused"
);
let past = "parse error: the number has more fraction digits than the decimal's scale at line 1 column 11";
assert_eq!(
read(r#""64250.755""#),
Err(past.into()),
"a digit past the scale, as parsing refuses it"
);
}
Choosing a Field’s Form
A field takes another form through #[serde(with = …)] and one of the modules
in ddust::serde, each of which has an option twin for an Option field:
| Form | Where a person reads the format | Where none does |
|---|---|---|
| the decimal’s own | its text | its steps |
text | its text | its text |
steps | its steps, as a number | its steps |
float | its nearest double | the same |
Each reads what it writes; text and the decimal’s own form read a whole number
too, and steps a string of digits, where a person reads the format:
fn a_field_chooses_its_form() {
use ddust::{D64, dec};
use serde::{Deserialize, Serialize};
use serde_json::{from_str, to_string};
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Fill {
price: D64<2>,
#[serde(with = "ddust::serde::steps")]
quantity: D64<4>,
#[serde(with = "ddust::serde::float")]
fee: D64<4>,
#[serde(with = "ddust::serde::text::option", default)]
rebate: Option<D64<4>>,
}
let fill =
Fill { price: dec!(64250.75), quantity: dec!(0.0153), fee: dec!(0.25), rebate: None };
let json = to_string(&fill).expect("each field writes as a JSON value");
assert_eq!(json, r#"{"price":"64250.75","quantity":153,"fee":0.25,"rebate":null}"#);
assert_eq!(from_str::<Fill>(&json).expect("each form reads what it writes"), fill);
}
steps suits a format whose numbers are integers and whose scale the schema
already states. float suits a consumer that takes only doubles: a double is
rarely exact at a decimal scale, and is read back at the nearest step, as
Floats says.
Run-Time Scales
A decimal of a run-time scale is written as every decimal of its scale, so
1.50 stays at two decimals, and is read at the scale its text spells. Where no
person reads the format, it is its steps and its decimals, and more than 38
decimals are refused:
fn a_run_time_scale_is_read_from_the_text() {
use ddust::{Decimal, Dynamic};
use serde_json::{from_str, to_string};
let price: Decimal<i64, Dynamic> = from_str(r#""1.50""#).expect("1.50 is a decimal");
assert_eq!((price.steps(), price.decimals()), (150, 2));
let json = to_string(&price).expect("a decimal writes as a JSON string");
assert_eq!(json, r#""1.50""#, "its scale kept");
}
The modules take a decimal of a static scale, whose scale the type already gives.
Choosing a Decimal
A decimal is three choices: the integer, which sets the range; the scale, which sets the decimals and whether the compiler knows them; and, for each operation that rounds, the mode.
The Integer and the Scale
| Need | Take | Why |
|---|---|---|
| an amount to the cent: a balance, a payment, an invoice | D64<2> | eight bytes, up to about ±9.2 × 10¹⁶ |
| a price or a quantity to eight decimals | D64<8> | eight bytes, up to about ±9.2 × 10¹⁰ |
| a token’s amount at eighteen decimals, or a large notional | D128<18> | sixteen bytes, up to about ±1.7 × 10²⁰ |
the exact product of two D64s | widen them first | a D128 at the sum of their scales holds every product of two i64s |
| a value that is never negative | UD64 and its siblings | the type refuses a negative value, and a u64 holds twice an i64’s largest value |
| millions of values in memory | the narrowest that holds them | a D32<2> holds ±21 million in four bytes, sixteen to a 64-byte cache line |
The scale:
| Need | Take | Why |
|---|---|---|
| a scale known when the code is written | Fixed<D> | no bytes, two scales that never mix by accident, and every operation at its fastest |
| a scale learned as the program runs: a venue’s, a column’s | Dynamic | a byte beside the steps, and two scales that line up exactly |
A program that learns its scales at run time can still keep each in a type where the values of one scale gather: a book of one instrument, or a column of one table. Run-Time Scales converts between the two.
The Rounding Mode
| Need | Take |
|---|---|
| a mode the code names | its type: HalfEven, Floor and the rest |
| a mode a configuration, a venue or a protocol chooses | a Rounding |
| a rule that names the nearest cent | HalfEven, banker’s rounding, or HalfExpand, the school’s, as the rule says |
| a fee or a margin a business must not undercharge | Ceil, or Expand for both signs |
| a size an account must be able to pay for | Floor, or Trunc for both signs |
Division
| Need | Take |
|---|---|
| a quotient truncated, as an integer’s | / |
| a quotient rounded | div_round, with a mode |
| a quotient at another scale | div_round_to |
| many quotients by one divisor | a Divisor, prepared once |
a share: a × b / c, rounded once | mul_div_round |
Performance measures what each choice costs.
Performance
Every figure on this page is from one run, committed under
crates/ddust/bench/results/: each pass’s transcript and figures, the code size
of each operation, and a manifest.toml that says how they were taken. The run
was taken on an AWS Graviton4 (Arm Neoverse V2, a c8g.8xlarge) under Linux
6.12.53, built by rustc 1.101.0-nightly of 2026-09-27 in the bench profile
(release’s settings with fat LTO, one codegen unit, panic = "abort" and no
overflow checks), pinned with taskset to a core the kernel keeps other work
off; the manifest records the host’s load before and after.
| Run | What it measures |
|---|---|
2026-10-14T06-08Z-pr18-baseline-graviton4 | every operation of ddust beside every contender, time and counters |
The name is the time the run was taken, in UTC, the pull request whose code it measured, what it measured, and the machine.
How It Is Measured
Every crate is built into one binary and measured in one run, since code layout alone moves a figure between builds by a few percent. Each operation is each crate’s fastest correct call, with the rounding mode the crate uses, and ddust is measured again in that mode where a crate’s differs from half to even. The benchmarks’ equivalence test checks every result against an exact reference; a row whose results differ is marked inexact, and is timed all the same.
Each operation runs over two sets of values:
- Predictable: 1,024 values of one sign and one count of digits, which stay in the level 1 cache: the best case for a branch on the value.
- Unpredictable: 65,536 values of random signs and counts of digits, more than a branch predictor learns, so a branch on the value mispredicts as it would in a program.
Narrow values are 64-bit at 8 decimals, a price or a quantity as most venues
send one; wide values are 128-bit at 18 decimals, a token’s amount as a chain
holds it. A crate with one type, as rust_decimal has, is measured at both.
Beside the decimals stand the floors, i64, i128 and f64 doing the plain
operation a decimal’s is built on: the cost no decimal goes below, which the
reference does not check.
Each table lists the crates fastest first on the unpredictable set, with the nanoseconds per operation on each set, and the cycles, instructions and branch misses per operation on the unpredictable set. An operation is timed by throughput, independent operations over the values; the sum, the rounded product and the rounded quotients are timed by latency too, in a chain where each waits for the one before.
Adding and Comparing
A decimal of a static scale is its integer, so adding two is the integer’s add with its overflow check, and comparing two is the integer’s compare:
The checked sum, narrow, 64 bits at 8 decimals:
| Contender | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|
f64 | 0.27 | 0.39 | 1.08 | 3.50 | 0.00 |
fixdec 0.1.0 D64 | 0.68 | 0.73 | 2.03 | 7.00 | 0.00 |
primitive_fixed_point_decimal 1.5.0 <i64, 8> | 0.68 | 0.73 | 2.03 | 7.00 | 0.00 |
nexus-decimal 1.2.2 <i64, 8> | 0.68 | 0.73 | 2.04 | 7.00 | 0.00 |
fixnum 0.9.5 <i64, U8> | 0.68 | 0.75 | 2.09 | 7.00 | 0.00 |
fin_decimal 0.4.0 Decimal<8> | 0.68 | 0.75 | 2.10 | 7.00 | 0.00 |
i64 | 0.68 | 0.75 | 2.11 | 7.00 | 0.00 |
ddust D64<8> | 0.69 | 0.76 | 2.13 | 9.00 | 0.00 |
fixed 1.31.0 I64F64 | 0.95 | 1.02 | 2.86 | 14.0 | 0.00 |
| decimal-rs 0.2.0 | 6.64 | 7.61 | 21.3 | 54.7 | 0.76 |
| rust_decimal 1.43.0 | 7.88 | 9.79 | 27.4 | 67.5 | 1.23 |
fastnum 0.7.5 D64 | 7.75 | 16.6 | 46.4 | 126 | 1.48 |
| bigdecimal 0.4.11 | 28.6 | 38.0 | 106 | 317 | 1.78 |
The checked sum, wide, 128 bits at 18 decimals:
| Contender | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|
f64 | 0.28 | 0.39 | 1.09 | 3.50 | 0.00 |
fixnum 0.9.5 <i128, U18> | 0.94 | 1.03 | 2.88 | 14.0 | 0.00 |
i128 | 0.95 | 1.04 | 2.91 | 14.0 | 0.00 |
primitive_fixed_point_decimal 1.5.0 <i128, 18> | 0.94 | 1.05 | 2.93 | 14.0 | 0.00 |
nexus-decimal 1.2.2 <i128, 18> | 0.94 | 1.05 | 2.94 | 14.0 | 0.00 |
fin_decimal 0.4.0 Decimal128<18> | 0.94 | 1.05 | 2.93 | 14.0 | 0.00 |
ddust D128<18> | 0.95 | 1.06 | 2.94 | 14.0 | 0.00 |
| decimal-rs 0.2.0 | 6.68 | 7.65 | 21.4 | 54.8 | 0.77 |
| rust_decimal 1.43.0 | 8.06 | 10.6 | 29.7 | 67.1 | 1.52 |
fastnum 0.7.5 D128 | 8.86 | 18.2 | 50.9 | 141 | 1.57 |
| bigdecimal 0.4.11 | 50.5 | 47.8 | 134 | 451 | 2.27 |
The order, narrow, 64 bits at 8 decimals:
| Contender | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|
nexus-decimal 1.2.2 <i64, 8> | 0.15 | 0.21 | 0.58 | 1.69 | 0.00 |
ddust D64<8> | 0.15 | 0.21 | 0.59 | 1.69 | 0.00 |
fixdec 0.1.0 D64 | 0.15 | 0.21 | 0.59 | 1.69 | 0.00 |
fixnum 0.9.5 <i64, U8> | 0.15 | 0.21 | 0.59 | 1.69 | 0.00 |
fin_decimal 0.4.0 Decimal<8> | 0.15 | 0.21 | 0.59 | 1.69 | 0.00 |
i64 | 0.15 | 0.21 | 0.59 | 1.69 | 0.00 |
primitive_fixed_point_decimal 1.5.0 <i64, 8> | 0.15 | 0.21 | 0.59 | 1.69 | 0.00 |
f64 | 0.15 | 0.21 | 0.59 | 1.69 | 0.00 |
fixed 1.31.0 I64F64 | 0.55 | 0.60 | 1.69 | 7.63 | 0.00 |
| decimal-rs 0.2.0 | 1.81 | 4.17 | 11.7 | 25.0 | 0.49 |
| bigdecimal 0.4.11 | 3.61 | 5.62 | 15.7 | 43.9 | 0.49 |
| rust_decimal 1.43.0 | 3.75 | 6.58 | 18.4 | 45.5 | 0.83 |
fastnum 0.7.5 D64 | 8.30 | 7.64 | 21.4 | 43.0 | 1.07 |
The order, wide, 128 bits at 18 decimals:
| Contender | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|
f64 | 0.15 | 0.21 | 0.59 | 1.69 | 0.00 |
fin_decimal 0.4.0 Decimal128<18> | 0.53 | 0.56 | 1.58 | 7.63 | 0.00 |
fixnum 0.9.5 <i128, U18> | 0.54 | 0.58 | 1.62 | 7.63 | 0.00 |
nexus-decimal 1.2.2 <i128, 18> | 0.55 | 0.58 | 1.62 | 7.63 | 0.00 |
primitive_fixed_point_decimal 1.5.0 <i128, 18> | 0.53 | 0.58 | 1.63 | 7.63 | 0.00 |
ddust D128<18> | 0.54 | 0.60 | 1.68 | 7.63 | 0.00 |
i128 | 0.53 | 0.61 | 1.69 | 7.63 | 0.00 |
| decimal-rs 0.2.0 | 1.83 | 4.21 | 11.8 | 25.0 | 0.49 |
| bigdecimal 0.4.11 | 3.63 | 6.83 | 19.1 | 40.8 | 0.80 |
| rust_decimal 1.43.0 | 3.75 | 7.84 | 21.9 | 44.7 | 1.02 |
fastnum 0.7.5 D128 | 15.3 | 10.0 | 28.0 | 56.4 | 1.34 |
The fixed-point crates are within a few percent of each other and of the integer
here, since each is the integer underneath; ddust’s checked add takes 9.00
instructions where the i64’s takes 7.00. The two more are the cost of
Decimal’s repr(C) layout, which keeps a decimal its integer’s bytes for
zerocopy and bytemuck: Rust builds an Option of such a struct in memory, where
it returns an Option of a bare integer in two registers. The crates that carry
a scale in each value, rust_decimal, fastnum, decimal-rs and bigdecimal, line
two scales up before they add, at the cost of branches the unpredictable set
mispredicts.
Multiplying
An exact product of a price at 2 decimals and a quantity at 5 is a notional at 7: the integer’s multiply with its overflow check, and nothing for the scale:
The exact product, narrow, 64 bits at 8 decimals:
| Contender | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|
i64 | 0.77 | 0.87 | 2.42 | 10.0 | 0.00 |
primitive_fixed_point_decimal 1.5.0 <i64, 2> × <i64, 5> | 0.77 | 0.87 | 2.43 | 10.0 | 0.00 |
ddust D64<2> × D64<5> | 0.80 | 0.89 | 2.50 | 11.0 | 0.00 |
fastnum 0.7.5 D64 | 7.17 | 7.20 | 20.1 | 122 | 0.00 |
| rust_decimal 1.43.0 | 7.27 | 7.29 | 20.4 | 60.0 | 0.00 |
| bigdecimal 0.4.11 | 12.3 | 20.4 | 56.9 | 196 | 1.12 |
| decimal-rs 0.2.0 | 33.8 | 33.8 | 94.6 | 231 | 0.00 |
A product rounded back to the factors’ scale is the exact product in an integer
twice as wide, divided by or through a multiply by its
reciprocal, and the remainder read against half the divisor for the mode; no
division instruction is left in it, which just bench-assembly checks on
aarch64 and x86_64:
The rounded product, narrow, 64 bits at 8 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
f64 | HalfEven | 0.27 | 0.41 | 1.13 | 3.50 | 0.00 |
i64 | Trunc | 0.77 | 0.86 | 2.42 | 10.0 | 0.00 |
fixed 1.31.0 I64F64 | Floor | 2.65 | 2.63 | 7.36 | 38.0 | 0.00 |
ddust D64<8> | Trunc | 2.87 | 3.31 | 9.25 | 35.5 | 0.15 |
primitive_fixed_point_decimal 1.5.0 <i64, 8> | HalfExpand | 4.02 | 3.68 | 10.3 | 36.4 | 0.18 |
ddust D64<8> | HalfExpand | 3.29 | 3.73 | 10.4 | 42.9 | 0.15 |
ddust D64<8> | HalfEven | 3.96 | 3.97 | 11.1 | 46.9 | 0.15 |
fixdec 0.1.0 D64, inexact | Trunc | 1.69 | 4.49 | 12.5 | 25.5 | 0.49 |
nexus-decimal 1.2.2 <i64, 8> | Trunc | 3.04 | 5.57 | 15.6 | 41.5 | 0.50 |
fin_decimal 0.4.0 Decimal<8> | HalfEven | 4.85 | 9.63 | 26.9 | 64.3 | 0.56 |
fixnum 0.9.5 <i64, U8> | HalfExpand | 14.3 | 13.3 | 37.3 | 101 | 0.71 |
| rust_decimal 1.43.0 | HalfEven | 27.8 | 30.7 | 85.8 | 270 | 0.92 |
fastnum 0.7.5 D64 | HalfExpand | 69.9 | 33.8 | 94.6 | 253 | 1.23 |
| decimal-rs 0.2.0 | HalfExpand | 41.2 | 40.9 | 114 | 314 | 0.32 |
| bigdecimal 0.4.11 | HalfEven | 163 | 124 | 348 | 1,329 | 4.00 |
The rounded product, wide, 128 bits at 18 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
f64 | HalfEven | 0.27 | 0.41 | 1.14 | 3.75 | 0.00 |
i128 | Trunc | 3.46 | 3.70 | 10.4 | 27.1 | 0.30 |
ddust D128<18> | Trunc | 8.83 | 6.62 | 18.5 | 77.9 | 0.08 |
ddust D128<18> | HalfExpand | 10.1 | 7.22 | 20.2 | 83.6 | 0.09 |
primitive_fixed_point_decimal 1.5.0 <i128, 18> | HalfExpand | 11.7 | 7.30 | 20.4 | 81.8 | 0.07 |
ddust D128<18> | HalfEven | 10.1 | 8.12 | 22.7 | 92.0 | 0.09 |
nexus-decimal 1.2.2 <i128, 18> | Trunc | 10.1 | 8.63 | 24.1 | 73.8 | 0.54 |
fin_decimal 0.4.0 Decimal128<18> | HalfEven | 11.2 | 12.9 | 36.0 | 98.6 | 0.79 |
fixnum 0.9.5 <i128, U18> | HalfExpand | 20.0 | 17.4 | 48.8 | 141 | 1.27 |
fastnum 0.7.5 D128 | HalfExpand | 132 | 44.7 | 125 | 347 | 1.68 |
| decimal-rs 0.2.0 | HalfExpand | 130 | 52.5 | 147 | 372 | 0.81 |
| rust_decimal 1.43.0 | HalfEven | 69.7 | 57.8 | 162 | 389 | 2.03 |
| bigdecimal 0.4.11 | HalfEven | 246 | 171 | 478 | 1,771 | 4.82 |
Division
A rounded quotient is in an integer twice as wide. A
narrow numerator that fits in 64 bits is divided by one udiv. On aarch64, a
wider one is taken from a double’s estimate, which the core’s pipelined fdiv
makes in a few cycles, then corrected to the exact quotient by a comparison, or
a step and a comparison, wherever a proof bounds the estimate’s error; a wide
quotient, 256 bits by 128, is taken the same way:
The rounded quotient, narrow, 64 bits at 8 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
f64 | HalfEven | 0.89 | 0.93 | 2.60 | 3.75 | 0.00 |
i64 | Trunc | 2.17 | 2.13 | 5.94 | 12.0 | 0.00 |
ddust D64<8> | Trunc | 3.82 | 4.49 | 12.6 | 46.7 | 0.22 |
ddust D64<8> | HalfExpand | 4.54 | 5.31 | 14.9 | 52.2 | 0.23 |
ddust D64<8> | HalfEven | 4.70 | 5.40 | 15.1 | 52.2 | 0.24 |
fixdec 0.1.0 D64 | Trunc | 8.00 | 8.12 | 22.7 | 72.8 | 0.45 |
nexus-decimal 1.2.2 <i64, 8> | Trunc | 8.12 | 8.18 | 22.9 | 72.8 | 0.46 |
primitive_fixed_point_decimal 1.5.0 <i64, 8> | HalfExpand | 11.7 | 10.9 | 30.5 | 71.4 | 1.34 |
fin_decimal 0.4.0 Decimal<8> | HalfEven | 13.6 | 11.4 | 31.8 | 66.4 | 0.73 |
fixnum 0.9.5 <i64, U8> | HalfExpand | 9.99 | 14.3 | 40.0 | 100 | 0.88 |
fastnum 0.7.5 D64 | HalfExpand | 68.7 | 86.2 | 241 | 525 | 2.67 |
| rust_decimal 1.43.0 | HalfEven | 104 | 114 | 318 | 763 | 2.98 |
| decimal-rs 0.2.0 | HalfExpand | 209 | 199 | 555 | 1,154 | 2.68 |
| bigdecimal 0.4.11 | HalfEven | 4,278 | 4,416 | 12,348 | 49,100 | 29.78 |
The rounded quotient, wide, 128 bits at 18 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
f64 | HalfEven | 0.89 | 0.93 | 2.59 | 3.75 | 0.00 |
i128 | Trunc | 6.97 | 6.29 | 17.6 | 69.3 | 0.41 |
ddust D128<18> | Trunc | 12.7 | 13.5 | 37.9 | 129 | 0.49 |
ddust D128<18> | HalfExpand | 13.6 | 14.4 | 40.1 | 138 | 0.50 |
ddust D128<18> | HalfEven | 15.0 | 15.8 | 44.2 | 140 | 0.50 |
nexus-decimal 1.2.2 <i128, 18> | Trunc | 36.3 | 22.2 | 62.2 | 172 | 1.58 |
fin_decimal 0.4.0 Decimal128<18> | HalfEven | 35.2 | 27.4 | 76.6 | 201 | 1.47 |
primitive_fixed_point_decimal 1.5.0 <i128, 18> | HalfExpand | 49.6 | 29.9 | 83.6 | 240 | 1.80 |
fixnum 0.9.5 <i128, U18> | HalfExpand | 39.3 | 34.7 | 97.0 | 256 | 2.41 |
| rust_decimal 1.43.0 | HalfEven | 84.9 | 98.8 | 276 | 637 | 4.33 |
| decimal-rs 0.2.0 | HalfExpand | 234 | 198 | 554 | 1,127 | 3.07 |
fastnum 0.7.5 D128 | HalfExpand | 206 | 206 | 576 | 1,324 | 6.70 |
| bigdecimal 0.4.11 | HalfEven | 14,834 | 10,127 | 28,315 | 138,450 | 49.13 |
The same quotients, each dividend by one divisor, as a book is divided by one price:
The rounded quotient by one divisor, narrow, 64 bits at 8 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
f64 | HalfEven | 0.89 | 0.92 | 2.58 | 3.75 | 0.00 |
i64 | Trunc | 2.17 | 2.40 | 6.72 | 12.0 | 0.00 |
ddust D64<8> | Trunc | 2.71 | 4.29 | 12.0 | 30.3 | 0.29 |
ddust D64<8> | HalfExpand | 3.38 | 4.88 | 13.7 | 36.7 | 0.32 |
ddust D64<8> | HalfEven | 4.72 | 5.97 | 16.7 | 52.9 | 0.30 |
fixdec 0.1.0 D64 | Trunc | 7.81 | 6.81 | 19.1 | 69.9 | 0.20 |
nexus-decimal 1.2.2 <i64, 8> | Trunc | 7.80 | 6.81 | 19.1 | 69.9 | 0.20 |
primitive_fixed_point_decimal 1.5.0 <i64, 8> | HalfExpand | 11.2 | 8.81 | 24.6 | 67.9 | 0.77 |
fin_decimal 0.4.0 Decimal<8> | HalfEven | 13.2 | 11.2 | 31.3 | 66.6 | 0.64 |
fixnum 0.9.5 <i64, U8> | HalfExpand | 10.5 | 14.0 | 39.1 | 97.8 | 0.67 |
fastnum 0.7.5 D64 | HalfExpand | 70.4 | 80.1 | 224 | 501 | 2.48 |
| rust_decimal 1.43.0 | HalfEven | 106 | 109 | 306 | 716 | 2.46 |
| decimal-rs 0.2.0 | HalfExpand | 210 | 208 | 582 | 1,248 | 2.61 |
| bigdecimal 0.4.11 | HalfEven | 4,251 | 4,411 | 12,335 | 47,381 | 23.30 |
The rounded quotient by one divisor, wide, 128 bits at 18 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
f64 | HalfEven | 0.89 | 0.93 | 2.60 | 3.50 | 0.00 |
i128 | Trunc | 6.85 | 7.52 | 21.0 | 71.9 | 0.65 |
ddust D128<18> | Trunc | 12.8 | 13.6 | 38.0 | 129 | 0.50 |
ddust D128<18> | HalfExpand | 13.6 | 14.5 | 40.5 | 138 | 0.50 |
ddust D128<18> | HalfEven | 15.0 | 16.0 | 44.7 | 140 | 0.50 |
nexus-decimal 1.2.2 <i128, 18> | Trunc | 38.0 | 22.3 | 62.4 | 174 | 1.39 |
fin_decimal 0.4.0 Decimal128<18> | HalfEven | 32.8 | 23.2 | 64.8 | 154 | 0.93 |
primitive_fixed_point_decimal 1.5.0 <i128, 18> | HalfExpand | 49.7 | 30.0 | 84.1 | 243 | 1.37 |
fixnum 0.9.5 <i128, U18> | HalfExpand | 44.2 | 34.9 | 97.6 | 261 | 1.77 |
| rust_decimal 1.43.0 | HalfEven | 86.3 | 86.9 | 243 | 610 | 3.05 |
fastnum 0.7.5 D128 | HalfExpand | 198 | 171 | 479 | 1,151 | 4.51 |
| decimal-rs 0.2.0 | HalfExpand | 227 | 208 | 581 | 1,183 | 2.66 |
| bigdecimal 0.4.11 | HalfEven | 14,917 | 4,376 | 12,236 | 50,876 | 37.99 |
A Divisor prepared from that one divisor divides by two multiplications and
a comparison, with no division at all; no other crate has one:
The rounded quotient by a prepared divisor, narrow, 64 bits at 8 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
ddust D64<8> | Trunc | 2.07 | 2.07 | 5.78 | 29.0 | 0.00 |
ddust D64<8> | HalfExpand | 2.70 | 2.70 | 7.55 | 35.0 | 0.00 |
ddust D64<8> | HalfEven | 3.01 | 3.00 | 8.37 | 37.0 | 0.00 |
The rounded quotient by a prepared divisor, wide, 128 bits at 18 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
ddust D128<18> | Trunc | 7.88 | 9.32 | 26.1 | 91.0 | 0.48 |
ddust D128<18> | HalfExpand | 10.4 | 11.5 | 32.2 | 111 | 0.48 |
ddust D128<18> | HalfEven | 11.1 | 12.4 | 34.7 | 120 | 0.49 |
A narrow quotient by a prepared divisor takes 3.00 ns where by the decimal it takes 5.97, and a wide one 12.4 where by the decimal it takes 16.0. Preparing it costs a division, so it pays from the second quotient on.
Rescaling
A value rounded from 8 or 18 decimals to 2, a price to cents, divides by a power of ten the compiler knows, through a multiply by its reciprocal:
To cents, narrow, 64 bits at 8 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
i64 | Trunc | 0.72 | 0.72 | 2.02 | 6.75 | 0.00 |
ddust D64<8> | Trunc | 0.84 | 0.84 | 2.36 | 10.0 | 0.00 |
f64 | HalfExpand | 0.92 | 0.89 | 2.49 | 4.00 | 0.00 |
ddust D64<8> | HalfExpand | 1.14 | 1.14 | 3.19 | 14.0 | 0.00 |
ddust D64<8> | HalfEven | 1.30 | 1.30 | 3.63 | 16.0 | 0.00 |
primitive_fixed_point_decimal 1.5.0 <i64, 8> | HalfExpand | 1.13 | 3.21 | 8.98 | 17.0 | 0.48 |
fixdec 0.1.0 D64 | HalfEven | 1.19 | 3.82 | 10.7 | 18.7 | 0.34 |
nexus-decimal 1.2.2 <i64, 8> | HalfEven | 1.88 | 4.32 | 12.1 | 23.8 | 0.33 |
fin_decimal 0.4.0 Decimal<8> | HalfEven | 1.86 | 4.99 | 13.9 | 27.0 | 0.35 |
| decimal-rs 0.2.0 | HalfExpand | 5.32 | 6.57 | 18.4 | 61.1 | 0.32 |
fastnum 0.7.5 D64 | HalfExpand | 12.9 | 18.3 | 51.3 | 135 | 1.08 |
| rust_decimal 1.43.0 | HalfEven | 20.2 | 21.8 | 61.0 | 190 | 0.36 |
| bigdecimal 0.4.11 | HalfEven | 87.8 | 75.5 | 211 | 917 | 2.06 |
To cents, wide, 128 bits at 18 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
f64 | HalfExpand | 0.92 | 0.82 | 2.30 | 4.00 | 0.00 |
ddust D128<18> | HalfExpand | 3.89 | 3.91 | 10.9 | 31.1 | 0.25 |
ddust D128<18> | Trunc | 3.89 | 4.02 | 11.2 | 31.1 | 0.26 |
ddust D128<18> | HalfEven | 4.12 | 4.15 | 11.6 | 33.1 | 0.25 |
primitive_fixed_point_decimal 1.5.0 <i128, 18> | HalfExpand | 12.4 | 8.39 | 23.5 | 58.9 | 0.98 |
i128 | Trunc | 10.1 | 8.66 | 24.2 | 68.5 | 0.95 |
fin_decimal 0.4.0 Decimal128<18> | HalfEven | 12.4 | 8.89 | 24.9 | 51.3 | 0.52 |
| decimal-rs 0.2.0 | HalfExpand | 10.9 | 9.58 | 26.6 | 66.8 | 0.71 |
nexus-decimal 1.2.2 <i128, 18> | HalfEven | 18.5 | 14.7 | 40.8 | 114 | 1.28 |
fastnum 0.7.5 D128 | HalfExpand | 22.0 | 24.0 | 67.2 | 184 | 1.05 |
| rust_decimal 1.43.0 | HalfEven | 42.9 | 43.7 | 122 | 354 | 0.23 |
| bigdecimal 0.4.11 | HalfEven | 126 | 90.5 | 253 | 1,010 | 2.35 |
Text
Reading takes a number of up to 32 bytes into 32 vector lanes at once, puts its digits in their steps’ places by one lookup, and makes the number by multiplies in the lanes, with no branch on its length, so the unpredictable set costs nearly what the predictable one does. Each crate reads the text exactly, and refuses a digit past its scale; fin_decimal rounds one instead, and is marked so:
Reading, narrow, 64 bits at 8 decimals:
| Contender | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|
ddust D64<8> | 9.56 | 9.75 | 27.3 | 101 | 0.17 |
| rust_decimal 1.43.0 | 17.5 | 19.1 | 53.5 | 175 | 1.74 |
nexus-decimal 1.2.2 <i64, 8> | 11.6 | 19.2 | 53.8 | 173 | 1.66 |
f64 | 14.2 | 19.5 | 54.6 | 169 | 1.78 |
primitive_fixed_point_decimal 1.5.0 <i64, 8> | 18.8 | 20.5 | 57.4 | 202 | 1.69 |
fin_decimal 0.4.0 Decimal<8>, inexact | 15.8 | 20.7 | 57.8 | 216 | 1.68 |
fixdec 0.1.0 D64 | 21.4 | 23.5 | 65.7 | 241 | 1.51 |
fastnum 0.7.5 D64 | 20.4 | 24.2 | 67.6 | 267 | 1.68 |
fixnum 0.9.5 <i64, U8> | 26.7 | 29.4 | 82.3 | 360 | 1.55 |
| decimal-rs 0.2.0 | 37.8 | 43.6 | 122 | 384 | 2.96 |
| bigdecimal 0.4.11 | 92.8 | 91.1 | 255 | 1,373 | 1.42 |
Reading, wide, 128 bits at 18 decimals:
| Contender | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|
ddust D128<18> | 9.32 | 10.0 | 28.1 | 95.5 | 0.50 |
nexus-decimal 1.2.2 <i128, 18> | 16.9 | 20.8 | 58.3 | 230 | 1.38 |
| rust_decimal 1.43.0 | 39.8 | 30.0 | 83.9 | 365 | 1.56 |
f64 | 48.4 | 31.8 | 89.1 | 329 | 1.33 |
fastnum 0.7.5 D128 | 30.8 | 31.9 | 89.3 | 429 | 1.45 |
fin_decimal 0.4.0 Decimal128<18>, inexact | 35.5 | 35.2 | 98.4 | 425 | 1.54 |
primitive_fixed_point_decimal 1.5.0 <i128, 18> | 44.0 | 39.6 | 110 | 402 | 1.58 |
| decimal-rs 0.2.0 | 59.7 | 62.1 | 174 | 577 | 2.13 |
fixnum 0.9.5 <i128, U18> | 68.1 | 65.9 | 184 | 762 | 1.78 |
| bigdecimal 0.4.11 | 129 | 117 | 326 | 1,751 | 1.34 |
Writing splits the steps into their 32 digits by multiplies, and makes each
window of the text by one lookup into them; Display, through a formatter, is
measured where a crate has nothing faster:
Writing, narrow, 64 bits at 8 decimals:
| Contender | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|
ddust D64<8> | 9.84 | 9.84 | 27.5 | 99.0 | 0.00 |
nexus-decimal 1.2.2 <i64, 8> | 18.4 | 23.3 | 65.1 | 198 | 2.25 |
i64 | 14.7 | 26.5 | 74.2 | 215 | 2.25 |
fin_decimal 0.4.0 Decimal<8> | 17.4 | 26.6 | 74.4 | 275 | 1.83 |
fixnum 0.9.5 <i64, U8> | 21.2 | 28.5 | 79.8 | 268 | 1.62 |
primitive_fixed_point_decimal 1.5.0 <i64, 8> | 23.9 | 33.4 | 93.6 | 333 | 2.54 |
fixdec 0.1.0 D64 | 34.2 | 40.4 | 113 | 408 | 2.12 |
| decimal-rs 0.2.0 | 34.4 | 44.0 | 123 | 517 | 2.20 |
| rust_decimal 1.43.0 | 77.7 | 67.0 | 187 | 641 | 1.45 |
f64 | 63.8 | 67.7 | 189 | 650 | 2.98 |
fastnum 0.7.5 D64 | 64.9 | 80.0 | 224 | 889 | 2.22 |
| bigdecimal 0.4.11 | 108 | 111 | 312 | 1,391 | 1.89 |
Writing, wide, 128 bits at 18 decimals:
| Contender | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|
ddust D128<18> | 18.0 | 16.4 | 45.9 | 177 | 0.32 |
i128 | 20.9 | 31.5 | 88.2 | 263 | 2.62 |
fixnum 0.9.5 <i128, U18> | 44.1 | 43.0 | 120 | 429 | 1.74 |
fin_decimal 0.4.0 Decimal128<18> | 58.3 | 46.2 | 129 | 611 | 1.34 |
| decimal-rs 0.2.0 | 37.9 | 48.4 | 135 | 551 | 2.62 |
nexus-decimal 1.2.2 <i128, 18> | 68.4 | 49.3 | 138 | 439 | 2.29 |
f64 | 65.8 | 74.4 | 208 | 706 | 3.50 |
primitive_fixed_point_decimal 1.5.0 <i128, 18> | 96.3 | 77.6 | 217 | 569 | 2.22 |
| rust_decimal 1.43.0 | 118 | 91.7 | 256 | 956 | 1.48 |
fastnum 0.7.5 D128 | 83.1 | 103 | 289 | 1,148 | 2.66 |
| bigdecimal 0.4.11 | 148 | 139 | 389 | 1,646 | 2.40 |
Floats
A decimal becomes the nearest double. Steps below at up to 22 decimals take one conversion and one division by an exact power of ten, Clinger’s fast path; past them, a division of the steps by a power of five makes the double’s 53 bits and the bits that round them. The predictable set’s steps have 17 and 28 digits, past , so at narrow width it is the slower one here. The crates marked inexact compute the double in floating point, rounding more than once:
To a double, narrow, 64 bits at 8 decimals:
| Contender | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|
i64 | 0.12 | 0.15 | 0.43 | 2.00 | 0.00 |
primitive_fixed_point_decimal 1.5.0 <i64, 8>, inexact | 0.89 | 0.89 | 2.50 | 2.50 | 0.00 |
fin_decimal 0.4.0 Decimal<8>, inexact | 0.89 | 0.89 | 2.50 | 2.50 | 0.00 |
fixnum 0.9.5 <i64, U8>, inexact | 1.23 | 1.23 | 3.44 | 11.5 | 0.00 |
nexus-decimal 1.2.2 <i64, 8>, inexact | 1.23 | 1.23 | 3.44 | 11.5 | 0.00 |
ddust D64<8> | 4.79 | 1.38 | 3.87 | 15.6 | 0.04 |
fixdec 0.1.0 D64, inexact | 1.70 | 1.61 | 4.52 | 19.0 | 0.00 |
fastnum 0.7.5 D64 | 6.21 | 2.87 | 8.02 | 47.7 | 0.07 |
| decimal-rs 0.2.0 | 41.1 | 10.6 | 29.7 | 141 | 0.20 |
| rust_decimal 1.43.0, inexact | 21.7 | 23.9 | 66.8 | 246 | 0.59 |
| bigdecimal 0.4.11 | 109 | 108 | 301 | 1,234 | 1.56 |
To a double, wide, 128 bits at 18 decimals:
| Contender | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|
i128 | 3.55 | 3.55 | 9.94 | 43.5 | 0.00 |
primitive_fixed_point_decimal 1.5.0 <i128, 18>, inexact | 3.61 | 3.60 | 10.1 | 44.3 | 0.00 |
fin_decimal 0.4.0 Decimal128<18>, inexact | 3.60 | 3.60 | 10.1 | 44.3 | 0.00 |
ddust D128<18> | 7.42 | 7.73 | 21.6 | 60.7 | 0.43 |
fastnum 0.7.5 D128 | 32.3 | 15.5 | 43.5 | 136 | 0.44 |
nexus-decimal 1.2.2 <i128, 18>, inexact | 24.7 | 17.0 | 47.5 | 153 | 0.71 |
fixnum 0.9.5 <i128, U18>, inexact | 24.8 | 17.0 | 47.5 | 153 | 0.71 |
| rust_decimal 1.43.0, inexact | 33.8 | 27.3 | 76.4 | 274 | 0.78 |
| decimal-rs 0.2.0 | 81.2 | 42.7 | 119 | 496 | 1.46 |
| bigdecimal 0.4.11 | 184 | 144 | 401 | 1,601 | 1.98 |
A double becomes the decimal nearest its exact binary value, by the mode. The product of the double and the power of ten, and its residual, which a fused multiply-add computes exactly, decide the rounding below ; past it, the double’s bits are scaled exactly. The crates marked inexact multiply in floating point, or round the double’s shortest text:
From a double, narrow, 64 bits at 8 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
i64 | Trunc | 0.25 | 0.30 | 0.84 | 3.00 | 0.00 |
fin_decimal 0.4.0 Decimal<8>, inexact | Trunc | 0.43 | 0.47 | 1.32 | 4.75 | 0.00 |
primitive_fixed_point_decimal 1.5.0 <i64, 8>, inexact | HalfExpand | 0.62 | 0.62 | 1.74 | 5.75 | 0.00 |
nexus-decimal 1.2.2 <i64, 8>, inexact | HalfExpand | 1.02 | 1.03 | 2.88 | 15.0 | 0.00 |
fixdec 0.1.0 D64, inexact | HalfExpand | 1.02 | 1.03 | 2.88 | 15.0 | 0.00 |
ddust D64<8> | HalfEven | 5.14 | 3.91 | 10.9 | 52.1 | 0.09 |
fixnum 0.9.5 <i64, U8>, inexact | HalfExpand | 35.7 | 92.2 | 258 | 555 | 6.28 |
fastnum 0.7.5 D64 | HalfExpand | 63.2 | 176 | 492 | 1,193 | 3.62 |
| decimal-rs 0.2.0, inexact | HalfExpand | 113 | 231 | 645 | 1,304 | 7.46 |
| rust_decimal 1.43.0, inexact | HalfEven | 119 | 260 | 727 | 1,274 | 20.49 |
| bigdecimal 0.4.11 | HalfEven | 217 | 343 | 956 | 3,854 | 5.08 |
From a double, wide, 128 bits at 18 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
primitive_fixed_point_decimal 1.5.0 <i128, 18>, inexact | HalfExpand | 2.87 | 2.87 | 8.02 | 42.0 | 0.00 |
fin_decimal 0.4.0 Decimal128<18>, inexact | Trunc | 3.07 | 3.08 | 8.60 | 49.0 | 0.00 |
nexus-decimal 1.2.2 <i128, 18>, inexact | HalfExpand | 3.42 | 3.44 | 9.61 | 53.0 | 0.00 |
i128 | Trunc | 2.78 | 4.29 | 12.0 | 30.2 | 0.36 |
ddust D128<18> | HalfEven | 4.94 | 6.31 | 17.7 | 66.0 | 0.49 |
fixnum 0.9.5 <i128, U18>, inexact | HalfExpand | 36.8 | 138 | 387 | 743 | 12.37 |
fastnum 0.7.5 D128 | HalfExpand | 104 | 179 | 502 | 1,348 | 4.17 |
| decimal-rs 0.2.0, inexact | HalfExpand | 95.7 | 274 | 766 | 1,459 | 13.83 |
| rust_decimal 1.43.0, inexact | HalfEven | 108 | 299 | 835 | 1,485 | 26.92 |
| bigdecimal 0.4.11 | HalfEven | 202 | 407 | 1,136 | 4,435 | 6.12 |
Latency
A chain makes each operation wait for the one before, by taking its operands at an index the previous result decides: what a calculation that feeds on its own results costs, where throughput is what a loop over independent values costs. The index takes a byte that depends on both halves of each result, so a compiler that inlines the operation into the chain still computes all of it.
The checked sum, in a chain, narrow, 64 bits at 8 decimals:
| Contender | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|
ddust D64<8> | 3.57 | 3.67 | 10.3 | 14.0 | 0.00 |
i64 | 3.57 | 3.67 | 10.3 | 14.0 | 0.00 |
nexus-decimal 1.2.2 <i64, 8> | 3.56 | 3.68 | 10.3 | 14.0 | 0.00 |
fixnum 0.9.5 <i64, U8> | 3.56 | 3.68 | 10.3 | 14.0 | 0.00 |
fin_decimal 0.4.0 Decimal<8> | 3.56 | 3.68 | 10.3 | 14.0 | 0.00 |
primitive_fixed_point_decimal 1.5.0 <i64, 8> | 3.57 | 3.68 | 10.3 | 14.0 | 0.00 |
fixdec 0.1.0 D64 | 3.56 | 3.69 | 10.3 | 14.0 | 0.00 |
f64 | 4.94 | 5.00 | 14.0 | 14.0 | 0.00 |
fixed 1.31.0 I64F64 | 5.12 | 5.20 | 14.6 | 19.0 | 0.00 |
| decimal-rs 0.2.0 | 7.48 | 11.6 | 32.5 | 65.8 | 0.78 |
| rust_decimal 1.43.0 | 13.0 | 17.8 | 49.7 | 84.5 | 1.20 |
fastnum 0.7.5 D64 | 20.2 | 29.7 | 83.2 | 136 | 1.43 |
| bigdecimal 0.4.11 | 37.6 | 45.6 | 128 | 330 | 1.75 |
The checked sum, in a chain, wide, 128 bits at 18 decimals:
| Contender | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|
f64 | 4.94 | 5.00 | 14.0 | 14.0 | 0.00 |
i128 | 5.12 | 5.20 | 14.5 | 19.0 | 0.00 |
primitive_fixed_point_decimal 1.5.0 <i128, 18> | 5.12 | 5.20 | 14.5 | 19.0 | 0.00 |
fixnum 0.9.5 <i128, U18> | 5.12 | 5.20 | 14.5 | 19.0 | 0.00 |
ddust D128<18> | 5.12 | 5.20 | 14.5 | 19.0 | 0.00 |
nexus-decimal 1.2.2 <i128, 18> | 5.12 | 5.20 | 14.5 | 19.0 | 0.00 |
fin_decimal 0.4.0 Decimal128<18> | 5.12 | 5.20 | 14.5 | 19.0 | 0.00 |
| decimal-rs 0.2.0 | 7.49 | 11.6 | 32.4 | 65.8 | 0.77 |
| rust_decimal 1.43.0 | 15.7 | 20.5 | 57.3 | 83.1 | 1.50 |
fastnum 0.7.5 D128 | 18.4 | 26.7 | 74.6 | 155 | 1.57 |
| bigdecimal 0.4.11 | 56.6 | 55.5 | 155 | 462 | 2.22 |
The rounded product, in a chain, narrow, 64 bits at 8 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
i64 | Trunc | 4.62 | 4.65 | 13.0 | 16.0 | 0.00 |
f64 | HalfEven | 5.29 | 5.36 | 15.0 | 14.0 | 0.00 |
fixdec 0.1.0 D64, inexact | Trunc | 5.20 | 7.53 | 21.1 | 26.0 | 0.47 |
ddust D64<8> | Trunc | 10.2 | 8.08 | 22.6 | 31.9 | 0.14 |
fixed 1.31.0 I64F64 | Floor | 8.67 | 8.66 | 24.2 | 41.0 | 0.00 |
ddust D64<8> | HalfExpand | 10.6 | 9.00 | 25.2 | 34.8 | 0.14 |
primitive_fixed_point_decimal 1.5.0 <i64, 8> | HalfExpand | 10.9 | 9.10 | 25.4 | 34.2 | 0.17 |
ddust D64<8> | HalfEven | 12.4 | 9.46 | 26.5 | 39.3 | 0.15 |
nexus-decimal 1.2.2 <i64, 8> | Trunc | 10.6 | 12.9 | 36.0 | 33.5 | 0.45 |
fin_decimal 0.4.0 Decimal<8> | HalfEven | 11.7 | 14.2 | 39.7 | 55.0 | 0.58 |
fixnum 0.9.5 <i64, U8> | HalfExpand | 22.1 | 16.3 | 45.7 | 96.5 | 0.77 |
| rust_decimal 1.43.0 | HalfEven | 36.7 | 39.1 | 109 | 286 | 0.87 |
fastnum 0.7.5 D64 | HalfExpand | 84.6 | 43.9 | 123 | 263 | 1.24 |
| decimal-rs 0.2.0 | HalfExpand | 52.7 | 45.0 | 126 | 295 | 0.33 |
| bigdecimal 0.4.11 | HalfEven | 162 | 124 | 347 | 1,328 | 4.02 |
The rounded product, in a chain, wide, 128 bits at 18 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
f64 | HalfEven | 5.29 | 5.36 | 15.0 | 14.0 | 0.00 |
i128 | Trunc | 3.76 | 7.73 | 21.6 | 29.1 | 0.30 |
primitive_fixed_point_decimal 1.5.0 <i128, 18> | HalfExpand | 21.9 | 14.1 | 39.5 | 72.9 | 0.06 |
ddust D128<18> | Trunc | 17.9 | 15.3 | 42.9 | 86.9 | 0.10 |
nexus-decimal 1.2.2 <i128, 18> | Trunc | 20.1 | 15.7 | 43.9 | 66.7 | 0.51 |
ddust D128<18> | HalfExpand | 19.1 | 15.7 | 44.1 | 92.6 | 0.10 |
ddust D128<18> | HalfEven | 19.9 | 17.7 | 49.5 | 101 | 0.09 |
fin_decimal 0.4.0 Decimal128<18> | HalfEven | 23.1 | 19.5 | 54.7 | 109 | 0.81 |
fixnum 0.9.5 <i128, U18> | HalfExpand | 30.8 | 23.5 | 65.7 | 151 | 1.19 |
fastnum 0.7.5 D128 | HalfExpand | 150 | 53.7 | 150 | 361 | 1.70 |
| decimal-rs 0.2.0 | HalfExpand | 148 | 57.7 | 161 | 354 | 0.83 |
| rust_decimal 1.43.0 | HalfEven | 85.4 | 64.5 | 180 | 405 | 1.99 |
| bigdecimal 0.4.11 | HalfEven | 246 | 171 | 478 | 1,770 | 4.84 |
The rounded quotient, in a chain, narrow, 64 bits at 8 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
i64 | Trunc | 4.97 | 4.95 | 13.9 | 15.0 | 0.00 |
f64 | HalfEven | 8.61 | 8.73 | 24.5 | 14.0 | 0.00 |
ddust D64<8> | Trunc | 14.6 | 10.5 | 29.4 | 33.5 | 0.22 |
ddust D64<8> | HalfExpand | 15.3 | 12.2 | 34.0 | 40.0 | 0.22 |
ddust D64<8> | HalfEven | 16.1 | 12.4 | 34.7 | 39.9 | 0.24 |
fixdec 0.1.0 D64 | Trunc | 17.5 | 16.0 | 44.8 | 74.8 | 0.47 |
nexus-decimal 1.2.2 <i64, 8> | Trunc | 17.5 | 16.0 | 44.8 | 74.8 | 0.47 |
primitive_fixed_point_decimal 1.5.0 <i64, 8> | HalfExpand | 21.8 | 16.1 | 45.0 | 63.8 | 1.30 |
fin_decimal 0.4.0 Decimal<8> | HalfEven | 20.8 | 16.2 | 45.2 | 59.7 | 0.78 |
fixnum 0.9.5 <i64, U8> | HalfExpand | 16.9 | 18.2 | 50.9 | 93.9 | 0.91 |
fastnum 0.7.5 D64 | HalfExpand | 82.7 | 95.6 | 267 | 535 | 2.68 |
| rust_decimal 1.43.0 | HalfEven | 109 | 118 | 329 | 764 | 2.98 |
| decimal-rs 0.2.0 | HalfExpand | 220 | 207 | 580 | 1,141 | 2.67 |
| bigdecimal 0.4.11 | HalfEven | 4,269 | 4,410 | 12,332 | 49,179 | 30.47 |
The rounded quotient, in a chain, wide, 128 bits at 18 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
f64 | HalfEven | 8.61 | 8.73 | 24.5 | 14.0 | 0.00 |
i128 | Trunc | 14.5 | 10.1 | 28.2 | 77.3 | 0.36 |
ddust D128<18> | Trunc | 30.3 | 30.6 | 85.5 | 143 | 0.49 |
nexus-decimal 1.2.2 <i128, 18> | Trunc | 50.6 | 31.1 | 87.0 | 182 | 1.59 |
ddust D128<18> | HalfExpand | 31.4 | 31.7 | 88.6 | 152 | 0.50 |
ddust D128<18> | HalfEven | 33.1 | 33.2 | 92.8 | 154 | 0.50 |
fin_decimal 0.4.0 Decimal128<18> | HalfEven | 46.1 | 37.2 | 104 | 216 | 1.50 |
primitive_fixed_point_decimal 1.5.0 <i128, 18> | HalfExpand | 60.8 | 39.7 | 111 | 255 | 1.82 |
fixnum 0.9.5 <i128, U18> | HalfExpand | 43.9 | 40.9 | 115 | 271 | 2.45 |
| rust_decimal 1.43.0 | HalfEven | 93.3 | 105 | 292 | 638 | 4.31 |
| decimal-rs 0.2.0 | HalfExpand | 243 | 208 | 580 | 1,115 | 3.08 |
fastnum 0.7.5 D128 | HalfExpand | 217 | 218 | 609 | 1,338 | 6.62 |
| bigdecimal 0.4.11 | HalfEven | 14,829 | 9,958 | 27,849 | 138,627 | 48.67 |
The rounded quotient by one divisor, in a chain, narrow, 64 bits at 8 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
i64 | Trunc | 4.97 | 5.24 | 14.7 | 15.0 | 0.00 |
f64 | HalfEven | 8.62 | 8.74 | 24.5 | 14.0 | 0.00 |
ddust D64<8> | Trunc | 14.5 | 11.5 | 32.1 | 34.4 | 0.29 |
ddust D64<8> | HalfExpand | 15.3 | 13.0 | 36.3 | 40.7 | 0.26 |
ddust D64<8> | HalfEven | 16.2 | 13.3 | 37.3 | 40.6 | 0.28 |
primitive_fixed_point_decimal 1.5.0 <i64, 8> | HalfExpand | 21.4 | 15.8 | 44.3 | 60.5 | 0.78 |
nexus-decimal 1.2.2 <i64, 8> | Trunc | 17.1 | 16.0 | 44.9 | 71.9 | 0.20 |
fixdec 0.1.0 D64 | Trunc | 17.1 | 16.0 | 44.9 | 71.9 | 0.20 |
fin_decimal 0.4.0 Decimal<8> | HalfEven | 20.5 | 16.6 | 46.5 | 59.8 | 0.70 |
fixnum 0.9.5 <i64, U8> | HalfExpand | 17.0 | 18.3 | 51.2 | 91.3 | 0.67 |
fastnum 0.7.5 D64 | HalfExpand | 83.3 | 90.5 | 253 | 511 | 2.48 |
| rust_decimal 1.43.0 | HalfEven | 109 | 113 | 316 | 717 | 2.46 |
| decimal-rs 0.2.0 | HalfExpand | 222 | 220 | 616 | 1,236 | 2.61 |
| bigdecimal 0.4.11 | HalfEven | 4,251 | 4,392 | 12,285 | 47,476 | 22.86 |
The rounded quotient by one divisor, in a chain, wide, 128 bits at 18 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
f64 | HalfEven | 8.61 | 8.80 | 24.6 | 14.0 | 0.00 |
i128 | Trunc | 14.5 | 11.6 | 32.4 | 78.9 | 0.60 |
ddust D128<18> | Trunc | 30.3 | 30.6 | 85.5 | 143 | 0.49 |
nexus-decimal 1.2.2 <i128, 18> | Trunc | 51.2 | 31.4 | 87.9 | 184 | 1.37 |
ddust D128<18> | HalfExpand | 31.3 | 31.6 | 88.4 | 152 | 0.50 |
ddust D128<18> | HalfEven | 33.1 | 33.2 | 92.7 | 154 | 0.49 |
fin_decimal 0.4.0 Decimal128<18> | HalfEven | 42.8 | 33.4 | 93.5 | 169 | 0.95 |
primitive_fixed_point_decimal 1.5.0 <i128, 18> | HalfExpand | 60.8 | 40.8 | 114 | 258 | 1.36 |
fixnum 0.9.5 <i128, U18> | HalfExpand | 47.9 | 41.9 | 117 | 276 | 1.79 |
| rust_decimal 1.43.0 | HalfEven | 92.5 | 93.1 | 260 | 622 | 3.01 |
fastnum 0.7.5 D128 | HalfExpand | 211 | 183 | 512 | 1,165 | 4.44 |
| decimal-rs 0.2.0 | HalfExpand | 240 | 219 | 614 | 1,179 | 2.68 |
| bigdecimal 0.4.11 | HalfEven | 14,907 | 4,352 | 12,171 | 50,970 | 37.22 |
The rounded quotient by a prepared divisor, in a chain, narrow, 64 bits at 8 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
ddust D64<8> | Trunc | 6.13 | 6.13 | 17.2 | 34.0 | 0.00 |
ddust D64<8> | HalfExpand | 7.59 | 7.60 | 21.3 | 40.0 | 0.00 |
ddust D64<8> | HalfEven | 8.63 | 8.64 | 24.2 | 43.0 | 0.00 |
The rounded quotient by a prepared divisor, in a chain, wide, 128 bits at 18 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | Cycles | Instructions | Branch misses |
|---|---|---|---|---|---|---|
ddust D128<18> | Trunc | 13.8 | 14.4 | 40.2 | 94.0 | 0.49 |
ddust D128<18> | HalfExpand | 17.2 | 17.7 | 49.5 | 114 | 0.49 |
ddust D128<18> | HalfEven | 19.4 | 19.7 | 55.1 | 123 | 0.50 |
A Mix, and Code Size
A program mixes operations, and what each brings into the instruction cache is then a cost of its own. The mix runs a sum, an order, a rounded product, a rounded quotient and a value to cents in turn over the same values, with the level 1 instruction cache’s misses and the front end’s stalled cycles per operation:
The mix, narrow, 64 bits at 8 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | L1i misses | Front-end stalls |
|---|---|---|---|---|---|
i64 | Trunc | 0.48 | 0.47 | 0.000 | 0.00 |
f64 | HalfEven, HalfExpand | 0.49 | 0.48 | 0.000 | 0.00 |
ddust D64<8> | Trunc | 1.18 | 1.41 | 0.000 | 0.40 |
ddust D64<8> | HalfExpand | 1.40 | 1.61 | 0.000 | 0.36 |
ddust D64<8> | HalfEven | 1.66 | 1.73 | 0.000 | 0.37 |
fixdec 0.1.0 D64, inexact | Trunc, HalfEven | 2.29 | 3.21 | 0.000 | 1.30 |
nexus-decimal 1.2.2 <i64, 8> | Trunc, HalfEven | 2.57 | 3.38 | 0.000 | 1.21 |
primitive_fixed_point_decimal 1.5.0 <i64, 8> | HalfExpand | 3.53 | 3.60 | 0.000 | 1.88 |
fin_decimal 0.4.0 Decimal<8> | HalfEven | 3.28 | 4.85 | 0.000 | 1.31 |
fastnum 0.7.5 D64 | HalfExpand | 35.5 | 31.1 | 0.001 | 7.33 |
| rust_decimal 1.43.0 | HalfEven | 33.3 | 35.1 | 0.001 | 6.29 |
| decimal-rs 0.2.0 | HalfExpand | 51.8 | 51.1 | 0.001 | 4.03 |
| bigdecimal 0.4.11 | HalfEven | 927 | 931 | 0.058 | 81.5 |
The mix, wide, 128 bits at 18 decimals:
| Contender | Mode | ns, predictable | ns, unpredictable | L1i misses | Front-end stalls |
|---|---|---|---|---|---|
f64 | HalfEven, HalfExpand | 0.49 | 0.48 | 0.000 | 0.00 |
i128 | Trunc | 3.54 | 3.49 | 0.000 | 1.05 |
ddust D128<18> | Trunc | 5.21 | 4.75 | 0.000 | 1.17 |
ddust D128<18> | HalfExpand | 5.96 | 5.27 | 0.000 | 1.33 |
ddust D128<18> | HalfEven | 6.38 | 5.68 | 0.000 | 1.24 |
primitive_fixed_point_decimal 1.5.0 <i128, 18> | HalfExpand | 13.8 | 8.40 | 0.000 | 2.78 |
nexus-decimal 1.2.2 <i128, 18> | Trunc, HalfEven | 11.7 | 8.52 | 0.000 | 2.90 |
fin_decimal 0.4.0 Decimal128<18> | HalfEven | 10.9 | 9.77 | 0.000 | 2.48 |
| rust_decimal 1.43.0 | HalfEven | 46.6 | 42.2 | 0.001 | 9.23 |
| decimal-rs 0.2.0 | HalfExpand | 74.2 | 53.3 | 0.001 | 4.99 |
fastnum 0.7.5 D128 | HalfExpand | 79.9 | 60.0 | 0.003 | 13.3 |
| bigdecimal 0.4.11 | HalfEven | 3,096 | 2,074 | 0.452 | 130 |
Each operation’s code, in instructions, with every function it calls but the panics and ddust’s cold paths, which a value past 19 decimals or a result past its integer reaches; read from the release build’s disassembly on aarch64:
| Operation | ddust D64<8> | ddust D128<18> | nexus-decimal | primitive_fixed_point_decimal | fin_decimal | fixnum | rust_decimal | fastnum | decimal-rs | bigdecimal |
|---|---|---|---|---|---|---|---|---|---|---|
| add | 10 | 12 | 5 | 5 | 5 | 5 | 955 | 3,184 | 1,609 | 9,888 |
| compare | 5 | 6 | 5 | 5 | 5 | 5 | 142 | 322 | 103 | 10,189 |
| mul-round | 103 | 215 | 69 | 64 | 101 | 281 | 587 | 3,295 | 1,295 | 11,060 |
| div-round | 373 | 674 | 248 | 330 | 164 | 280 | 1,267 | 4,603 | 1,854 | 12,338 |
| div-round-prepared | 48 | 132 | ||||||||
| rescale-round | 23 | 91 | 43 | 28 | 33 | 221 | 3,214 | 262 | 10,359 | |
| parse | 1,186 | 1,189 | 361 | 616 | 379 | 598 | 1,051 | 535 | 511 | 1,356 |
| format | 343 | 1,724 | 333 | 129 | 1,248 | 129 | 129 | 129 | 129 | 132 |
| to-f64 | 70 | 130 | 19 | 7 | 7 | 19 | 376 | 177 | 1,993 | 10,163 |
| from-f64 | 141 | 101 | 18 | 15 | 11 | 488 | 467 | 130,345 | 530 | 10,916 |
A call through a pointer, which core::fmt makes, is not followed: a crate that
writes text through Display counts only the code around the formatter. ddust’s
reader and writer count their general paths, for a number past 32 bytes or 30
decimals, beside their vector ones.
What to Keep off a Hot Path
- A wide division where a narrow one does. A
D128quotient costs about three times aD64’s, and a chain of them two to three times; a quantity, a price or a cash amount fits aD64. - Dividing many values by one decimal. A
Divisorprepared once divides with no division. - A conversion to a double inside a calculation. It is correctly rounded and cheap, but the double is rarely the decimal, and a calculation that goes through one loses the exactness ddust keeps.
How It Was Checked
The benchmarks’ equivalence test checks every result against an exact reference,
on both sets and at both widths. The kernels that divide only by constants, the
add, the compare, the rounded product, the rescale, the reader, the writer and
both float conversions, are held by just bench-assembly to contain no division
instruction and call no division routine, on aarch64 and x86_64. Each figure
is the median of the five passes’ medians: 124 of the run’s 782 times spread
past 2% across the passes, half of them operations under two nanoseconds, where
2% is under a twentieth of one, and the run’s summary.toml keeps each one’s
range. It runs with:
just bench-run 18 baseline graviton4 --passes 5
Platforms and Features
ddust is #![no_std], allocates nothing, and has no dependencies unless a
feature asks for one. It builds on nightly Rust, from nightly-2026-09-28,
since its arithmetic, rounding and literals are const fn over every integer
through const traits, which are not stable yet.
Where It Is Tested
| Target | What runs |
|---|---|
x86_64 Linux, at x86-64-v2 | every test and doctest, with every feature, and the book’s listings |
| arm64 Linux | every test, with every feature, with none, and in release |
| macOS on Apple silicon, and on Intel | the same |
x86_64 Linux, at plain x86-64 | every test, debug and release, with no feature and with runtime-dispatch |
x86_64 Linux, at x86-64-v3 | every test, debug and release |
aarch64-unknown-none, x86_64-unknown-none | the build, with no feature and with every feature but arbitrary’s |
i686-unknown-linux-gnu | cargo check, with every feature |
A release build runs the tests where overflow wraps rather than panics, and the
bare-metal builds fail on any use of std. Every night, each feature is linted
alone. The fuzz targets, the readers at each width, the arithmetic and the float
conversions, run over the committed corpus, then for a minute each, on every
change.
Text in Vector Lanes
Text is read in 32 vector lanes at once, through core::simd, when it is up to
32 bytes long at up to 32 decimals, and written in them when its steps are below
, at up to 30 decimals, where the build has the instructions:
| Build | Text is read and written |
|---|---|
| aarch64, little-endian, with NEON: every one but the soft-float | in NEON’s lanes |
x86_64 with SSSE3: -C target-cpu=x86-64-v2 or newer | in SSSE3’s lanes, with no check at run time |
x86_64 without SSSE3, with runtime-dispatch | in SSSE3’s lanes where a check of the CPU, once, finds them |
| anything else, and any other text | eight bytes at a time in a 64-bit word |
The repository’s own builds take x86-64-v2 as their floor, which every
x86_64 CPU since 2009 meets.
Division
On aarch64 with NEON, whose floating-point division is pipelined, a quotient within a double’s reach is taken from a double’s estimate, then corrected to the exact one; on any other build it is divided as integers.
Crate Features
| Feature | What it adds |
|---|---|
arbitrary | arbitrary’s Arbitrary, for fuzzing: any steps at any scale, and any rounding mode; needs std |
bytemuck | bytemuck’s Pod for a decimal of a Fixed scale, D8 to UD128, read from and written as its steps’ bytes; Zeroable for a decimal of a Dynamic one; and NoUninit and Zeroable for Dynamic, to write a scale’s byte |
defmt | defmt’s Format, to log a decimal from a device as its text, written without core::fmt |
num-traits-02 | num-traits 0.2’s Zero, ConstZero, Bounded, Checked*, Saturating* and Wrapping* traits, FromPrimitive and ToPrimitive |
proptest | proptest’s Arbitrary, for property tests: any steps at any scale, and any rounding mode |
rand-09 | rand 0.9’s SampleUniform, to draw a decimal uniformly from a range, through ddust::rand_09::UniformDecimal |
runtime-dispatch | a check of the CPU, once, on an x86_64 build without SSSE3, so text is read and written with SSSE3 where the CPU has it |
serde | serde’s Serialize and Deserialize: the text where a person reads the format, the steps where none does, and the modules in ddust::serde that choose the form at a field |
zerocopy-08 | zerocopy 0.8’s FromBytes and IntoBytes for a decimal of a Fixed scale, D8 to UD128, read from and written as its steps’ bytes; KnownLayout and Immutable for every decimal; and IntoBytes and Unaligned for Dynamic, to write its byte, and for Fixed, which has none |
A feature that names a crate’s version, num-traits-02, rand-09 and
zerocopy-08, follows that version, so a new major version of the crate comes
as a feature of its own, beside the old one. Interop shows each
integration at work.
Interop
Each crate ddust works with is behind a feature, so a program that does not use it builds none of it. Serialization covers serde, and Testing proptest, arbitrary and rand.
Numeric Traits
With num-traits-02, a decimal implements num-traits 0.2’s traits, so generic
numeric code takes it:
| Trait | For |
|---|---|
CheckedAdd, CheckedSub, CheckedDiv, CheckedRem | every decimal |
CheckedNeg, WrappingNeg | every signed decimal |
SaturatingAdd, SaturatingSub, WrappingAdd, WrappingSub | a static scale, and Dynamic |
CheckedMul | Dynamic, whose product is its own type |
Zero, ConstZero, Bounded, FromPrimitive | a static scale |
ToPrimitive | every decimal |
fn generic_numeric_code_takes_a_decimal() {
use ddust::{D64, dec};
use num_traits::{CheckedAdd, FromPrimitive, ToPrimitive, Zero};
fn total<T: Zero + CheckedAdd>(values: &[T]) -> Option<T> {
values.iter().try_fold(T::zero(), |sum, value| sum.checked_add(value))
}
let fills: [D64<2>; 3] = [dec!(1.25), dec!(2.50), dec!(0.75)];
assert_eq!(total(&fills), Some(dec!(4.5)));
assert_eq!(total(&[D64::<2>::MAX, dec!(0.01)]), None);
let rate: Option<D64<2>> = FromPrimitive::from_f64(0.125);
assert_eq!(rate, Some(dec!(0.12)), "the nearest step, a tie to even");
assert_eq!(dec!(-2.75: D64<2>).to_i64(), Some(-2), "the whole part, as a float's");
}
Each trait’s method is the decimal’s own method of that name. A product of a
static scale is at the sum of its factors’ scales, another type, so CheckedMul
is Dynamic’s alone; and the saturating and wrapping traits never panic, so a
scale of another crate, whose values may refuse to mix, has none of them.
FromPrimitive reads a whole number exactly and a float at the nearest step, a
tie to the even one, and returns None past the range or for a float that is
not finite. ToPrimitive gives the whole part, truncated toward zero as a
float’s as is, or None past the integer’s range, and the nearest f64, from
which an f32 is then rounded: rounded twice, so in rare cases a step from the
f32 nearest the decimal.
bytemuck and zerocopy
A decimal of a Fixed scale is its integer and nothing else, so its bytes are
its steps’. With bytemuck, it is bytemuck’s Pod, and a slice of decimals
casts to bytes and back with no copy:
fn a_static_scale_decimal_is_its_steps_bytes() {
use bytemuck::{cast, cast_slice};
use ddust::{D64, dec};
let prices: [D64<2>; 2] = [dec!(1.25), dec!(-0.01)];
let bytes: &[u8] = cast_slice(&prices);
assert_eq!(bytes.len(), 16, "eight bytes a price, and nothing else");
assert_eq!(cast_slice::<u8, D64<2>>(bytes), prices);
assert_eq!(cast::<D64<2>, i64>(prices[0]), 125, "the steps");
}
With zerocopy-08, it is zerocopy 0.8’s FromBytes and IntoBytes, and every
decimal is KnownLayout and Immutable:
fn a_static_scale_decimal_is_read_from_bytes_in_place() {
use ddust::{D64, dec};
use zerocopy::{FromBytes, IntoBytes};
let price: D64<2> = dec!(64250.75);
let bytes = price.as_bytes();
assert_eq!(bytes, 6_425_075_i64.as_bytes());
assert_eq!(D64::<2>::read_from_bytes(bytes), Ok(price));
}
The bytes are the steps alone, so the scale comes from the type: a reader of the
bytes knows them as a D64<2> because its schema says so. A decimal of a
run-time scale is never read from bytes, since its scale’s byte must be 0 to 38,
nor written as bytes, since its padding holds no value; bytemuck can zero it,
and a Dynamic alone is written as its byte. The compiler refuses the rest:
//! A run-time scale's byte may be past 38 decimals, so neither it nor a decimal
//! of it reads from bytes.
use ddust::{Decimal, Dynamic};
use zerocopy::FromBytes;
fn main() {
let _scale = Dynamic::read_from_bytes(&[4]);
let _price = Decimal::<i64, Dynamic>::read_from_bytes(&[0; 16]);
}
error[E0599]: no associated function or constant named `read_from_bytes` found for struct `Dynamic` in the current scope
--> tests/compile_fail_bytes/a_run_time_scale_is_never_read_from_bytes_by_zerocopy.rs:8:27
|
8 | let _scale = Dynamic::read_from_bytes(&[4]);
| ^^^^^^^^^^^^^^^ associated function or constant not found in `Dynamic`
|
note: if you're trying to build a new `Dynamic`, consider using `Dynamic::new` which returns `Option<Dynamic>`
--> src/scale.rs
|
| pub const fn new(decimals: u8) -> Option<Self> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: there is a method `as_bytes` with a similar name, but with different arguments
--> $CARGO/zerocopy-$VERSION/src/lib.rs
|
| / fn as_bytes(&self) -> &[u8]
| | where
| | Self: Immutable,
| |________________________^
error[E0599]: the associated function or constant `read_from_bytes` exists for struct `Decimal<i64, Dynamic>`, but its trait bounds were not satisfied
--> tests/compile_fail_bytes/a_run_time_scale_is_never_read_from_bytes_by_zerocopy.rs:9:43
|
9 | let _price = Decimal::<i64, Dynamic>::read_from_bytes(&[0; 16]);
| ^^^^^^^^^^^^^^^ associated function or constant cannot be called on `Decimal<i64, Dynamic>` due to unsatisfied trait bounds
|
::: src/scale.rs
|
| pub struct Dynamic {
| ------------------ doesn't satisfy `Dynamic: FromBytes`
|
::: src/decimal.rs
|
| pub struct Decimal<I, S> {
| ------------------------ doesn't satisfy `Decimal<i64, Dynamic>: FromBytes`
|
note: if you're trying to build a new `Decimal<i64, Dynamic>` consider using one of the following associated functions:
ddust::convert::<impl Decimal<I, S>>::from_int
Decimal::<I, S>::from_steps
Decimal::<I, S>::new
Decimal::<I, S>::new_round
and $N others
--> src/decimal.rs
|
| pub const fn from_steps(steps: I, scale: S) -> Self {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
| / pub const fn new(digits: I, decimals: u8, scale: S) -> Result<Self, ConvertError>
| | where
| | I: [const] Int,
| | S: [const] Scale,
| |_________________________^
...
| / pub const fn new_round<R>(
| | digits: I, decimals: u8, scale: S, mode: R,
| | ) -> Result<Self, ConvertError>
| | where
| | I: [const] Int,
| | S: [const] Scale,
| | R: [const] RoundingMode,
| |________________________________^
|
::: src/convert.rs
|
| / pub const fn from_int(n: I, scale: S) -> Result<Self, ConvertError>
| | where
| | I: [const] Int,
| | S: [const] Scale,
| |_________________________^
= note: the following trait bounds were not satisfied:
`Dynamic: FromBytes`
which is required by `Decimal<i64, Dynamic>: FromBytes`
defmt
With defmt, a decimal implements defmt’s Format, so firmware logs it as its
shortest exact decimal, the text Display writes, without core::fmt.
Fixed<D> logs as its Debug writes it, with its decimals.
Testing
Code that uses ddust is tested as code that uses integers is: a decimal is a value, with no clock, no allocator and no global state, so a test that builds its decimals from literals is deterministic. Three features draw a test’s decimals instead.
Property Tests
With proptest, a decimal implements proptest’s Arbitrary: any steps, at any
scale its scale type takes, from 0 to 38 decimals for Dynamic. A rounding mode
is Arbitrary too, any of the nine:
mod properties {
use ddust::D64;
use proptest::prelude::*;
proptest! {
#[test]
fn a_sum_is_the_same_either_way(a: D64<2>, b: D64<2>) {
prop_assert_eq!(a.checked_add(b), b.checked_add(a));
}
}
}
Any steps means the ends of the range too, so a property that adds two values
meets an overflow, and is written in the checked_* forms, or over a narrower
strategy of its own.
Fuzzing
With arbitrary, a decimal implements arbitrary’s Arbitrary, the trait
cargo-fuzz and libFuzzer targets draw their inputs through: a fuzz target takes
decimals from the fuzzer’s bytes as it takes integers. The feature needs std,
as arbitrary does:
fn a_fuzzer_draws_any_decimal_from_its_bytes() {
use arbitrary::Unstructured;
use ddust::{D64, Decimal, Dynamic};
let mut input = Unstructured::new(b"the bytes a fuzzer gives, any of them");
let (a, b): (D64<2>, D64<2>) = input.arbitrary().expect("sixteen bytes");
assert_eq!(a.checked_add(b), b.checked_add(a));
let carried: Decimal<i64, Dynamic> = input.arbitrary().expect("steps, and a scale");
assert!(carried.decimals() <= 38);
}
Random Values
With rand-09, a decimal implements rand 0.9’s SampleUniform, through
ddust::rand_09::UniformDecimal, so it is drawn uniformly from a range as an
integer is: its steps from the bounds’ steps. Seeded, the draws are the same on
every run:
fn a_seeded_draw_is_the_same_on_every_run() {
use ddust::{D64, dec};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
let draw = |seed| {
let mut rng = StdRng::seed_from_u64(seed);
let price: D64<2> = rng.random_range(dec!(99.00)..=dec!(101.00));
price
};
assert_eq!(draw(7), draw(7), "one seed, one price");
assert!((dec!(99)..=dec!(101)).contains(&draw(7)));
}
Overflow in Tests
Cargo’s test profile has overflow checks on, so an operator past the range
panics in a test even where it wraps in the release build. A test of what
happens past the range calls the checked_*, saturating_*, wrapping_* or
overflowing_* method, which does the same in every profile, and a test of the
operator itself is gated on the build’s checks, as
Arithmetic and Overflow shows.
Compared with Others
Each crate below is described from its own source and documentation, at the version the benchmarks build, so every claim can be checked there. Each speed is from the run Performance cites, where every crate is measured in one binary on one machine, on values every crate holds, and every result is checked against an exact reference.
Speed
Nanoseconds per operation on the unpredictable values. ddust rounds half to even here, and each other crate in its own mode; Performance gives ddust in every crate’s mode beside it:
| Crate | Sum | Rounded product | Rounded quotient | To cents | Read | Write | The mix |
|---|---|---|---|---|---|---|---|
ddust D64<8> | 0.76 | 3.97 | 5.40 | 1.30 | 9.75 | 9.84 | 1.73 |
fixdec 0.1.0 D64 | 0.73 | 4.49, inexact | 8.12 | 3.82 | 23.5 | 40.4 | 3.21, inexact |
nexus-decimal 1.2.2 <i64, 8> | 0.73 | 5.57 | 8.18 | 4.32 | 19.2 | 23.3 | 3.38 |
primitive_fixed_point_decimal 1.5.0 <i64, 8> | 0.73 | 3.68 | 10.9 | 3.21 | 20.5 | 33.4 | 3.60 |
fin_decimal 0.4.0 Decimal<8> | 0.75 | 9.63 | 11.4 | 4.99 | 20.7, inexact | 26.6 | 4.85 |
fastnum 0.7.5 D64 | 16.6 | 33.8 | 86.2 | 18.3 | 24.2 | 80.0 | 31.1 |
| rust_decimal 1.43.0 | 9.79 | 30.7 | 114 | 21.8 | 19.1 | 67.0 | 35.1 |
| decimal-rs 0.2.0 | 7.61 | 40.9 | 199 | 6.57 | 43.6 | 44.0 | 51.1 |
| bigdecimal 0.4.11 | 38.0 | 124 | 4,416 | 75.5 | 91.1 | 111 | 931 |
fixnum 0.9.5 <i64, U8> | 0.75 | 13.3 | 14.3 | 29.4 | 28.5 |
| Crate | Sum | Rounded product | Rounded quotient | To cents | Read | Write | The mix |
|---|---|---|---|---|---|---|---|
ddust D128<18> | 1.06 | 8.12 | 15.8 | 4.15 | 10.0 | 16.4 | 5.68 |
primitive_fixed_point_decimal 1.5.0 <i128, 18> | 1.05 | 7.30 | 29.9 | 8.39 | 39.6 | 77.6 | 8.40 |
nexus-decimal 1.2.2 <i128, 18> | 1.05 | 8.63 | 22.2 | 14.7 | 20.8 | 49.3 | 8.52 |
fin_decimal 0.4.0 Decimal128<18> | 1.05 | 12.9 | 27.4 | 8.89 | 35.2, inexact | 46.2 | 9.77 |
| rust_decimal 1.43.0 | 10.6 | 57.8 | 98.8 | 43.7 | 30.0 | 91.7 | 42.2 |
| decimal-rs 0.2.0 | 7.65 | 52.5 | 198 | 9.58 | 62.1 | 48.4 | 53.3 |
fastnum 0.7.5 D128 | 18.2 | 44.7 | 206 | 24.0 | 31.9 | 103 | 60.0 |
| bigdecimal 0.4.11 | 47.8 | 171 | 10,127 | 90.5 | 117 | 139 | 2,074 |
fixnum 0.9.5 <i128, U18> | 1.03 | 17.4 | 34.7 | 65.9 | 43.0 |
The fixed-point crates add and compare as their integers do, within a few
percent of each other. ddust’s checked sum takes two instructions more than the
integer’s, and its exact product one more, where its repr(C) layout, which
keeps a decimal its integer’s bytes, has Rust build the Option in memory: the
narrow sum is 0.76 ns against nexus-decimal’s 0.73, and the exact product 0.89
against primitive_fixed_point_decimal’s 0.87. Past them, ddust is the fastest at
every operation, in the same rounding mode, but three rows of the
Performance tables:
- a narrow rescale to cents on predictable values, where fixdec takes 1.19 ns and ddust 1.30;
- a chain of narrow rounded products on predictable values, where fin_decimal takes 11.7 ns a link and ddust 12.4;
- a chain of wide rounded products on unpredictable values, where LLVM inlines primitive_fixed_point_decimal’s smaller code into the chain and calls ddust’s: 14.1 ns a link against 15.7.
The decimals that carry a scale in each value take ten to a hundred times as long, and bigdecimal, which allocates, longer still.
rust_decimal
rust_decimal 1.43.0’s Decimal is a
96-bit magnitude, a sign and a scale from 0 to 28, in 16 bytes: each value
carries its own scale, and an operation chooses its result’s.
| rust_decimal 1.43.0 | ddust |
|---|---|
a scale per value, 0 to 28; + lines two up | a scale in the type, 0 to 38, or carried at run time and lined up |
| 28 or 29 significant digits, in 16 bytes | the integer’s: 18 in 8 bytes, 38 in 16, down to 2 in 1 |
* exact while the product fits, then rounded half to even | * exact, its scale the sum of the factors’, or an overflow |
/ rounded half to even at the last digit that fits | / truncated at the dividend’s scale, or rounded by a mode when asked |
seven rounding strategies, taken by round_dp_with_strategy and round_sf_with_strategy | nine modes, taken by every operation that can lose a digit |
operators that panic past the range in every build; checked_* and saturating_add, _sub and _mul | operators that panic or wrap as the integer’s; all four families |
FromStr rounds a digit past 28 decimals or 96 bits; from_str_exact refuses it | every reader exact or refused; from_ascii_round rounds by a mode |
Display keeps the trailing zeros a value carries | Display writes the shortest exact decimal; {:#} every decimal |
from_f64 from the double’s shortest digits; to_f64 not correctly rounded | from_f64 from the double’s exact value, by a mode; to_f64 correctly rounded |
no_std with alloc | no_std with no allocator |
| stable Rust, from 1.67.1 | nightly Rust |
rust_decimal has what ddust has not: square roots, exponentials, logarithms,
powers and trigonometry, under its maths feature; rounding to significant
figures; reading text in any radix; and integrations with diesel, postgres,
tokio-postgres, borsh, ndarray and rocket. Take it on stable Rust, where values
of many scales meet and each result should keep as many digits as fit, or where
those integrations or functions are needed.
fastnum
fastnum 0.7.5 is decimal floating point, after
IEEE 754 and the General Decimal Arithmetic specification: a coefficient of 64
to 1,024 bits and an exponent, with NaN, the infinities, signed zero and
signals. A value carries a context, which holds its rounding mode, one of seven.
A coefficient that fills rounds away its last digits and signals that it did; an
exponent past the range gives an infinity, and a trapped signal panics only with
debug_assertions on. It has square roots, exponentials, logarithms, powers and
trigonometry, and integrations with diesel, sqlx and tokio-postgres; it needs
alloc, and builds on stable Rust from 1.94. Take it for IEEE decimal
semantics, for precision past 38 digits, or for those functions.
bigdecimal
bigdecimal 0.4.11 is arbitrary precision: a
heap-allocated integer of any length and a 64-bit scale, so no value overflows.
A quotient is rounded to 100 significant digits unless a build-time setting says
otherwise, and a Context sets the precision of the functions that take one:
square and cube roots, exponentials and powers. Take it where a value can
outgrow 38 digits and allocation is acceptable.
The Fixed-Point Crates
These crates are decimals of a fixed scale, as ddust’s static ones are, each in one of Rust’s integers:
| Crate | Scale | Rounding | * and / | An operator past the range | Text past the scale | no_std |
|---|---|---|---|---|---|---|
| ddust | a const generic, or at run time | nine modes, as types or a value | exact; truncated | as the integer’s | refused | yes |
| primitive_fixed_point_decimal 1.5.0 | a const generic, or passed with each call | five modes, by an argument | rounded half away from zero | + and - as the integer’s; * and / panic | refused | yes |
| nexus-decimal 1.2.2 | a const generic | four, each a method | truncated | panics in every build | refused | yes |
| fixnum 0.9.5 | a type parameter | three modes, by an argument | no operators: methods with a mode | no operators: its methods return an error | rounded; from_str_exact refuses | yes |
| fin_decimal 0.4.0 | a const generic | five modes, by an argument | rounded by its HalfUp | + and - as the integer’s, but on 256 bits; * and / panic | rounded by its HalfUp | yes |
| const-decimal 0.4.0 | a const generic | truncation alone | truncated | panics in every build | refused | no |
Each builds on stable Rust, and has what ddust has not:
primitive_fixed_point_decimal, negative scales and a scale kept outside the
value; nexus-decimal, helpers for ticks, basis points and spreads; fixnum, a
macro for typed wrappers’ arithmetic, and parity-scale-codec, rkyv and schemars;
fin_decimal, split_evenly, a correctly rounded square root and 256-bit values;
const-decimal, borsh. Take one of them on stable Rust, choosing by the columns
above.
decimal-rs 0.2.0, also measured, is a 38-digit decimal with a floating exponent,
in 20 bytes, that rounds half up wherever it rounds and needs std.
fixed
fixed 1.31.0 is binary fixed point: a number of
fraction bits, not of decimals, so it holds no decimal fraction exactly, as its
own docs say. Its * and / overflow by debug_assertions rather than
overflow-checks, and it has the integer’s families and more. Take it for
signal processing and embedded arithmetic, where a binary fraction is the point;
the benchmarks time its multiply beside the decimals’ for contrast.
fixdec
fixdec 0.1.0 is measured with its results marked inexact. Its D64 multiply
divides by through an approximate reciprocal, which is wrong once the
product of the steps reaches : 1,000 × 1,000 is 1,000,000.0000049.
Parsing an integer part too large for its word wraps silently, and its D96
writes and divides some large values wrongly.
What ddust Gives Up
- Stable Rust. ddust needs nightly, for const traits.
- A scale each value carries and each operation chooses, as rust_decimal’s and the decimal floats’ do. ddust’s run-time scale lines up exactly but never grows a quotient’s decimals.
- Arbitrary precision, and decimal floating point: 38 digits is its limit.
- NaN, the infinities and negative zero.
- Square roots, exponentials, logarithms, powers and trigonometry.
- The integrations a crate gathers over years of use: diesel, sqlx, postgres, borsh, rkyv and the rest.