Tại sao chuyển đổi khứ hồi qua chuỗi không an toàn cho gấp đôi?


185

Gần đây tôi đã phải xâu chuỗi một đôi thành văn bản, và sau đó lấy lại. Giá trị dường như không tương đương:

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

Nhưng theo MSDN: Chuỗi định dạng số tiêu chuẩn , tùy chọn "R" được cho là đảm bảo an toàn cho chuyến đi khứ hồi.

Trình xác định định dạng khứ hồi ("R") được sử dụng để đảm bảo rằng giá trị số được chuyển đổi thành chuỗi sẽ được phân tích cú pháp trở lại thành cùng một giá trị số

Tại sao điều này xảy ra?


6
Tôi đã gỡ lỗi trong VS của mình và nó trở lại đúng ở đây
Neel

19
Tôi đã sao chép nó trở lại sai. Câu hỏi rất thú vị.
Jon Skeet

40
.net 4.0 x86 - đúng, .net 4.0 x64 - sai
Ulugbek Umirov

25
Chúc mừng bạn đã tìm thấy một lỗi ấn tượng như vậy trong .net.
Aron

14
@Casperah Chuyến đi khứ hồi có ý nghĩa đặc biệt để tránh sự không nhất quán điểm nổi
Gusdor

Câu trả lời:


178

Tôi tìm thấy lỗi.

.NET thực hiện như sau 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);

DoubleToNumberkhá đơn giản - nó chỉ gọi _ecvt, trong thời gian chạy 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;
    }
}

Nó chỉ ra rằng _ecvttrả về chuỗi 845512408225570.

Chú ý dấu 0? Nó chỉ ra rằng làm cho tất cả sự khác biệt!
Khi có số 0, kết quả thực sự phân tích trở lại0.84551240822557006, đó là bản gốc của bạn số - để nó so sánh bằng nhau và do đó chỉ có 15 chữ số được trả về.

Tuy nhiên, nếu tôi cắt bớt chuỗi ở số 0 84551240822557đó, thì tôi sẽ quay lại 0.84551240822556994, đây không phải số ban đầu của bạn và do đó nó sẽ trả về 17 chữ số.

Bằng chứng: chạy mã 64 bit sau (hầu hết trong số đó tôi đã trích xuất từ ​​Microsoft Shared Source CLI 2.0) trong trình gỡ lỗi của bạn và kiểm tra vở cuối 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
Giải thích tốt +1. Mã này là từ shared-source-cli-2.0 phải không? Đây là suy nghĩ duy nhất tôi tìm thấy.
Soner Gönül

10
Tôi phải nói rằng nó khá thảm hại. Các chuỗi có giá trị bằng nhau về mặt toán học (như một chuỗi có số 0 ở cuối hoặc giả sử 2.1e-1 so với 0.21) phải luôn cho kết quả giống hệt nhau và các chuỗi được sắp xếp theo toán học sẽ cho kết quả phù hợp với thứ tự.
gnasher729

4
@MrLister: Tại sao không nên "2.1E-1 giống với 0.21 như vậy"?
dùng541686

9
@ gnasher729: Tôi phần nào đồng ý với "2.1e-1" và "0.21" ... nhưng một chuỗi có số 0 không chính xác bằng một không có - trước đây, số 0 là một chữ số có nghĩa và thêm độ chính xác.
cHao

4
@cHao: Er ... nó thêm độ chính xác, nhưng điều đó chỉ ảnh hưởng đến cách bạn quyết định làm tròn câu trả lời cuối cùng nếu sigfigs quan trọng với bạn, chứ không phải máy tính nên tính toán câu trả lời cuối cùng như thế nào. Công việc của máy tính là tính toán mọi thứ với độ chính xác cao nhất bất kể các phép đo thực tế của các con số; đó là vấn đề của lập trình viên nếu anh ta muốn làm tròn kết quả cuối cùng.
dùng541686

107

Dường như với tôi rằng đây chỉ đơn giản là một lỗi. Kỳ vọng của bạn là hoàn toàn hợp lý. Tôi đã sao chép nó bằng .NET 4.5.1 (x64), chạy ứng dụng bảng điều khiển sau sử dụng DoubleConverterlớp của tôi . DoubleConverter.ToExactStringhiển thị giá trị chính xác được đại diện bởi 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);
    }
}

Kết quả trong .NET:

0.84551240822557
0.845512408225570055719799711368978023529052734375
0.84551240822556994469749724885332398116588592529296875
False

Kết quả trong Mono 3.3.0:

