Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 * b is exact, its scale the sum of the factors’; a / b truncates 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 a D64<4> does not compile. Two scales known only at run time line up exactly at the finer one, as SQL’s DECIMAL does.
  • Every operator behaves as the integer’s. A D64<2> is an i64 and adds as one. Overflow panics with overflow checks on and wraps otherwise, and each operator has the integer’s checked_*, saturating_*, wrapping_* and overflowing_* methods.

What It Holds

  • The decimal, Decimal, with an alias for each integer at a static scale: D8 to D128 and UD8 to UD128. dec! writes a literal that is checked when the constant is evaluated.
  • Scales: Fixed<D>, known to the compiler and zero-sized, and Dynamic, carried at run time.
  • Rounding: nine modes in round, each a zero-sized type, and Rounding, a mode chosen at run time. Divisor prepares one divisor for many divisions.
  • Text: reading that is exact or refused, from a slice or in place inside a message, and writing through Display or 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 an i128.
  • 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 are const fn through 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:

SignedUnsignedBytesDigits every value has room for
D8<D>UD8<D>12
D16<D>UD16<D>24
D32<D>UD32<D>49
D64<D>UD64<D>818, and 19 unsigned
D128<D>UD128<D>1638

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));
}
OperationIts scaleExact
a + b, a - b, a % b, -athe operands’yes
a * n and n * a, n an Ia’syes
a * bthe sum of the factors’yes
a / n, n an Ia’struncated toward zero
a / bthe dividend’struncated toward zero
==, < and the other comparesyes

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");
}
FamilyPast 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

ModeRounds
Floortoward −∞
Ceiltoward +∞
Trunctoward zero
Expandaway from zero
HalfFloorto the nearer step, and a tie toward −∞
HalfCeilto the nearer step, and a tie toward +∞
HalfTruncto the nearer step, and a tie toward zero
HalfExpandto the nearer step, and a tie away from zero
HalfEvento 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

MethodWhat it rounds
mul_rounda product, at this value’s scale
mul_round_toa product, at a scale named
div_rounda quotient by a decimal or a Divisor, at this value’s scale
div_round_toa quotient, at a scale named
div_int_roundone of n equal parts
mul_div_roundself × numerator / denominator, rounded once
round_toto a multiple of a step: a tick, a lot, a coin
roundto a whole number, at this value’s scale
to_intto a whole number, as an integer
rescale_roundto another scale
new_round, to_digits_rounddigits, to and from a decimal
from_ascii_roundtext with more decimals than the scale
from_f64a 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:

ParseErrorKindThe text
Emptyis empty
InvalidDigitis not a number: a sign, digits, a point, an exponent
PosOverflowis above the decimal’s range
NegOverflowis below it
TooManyDecimalshas a non-zero digit past the decimal’s scale
RangeOutsideBufferwas 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:

FormWhere a person reads the formatWhere none does
the decimal’s ownits textits steps
textits textits text
stepsits steps, as a numberits steps
floatits nearest doublethe 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

NeedTakeWhy
an amount to the cent: a balance, a payment, an invoiceD64<2>eight bytes, up to about ±9.2 × 10¹⁶
a price or a quantity to eight decimalsD64<8>eight bytes, up to about ±9.2 × 10¹⁰
a token’s amount at eighteen decimals, or a large notionalD128<18>sixteen bytes, up to about ±1.7 × 10²⁰
the exact product of two D64swiden them firsta D128 at the sum of their scales holds every product of two i64s
a value that is never negativeUD64 and its siblingsthe type refuses a negative value, and a u64 holds twice an i64’s largest value
millions of values in memorythe narrowest that holds thema D32<2> holds ±21 million in four bytes, sixteen to a 64-byte cache line

The scale:

NeedTakeWhy
a scale known when the code is writtenFixed<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’sDynamica 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

NeedTake
a mode the code namesits type: HalfEven, Floor and the rest
a mode a configuration, a venue or a protocol choosesa Rounding
a rule that names the nearest centHalfEven, banker’s rounding, or HalfExpand, the school’s, as the rule says
a fee or a margin a business must not underchargeCeil, or Expand for both signs
a size an account must be able to pay forFloor, or Trunc for both signs

Division

NeedTake
a quotient truncated, as an integer’s/
a quotient roundeddiv_round, with a mode
a quotient at another scalediv_round_to
many quotients by one divisora Divisor, prepared once
a share: a × b / c, rounded oncemul_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.

RunWhat it measures
2026-10-14T06-08Z-pr18-baseline-graviton4every 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:

Contenderns, predictablens, unpredictableCyclesInstructionsBranch misses
f640.270.391.083.500.00
fixdec 0.1.0 D640.680.732.037.000.00
primitive_fixed_point_decimal 1.5.0 <i64, 8>0.680.732.037.000.00
nexus-decimal 1.2.2 <i64, 8>0.680.732.047.000.00
fixnum 0.9.5 <i64, U8>0.680.752.097.000.00
fin_decimal 0.4.0 Decimal<8>0.680.752.107.000.00
i640.680.752.117.000.00
ddust D64<8>0.690.762.139.000.00
fixed 1.31.0 I64F640.951.022.8614.00.00
decimal-rs 0.2.06.647.6121.354.70.76
rust_decimal 1.43.07.889.7927.467.51.23
fastnum 0.7.5 D647.7516.646.41261.48
bigdecimal 0.4.1128.638.01063171.78

