[.NET 11] Decimal128.Acos and Decimal128.Asin lose substantial accuracy near ±1
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
### Description
`System.Numerics.Decimal128.Acos` and `Decimal128.Asin` lose substantial numerical accuracy for exactly representable decimal inputs close to ±1.
For `x = 1 - 1e-30`, `Decimal128.Acos(x)` has a relative error of approximately `1.59e-9`, corresponding to only about nine significant decimal digits of accuracy. `Asin` at either sign and `Acos(-x)` have smaller relative errors, but still differ from high-precision references by approximately 2.24 billion result ulps.
The input is constructed entirely with `Decimal128` arithmetic, without conversion through `double` or `System.Decimal`. The reproduction calls the public .NET APIs directly and has no KatLang or NuGet package dependency.
### Reproduction Steps
Install SDK `11.0.100-preview.7.26381.103` with runtime `11.0.0-preview.7.26381.103`.
**Decimal128InverseTrigRepro.csproj**
```xml
Exe
net11.0
11.0.0-preview.7.26381.103
Disable
```
**Program.cs**
```csharp
using System;
using System.Globalization;
using System.Numerics;
using System.Reflection;
using System.Runtime.InteropServices;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine($"Runtime: {RuntimeInformation.FrameworkDescription}");
Console.WriteLine($"OS: {RuntimeInformation.OSDescription}");
Console.WriteLine($"Process architecture: {RuntimeInformation.ProcessArchitecture}");
Console.WriteLine($"CoreLib build: {typeof(Decimal128).Assembly.GetCustomAttribute()?.InformationalVersion}");
// Both the gap and x are exactly representable in Decimal128.
var gap = Decimal128.Parse("1e-30", CultureInfo.InvariantCulture);
var x = Decimal128.One - gap;
Console.WriteLine($"1 - x: {Decimal128.One - x}");
foreach (var input in new[] { x, -x })
{
Console.WriteLine();
Console.WriteLine($"Input: {input}");
Console.WriteLine($"Decimal128.Acos: {Decimal128.Acos(input)}");
Console.WriteLine($"Decimal128.Asin: {Decimal128.Asin(input)}");
// Comparison only; these calls are not the reference oracle.
var root = Decimal128.Sqrt(
Decimal128.FusedMultiplyAdd(-input, input, Decimal128.One));
Console.WriteLine($"Acos workaround: {Decimal128.Atan2(root, input)}");
Console.WriteLine($"Asin workaround: {Decimal128.Atan2(input, root)}");
}
```
For additional endpoint cases, replace `"1e-30"` with `"1e-12"`, `"1e-20"`, `"1e-33"`, or `"1e-34"`. The `1e-34` gap produces the nearest representable value strictly inside the domain at each endpoint.
### Expected behavior
The functions should preserve accuracy appropriate to Decimal128's 34 significant decimal digits for these exactly representable inputs. This report does not assume a universal correct-rounding guarantee; the issue is the loss of many digits, far beyond a last-place rounding difference.
For the exact input `x = 0.999999999999999999999999999999`, the high-precision references rounded to 34 significant decimal digits (nearest, ties to even) are:
```text
acos(x) = 0.000000000000001414213562373095048801688724209816
asin(x) = 1.570796326794895205017759318544703
acos(-x) = 3.141592653589791824249081010184454
asin(-x) = -1.570796326794895205017759318544703
```
These are rounded reference values, not exact mathematical values. They were computed independently of the .NET APIs and the workaround using mpmath 1.3.0 at 100 and 200 decimal digits of working precision. Both precisions agree on the displayed digits.
Independent reference calculation (Python with mpmath 1.3.0)
Install `mpmath==1.3.0` in a Python environment, then run:
```python
import mpmath as mp
from decimal import Decimal, localcontext, ROUND_HALF_EVEN
for precision in (100, 200):
mp.mp.dps = precision
# Parse the decimal input at high precision; do not use a Python float.
x = mp.mpf("0.999999999999999999999999999999")
print(f"Working precision: {precision} decimal digits")
for label, value in (
("acos(x)", mp.acos(x)),
("asin(x)", mp.asin(x)),
("acos(-x)", mp.acos(-x)),
("asin(-x)", mp.asin(-x)),
):
with localcontext() as ctx:
ctx.prec = 34
ctx.rounding = ROUND_HALF_EVEN
rounded = +Decimal(mp.nstr(value, 90))
print(label, "reference:", mp.nstr(value, 55))
print(" rounded to 34 significant digits:", rounded)
```
### Actual behavior
Complete output from the reproduction on the affected build:
```text
Runtime: .NET 11.0.0-preview.7.26381.103
OS: Microsoft Windows 10.0.26100
Process architecture: X64
CoreLib build: 11.0.0-preview.7.26381.103+e2c1e00b3d0f96afb892fb261d5921565b400246
1 - x: 0.000000000000000000000000000001
Input: 0.999999999999999999999999999999
Decimal128.Acos: 0.000000000000001414213564615384992009383671093284
Decimal128.Asin: 1.570796326794895205017757076254759
Acos workaround: 0.000000000000001414213562373095048801688724209816
Asin workaround: 1.570796326794895205017759318544703
Input: -0.999999999999999999999999999999
Decimal128.Acos: 3.141592653589791824249078767894511
Decimal128.Asin: -1.570796326794895205017757076254759
Acos workaround: 3.141592653589791824249081010184454
Asin workaround: -1.570796326794895205017759318544703
```
The errors below compare the exact decimal values returned by .NET with the unrounded high-precision mathematical references:
| Call | Absolute error (approximately) | Relative error (approximately) | Result ulp | Error in ulps (approximately) |
| --- | ---: | ---: | ---: | ---: |
| `Acos(x)` | `2.24229e-24` | `1.58554e-9` | `1e-48` | `2.24229e24` |
| `Asin(x)` | `2.24229e-24` | `1.42749e-24` | `1e-33` | `2.24229e9` |
| `Acos(-x)` | `2.24229e-24` | `7.13743e-25` | `1e-33` | `2.24229e9` |
| `Asin(-x)` | `2.24229e-24` | `1.42749e-24` | `1e-33` | `2.24229e9` |
Here, one result ulp is `10^(floor(log10(abs(reference))) - 33)`, the spacing for a 34-significant-digit decimal result at that magnitude. It is not the input spacing or the stored output quantum. Absolute errors are in radians.
The calls complete normally and return finite values. There is no exception or diagnostic.
### Regression?
_No response_
### Known Workarounds
Near the endpoints, computing `sqrt(1 - x²)` with decimal `FusedMultiplyAdd` and `Sqrt`, then using `Atan2`, substantially improves accuracy in the demonstrated cases. A wrapper that applies the composition only for `0.9999 < |x| <= 1` is:
```csharp
static (Decimal128 Acos, Decimal128 Asin) InverseTrigWithWorkaround(
Decimal128 input)
{
var magnitude = Decimal128.Abs(input);
var threshold = Decimal128.Parse("0.9999", CultureInfo.InvariantCulture);
if (magnitude > threshold && magnitude <= Decimal128.One)
{
var root = Decimal128.Sqrt(
Decimal128.FusedMultiplyAdd(-input, input, Decimal128.One));
return (
Decimal128.Atan2(root, input),
Decimal128.Atan2(input, root));
}
return (Decimal128.Acos(input), Decimal128.Asin(input));
}
```
This uses the same namespaces as the reproduction. `FusedMultiplyAdd(-input, input, Decimal128.One)` rounds the complement once; `Decimal128.One - input * input` instead rounds the product before the subtraction and can lose information.
For the two inputs in the reproduction, the workaround agrees with the rounded reference values above. The composition is not a certified correctly rounded implementation, and this example establishes no whole-band or whole-domain error bound.
### Configuration
```
.NET SDK:
Version: 11.0.100-preview.7.26381.103
Commit: e2c1e00b3d
Workload version: 11.0.100-manifests.2ce71a7f
MSBuild version: 18.10.0-1.26381.103+e2c1e00b3
Runtime Environment:
OS Name: Windows
OS Version: 10.0.26100
OS Platform: Windows
RID: win-x64
```
### Other information
A suspected cause is decimal-to-binary conversion before the endpoint reduction. In the source corresponding to the tested build, `AsinDecimalIeee754` and `AcosDecimalIeee754` call `DecimalToDiyFp128` before their inverse-trigonometric routines. See the [conversion and dispatch code](https://github.com/dotnet/runtime/blob/253bde0a42aff6d8e99e039db1e710c5e1f5436a/src/libraries/System.Private.CoreLib/src/System/Number.DecimalIeee754.Transcendental.cs#L1160).
For `1/2 <= |x| < 1`, the shared routine then forms `sqrt((1 - |x|) / 2)` in its binary representation. Rounding introduced before subtracting from one can become large relative to the small endpoint gap. This is consistent with the observed error, but is a hypothesis rather than a confirmed root cause. See [`DiyFp128AsinAcos`](https://github.com/dotnet/runtime/blob/253bde0a42aff6d8e99e039db1e710c5e1f5436a/src/libraries/System.Private.CoreLib/src/System/Number.DecimalIeee754.DiyFp128InvTrig.cs#L204).
Source provenance: the reproduced CoreLib informational version identifies `dotnet/dotnet` commit `e2c1e00b3d0f96afb892fb261d5921565b400246`. Its [source manifest](https://github.com/dotnet/dotnet/blob/e2c1e00b3d0f96afb892fb261d5921565b400246/src/source-manifest.json#L77) maps `dotnet/runtime` to `253bde0a42aff6d8e99e039db1e710c5e1f5436a`, which is the revision linked above.
Contributor guide
Assessment
This issue has not been assessed yet.