Skip to content

Commit 25c7820

Browse files
committed
ENH: improve f32 sin/cos accuracy
The new improvements provide better accuracy for both non-FMA and FMA targets, so we can achieve < 2ulp for the low accuracy path and < 0.6 for the high accuracy path without affecting performance, except for a few ops on non-FMA targets, which was necessary to match FMA target precision. The improvements also include increasing the fast path ranges for FMA targets, and it has been tested against an exhaustive test covering all float32 ranges. This was achieved by refitting the f32 sin/cos polynomials as generated minimax sets (new data/polyf32.h.pol) instead of SVML hardcoded coefficients, split per op and per FMA mode. For non-FMA: - High<> gets a third pi word with 31-bit heads so n*pi_i stays - exact; FMA keeps the two-word 35+53-bit split. - Low<> recovers the pi[2] rounding error via Fast2Sum on non-FMA, the same approach applies to float64 and buys ~0.3 ulp. Also, for all targets: - Low<> cos(x) == 1 is answered for all |x| below a per-type bound, not just x_trans == pi/2, which fixes tiny tests. - kLargeF32 replaces the flat 10000 with per-path measured thresholds. - Sollya prec 512 -> 1024, which was needed for generating the float32-widening double poly.
1 parent 6bcea9f commit 25c7820

10 files changed

Lines changed: 243 additions & 61 deletions

File tree

npsr/lut-inl.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
#include <limits>
99
#include <tuple>
10+
#include <limits>
1011

1112
#include "npsr/hwy.h"
1213

npsr/trig/data/constants.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ template <> inline constexpr double kPi<double, false>[] = {
1919
template <bool FMA> inline constexpr double kPiPrec35[] = {
2020
0x1.921fb5444p1, 0x1.68c234c4c6628p-38, };
2121
template <> inline constexpr double kPiPrec35<false>[] = {
22-
0x1.921fb6p1, -0x1.777a5cp-24, -0x1.ee59dap-49, };
22+
0x1.921fb544p1, 0x1.0b4611a8p-33, -0x1.d9cceba3f91f2p-65, };
2323

