All notable changes to FastDecimal.
1.1.0 — 2026-07-03
Changed — div/3 exact results now use the GDA ideal exponent
div/3 previously always ran the scale-to-precision machinery and returned
un-reduced coefficients: div(10, 2) gave {coef: 5×10^27, exp: -27} —
numerically 5, but carried as a 28-digit bignum that pushed every downstream
add/compare/to_string onto slow paths (compare(result, 5) cost 211 ns
because the exponent-gap fallback had to materialize pow10(27) per call).
Exact divisions now return the ideal exponent form from the General
Decimal Arithmetic spec — exactly what Decimal.div/2 returns, bit-for-bit:
div(10, 2) → %FastDecimal{coef: 5, exp: 0} (was coef: 5×10^27)
div(1, 2) → %FastDecimal{coef: 5, exp: -1}
div("10.0", 2) → %FastDecimal{coef: 50, exp: -1} ("5.0", like Decimal)
div(0, 3) → %FastDecimal{coef: 0, exp: 0} (was {0, -28})This is a representation change only — values are identical, equal?
and compare are unaffected, but code pattern-matching raw coef/exp of
exact division results will see the new (Decimal-matching) form. Inexact
results additionally get the GDA carry re-round: an all-nines coefficient
that rounds up (999…9 → 1000…0) folds back to precision digits
(div(95, 10, precision: 1) → {1, 1}, not {10, 0}). Python's decimal
agrees with the fold; Elixir's Decimal deviates from the spec here and
keeps the precision+1-digit {10, 0} form — we follow the spec.
Performance (medians, same micro-harness as bench/targeted.exs):
- Integer-exact (
10/2), ÷1, ÷10^k divisions: ~360 ns → ~30 ns (11-14×) via a smallint-gated fast path — oneremprobe, one integer division, no scaling. Divisor trailing zeros are peeled first so ÷10/÷100/÷1000 (percent, cents, basis points) qualify. - Zero dividend: 44 → 25 ns, and now exponent-correct (
{0, ideal}). - Divide-then-compare chain (
div(10,2) |> compare(5)): 493 → 31 ns — the downstream unbloating is the bigger half of the win. - Costs, called out honestly: inexact smallint div pays the probe
(~369 → ~385 ns, +4%); fractional-exact via the scaled route
(
1/2 = 0.5) pays a bounded zero-strip (~283 → ~372 ns) that buys the compact result; exact bignum div (~501 → ~671 ns) likewise. Inexact bignum div skips the probe entirely via the smallint gate (~+5%, guard dispatch only). Headlinemix benchgeomean is unchanged within noise (10.51×–10.72× across 3 runs); realistic fintech workloads (bench/realistic.exs) improved to 11–21× vs decimal.
New coverage: pinned GDA-representation unit tests (validated against
Decimal output), a differential property asserting exact divisions match
Decimal's representation bit-for-bit, an independent correct-rounding oracle
property (|a/b − r| ≤ ½ulp, ties to even) over precisions 1–30, and a
precision-bound property (digits(coef) ≤ precision always).
Correctness — divergence found while testing: decimal mis-rounds; we don't
The correct-rounding oracle surfaced that decimal itself mis-rounds
certain :half_even divisions where the guard digit is 5 followed by a
nonzero tail (not a true tie): 4.7460 / -5522 at precision 28 truly ends
…140|5287… and must round up to …141; Decimal returns …140.
FastDecimal rounds these correctly and deliberately does not bug-compat
(pinned in correctness_test.exs).
Performance — digits/1, div opts handling, Compat.div
Headline mix bench geomean moved from ~9.9× to ~10.7× (22/22 scenarios
faster, 19/22 stable ≥2× at IQR edges), driven by the div rows. All numbers
below are medians from the same micro-harness, same machine, same run
methodology as bench/targeted.exs.
Hybrid
digits/1: guard chain for smallints,integer_to_binary/1for bignums. The old implementation recursed with div-by-10^9 — O(d²) in bignum word ops. Now: values < 10^17 walk a pure smallint guard chain (extended from 10^9, so 10–17-digit values no longer pay a bignum division per step), the 18-digit smallint band[10^17, 2^59)is answered by one compare against the max-smallint constant, and everything larger goes tobyte_size(:erlang.integer_to_binary(n)). Affectsdiv/3,compare/2's large-gap path, andto_string/2with positive exponents:div41-digit ÷ 15-digit coef: 861 ns → ~460 ns (1.9× faster).div201-digit ÷ 91-digit coef: 7.96 µs → ~3.2 µs (2.5× faster).comparelarge-gap with an 18-digit coef: 49 ns → 27 ns.to_stringof a 41-digit-coef value withexp > 0: 648 ns → ~405 ns.
Implementation gotcha, preserved for posterity: the dispatch constant must stay a smallint. A first draft guarded on
n >= 10^18— but 10^18 exceeds the 60-bit immediate range (max ≈ 5.76×10^17), so the literal is a bignum and every call paid ~5 ns in the generic-compare path, three times perdiv. Dispatching on 10^17 fixed it.Default-opts fast path in
div/3.div(a, b)(empty opts, the common case) now pattern-matches[]and passes literal defaults instead of twoKeyword.getlookups. Together with thedigits/1change:divsmall coefs, precision 28: 527 ns → ~360 ns (1.5× faster). Explicit-opts calls are unchanged.Compat.div/3no longer injectsprecision: 28viaKeyword.put_new—FastDecimal.div/3already defaults to 28, so the shim was allocating a keyword cell per call for nothing. Compat div: 549 ns → ~375 ns (combined with the two changes above).Tried and rejected (documented in
bench/README.md): computing thediv_remremainder asdividend - quot * divisorinstead of a secondremBIF call — measured 61 ns vs 33 ns for the BIF pair; BEAM's two division BIFs beat the mul-sub at every size tested. Current code kept.
Fixed
min/2/max/2NaN semantics now matchDecimal(IEEE 754 minNum/maxNum). Previouslymin(nan, x)returned NaN butmin(x, nan)returnedx— asymmetric, and diverging fromDecimal.min/2, which returns the number whenever exactly one operand is NaN. Both functions now return the non-NaN operand; NaN is returned only when both operands are NaN. Costs one extra clause-head check (~1 ns) on the numeric path.
1.0.2 — 2026-05-24
Performance
Four focused micro-optimizations from a hot-path audit. Headline mix bench
geomean is unchanged because the existing summary scenarios use same-exp
medium values that already hit the fast path; the wins live on
the dedicated bench/targeted.exs script.
Hybrid
compare/2for large exponent gaps. When|e1 − e2| > 4, the compare now routes through a sign + adjusted-exponent prefilter that resolves obviously-different magnitudes in O(1) instead of buildingpow10(gap). The fall-back to direct scaling only fires when adjusted exponents tie (a shape that requires the digit counts to differ by exactly the exp gap — not reachable from compact untrusted input). Bench impact:compare gap=100(e.g.,coef=1 exp=100vscoef=2 exp=0): 77 ns → 25 ns (3.1× faster).compare gap=1000: 1290 ns → 24 ns (54× faster).- Same-exp, small-gap, NaN, Inf paths: unchanged.
Security note: this also makes
compare(huge_exp, _)resolve safely for the common DoS shape instead of raising at the pow10 cap. CVE-2026-32686- class protection is stricter — same allocation guarantee, plus a correct answer where the old code threw.Group-9 trailing-zero stripping in
normalize/1. When the coefficient has|c| ≥ 10^9, strips zeros in chunks of nine viadiv(c, 10^9)instead of one-at-a-time. Theor-short-circuit in the precondition guard skips therem(c, 10^9)probe entirely for smaller coefficients, so the typical 0- or 1-zero case stays at baseline speed.normalizewith 9 trailing zeros: 50 ns → 22 ns (2.3× faster).normalizewith 27 trailing zeros: 720 ns → 130 ns (5.5× faster).- No-zeros / 1-zero / zero-coef paths: unchanged.
Parser argument pruning in the internal numeric walk. Dropped the always-zero
_digitsarg fromparse_intand the always-true_seen_frac?arg fromparse_frac. Pure refactor — same semantics, smaller stack frames, fewer arg loads per recursion. Marginal but consistent ~1-3% improvement on parse paths inmix bench(parse small: 51 ns → 49 ns;parse medium: 64 ns → 62 ns).to_integer/1fore < 0bindspow10(-e)once instead of computing it twice (once forrem, once fordiv). 10–20% faster on exact-integer cases (to_integer @ exp=-2: 18 ns → 15 ns;to_integer @ exp=-18: 29 ns → 26 ns).
Tests
- 13 new tests pinning each branch of the hybrid-compare prefilter (sign-decides, zero-coef short-circuit, adjusted-equal fallback, negative-coef large-gap) and the group-9 normalize path (1/9/27 trailing zeros, negative coef, exactly-8 below threshold).
- 2 new property tests covering
compareover a wider exp range (±100) vsDecimal.compare, exercising the adjusted-exponent prefilter path that the default ±12 generator never reaches. - Updated 1 security test for
compare(huge, _): asserts the new safe-return behavior with explicit DoS-shape inputs instead of the priorpow10raise.
Benchmarks
- New
bench/targeted.exsscript (added tobench.all). Runs throughBench.Supportfor statistically-rigorous measurement on the exact scenarios this release touches: 5 parse cases, 7 compare cases (including the new large-gap and adjusted-equal paths), 5 normalize cases (no-zeros through 27 zeros), and 3 to_integer cases. Use this to verify any future regression in these paths.
1.0.1 — 2026-05-13
API
- Added
FastDecimal.from_float/1to close a drop-in compatibility gap withdecimal. Surveyed 10 production Elixir libraries (ash, ecto, kipcole9/money, ex_cldr_numbers, teslamate, plausible, etc.) andDecimal.from_float/1is the #3 most-called function in real-world Elixir code (8.3% of allDecimal.*calls, used by 7/10 surveyed repos). Previously directFastDecimal.from_float/1raisedUndefinedFunctionError; theCompatshim already provided it viacast/1. Now both routes work identically. Mirrors decimal'sfrom_float/1signature.
Performance
- Parser 4-byte fast path in the internal numeric walk (used by
new/1andparse/1). Multi-digit integer and fractional runs now consume 4 bytes per recursive call instead of 1. Bench impact:parse "1234.56789"(medium): 123 ns → 65 ns (1.9× faster), bumping the speedup overdecimalfrom 1.94× to 3.7× (now stable at IQR edges).parse "1.23"(small): unchanged within bench noise.- Longer numeric strings benefit proportionally — the win scales with significant-digit count.
Internal hygiene
- Added
@speccoverage to every public function inFastDecimal.Compat(the drop-inalias FastDecimal.Compat, as: Decimalmigration shim). Improves Dialyzer / IDE / tooling support for migrators. - Marked the internal parser's
parse_walkandparse_splitheads as@doc false— they'redef(notdefp) only becausebench/parse.exsandtest/fastdecimal/parser_test.exsreach into them for the strategy shootout. The doc tag makes the intent explicit. - Removed a dead
isqrt(0)clause insqrt/2. Caller already filterscoef: 0directly, so the clause was unreachable. Dialyzer-clean. - Added zero-coefficient short-circuits to
to_integer/1andto_float/1.to_integer(%FastDecimal{coef: 0, exp: -1_000_000_000})now returns0instead of tripping the pow10 cap. (Mirrors the decimal v2.4.1 fix philosophy —0 × 10^anythingis always0, so don't bother materializing the alignment factor.)
Documentation
- MIGRATION.md section 5 now covers both
decimalv2.4 (opt-in:max_digits/:max_exponent) and v3.0+ (IEEE 754 decimal128 defaults) migration paths. - Updated decision-tree grep to catch the 2-arg form of
Decimal.newwith opts (added indecimalv3.1.0).
Infrastructure
- GitHub Actions CI: matrix test across Elixir 1.15/OTP 26 (minimum),
1.17/OTP 27, 1.18/OTP 28 (latest);
mix format --check-formattedand Dialyzer jobs; PR-onlymix benchsmoke test that catches bench-script rot but explicitly does not gate on Actions-runner timing noise.
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.