The checked sum, wide, 128 bits at 18 decimals:

Contenderns, predictablens, unpredictableCyclesInstructionsBranch misses
f640.280.391.093.500.00
fixnum 0.9.5 <i128, U18>0.941.032.8814.00.00
i1280.951.042.9114.00.00
primitive_fixed_point_decimal 1.5.0 <i128, 18>0.941.052.9314.00.00
nexus-decimal 1.2.2 <i128, 18>0.941.052.9414.00.00
fin_decimal 0.4.0 Decimal128<18>0.941.052.9314.00.00
ddust D128<18>0.951.062.9414.00.00
decimal-rs 0.2.06.687.6521.454.80.77
rust_decimal 1.43.08.0610.629.767.11.52
fastnum 0.7.5 D1288.8618.250.91411.57
bigdecimal 0.4.1150.547.81344512.27

The order, narrow, 64 bits at 8 decimals:

Contenderns, predictablens, unpredictableCyclesInstructionsBranch misses
nexus-decimal 1.2.2 <i64, 8>0.150.210.581.690.00
ddust D64<8>0.150.210.591.690.00
fixdec 0.1.0 D640.150.210.591.690.00
fixnum 0.9.5 <i64, U8>0.150.210.591.690.00
fin_decimal 0.4.0 Decimal<8>0.150.210.591.690.00
i640.150.210.591.690.00
primitive_fixed_point_decimal 1.5.0 <i64, 8>0.150.210.591.690.00
f640.150.210.591.690.00
fixed 1.31.0 I64F640.550.601.697.630.00
decimal-rs 0.2.01.814.1711.725.00.49
bigdecimal 0.4.113.615.6215.743.90.49
rust_decimal 1.43.03.756.5818.445.50.83
fastnum 0.7.5 D648.307.6421.443.01.07

The order, wide, 128 bits at 18 decimals:

Contenderns, predictablens, unpredictableCyclesInstructionsBranch misses
f640.150.210.591.690.00
fin_decimal 0.4.0 Decimal128<18>0.530.561.587.630.00
fixnum 0.9.5 <i128, U18>0.540.581.627.630.00
nexus-decimal 1.2.2 <i128, 18>0.550.581.627.630.00
primitive_fixed_point_decimal 1.5.0 <i128, 18>0.530.581.637.630.00
ddust D128<18>0.540.601.687.630.00
i1280.530.611.697.630.00
decimal-rs 0.2.01.834.2111.825.00.49
bigdecimal 0.4.113.636.8319.140.80.80
rust_decimal 1.43.03.757.8421.944.71.02
fastnum 0.7.5 D12815.310.028.056.41.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:

Contenderns, predictablens, unpredictableCyclesInstructionsBranch misses
i640.770.872.4210.00.00
primitive_fixed_point_decimal 1.5.0 <i64, 2> × <i64, 5>0.770.872.4310.00.00
ddust D64<2> × D64<5>0.800.892.5011.00.00
fastnum 0.7.5 D647.177.2020.11220.00
rust_decimal 1.43.07.277.2920.460.00.00
bigdecimal 0.4.1112.320.456.91961.12
decimal-rs 0.2.033.833.894.62310.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:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
f64HalfEven0.270.411.133.500.00
i64Trunc0.770.862.4210.00.00
fixed 1.31.0 I64F64Floor2.652.637.3638.00.00
ddust D64<8>Trunc2.873.319.2535.50.15
primitive_fixed_point_decimal 1.5.0 <i64, 8>HalfExpand4.023.6810.336.40.18
ddust D64<8>HalfExpand3.293.7310.442.90.15
ddust D64<8>HalfEven3.963.9711.146.90.15
fixdec 0.1.0 D64, inexactTrunc1.694.4912.525.50.49
nexus-decimal 1.2.2 <i64, 8>Trunc3.045.5715.641.50.50
fin_decimal 0.4.0 Decimal<8>HalfEven4.859.6326.964.30.56
fixnum 0.9.5 <i64, U8>HalfExpand14.313.337.31010.71
rust_decimal 1.43.0HalfEven27.830.785.82700.92
fastnum 0.7.5 D64HalfExpand69.933.894.62531.23
decimal-rs 0.2.0HalfExpand41.240.91143140.32
bigdecimal 0.4.11HalfEven1631243481,3294.00

