Perché una conversione di andata e ritorno tramite una stringa non è sicura per una doppia?


185

Di recente ho dovuto serializzare un doppio nel testo e poi ripristinarlo. Il valore sembra non essere equivalente:

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

Ma secondo MSDN: Stringhe di formato numerico standard , l'opzione "R" dovrebbe garantire la sicurezza di andata e ritorno.

L'identificatore di formato round-trip ("R") viene utilizzato per garantire che un valore numerico che viene convertito in una stringa verrà analizzato nuovamente nello stesso valore numerico

Perché è successo?


6
Ho fatto il debug nel mio VS e sta tornando vero qui
Neel

19
L'ho riprodotto restituendo falso. Domanda molto interessante.
Jon Skeet,

40
.net 4.0 x86 - true, .net 4.0 x64 - false
Ulugbek Umirov

25
Congratulazioni per aver trovato un bug così impressionante in .net.
Aron,

14
@Casperah Round trip ha lo scopo specifico di evitare incoerenze in virgola mobile
Gusdor

Risposte:


178

Ho trovato il bug.

.NET esegue le seguenti operazioni 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è piuttosto semplice - chiama solo _ecvt, che è nel runtime C:

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;
    }
}

Si scopre che _ecvtrestituisce la stringa 845512408225570.

Notare lo zero finale? Si scopre che fa la differenza!
Quando lo zero è presente, il risultato in realtà ritorna a0.84551240822557006, che è il tuonumero originale , quindi confronta lo stesso, e quindi vengono restituite solo 15 cifre.

Tuttavia, se tronco la stringa a quello zero a 84551240822557, allora torno indietro 0.84551240822556994, che non è il tuo numero originale, e quindi restituirebbe 17 cifre.

Prova: esegui il seguente codice a 64 bit (la maggior parte dei quali ho estratto dalla CLI 2.0 di Microsoft Shared Source) nel tuo debugger ed esamina valla fine di 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;
}

4
Buona spiegazione +1. Questo codice proviene da shared-source-cli-2.0 giusto? Questa è l'unica cosa che ho trovato.
Soner Gönül,

10
Devo dire che è piuttosto patetico. Le stringhe che sono matematicamente uguali (come una con uno zero finale, o diciamo 2.1e-1 contro 0.21) dovrebbero sempre dare risultati identici e le stringhe che sono matematicamente ordinate dovrebbero dare risultati coerenti con l'ordinamento.
gnasher729,

4
@MrLister: Perché "2.1E-1 non dovrebbe essere uguale a 0.21 come quello"?
user541686

9
@ gnasher729: concordo in qualche modo su "2.1e-1" e "0.21" ... ma una stringa con zero finale non è esattamente uguale a una senza - nel primo, lo zero è una cifra significativa e aggiunge precisione.
cHao,

4
@cHao: Ehm ... aggiunge precisione, ma ciò influisce solo sul modo in cui decidi di arrotondare la risposta finale se i sigfigs sono importanti per te, non su come il computer dovrebbe calcolare la risposta finale in primo luogo. Il compito del computer è calcolare tutto con la massima precisione, indipendentemente dalle effettive precisioni di misurazione dei numeri; è il problema del programmatore se vuole arrotondare il risultato finale.
user541686,

107

Mi sembra che questo sia semplicemente un bug. Le tue aspettative sono del tutto ragionevoli. L'ho riprodotto usando .NET 4.5.1 (x64), eseguendo la seguente app console che utilizza la mia DoubleConverterclasse. DoubleConverter.ToExactStringmostra il valore esatto rappresentato da un 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);
    }
}

Risultati in .NET:

0.84551240822557
0.845512408225570055719799711368978023529052734375
0.84551240822556994469749724885332398116588592529296875
False

Risultati in Mono 3.3.0:

0.84551240822557006
0.845512408225570055719799711368978023529052734375
0.845512408225570055719799711368978023529052734375
True

Se si specifica manualmente la stringa da Mono (che contiene "006" alla fine), .NET analizzerà nuovamente il valore originale. Sembra che il problema sia nella ToString("R")gestione piuttosto che nell'analisi.

Come notato in altri commenti, sembra che questo sia specifico per l'esecuzione con CLR x64. Se compili ed esegui il codice sopra indicato per x86, va bene:

csc /platform:x86 Test.cs DoubleConverter.cs

... ottieni gli stessi risultati di Mono. Sarebbe interessante sapere se il bug si presenta sotto RyuJIT - al momento non l'ho installato da solo. In particolare, posso immaginare che questo potrebbe essere un bug JIT, o è del tutto possibile che ci siano implementazioni completamente diverse degli interni di double.ToStringbasate sull'architettura.

Ti suggerisco di presentare un bug su http://connect.microsoft.com


1
Quindi Jon? Per confermare, si tratta di un bug nel JITer, che sottolinea il ToString()? Come ho provato a sostituire il valore hard coded con rand.NextDouble()e non ci sono stati problemi.
Aron,

1
Sì, è sicuramente nella ToString("R")conversione. Prova a ToString("G32")notare che stampa il valore corretto.
user541686

1
@Aron: non so dire se si tratta di un bug in JITter o in un'implementazione specifica per x64 del BCL. Dubito fortemente che sia semplice quanto allinearsi. Testare con valori casuali non è di grande aiuto, IMO ... Non sono sicuro di cosa ti aspetti da dimostrare.
Jon Skeet,

2
Quello che sta succedendo penso sia che il formato "round trip" stia producendo un valore che è 0,498ulp più grande di quanto dovrebbe essere, e l'analisi della logica a volte arrotonda erroneamente il robo nell'ultima piccola frazione di un'ulp. Non sono sicuro di quale codice biasimo di più, dal momento che penserei che un formato "round-trip" dovrebbe produrre un valore numerico che è entro un quarto di ULP di essere numericamente corretto; l'analisi della logica che produce un valore entro 0,75 ppi di quanto specificato è molto più semplice della logica che deve produrre un risultato entro 0,502 ppi da ciò che è specificato.
supercat,

1
Il sito Web di Jon Skeet è inattivo? Trovo che sia così improbabile che sto ... perdendo tutta la fiducia qui.
Patrick M,

2

Di recente, sto cercando di risolvere questo problema . Come sottolineato attraverso il codice , il double.ToString ("R") ha la seguente logica:

  1. Prova a convertire il doppio in stringa con una precisione di 15.
  2. Converti la stringa in doppio e confronta con il doppio originale. Se sono uguali, restituiamo la stringa convertita la cui precisione è 15.
  3. Altrimenti, converti il ​​doppio in stringa con una precisione di 17.

In questo caso, double.ToString ("R") ha erroneamente scelto il risultato con una precisione di 15, quindi si verifica un errore. C'è una soluzione ufficiale nel documento MSDN:

In alcuni casi, i valori Double formattati con la stringa di formato numerico standard "R" non vanno di andata e ritorno se compilati utilizzando gli switch / platform: x64 o / platform: anycpu ed eseguiti su sistemi a 64 bit. Per aggirare questo problema, è possibile formattare i valori Double utilizzando la stringa di formato numerico standard "G17". L'esempio seguente utilizza la stringa di formato "R" con un valore Double che non effettua il round trip con successo e utilizza anche la stringa di formato "G17" per round trip il valore originale.

Quindi, a meno che questo problema non venga risolto, devi usare double.ToString ("G17") per il round-trip.

Aggiornamento : ora c'è un problema specifico per tracciare questo errore.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.