This page records the decisions behind Dec and the reasoning
that led to them. It is the place to look before changing how a value is
represented, how an operation reports failure, or how two values compare.
Goal
Troy provides numeric primitives for high frequency trading, where a decimal operation sits on the critical path between a market data message and an order. Correctness and latency are the two priorities, and where they conflict the design prefers a representation that makes the fast case branch-free over one that makes the failing case convenient.
Dec is deliberately not a general purpose decimal. It carries a fixed scale,
so no operation ever renegotiates it, and no value carries a scale field that
has to be read, compared, or aligned.
Representation
A Dec is a single i128 holding the value scaled by 10^SCALE, where
Dec::SCALE is 18. The type is Copy, 16 bytes, and has
no niche beyond the one described below. The
memory layout page compares those 16 bytes with the
other decimals the benchmarks measure.
use troy::{Dec, dec};
assert_eq!(dec!(1.5).into_raw(), 1_500_000_000_000_000_000);
assert_eq!(Dec::ONE.into_raw(), 1_000_000_000_000_000_000);
Eighteen places is enough for every venue’s price and size increment with room left for intermediate products, and it leaves a finite range of roughly ±1.7e20 — seven orders of magnitude above any notional that will ever be priced.
The finite range is symmetric
i128 is asymmetric: i128::MIN has no positive counterpart, so -x and
x.abs() are partial functions on it. Rather than special case 2^127 in
every magnitude check, Dec holds i128::MIN out of the finite range:
| constant | raw |
|---|---|
Dec::MAX | i128::MAX |
Dec::MIN | -i128::MAX |
Dec::NAN | i128::MIN |
Reserving one value in 2^128 buys a range where negation and absolute value
are total and exact, and it leaves exactly one spare bit pattern.
use troy::Dec;
assert_eq!(-Dec::MIN, Dec::MAX);
assert_eq!(Dec::MIN.into_raw(), -Dec::MAX.into_raw());
The spare pattern is NaN
That spare pattern is Dec::NAN, the not-a-number state.
Every operation that leaves the finite range returns it, and every operation
handed it returns it, so a fault propagates to wherever the result is finally
examined instead of being resolved at the point it occurs.
The alternatives were considered and rejected:
- Saturating answers an overflow with a plausible looking figure. A price
clamped to
Dec::MAXpasses every downstream sanity check and reaches the wire as an order. - Panicking puts an abort in a matching engine’s hot path, which is a worse failure mode than a wrong number in most trading systems.
Optioneverywhere costs an unwrap at every step of an expression and makes the common path read like the failing one.
use troy::{Dec, dec};
// overflow poisons the result rather than clamping it
assert!((Dec::MAX + Dec::ONE).is_nan());
assert!((Dec::MAX * dec!(2)).is_nan());
// and the poison survives everything downstream
assert!((Dec::NAN * dec!(0)).is_nan());
assert!((Dec::NAN - Dec::NAN).is_nan());
assert!(Dec::NAN.abs().is_nan());
assert!(Dec::NAN.round_dp(2).is_nan());
Callers who want the fault resolved on the spot still have the explicit forms:
checked_* returns None, saturating_* clamps to the finite bounds. Both
treat a NaN operand as a failure.
use troy::Dec;
assert_eq!(Dec::MAX.checked_add(Dec::ONE), None);
assert_eq!(Dec::MAX.saturating_add(Dec::ONE), Dec::MAX);
assert_eq!(Dec::NAN.checked_add(Dec::ONE), None);
assert!(Dec::NAN.saturating_add(Dec::ONE).is_nan());
Comparison keeps a total order
This is the one place Dec deliberately departs from IEEE 754. Dec::NAN
equals itself and orders below Dec::MIN:
use troy::Dec;
assert_eq!(Dec::NAN, Dec::NAN);
assert!(Dec::NAN < Dec::MIN);
An IEEE NaN is unordered, which would force PartialEq to be irreflexive and
so remove Eq, and Ord with it. For a decimal whose primary container is a
sorted book that is too high a price: BTreeMap<Dec, Level>, HashMap keys,
sort, binary_search, min/max and #[derive(Eq)] on any struct holding
a Dec all depend on those traits. Comparison is also the cheapest operation
in the crate at roughly one i128 compare, and IEEE semantics would double it
for no benefit on the path that matters.
The justification is that a Dec NaN is not an IEEE NaN. It never arises from
a legitimate calculation like 0.0 / 0.0; it means an overflow happened, which
is a bug in the inputs or in an accumulation. Treating it as an ordered
sentinel is both cheaper and easier to reason about.
The consequence to be aware of: a NaN wins any min reduction and sorts to
the front. Ordering it below Dec::MIN rather than above Dec::MAX is
chosen so that a poisoned value is conspicuous at the head of a sorted book
rather than buried at the tail.
use troy::Dec;
let values = [Dec::ZERO, Dec::NAN, Dec::MIN];
assert_eq!(values.iter().min(), Some(&Dec::NAN));
Check at the boundary
Because arithmetic propagates rather than reports, the check belongs where a
value leaves the system — an order about to be sent, a figure about to be
persisted or published — not at every intermediate step.
is_finite is that check.
use troy::{Dec, dec};
fn submit(price: Dec, size: Dec) -> Option<Dec> {
let notional = price * size;
notional.is_finite().then_some(notional)
}
assert_eq!(submit(dec!(104_237.25), dec!(0.5)), Some(dec!(52_118.625)));
assert_eq!(submit(Dec::MAX, Dec::MAX), None);
A NaN is neither positive, negative, nor zero, so the sign predicates cannot be used as a finiteness test:
use troy::Dec;
assert!(!Dec::NAN.is_zero());
assert!(!Dec::NAN.is_sign_negative());
assert!(!Dec::NAN.is_sign_positive());
Cost: fold the check into an existing one
The NaN state was measured, not assumed, and the result shaped the
implementation. Testing is_nan() at the top of every operation cost 15% on
multiplication, 43% on ceil and 51% on to_f64. Nearly all of that was
recovered by observing that most operations already have a check that a NaN
fails:
floorneeds no test. Flooring2^127rounds away from zero and the reconstruction then overflows, which is already the NaN answer.ceilis written as-floor(-x). Wrapping negation maps NaN to itself and every finite raw to its exact opposite, so it inherits floor’s handling.absandnegusewrapping_abs/wrapping_neg, which fixi128::MINto itself. Propagation is free and branchless.- multiplication tests inside
mul_widerather than on the fast path. A NaN has magnitude2^127, which10^9does not divide, so it can never take the exact-division fast path. to_f64tests after the fast path, for the same reason.
Where no existing check catches it, the test is real and stays: trunc,
round_dp, and round_to_step all need one. The last is load-bearing rather
than defensive — with a step of 0.001 the fraction rounds down, so a NaN
would otherwise reconstruct into the finite range and read as
-170141183460469231731.687.
The general rule for new operations: look for an overflow or exactness check
that a 2^127 magnitude already fails, and let it do the work.
Division reaches 187 bits in u128 steps
The raw quotient is a * 10^18 / b, a product needing 187 bits that no u128
holds. Rather than carry a 256-bit intermediate, every path divides first and
scales the remainder afterwards, which keeps each step inside a u128:
- The divisor carries at most 9 decimals, so
10^9divides its raw exactly. Cancelling that against the10^18leavesa * 10^9 / b9, done as a quotient and a scaled remainder — two divisions and no wide arithmetic. Every price, size and tick takes this path. - Finer than that,
ais reduced belowbfirst, then the remaining10^18is applied to the remainder in two10^9steps. The remainder is belowb, so each step has room. This needs|b|at or below3.4e11. - A divisor above
3.4e11with more than 9 decimals has room for neither step, so the scaling walks the 60 bits of10^18one at a time, keeping a window belowb. Doubling that window or adding the remainder to it stays under twiceb, so the 187-bit product is never formed. It is slow and effectively unreachable from market data, but it keeps the operation exact over the whole range rather than returning NaN for inputs that have an answer.
The three paths overlap on small divisors, where the tests run them against each other: a wrong result would have to be produced identically by three independent routines.
Dividing by zero yields NaN. There is no infinity in the type, so it is the same class of fault as an overflow and collapses to the same state.
Prefer a branch to a select
Overflow handling should be written so the compiler emits a predicted branch
rather than a conditional move. A never-taken branch is speculated through and
never joins the loop-carried dependency chain; a cmov sits on it and adds a
cycle to every iteration.
This is worth more than it sounds. Replacing saturating_add — whose
sign-dependent select was on the dependency chain — with a checked add that
branches to NaN made addition 57% faster, from 2.03 ns to 0.87 ns per
operation, moving Dec past rust_decimal on that benchmark.
Open questions
Displayrenders NaN as"NaN"butFromStrdoes not accept it, so a NaN does not round trip through text, andserdewill serialise a value it cannot read back.checked_*returnsNoneboth for an overflowing result and for a NaN operand, conflating “too big” with “already invalid”.