Releases: b-erdem/fastdecimal
Release list
v1.1.0 — div exact fast path + GDA ideal exponent
Minor release: exact divisions get a dedicated fast path and Decimal-matching ideal-exponent results, big-coefficient digit counting is rewritten, and min/max NaN semantics now match Decimal. Headline mix bench geomean moves from ~9.9× to ~10.6× over decimal (22/22 scenarios faster). The new division test suite also uncovered two rounding bugs in decimal itself, reported upstream as ericmj/decimal#236.
📦 Hex: https://hex.pm/packages/fastdecimal/1.1.0
📖 Docs: https://hexdocs.pm/fastdecimal/1.1.0
Changed — exact div/3 results use the GDA ideal exponent
div(10, 2) previously returned {coef: 5×10^27, exp: -27} — numerically 5, but a 28-digit bignum that dragged every downstream add/compare/to_string onto slow paths. Exact divisions now return the ideal-exponent form from the General Decimal Arithmetic spec, bit-for-bit what Decimal.div/2 returns:
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)Representation change only — values identical, equal?/compare unaffected; only code pattern-matching raw coef/exp of exact division results sees the new form. Inexact results additionally get the GDA all-nines carry re-round (div(95, 10, precision: 1) → {1, 1}).
| Case | Before | After |
|---|---|---|
div(10, 2) (exact) |
366 ns | 32 ns |
div by 1 / by 100 |
387 / 356 ns | 35 / 41 ns |
div(10,2) |> compare(5) chain |
493 ns | 31 ns |
| inexact div (pays the probe) | 369 ns | 385 ns |
Performance — hybrid digits/1, opts fast path
digits/1now uses a smallint guard chain below 10^17 andinteger_to_binary/1for bignums (the old div-by-10^9 recursion was O(d²)): big-coefficientdiv~2× faster (861 → ~460 ns at 41 digits), large-gapcomparewith 18-digit coefs 49 → 27 ns.div(a, b)with default opts skips theKeyword.getlookups;Compat.div/3drops a redundantKeyword.put_new.
Fixed
min/2/max/2now return the number when exactly one operand is NaN (IEEE 754 minNum/maxNum, matchingDecimal). Previously asymmetric:min(nan, x)→ NaN butmin(x, nan)→ x.
Correctness
The new independent correct-rounding oracle property (|a/b − r| ≤ ½ulp, ties to even, precisions 1–30) surfaced that decimal itself mis-rounds ~5% of random divisions under :half_even (sticky remainder discarded) and violates the :ceiling/:floor directional guarantee on another ~5% — FastDecimal rounds all of these correctly (verified against Python's reference implementation on 20,000-case fuzz runs) and deliberately does not bug-compat. Details, fuzz data, and a validated one-line upstream fix: ericmj/decimal#236.
Coverage
385 tests (was 365): pinned GDA-representation tests, an exact-div differential property vs Decimal, the correct-rounding oracle, a precision-bound property, and eight pinned div scenarios in bench/targeted.exs. Full before/after numbers in the CHANGELOG.
v1.0.2 — hybrid compare + group-9 normalize
Patch release: four focused hot-path optimizations from a deep review pass. Same headline mix bench geomean (the existing summary scenarios already hit the fast path), but meaningful wins on the rarer paths where they were paying for it.
📦 Hex: https://hex.pm/packages/fastdecimal/1.0.2
📖 Docs: https://hexdocs.pm/fastdecimal/1.0.2
Performance
Hybrid compare/2 for large exponent gaps
When |e1 − e2| > 4, compare now routes through a sign + adjusted-exponent (digits(|c|) + e) prefilter that resolves obviously-different magnitudes in O(1) instead of building pow10(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).
| Case | Before | After | Speedup |
|---|---|---|---|
compare gap=100 |
77 ns | 25 ns | 3.1× |
compare gap=1000 |
1290 ns | 24 ns | 54× |
| same-exp / small-gap / NaN / Inf | — | — | unchanged |
Security note: this makes compare(huge_exp, _) resolve safely for the common DoS shape instead of raising at the pow10 cap. CVE-2026-32686 protection is now stricter — the operation returns a correct answer with zero allocation where the old code threw an error.
Group-9 trailing-zero stripping in normalize/1
Strips zeros in chunks of nine via div(c, 10^9) when |c| ≥ 10^9. The or-short-circuit in the precondition guard skips the rem(c, 10^9) probe entirely for smaller coefficients, so the typical 0- or 1-zero case stays at baseline speed.
| Case | Before | After | Speedup |
|---|---|---|---|
normalize 9 trailing zeros |
50 ns | 22 ns | 2.3× |
normalize 27 trailing zeros |
720 ns | 130 ns | 5.5× |
| no-zeros / 1-zero / zero-coef | — | — | unchanged |
Parser argument pruning
Dropped the always-zero _digits arg from parse_int and the always-true _seen_frac? arg from parse_frac. Pure refactor — same semantics, smaller stack frames, fewer arg loads per recursion. Marginal but consistent ~1–3% improvement on parse paths in mix bench.
to_integer/1 cleanup
For e < 0, binds pow10(-e) once instead of computing it twice (once for rem, once for div). 10–20% faster on exact-integer cases.
Tests
- +13 unit 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 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, _)to assert the new safe-return behavior with explicit DoS-shape inputs. - Total: 365 (15 doctests + 37 properties + 313 unit), all green.
Benchmarks
New bench/targeted.exs script (added to mix bench.all). Runs through the same Bench.Support harness as bench/summary.exs and covers the exact scenarios these optimizations target: 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 regression-check any future hot-path tweaks.
Full notes in CHANGELOG.md.
v1.0.1 — parser speedup + from_float/1
Patch release with one API addition and one meaningful performance win, plus CI infrastructure.
📦 Hex: https://hex.pm/packages/fastdecimal/1.0.1
📖 Docs: https://hexdocs.pm/fastdecimal/1.0.1
API additions
FastDecimal.from_float/1— closes a drop-in compat gap withdecimal. Surveyed 10 production Elixir libs (ash, ecto, kipcole9/money, ex_cldr_numbers, teslamate, plausible, etc.) andDecimal.from_float/1is the #3 most-called function in real-world code (8.3% of allDecimal.*calls, 7/10 surveyed repos). FastDecimal hadfrom_integer/1but no float equivalent — direct callers writingFastDecimal.from_float(0.5)hitUndefinedFunctionError. Now they don't. (TheCompatshim already provided it viacast/1; both routes now work identically.)
Performance
- Parser 4-byte fast path. Multi-digit integer and fractional runs in the binary walk now consume 4 bytes per recursive call instead of 1. Bench impact:
parse "1234.56789": 123 ns → 65 ns — speedup vsdecimaljumps from 1.94× (marginal) to 3.7× (stable).parse "1.23": within bench noise (short strings don't hit the fast path; the new clause's binary-length check is essentially free when it can't match).- Longer numeric strings benefit proportionally with significant-digit count.
Infrastructure
- GitHub Actions CI added — matrix test across Elixir 1.15/OTP 26 (minimum supported), 1.17/OTP 27, 1.18/OTP 28 (latest). Separate
mix format --check-formattedand Dialyzer jobs (PLT cached separately from_buildso dep churn doesn't bust it). PR-onlymix benchsmoke test catches bench-script rot without trying to detect ns-scale regressions on noisy shared runners. - Status badge added to README.
Internal hygiene
- Added
@speccoverage to every public function inFastDecimal.Compat(the drop-in migration shim). Improves Dialyzer / IDE / tooling support for migrators. - Marked the internal parser's
parse_walkandparse_splitheads as@doc false. - Removed a dead
isqrt(0)clause insqrt/2(Dialyzer-clean). - Zero-coefficient short-circuits in
to_integer/1andto_float/1.to_integer(%FastDecimal{coef: 0, exp: -1_000_000_000})now returns0instead of tripping the pow10 cap. (Mirrors thedecimalv2.4.1 fix philosophy —0 × 10^anythingis always0.)
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. - Decision-tree grep widened to catch the 2-arg form of
Decimal.new(added indecimalv3.1.0).
Tests
- 350 total (15 doctests + 35 properties + 300 unit). All green.
Full notes in CHANGELOG.md.
v1.0.0 — initial release
Pure-Elixir fast arbitrary-precision decimal arithmetic. A drop-in alternative to ericmj/decimal with feature parity except Decimal.Context (intentional design — see FastDecimal moduledoc).
📦 Install: {:fastdecimal, "~> 1.0"}
📖 Docs: https://hexdocs.pm/fastdecimal
🔁 Migrating from decimal: see MIGRATION.md
Performance vs ericmj/decimal v2.4
Geomean speedup across 22 op/size scenarios: ~11.2× (M-series Mac, OTP 26, BEAMAsm JIT). Wins on 22/22 scenarios in most runs.
| 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× |
| round (3dp) | ~430 ns | ~33 ns | ~13× |
| parse | ~235 ns | ~78 ns | ~3× |
| sum of 100 | ~22 µs | ~0.4 µs | ~55× |
Reproduce with mix bench. Large-value (~10^14) arithmetic widens to 70–100× as decimal's BigInt costs dominate. Non-JIT BEAM geomean is ~7.7× — the JIT amplifies the advantage but doesn't create it.
Security
Not vulnerable to CVE-2026-32686 (exponent-amplification DoS that affected decimal < 2.4.0). Three layers of defense — parser cap (±65,535 exp), pow10/1 cap (100,000), to_string :normal cap (1 MB). See test/fastdecimal/security_test.exs for regression coverage of each layer.
Features
- Struct API —
%FastDecimal{coef: integer | :nan | :inf | :neg_inf, exp: integer} - Sigil —
~d"1.23"for compile-time literals - Special values — NaN, +Infinity, -Infinity with IEEE-style propagation
- 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 & predicates —
compare/2,equal?/2,lt?/2,gt?/2,zero?/1,positive?/1,negative?/1,nan?/1,inf?/1,finite?/1,min/2,max/2 - Rounding — all 7 modes:
:half_even,:half_up,:half_down,:down,:up,:floor,:ceiling - Conversion —
to_string/2with:normal,:scientific,:raw,:xsd;to_integer/1,to_float/1,normalize/1 - Parsing —
new/1,parse/1,cast/1(soft). Decimals, scientific notation, special-value strings - Guards —
is_decimal/1macro - Compat shim —
alias FastDecimal.Compat, as: Decimalfor drop-in migration - Ecto integration —
FastDecimal.Ecto.Type(auto-compiled when Ecto is present)
Correctness
348 tests total — 13 doctests, 35 property tests, 277 unit tests, 23 security regression tests. All green. The correctness suite performs >10,000 cross-checks against decimal across diverse input matrices, plus pinned exact mathematical results per operation (e.g. 0.1 + 0.2 == 0.3 exactly, sqrt(4) == 2, full banker's rounding tables).
See CHANGELOG.md for the full v1.0.0 notes including design choices and the rejected Rust NIF experiment.