Why is a round-trip conversion via a string not safe for a double?
Asked Answered
P

3

196

Recently I have had to serialize a double into text, and then get it back. The value seems to not be equivalent:

double d1 = 0.84551240822557006;
string s = d1.ToString("R");
double d2 = double.Parse(s);
bool s1 = d1 == d2;
// -> s1 is False

But according to MSDN: Standard Numeric Format Strings, the "R" option is supposed to guarantee round-trip safety.

The round-trip ("R") format specifier is used to ensure that a numeric value that is converted to a string will be parsed back into the same numeric value

Why did this happen?

Pneumoconiosis answered 19/6, 2014 at 5:58 Comment(16)
I debugged in my VS and its returning true hereFrier
I've reproduced it returning false. Very interesting question.Calamitous
Works fine on mine too, s1 is trueRe
@RaphaëlAlthaus untrue. It will use 17 digits if required, and it does that here. For me, the result of ToString() is the exact same as the literal in the code.Underline
Running against .net 4.0 here (en-US). Test case passes. I am wondering if its a bug in 4.5? Anyone?Angelita
@CoryNelson you're right, didn't read enough...Antibiotic
it's true for me as well. will it be because of different culture ?Klink
.net 4.0 x86 - true, .net 4.0 x64 - falseSpile
Congratulations on finding such an impressive bug in .net.Angelita
The bug is not .NET, the bug is to compare two floating point numbers - check this answer: #10335188Scoundrelly
@Scoundrelly Round trip is specifically meant to avoid floating point inconsistenciesEdlyn
Microsoft should now exhaustively test the round-trip format by running this test for all possible 32 bit float numbers. double is too big to be tested exhaustively.Batsheva
@usr: I think it's possible to identify the problematic scenarios, specifically those where a 15-digit representation would fall very nearly halfway between two adjacent double values. The proper behavior for a "good" round-trip method would be to use 15 digits if the resulting value is within 1/4LSB of the supplied double, or 16 if that would yield a value within 1/4LSB of the supplied double, or 17 in the cases that the value from 16 wasn't within 1/4 LSB. Since any 17-digit value will be within 1/8 lsb, 17 digits will always be sufficient. Alternatively, code could use...Nunnally
...the first decimal digit to select the required precision (I think 17 digits are only needed if it's a "1"; and 16 digits will suffice if 3 or less)Nunnally
Bug reported to Microsoft at github.com/dotnet/coreclr/issues/3313Tweeze
The documentation for the roundtrip format now states that it is not reliable for x64 and you should implement it yourself which unfortunately is not possible since you can't override what format is used in for example XmlConvert. msdn.microsoft.com/en-us/library/…Stalwart
I
182

I found the bug.

.NET does the following in clr\src\vm\comnumber.cpp:

DoubleToNumber(value, DOUBLE_PRECISION, &number);

if (number.scale == (int) SCALE_NAN) {
    gc.refRetVal = gc.numfmt->sNaN;
    goto lExit;
}

if (number.scale == SCALE_INF) {
    gc.refRetVal = (number.sign? gc.numfmt->sNegativeInfinity: gc.numfmt->sPositiveInfinity);
    goto lExit;
}

NumberToDouble(&number, &dTest);

if (dTest == value) {
    gc.refRetVal = NumberToString(&number, 'G', DOUBLE_PRECISION, gc.numfmt);
    goto lExit;
}

DoubleToNumber(value, 17, &number);

DoubleToNumber is pretty simple -- it just calls _ecvt, which is in the C runtime:

void DoubleToNumber(double value, int precision, NUMBER* number)
{
    WRAPPER_CONTRACT
    _ASSERTE(number != NULL);

    number->precision = precision;
    if (((FPDOUBLE*)&value)->exp == 0x7FF) {
        number->scale = (((FPDOUBLE*)&value)->mantLo || ((FPDOUBLE*)&value)->mantHi) ? SCALE_NAN: SCALE_INF;
        number->sign = ((FPDOUBLE*)&value)->sign;
        number->digits[0] = 0;
    }
    else {
        char* src = _ecvt(value, precision, &number->scale, &number->sign);
        wchar* dst = number->digits;
        if (*src != '0') {
            while (*src) *dst++ = *src++;
        }
        *dst = 0;
    }
}

It turns out that _ecvt returns the string 845512408225570.

Notice the trailing zero? It turns out that makes all the difference!
When the zero is present, the result actually parses back to 0.84551240822557006, which is your original number -- so it compares equal, and hence only 15 digits are returned.

However, if I truncate the string at that zero to 84551240822557, then I get back 0.84551240822556994, which is not your original number, and hence it would return 17 digits.

Proof: run the following 64-bit code (most of which I extracted from the Microsoft Shared Source CLI 2.0) in your debugger and examine v at the end of main:

#include <stdlib.h>
#include <string.h>
#include <math.h>

#define min(a, b) (((a) < (b)) ? (a) : (b))

struct NUMBER {
    int precision;
    int scale;
    int sign;
    wchar_t digits[20 + 1];
    NUMBER() : precision(0), scale(0), sign(0) {}
};


#define I64(x) x##LL
static const unsigned long long rgval64Power10[] = {
    // powers of 10
    /*1*/ I64(0xa000000000000000),
    /*2*/ I64(0xc800000000000000),
    /*3*/ I64(0xfa00000000000000),
    /*4*/ I64(0x9c40000000000000),
    /*5*/ I64(0xc350000000000000),
    /*6*/ I64(0xf424000000000000),
    /*7*/ I64(0x9896800000000000),
    /*8*/ I64(0xbebc200000000000),
    /*9*/ I64(0xee6b280000000000),
    /*10*/ I64(0x9502f90000000000),
    /*11*/ I64(0xba43b74000000000),
    /*12*/ I64(0xe8d4a51000000000),
    /*13*/ I64(0x9184e72a00000000),
    /*14*/ I64(0xb5e620f480000000),
    /*15*/ I64(0xe35fa931a0000000),

    // powers of 0.1
    /*1*/ I64(0xcccccccccccccccd),
    /*2*/ I64(0xa3d70a3d70a3d70b),
    /*3*/ I64(0x83126e978d4fdf3c),
    /*4*/ I64(0xd1b71758e219652e),
    /*5*/ I64(0xa7c5ac471b478425),
    /*6*/ I64(0x8637bd05af6c69b7),
    /*7*/ I64(0xd6bf94d5e57a42be),
    /*8*/ I64(0xabcc77118461ceff),
    /*9*/ I64(0x89705f4136b4a599),
    /*10*/ I64(0xdbe6fecebdedd5c2),
    /*11*/ I64(0xafebff0bcb24ab02),
    /*12*/ I64(0x8cbccc096f5088cf),
    /*13*/ I64(0xe12e13424bb40e18),
    /*14*/ I64(0xb424dc35095cd813),
    /*15*/ I64(0x901d7cf73ab0acdc),
};

static const signed char rgexp64Power10[] = {
    // exponents for both powers of 10 and 0.1
    /*1*/ 4,
    /*2*/ 7,
    /*3*/ 10,
    /*4*/ 14,
    /*5*/ 17,
    /*6*/ 20,
    /*7*/ 24,
    /*8*/ 27,
    /*9*/ 30,
    /*10*/ 34,
    /*11*/ 37,
    /*12*/ 40,
    /*13*/ 44,
    /*14*/ 47,
    /*15*/ 50,
};

static const unsigned long long rgval64Power10By16[] = {
    // powers of 10^16
    /*1*/ I64(0x8e1bc9bf04000000),
    /*2*/ I64(0x9dc5ada82b70b59e),
    /*3*/ I64(0xaf298d050e4395d6),
    /*4*/ I64(0xc2781f49ffcfa6d4),
    /*5*/ I64(0xd7e77a8f87daf7fa),
    /*6*/ I64(0xefb3ab16c59b14a0),
    /*7*/ I64(0x850fadc09923329c),
    /*8*/ I64(0x93ba47c980e98cde),
    /*9*/ I64(0xa402b9c5a8d3a6e6),
    /*10*/ I64(0xb616a12b7fe617a8),
    /*11*/ I64(0xca28a291859bbf90),
    /*12*/ I64(0xe070f78d39275566),
    /*13*/ I64(0xf92e0c3537826140),
    /*14*/ I64(0x8a5296ffe33cc92c),
    /*15*/ I64(0x9991a6f3d6bf1762),
    /*16*/ I64(0xaa7eebfb9df9de8a),
    /*17*/ I64(0xbd49d14aa79dbc7e),
    /*18*/ I64(0xd226fc195c6a2f88),
    /*19*/ I64(0xe950df20247c83f8),
    /*20*/ I64(0x81842f29f2cce373),
    /*21*/ I64(0x8fcac257558ee4e2),

    // powers of 0.1^16
    /*1*/ I64(0xe69594bec44de160),
    /*2*/ I64(0xcfb11ead453994c3),
    /*3*/ I64(0xbb127c53b17ec165),
    /*4*/ I64(0xa87fea27a539e9b3),
    /*5*/ I64(0x97c560ba6b0919b5),
    /*6*/ I64(0x88b402f7fd7553ab),
    /*7*/ I64(0xf64335bcf065d3a0),
    /*8*/ I64(0xddd0467c64bce4c4),
    /*9*/ I64(0xc7caba6e7c5382ed),
    /*10*/ I64(0xb3f4e093db73a0b7),
    /*11*/ I64(0xa21727db38cb0053),
    /*12*/ I64(0x91ff83775423cc29),
    /*13*/ I64(0x8380dea93da4bc82),
    /*14*/ I64(0xece53cec4a314f00),
    /*15*/ I64(0xd5605fcdcf32e217),
    /*16*/ I64(0xc0314325637a1978),
    /*17*/ I64(0xad1c8eab5ee43ba2),
    /*18*/ I64(0x9becce62836ac5b0),
    /*19*/ I64(0x8c71dcd9ba0b495c),
    /*20*/ I64(0xfd00b89747823938),
    /*21*/ I64(0xe3e27a444d8d991a),
};

static const signed short rgexp64Power10By16[] = {
    // exponents for both powers of 10^16 and 0.1^16
    /*1*/ 54,
    /*2*/ 107,
    /*3*/ 160,
    /*4*/ 213,
    /*5*/ 266,
    /*6*/ 319,
    /*7*/ 373,
    /*8*/ 426,
    /*9*/ 479,
    /*10*/ 532,
    /*11*/ 585,
    /*12*/ 638,
    /*13*/ 691,
    /*14*/ 745,
    /*15*/ 798,
    /*16*/ 851,
    /*17*/ 904,
    /*18*/ 957,
    /*19*/ 1010,
    /*20*/ 1064,
    /*21*/ 1117,
};

static unsigned DigitsToInt(wchar_t* p, int count)
{
    wchar_t* end = p + count;
    unsigned res = *p - '0';
    for ( p = p + 1; p < end; p++) {
        res = 10 * res + *p - '0';
    }
    return res;
}
#define Mul32x32To64(a, b) ((unsigned long long)((unsigned long)(a)) * (unsigned long long)((unsigned long)(b)))

static unsigned long long Mul64Lossy(unsigned long long a, unsigned long long b, int* pexp)
{
    // it's ok to losse some precision here - Mul64 will be called
    // at most twice during the conversion, so the error won't propagate
    // to any of the 53 significant bits of the result
    unsigned long long val = Mul32x32To64(a >> 32, b >> 32) +
        (Mul32x32To64(a >> 32, b) >> 32) +
        (Mul32x32To64(a, b >> 32) >> 32);

    // normalize
    if ((val & I64(0x8000000000000000)) == 0) { val <<= 1; *pexp -= 1; }

    return val;
}

void NumberToDouble(NUMBER* number, double* value)
{
    unsigned long long val;
    int exp;
    wchar_t* src = number->digits;
    int remaining;
    int total;
    int count;
    int scale;
    int absscale;
    int index;

    total = (int)wcslen(src);
    remaining = total;

    // skip the leading zeros
    while (*src == '0') {
        remaining--;
        src++;
    }

    if (remaining == 0) {
        *value = 0;
        goto done;
    }

    count = min(remaining, 9);
    remaining -= count;
    val = DigitsToInt(src, count);

    if (remaining > 0) {
        count = min(remaining, 9);
        remaining -= count;

        // get the denormalized power of 10
        unsigned long mult = (unsigned long)(rgval64Power10[count-1] >> (64 - rgexp64Power10[count-1]));
        val = Mul32x32To64(val, mult) + DigitsToInt(src+9, count);
    }

    scale = number->scale - (total - remaining);
    absscale = abs(scale);
    if (absscale >= 22 * 16) {
        // overflow / underflow
        *(unsigned long long*)value = (scale > 0) ? I64(0x7FF0000000000000) : 0;
        goto done;
    }

    exp = 64;

    // normalize the mantisa
    if ((val & I64(0xFFFFFFFF00000000)) == 0) { val <<= 32; exp -= 32; }
    if ((val & I64(0xFFFF000000000000)) == 0) { val <<= 16; exp -= 16; }
    if ((val & I64(0xFF00000000000000)) == 0) { val <<= 8; exp -= 8; }
    if ((val & I64(0xF000000000000000)) == 0) { val <<= 4; exp -= 4; }
    if ((val & I64(0xC000000000000000)) == 0) { val <<= 2; exp -= 2; }
    if ((val & I64(0x8000000000000000)) == 0) { val <<= 1; exp -= 1; }

    index = absscale & 15;
    if (index) {
        int multexp = rgexp64Power10[index-1];
        // the exponents are shared between the inverted and regular table
        exp += (scale < 0) ? (-multexp + 1) : multexp;

        unsigned long long multval = rgval64Power10[index + ((scale < 0) ? 15 : 0) - 1];
        val = Mul64Lossy(val, multval, &exp);
    }

    index = absscale >> 4;
    if (index) {
        int multexp = rgexp64Power10By16[index-1];
        // the exponents are shared between the inverted and regular table
        exp += (scale < 0) ? (-multexp + 1) : multexp;

        unsigned long long multval = rgval64Power10By16[index + ((scale < 0) ? 21 : 0) - 1];
        val = Mul64Lossy(val, multval, &exp);
    }

    // round & scale down
    if ((unsigned long)val & (1 << 10))
    {
        // IEEE round to even
        unsigned long long tmp = val + ((1 << 10) - 1) + (((unsigned long)val >> 11) & 1);
        if (tmp < val) {
            // overflow
            tmp = (tmp >> 1) | I64(0x8000000000000000);
            exp += 1;
        }
        val = tmp;
    }
    val >>= 11;

    exp += 0x3FE;

    if (exp <= 0) {
        if (exp <= -52) {
            // underflow
            val = 0;
        }
        else {
            // denormalized
            val >>= (-exp+1);
        }
    }
    else
        if (exp >= 0x7FF) {
            // overflow
            val = I64(0x7FF0000000000000);
        }
        else {
            val = ((unsigned long long)exp << 52) + (val & I64(0x000FFFFFFFFFFFFF));
        }

        *(unsigned long long*)value = val;

done:
        if (number->sign) *(unsigned long long*)value |= I64(0x8000000000000000);
}

int main()
{
    NUMBER number;
    number.precision = 15;
    double v = 0.84551240822557006;
    char *src = _ecvt(v, number.precision, &number.scale, &number.sign);
    int truncate = 0;  // change to 1 if you want to truncate
    if (truncate)
    {
        while (*src && src[strlen(src) - 1] == '0')
        {
            src[strlen(src) - 1] = 0;
        }
    }
    wchar_t* dst = number.digits;
    if (*src != '0') {
        while (*src) *dst++ = *src++;
    }
    *dst++ = 0;
    NumberToDouble(&number, &v);
    return 0;
}
Intrinsic answered 19/6, 2014 at 6:50 Comment(32)
Good explanation +1. This code is from shared-source-cli-2.0 right? This is the only think I found.Jiggered
So why does this only happen for 64-bit builds? Does the code for the 32-bit CLR not trim off trailing zeros?Kindergarten
@CodyGray: I'm not sure yet. This portion of the code behaves the same for 32-bit and 64-bit builds; however, I suspect that what happens is that the G format specifier (which is used when the equality holds) doesn't behave the same for 32-bit and 64-bit code. IMO the equality shouldn't hold in the first place, so that's why I think this is the real bug, not the code for the G specifier (though it may very well be that something there masks the problem in 32-bit). But I could be wrong. Unfortunately getting the rest of the code to run needs more effort so I haven't gotten around to it yet.Intrinsic
I must say that is rather pathetic. Strings that are mathematically equal (like one with a trailing zero, or let's say 2.1e-1 vs. 0.21) should always give identical results, and strings that are mathematically ordered should give results consistent with the ordering.Votyak
I wouldn't expect something like 2.1E-1 to be the same as 0.21 just like that. Unlike the trailing zero.Haland
@MrLister: Why shouldn't "2.1E-1 be the same as 0.21 just like that"?Intrinsic
@gnasher729: I'd somewhat agree on "2.1e-1" and "0.21"...but a string with a trailing zero is not exactly equal to one without -- in the former, the zero is a significant digit and adds precision.Sussi
@cHao: Er... it adds precision, but that only affects how you decide to round the final answer if sigfigs matter to you, not how the computer should compute the final answer in the first place. The computer's job is to compute everything at the highest precision regardless of the actual measurement precisions of the numbers; it's the programmer's problem if he wants to round the final result.Intrinsic
@cHao: The double you're converting into doesn't have a notion of significant digits, so it would be nonsense to give meaning to those trailing zeros.Labaw
argument about trailing zero adding precision could also be used to justify "1.0" to be parsed to something farther from 1 than "1.00"Slovak
@Mehrdad Because it might or might not depend on how the parsing is done, even when no bugs exist in the parsing routine. The 1 in 2.1E-1 could get calculated as 1/10 first, and then divided by 10 through the exponent, while the 1 in .21 could be calculated in one step as 1/100. In other words, different round-off errors; I wouldn't trust those kinds of things to be equal under all circumstances.Haland
@MrLister: Are you saying they shouldn't be equal, or are you merely stating the fact that they aren't equal in practice, even though they should be? Your statement is ambiguous.Intrinsic
@Mehrdad Is it? Sorry, why do you think I said they shouldn't be equal?Haland
@MrLister: Because you said "I wouldn't trust those to kinds of things to be equal under all circumstances", which could either mean that you're saying (1) you expect there to be bugs in the implementations, or that you're saying (2) you expect them to have different meanings inherently, and thus they shouldn't necessarily be equal.Intrinsic
@Mehrdad No, sorry, that wasn't what I said at all. There were more sentences to my comment than just the final 10 words.Haland
@MrLister: Well, I can't imagine anything other than a bug (or feature?) in an implementation giving a different result for those two strings. You just stated an example of why they might be treated differently in the midst of a calculation, but it says nothing about whether they should be treated differently as far as the final result is concerned, hence why I'm confused.Intrinsic
Part of the problem is that computers think in binary rather than decimal, and in binary the numbers 1/10 and 1/100 are like the number 1/3 in decimal. So there is always a bit of rounding error when storing numbers like 2.1 on a computer. With this in mind, you can see how the order in which 2.1E-1 is parsed can influence the number being stored. If exact equality is important to you than it is typically safer to work with integers, i.e. multiply the number 0.21 with 100 to get 21. This can work if there is a known number of digits behind the decimal point, e.g. dollars and cents.Madeira
@Mehrdad: The string in question, with or without the "0", is 0.498lsb away from the nearest representable value. While it may be reasonable to specify that a parsing routine should always return the nearest representable double, I would suggest that any double which is round-tripped should be specified as a string whose numerical value would be within a quarter-LSB of the desired value, rather than relying upon a parsing routine to be accurate to 0.002lsb.Nunnally
@Mehrdad: Another way of looking at is that the string represents a number which is is 500 times further away from the double that it's supposed to represent than from the number string which should be represented by a different double. I would suggest round-trip formats should use values which are closer to the double they represent than to any value that might represent something else.Nunnally
@MaartenBuis: Of course I can see how a parsing algorithm could do it the way you mention. The point is, it shouldn't -- i.e., what you're describing is a bug, not a feature. A correct parsing algorithm would use integers, and a correct parsing algorithm would not (and should not) lead to a different result for those two strings.Intrinsic
@supercat: I'm not sure why you're directing your suggestions at me, I didn't write the code =P you should tell someone at Microsoft so they can actually implement your suggestion!Intrinsic
@Mehrdad: I guess my point is that whoever wrote the parser could perfectly legitimately blame the person who wrote the number to be parsed, so I don't know that I'd call the parser buggy unless it's specified to round perfectly in absolutely all cases, which I really wouldn't expect it to.Nunnally
@supercat: Wait, so you're saying whoever wrote the parser can legitimately blame the user for expecting "round-trip" behavior to be, in fact, round-trip behavior? I think that's kinda ridiculous. If they can't support such a thing as "round-trip" conversion, then they must not advertise it as such. It's not like they had to have such a mode available for the user.Intrinsic
@Mehrdad: I'm saying the person who wrote the parser can blame the person who wrote the ToString method, and the person who wrote the ToString method can blame whoever wrote the parser. Personally I put more blame on the ToString method, since a proper round-trip format should work properly with an even-remotely-decent parser.Nunnally
@supercat: Eh... I honestly don't think it makes any difference whether the blame goes to the parser author or the stringification author. They both should've collaborated when ToString("R")'s documentation explicitly says the string is parsed when performing a round-trip conversion. As far as I'm concerned, they're both responsible, and in any case I don't see why it matter who gets the blame.Intrinsic
@Mehrdad: Since R format doesn't care whether the numerical value that gets written is within 0.49lsb of the value it's supposed to represent, provided only that a potentially-dodgy parser reports the correct value, I would not regard it as at all implausible that there exist some values where ToString goes with a 15-digit representation that's off by more than half an LSB. If that is in fact the case, fixing the parser to always round correctly would cause existing stored strings that were round-tripped by an earlier version of the parser to change their values.Nunnally
Ignoring all of the 20+ comments about who to blame for the bug... my favorite SO questions and answers start with the phrase I found a [the] bug. Just makes my day when I see things like this!Fortier
@Mehrdad: Have you written a bug report? If so, can you post a link? Thanks.Iong
You do realize that your answer outscored Jon's answer?Frobisher
@Mehrdad Thanks for the demonstration, it is fantastic! I have wrote a bug report, and waiting for their feedback.Pneumoconiosis
@Mehrdad I have confirmed that current implementation of NumberToDouble in x64 has such problem, x86 is not because x86 is implemented by aseembly which has different behavior from x64 library.Pneumoconiosis
The documentation says «For Double values, the "R" format specifier in some cases fails to successfully round-trip the original value. For both Double and Single values, it also offers relatively poor performance. Instead, we recommend that you use the "G17" format specifier for Double values and the "G9" format specifier to successfully round-trip Single values.» I wonder if, at some point, MS explicitly decided not to fix the bug in "R" and just add a note in the docs instead.Polyadelphous
C
108

It seems to me that this is simply a bug. Your expectations are entirely reasonable. I've reproduced it using .NET 4.5.1 (x64), running the following console app which uses my DoubleConverter class.DoubleConverter.ToExactString shows the exact value represented by a double:

using System;

class Test
{
    static void Main()
    {
        double d1 = 0.84551240822557006;
        string s = d1.ToString("r");
        double d2 = double.Parse(s);
        Console.WriteLine(s);
        Console.WriteLine(DoubleConverter.ToExactString(d1));
        Console.WriteLine(DoubleConverter.ToExactString(d2));
        Console.WriteLine(d1 == d2);
    }
}

Results in .NET:

0.84551240822557
0.845512408225570055719799711368978023529052734375
0.84551240822556994469749724885332398116588592529296875
False

Results in Mono 3.3.0:

0.84551240822557006
0.845512408225570055719799711368978023529052734375
0.845512408225570055719799711368978023529052734375
True

If you manually specify the string from Mono (which contains the "006" on the end), .NET will parse that back to the original value. To it looks like the problem is in the ToString("R") handling rather than the parsing.

As noted in other comments, it looks like this is specific to running under the x64 CLR. If you compile and run the above code targeting x86, it's fine:

csc /platform:x86 Test.cs DoubleConverter.cs

... you get the same results as with Mono. It would be interesting to know whether the bug shows up under RyuJIT - I don't have that installed at the moment myself. In particular, I can imagine this possibly being a JIT bug, or it's quite possible that there are whole different implementations of the internals of double.ToString based on architecture.

I suggest you file a bug at http://connect.microsoft.com

Calamitous answered 19/6, 2014 at 6:9 Comment(7)
So Jon? To confirm, is this a bug in the JITer, inlining the ToString()? As I tried replacing the hard coded value with rand.NextDouble() and there was no issue.Angelita
Yeah, it's definitely in the ToString("R") conversion. Try ToString("G32") and notice it prints the correct value.Intrinsic
@Aron: I can't tell whether it's a bug in the JITter or in an x64-specific implementation of the BCL. I very much doubt that it's as simple as inlining though. Testing with random values doesn't really help much, IMO... I'm not sure what you expect that to demonstrate.Calamitous
What's happening I think is that the "round trip" format is outputting a value which is 0.498ulp bigger than it should be, and parsing logic sometimes erroneously rounds it up that last tiny fraction of an ulp. I'm not sure which code I blame more, since I would think a "round-trip" format should output a numerical value which is within a quarter-ULP of being numerically correct; parsing logic which yields a value within 0.75ulp of what's specified is much easier than logic which must yield a result within 0.502ulp of what's specified.Nunnally
Jon Skeet's website is down? I find that so unlikely I'm... losing all faith here.Commutual
@JonSkeet Can you share us the DoubleConverter? How do you guarantee the "ExactString"?Pneumoconiosis
@PhilipDing: I've edited the answer to link to the file, now that the site is up again. I guarantee the value of ExactString by creating it myself from the raw bits of the double value :)Calamitous
O
3

Recently, I'm trying to resolve this issue. As pointed out through the code , the double.ToString("R") has following logic:

  1. Try to convert the double to string in precision of 15.
  2. Convert the string back to double and compare to the original double. If they are the same, we return the converted string whose precision is 15.
  3. Otherwise, convert the double to string in precision of 17.

In this case, double.ToString("R") wrongly chose the result in precision of 15 so the bug happens. There's an official workaround in the MSDN doc:

In some cases, Double values formatted with the "R" standard numeric format string do not successfully round-trip if compiled using the /platform:x64 or /platform:anycpu switches and run on 64-bit systems. To work around this problem, you can format Double values by using the "G17" standard numeric format string. The following example uses the "R" format string with a Double value that does not round-trip successfully, and also uses the "G17" format string to successfully round-trip the original value.

So unless this issue being resolved, you have to use double.ToString("G17") for round-tripping.

Update: Now there's a specific issue to track this bug.

Omnivorous answered 18/7, 2017 at 2:24 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.