Apparently writing 6k+ word novels is not allowed on this forum, so I had to split this reply into 4 separate posts. You may want to read it on GitHub.
github.com/LiarPrincess/Oh-my-decimal is about 95% of what you need. Almost any design of DecimalFloatingPoint
should be a subset of what is implemented in this repository. Not tested on Apple silicon.
Vocabulary (oh-my-decimal
uses the same names in code):
- standard - IEEE 754 2008
- standard 2019 - IEEE 754 2019
- signed exponent - human readable exponent, for example in
1E+3 = 1000
the exponent is+3
. Inoh-my-decimal
it is represented asInt
type. - biased exponent - encoded exponent stored in decimal. In
oh-my-decimal
it is represented asBID
type. DecimalFloatingPointRoundingRule
- similar toFloatingPointRoundingRule
, but withoutawayFromZero
- not required by IEEE 754, not enough test cases to guarantee correctness.DecimalStatus
- IEEE 754 flags:isInvalidOperation
,isDivisionByZero
,isOverflow
,isUnderflow
, and (most importantly)isInexact
. Most of the methods have it as a last argument:status: inout DecimalStatus
.
How this post works:
- each section starts with
oh-my-decimal
code followed by discussion. All of the examples can be run usingoh-my-decimal
. - I will mix discussion about the protocol with my remarks to @mgriebling code from github.com/mgriebling/DecimalNumbers. It may get a little messy, but is it still a discussion about the implementation, so it is not off-topic. Note that their repository does not have a LICENSE file, so if they complain I will have to remove those parts.
- I am used to C# System.Decimal, so you may see some influences.
- oh-my-decimal contains
DecimalStatus
(container for IEEE flags). Judging by the design of theFloatingPoint
protocol Swift will not need this. This changes A LOT.
Protocols
FloatingPoint
You can, but you do not get a lot from it.
We all know what the final Decimal
type in Swift will conform to FloatingPoint
, so the discussion here is a little bit pointless, but:
There are not a lot of functions that could work on Decimal/BinaryFloatingPoint
(or Int
if we talk about AdditiveArithmetic/Numeric
) and be correct on all of them. Writing code that works on just Decimal
is difficult, and as soon as you introduce multiplication (for example price * quantity
) you almost always want to specialize to Decimal
. Decimals
have their own homogenous arithmetic, so you tend to use it exclusively instead of being generic.
In fact, in 99.9% of the cases you use only 1 Decimal
type (most probably Decimal128
), and specialize everything to it. You don't even use DecimalFloatingPoint
, this protocol is more of a "decimal semantic" marker and a place where we declare common implementation (overloads with default rounding
etc.). In binary floating point different types have different uses and tradeoffs: storage, speed, are we on CPU or GPU? For decimal you use 1 type for everything and never change it.
Not conforming to FloatingPoint
would also allow you to support DecimalStatus
.
As far as I know Foundation.Decimal does not conform to FloatingPoint
. I'm not sure how many people complained about this fact.
I am not saying that the Decimal
should not conform to FloatingPoint
protocol. It can, but it is not nearly that useful.
AdditiveArithmetic, Numeric
Same story as FloatingPoint
: you can, but you do not get a lot from it. You can be generic over DecimalFloatingPoint
. But being generic over AdditiveArithmetic
while using Decimal
is a little bit weird.
ExpressibleByFloatLiteral
Swift converts to Float80/Double
and then converts to a number.
This conversion may not be exact, so it is basically a random number generator.
If this gets fixed then: yes.
I see people proposing a new protocol
for this, but can't we just use ExpressibleByFloatLiteral
? We would need to add a new type to associatedtype FloatLiteralType: _ExpressibleByBuiltinFloatLiteral
. This should be purely additive, because Float/Double/Float80
would use Float/Double/Float80
(same as currently - no changes).
The design of the new _ExpressibleByBuiltinFloatLiteral
type is out of scope here, but you need to support compiler errors for isInexact
and hex character sequences for (quote from standard):
5.4.3 Conversion operations for binary formats
Implementations shall provide the following formatOf conversion operations to and from all supported binary floating-point formats; these operations never propagate non-canonical floating-point results.
―formatOf-convertFromHexCharacter(hexCharacterSequence)
See 5.12 for details.
―hexCharacterSequence convertToHexCharacter(source, conversionSpecification)
See 5.12 for details. The conversionSpecification specifies the precision and formatting of the hexCharacterSequence result.
Or maybe that's not possible. I have not looked at this. Anyway, as long as the future ExpressibleByFloatLiteral
can express Decimal
then this discussion is completely orthogonal to the issue raised in this thread.
ExpressibleByIntegerLiteral
Oh-my-decimal has it.
Required by Numeric
protocol, and as we know FloatingPoint
requires Numeric
.
Codable
Yes!
But how? We need to preserve sign/exponent/significant/cohort/payload/signaling bit etc. What do we do with non-canonical values?
Oh-my-decimal uses binary encoding (BID
). We need to remember that receiver may not support parsing UInt128
- most of the languages stop at UInt64
, the worst case would be if they tried to parse it as Double
(ekhm… JavaScript). If we store each value as String
then it should not be a problem -> they will fix it in post-processing. Why BID
not DPD
? It was easier for me.
Decimal | Encoded positive | Encoded negative |
---|---|---|
nan(0x123) | "2080375075" | "4227858723" |
snan(0x123) | "2113929507" | "4261413155" |
inf | "2013265920" | "4160749568" |
0E-101 | "0" | "2147483648" |
0E+0 | "847249408" | "2994733056" |
0E+90 | "1602224128" | "3749707776" |
1E-101 | "1" | "2147483649" |
1000000E-101 | "1000000" | "2148483648" |
9999999E+90 | "2012780159" | "4160263807" |
We could also use unambiguous String
representation (described below in "Decimal -> String" section), but:
- not canonical values will be encoded as
0
- this matches the "shall not propagate non-canonical results" from the standard. - parsing
Decimal
is slower thanInt
- but probably fast enough that we can get away with it. - different systems have different NaN payload encodings - not sure how much we care about them.
print(+Decimal64(nan: 0x123, signaling: false)) // nan(0x123)
print(-Decimal64(nan: 0x123, signaling: true)) // -snan(0x123)
print(+Decimal64.infinity) // inf
print(Decimal64.zero) // 0E+0
print(Decimal64.greatestFiniteMagnitude) // 9999999999999999E+369
print(Decimal64("123")!) // 123E+0
print(Decimal64("-123")!) // -123E+0
print(Decimal64("123E2")!) // 123E+2
print(Decimal64("12300E0")!) // 12300E+0, same value as above, different cohort
I'm not sure what is the correct answer for Swift. String
representation is not a bad choice, even with all of its drawbacks.
@mgriebling I think you are missing Codable
.
Sendable
Yes!
Fortunately this is not a problem, as long as UInt128
is also Sendable
.
Note that using Decimals
in Tasks/threads
becomes more difficult if we store our rounding
as a static
property.
Though I would take static
property over not having access to rounding
at all - there is a quite popular Money
type on github that has hard-coded nearestEven
. THIS IS NOT LEGAL FOR EURO AND PLN (PLN = currency in Poland, obviously rounding mode is not connected to currency). I will not link to this repository.
Oh-my-decimal takes rounding
as a method argument. This way we can just throw as many cores as we have at the problem. Hossam A. H. Fahmy test suite and oh-my-decimal-tests already take 10 min, and I don't even want to know how long they would take in serial execution.
You could also introduce context
which stores the rounding method. Then every operation is done by context. Python
allows you to do this. This is how oh-my-decimal-tests are written:
result = ctx_python.quantize(d, precision)
Another way is to store the rounding
in thread local storage, but I would not recommend this. It makes for an awful programmer/user experience, and it breaks spectacularly with green threads.
I would say that having rounding
as an argument is the safest choice. But this is how oh-my-decimal works, so I am biased. Official Swift Decimal
may have some different needs. Or maybe we do not need rounding
at all? Just quantize(to:rounding:status:)
and round(_:status:)
, and that's it. I have seen such implementations.
@mgriebling I think you are missing Sendable
.
Strideable
Oh-my-decimal does not conform to this protocol.
What is the distance between greatestFiniteMagnitude
and leastNormalMagnitude
? You can make it work, but I'm not sure if there is a clear use-case for this. I can't think of any.
Double
has this, so we will probably also need it.
Random
Kind of weird for floating point. Apart from a few specific input ranges it would not do what user wants:
- simple random between 0 and 10 would be skewed towards smaller numbers because more of them are representable - tons of possible negative exponents.
- if we generated truly random (infinitely precise) value and rounded then bigger numbers would be more common - they have bigger ulp.
Oh-my-decimal does not have it.
Double
does, and so should future Decimal
type.
Properties
Static properties
static var nan: Self { get }
static var signalingNaN: Self { get }
static var infinity: Self { get }
static var pi: Self { get }
static var zero: Self { get }
static var leastNonzeroMagnitude: Self { get }
static var leastNormalMagnitude: Self { get }
static var greatestFiniteMagnitude: Self { get }
// Default on protocol:
static var radix: Int { 10 }
0
- which cohort/sign/exponent? In Oh-my-decimal it is0E+0
.leastNormalMagnitude
- which cohort? In Oh-my-decimal:Decimal64.leastNormalMagnitude = 1000000000000000E-398
- all 'precision' digits filled = lowest exponent.greatestFiniteMagnitude
- interestingly this one has to usepack
. You can't just useSelf.maxDecimalDigits
andSelf.maxExponent
becauseDecimal128
does not need the11
in combination bits (though this is an implementation detail).pi
should be rounded.towardZero
.
For nerds we can have:
static var is754version1985: Bool { return false }
static var is754version2008: Bool { return true }
Oh-my-decimal defines this on DecimalFloatingPoint
protocol - we know that all of our implementations share the same values. Swift should do it on each Decimal
type separately (or just skip it). We can do this because Decimal
is implemented in software. Idk if Swift guarantees that binary floating point operations conform to a particular version of the standard.
As for the @mgriebling implementation:
// mgriebling code
// https://github.com/mgriebling/DecimalNumbers/tree/main
public struct Decimal32 : Codable, Hashable {
public static var greatestFiniteMagnitude: Self {
Self(bid:ID(expBitPattern:ID.maxEncodedExponent, sigBitPattern:ID.largestNumber))
}
public static var leastNormalMagnitude: Self {
Self(bid:ID(expBitPattern:ID.minEncodedExponent, sigBitPattern:ID.largestNumber))
}
public static var leastNonzeroMagnitude: Self {
Self(bid: ID(expBitPattern: ID.minEncodedExponent, sigBitPattern: 1))
}
public static var pi: Self {
Self(bid: ID(expBitPattern: ID.exponentBias-ID.maximumDigits+1, sigBitPattern: 3_141593))
}
}
-
leastNonzeroMagnitude
is basically1
, so you don't have topack
:- sign = 0 -> positive
- exponent = 0 ->
0 - Self.exponentBias
- significand = 1
-
leastNormalMagnitude
is equalb^Emin = 10^Emin
, try this:func test_xxx() { let lnm = Decimal32.leastNormalMagnitude let down = lnm.nextDown print(lnm, lnm.isNormal) // 9.999999e-95 true print(down, down.isNormal) // 9.999998e-95 true }
We were
leastNormalMagnitude
and we went toward0
-> the 2nd line should befalse
. You can use binary search to find the correctleastNormalMagnitude
(it will converge quite fast) and then just encode it directly. -
pi
- you can use this as a unit test (rounding has to be:.towardZero
!):let string = "3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117067982148086513282306647" let expected = T(string, rounding: .towardZero, status: &status) XCTAssertEqual(…)
Properties
var isZero: Bool { get }
var isFinite: Bool { get }
var isInfinite: Bool { get }
var isCanonical: Bool { get }
var isNormal: Bool { get }
var isSubnormal: Bool { get }
var isNaN: Bool { get }
var isSignalingNaN: Bool { get }
var sign: FloatingPointSign { get }
var magnitude: Self { get }
// Default on protocol:
var floatingPointClass: FloatingPointClassification
magnitude
- if the input is non-canonical then the result should be canonical or not? Oh-my-decimal returns non-canonical (it just clears the bit).
Binary/decimal significand encoding
associatedtype BitPattern: UnsignedInteger & FixedWidthInteger
/// IEEE-754: 5.5.2 Decimal re-encoding operations.
/// Binary integer decimal.
var binaryEncoding: BitPattern { get }
/// IEEE-754: 5.5.2 Decimal re-encoding operations.
/// Densely packed decimal.
var decimalEncoding: BitPattern { get }
init(binaryEncoding: BitPattern)
init(decimalEncoding: BitPattern)
On Double
we have:
let d = Double(bitPattern: UInt64)
let bitPattern: UInt64 = d.bitPattern
Oh-my-decimal uses the names defined by the standard. Though those names are only for the significand part, not the whole decimal bit pattern (emphasis mine):
3.5 Decimal interchange format encodings
c) For finite numbers, r is…
- If the implementation uses the decimal encoding for the significand…
- Alternatively, if the implementation uses the binary encoding for the significand, then…
(And also later in '5.5.2 Decimal re-encoding operations'.)
In Swift convention it would be more like: binaryEncodedBitPattern
with the documentation pointing that the binary
part is only for the significand
.
In init
oh-my-decimal will accept non-canonical values. They will be "corrected" on read, for example add
function will read non-canonical as 0
.
The use-cases for oh-my-decimal are:
- database does not support the given decimal format ->
binaryEncoding
->binary(16)
- function is not available on oh-my-decimal ->
binaryEncoding
-> Intel lib ->init(binaryEncoding:)
If we want to mirror BinaryFloatingPoint
protocol:
protocol BinaryFloatingPoint {
associatedtype RawSignificand: UnsignedInteger
associatedtype RawExponent: UnsignedInteger
var exponentBitPattern: RawExponent { get }
var significandBitPattern: RawSignificand { get }
init(sign: FloatingPointSign,
exponentBitPattern: RawExponent,
significandBitPattern: RawSignificand)
}
exponentBitPattern
is always the same.significandBitPattern
depends on binary/decimal encoding.
I will let the others do the name bike-shedding (maybe binaryEncodedSignificandBitPattern
would be better, but it is worse for code completion):
associatedtype RawSignificand: UnsignedInteger
associatedtype RawExponent: UnsignedInteger
var exponentBitPattern: RawExponent { get }
/// IEEE-754: 5.5.2 Decimal re-encoding operations.
/// Binary integer decimal.
var significandBinaryEncodedBitPattern: RawSignificand { get }
/// IEEE-754: 5.5.2 Decimal re-encoding operations.
/// Densely packed decimal.
var significandDecimalEncodedBitPattern: RawSignificand { get }
init(sign: FloatingPointSign,
exponentBitPattern: RawExponent,
significandBinaryBitPattern: RawSignificand)
init(sign: FloatingPointSign,
exponentBitPattern: RawExponent,
significandDecimalBitPattern: RawSignificand)
Oh-my-decimal is on the @taylorswift side of the whole binaryEncoding
debate. Though I was thinking about SQL (because that is what I'm used to when dealing with decimals), not databases with BSON backed.
From my experience 95+% of business operations contain either database read or write:
-
if your database supports IEEE 754 (@taylorswift asked about BSON which supports Decimal128 using BID):
// Read // oh-my-decimal naming let raw: UInt128 = row.column[2] let decimal = Decimal128(binaryEncoding: raw) // Write var row: DatabaseRow = … row.column[2] = decimal.binaryEncoding
You don't have to do anything! The decimal package already supports the conversion in both directions. You can't beat 0 lines of code, and we know that more lines of code = more bugs.
-
if you database does not support IEEE 754:
- use the decimal type supported by your database. For example MSSQL + C# = System.Decimal. This has nothing to do with the
Decimal
type that we are discussing, because here you are forced to use the decimal type defined by your database driver. - store
Decimal
usingbinary
column type -> goes back to the "your database supports IEEE 754" point.
- use the decimal type supported by your database. For example MSSQL + C# = System.Decimal. This has nothing to do with the
Btw. if we are worrying about the next Float80
situation then this field does not need to be on the protocol
, it can be on the type, remember that most of the time you use only 1 Decimal
type in the whole app. We do not have UInt80
(for Float80
), but we have UInt32
, UInt64
and UInt128
. For the Decimal128
in the beginning it would be UInt128
from the decimal package - maybe under some different name (Decimal128.BitPattern
?) and without all of the Int
operations (you do not need zeroBitCount
etc.). At some point before the final release it would switch to UInt128
from stdlib (if we ever get one).
This is exactly what Double
does - in addition to exponentBitPattern
and significandBitPattern
required by BinaryFloatingPoint
we have:
let d = Double(bitPattern: UInt64)
let bitPattern: UInt64 = d.bitPattern
// For Float80 it is not available:
Float80.nan.bitPattern (trigger code completion)
-> exponentBitPattern
-> significandBitPattern
Obviously if you want then you can add associatedtype BitPattern: UnsignedInteger
(with/without FixedWidthInteger
) to DecimalFloatingPoint
protocol, just like oh-my-decimal. Though @scanon already addressed this:
It does not, because FixedWidthInteger has all sorts of protocol requirements beyond being a bag of bits that can be used to build a decimal. There's no need to be able to divide bit patterns of decimal numbers, or count their leading zeros.
Are those things easier to implement than decimal arithmetic? Yes. Does requiring that they be implemented impose some additional burden on the author of a decimal type? Also yes.
I'm 100% against:
if the sign, raw exponent, and raw significand are each encodable and decodable (as they would be since they'd be of primitive types as per your link), then so too would be any type that's an aggregate of those three properties. In the simplest implementation, then, you would encode and decode a floating-point value in that manner.
(…)
This would also have the desirable (or, even essential) effect of allowing you to store bit patterns in any IEEE interchange format independent of what types are supported by the platform.
Storing Decimal
as a separate sign, exponent, and significand is not space efficient. Also: why?
Combining the "sign, raw exponent, and raw significand" into a 128-bit pattern (most space efficient encoding) is way too complicated to put into the user-land. You need to know the right shifts, handle non canonical values, NaN payloads. On top of that the significand becomes extremely messy if you do not have UInt128
.
I don't fully understand the 2nd paragraph. An example would be very helpful, it is very vague to the point that it says nothing.
-
are we talking about the next
Float80
situation? The whole#if (arch(i386) || arch(x86_64)) && !os(Windows) && !os(Android)
? Is there any practical example forDecimal
? -
are we talking about situation similar to what @taylorswift described? I believe that this is a quite common scenario -> 2 people (me + @taylorswift) had the same problem and we came to the same conclusion. In this case you can just convert using
init
:// We have value in Decimal64 let d64: Decimal64 = … // Our platform/database only supports Decimal128 let d128 = Decimal128(d64) // Always exact because 128 > 64 let bitPattern = d128.binaryEncoding // Or some other name
If you want to manually change the decimal format then you have to re-bias the exponent, which is non-trivial because of overflows. Don't even think about writing converter to a smaller format outside of the
Decimal
package - way too complicated.
Double
already has bitPattern
property, so there is a precedence. It is just a matter of having UInt128
. I would argue that there is a use case for having an access to the whole bitPattern
of a Decimal
type. It does not have to be on the DecimalFloatingPoint
protocol, can be on type.