strtod edge of denormals glitch
Asked Answered
B

2

7

I have been looking at some code which had a weird optimisation fault and, in the process, stumbled upon a fault condition in strtod(), which has a different behaviour to strtof(), right on the edge of denormal values. The behaviour of strtof() seems entirely reasonable to me, but strtod() does not! Specifically, it returns -0.0 for the input value "-0x1.fffffffffffffp-1023".

That is one extra bit set to one in the representation "-0x1.ffffffffffffep-1023" which it decodes correctly. More peculiar still adding extra trailing digits gets a value 2-1018 which I cannot explain. It looks to me like the special edge case on the transition from denormals to normal floats has been handled incorrectly leading to a zero value.

Can anyone explain the other strange number that additional extra digits provoke?

The fault is identical on both MSC 2022 and Intel 2023

Sample code MRE for doubles & output (float works as you would expect)

// strtod() fails to handle edge case overflow from denormals correctly
// 
// Problem manifests on both MS 2022 and Intel 2023 compilers so by design? but why???
// 
// using Windows 11 and Microsoft Visual Studio Community 2022 (64-bit) - Version 17.1.0
 
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

void Show(const char *name, int err, double arg)
{
    unsigned iarg[2];
    memcpy(&iarg, &arg, sizeof arg);
    printf("\n\"%25s\" decoded errno=%i as 0x%08x%08x %.13a %30.22g",
           name, err, iarg[1], iarg[0], arg, arg);
}

void DecodeShow(const char *value)
{
    char *stopstr;
    double arg;
    errno = 0;
    arg = strtod(value, &stopstr);
    Show(value, errno, arg);
}

int main(void)
{
    printf("Test of doubles near denorm boundary\n");
    DecodeShow("-0x1.ffffffffffffp-1023");
    DecodeShow("-0x1.ffffffffffffep-1023");
    DecodeShow("-0x1.ffffffffffffe8p-1023");
    DecodeShow("-0x1.fffffffffffff0p-1023"); // hard fail == -0.0 !
    DecodeShow("-0x1.fffffffffffff8p-1023");
    DecodeShow("-0x1.ffffffffffffffp-1023");
    DecodeShow("-0x1.fffffffffffffffp-1023");
    printf("\n");
    DecodeShow("0x1.ffffffffffffe8p-1023");
    DecodeShow("0x1.fffffffffffff0p-1023"); // hard fail == +0.0
    DecodeShow("0x1.fffffffffffff8p-1023");
    DecodeShow("0x1.ffffffffffffffp-1023");
    printf("\n");
    DecodeShow("0x1.ffffffffffffep-1022");
    DecodeShow("-0x1.fffffffffffffp-1022");
    DecodeShow("-0x1.fffffffffffff8p-1022");
    DecodeShow("-0x1.ffffffffffffffp-1022");
}

Selected output around failure boundary:

" -0x1.ffffffffffffep-1023" decoded errno=0 as 0x800fffffffffffff -0x0.fffffffffffffp-1022  -2.225073858507200889025e-308
"-0x1.ffffffffffffe8p-1023" decoded errno=0 as 0x800fffffffffffff -0x0.fffffffffffffp-1022  -2.225073858507200889025e-308
"-0x1.fffffffffffff0p-1023" decoded errno=0 as 0x8000000000000000 -0x0.0000000000000p+0                             -0
"-0x1.fffffffffffff8p-1023" decoded errno=0 as 0x8040000000000000 -0x1.0000000000000p-1019  -1.780059086805761106472e-30

It is my view that extra trailing digits going beyond machine precision should not radically alter the floating point value that strtod decodes like this. I could not provoke the same failure by using decimal digit input strings - the transition across the boundary seemed well-behaved (although I can't rule out one spot value not working that I haven't yet found).