0.84551240822557006
0.845512408225570055719799711368978023529052734375
0.845512408225570055719799711368978023529052734375
True

Nếu bạn chỉ định thủ công chuỗi từ Mono (có chứa "006" ở cuối), .NET sẽ phân tích cú pháp đó trở lại giá trị ban đầu. Có vẻ như vấn đề là ởToString("R") xử lý chứ không phải phân tích cú pháp.

Như đã lưu ý trong các bình luận khác, có vẻ như điều này là cụ thể để chạy theo x64 CLR. Nếu bạn biên dịch và chạy mã trên nhắm mục tiêu x86, thì tốt:

csc /platform:x86 Test.cs DoubleConverter.cs

... bạn nhận được kết quả tương tự như với Mono. Thật thú vị khi biết liệu lỗi có xuất hiện dưới RyuJIT hay không - bản thân tôi không cài đặt nó vào lúc này. Cụ thể, tôi có thể tưởng tượng đây có thể là một lỗi JIT hoặc hoàn toàn có thể có nhiều cách triển khai khác nhau của các phần bên trong double.ToStringdựa trên kiến ​​trúc.

Tôi đề nghị bạn gửi một lỗi tại http://connect.microsoft.com


1
Vậy Jon? Để xác nhận, đây có phải là một lỗi trong JITer, nội tuyến ToString()không? Vì tôi đã cố gắng thay thế giá trị mã hóa cứng rand.NextDouble()và không có vấn đề gì.
Aron

1
Vâng, nó chắc chắn trong ToString("R")chuyển đổi. Hãy thử ToString("G32")và nhận thấy nó in giá trị chính xác.
dùng541686

1
@Aron: Tôi không thể biết đó là lỗi trong JITter hay trong triển khai BCL dành riêng cho x64. Tôi rất nghi ngờ rằng nó đơn giản như nội tuyến. Thử nghiệm với các giá trị ngẫu nhiên không thực sự giúp ích nhiều, IMO ... Tôi không chắc bạn mong đợi điều đó sẽ chứng minh điều gì.
Jon Skeet

2
Điều đang xảy ra tôi nghĩ là định dạng "chuyến đi khứ hồi" đang xuất ra một giá trị lớn hơn 0,498ulp so với mức cần thiết và phân tích logic đôi khi làm sai lệch nó thành một phần rất nhỏ của một ulp. Tôi không chắc chắn mã nào tôi đổ lỗi nhiều hơn, vì tôi nghĩ rằng định dạng "khứ hồi" sẽ tạo ra một giá trị số nằm trong một phần tư ULP là chính xác về số; phân tích cú pháp logic mang lại giá trị trong vòng 0,75ulp của những gì được chỉ định dễ hơn nhiều so với logic phải mang lại kết quả trong vòng 0,52ulp so với những gì được chỉ định.
supercat

1
Trang web của Jon Skeet bị sập? Tôi thấy rằng rất có thể tôi ... mất hết niềm tin ở đây.
Patrick M

2

Gần đây, tôi đang cố gắng giải quyết vấn đề này . Như được chỉ ra thông qua mã , double.ToString ("R") có logic sau:

  1. Cố gắng chuyển đổi gấp đôi thành chuỗi với độ chính xác là 15.
  2. Chuyển đổi chuỗi trở lại gấp đôi và so sánh với gấp đôi ban đầu. Nếu chúng giống nhau, chúng ta trả về chuỗi đã chuyển đổi có độ chính xác là 15.
  3. Mặt khác, chuyển đổi gấp đôi thành chuỗi với độ chính xác là 17.

Trong trường hợp này, double.ToString ("R") đã chọn sai kết quả với độ chính xác là 15 để xảy ra lỗi. Có một cách giải quyết chính thức trong tài liệu MSDN:

Trong một số trường hợp, Giá trị kép được định dạng bằng chuỗi định dạng số chuẩn "R" không thành công khứ hồi nếu được biên dịch bằng cách sử dụng / platform: x64 hoặc / platform: anycpu và chạy trên hệ thống 64 bit. Để khắc phục sự cố này, bạn có thể định dạng Giá trị kép bằng cách sử dụng chuỗi định dạng số chuẩn "G17". Ví dụ sau sử dụng chuỗi định dạng "R" với giá trị Double không thành công khứ hồi và cũng sử dụng chuỗi định dạng "G17" để thực hiện thành công giá trị gốc.

Vì vậy, trừ khi vấn đề này được giải quyết, bạn phải sử dụng double.ToString ("G17") để xử lý vòng.

Cập nhật : Bây giờ có một vấn đề cụ thể để theo dõi lỗi này.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.