Just.PreciseMath
Extended-precision floating-point arithmetic for .NET using double-double
representations: a high/low pair of double values. The goal is to retain more
precision than a single double while using a fixed-size representation,
rather than arbitrary-precision arithmetic.
Work in progress. The public API is incomplete and may change. Numerical contracts are covered by regression tests, not an exhaustive accuracy certification. The library is not ready for production use.
DoubleDouble core
DoubleDouble stores a normalized high/low pair. Use new DoubleDouble(value)
for a single double, or DoubleDouble.FromComponents(high, low) for arbitrary
components. The factory normalizes finite sums and canonicalizes NaN/infinity
with a positive-zero low component. The two-component constructor is internal
and performs no normalization or validation; it is reserved for trusted,
already-normalized results. The constants PI, E, and LN2 include binary64
residuals checked against independently computed high-precision values.
- Arithmetic: unary
+/-, binary+,-,*,/, and both operand orders with adouble. Addition retains residuals under cancellation; multiplication uses fused multiply-add; division uses residual corrections. Mixeddoubleoperators use specialized scalar paths rather than promoting the scalar toDoubleDouble. Their finite fast paths normalize once with a final sum transform; scalar division uses one compensated quotient correction within the error contract below. - Exponent boundaries: bounded
BigIntegercalculations avoid intermediate overflow and underflow on the exceptional finite path. Ordinary arithmetic uses floating-point transforms without allocations. The stored value remains two doubles; this is not an arbitrary-precision API. - Comparisons use both components.
Equalstreats NaNs as equal and signed zeros as equal for collections.CompareToorders NaN before other values. Numerical equality and relational operators treat NaN as unordered, likedouble. - Signed zero is preserved by single-value construction and unary negation.
FromComponentswith a zero low input preserves the high zero's sign. Exact cancellation of nonzero values yields positive zero. Arithmetic special values follow binary64 rules.
Arithmetic is approximate double-double arithmetic, not a promise of correctly
rounded 106-bit results. The deterministic rational-oracle tests check a
conservative error bound of 2^-100 relative plus one minimum binary64 subnormal,
with exact component checks for selected representable cases. Near underflow,
extended precision necessarily decreases; overflow produces infinity. Performance
of the allocating exponent-boundary path is not covered by the basic benchmarks.
Conversions and formatting
- Explicit conversions support
double,float,int,long, anddecimalin both directions. Integer inputs are exact. Decimal inputs use their exact coefficient and scale to compute the high component and its residual. - Integer casts truncate the complete expansion toward zero and throw
OverflowExceptionfor nonfinite or out-of-range results.IConvertibleinteger conversions instead round to nearest, ties to even, with range checks. - Binary32 output rounds the complete expansion directly, including low-component
decisions at midpoints. Decimal output rounds to the greatest fitting scale up
to 28; nonfinite values and magnitudes above
decimal.MaxValuethrow. IConvertiblereportsTypeCode.Object, supports conversion to itself, and treats only numerical zero as false. Char, DateTime, and enum conversions are unsupported and throwInvalidCastException.ToStringformats the exact component sum, supports culture-sensitiveG/g,E/e, andF/f, and rounds ties to even. Precision is bounded to 0–999; other standard and custom formats throwFormatException. DefaultG32is not shortest-round-trip formatting. NaN, infinities, and signed zero are supported without converting through decimal.TryFormat(Span<char>, ...)implementsISpanFormattablewith the same formats. It currently allocates viaToString; insufficient space returnsfalse, writes zero characters, and leaves the destination unchanged.
DoubleDouble implements ISignedNumber<DoubleDouble>, including the inherited
INumberBase contracts: binary radix, classification, absolute value, magnitude
selection, increment/decrement, and generic numeric conversions. Integer/parity
tests and magnitude comparisons retain both components. Magnitude ties prefer
positive values for maximum and negative values for minimum, including signed zero;
the Number variants prefer a number over NaN.
CreateChecked, CreateSaturating, and CreateTruncating support built-in numeric
types and BigInteger. Floating overflow produces signed infinity in all modes.
Finite integer output truncates the exact sum, then throws on overflow, clamps,
or retains the low destination-width bits, respectively. Decimal nonchecked output
clamps out-of-range values and maps NaN to zero. These policies are distinct from
the existing casts and IConvertible conversions above.
Conversions, parsing, and formatting use allocating BigInteger intermediates
where needed to preserve precision; no additional dependency is required.
Parsing
Parse and TryParse accept strings and ReadOnlySpan<char> and implement
IParsable<DoubleDouble> / ISpanParsable<DoubleDouble>. This initial parser
preserves high/low precision rather than parsing through double or decimal.
It converts an exact decimal coefficient/exponent into rounded high and residual
components, then normalizes the pair. It does not promise universally correctly
rounded 106-bit results or a general ToString round trip.
using System.Globalization;
using Just.PreciseMath;
DoubleDouble value = DoubleDouble.Parse("9007199254740993", CultureInfo.InvariantCulture);
// value.High == 9007199254740992.0; value.Low == 1.0
bool success = DoubleDouble.TryParse("1.25e-2".AsSpan(), CultureInfo.InvariantCulture,
out DoubleDouble parsed);
- Provider-only finite grammar: optional sign, ASCII decimal digits with an optional decimal
separator, and optional
e/Eexponent with sign and digits. At least one mantissa digit is required;.5and1.are accepted with invariant culture. Surrounding whitespace is allowed; internal whitespace is not. - Signs and the decimal separator come from the supplied culture; a null or
omitted provider uses the current culture. Culture-specific NaN and infinity
symbols are recognized case-insensitively. The additional alias
infaccepts an optional culture-specific sign (inf,+inf,-infwith invariant culture). Exact custom special symbols take precedence over the alias. Special values accept surrounding whitespace and signs even withNumberStyles.None; ordinary finite numbers still obey the supplied style flags. Signed zero is preserved. - Provider-only overloads reject grouping, currency, and parentheses. Explicit
NumberStylesoverloads support decimal flags throughNumberStyles.Any, including grouping, currency, parentheses, and trailing signs; group sizes are not validated. Hexadecimal, binary, and undefined style flags throwArgumentException, including inTryParse. Hexadecimal notation and programming-language digit separators remain unsupported. - Input is limited to 2048 characters, including surrounding whitespace. Huge exponents are bounded before constructing powers of ten. Well-formed overflow succeeds with signed infinity; underflow rounds to a subnormal or signed zero. A second rounding just below the overflow midpoint stays finite.
ParsethrowsArgumentNullExceptionfor a null string andFormatExceptionfor invalid, unsupported, or oversized input.TryParsereturnsfalseand positiveZerofor those inputs.
Deferred scope
The planned PreciseMath static class and its Abs, Sqrt, Pow, Exp, and
Log functions are not implemented. Generic-math interfaces beyond ISignedNumber,
additional text formats/general round-trip formatting, and broader performance
benchmarks remain deferred.
Replacing allocating arithmetic boundary fallbacks is also deferred; the current
BigInteger paths remain in place. That optimization does not require removing
BigInteger from conversions, parsing, formatting, or independent test oracles.
Build and test
Requires the .NET 10 SDK in the 10.0.1xx feature band, as selected by global.json.
Run from the repository root:
dotnet restore Just.PreciseMath.slnx --locked-mode
dotnet build Just.PreciseMath.slnx -c Release --no-restore
dotnet test --solution Just.PreciseMath.slnx -c Release --no-build --minimum-expected-tests 1
dotnet format Just.PreciseMath.slnx --verify-no-changes --no-restore
Test results and coverage reports are available in the test-results artifact
on CI workflow runs.
Benchmarks
The BenchmarkDotNet suite compares same-type +, -, *, and / operations for
DoubleDouble, decimal, and double: 12 methods with two input cases each.
Each operation/input group uses double as its baseline and reports allocations.
Operands are stored in fields and results are returned to the harness; conversions
and construction happen in setup, outside the timed methods.
The left inputs are 1.25 (binary-exact) and 1.1 (a nonzero low component in
DoubleDouble); the right input is 0.75. Decimal and double-double inputs originate
from the same decimal values, while double rounds to binary64. These types have
different precision and range contracts: this is a cost comparison, not an accuracy
test. Exceptional values, exponent-boundary fallbacks, mixed-type operators,
conversions, parsing, and formatting are not benchmarked yet.
After the Release build above, run from the repository root:
# Discover benchmark methods without running them.
dotnet run --project 2-benchmarks/Just.PreciseMath.Benchmarks -c Release --no-build -- --list flat
# Quick execution check (also run in CI); not useful for timing comparisons.
dotnet run --project 2-benchmarks/Just.PreciseMath.Benchmarks -c Release --no-build -- --job Dry --filter '*'
# Full measurement run; optionally select an operation with --anyCategories Addition.
dotnet run --project 2-benchmarks/Just.PreciseMath.Benchmarks -c Release --no-build -- --filter '*'
Reports are written under the ignored BenchmarkDotNet.Artifacts/ directory.
Empty selections and failed benchmark runs return a nonzero exit code. CI only
checks execution, with no performance gate. Use full runs on an idle, controlled
machine for comparisons; a scalar double operation may approach harness overhead,
so inspect BenchmarkDotNet warnings before interpreting ratios. Do not compare
coverage-instrumented runs or treat Dry-job timings as performance measurements.
Project structure
0-source/Just.PreciseMath/: library implementation.1-tests/Just.PreciseMath.Tests/: unit tests.2-benchmarks/Just.PreciseMath.Benchmarks/: arithmetic benchmarks againstdecimalanddouble.
Contributing
Follow .editorconfig and include regression tests with numerical changes.
Explain the algorithm's assumptions, the source of reference values, and any
error tolerances. Include updated packages.lock.json files with dependency changes.
License
Licensed under the MIT License.