The rounded product, wide, 128 bits at 18 decimals:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
f64HalfEven0.270.411.143.750.00
i128Trunc3.463.7010.427.10.30
ddust D128<18>Trunc8.836.6218.577.90.08
ddust D128<18>HalfExpand10.17.2220.283.60.09
primitive_fixed_point_decimal 1.5.0 <i128, 18>HalfExpand11.77.3020.481.80.07
ddust D128<18>HalfEven10.18.1222.792.00.09
nexus-decimal 1.2.2 <i128, 18>Trunc10.18.6324.173.80.54
fin_decimal 0.4.0 Decimal128<18>HalfEven11.212.936.098.60.79
fixnum 0.9.5 <i128, U18>HalfExpand20.017.448.81411.27
fastnum 0.7.5 D128HalfExpand13244.71253471.68
decimal-rs 0.2.0HalfExpand13052.51473720.81
rust_decimal 1.43.0HalfEven69.757.81623892.03
bigdecimal 0.4.11HalfEven2461714781,7714.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:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
f64HalfEven0.890.932.603.750.00
i64Trunc2.172.135.9412.00.00
ddust D64<8>Trunc3.824.4912.646.70.22
ddust D64<8>HalfExpand4.545.3114.952.20.23
ddust D64<8>HalfEven4.705.4015.152.20.24
fixdec 0.1.0 D64Trunc8.008.1222.772.80.45
nexus-decimal 1.2.2 <i64, 8>Trunc8.128.1822.972.80.46
primitive_fixed_point_decimal 1.5.0 <i64, 8>HalfExpand11.710.930.571.41.34
fin_decimal 0.4.0 Decimal<8>HalfEven13.611.431.866.40.73
fixnum 0.9.5 <i64, U8>HalfExpand9.9914.340.01000.88
fastnum 0.7.5 D64HalfExpand68.786.22415252.67
rust_decimal 1.43.0HalfEven1041143187632.98
decimal-rs 0.2.0HalfExpand2091995551,1542.68
bigdecimal 0.4.11HalfEven4,2784,41612,34849,10029.78

The rounded quotient, wide, 128 bits at 18 decimals:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
f64HalfEven0.890.932.593.750.00
i128Trunc6.976.2917.669.30.41
ddust D128<18>Trunc12.713.537.91290.49
ddust D128<18>HalfExpand13.614.440.11380.50
ddust D128<18>HalfEven15.015.844.21400.50
nexus-decimal 1.2.2 <i128, 18>Trunc36.322.262.21721.58
fin_decimal 0.4.0 Decimal128<18>HalfEven35.227.476.62011.47
primitive_fixed_point_decimal 1.5.0 <i128, 18>HalfExpand49.629.983.62401.80
fixnum 0.9.5 <i128, U18>HalfExpand39.334.797.02562.41
rust_decimal 1.43.0HalfEven84.998.82766374.33
decimal-rs 0.2.0HalfExpand2341985541,1273.07
fastnum 0.7.5 D128HalfExpand2062065761,3246.70
bigdecimal 0.4.11HalfEven14,83410,12728,315138,45049.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:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
f64HalfEven0.890.922.583.750.00
i64Trunc2.172.406.7212.00.00
ddust D64<8>Trunc2.714.2912.030.30.29
ddust D64<8>HalfExpand3.384.8813.736.70.32
ddust D64<8>HalfEven4.725.9716.752.90.30
fixdec 0.1.0 D64Trunc7.816.8119.169.90.20
nexus-decimal 1.2.2 <i64, 8>Trunc7.806.8119.169.90.20
primitive_fixed_point_decimal 1.5.0 <i64, 8>HalfExpand11.28.8124.667.90.77
fin_decimal 0.4.0 Decimal<8>HalfEven13.211.231.366.60.64
fixnum 0.9.5 <i64, U8>HalfExpand10.514.039.197.80.67
fastnum 0.7.5 D64HalfExpand70.480.12245012.48
rust_decimal 1.43.0HalfEven1061093067162.46
decimal-rs 0.2.0HalfExpand2102085821,2482.61
bigdecimal 0.4.11HalfEven4,2514,41112,33547,38123.30

The rounded quotient by one divisor, wide, 128 bits at 18 decimals:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
f64HalfEven0.890.932.603.500.00
i128Trunc6.857.5221.071.90.65
ddust D128<18>Trunc12.813.638.01290.50
ddust D128<18>HalfExpand13.614.540.51380.50
ddust D128<18>HalfEven15.016.044.71400.50
nexus-decimal 1.2.2 <i128, 18>Trunc38.022.362.41741.39
fin_decimal 0.4.0 Decimal128<18>HalfEven32.823.264.81540.93
primitive_fixed_point_decimal 1.5.0 <i128, 18>HalfExpand49.730.084.12431.37
fixnum 0.9.5 <i128, U18>HalfExpand44.234.997.62611.77
rust_decimal 1.43.0HalfEven86.386.92436103.05
fastnum 0.7.5 D128HalfExpand1981714791,1514.51
decimal-rs 0.2.0HalfExpand2272085811,1832.66
bigdecimal 0.4.11HalfEven14,9174,37612,23650,87637.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:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
ddust D64<8>Trunc2.072.075.7829.00.00
ddust D64<8>HalfExpand2.702.707.5535.00.00
ddust D64<8>HalfEven3.013.008.3737.00.00

