Точность арифметики с плавающей запятой

У меня проблемы с пониманием вывода этой программы

int main()
{
    double x = 1.8939201459282359e-308;
    double y = 4.9406564584124654e-324;
    printf("%23.16e\n", 1.6*y);
    printf("%23.16e\n", 1.7*y);
    printf("%23.16e\n", 1.8*y);
    printf("%23.16e\n", 1.9*y);
    printf("%23.16e\n", 2.0*y);
    printf("%23.16e\n", x + 1.6*y);
    printf("%23.16e\n", x + 1.7*y);
    printf("%23.16e\n", x + 1.8*y);
    printf("%23.16e\n", x + 1.9*y);
    printf("%23.16e\n", x + 2.0*y);
}

Выход

9.8813129168249309e-324
9.8813129168249309e-324
9.8813129168249309e-324
9.8813129168249309e-324
9.8813129168249309e-324
1.8939201459282364e-308
1.8939201459282364e-308
1.8939201459282369e-308
1.8939201459282369e-308
1.8939201459282369e-308

Я использую арифметику IEEE. Переменная y содержит наименьший возможный номер IEEE. Первые пять отпечатков показывают число, которое в два раза больше y, чем я ожидал. Что меня смущает, так это то, что следующие пять отпечатков показывают разные числа. Если 1.6*y совпадает с 2.0*y, то как x + 1.6*y может отличаться от x + 2.0*y?


person john    schedule 15.03.2013    source источник
comment
Из любопытства, какую платформу/компилятор/версию/флаги вы используете? Я бы не ожидал, что gcc или clang на x86 или x86_64 с каким-либо общим набором параметров выдадут такой результат. (Не то чтобы вы должны полагаться на это, мне просто любопытно, что вы используете.)   -  person abarnert    schedule 16.03.2013
comment
Я использую Visual C++ 2010 Express. Все обычные флаги для отладочной сборки (например, отсутствие оптимизации). Релизная сборка дает ожидаемый результат.   -  person john    schedule 16.03.2013


Ответы (1)


В двух словах

Вы говорите, что ваш компилятор — Visual C++ 2010 Express. У меня нет доступа к этому компилятору, но я понимаю, что он генерирует программы, изначально настраивающие ЦП x87 на использование 53-битной точности, чтобы как можно точнее эмулировать вычисления двойной точности IEEE 754.

К сожалению, «как можно ближе» не всегда достаточно близко. Исторические 80-битные регистры с плавающей запятой могут иметь значащую ограниченную ширину в целях эмуляции двойной точности, но они всегда сохраняют полный диапазон для экспоненты. Разница проявляется, в частности, при манипулировании денормалями (например, вашим y).

Что случается

Мое объяснение состоит в том, что в printf("%23.16e\n", 1.6*y); 1.6*y вычисляется как 80-битное число с уменьшенной значимостью и полной экспонентой (таким образом, это нормальное число), затем преобразуется в IEEE 754 с двойной точностью (что приводит к денормальному), а затем печатается.

С другой стороны, в printf("%23.16e\n", x + 1.6*y);, x + 1.6*y вычисляется со всеми 80-битными числами с пониженной значимостью и полной экспонентой (опять же, все промежуточные результаты являются нормальными числами), затем преобразуются в IEEE 754 с двойной точностью, а затем печатаются.

Это объясняет, почему 1.6*y печатается так же, как 2.0*y, но имеет другой эффект при добавлении к x. Напечатанное число является денормализованным с двойной точностью. Число, которое добавляется к x, представляет собой 80-битное нормальное число с уменьшенной значимостью и полной экспонентой (не то же самое).

Что происходит с другими компиляторами при генерации инструкций x87

Другие компиляторы, такие как GCC, не настраивают x87 FPU для работы с 53-битными мантиссандами. Это может иметь те же последствия (в этом случае x + 1.6*y будет вычисляться со всеми 80-битными числами с полной мантиссой и полной экспонентой, а затем преобразовываться в двойную точность для печати или сохранения в памяти). В этом случае проблема заметна даже чаще (не нужно задействовать денормали или бесконечные числа, чтобы заметить различия).

Эта статья Дэвида Моннио содержит всю необходимую информацию и многое другое.

Удаление нежелательного поведения

Чтобы избавиться от проблемы (если вы считаете ее таковой), найдите флаг, который указывает вашему компилятору генерировать инструкции SSE2 для операций с плавающей запятой. Они реализуют точно семантику IEEE 754 для одинарной и двойной точности.

person Pascal Cuoq    schedule 15.03.2013
comment
Мне все еще любопытно, почему сборка Release этого не делает. Есть несколько возможностей (использование SSE2 вместо x87, если доступно, использование x87 FMA доступно, установка разных флагов x87, …); Мне просто интересно, какой именно. - person abarnert; 16.03.2013
comment
@abarnert На этот вопрос сложнее ответить без доступа к компилятору, но вот мое предположение: ABI не требует, чтобы аргумент с плавающей запятой printf() проходил через память (и округлялся до двойной точности). Аргумент с плавающей запятой фактически передается в 80-битном регистре. Без оптимизации (в режиме отладки) результат проходит через 64-битный слот в стеке, потому что это то, что делает неоптимизированный код. При оптимизации результат вообще не проходит через память и никогда не округляется. Просто предположение... - person Pascal Cuoq; 16.03.2013
comment
@PascalCuoq Спасибо за очень четкий и подробный ответ. Это проблема, о которой я совершенно не знал, что, вероятно, было очевидно из моего комментария «Я использую арифметику IEEE». Один из мифов, который Давид Моннио упоминает в своей статье. Выбор правильного варианта компилятора для SSE2 устраняет проблему. Между прочим, я думаю, что причина того, что режим выпуска работает по-другому, заключается в том, что в режиме выпуска все вычисления с плавающей запятой выполняются во время компиляции и (предположительно) в строгом порядке IEEE. И в отладке, и в выпуске значение передается в printf в стеке. - person john; 16.03.2013