Skip to content

Fix the accuracy and monotonicity of asin, acos, atan and atan2 - #1478

Merged
ckormanyos merged 1 commit into
boostorg:developfrom
ibmibmibm:trig-asin-acos-atan
Oct 1, 2026
Merged

ckormanyos merged 1 commit into
boostorg:developfrom
ibmibmibm:trig-asin-acos-atan

Conversation

@ibmibmibm

Copy link
Copy Markdown
Contributor

See #1472.

Summary

On develop, asin, acos, atan and atan2 lose digits. The decimal64 functions have errors up to 9,230 ulp, and the decimal128 functions have errors up to 1.5e18 ulp. The results also go against the direction of the exact function for thousands of adjacent inputs. This PR replaces the kernels and the reductions. The error is now less than 1 ulp in round to nearest for all types, and the results are in order. All four functions are faster, by up to 20 times.

Problems on develop
  • The decimal64 and decimal128 asin polynomials are too short near 0.5, and they have a constant term. This constant term breaks small arguments. acos uses the same polynomials.
  • Below cbrt(eps), asin rounds 1 + x^2/6 to the type, then it loses digits.
  • For x >= 0.5, acos subtracts pi/2 and adds it back. Near 1 this removes most digits.
  • The decimal128 atan Pade kernel is too short above 0.3. Two decimal128 atan constants are wrong.
  • pi_v<T> / 2 rounds two times, then asin(1), atan(inf) and atan2(y, 0) are 1 ulp off. atan2 takes pi/4 and 3 pi/4 from numbers::pi_over_four_v. For decimal128_t, this constant is 100 times too small.
  • atan2 computes atan(y / x). For q = y / x in [1, 1.557), q has a grid ten times coarser than the result. The rounding of q then adds up to 2.5 ulp.
  • The kernels are chains of fma calls on decimal values.

Examples (decimal64_t unless noted). The correct values come from MPFR at 600 bits.

Call develop this PR Correct value
asin(0.5004651001747461) 0.524135910332543 0.5241359103330428 0.5241359103330428
asin(1) 1.570796326794896 1.570796326794897 1.570796326794897
acos(0.9999999999997268) 7.391887450000000e-07 7.391887445030700e-07 7.391887445030700e-07
atan(0.7136198652127231) 0.6198084470537837 0.6198084470537839 0.6198084470537839
atan(78.12423720033109) 1.557996900819006 1.557996900819007 1.557996900819007
atan2(11.20258985683803, 10.91340798186341) 0.7984731057062603 0.7984731057062606 0.7984731057062606
decimal32_t acos(-0.9995746) 3.112424 3.112423 3.112423
decimal32_t asin(0.009633317) 9.633461e-03 9.633466e-03 9.633466e-03
decimal128_t atan(0.4) 0.3805063771123648863035879172013003 0.3805063771123648863035879168104331 0.3805063771123648863035879168104331
decimal128_t asin(0.5003912494167833183647481136970553) 0.52405061046026979838450239673504 0.5240506104602699416431335061998704 0.5240506104602699416431335061998704
decimal128_t acos(0.9999999999999999999999999999998349) 5.746303159423456670000000000000000e-16 5.746303159423456662845786731898078e-16 5.746303159423456662845786731898078e-16

The tests did not find these problems. test_asin.cpp, test_acos.cpp, test_atan.cpp and test_atan2.cpp compare with float std::asin, std::acos, std::atan and std::atan2, with a limit of 50 float ulps. Then they cannot see an error below the precision of float. No test uses a directed rounding mode or a reference with more digits than the type.

Changes
  • The kernels are odd polynomials x + x^3 P(x^2) from Chebyshev fits. The new impl/fixed_point_series.hpp computes P in binary fixed point: Q62 in an int64_t for the 32-bit and 64-bit types, and Q126 in an int128_t for the 128-bit types. The product x^2 P has one rounding.
  • The new impl/split_pi.hpp has pi/4, pi/2, 3 pi/4 and pi as a rounded part and the rest. asin, acos, atan and atan2 use this table. This PR does not change numbers::pi_over_four_v.
  • Above 0.5, asin and acos use 2 asin(sqrt((1 - x) / 2)). A low part corrects the rounding of the square root and of s + s.
  • atan uses the fdlibm reduction at 1/2, 1 and 3/2. It adds the rounding of the denominator and of the quotient to the low part.
  • Each result has one rounding at its own grid. Then the results of adjacent inputs are in order.
  • For q in [10^k, tan(10^k)), atan2 gives the remainder of y / x to atan as a low part of its argument. Other q do not need it, and they keep the speed of one atan call.
  • test_asin.cpp, test_atan.cpp and test_atan2.cpp expected the old values of pi/2, pi/4, 3 pi/4 and asin(sqrt(eps)). They now expect the correctly rounded values.
  • The new test test_inverse_trig_rounding.cpp compares with MPFR values, stored as a high and a low part. The error must be less than 1 ulp. The test also walks adjacent inputs around each point where a formula changes, and the results must be in order.