The rounded quotient by a prepared divisor, wide, 128 bits at 18 decimals:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
ddust D128<18>Trunc7.889.3226.191.00.48
ddust D128<18>HalfExpand10.411.532.21110.48
ddust D128<18>HalfEven11.112.434.71200.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:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
i64Trunc0.720.722.026.750.00
ddust D64<8>Trunc0.840.842.3610.00.00
f64HalfExpand0.920.892.494.000.00
ddust D64<8>HalfExpand1.141.143.1914.00.00
ddust D64<8>HalfEven1.301.303.6316.00.00
primitive_fixed_point_decimal 1.5.0 <i64, 8>HalfExpand1.133.218.9817.00.48
fixdec 0.1.0 D64HalfEven1.193.8210.718.70.34
nexus-decimal 1.2.2 <i64, 8>HalfEven1.884.3212.123.80.33
fin_decimal 0.4.0 Decimal<8>HalfEven1.864.9913.927.00.35
decimal-rs 0.2.0HalfExpand5.326.5718.461.10.32
fastnum 0.7.5 D64HalfExpand12.918.351.31351.08
rust_decimal 1.43.0HalfEven20.221.861.01900.36
bigdecimal 0.4.11HalfEven87.875.52119172.06

To cents, wide, 128 bits at 18 decimals:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
f64HalfExpand0.920.822.304.000.00
ddust D128<18>HalfExpand3.893.9110.931.10.25
ddust D128<18>Trunc3.894.0211.231.10.26
ddust D128<18>HalfEven4.124.1511.633.10.25
primitive_fixed_point_decimal 1.5.0 <i128, 18>HalfExpand12.48.3923.558.90.98
i128Trunc10.18.6624.268.50.95
fin_decimal 0.4.0 Decimal128<18>HalfEven12.48.8924.951.30.52
decimal-rs 0.2.0HalfExpand10.99.5826.666.80.71
nexus-decimal 1.2.2 <i128, 18>HalfEven18.514.740.81141.28
fastnum 0.7.5 D128HalfExpand22.024.067.21841.05
rust_decimal 1.43.0HalfEven42.943.71223540.23
bigdecimal 0.4.11HalfEven12690.52531,0102.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:

Contenderns, predictablens, unpredictableCyclesInstructionsBranch misses
ddust D64<8>9.569.7527.31010.17
rust_decimal 1.43.017.519.153.51751.74
nexus-decimal 1.2.2 <i64, 8>11.619.253.81731.66
f6414.219.554.61691.78
primitive_fixed_point_decimal 1.5.0 <i64, 8>18.820.557.42021.69
fin_decimal 0.4.0 Decimal<8>, inexact15.820.757.82161.68
fixdec 0.1.0 D6421.423.565.72411.51
fastnum 0.7.5 D6420.424.267.62671.68
fixnum 0.9.5 <i64, U8>26.729.482.33601.55
decimal-rs 0.2.037.843.61223842.96
bigdecimal 0.4.1192.891.12551,3731.42

Reading, wide, 128 bits at 18 decimals:

Contenderns, predictablens, unpredictableCyclesInstructionsBranch misses
ddust D128<18>9.3210.028.195.50.50
nexus-decimal 1.2.2 <i128, 18>16.920.858.32301.38
rust_decimal 1.43.039.830.083.93651.56
f6448.431.889.13291.33
fastnum 0.7.5 D12830.831.989.34291.45
fin_decimal 0.4.0 Decimal128<18>, inexact35.535.298.44251.54
primitive_fixed_point_decimal 1.5.0 <i128, 18>44.039.61104021.58
decimal-rs 0.2.059.762.11745772.13
fixnum 0.9.5 <i128, U18>68.165.91847621.78
bigdecimal 0.4.111291173261,7511.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:

Contenderns, predictablens, unpredictableCyclesInstructionsBranch misses
ddust D64<8>9.849.8427.599.00.00
nexus-decimal 1.2.2 <i64, 8>18.423.365.11982.25
i6414.726.574.22152.25
fin_decimal 0.4.0 Decimal<8>17.426.674.42751.83
fixnum 0.9.5 <i64, U8>21.228.579.82681.62
primitive_fixed_point_decimal 1.5.0 <i64, 8>23.933.493.63332.54
fixdec 0.1.0 D6434.240.41134082.12
decimal-rs 0.2.034.444.01235172.20
rust_decimal 1.43.077.767.01876411.45
f6463.867.71896502.98
fastnum 0.7.5 D6464.980.02248892.22
bigdecimal 0.4.111081113121,3911.89

Writing, wide, 128 bits at 18 decimals:

Contenderns, predictablens, unpredictableCyclesInstructionsBranch misses
ddust D128<18>18.016.445.91770.32
i12820.931.588.22632.62
fixnum 0.9.5 <i128, U18>44.143.01204291.74
fin_decimal 0.4.0 Decimal128<18>58.346.21296111.34
decimal-rs 0.2.037.948.41355512.62
nexus-decimal 1.2.2 <i128, 18>68.449.31384392.29
f6465.874.42087063.50
primitive_fixed_point_decimal 1.5.0 <i128, 18>96.377.62175692.22
rust_decimal 1.43.011891.72569561.48
fastnum 0.7.5 D12883.11032891,1482.66
bigdecimal 0.4.111481393891,6462.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:

