FixedPointDecimal — exact decimal arithmetic, 50x–1000x faster than Foundation.Decimal

FixedPointDecimal — high-performance fixed-point decimal arithmetic for Swift

A few weeks ago we open sourced FuzzyMatch, our fuzzy string matching library built with Claude Code. That experiment went well enough that we applied the same approach to another important problem: exact decimal arithmetic that's fast enough for latency-sensitive financial systems.

The problem

Financial systems (or any system that wants to keep user input of decimal numbers correct) need exact decimal arithmetic — Double can't represent 0.1 exactly, causing accumulation errors.

Foundation.Decimal solves exactness but carries inherent overhead: it's a 20-byte variable-precision type with multi-word mantissa arithmetic. We contributed a fix to swift-foundation that removed heap allocations from Decimal's critical path (merged, ~3-5x improvement, should land with Swift 6.4), but even with that fix the architectural gap remains — variable-precision arithmetic is fundamentally more work than operating on a single machine integer.

For both throughput and latency-sensitive code paths processing tens- or hundred- of thousands of prices per tick, that overhead matters.

What it is

FixedPointDecimal is an @frozen struct backed by Int64, storing values with exactly 8 fractional decimal digits (value × 10⁸). All arithmetic compiles to native integer instructions with zero heap allocations.

let price: FixedPointDecimal = 123.45
let quantity: FixedPointDecimal = 1000
let notional = price * quantity // 123450

Eight fractional digits cover all practical financial instruments: cents (2), mils (3), basis points (4), FX pips (5), and cryptocurrency satoshis (8). The range (±92 billion) is sufficient for individual prices and quantities.

We have tried to follow standard library conventions and Decimal conventions as far as possible to make it easy to drop this in as a replacement if desired.

Performance

On Apple Silicon (M4 Max), compared to Foundation.Decimal:

Operation FixedPointDecimal Foundation.Decimal Speedup
Addition 0.67 ns 240 ns 359x
Comparison 0.33 ns 300 ns 901x
Hash 5 ns 261 ns 48x
Multiplication 8 ns 607 ns 79x
Division 8 ns 1,285 ns 168x
init(Double) 1.4 ns 2,319 ns 1,622x
rounded(scale:) 2 ns 705 ns 349x
JSON encode 320 ns 1,215 ns 3.8x
JSON decode 457 ns 831 ns 1.8x

Zero heap allocations across all operations. At 8 bytes vs Decimal's 20, arrays of prices use 40% of the memory — relevant when working with large datasets with millions of entries - both in memory, on disk or over the network.

Design choices

  • Banker's rounding everywhere — all entry points (string parsing, Double conversion, Decimal conversion, arithmetic) use round-half-to-even. The same input always produces the same stored value regardless of construction path.

  • Safe by default — trapping arithmetic matching Swift Int. Wrapping (&+, &-, &*) and overflow-reporting variants available.

  • NaN as sentinelInt64.min as the NaN value, with trapping semantics. No optional wrapper overhead.

  • @frozen — enables cross-module inlining and optimal ContiguousArray layout.

Protocol conformances

Sendable, BitwiseCopyable, AtomicRepresentable, Equatable, Hashable, Comparable, Numeric, SignedNumeric, Strideable, Codable, LosslessStringConvertible, ExpressibleByIntegerLiteral, ExpressibleByFloatLiteral SwiftUI: VectorArithmetic (animations), Plottable (Charts), FormatStyle, ParseableFormatStyle with full Decimal.FormatStyle forwarding for locale-aware currency/number/percent formatting.

Quality

549 tests across 15 suites, including property-based parity testing against Foundation.Decimal (10k iterations), exhaustive float-literal precision verification (1.1M+ values), and a libFuzzer fuzz harness with AddressSanitizer.

Acknowledgments

