10

I have a short program that performs a numerical computation, and obtains an incorrect NaN result when some specific conditions hold. I cannot see how this NaN result can arise. Note that I am not using compiler options that allow the reordering of arithmetic operations, such as -ffath-math.

Question: I am looking for an explanation of how the NaN result arises. Mathematically, there is nothing in the computation that leads to division by zero or similar. Am I missing something obvious?

Note that I am not asking how to fix the problem—that is easy. I am simply looking for an understanding of how the NaN appears.

Minimal example

Note that this example is very fragile and even minor modifications, such as adding printf() calls in the loop to observe values, will change the behaviour. This is why I was unable to minimize it further.

// prog.c

#include <stdio.h>
#include <math.h>

typedef long long myint;

void fun(const myint n, double *result) {
    double z = -1.0;
    double phi = 0.0;
    for (myint i = 0; i < n; i++) {
        double r = sqrt(1 - z*z);

        /* avoids division by zero when r == 0 */
        if (i != 0 && i != n-1) {
            phi += 1.0 / r;
        }

        double x = r*cos(phi);
        double y = r*sin(phi);

        result[i + n*0] = x;
        result[i + n*1] = y;
        result[i + n*2] = z;

        z += 2.0 / (n - 1);
    }
}

#define N 11

int main(void) {
    // perform computation
    double res[3*N];
    fun(N, res);

    // output result
    for (int i=0; i < N; i++) {
        printf("%g %g %g\n", res[i+N*0], res[i+N*1], res[i+N*2]);
    }

    return 0;
}

Compile with:

gcc -O3 -mfpmath=387 prog.c -o prog -lm

The last line of the output is:

nan nan 1

Instead of NaN, I expect a number close to zero.

Critical features of the example

The following must all hold for the NaN output to appear:

  • Compile with GCC on an x86 platform. I was able to reproduce with this GCC 12.2.0 (from MacPorts) on macOS 10.14.6, as well as with GCC versions 9.3.0, 8.3.0 and 7.5.0 on Linux (openSUSE Leap 15.3).

    I cannot reproduce it with GCC 10.2.0 or later on Linux, or GCC 11.3.0 on macOS.

  • Choose to use x87 instructions with -mfpmath=387, and an optimization level of -O2 or -O3.

  • myint must be a signed 64-bit type.

  • Thinking of result as an n-by-3 matrix, it must be stored in column-major order.

  • No printf() calls in the main loop of fun().

Without these features, I do get the expected output, i.e. something like 1.77993e-08 -1.12816e-08 1 or 0 0 1 as the last line.

Explanation of the program

Even though it doesn't really matter to the question, I give a short explanation of what the program does, to make it easier to follow. It computes x, y, z three-dimensional coordinates of n points on the surface of a sphere in a specific arrangement. z values go from -1 to 1 in equal increments, however, the last value won't be precisely 1 due to numerical round-off errors. The coordinates are written into an n-by-3 matrix, result, stored in column-major order. r and phi are polar coordinates in the (x, y) plane.

Note that when z is -1 or 1 then r becomes 0. This happens in the first and last iteration steps. This would lead to division by 0 in the 1.0 / r expression. However, 1.0 / r is excluded from the first and last iteration of the loop.

sideshowbarker
  • 81,827
  • 26
  • 193
  • 197