Accuracy

The dense survey uses 20,000 random full-precision inputs per range and per rounding mode, on the ranges with the largest errors. The reference is MPFR.

Function Type develop, nearest this PR, nearest this PR, all modes
asin decimal32_t 15.3 0.88 1.19
asin decimal64_t 5,010 0.72 1.18
asin decimal128_t 1.5e18 0.80 1.19
acos decimal32_t 4,560 0.80 1.50
acos decimal64_t 4,990 0.74 1.50
acos decimal128_t 2.7e17 0.77 1.52
atan decimal32_t 21.4 0.62 1.16
atan decimal64_t 3.1 0.62 1.17
atan decimal128_t 9.8e7 0.64 1.15

Errors in ulp. The fast types give the same values.

For decimal32_t, a check of all inputs against MPFR gives these maximum errors in round to nearest: 0.89 ulp for asin, 0.81 ulp for acos and 0.64 ulp for atan. No result has an error of 1 ulp or more. In the directed modes the maximum is 1.58 ulp.

atan2 survey: 5,000 inputs per band of |y/x|, in four quadrants and five rounding modes.

Type develop, nearest this PR, nearest this PR, all modes
decimal32_t 20.7 0.98 2.0
decimal64_t 3.51 0.98 2.0
decimal128_t 9.1e7 0.99 1.99

In the directed modes, outside the ranges of q above, the rounding of q and the rounding of atan can each add 1 ulp.

Monotonicity: runs of adjacent inputs around each point where a formula changes, in five rounding modes. A plateau is a step where the result stays the same, but the exact value changes by 2 ulp or more.

develop this PR
atan, steps against the exact direction 3,305 to 3,433 per type 0
acos, plateaus about 14,500 per type 0
atan2, steps against the exact direction 892 to 1,613 per type and sign of x 0
acos, steps against the exact direction 4 to 10 per type 1 for the 64-bit and 128-bit types, upward mode only

The one acos step is at x = -0.5 in upward mode. The result changes by 1 ulp in the wrong direction.

Speed

Google Benchmark, median ns per call, one core, GCC -O3. The fast types give similar numbers.

Function Type [0, 0.5) [0.5, 1) (-1, -0.5]
asin decimal32_t 458 -> 97 610 -> 481 611 -> 481
asin decimal64_t 2901 -> 140 3149 -> 742 3160 -> 740
asin decimal128_t 6623 -> 520 8052 -> 2169 8033 -> 2169
acos decimal32_t 483 -> 138 623 -> 472 606 -> 474
acos decimal64_t 2920 -> 220 3172 -> 736 3154 -> 762
acos decimal128_t 7320 -> 868 8062 -> 1989 7931 -> 2131
Function Type [0, 0.4375) [0.4375, 2.4375) [2.4375, 48) [48, 1e4)
atan decimal32_t 481 -> 96 627 -> 341 698 -> 165 539 -> 162
atan decimal64_t 813 -> 145 1029 -> 462 1222 -> 252 1145 -> 296
atan decimal128_t 2130 -> 487 2742 -> 1266 3436 -> 861 4291 -> 941
Function Type |y/x| < 0.4375 0.4375 to 2.4375 > 2.4375
atan2 decimal32_t 576 -> 159 731 -> 421 731 -> 213
atan2 decimal64_t 959 -> 229 1059 -> 573 1154 -> 310
atan2 decimal128_t 3235 -> 660 2939 -> 1458 3767 -> 1041

The atan2 numbers are for x > 0. For x < 0 they are similar. Above 0.5, asin and acos spend most of the time in sqrt and in the divisions.

Code size

Bytes that the functions add to a program (.text and .rodata), for all six types.

asin, acos and atan, develop this PR atan2, develop this PR
GCC -O2 189,622 174,911 107,288 145,404
GCC -Os 103,455 102,293 59,477 83,443
Clang -O2 138,049 134,640 71,722 104,273
Clang -Os 106,767 104,790 58,883 87,005

asin, acos and atan are smaller than on develop. atan2 is larger. It has a second atan path with a low part, and it uses fma for the remainder of y / x. For the 64-bit and 128-bit types, fma needs 256-bit arithmetic.

Other functions

ellint_1 and ellint_2 call atan in each step of the AGM. With this PR, their decimal128 error goes from up to 5.6e7 ulp to 11 to 26 ulp. They are also 10 to 20 percent faster. The decimal64 error does not change.

Tests

All single-file tests in test/Jamfile pass with GCC in C++17 and -Werror. test_format_fmtlib does not build with the local fmt 12, but this PR does not change it. The tests of these functions also pass with GCC and Clang in C++14 and C++20, in a 32-bit build and with BOOST_DECIMAL_FAST_MATH. The four functions also work in constant evaluation with GCC and Clang.

@codecov

