Подтвердить что ты не робот

Почему возврат значения с плавающей запятой меняет его значение?

Следующий код поднимает assert на Red Hat 5.4 32 бита, но работает на Red Hat 5.4 64 бит (или CentOS).

В 32 битах я должен поместить возвращаемое значение millis2seconds в переменную, иначе assert будет поднят, показывая, что значение double, возвращаемое функцией, отличается от значения, которое было передано к нему.

Если вы прокомментируете строку "#define BUG", она работает.

Благодаря @R, передавая компилятору параметры -msse2 -mfpmath, производят оба варианта функции millis2seconds.

/*
 * TestDouble.cpp
 */

#include <assert.h>
#include <stdint.h>
#include <stdio.h>

static double millis2seconds(int millis) {
#define BUG
#ifdef BUG
    // following is not working on 32 bits architectures for any values of millis
    // on 64 bits architecture, it works
    return (double)(millis) / 1000.0;
#else
    //  on 32 bits architectures, we must do the operation in 2 steps ?!? ...
    // 1- compute a result in a local variable, and 2- return the local variable
    // why? somebody can explains?
    double result = (double)(millis) / 1000.0;
    return result;
#endif
}

static void testMillis2seconds() {
    int millis = 10;
    double seconds = millis2seconds(millis);

    printf("millis                  : %d\n", millis);
    printf("seconds                 : %f\n", seconds);
    printf("millis2seconds(millis)  : %f\n", millis2seconds(millis));
    printf("seconds <  millis2seconds(millis)  : %d\n", seconds < millis2seconds(millis));
    printf("seconds >  millis2seconds(millis)  : %d\n", seconds > millis2seconds(millis));
    printf("seconds == millis2seconds(millis)  : %d\n", seconds == millis2seconds(millis));

    assert(seconds == millis2seconds(millis));
}

extern int main(int argc, char **argv) {
    testMillis2seconds();
}
4b9b3361

Ответ 1

С помощью соглашения о вызове cdecl, которое используется в системах Linux x86, double возвращается из функции с использованием регистра st0 x87. Все регистры x87 имеют 80-битную точность. С помощью этого кода:

static double millis2seconds(int millis) {
    return (double)(millis) / 1000.0;
};

Компилятор вычисляет деление с использованием 80-битной точности. Когда gcc использует диалект GNU стандарта (который он делает по умолчанию), он оставляет результат в регистре st0, поэтому полная точность возвращается обратно вызывающему. Конец кода сборки выглядит следующим образом:

fdivrp  %st, %st(1)  # Divide st0 by st1 and store the result in st0
leave
ret                  # Return

С помощью этого кода

static double millis2seconds(int millis) {
    double result = (double)(millis) / 1000.0;
    return result;
}

результат сохраняется в 64-битной ячейке памяти, которая теряет некоторую точность. 64-битное значение загружается обратно в 80-разрядный регистр st0 перед возвратом, но повреждение уже выполнено:

fdivrp  %st, %st(1)   # Divide st0 by st1 and store the result in st0
fstpl   -8(%ebp)      # Store st0 onto the stack
fldl    -8(%ebp)      # Load st0 back from the stack
leave
ret                   # Return

В основном, первый результат сохраняется в 64-разрядной ячейке памяти, поэтому дополнительная точность теряется в любом случае:

double seconds = millis2seconds(millis);

но во втором вызове обратное значение используется напрямую, поэтому компилятор может хранить его в регистре:

assert(seconds == millis2seconds(millis));

При использовании первой версии millis2seconds вы заканчиваете сравнение значения, которое было усечено до 64-битной точности, до значения с полной 80-битной точностью, поэтому есть разница.

На x86-64 вычисления выполняются с использованием регистров SSE, которые являются только 64-разрядными, поэтому эта проблема не возникает.

Кроме того, если вы используете -std=c99, чтобы не получить диалект GNU, вычисленные значения сохраняются в памяти и повторно загружаются в регистр перед возвратом, чтобы соответствовать стандарту.

Ответ 2

