All notable changes to FastDecimal.
1.0.0 — 2026-05-13
Initial release. Feature parity with ericmj/decimal except the implicit
Decimal.Context (intentional design decision — see FastDecimal moduledoc).
Security
- Not vulnerable to CVE-2026-32686 (exponent-amplification DoS that
affected
decimal< 2.4.0). FastDecimal mitigates with three layers:- Parser layer (
FastDecimal.Parser): explicit exponents in scientific notation are capped at ±65,535. Inputs like"1e1000000000"from untrusted sources return:errorrather than landing as a%FastDecimal{}. pow10/1cap (defense in depth): any internalpow10call withn > 100_000raisesArgumentError. Catches the DoS at every operation that would materialize a large-exponent value (add/sub with huge gap, compare across huge gap, etc.) — even when the value was constructed directly vianew/2bypassing the parser.to_string :normalcap: output >1 MB raises.:scientificand:rawformats remain available for legitimate extreme-exp values.
- Parser layer (
- See
test/fastdecimal/security_test.exsfor regression tests covering each layer.
Notable semantic difference vs prior internal versions
to_string(d, :scientific)now follows IEEE 754-2008's "to-scientific-string" rule (same asdecimal): use normal form whenadjusted_exp >= -6, scientific form only when very small/large. Previously emitted scientific form always. This matches whatdecimalproduces and is what most callers expect.
Features
Struct API —
%FastDecimal{coef: integer | :nan | :inf | :neg_inf, exp: integer}- Sigil —
~d"1.23"for compile-time literals (zero runtime parse cost) - Special values — NaN, +Infinity, -Infinity with IEEE-style propagation through all ops
- Arithmetic —
add/2,sub/2,mult/2,div/3,div_int/2,div_rem/2,rem/2,negate/1,abs/1,sqrt/2 - Batch —
sum/1,product/1(~26-30× faster thanEnum.reduce(_, _, &Decimal.add/2)) - Comparison —
compare/2,equal?/2,lt?/2,gt?/2,min/2,max/2 - Predicates —
zero?/1,positive?/1,negative?/1,nan?/1,inf?/1,finite?/1 - Rounding —
round/3with all 7 rounding modes (:half_even,:half_up,:half_down,:down,:up,:floor,:ceiling) - Conversion —
to_string/2with:normal,:scientific,:raw,:xsdformats;to_integer/1,to_float/1,normalize/1 - Parsing —
new/1,parse/1,cast/1(soft). Accepts decimals, scientific notation (1.23e10), and special-value strings ("NaN","Infinity","-Inf"). - Guards —
is_decimal/1macro for guard clauses - Compat shim —
FastDecimal.CompatmirrorsDecimal's function signatures; drop-in viaalias FastDecimal.Compat, as: Decimal - Ecto integration —
FastDecimal.Ecto.TypeimplementsEcto.Type(auto-compiled when Ecto is present)
Performance vs ericmj/decimal v2.4 (M-series Mac, OTP 26, BEAMAsm)
Geometric mean speedup across 22 op/size scenarios: ~11.2× (range
observed across consecutive runs: 11.11×–11.28×). FastDecimal wins on
22/22 scenarios in most runs; to_string ops hover at parity and may
flip to 21/22 ±1 op based on macOS scheduler noise. Full table and methodology in README.md and
bench/README.md; reproduce with mix bench.
Highlights (tight-loop medians, BEAMAsm JIT):
| Op (medium values) | decimal | FastDecimal | speedup |
|---|---|---|---|
| add / sub / mult | ~250 ns | ~13 ns | ~20× |
| compare | ~85 ns | ~8.5 ns | ~10× |
| div (p=28) | ~3.0 µs | ~234 ns | ~13× |
| div_rem | ~137 ns | ~22 ns | ~6× |
| round (3dp) | ~430 ns | ~33 ns | ~13× |
| parse | ~235 ns | ~78 ns | ~3× |
| sum of 100 | ~22 µs | ~0.4 µs | ~55× |
Large values (~10^14) widen the arithmetic gap to 70–100× because decimal's BigInt allocation cost dominates while FastDecimal stays in the 60-bit immediate-int range longer.
to_string(_, :normal) and to_string(_, :scientific) are at parity
(~1.0×); decimal's formatter is exceptionally tight. to_integer is 1.6×
faster but the op is so cheap (~10 ns) that scheduler noise dominates the
pessimistic IQR edge. No other op is below 2× in our measured set.
On non-JIT BEAM (older threaded-code interpreter), geomean speedup drops to ~7.7× — the JIT amplifies our advantage but doesn't create it.
Correctness verification
- 13 doctests + 35 property tests + 277 unit tests = 325 total, all green.
- The correctness suite (test/fastdecimal/correctness_test.exs) performs >10,000 individual cross-checks between FastDecimal and Decimal across diverse input matrices for every operation. It also pins known exact mathematical results per operation (e.g.,
0.1 + 0.2 == 0.3exactly,sqrt(4) == 2, full banker's rounding tables) — verifying correctness without relying on Decimal as the source of truth. - Property tests cover invariants: round-trip, commutativity, associativity,
div_remidentity (a == q·b + r),sqrt(x)² ≈ x, comparison antisymmetry/transitivity/reflexivity, NaN propagation, normalize idempotence.
Design choices documented
- Exact arithmetic.
add/sub/mult/sum/productnever round. - Per-call precision (only
div/3,sqrt/2,round/3take a precision arg). - No
Decimal.Context— would erase the speedup; specify precision per call. - No separate sign field — sign lives in
coef. - No NaN signaling distinction (
sNaN/qNaNcollapsed to one:nan). - Pure Elixir core, no native compilation step. A Rust NIF was prototyped, benchmarked, and rejected for nearly every op (per-op NIF dispatch ≈ 36 ns ≥ BEAM-side add ≈ 42 ns). The prototype was deleted before v1.0; the design rationale is preserved in README.md and bench/README.md.