Fix degree trig functions for large arguments - #252
Conversation
Reduce modulo 360 with std::fmod before the existing range reduction instead of giving up and returning 0.0 for |x| > 1e14. See scipy/scipy#20723
|
Some of the comments are useful but there is a lot of llm waffle, please reduce this |
|
Could you also comment on how to decided on this approach Vs the ideas discussed in the original issue |
|
Comments trimmed. I took @fancidev's @nickodell is right that large |
fmod exact?
I was fairly surprised by the claim that fmod is exact, so I looked into it further. Here's what the IEEE754 spec has to say about the remainder operation.
Surprisingly, even though the operation x / y is inexact, and the result is not even always representable as a floating point number, the operation The C++ spec is a little less specific about the behavior of std::fmod(), but I think most implementations implement it in substantially the same way. (Minus that fmod()'s range is [0, y) and remainder()'s range is [-y/2, y/2].) The thing that is more interesting than whether If you accept this argument that fmod is exact, it implies that a huge portion of sindg() is unneeded. For example, it computes This is designed to avoid directly doing a division. But if we could do Then it does bit-twiddling on j to get an integer 0-7. But you can recognize this as j = j % 8. I suspect many simplifications like this are possible. Conceptual issuePreviously, I said this.
I continue to think this is true: in some sense if someone is asking what sindg(10.0**100) is, they are in big trouble the moment their argument to sindg is rounded. This issue is largely unfixable. If they are trying to get sin of numbers exactly representable as a float, then they're fine, but my feeling is that this is a pretty unusual case. However, I do agree that defining sindg(x) to be a best-effort attempt to compute the sin is more mathematically elegant than just returning zero. Test script?
How'd you test this? Can you provide your testing script? Is this ULP error figure based on RMSE, max error, something else? |
|
Good catch, and thanks for going to the spec on this. The 0.64 figure doesn't hold up. I didn't keep the script that produced it, and re-measuring doesn't give me that number however I slice it, so I'm withdrawing it. What I can reproduce is the part that matters. Over 4000 arguments between 1e14 and 1e300, both signs, Over that range I measure a worst case of 1.43 ulp and an average of 0.11, against mpmath at 60 digits, skipping points right beside a zero where the comparison doesn't mean much. That is the existing code, which this patch doesn't change. Script below. On the simplifications you spotted, I think you're right, but I'd rather keep this PR to the fix and open a follow-up for them. Script"""Accuracy check for xsf's degree trig after the fmod reduction. Needs mpmath.
Build the probe first:
c++ -std=c++17 -O2 -I include probe.cpp -o probe
where probe.cpp reads doubles as hex from stdin and prints
sindg/cosdg/tandg/cotdg as hex, so bit patterns round-trip.
It answers two separate questions:
A. Is the reduction exact? sindg(x) must be bit-identical to
sindg(fmod(x, 360)). If that holds, accuracy at large x IS accuracy on
[0, 360), so large arguments need no separate error budget.
B. How accurate is the kernel on [0, 360)? This is the pre-existing cephes
polynomial and is unchanged by the patch; it is here as the baseline that
claim A inherits.
"""
import math, random, struct, subprocess, sys
from mpmath import mp, mpf, sin as msin, cos as mcos, pi as mpi
mp.dps = 60
PROBE = sys.argv[1] if len(sys.argv) > 1 else "./probe"
N = 4000
b = lambda x: struct.unpack("<Q", struct.pack("<d", x))[0]
d = lambda u: struct.unpack("<d", struct.pack("<Q", u))[0]
def run(xs):
out = subprocess.run([PROBE], input="\n".join(f"{b(x):x}" for x in xs),
capture_output=True, text=True).stdout.split()
return [[d(int(h, 16)) for h in out[i*4:i*4+4]] for i in range(len(xs))]
random.seed(20723)
NAMES = ("sindg", "cosdg", "tandg", "cotdg")
big = []
while len(big) < N:
x = math.exp(random.uniform(math.log(1e14), math.log(1e300)))
if math.isfinite(x):
big.append(random.choice([1, -1]) * x)
gb = run(big)
gr = run([math.fmod(x, 360.0) for x in big])
print(f"A. reduction exactness, {N} args in [1e14, 1e300], both signs")
for i, n in enumerate(NAMES):
ok = sum(b(p[i]) == b(q[i]) or (math.isnan(p[i]) and math.isnan(q[i]))
for p, q in zip(gb, gr))
print(f" {n:6} bit-identical to reduced argument: {ok}/{N}")
xs = [random.uniform(0, 360) for _ in range(N)]
res = run(xs)
print(f"\nB. kernel accuracy on [0,360) vs mpmath, {mp.dps} digits")
for i, n in enumerate(("sindg", "cosdg")):
e = []
for x, r in zip(xs, res):
ref = (msin if n == "sindg" else mcos)(mpi * mpf(x) / 180)
if abs(float(ref)) < 1e-8: # near a zero, a relative ulp says nothing
continue
e.append(0.0 if r[i] == float(ref)
else abs(float(mpf(r[i]) - ref)) / math.ulp(float(ref)))
e.sort()
rms = math.sqrt(sum(v * v for v in e) / len(e))
print(f" {n:6} n={len(e)} max {e[-1]:.2f} mean {sum(e)/len(e):.2f} "
f"rms {rms:.2f} p99 {e[int(.99*len(e))]:.2f} >1ulp {sum(v>1 for v in e)}")probe.cpp// Prints sindg/cosdg/tandg/cotdg for doubles read from stdin as hex, so the
// exact bit pattern round-trips and nothing is lost to decimal formatting.
#include <cstdio>
#include <cstring>
#include <cstdint>
#include "xsf/cephes/sindg.h"
#include "xsf/cephes/tandg.h"
int main() {
char line[128];
while (fgets(line, sizeof line, stdin)) {
uint64_t bits; if (sscanf(line, "%llx", (unsigned long long*)&bits) != 1) continue;
double x; memcpy(&x, &bits, 8);
double s = xsf::cephes::sindg(x), c = xsf::cephes::cosdg(x);
double t = xsf::cephes::tandg(x), o = xsf::cephes::cotdg(x);
uint64_t bs, bc, bt, bo;
memcpy(&bs,&s,8); memcpy(&bc,&c,8); memcpy(&bt,&t,8); memcpy(&bo,&o,8);
printf("%llx %llx %llx %llx\n", (unsigned long long)bs, (unsigned long long)bc,
(unsigned long long)bt, (unsigned long long)bo);
}
return 0;
} |
Sorry for taking a few days to get back to you. My first reaction to this is that 4000 seems a bit low. If you think about the Pentium FDIV bug, that was a 1 in 9 billion bug. I'm not expecting you to test that far - we don't have Intel's resources. But if I 100x the size of this test, it takes 16 seconds to run. This is not especially relevant to your claims about the mean of errors, (these hardly change at larger sizes) but it's quite important to your claims about the worst case. There are relatively few cases where we can just crank a handle, pour in more compute, and get better software out the other side, but randomized testing against a reliable benchmark is one of them. LLMs will steer you wrong here, because they always seem to suggest parameters for randomized testing that are too small. Two days ago, I wrote a fuzzer for a parser at work. The LLM ran it, and said, "I tried 30K iterations and different seeds, and there are no findings. This fuzzer is probably a dead end." I ran the same piece of software unchanged for 30M iterations, and found a security bug. So my advice would be to either make the test way way bigger, or to identify a way to test more efficiently, or both. Hypothesis is a useful library here, because it'll pick lots of numbers that trigger corners cases, and you get something like the log uniform distribution you're using (
Should I understand this to mean that you get 0/4000 for all of the tests under A shown below? Because that is not what I get on my computer. (Linux, gcc, AMD Ryzen 9 5900X.) |
Fixes scipy/scipy#20723.
sindg,cosdg,tandgandcotdgbail out withreturn 0.0above 1e14, sosindg(2e14)gives 0.0 instead of-sin(20 deg).Replaced the bail-out with an exact
std::fmod(x, 360.0)reduction. 360 isexactly representable and the remainder is exact, so the reduced argument is the
true one.
sindg(2e14)-> -0.3420201433256687+-infstill returns 0.0 withSF_ERROR_NO_RESULT, unchanged; NaN still NaNTest added to
tests/xsf_tests/test_trig.cpp: 12 argument pairs across all fourfunctions, both signs, asserting the reduced and unreduced arguments agree.
The scipy-side tests need this merged and the submodule bumped first, so I will
open that separately.
Note the thread is not settled on whether to fix this or document it. I went with
the reduction because it turns out to be exact, but happy to be told otherwise.
AI disclosure: an AI assistant helped investigate and draft this. I compiled the
headers standalone and ran the tests, and can explain the change.