В i386 (32-разрядный x86) все выражения с плавающей запятой оцениваются как 80-битный тип с плавающей точкой расширенного IEEE. Это отражено в FLT_EVAL_METHOD, from float.h, определяемом как 2. Сохранение результата переменной или применение приведения к результату снижает избыточную точность посредством округления, но этого все еще недостаточно, чтобы гарантировать тот же результат, который вы бы см. реализацию (например, x86_64) без избыточной точности, так как округление дважды может давать разные результаты, чем выполнять вычисление и округление на том же шаге.

Один из способов решения этой проблемы - построить с использованием математики SSE даже для целей x86 с помощью -msse2 -mfpmath=sse.

Ответ 3

Стоит прежде всего отметить, что, поскольку функция неявно чиста и дважды называется с аргументом константы, компилятор будет в пределах своих прав, чтобы полностью исключить вычисление и сравнение.

clang-3.0-6ubuntu3 устраняет вызов чистой функции с -O9 и выполняет все вычисления с плавающей запятой во время компиляции, поэтому программа преуспевает.

Стандарт C99, ISO/IEC 9899, говорится

Значения плавающих операндов и результаты плавающих выражений могут быть представлены в большей точности и дальности, чем требуемые типом; типы не изменяются.

Таким образом, компилятор может передать 80-битное значение, как описано другими. Однако в стандарте говорится:

Операторы литья и присваивания по-прежнему должны выполнять свои указанные преобразования.

Это объясняет, почему конкретное присвоение double заставляет значение до 64 бит и возвращать как double из функции нет. Это довольно удивительно для меня.

Однако, похоже, стандарт C11 на самом деле сделает это менее запутанным, добавив этот текст:

Если выражение return вычисляется в формате с плавающей запятой, отличном от типа возвращаемого выражения, выражение преобразуется, как если бы по присваиванию [который удаляет любой дополнительный диапазон и точность] возвращаемому типу функции, и результирующее значение возвращается вызывающему.

Таким образом, этот код в основном выполняет неуказанное поведение относительно того, будет ли значение усекаться или нет в разных точках.


Для меня, на Ubuntu Precise, с -m32:

  • clang проходит
  • clang -O9 также проходит
  • gcc, утверждение терпит неудачу
  • gcc -O9 проходит, поскольку он также устраняет константные выражения
  • gcc -std=c99 не работает
  • gcc -std=c1x также терпит неудачу (но он может работать на более позднем gcc)
  • gcc -ffloat-store проходит, но, похоже, имеет побочный эффект постоянной элиминации

Я не думаю, что это ошибка gcc, потому что стандарт позволяет это поведение, но поведение clang более приятное.

Ответ 4

В дополнение ко всем деталям, объясняемым в других ответах, я бы сказал, что существует очень простое правило относительно использования типов с плавающей точкой почти на любом языке программирования, поскольку Fortran: никогда не проверять значения с плавающей запятой для точного равенства. Все знания о 80-битных и 64-битных значениях истинны, но это верно для определенного оборудования и определенного компилятора (да, если вы измените компилятор или даже включите или отключите оптимизацию, что-то может измениться). Более общее правило (применимое к любому коду, предназначенному для переносимости), состоит в том, что значения с плавающей запятой обычно не похожи на целые числа или последовательности байтов и могут быть изменены, например. при копировании и проверке их на равенство часто имеют непредсказуемые результаты.

Итак, даже если он работает в тесте, обычно лучше не делать этого. Это может произойти позже, когда что-то изменится.

UPD: Хотя некоторые люди отказались, я настаиваю, что рекомендация в целом правильная. Вещи, которые, кажется, просто копируют значение (они выглядят так с точки зрения программиста на языке высокого уровня, что происходит в первом примере, является типичным примером, значение возвращается и помещается в переменную и - вуаля - он изменен!), МОЖЕТ менять значения с плавающей запятой. Сравнение значений с плавающей запятой для равенства или неравенства часто является плохой практикой, которая может быть разрешена ТОЛЬКО, если вы знаете, почему вы можете сделать это в своем конкретном случае. И для написания переносных программ обычно требуется минимизировать знания низкого уровня. Да, очень маловероятно, чтобы целочисленные значения, такие как 0 или 1, были изменены при вводе в переменную с плавающей запятой или скопированы. Но более сложные значения (в приведенном выше примере мы видим, что происходит с результатом простого арифметического выражения!).