Like FuzzyMatch, this was built with Claude Code Opus 4.6 with careful guidance. The approach works well for self-contained, heavily testable components: we steer architecture and review every change, Claude writes the code, tests, benchmarks, and documentation. The overall implementation achieved in just two days — 15 source files, 13 test suites, benchmark suite, fuzz harness, DocC documentation — would have taken considerably longer manually.

Getting started

GitHub

API Documentation

If you need exact decimal arithmetic within the supported range with excellent performance — give it a try.

Joakim

26 Likes

Love this. Consider an option for "signalling" (trapping) nans.

1 Like

That probably makes sense instead, I think we will push out a 1.0.2 with that right now - even if formally a new major...(so not an option, but really the better safer default behaviour)

1 Like

It is!

Thank you. Still, consider making it an option at some later point.. I do love nans, how they propagate, always wanted something similar for integers (e.g. by sacrificing one of the bit patters) and that's exactly what you did. In some cases this is better than trapping.

FWIW, unlike floats my preference would be for Fixed.nan == itself. Didn't check your code in these regards yeah, all good here:

    /// NaN compares equal to itself, using sentinel semantics (not IEEE 754).
    /// This is required for `Hashable` and `Comparable` protocol correctness
    /// (strict total order).

Hashable correctness requires nan == nan? Don't think so... Comparable correctness - yes.

1 Like

You are of course correct, will fix docs.

Comparable doesn't either, or Float wouldn't implement it. NaN does behave weirdly for both hash-based collections and comparisons, though.

2 Likes

Nananana Batman!

In such cases I would try to follow the platform rules, which means imitating Double as much as possible.

let nan = Double.nan

print(nan == nan)  // false
print(nan < nan)  // false
print(nan > nan)  // false

print(nan == 4.2)  // false
print(nan < 4.2)  // false
print(nan > 4.2)  // false

let dict = [nan: 1]
print(dict[nan]) // nil

All of this comes from IEEE-754, and I feel that everyone expects it to behave this way.

Do you actually need nan in this library? Which operations can produce it?

The reason why IEEE-754 has nan is because they ALWAYS have to produce something - they can't crash. In case of a serious error they return nan and raise the invalid operation flag (see section “7.2 Invalid operation” of the standard). They cannot return a (finite or infinite) number because the users do not check the return values or flags. Thus, they decided to create a special “not a number” value that propagates.

print(0.0 / 0.0)  // nan <-- Special error case
print(Double.infinity / Double.infinity)  // -nan <-- Special error case
print(0.0 / 5.0)  // 0.0 <-- ordinary number
print(5.0 / 0.0)  // inf <-- ordinary number

From what I see, your library always crashes when some invariant is not satisfied, so nan is not for arithmetic purposes.

If the main usage for nan is to represent Optional<Decimal> then is it really worth it? Optional is checked at the compile time - you can't access the value without a check. This way you will end up with “integer where the last 8 digits are fractions” semantic, which is ultra easy to explain/understand/use. This is not about the performance/storage, but about the correctness - at some point someone will forget the nan check and print it in the invoice.

I guess your particular use case is: a lot of decimals that have to be tightly packed, and some of them may be missing. If that's the case, then your approach is good.

Double and ExpressibleByFloatLiteral

extension FixedPointDecimal {
  init(_ value: Double)
  init?(exactly value: Double)
  var doubleValue: Double
}

Do you need those? From my experience, you either use a Decimal or a Double, and never mix them. Including conversions causes problems when users try to be too smart. For example, when a certain operation is not available on Decimal they try to:

  1. Convert to Double
  2. Perform the operation
  3. Convert back to Decimal

This will result in precision loss. Note that Foundation.Decimal interop is perfectly fine (or even required).

As you pointed out in the documentation ExpressibleByFloatLiteral is problematic:

let double = 12345678901.12345678
let fromDouble = FixedPointDecimal(double)
let fromLiteral: FixedPointDecimal = 12345678901.12345678
print(double)       // 12345678901.123457
print(fromDouble)   // 12345678901.123456
print(fromLiteral)  // 12345678901.123456

