Skip to content

Releases: b-erdem/fastdecimal

v1.1.0 — div exact fast path + GDA ideal exponent

Choose a tag to compare

@b-erdem b-erdem released this 04 Jul 10:24

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/1 now uses a smallint guard chain below 10^17 and integer_to_binary/1 for bignums (the old div-by-10^9 recursion was O(d²)): big-coefficient div ~2× faster (861 → ~460 ns at 41 digits), large-gap compare with 18-digit coefs 49 → 27 ns.
  • div(a, b) with default opts skips the Keyword.get lookups; Compat.div/3 drops a redundant Keyword.put_new.

Fixed

  • min/2/max/2 now return the number when exactly one operand is NaN (IEEE 754 minNum/maxNum, matching Decimal). Previously asymmetric: min(nan, x) → NaN but min(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

Choose a tag to compare

@b-erdem b-erdem released this 24 May 13:20

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 compare over a wider exp range (±100) vs Decimal.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

Choose a tag to compare

@b-erdem b-erdem released this 13 May 20:14

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 with decimal. Surveyed 10 production Elixir libs (ash, ecto, kipcole9/money, ex_cldr_numbers, teslamate, plausible, etc.) and Decimal.from_float/1 is the #3 most-called function in real-world code (8.3% of all Decimal.* calls, 7/10 surveyed repos). FastDecimal had from_integer/1 but no float equivalent — direct callers writing FastDecimal.from_float(0.5) hit UndefinedFunctionError. Now they don't. (The Compat shim already provided it via cast/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 vs decimal jumps 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-formatted and Dialyzer jobs (PLT cached separately from _build so dep churn doesn't bust it). PR-only mix bench smoke test catches bench-script rot without trying to detect ns-scale regressions on noisy shared runners.
  • Status badge added to README.

Internal hygiene

  • Added @spec coverage to every public function in FastDecimal.Compat (the drop-in migration shim). Improves Dialyzer / IDE / tooling support for migrators.
  • Marked the internal parser's parse_walk and parse_split heads as @doc false.
  • Removed a dead isqrt(0) clause in sqrt/2 (Dialyzer-clean).
  • Zero-coefficient short-circuits in to_integer/1 and to_float/1. to_integer(%FastDecimal{coef: 0, exp: -1_000_000_000}) now returns 0 instead of tripping the pow10 cap. (Mirrors the decimal v2.4.1 fix philosophy — 0 × 10^anything is always 0.)

Documentation

  • MIGRATION.md section 5 now covers both decimal v2.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 in decimal v3.1.0).

Tests

  • 350 total (15 doctests + 35 properties + 300 unit). All green.

Full notes in CHANGELOG.md.

v1.0.0 — initial release

Choose a tag to compare

@b-erdem b-erdem released this 13 May 18:53

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
  • Arithmeticadd/2, sub/2, mult/2, div/3, div_int/2, div_rem/2, rem/2, negate/1, abs/1, sqrt/2
  • Batchsum/1, product/1 (~26–30× faster than Enum.reduce(_, _, &Decimal.add/2))
  • Comparison & predicatescompare/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
  • Conversionto_string/2 with :normal, :scientific, :raw, :xsd; to_integer/1, to_float/1, normalize/1
  • Parsingnew/1, parse/1, cast/1 (soft). Decimals, scientific notation, special-value strings
  • Guardsis_decimal/1 macro
  • Compat shimalias FastDecimal.Compat, as: Decimal for drop-in migration
  • Ecto integrationFastDecimal.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.