Contenderns, predictablens, unpredictableCyclesInstructionsBranch misses
i640.120.150.432.000.00
primitive_fixed_point_decimal 1.5.0 <i64, 8>, inexact0.890.892.502.500.00
fin_decimal 0.4.0 Decimal<8>, inexact0.890.892.502.500.00
fixnum 0.9.5 <i64, U8>, inexact1.231.233.4411.50.00
nexus-decimal 1.2.2 <i64, 8>, inexact1.231.233.4411.50.00
ddust D64<8>4.791.383.8715.60.04
fixdec 0.1.0 D64, inexact1.701.614.5219.00.00
fastnum 0.7.5 D646.212.878.0247.70.07
decimal-rs 0.2.041.110.629.71410.20
rust_decimal 1.43.0, inexact21.723.966.82460.59
bigdecimal 0.4.111091083011,2341.56

To a double, wide, 128 bits at 18 decimals:

Contenderns, predictablens, unpredictableCyclesInstructionsBranch misses
i1283.553.559.9443.50.00
primitive_fixed_point_decimal 1.5.0 <i128, 18>, inexact3.613.6010.144.30.00
fin_decimal 0.4.0 Decimal128<18>, inexact3.603.6010.144.30.00
ddust D128<18>7.427.7321.660.70.43
fastnum 0.7.5 D12832.315.543.51360.44
nexus-decimal 1.2.2 <i128, 18>, inexact24.717.047.51530.71
fixnum 0.9.5 <i128, U18>, inexact24.817.047.51530.71
rust_decimal 1.43.0, inexact33.827.376.42740.78
decimal-rs 0.2.081.242.71194961.46
bigdecimal 0.4.111841444011,6011.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:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
i64Trunc0.250.300.843.000.00
fin_decimal 0.4.0 Decimal<8>, inexactTrunc0.430.471.324.750.00
primitive_fixed_point_decimal 1.5.0 <i64, 8>, inexactHalfExpand0.620.621.745.750.00
nexus-decimal 1.2.2 <i64, 8>, inexactHalfExpand1.021.032.8815.00.00
fixdec 0.1.0 D64, inexactHalfExpand1.021.032.8815.00.00
ddust D64<8>HalfEven5.143.9110.952.10.09
fixnum 0.9.5 <i64, U8>, inexactHalfExpand35.792.22585556.28
fastnum 0.7.5 D64HalfExpand63.21764921,1933.62
decimal-rs 0.2.0, inexactHalfExpand1132316451,3047.46
rust_decimal 1.43.0, inexactHalfEven1192607271,27420.49
bigdecimal 0.4.11HalfEven2173439563,8545.08

From a double, wide, 128 bits at 18 decimals:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
primitive_fixed_point_decimal 1.5.0 <i128, 18>, inexactHalfExpand2.872.878.0242.00.00
fin_decimal 0.4.0 Decimal128<18>, inexactTrunc3.073.088.6049.00.00
nexus-decimal 1.2.2 <i128, 18>, inexactHalfExpand3.423.449.6153.00.00
i128Trunc2.784.2912.030.20.36
ddust D128<18>HalfEven4.946.3117.766.00.49
fixnum 0.9.5 <i128, U18>, inexactHalfExpand36.813838774312.37
fastnum 0.7.5 D128HalfExpand1041795021,3484.17
decimal-rs 0.2.0, inexactHalfExpand95.72747661,45913.83
rust_decimal 1.43.0, inexactHalfEven1082998351,48526.92
bigdecimal 0.4.11HalfEven2024071,1364,4356.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:

Contenderns, predictablens, unpredictableCyclesInstructionsBranch misses
ddust D64<8>3.573.6710.314.00.00
i643.573.6710.314.00.00
nexus-decimal 1.2.2 <i64, 8>3.563.6810.314.00.00
fixnum 0.9.5 <i64, U8>3.563.6810.314.00.00
fin_decimal 0.4.0 Decimal<8>3.563.6810.314.00.00
primitive_fixed_point_decimal 1.5.0 <i64, 8>3.573.6810.314.00.00
fixdec 0.1.0 D643.563.6910.314.00.00
f644.945.0014.014.00.00
fixed 1.31.0 I64F645.125.2014.619.00.00
decimal-rs 0.2.07.4811.632.565.80.78
rust_decimal 1.43.013.017.849.784.51.20
fastnum 0.7.5 D6420.229.783.21361.43
bigdecimal 0.4.1137.645.61283301.75

The checked sum, in a chain, wide, 128 bits at 18 decimals:

Contenderns, predictablens, unpredictableCyclesInstructionsBranch misses
f644.945.0014.014.00.00
i1285.125.2014.519.00.00
primitive_fixed_point_decimal 1.5.0 <i128, 18>5.125.2014.519.00.00
fixnum 0.9.5 <i128, U18>5.125.2014.519.00.00
ddust D128<18>5.125.2014.519.00.00
nexus-decimal 1.2.2 <i128, 18>5.125.2014.519.00.00
fin_decimal 0.4.0 Decimal128<18>5.125.2014.519.00.00
decimal-rs 0.2.07.4911.632.465.80.77
rust_decimal 1.43.015.720.557.383.11.50
fastnum 0.7.5 D12818.426.774.61551.57
bigdecimal 0.4.1156.655.51554622.22