This may blow up in some unexpected place. On certain platforms you can use Float80 instead of a Double as a ExpressibleByFloatLiteral intermediate. This protocol is one of those Swift decisions that never made sense to me, and I gave up on understanding it a long time ago. It was discussed on this forum a few times, and as far as I know SR-920 is the issue for it.

Complaining about ExpressibleByFloatLiteral is pointless if you do not provide an alternative, and in this case it would be macros. This way you will be able to validate everything at the compile time. You can even check the number of fractional digits (<= 8 is good, >=9 is bad)! Similar thing already exists for Foundation.Decimal: github.com/aperkov/DecimalMacro. (This is the 1st result from duckduckgo, there are other libraries like this.)

In the future we will be able to use const for this, but this feature is still in the proposal stage. You would mark the FixedPointDecimal.init as const and the conversion would happen (mostly) at the compile time.

Anyway, the end result would be that you do not have a Double interop, and all of the initializers are ultra safe.

Tests

You do not implement the IEEE-754, but I bet you can use some parts of the test suites developed for the standard:

Licenses are very permissive. In total this a few million test cases, so I think it is worth it even if you end up using just a fraction of those. In a way Decimal is one of the most important types in the system - this is literally where the money is.

Minimum

One operation that is decently useful, and is still missing is FixedPointDecimal.minimum(x, y). This sounds trivial, but for example in IEEE-754 2008 there was a “gotcha” with nan propagation/associativity. Even the committee was surprised, so read their report. This particular case applies only if you have a separate snan and qnan, but this is the reason why min/max were redefined in IEEE-754 2019 .

In Swift:

print(Double.minimum(Double.nan, 5.0))           // 5.0 <- NaN was not propagated!
print(Double.minimum(Double.signalingNaN, 5.0))  // 5.0
print(Double.minimum(5.0, Double.nan))           // 5.0
print(Double.minimum(5.0, Double.signalingNaN))  // 5.0

I think in your case you can just crash on nan argument, to preserve the nan = Optional.none sematic. In general, you can treat your nan as a signalingNaN from IEEE-754, but instead of raising invalid operation (see “7.2 Invalid operation” section from the standard) just crash. The only exceptions would be format conversions in both directions: Double/Foundation.Decimal/String/Codable.

2 Likes

Technically, Equatable is what requires reflexivity, but I think we have an explicit carve out for NaN.

1 Like

Traps on preconditions, and the previous version of the code used non-signalling nan's – I suggest there's still an option for that mode of operation.


Speaking of Double.nan: that min(Double.nan, 1.0) and min(1.0, Double.nan) give different result is – as you mentioned – very surprising and goes against the very purpose of nan (which should propagate). IMHO this is worth fixing.

We have an IEEE-specified operation for that: Double.minimum(_:_:)

Good to know, although the name might be confusing: this matches IEEE's minNum semantics IRT nan propagating, while there's a newer nan propagating versions (named in IEEE as minimum / maximum) which preserve nan.

#include <stdio.h>
#include <math.h>

int main(void) {
    printf("fminimum(1.0, NaN)      = %f\n", fminimum(1.0, NAN)); // nan
    printf("fminimum(NaN, 1.0)      = %f\n", fminimum(NAN, 1.0)); // nan
    printf("fminimum_num(1.0, NaN)  = %f\n", fminimum_num(1.0, NAN)); // 1.0
    printf("fminimum_num(NaN, 1.0)  = %f\n", fminimum_num(NAN, 1.0)); // 1.0
}

Thanks everyone for the really thorough feedback -- quite a few changes came out of this.

NaN semantics

Hashable correctness requires nan == nan? Don't think so...

@jrose -- you're of course correct, fixed the docs. It's really a design choice for collection usability, not a protocol requirement. We've documented the rationale properly now: "signalling for computation, sentinel for observation" -- arithmetic traps (matching Swift Int), but comparison/hashing/encoding use sentinel semantics so NaN works predictably in Set, Dictionary, and sort().

