All notable changes to FastDecimal.

1.0.1 — 2026-05-13

API

  • Added FastDecimal.from_float/1 to close a drop-in compatibility gap with decimal. Surveyed 10 production Elixir libraries (ash, ecto, kipcole9/money, ex_cldr_numbers, teslamate, plausible, etc.) and Decimal.from_float/1 is the #3 most-called function in real-world Elixir code (8.3% of all Decimal.* calls, used by 7/10 surveyed repos). Previously direct FastDecimal.from_float/1 raised UndefinedFunctionError; the Compat shim already provided it via cast/1. Now both routes work identically. Mirrors decimal's from_float/1 signature.

Performance

  • Parser 4-byte fast path in the internal numeric walk (used by new/1 and parse/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 over decimal from 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 @spec coverage to every public function in FastDecimal.Compat (the drop-in alias FastDecimal.Compat, as: Decimal migration shim). Improves Dialyzer / IDE / tooling support for migrators.
  • Marked the internal parser's parse_walk and parse_split heads as @doc false — they're def (not defp) only because bench/parse.exs and test/fastdecimal/parser_test.exs reach into them for the strategy shootout. The doc tag makes the intent explicit.
  • Removed a dead isqrt(0) clause in sqrt/2. Caller already filters coef: 0 directly, so the clause was unreachable. Dialyzer-clean.
  • Added zero-coefficient short-circuits to 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, so don't bother materializing the alignment factor.)

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.
  • Updated decision-tree grep to catch the 2-arg form of Decimal.new with opts (added in decimal v3.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-formatted and Dialyzer jobs; PR-only mix bench smoke 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 :error rather than landing as a %FastDecimal{}.
    • pow10/1 cap (defense in depth): any internal pow10 call with n > 100_000 raises ArgumentError. 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 via new/2 bypassing the parser.
    • to_string :normal cap: output >1 MB raises. :scientific and :raw formats remain available for legitimate extreme-exp values.
  • See test/fastdecimal/security_test.exs for 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 as decimal): use normal form when adjusted_exp >= -6, scientific form only when very small/large. Previously emitted scientific form always. This matches what decimal produces 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
  • 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))
  • Comparisoncompare/2, equal?/2, lt?/2, gt?/2, min/2, max/2
  • Predicateszero?/1, positive?/1, negative?/1, nan?/1, inf?/1, finite?/1
  • Roundinground/3 with all 7 rounding modes (:half_even, :half_up, :half_down, :down, :up, :floor, :ceiling)
  • Conversionto_string/2 with :normal, :scientific, :raw, :xsd formats; to_integer/1, to_float/1, normalize/1
  • Parsingnew/1, parse/1, cast/1 (soft). Accepts decimals, scientific notation (1.23e10), and special-value strings ("NaN", "Infinity", "-Inf").
  • Guardsis_decimal/1 macro for guard clauses
  • Compat shimFastDecimal.Compat mirrors Decimal's function signatures; drop-in via alias FastDecimal.Compat, as: Decimal
  • Ecto integrationFastDecimal.Ecto.Type implements Ecto.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)decimalFastDecimalspeedup
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.3 exactly, 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_rem identity (a == q·b + r), sqrt(x)² ≈ x, comparison antisymmetry/transitivity/reflexivity, NaN propagation, normalize idempotence.

Design choices documented

  • Exact arithmetic. add / sub / mult / sum / product never round.
  • Per-call precision (only div/3, sqrt/2, round/3 take 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/qNaN collapsed 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.