The rounded product, in a chain, narrow, 64 bits at 8 decimals:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
i64Trunc4.624.6513.016.00.00
f64HalfEven5.295.3615.014.00.00
fixdec 0.1.0 D64, inexactTrunc5.207.5321.126.00.47
ddust D64<8>Trunc10.28.0822.631.90.14
fixed 1.31.0 I64F64Floor8.678.6624.241.00.00
ddust D64<8>HalfExpand10.69.0025.234.80.14
primitive_fixed_point_decimal 1.5.0 <i64, 8>HalfExpand10.99.1025.434.20.17
ddust D64<8>HalfEven12.49.4626.539.30.15
nexus-decimal 1.2.2 <i64, 8>Trunc10.612.936.033.50.45
fin_decimal 0.4.0 Decimal<8>HalfEven11.714.239.755.00.58
fixnum 0.9.5 <i64, U8>HalfExpand22.116.345.796.50.77
rust_decimal 1.43.0HalfEven36.739.11092860.87
fastnum 0.7.5 D64HalfExpand84.643.91232631.24
decimal-rs 0.2.0HalfExpand52.745.01262950.33
bigdecimal 0.4.11HalfEven1621243471,3284.02

The rounded product, in a chain, wide, 128 bits at 18 decimals:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
f64HalfEven5.295.3615.014.00.00
i128Trunc3.767.7321.629.10.30
primitive_fixed_point_decimal 1.5.0 <i128, 18>HalfExpand21.914.139.572.90.06
ddust D128<18>Trunc17.915.342.986.90.10
nexus-decimal 1.2.2 <i128, 18>Trunc20.115.743.966.70.51
ddust D128<18>HalfExpand19.115.744.192.60.10
ddust D128<18>HalfEven19.917.749.51010.09
fin_decimal 0.4.0 Decimal128<18>HalfEven23.119.554.71090.81
fixnum 0.9.5 <i128, U18>HalfExpand30.823.565.71511.19
fastnum 0.7.5 D128HalfExpand15053.71503611.70
decimal-rs 0.2.0HalfExpand14857.71613540.83
rust_decimal 1.43.0HalfEven85.464.51804051.99
bigdecimal 0.4.11HalfEven2461714781,7704.84

The rounded quotient, in a chain, narrow, 64 bits at 8 decimals:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
i64Trunc4.974.9513.915.00.00
f64HalfEven8.618.7324.514.00.00
ddust D64<8>Trunc14.610.529.433.50.22
ddust D64<8>HalfExpand15.312.234.040.00.22
ddust D64<8>HalfEven16.112.434.739.90.24
fixdec 0.1.0 D64Trunc17.516.044.874.80.47
nexus-decimal 1.2.2 <i64, 8>Trunc17.516.044.874.80.47
primitive_fixed_point_decimal 1.5.0 <i64, 8>HalfExpand21.816.145.063.81.30
fin_decimal 0.4.0 Decimal<8>HalfEven20.816.245.259.70.78
fixnum 0.9.5 <i64, U8>HalfExpand16.918.250.993.90.91
fastnum 0.7.5 D64HalfExpand82.795.62675352.68
rust_decimal 1.43.0HalfEven1091183297642.98
decimal-rs 0.2.0HalfExpand2202075801,1412.67
bigdecimal 0.4.11HalfEven4,2694,41012,33249,17930.47

The rounded quotient, in a chain, wide, 128 bits at 18 decimals:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
f64HalfEven8.618.7324.514.00.00
i128Trunc14.510.128.277.30.36
ddust D128<18>Trunc30.330.685.51430.49
nexus-decimal 1.2.2 <i128, 18>Trunc50.631.187.01821.59
ddust D128<18>HalfExpand31.431.788.61520.50
ddust D128<18>HalfEven33.133.292.81540.50
fin_decimal 0.4.0 Decimal128<18>HalfEven46.137.21042161.50
primitive_fixed_point_decimal 1.5.0 <i128, 18>HalfExpand60.839.71112551.82
fixnum 0.9.5 <i128, U18>HalfExpand43.940.91152712.45
rust_decimal 1.43.0HalfEven93.31052926384.31
decimal-rs 0.2.0HalfExpand2432085801,1153.08
fastnum 0.7.5 D128HalfExpand2172186091,3386.62
bigdecimal 0.4.11HalfEven14,8299,95827,849138,62748.67

The rounded quotient by one divisor, in a chain, narrow, 64 bits at 8 decimals:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
i64Trunc4.975.2414.715.00.00
f64HalfEven8.628.7424.514.00.00
ddust D64<8>Trunc14.511.532.134.40.29
ddust D64<8>HalfExpand15.313.036.340.70.26
ddust D64<8>HalfEven16.213.337.340.60.28
primitive_fixed_point_decimal 1.5.0 <i64, 8>HalfExpand21.415.844.360.50.78
nexus-decimal 1.2.2 <i64, 8>Trunc17.116.044.971.90.20
fixdec 0.1.0 D64Trunc17.116.044.971.90.20
fin_decimal 0.4.0 Decimal<8>HalfEven20.516.646.559.80.70
fixnum 0.9.5 <i64, U8>HalfExpand17.018.351.291.30.67
fastnum 0.7.5 D64HalfExpand83.390.52535112.48
rust_decimal 1.43.0HalfEven1091133167172.46
decimal-rs 0.2.0HalfExpand2222206161,2362.61
bigdecimal 0.4.11HalfEven4,2514,39212,28547,47622.86