Do you actually need nan in this library?

@LiarPrincess -- good question. For us, yes, it's critical. Our use case involves dense arrays with tens or even hundreds of millions of decimal entries (across a few arrays) where many values may be missing.

Optional<FixedPointDecimal> has stride 16 (the 1-byte tag rounds up due to 8-byte alignment), exactly 2x our 8-byte stride.

There's currently no Swift mechanism for custom types to declare extra inhabitants AFAIK -- @Joe_Groff had a 2017 proposal for Float/Double NaN extra inhabitants which wasn't implemented AFAICT, and even Optional<Double> is stride 16 today. Until that changes, the sentinel seems to be the only way to get 8-byte optional-like semantics with minimal footprint.

We've documented the whole trade-off in TypeDesign.md for anyone evaluating this.

minimum/maximum

Added FixedPointDecimal.minimum(_:_:) and .maximum(_:_:) per @LiarPrincess's suggestion -- trapping on NaN, matching the FloatingPoint naming convention. The stdlib min()/max() give asymmetric NaN behaviour due to sentinel ordering, so the explicit methods are quite useful.

Also fixed numberOfFractionalDigits to trap on NaN -- was inconsistently returning 0, unlike every other value-inspecting property.

Double / ExpressibleByFloatLiteral

This may blow up in some unexpected place.

The precision limitation is real and inherent to the protocol. That said, for typical financial use for constants (e.g. 0.1, 0.05, 0.00000001, 99999.95) it's exact -- all values with up to 8 fractional digits roundtrip correctly through Double, exhaustively verified for all 111M fractional parts.

The problem only appears when the total significant digit count approaches Double's ~15.9 limit (e.g. 12345678901.12345678 -- 19 significant digits). For those cases the string initializer is the right choice, and the docs say so. This is what people are used to doing for Decimal, the big difference is that for most reasonable real-world use cases the init from Double works here, unlike for Decimal where it doesn't always:

// Foundation.Decimal: literal goes through Double, preserves binary imprecision
let x: Decimal = 123.456789 // 123.45678900000002048
                                                                                                                                                              
// FixedPointDecimal: same Double intermediate, but round(123.456789 × 10⁸) = 12345678900 exactly                                                           
let y: FixedPointDecimal = 123.456789 // 123.456789                                                                                                         

Also, we originally had macro support internally (ie. #fd(123.456)) but removed it before open-sourcing -- in practice all constant assignment sites had fewer significant digits than the limit, so the macro never added much value, just syntactic noise (it is especially nice for inline expressions to have the double initialiser).

const functions in a future Swift could perhaps revisit this more cleanly.

External test suites

Thank you @LiarPrincess for the links to the established test suites -- we integrated all three with zero failures across all compatible vectors:

Suite Passed Operations
Speleotrove/Cowlishaw GDA (81K+ vectors) 2,492 add, subtract, multiply, divide, remainder, compare, abs, negate, min, max, plus
Fahmy/Cairo University 157 add, multiply, divide
Intel Decimal FP Math Library 37 add, subtract, abs, negate

Vectors are skipped when they fall outside our type's domain -- values exceeding our range, operands needing more than 8 fractional digits, infinity/NaN arithmetic (we trap), overflow conditions, or non-half_even rounding on multiply/divide (our arithmetic uses banker's rounding). Rounding-independent operations (add, subtract, compare, abs, negate, min, max) run with all rounding modes since the result is exact.

Each test prints a skip reason breakdown so it's easy to see exactly why vectors were excluded.

Non-signalling NaN mode* (tera)

Not planned for now, but the design doesn't preclude a generic parameter or compile-time flag if demand warrants it.

The fixes and new test suites are in Release 2.1.0 · ordo-one/FixedPoint · GitHub

Thanks again,

Joakim

3 Likes

Thank you for the link. Interestingly there they are talking about using negativenan for optionals, however even a single bit pattern like fffffffe could be reserved to represent none.