Blackbird answered 19/1 at 16:28 Comment(26)
Aside: #include <cstdint> suggests you are compiling this as C++.Hist
I am (I had meant to delete that include line before posting - it isn't needed now) although it is not using any C++ features so it is C code.Blackbird
You should also set errno = 0 before calling strtod() and include its value after the call. See C11 7.22.1.3p10: ""Brimmer
Re "it is not using any C++ features so it is C code.", That's not how things work. C++ is not a superset of CHierogram
"I had meant to delete that include line before posting": You can also edit your question after posting (make sure it does not alter the behavior of your code).Briton
@AndrewHenle I have done that in my local copy and in all cases errno is zero. strtod thinks it was successful.Blackbird
@Hierogram I merely deleted some whitespace and changed brackets to quotes so that more of the output numbers were visible in the default window. It looked a mess as originally printed with the exponents of the %a representation hanging off the screen. My intention was to minimise the amount of scrolling needed.Blackbird
@MartinBrown Please report errno in the output. There is much implementation defined behavior with wee values.Frager
@MartinBrown Posted code's final comments indicate 3 lines of output. I'd expect 15. Consider editing to make consistent.Frager
@chux you have my word that in all cases it returns errno == 0Blackbird
@MartinBrown You have my word for it that it is not always 0 on my machine. Posting code that allows us all to quickly gather relevant info helps as others may see variations with the implementation defined behavior of conversions near 0.0 and give insight to your issue.Frager
@chux I'm still not quite there yet with the protocol here. I just included the interesting case I found cosmetically edited to fit window width (it also fails exactly the same way for positive values). The program tests several more values either side to try and find any other edge cases. Which way should I edit it? - add the full output verbatim or reload the program and output to reflect the version I now have after adding errno?Blackbird
@MartinBrown If "also fails exactly the same way for positive values", consider simplifying by stating that and only exercise non-negative values. "cosmetically edited to fit window width" --> for me, the memory dump is noise, but others may like it. FP is sensitive to things like global FLT_EVAL_METHOD and FLT_ROUNDS, so seeing those is useful too. "%24.16g" better as "% 25.17g" as double deserves at least 17 digits. Printing out your DBL_MIN would help show the relationship to the strings being converted. So far, I was unable to re-create your problem outputs.Frager
@MartinBrown "%a" has various implementation defined aspects so using "%.13a" may force a rounded output as 53-bit may need 14 places after the . when a leading 0 is used. Better as %24a to not round to 13 places and risk rounding -might explain your woes.Frager
@chux %a is working OK it is input decoding via strtod that is misbehaving. The values you asked for FLT_EVAL_METHOD = -1, FLT_ROUNDS = 4.94066e-324, DBL_MIN = 2.2250738585072014e-308 (Intel compiler has FLT_ROUNDS=0 others same). I will hazard a guess that you are using GCC since I just tested it on there and all OK. GCC reports FLT_EVAL_METHOD=0. It looks specific to Intel/MS C/C++ library code.Blackbird
For contrast/reference, output from gcc-compiled programHierogram
Note that given input matching the form of a floating-point constant of type double, C requires that strtod() compute the same result at runtime (given default rounding) that the compiler produces from the lexical constant at translation time. That might be an interesting point to probe.Wrongheaded
@Hierogram it compiles here OK on Intel/MS/GCC as is. I just tried it on GCC 13.1.0 MinGW and got correct behaviour too although it only faulted the last two decimal tests with errno=34 (ERANGE) - aka "too large" which seems an odd error report for ultra small values. Yours faulted only the cases ending with f0 (ie. one extra adjacent bit set beyond what can be stored).Blackbird
ERANGE does not mean "too large". It means "out of range". The spec makes it implementation-defined whether strtod sets errno to ERANGE when the result underflows, so it certainly contemplates that that's something that some implementations will do. Given a strtod that always returns a normalized number as its result, it is reasonable to consider subnormal numbers not to be within its range.Wrongheaded
This ... "FLT_ROUNDS = 4.94066e-324" ... seems unlikely. FLT_ROUNDS normally evaluates to a small integer. I guess that might be the result of making printf misinterpret such an integer as a double.Wrongheaded
@johnbollinger Probing the two edge cases either side of the boundary at compile time was interesting. MSC fails in exactly the same way. But Intel gets the values right at compile time and can tolerate extra trailing f's. I should also add that compiling was /fp:precise (and same results with /fp:strict). Things get a whole lot worse on the Intel compiler with /fp:fast with several other values decoding to "-0.0" in addition so it gets none of these edge cases right at all. BTW FLT_ROUNDS = 1 sorry about that.Blackbird
@MartinBrown Review comment in code "-0x1.fffffffffffff8p-1023" decoded errno=0 as 0x8040000000000000 -0x1.0000000000000p-1019 -1.780059086805761106472e-30. Missing a final 8? Consider posting all 15 output, not just the select 4.Frager
@MartinBrown Curious, does arg = (double) strtold(value, &stopstr); give the expected results? (Note the l)?Frager
@chux long doubles are prohibited on the MS compiler (aliased to double) and although they are available on the Intel compiler I tried enabling them and could see x87 code in the debugger but execution generated a hard trap :( I'm not sure why - the load was on a 16 byte boundary. Intel manual warns it is a bit "here be dragons" using long doubles on their platform under Windows (and I'm inside the MS debugging environment which will be blissfully unaware of 80 bit long doubles). It also crashed same way on the command line :(Blackbird
@MartinBrown Love the Here be dragons.Frager
@Martin Brown Rick Regan of Exploring Binary used to track all kind of bugs in strtod() implementations and other conversions. They often lingered for many years before being fixed. You might want to check his site to see whether you hit a known issue or try contacting him directly.Neri
M
2

This is the same issue reported at https://developercommunity.visualstudio.com/t/strtod-parses-0x0fffffffffffff8p-1022-i/10293606 . A bug was filed for it in Feb 2023 (it appears it was fixed in the compiler previously, but not strtod()).

I ran the five strtod() examples in the report and these four give incorrect conversions (includes your 0 example). Each should result in 0x1.0000000000000p-1022 but

0x0.fffffffffffff8p-1022 => 0x1.0000000000000p-1020
0x1.fffffffffffffp-1023 => 0x0.0000000000000p+0
0x7.ffffffffffffcp-1025 => 0x1.0000000000000p-1021
0xf.ffffffffffff8p-1026 => 0x1.0000000000000p-1020

Note the incorrect exponents. There was no speculation in the report about what went wrong.

Milreis answered 24/1 at 15:16 Comment(3)
Thanks for taking the time to confirm the fault and find the previous bug report about it. Saves me generating a Repro. Thanks again!Blackbird
I wrote up an article about this (exploringbinary.com/…) to add to my "catalog" (thanks @njuffa).Milreis
Thanks for the mention! Shame I wasn't the first to report it. BTW the exact same fault is also present in the C/C++ system library strtod() for the Intel 2023 compiler (although not for compile time constants - it gets those right). Do they perhaps have a common origin? GCC13 gets them all right.Blackbird
W
2

The behaviour of [...] strtod() does not [seem reasonable]! Specifically, it returns -0.0 for the input value "-0x1.fffffffffffffp-1023".

I disagree that that specific result is unreasonable, but I'm inclined to agree that the combination of results you present does not seem reasonable.

"-0x1.fffffffffffffp-1023" should not be converted to a value whose magnitude is smaller than that of the value to which "-0x1.ffffffffffffep-1023" is converted. However, given that the former is not exactly representable in IEEE-754 binary64 format whereas the latter is (as a subnormal), it would be reasonable for both to be converted to the same result. And it would be reasonable -- and certainly not contrary to the language spec -- for that result to be -0.0. That's one of the two normalized doubles bracketing the exact arithmetic results. -0.0 is exactly what I would expect, in fact, of an implementation that rounds to a normalized number when the rounding mode is FE_TOWARDZERO.

Your result for "-0x1.fffffffffffff8p-1023" is much more concerning, however. The spec calls for the result to be correctly rounded, and I don't see any circumstances under which the result you report could satisfy that requirement.

Can anyone explain the other strange number that additional extra digits provoke?

In a sense: strtod() converting "-0x1.fffffffffffff8p-1023" to -0x1.0000000000000p-1019 is a manifestation of a bug in your C implementation. Probably the same for the -0.0 result for "-0x1.fffffffffffffp-1023". I have some vague ideas about what the implementation flaw might be, but it doesn't really matter, and I choose not to speculate in this answer.

Wrongheaded answered 19/1 at 20:17 Comment(1)
Thanks for your insight. This sounds a plausible explanation. Rounding mode default (unchanged) was actually FE_TONEAREST = 0 (Intel & MS). I was struck by the fact that strtof() did exactly what I expected to see at the corresponding boundary whereas strtod() doesn't. I wonder now if it is a length parity thing affecting rounding with 23 bits for a float mantissa vs 52 bits for a double.Blackbird
M
2

This is the same issue reported at https://developercommunity.visualstudio.com/t/strtod-parses-0x0fffffffffffff8p-1022-i/10293606 . A bug was filed for it in Feb 2023 (it appears it was fixed in the compiler previously, but not strtod()).

I ran the five strtod() examples in the report and these four give incorrect conversions (includes your 0 example). Each should result in 0x1.0000000000000p-1022 but

0x0.fffffffffffff8p-1022 => 0x1.0000000000000p-1020
0x1.fffffffffffffp-1023 => 0x0.0000000000000p+0
0x7.ffffffffffffcp-1025 => 0x1.0000000000000p-1021
0xf.ffffffffffff8p-1026 => 0x1.0000000000000p-1020

Note the incorrect exponents. There was no speculation in the report about what went wrong.

Milreis answered 24/1 at 15:16 Comment(3)
Thanks for taking the time to confirm the fault and find the previous bug report about it. Saves me generating a Repro. Thanks again!Blackbird
I wrote up an article about this (exploringbinary.com/…) to add to my "catalog" (thanks @njuffa).Milreis
Thanks for the mention! Shame I wasn't the first to report it. BTW the exact same fault is also present in the C/C++ system library strtod() for the Intel 2023 compiler (although not for compile time constants - it gets those right). Do they perhaps have a common origin? GCC13 gets them all right.Blackbird

© 2022 - 2024 — McMap. All rights reserved.