2424
template <typename T> inline constexpr char kPiMul2[] = {};
2525
template <> inline constexpr float kPiMul2<float>[] = {

npsr/trig/data/constants.h.sol

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,20 @@ Append(
4444
// rounds ~2^-105, harmless even for tiny reduced arguments near k*π/2
4545
"",
4646

47-
// Special 35-bit precision π for specific algorithms
47+
// Special 35-bit precision π for the f32 High path, which reduces in double.
48+
// FMA: the fused subtractions never need n*πᵢ to be representable, so two
49+
// words carry the whole budget and π is held to 35+53 = 88 bits.
50+
//
51+
// non-FMA: NegMulAdd decays to `x - fl(n*πᵢ)`, so each n*πᵢ has to be exact.
52+
// The quotient is bounded by the f32 magic-round in High(), |n| < 2^22, hence
53+
// 53-22 = 31-bit heads. Two of those only pin π to 62 bits, so a 53-bit tail
54+
// follows; its product rounds at ~2^-92, just under the FMA split's 2^-90
55+
// residual, which is what keeps both variants on the same accuracy curve.
4856
"template <bool FMA> inline constexpr double kPiPrec35[] = " @
4957
KArray_(Float64, pi, [|RN, 35|], [|RD, 53|]),
50-
58+
5159
"template <> inline constexpr double kPiPrec35<false>[] = " @
52-
KArray_(Float64, pi, [|RN, 24, 24, 24|]),
60+
KArray_(Float64, pi, [|RN, 31, 31|], [|RN, 53|]),
5361
"",
5462

5563
// 2π constants for angle wrapping

npsr/trig/data/data.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
// their own guards and no-op on re-entry.
99
#include "npsr/lut-inl.h"
1010
#include "npsr/trig/data/constants.h"
11+
#include "npsr/trig/data/polyf32.h"
1112
#include "npsr/trig/data/kpi16-inl.h"
1213
#include "npsr/trig/data/approx.h"
1314
#include "npsr/trig/data/reduction.h"

npsr/trig/data/polyf32.h

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Auto-generated by npsr/trig/data/polyf32.h.sol
2+
// Use `spin sollya -f` to force regeneration
3+
#ifndef NPSR_TRIG_DATA_POLYF32_H
4+
#define NPSR_TRIG_DATA_POLYF32_H
5+
6+
namespace npsr::trig::data {
7+
// Degree-9 odd minimax sin(x) = x + x^3*P(x^2) on [2^-20, pi/2].
8+
// Coefficients {c3, c5, c7, c9} for the f32 PolyLow in npsr/trig/low-inl.h.
9+
// See poly.h.sol for the recipe, pins and measured worst cases.
10+
template <bool FMA> inline constexpr float kSinPolyLowF32[] = {
11+
-0x1.555544p-3f, 0x1.110d94p-7f, -0x1.9f3644p-13f, 0x1.5a9eap-19f,
12+
};
13+
template <bool FMA> inline constexpr float kCosPolyLowF32[] = {
14+
-0x1.555548p-3f, 0x1.110e34p-7f, -0x1.9f527cp-13f, 0x1.5c1b4p-19f,
15+
};
16+
template <> inline constexpr float kSinPolyLowF32<false>[] = {
17+
-0x1.55554ap-3f, 0x1.110e88p-7f, -0x1.9f621ap-13f, 0x1.5cfce2p-19f,
18+
};
19+
template <> inline constexpr float kCosPolyLowF32<false>[] = {
20+
-0x1.55554ap-3f, 0x1.110e9p-7f, -0x1.9f653ep-13f, 0x1.5d3afep-19f,
21+
};
22+
23+
// Degree-9 odd minimax sin(r) on [2^-30, pi/2 + 0.012] for the f32 High path
24+
// in trig/high-inl.h; c1 is fitted, not pinned to 1. See poly.h.sol.
25+
// Relative error 9.64818894863128662109375e-2 ulp(f32). Coefficients {c1, c3, c5, c7, c9}.
26+
inline constexpr double kSinPolyHighF32[] = {
27+
0x1.ffffffce99ecep-1, -0x1.5555471207a17p-3, 0x1.110e5bbaaff44p-7,
28+
-0x1.9f5e996744e9ep-13, 0x1.5ce7e229f1f1p-19, };
29+
30+
} // namespace npsr::trig::data
31+
32+
#endif // NPSR_TRIG_DATA_POLYF32_H

npsr/trig/data/polyf32.h.sol

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// f32 Low trig coefficients: sin(x) ~= x + x^3*P(x^2), degree 9, odd.
2+
// 333/346/368 are recoverable Remez warnings the constrained refits below
3+
// always trip; the driver would treat them as errors.
4+
suppressmessage(346, 333, 368);
5+
6+
I = [0x1p-20; pi/2];
7+
vFree = fpminimax(sin(x), [|3, 5, 7, 9|], [|single...|], I, relative, floating, x);
8+
9+
// Relative error in ULP at 1 (= 2^-24); guards regeneration against a worse fit.
10+
procedure ErrULP(pP) { return dirtyinfnorm(pP(x)/sin(x) - 1, I) * 0x1p24; };
11+
Assert(ErrULP(vFree) < 0.12, "f32 Low free fit error regressed: " @ ErrULP(vFree));
12+
13+
// One f32 ULP at a coefficient's scale. No coefficient sits within a step of
14+
// a power of two, so the constant step is exact.
15+
procedure Ulp(pC) { return 2^(floor(log2(abs(pC))) - 23); };
16+
17+
// Pin c3 (then c5, then optionally c7) an integer number of ULPs off the free
18+
// fit and refit the rest under the pin. Pin indices come from exhaustive
19+
// measurement of the compiled kernel over [0, 1e4); no analytic criterion
20+
// sees the eval DAG's per-operation rounding.
21+
procedure Stage2(pK3, pJ5) {
22+
var vC3, vP5, vC5, vP7;
23+
vC3 = round(coeff(vFree, 3) + pK3 * Ulp(coeff(vFree, 3)), single, RN);
24+
vP5 = fpminimax(sin(x), [|5, 7, 9|], [|single...|], I, relative, floating,
25+
x + vC3 * x^3);
26+
vC5 = round(coeff(vP5, 5) + pJ5 * Ulp(coeff(vP5, 5)), single, RN);
27+
vP7 = fpminimax(sin(x), [|7, 9|], [|single...|], I, relative, floating,
28+
x + vC3 * x^3 + vC5 * x^5);
29+
return [| vC3, vC5, vP7 |];
30+
};
31+
32+
procedure CheckPinned(pSet, pTag) {
33+
Assert(ErrULP(pSet[0]*x^3 + pSet[1]*x^5 + pSet[2]*x^7 + pSet[3]*x^9 + x) < 1.0,
34+
"pinned set " @ pTag @ " analytic error drifted");
35+
return pSet;
36+
};
37+
38+
// c7/c9 come jointly from the stage-two fit.
39+
procedure Pinned(pK3, pJ5) {
40+
var vS;
41+
vS = Stage2(pK3, pJ5);
42+
return CheckPinned([| vS[0], vS[1], coeff(vS[2], 7), coeff(vS[2], 9) |],
43+
"(" @ pK3 @ "," @ pJ5 @ ")");
44+
};
45+
46+
// Additionally pin c7 and refit c9 alone. Only FMA cos gains from it.
47+
procedure Pinned3(pK3, pJ5, pL7) {
48+
var vS, vC7, vP9;
49+
vS = Stage2(pK3, pJ5);
50+
vC7 = round(coeff(vS[2], 7) + pL7 * Ulp(coeff(vS[2], 7)), single, RN);
51+
vP9 = fpminimax(sin(x), [|9|], [|single...|], I, relative, floating,
52+
x + vS[0] * x^3 + vS[1] * x^5 + vC7 * x^7);
53+
return CheckPinned([| vS[0], vS[1], vC7, coeff(vP9, 9) |],
54+
"(" @ pK3 @ "," @ pJ5 @ "," @ pL7 @ ")");
55+
};
56+
57+
// Exhaustive [0, 1e4) worst case, pinned vs free fit, in ULP:
58+
// FMA: sin 1.886 (free 2.191) cos 1.815 (free 2.110)
59+
// non-FMA: sin 1.912 (free 1.939) cos 1.948 (free 1.966)
60+
// Non-FMA is eval-rounding bound (~1.9 of the ~1.95 total), so pins only
61+
// shift which argument realizes the worst alignment; the whole (k3, j5, l7)
62+
// grid stays within 0.02 of the sets below.
63+
vFmaSin = Pinned(4, 1);
64+
vFmaCos = Pinned3(2, 1, 8);
65+
vNoFmaSin = Pinned(1, 1);
66+
vNoFmaCos = Pinned(1, 5);
67+
68+
Append(
69+
"// Degree-9 odd minimax sin(x) = x + x^3*P(x^2) on [2^-20, pi/2].",
70+
"// Coefficients {c3, c5, c7, c9} for the f32 PolyLow in npsr/trig/low-inl.h.",
71+
"// See poly.h.sol for the recipe, pins and measured worst cases.",
72+
"template <bool FMA> inline constexpr float kSinPolyLowF32[] = " @ CArrayT(Float32, vFmaSin, 4) @ ";",
73+
"template <bool FMA> inline constexpr float kCosPolyLowF32[] = " @ CArrayT(Float32, vFmaCos, 4) @ ";",
74+
"template <> inline constexpr float kSinPolyLowF32<false>[] = " @ CArrayT(Float32, vNoFmaSin, 4) @ ";",
75+
"template <> inline constexpr float kCosPolyLowF32<false>[] = " @ CArrayT(Float32, vNoFmaCos, 4) @ ";",
76+
""
77+
);
78+
79+
// f32 High coefficients, held and evaluated in double by npsr/trig/high-inl.h.
80+
//
81+
// c1 is fitted, not pinned to 1: MulAdd(r2, poly, c1) + Mul(poly, r) closes in
82+
// the same two ops as the pinned x + x^3*P(x^2), so the free parameter is free
83+
// -- 0.1099 -> 0.0965 ulp. No pinning stage either: eval is in double (~2^-52),
84+
// so unlike the single Low sets above there is no rounding for pins to steer.
85+
//
86+
// The interval tracks the cutover in npsr/trig/inl.h and must move with it: n
87+
// comes from a f32 magic-round, so |r| overshoots pi/2 by up to |x|*1.7e-7.
88+
// 0.012 is the exhaustive ceiling below both cutovers, and widening costs
89+
// accuracy fast: 0.0892 ulp at pi/2, 0.0965 here, 0.1073 at 0.028.
90+
IHigh = [0x1p-30; pi/2 + 0.012];
91+
vHigh = fpminimax(sin(x), [|1, 3, 5, 7, 9|], [|double...|], IHigh, relative, floating);
92+
93+
// Leaves High<> at 0.5 (final f32 rounding) + 0.097, the f32 ceiling: the
94+
// Extended kernel above the cutover measures 0.5031, so this fit -- not the
95+
// Payne-Hanek reduction -- is what keeps sin/cos off correct rounding.
96+
errHigh = dirtyinfnorm(vHigh(x)/sin(x) - 1, IHigh) * 0x1p24;
97+
Assert(errHigh < 0.0970, "f32 High fit error regressed: " @ errHigh);
98+
// The driver leaves display in hexadecimal, which is unreadable for an error.
99+
display = decimal!;
100+
errHighStr = "" @ round(errHigh, 24, RN);
101+
display = hexadecimal!;
102+
103+
Append(
104+
"// Degree-9 odd minimax sin(r) on [2^-30, pi/2 + 0.012] for the f32 High path",
105+
"// in trig/high-inl.h; c1 is fitted, not pinned to 1. See poly.h.sol.",
106+
"// Relative error " @ errHighStr @ " ulp(f32). Coefficients {c1, c3, c5, c7, c9}.",
107+
"inline constexpr double kSinPolyHighF32[] = " @
108+
CArrayT(Float64, [| coeff(vHigh, 1), coeff(vHigh, 3), coeff(vHigh, 5),
109+
coeff(vHigh, 7), coeff(vHigh, 9) |], 3) @ ";",
110+
""
111+
);
112+
113+
WriteCPPHeader("npsr::trig::data");

npsr/trig/high-inl.h

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,21 +59,27 @@ NPSR_INTRIN V High(V x) {
5959
}
6060
auto WideCal = [](const VW &nh, const VW &xh_abs) -> VW {
6161
const DFromV<VW> dw;
62-
constexpr auto kPiPrec35 = data::kPiPrec35<true>;
62+
// Cody-Waite r = x - n*π, one word per NegMulAdd. Without FMA the product
63+
// rounds, so that split spends a third word on 31-bit heads to keep n*πᵢ
64+
// exact; see constants.h.sol.
65+
constexpr auto kPiPrec35 = data::kPiPrec35<kNativeFMA>;
6366
VW r = NegMulAdd(nh, Set(dw, kPiPrec35[0]), xh_abs);
6467
r = NegMulAdd(nh, Set(dw, kPiPrec35[1]), r);
68+
if constexpr (!kNativeFMA) {
69+
r = NegMulAdd(nh, Set(dw, kPiPrec35[2]), r);
70+
}
6571
VW r2 = Mul(r, r);
6672

67-
// Polynomial coefficients for sin(r) approximation on [-π/2, π/2]
68-
const VW c9 = Set(dw, 0x1.5dbdf0e4c7deep-19);
69-
const VW c7 = Set(dw, -0x1.9f6ffeea73463p-13);
70-
const VW c5 = Set(dw, 0x1.110ed3804ca96p-7);
71-
const VW c3 = Set(dw, -0x1.55554bc836587p-3);
72-
VW poly = MulAdd(c9, r2, c7);
73-
poly = MulAdd(r2, poly, c5);
74-
poly = MulAdd(r2, poly, c3);
75-
poly = Mul(poly, r2);
76-
poly = MulAdd(r, poly, r);
73+
// Degree-9 odd minimax for sin(r); the fit is this path's whole error
74+
// budget (0.5 + 0.0965 ULP). c1 is fitted, not pinned to 1 -- same two
75+
// closing ops as r + r^3*P(r^2), one more free parameter. Within 2^-27.4
76+
// of 1, so sin(r) still demotes to r bit-exactly. See data/polyf32.h.sol.
77+
constexpr auto kPoly = data::kSinPolyHighF32;
78+
VW poly = MulAdd(Set(dw, kPoly[4]), r2, Set(dw, kPoly[3]));
79+
poly = MulAdd(r2, poly, Set(dw, kPoly[2]));
80+
poly = MulAdd(r2, poly, Set(dw, kPoly[1]));
81+
poly = MulAdd(r2, poly, Set(dw, kPoly[0]));
82+
poly = Mul(poly, r);
7783
return poly;
7884
};
7985

npsr/trig/inl.h

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
// Main trigonometric function dispatcher for Highway SIMD library
21
// This file provides the public API for sine and cosine functions with
3-
// configurable precision, special case handling, and algorithm selection
2+
// configurable precision, special case handling, and algorithm selection.
3+
//
4+
// The implementation automatically selects between:
5+
// 1. Low precision: < 2 ULP for f32 and < 3.5 ULP for f64
6+
// 2. High precision: < 0.6 ULP for f32, < 1 ULP for f64; moderate speed
7+
// 3. Extended precision: Payne-Hanek reduction for huge |x|, similar
8+
// accuracy to High precision but a slow path.
49
//
5-
// The implementation automatically selects between three algorithms:
6-
// 1. Low precision: ~2-3 ULP error, fastest
7-
// 2. High precision: ~1 ULP error, moderate speed
8-
// 3. Extended precision: Payne-Hanek reduction for huge |x|
9-
// (> 10^4 for float, > 2^24 for double)
10-
1110
#if defined(NPSR_TRIG_INL_H_) == defined(HWY_TARGET_TOGGLE) // NOLINT
1211
#ifdef NPSR_TRIG_INL_H_
1312
#undef NPSR_TRIG_INL_H_
@@ -42,11 +41,13 @@ namespace npsr::HWY_NAMESPACE::trig {
4241
*
4342
* Algorithm selection:
4443
* 1. If kLowAccuracy: Use Low<> (Cody-Waite with minimal polynomial)
45-
* 2. Otherwise: Use High<> (π/16 reduction with table lookup)
44+
* 2. Otherwise: Use High<> (f32: π reduction in double; f64: π/16 reduction
45+
* with table lookup)
4646
* 3. If kLargeArgument and |x| > threshold: Override with Extended<>
4747
*
4848
* Thresholds for extended precision:
49-
* - Float: |x| > 10,000 (empirically chosen for accuracy)
49+
* - Float: |x| > the largest argument the chosen path still meets its bound at
50+
* (differs per accuracy/FMA/op; see kLargeF32)
5051
* - Double: |x| > 2^24 (16,777,216 - beyond this the reduction's n*π products
5152
* stop being exactly representable)
5253
*/
@@ -58,12 +59,8 @@ NPSR_INTRIN V Trig(Prec &prec, V x) {
5859
V ret;
5960
// Step 1: Select base algorithm based on accuracy requirements
6061
if constexpr (Prec::kLowAccuracy) {
61-
// Low precision: Cody-Waite reduction, degree-9 (f32) / degree-15 (f64)
62-
// Error: ~2-3 ULP
6362
ret = Low<OP>(x);
6463
} else {
65-
// High precision: π/16 reduction with table lookup + polynomial
66-
// Error: ~1 ULP
6764
ret = High<OP>(x);
6865
}
6966
// Step 2: Handle special cases (NaN, Inf) if enabled
@@ -80,16 +77,26 @@ NPSR_INTRIN V Trig(Prec &prec, V x) {
8077
// For |x| > threshold, standard algorithms lose precision due to
8178
// catastrophic cancellation in x - n*π reduction
8279
if constexpr (Prec::kLargeArgument) {
83-
// Thresholds mark where each path's n*π products stop being exact:
84-
// - Float: 10,000, conservative but keeps the widened path < 1 ULP.
80+
// Thresholds mark where each path stops meeting its bound, i.e. where its
81+
// range reduction breaks down, not where mantissa resolution runs out:
82+
// - Float: each path keeps its own accuracy bound up to a different |x|;
83+
// every constant below is the round value under the exhaustively
84+
// measured, MPFR-confirmed first argument that exceeds it. The low paths
85+
// break far earlier without FMA, because there the extra π word is spent
86+
// on keeping n*πᵢ representable rather than on headroom.
8587
// - Double: 2^24, past which |round(x*16/π)| overflows the π/16 split.
8688
// - Double low-accuracy cos: holds until trunc(|x|/π) > 8388606; the
8789
// constant is the largest double below that boundary (~8388607π).
88-
constexpr bool kIsLowCos =
89-
Prec::kLowAccuracy && OP == trig::Operation::kCos;
90+
constexpr bool kIsCos = OP == trig::Operation::kCos;
91+
constexpr bool kIsLowCos = Prec::kLowAccuracy && kIsCos;
92+
constexpr float kLargeF32 =
93+
Prec::kLowAccuracy
94+
? (kNativeFMA ? (kIsCos ? 1600000.0f : 2200000.0f)
95+
: (kIsCos ? 12000.0f : 25000.0f))
96+
: (kNativeFMA ? 200000.0f : 130000.0f);
9097
constexpr double kLargeF64 = kIsLowCos ? 0x1.921fb2200366fp+24 : 16777216.0;
9198
auto has_large_arg =
92-
And(Gt(Abs(x), Set(d, kIsSingle ? 10000.0f : kLargeF64)), is_finite);
99+
And(Gt(Abs(x), Set(d, kIsSingle ? kLargeF32 : kLargeF64)), is_finite);
93100

94101
// Extended precision is expensive, only use when necessary
95102
if (HWY_UNLIKELY(!AllFalse(d, has_large_arg))) {

0 commit comments

Comments
 (0)