Szabolcs
  • 24,728
  • 9
  • 85
  • 174
  • 1
    Cannot reproduce. Did you try to debug this? – Jabberwocky Dec 08 '22 at 10:44
  • @Jabberwocky Not with a debugger, but I'm not sure that would help since the problem appears only with optimization. As I said, adding `printf`s to try to observe variable values makes the problem go away. I spent quite a lot of time trying to figure this out with no success, and also a lot of time obtaining this minimal example from something more complicated that depended on libraries. The example is extremely fragile, very minor modifications change the behaviour – Szabolcs Dec 08 '22 at 10:46
  • Cannot reproduce: https://www.godbolt.org/z/3h9hf44bx. What is your platform? – Jabberwocky Dec 08 '22 at 10:47
  • @Jabberwocky What platform are you on, what is your compiler (and its version) and did you use the compiler options I mentioned? I can reproduce the issue on two different systems (macOS and Linux) with several GCC versions. – Szabolcs Dec 08 '22 at 10:47
  • https://www.godbolt.org/z/3h9hf44bx contains all necessary information about the compiler version. Try to play around with it on https://www.godbolt.org/ – Jabberwocky Dec 08 '22 at 10:48
  • @Jabberwocky macOS 10.14 with GCC 12.2.0 from MacPorts and openSUSE Leap 15.3 with GCC 7.5.0 (as well as other versions). Let me try some more to see if I can make it reproduce with GodBolt. – Szabolcs Dec 08 '22 at 10:50
  • @Jabberwocky _What does "makes the problem go away" mean?_ <-- When I say "problem", I am referring to the last line of the output containing NaN values. I consider the behaviour correct (i.e. no problem) when instead of NaN I see zeroes (or numbers close to zero, e.g. `1e-8`) – Szabolcs Dec 08 '22 at 10:51
  • Updated the post with more precise information about what compiler versions I can reproduce it with. I understand that the way the computations are done are not ideal (e.g. it's better to compute `z` by linear rescaling instead of repeated additions). But I still want to understand what is happening here. I can't see why a NaN could appear. – Szabolcs Dec 08 '22 at 11:01
  • 1
    It could be a compiler bug, play around with it here: https://www.godbolt.org/z/56Eef1Ec8. Adding a printf makes to problem go away. (now I understand your comment) – Jabberwocky Dec 08 '22 at 11:02
  • 4
    I'm absolutely not sure about this but .... I recall some FPUs (e.g. Intel) internally used 80 bits for calculations. I wonder if the internal calculation of `z` produced a value "a little" greater than 1 and thereby causing `sqrt(1 - z*z);` producing a NaN. But again... this is just an idea - I'm not sure at all – Support Ukraine Dec 08 '22 at 11:05
  • @SupportUkraine Beat me by 11 seconds. If the argument to the `sqrt` call is even a teeny weeny bit negative, then you'll get a NaN for `r`. – Adrian Mole Dec 08 '22 at 11:06
  • I tried adding some tests if `z*z` is > 1.0 (https://www.godbolt.org/z/qjc6Wdna5) and the results are different if I add the test before or after `double r = sqrt(1 - z*z);`, but the test is never triggered. – Jabberwocky Dec 08 '22 at 11:12
  • FWIW: I kind of reproduced it using gcc (GCC) 8.5.0 20210514 (Red Hat 8.5.0-10). However, I didn't get the exact same result. Mine is `-nan -nan 1` Then I tried `printf("%g\n", sqrt(-0.000001));` and it printed `-nan`. It's not a proof, nor an explanation but it smells like `sqrt` of a negative number. – Support Ukraine Dec 08 '22 at 11:18
  • 1
    @Jabberwocky instead of `z * z > 1.0` use `isnan(r)` and it triggers. The problematic result of `1 - z*z` is `-1.11022e-16` https://www.godbolt.org/z/M349szcj8 . Fascinating. – Agent_L Dec 08 '22 at 11:50
  • @SupportUkraine You are right, it has to be that. I think this is worth posting as an answer. I feel a bit stupid now, in hindsight it's obvious. – Szabolcs Dec 08 '22 at 12:42

2 Answers2

11

This is caused by interplay of x87 80-bit internal precision, non-conforming behavior of GCC, and optimization decisions differing between compiler versions.

x87 supports IEEE binary32 and binary64 only as storage formats, converting to/from its 80-bit representation on loads/stores. To make program behavior predictable, the C standard requires that extra precision is dropped on assignments, and allows to check intermediate precision via the FLT_EVAL_METHOD macro. With -mfpmath=387, FLT_EVAL_METHOD is 2, so you know that intermediate precision corresponds to the long double type.

Unfortunately, GCC does not drop extra precision on assignments, unless you're requesting stricter conformance via -std=cNN (as opposed to -std=gnuNN), or explicitly passing -fexcess-precision=standard.

In your program, the z += 2.0 / (n - 1); statement should be computed by:

  1. Computing 2.0 / (n - 1) in the intermediate 80-bit precision.
  2. Adding to previous value of z (still in the 80-bit precision).
  3. Rounding to the declared type of z (i.e. to binary64).

In the version that ends up with NaNs, GCC instead does the following:

  1. Computes 2.0 / (n - 1) just once before the loop.
  2. Rounds this fraction from binary80 to binary64 and stores on stack.
  3. In the loop, it reloads this value from stack and adds to z.

This is non-conforming, because the 2.0 / (n - 1) undergoes rounding twice (first to binary80, then to binary64).


The above explains why you saw different results depending on compiler version and optimization level. However, in general you cannot expect your computation to not produce NaNs in the last iteration. When n - 1 is not a power of two, 2.0 / (n - 1) is not representable exactly and may be rounded up. In that case, 'z' may be growing a bit faster than the true sum -1.0 + 2.0 / (n - 1) * i, and may end up above 1.0 for i == n - 1, causing sqrt(1 - z*z) to produce a NaN due to a negative argument.

In fact, if you change #define N 11 to #define N 12 in your program, you will deterministically get a NaN both with 80-bit and 64-bit intermediate precision.

amonakov
  • 2,324
  • 11
  • 23
  • Great explanation. I confirm `-fexcess-precision=standard` works for me. – Jabberwocky Dec 08 '22 at 12:58
  • 1
    To complete the explanation, can you mention that `1 - z*z` may end up negative this way? – Szabolcs Dec 08 '22 at 13:42
  • 1
    @Szabolcs added, in general it may end up negative even without x87 subtleties for other values of N – amonakov Dec 09 '22 at 13:00
  • Huh, I didn't know `gcc -std=c11` implied `-fexcess-precision=standard` (e.g. `-ffloat-store` for x87). But https://godbolt.org/z/YrohGPvfn confirms it. (Except in GCC4.4 and earlier, where it keeps full x87 precision.) – Peter Cordes Dec 09 '22 at 14:38
  • 1
    @PeterCordes yes; note that unlike `-ffloat-store`, `-fexcess-precision=standard` also ensures that spills/fills of floating-point temporaries save/restore all 80 bits, without rounding to binary32/binary64. – amonakov Dec 09 '22 at 14:50
7

... how the NaN result arises (?)

Even though better adherence to the C spec may apparently solve OP's immediate problem, I assert other prevention practices should be considered.


sqrt(1 - z*z) is a candidate NaN when |z| > 1.0.

The index test prevention of division by zero may not be enough and then leading to cos(INFINITE), another NaN possibility.

// /* avoids division by zero when r == 0 */
//    if (i != 0 && i != n-1) {
//        phi += 1.0 / r;
//    }

To avoid these, 1) test directly and 2) use more a more precise approach.

if (r) {
  phi += 1.0 / r;
}

// double r = sqrt(1 - z*z);
double rr = (1-z)*(1+z);  // More precise than 1 - z*z
double r = rr < 0.0 ? 0.0 : sqrt(rr);
chux - Reinstate Monica
  • 143,097
  • 13
  • 135
  • 256
  • 2
    As long as we're talking numeric precision, what do you think about computing `z = 2.0 * i / (n - 1) - 1` at the top of the loop instead of incrementing `z` by a fixed amount on each iteration? It seems to me that the latter would tend to accumulate rounding error. – John Bollinger Dec 08 '22 at 16:01
  • 2
    @JohnBollinger Agree'd. We could go a step further: `zp1 = 2.0 * i / (n - 1); rr = (2-zp1)*(zp1)`. Various ways to improve precision by avoiding near `-` cancellations. – chux - Reinstate Monica Dec 08 '22 at 16:13
  • +1 for being explicit about the `1 - z*z < 0` issue. I disagree with some of the remaining advice though. The index test is the most robust way precisely because it does not rely on results subject to rounding error. It does not fail, and replacing it with`if (r)` makes the program less predictable because `r` _is_ subject to rounding errors. The same goes for improving the precision of the rest. If one is looking for guarantees that things won't go wrong, a simple improvement in precision is insufficient. – Szabolcs Dec 09 '22 at 12:45
  • All that said, I would like to know how you deduce that (1-z)(1+z) is more precise than 1-z^2 when z is smaller than 1. I believe the opposite to be the case. There is one more arithmetic operation involved with (1-z)(1+z). Suppose that both results of additions are rounded in the same direction. This can potentially introduce a higher error than in 1-z^2. Furthermore, when z is close to 1, you effectively have a more serious cancellation in 1-z than in 1-z^2. – Szabolcs Dec 09 '22 at 13:06
  • @Szabolcs: `if(r)` will definitely always avoid division by *zero*, but good point that you'd also want to avoid division by a very tiny almost-zero `r` value that will result in a huge value, or +-Inf if it was subnormal (or after rounding, if `r` had excess precision). Those aren't NaN, but they're perhaps worse. – Peter Cordes Dec 09 '22 at 14:48
  • @Szabolcs Try `int main() { float z = 1.0; for (int i = 0; i < 100; i++) { z = nextafterf(z, 0); double y0 = 1.0 - (1.0*z*z); float y1 = 1.0f - z*z; float y2 = (1.0f - z)*(1.0f + z); printf("%#.9g %#.9g %#.9g %#.9g\n", z, y0, y1-y0, y2-y0); } }`. `y2-y0` consistently same or smaller than `y1-y0`. It is because the subtraction of near values loses less info via `(1-z)(1+z)`. – chux - Reinstate Monica Dec 09 '22 at 15:04
  • @Szabolcs Point about index test. Perhaps `if (i != 0 && i != n-1 && r)`. – chux - Reinstate Monica Dec 09 '22 at 15:11
  • @Szabolcs Sometimes, about 1 in 10,000, `1.0f - z*z` is better, yet in those case `(1-z)(1+z)` is still within 1.0 `float` ULP of the higher precision `double` answer. – chux - Reinstate Monica Dec 09 '22 at 15:41