codecov Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.33555% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.7%. Comparing base (5b03306) to head (5a8ee56).
⚠️ Report is 2 commits behind head on develop.

Files with missing lines Patch % Lines
include/boost/decimal/detail/cmath/atan.hpp 97.5% 1 Missing ⚠️
...lude/boost/decimal/detail/cmath/impl/asin_impl.hpp 94.5% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff            @@
##           develop   #1478     +/-   ##
=========================================
+ Coverage     98.6%   98.7%   +0.1%     
=========================================
  Files          312     315      +3     
  Lines        26265   26454    +189     
  Branches      2264    2256      -8     
=========================================
+ Hits         25892   26101    +209     
+ Misses         373     353     -20     
Files with missing lines Coverage Δ
include/boost/decimal/detail/cmath/acos.hpp 100.0% <100.0%> (ø)
include/boost/decimal/detail/cmath/asin.hpp 100.0% <100.0%> (ø)
include/boost/decimal/detail/cmath/atan2.hpp 100.0% <100.0%> (ø)
...lude/boost/decimal/detail/cmath/impl/atan_impl.hpp 100.0% <100.0%> (ø)
...t/decimal/detail/cmath/impl/fixed_point_series.hpp 100.0% <100.0%> (ø)
...clude/boost/decimal/detail/cmath/impl/split_pi.hpp 100.0% <100.0%> (ø)
test/test_asin.cpp 100.0% <100.0%> (ø)
test/test_atan.cpp 100.0% <100.0%> (ø)
test/test_atan2.cpp 100.0% <100.0%> (ø)
test/test_inverse_trig_rounding.cpp 100.0% <100.0%> (ø)
... and 2 more

... and 8 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 5b03306...5a8ee56. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ibmibmibm
ibmibmibm marked this pull request as ready for review September 30, 2026 06:38
@mborland

Copy link
Copy Markdown
Member

@ckormanyos do you want to take a look at this one as well? It seems reasonable to me

@ckormanyos

Copy link
Copy Markdown
Member

Hi Matt (@mborland) hi @ibmibmibm and thanks for looking into this.

Actually I have gotten into the tradition of using detail::unchecked_fma() in all the <cmath>-like functions we are reworking. It's not a super time saver, but anything helps. I see a few unqualified calls in the diffs to regular fma().

Other than that, when changed, I think this is a good improvement.

Hi @ibmibmibm if you agree when and where sensible, save just a few cycles in the functions we refactor by using detail::unchecked_fma().

- The asin polynomials for 64 and 128 bits were too short near 0.5, and a
  constant term broke small arguments. acos uses the same polynomials.
- asin below cbrt(eps) rounded 1 + x^2/6 to the type, which lost digits.
  Add x^3/6 to x instead.
- acos for x >= 0.5 subtracted pi/2 and added it back, which lost most
  digits near 1.
- The 128-bit atan Pade kernel was too short above 0.3, and two atan
  constants were wrong.
- pi_v<T> / 2 rounds twice, so asin(1), atan(inf) and atan2(y, 0) were one
  ulp off. atan2 took pi/4 and 3 pi/4 from numbers::pi_over_four_v, which has
  the wrong exponent for decimal128_t.
- Use odd kernels x + x^3 P(x^2) from Chebyshev fits. Evaluate P in Q62 or
  Q126 fixed point and round x^2 P once, which also makes the functions
  faster.
- Put pi/4, pi/2, 3 pi/4 and pi, split into a rounded part and the rest, in
  one table for asin, acos, atan and atan2.
- asin and acos above 0.5 use 2 asin(sqrt((1 - x) / 2)), with a low part
  that corrects the rounding of the square root and of s + s.
- atan uses the fdlibm reduction at 1/2, 1 and 3/2, and adds the rounding
  of the denominator and of the quotient to the low part.
- atan2 rounded y / x before atan, and for a quotient q in [10^k, tan(10^k))
  the grid of q is ten times coarser than that of the result. For these q
  only, give the remainder of the division to atan as a low part of its
  argument.
- Write the integer constants in atan as T values, so that atan does not
  instantiate the operators for a decimal and an int.
- Round each result only once at its own grid, so that consecutive inputs
  give results in order.
- Three tests expected the old values of pi/2, pi/4, 3 pi/4 and
  asin(sqrt(eps)); update them.
- Add a regression test with MPFR values, stored as a high and a low part,
  that requires an error below one ulp, and that walks consecutive inputs
  around each point where a formula changes.

Refs boostorg#1472
@ibmibmibm
ibmibmibm force-pushed the trig-asin-acos-atan branch from 97aba40 to 5a8ee56 Compare October 1, 2026 03:19
@ibmibmibm

Copy link
Copy Markdown
Contributor Author

All five fma() calls now use detail::unchecked_fma().
I also changed 2 - z to T {2} - z in asin_impl.hpp.

@ckormanyos
ckormanyos merged commit bf02597 into boostorg:develop Oct 1, 2026
75 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants