Add Decimal32, Decimal64, Decimal128#100729
Conversation
|
Note regarding the |
|
This is on my radar to take a look at; just noting it might be a bit delayed due to other priorities for the .NET 9 release. CC. @jeffhandley |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 9 comments.
Comments suppressed due to low confidence (2)
src/libraries/System.Runtime/ref/System.Runtime.cs:1
- The reference assembly declarations for Decimal32/Decimal64/Decimal128 appear to be missing public members that exist in the implementations (notably
public override bool Equals(object? obj)andpublic override int GetHashCode()). Ref/impl mismatches will break API surface validation and can cause binding inconsistencies. Add these overrides (and any other public members present in the CoreLib implementations) to the ref file so the public contract matches.
src/libraries/System.Runtime/ref/System.Runtime.cs:1 - The Decimal32 implementation is annotated with
[StructLayout(LayoutKind.Sequential)], but the ref assembly declaration does not include this attribute. Since layout is part of the public interop contract, the ref should mirror it (or the implementation should drop it if not required).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
src/libraries/System.Runtime/ref/System.Runtime.cs:1
- These new numeric types expose
ToString(string?, IFormatProvider?)overloads but do not implementIFormattable. As a result, composite formatting / interpolated string format specifiers (e.g.,string.Format(\"{0:N}\", value)or$\"{value:N}\") may ignore the format string and fall back to parameterlessToString(). Consider addingSystem.IFormattabletoDecimal32/Decimal64/Decimal128(and updating the CoreLib implementations accordingly); optionally also implementISpanFormattableto match other numerics.
|
Hi @tannergooding I have resolved all Copilot comments. You can re-review it when you have time ;) |
| private const uint NaNMask = 0x7C00_0000; | ||
| private const uint InfinityMask = 0x7800_0000; | ||
| private const uint MaxSignificand = 9_999_999; | ||
| private const uint MaxInternalValue = 0x77F8_967F; // 9,999,999 x 10^90 |
There was a problem hiding this comment.
The comment here is potentially confusing as it mismatches the standard value documented, it'd help to have both specified.
In particular you have significand * 10^(exponent-bias), which allows treating significand as a positive integer. But more typically its represented in standard scientific notation and so you get 9.999999 instead of 9999999, this shifts the bias by p-1, where p is 7/16/34 for decimal32/64/128
In this case, that changes it from 9_999_999 * 10^90 to 9.999_999 * 10^96.
I'd recommend just doc'ing this as something like // +9.999_999 * 10^96; aka +9_999_999 * 10^90
| public Decimal32(int significand, int exponent) | ||
| { | ||
| bool isNegative = significand < 0; | ||
| uint unsignedSignificand = isNegative ? (uint)(-(long)significand) : (uint)significand; |
There was a problem hiding this comment.
nit: This can just be (uint)(-significand). Every negative int except int.MinValue becomes the appropriate positive int when negated. MinValue stays the same, but then when cast to uint becomes the right positive value anyways.
| private static readonly UInt128 PositiveInfinityValue = new UInt128(upper: 0x7800_0000_0000_0000, lower: 0); | ||
| private static readonly UInt128 NegativeInfinityValue = new UInt128(upper: 0xf800_0000_0000_0000, lower: 0); | ||
| private static readonly UInt128 ZeroValue = new UInt128(0, 0); | ||
| private static readonly UInt128 NegativeZeroValue = new UInt128(0x8000_0000_0000_0000, 0); | ||
| private static readonly UInt128 QuietNaNValue = new UInt128(0xFC00_0000_0000_0000, 0); | ||
| private static readonly UInt128 MaxInternalValue = new UInt128(upper: 0x5FFF_ED09_BEAD_87C0, lower: 0x378D_8E63_FFFF_FFFF); | ||
| private static readonly UInt128 MinInternalValue = new UInt128(upper: 0xDFFF_ED09_BEAD_87C0, lower: 0x378D_8E63_FFFF_FFFF); |
There was a problem hiding this comment.
This had been commented on before and I think got handled, but looks like it was since lost.
The UInt128 cases need to be properties, not static readonly, it will help improve codegen and make it easier for the JIT to see its just constant
There was a problem hiding this comment.
It may even be better to just have the internal Decimal128 constructor take ulong upperBits, ulong lowerBits instead of UInt128 and then you can just have:
public static Decimal128 PositiveInfinity => new Decimal128(0x7800_0000_0000_0000, 0x0000_0000_0000_0000);| 1000000, | ||
| ]; | ||
|
|
||
| public Decimal32(int significand, int exponent) |
There was a problem hiding this comment.
This still has the pending comment that it shouldn't be public as I'm not convinced its the right shape and UX. Please make it and the corresponding constructor on the other types internal
| uint comb = decimalBits & NaNMask; | ||
| return comb != NaNMask && comb != InfinityMask; |
There was a problem hiding this comment.
this can do (value & signMask) > Infinity to avoid multiple comparisons or extracting small parts
| return ClampExponentOverflow(signed, significand, exponent); | ||
| } | ||
|
|
||
| if (exponent < TDecimal.MinAdjustedExponent) |
There was a problem hiding this comment.
I don't think this is correct. For underflow for decimal 32, for example, it's ±1.0 ×10^−101 for subnormals, in conrast to ±1.000000 * 10^−95 for normals (noting this is the x.yyyyy form where its got exactly 1 integral digit)
There was a problem hiding this comment.
In particular while while MinAdjustedExponent is correct given TSelf.MinExponent - TSelf.Precision + 1; which is -95 - 7 + 1, so -102 + 1, or `-101, the issue is that you have to account for midpoint rounding
This is simple to explain with float where float.MaxValue + 50 doesn't become infinity, it rather stays float.MaxValue. You have to add at least half the delta to move it "closer" to infinity and make it round over.
Likewise float.Epsilon / float.BitDecrement(2) stays float.Epsilon, it's only for float.Epislon / 2 or greater that it becomes zero.
So I similarly believe that ±0.500000.......1 * 10^−96 becomes epsilon. -- This is notably a tricky edge case for binary floats too and is why we have to track up to 768 digits for double there and have to parse the whole string at least tracking "any non-zero digits", because we have to handle the case where its exactly a midpoint +/- 1
There was a problem hiding this comment.
Hi @tannergooding Correct me if I am wrong. Epsilon is the smallest positive value that is greater than zero. In this Decimal32 case I think it should be 1 * 10^-101.
Therefore "So I similarly believe that ±0.500000.......1 * 10^−96 becomes epsilon" I don't think it will become epsilon.
There was a problem hiding this comment.
Epsilon is the smallest positive value that is greater than zero.
Close. It is rather the smallest representable value that compares greater than zero. The representable here is key because values smaller than it may still round to it.
This can be seen with this trivial program, which showcases that float.Epsilon / 2 produces 0, but float.Epsilon / LargestValueThatComparesLessThanTwo still produces float.Epsilon (despite it clearly having an infinitely precise value that must be less than float.Epsilon):
Console.WriteLine($"{float.Epsilon:G99}");
Console.WriteLine($"{float.Epsilon / float.BitDecrement(2.0f):G99}");
Console.WriteLine($"{float.Epsilon / 2.0f:G99}");which outputs:
1.40129846432481707092372958328991613128026194187651577175706828388979108268586060148663818836212158E-45
1.40129846432481707092372958328991613128026194187651577175706828388979108268586060148663818836212158E-45
0
You can also see the same thing in the inverse direction as well:
var delta = float.MaxValue - float.BitDecrement(float.MaxValue);
Console.WriteLine($"{float.MaxValue:G99}");
Console.WriteLine($"{float.MaxValue + (delta / 2):G99}");
Console.WriteLine($"{float.MaxValue + (delta / float.BitIncrement(2)):G99}");which outputs:
340282346638528859811704183484516925440
∞
340282346638528859811704183484516925440This is because MaxValue + (delta / 2) puts it at exactly the midpoint value, so it rounds "up" to infinity. While the value just below that puts it below the midpoint, so despite being greater than MaxValue it rounds back down to MaxValue
There was a problem hiding this comment.
decimal32/64/128 must handle these same edges correctly and so we need to account for the fact that any value > (Epsilon / 2) becomes Epsilon
There was a problem hiding this comment.
Hi @tannergooding, thanks for the detailed explanation.
1 * 10^-101 is representable in Decimal32. Would it be correct to conclude that this should be Epsilon? Its encoded value is:
0_0000_0000_000_0000_0000_0000_0000_0001
For your example, ±0.500000.......1 * 10^-96, I don't think it would round to ±Epsilon if Epsilon means 1 * 10^-101, since this value is much larger than 1 * 10^-101 and should still be representable as a Decimal32 subnormal value.
There was a problem hiding this comment.
Would it be correct to conclude that this should be Epsilon
Yes
For your example, ±0.500000.......1 * 10^-96, I don't think it would round to ±Epsilon
Correct, I was commenting in part from memory and used the small normal value by accident
It would be 5.00000.....1 * 10^-102 as the actual boundary, where values less than this become 0 and values greater than or equal to this, but less than Epsilon, round to Epsilon
Resolve #81376