The rounded quotient by one divisor, in a chain, wide, 128 bits at 18 decimals:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
f64HalfEven8.618.8024.614.00.00
i128Trunc14.511.632.478.90.60
ddust D128<18>Trunc30.330.685.51430.49
nexus-decimal 1.2.2 <i128, 18>Trunc51.231.487.91841.37
ddust D128<18>HalfExpand31.331.688.41520.50
ddust D128<18>HalfEven33.133.292.71540.49
fin_decimal 0.4.0 Decimal128<18>HalfEven42.833.493.51690.95
primitive_fixed_point_decimal 1.5.0 <i128, 18>HalfExpand60.840.81142581.36
fixnum 0.9.5 <i128, U18>HalfExpand47.941.91172761.79
rust_decimal 1.43.0HalfEven92.593.12606223.01
fastnum 0.7.5 D128HalfExpand2111835121,1654.44
decimal-rs 0.2.0HalfExpand2402196141,1792.68
bigdecimal 0.4.11HalfEven14,9074,35212,17150,97037.22

The rounded quotient by a prepared divisor, in a chain, narrow, 64 bits at 8 decimals:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
ddust D64<8>Trunc6.136.1317.234.00.00
ddust D64<8>HalfExpand7.597.6021.340.00.00
ddust D64<8>HalfEven8.638.6424.243.00.00

The rounded quotient by a prepared divisor, in a chain, wide, 128 bits at 18 decimals:

ContenderModens, predictablens, unpredictableCyclesInstructionsBranch misses
ddust D128<18>Trunc13.814.440.294.00.49
ddust D128<18>HalfExpand17.217.749.51140.49
ddust D128<18>HalfEven19.419.755.11230.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:

ContenderModens, predictablens, unpredictableL1i missesFront-end stalls
i64Trunc0.480.470.0000.00
f64HalfEven, HalfExpand0.490.480.0000.00
ddust D64<8>Trunc1.181.410.0000.40
ddust D64<8>HalfExpand1.401.610.0000.36
ddust D64<8>HalfEven1.661.730.0000.37
fixdec 0.1.0 D64, inexactTrunc, HalfEven2.293.210.0001.30
nexus-decimal 1.2.2 <i64, 8>Trunc, HalfEven2.573.380.0001.21
primitive_fixed_point_decimal 1.5.0 <i64, 8>HalfExpand3.533.600.0001.88
fin_decimal 0.4.0 Decimal<8>HalfEven3.284.850.0001.31
fastnum 0.7.5 D64HalfExpand35.531.10.0017.33
rust_decimal 1.43.0HalfEven33.335.10.0016.29
decimal-rs 0.2.0HalfExpand51.851.10.0014.03
bigdecimal 0.4.11HalfEven9279310.05881.5

The mix, wide, 128 bits at 18 decimals:

ContenderModens, predictablens, unpredictableL1i missesFront-end stalls
f64HalfEven, HalfExpand0.490.480.0000.00
i128Trunc3.543.490.0001.05
ddust D128<18>Trunc5.214.750.0001.17
ddust D128<18>HalfExpand5.965.270.0001.33
ddust D128<18>HalfEven6.385.680.0001.24
primitive_fixed_point_decimal 1.5.0 <i128, 18>HalfExpand13.88.400.0002.78
nexus-decimal 1.2.2 <i128, 18>Trunc, HalfEven11.78.520.0002.90
fin_decimal 0.4.0 Decimal128<18>HalfEven10.99.770.0002.48
rust_decimal 1.43.0HalfEven46.642.20.0019.23
decimal-rs 0.2.0HalfExpand74.253.30.0014.99
fastnum 0.7.5 D128HalfExpand79.960.00.00313.3
bigdecimal 0.4.11HalfEven3,0962,0740.452130

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:

Operationddust D64<8>ddust D128<18>nexus-decimalprimitive_fixed_point_decimalfin_decimalfixnumrust_decimalfastnumdecimal-rsbigdecimal
add101255559553,1841,6099,888
compare56555514232210310,189
mul-round10321569641012815873,2951,29511,060
div-round3736742483301642801,2674,6031,85412,338
div-round-prepared48132
rescale-round23914328332213,21426210,359
parse1,1861,1893616163795981,0515355111,356
format3431,7243331291,248129129129129132
to-f64701301977193761771,99310,163
from-f64141101181511488467130,34553010,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 D128 quotient costs about three times a D64’s, and a chain of them two to three times; a quantity, a price or a cash amount fits a D64.
  • Dividing many values by one decimal. A Divisor prepared 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

TargetWhat runs
x86_64 Linux, at x86-64-v2every test and doctest, with every feature, and the book’s listings
arm64 Linuxevery test, with every feature, with none, and in release
macOS on Apple silicon, and on Intelthe same
x86_64 Linux, at plain x86-64every test, debug and release, with no feature and with runtime-dispatch
x86_64 Linux, at x86-64-v3every test, debug and release
aarch64-unknown-none, x86_64-unknown-nonethe build, with no feature and with every feature but arbitrary’s
i686-unknown-linux-gnucargo 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:

BuildText is read and written
aarch64, little-endian, with NEON: every one but the soft-floatin NEON’s lanes
x86_64 with SSSE3: -C target-cpu=x86-64-v2 or newerin SSSE3’s lanes, with no check at run time
x86_64 without SSSE3, with runtime-dispatchin SSSE3’s lanes where a check of the CPU, once, finds them
anything else, and any other texteight 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

FeatureWhat it adds
arbitraryarbitrary’s Arbitrary, for fuzzing: any steps at any scale, and any rounding mode; needs std
bytemuckbytemuck’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
defmtdefmt’s Format, to log a decimal from a device as its text, written without core::fmt
num-traits-02num-traits 0.2’s Zero, ConstZero, Bounded, Checked*, Saturating* and Wrapping* traits, FromPrimitive and ToPrimitive
proptestproptest’s Arbitrary, for property tests: any steps at any scale, and any rounding mode
rand-09rand 0.9’s SampleUniform, to draw a decimal uniformly from a range, through ddust::rand_09::UniformDecimal
runtime-dispatcha 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
serdeserde’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-08zerocopy 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:

TraitFor
CheckedAdd, CheckedSub, CheckedDiv, CheckedRemevery decimal
CheckedNeg, WrappingNegevery signed decimal
SaturatingAdd, SaturatingSub, WrappingAdd, WrappingSuba static scale, and Dynamic
CheckedMulDynamic, whose product is its own type
Zero, ConstZero, Bounded, FromPrimitivea static scale
ToPrimitiveevery 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:

CrateSumRounded productRounded quotientTo centsReadWriteThe mix
ddust D64<8>0.763.975.401.309.759.841.73
fixdec 0.1.0 D640.734.49, inexact8.123.8223.540.43.21, inexact
nexus-decimal 1.2.2 <i64, 8>0.735.578.184.3219.223.33.38
primitive_fixed_point_decimal 1.5.0 <i64, 8>0.733.6810.93.2120.533.43.60
fin_decimal 0.4.0 Decimal<8>0.759.6311.44.9920.7, inexact26.64.85
fastnum 0.7.5 D6416.633.886.218.324.280.031.1
rust_decimal 1.43.09.7930.711421.819.167.035.1
decimal-rs 0.2.07.6140.91996.5743.644.051.1
bigdecimal 0.4.1138.01244,41675.591.1111931
fixnum 0.9.5 <i64, U8>0.7513.314.329.428.5
CrateSumRounded productRounded quotientTo centsReadWriteThe mix
ddust D128<18>1.068.1215.84.1510.016.45.68
primitive_fixed_point_decimal 1.5.0 <i128, 18>1.057.3029.98.3939.677.68.40
nexus-decimal 1.2.2 <i128, 18>1.058.6322.214.720.849.38.52
fin_decimal 0.4.0 Decimal128<18>1.0512.927.48.8935.2, inexact46.29.77
rust_decimal 1.43.010.657.898.843.730.091.742.2
decimal-rs 0.2.07.6552.51989.5862.148.453.3
fastnum 0.7.5 D12818.244.720624.031.910360.0
bigdecimal 0.4.1147.817110,12790.51171392,074
fixnum 0.9.5 <i128, U18>1.0317.434.765.943.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.0ddust
a scale per value, 0 to 28; + lines two upa scale in the type, 0 to 38, or carried at run time and lined up
28 or 29 significant digits, in 16 bytesthe 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_strategynine 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 _muloperators 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 itevery reader exact or refused; from_ascii_round rounds by a mode
Display keeps the trailing zeros a value carriesDisplay writes the shortest exact decimal; {:#} every decimal
from_f64 from the double’s shortest digits; to_f64 not correctly roundedfrom_f64 from the double’s exact value, by a mode; to_f64 correctly rounded
no_std with allocno_std with no allocator
stable Rust, from 1.67.1nightly 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:

CrateScaleRounding* and /An operator past the rangeText past the scaleno_std
ddusta const generic, or at run timenine modes, as types or a valueexact; truncatedas the integer’srefusedyes
primitive_fixed_point_decimal 1.5.0a const generic, or passed with each callfive modes, by an argumentrounded half away from zero+ and - as the integer’s; * and / panicrefusedyes
nexus-decimal 1.2.2a const genericfour, each a methodtruncatedpanics in every buildrefusedyes
fixnum 0.9.5a type parameterthree modes, by an argumentno operators: methods with a modeno operators: its methods return an errorrounded; from_str_exact refusesyes
fin_decimal 0.4.0a const genericfive modes, by an argumentrounded by its HalfUp+ and - as the integer’s, but on 256 bits; * and / panicrounded by its HalfUpyes
const-decimal 0.4.0a const generictruncation alonetruncatedpanics in every buildrefusedno

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.