MinGW GCC 4.9.1 и детерминизм с плавающей запятой

Я написал небольшую программу для вычисления евклидовой нормы трехкоординатного вектора. Вот:

#include <array>
#include <cmath>
#include <iostream>

template<typename T, std::size_t N>
auto norm(const std::array<T, N>& arr)
    -> T
{
    T res{};
    for (auto value: arr)
    {
        res += value * value;
    }
    return std::sqrt(res);
}

int main()
{
    std::array<double, 3u> arr = { 4.0, -2.0, 6.0 };
    std::cout << norm(arr) - norm(arr) << '\n';
}

На моем компьютере он печатает -1.12323e-016.


Я знаю, что с типами с плавающей запятой следует обращаться осторожно. Однако я думал, что операции с плавающей запятой хоть как-то детерминированы. В В этой статье о детерминизме с плавающей запятой говорится, что:

Некоторые из гарантированных результатов — это результаты сложения, вычитания, умножения, деления и извлечения квадратного корня. Результаты этих операций гарантированно будут точно округлены правильно (подробнее об этом позже), поэтому, если вы предоставите одни и те же входные значения с теми же глобальными настройками и той же точностью назначения, вам гарантирован тот же результат.

Как видите, единственные операции, которые эта программа выполняет со значениями с плавающей запятой, — это сложение, вычитание, умножение и квадратный корень. Если я доверяю статье, которую я цитировал выше, учитывая, что она выполняется в одном потоке и что я не меняю режимы округления или что-либо еще, связанное с плавающей запятой, я подумал, что norm(arr) - norm(arr) будет 0, поскольку я выполняю точно такие же операции над одинаковые значения дважды.

Являются ли мои предположения неверными, или это случай, когда компилятор не строго соответствует математике с плавающей запятой IEEE? В настоящее время я использую MinGW-W64 GCC 4.9.1 32 бита (я пробовал все уровни оптимизации от -O0 до -O3). Та же программа с MinGW-W64 GCC 4.8.x отображала 0, чего я и ожидал.


EDIT: я дизассемблировал код. Я не буду публиковать всю сгенерированную сборку, потому что она слишком большая. Тем не менее, я считаю, что соответствующая часть здесь:

call    ___main
fldl    LC0
fstpl   -32(%ebp)
fldl    LC1
fstpl   -24(%ebp)
fldl    LC2
fstpl   -16(%ebp)
leal    -32(%ebp), %eax
movl    %eax, (%esp)
call    __Z4normIdLj3EET_RKSt5arrayIS0_XT0_EE
fstpl   -48(%ebp)
leal    -32(%ebp), %eax
movl    %eax, (%esp)
call    __Z4normIdLj3EET_RKSt5arrayIS0_XT0_EE
fsubrl  -48(%ebp)
fstpl   (%esp)
movl    $__ZSt4cout, %ecx
call    __ZNSolsEd
subl    $8, %esp
movl    $10, 4(%esp)
movl    %eax, (%esp)
call    __ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_c
movl    $0, %eax
movl    -4(%ebp), %ecx
.cfi_def_cfa 1, 0
leave

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


person Morwenn    schedule 17.09.2014    source источник
comment
Гарантированно ли T res{}; инициализирует res в 0?   -  person François Moisan    schedule 17.09.2014
comment
The same program with MinGW-W64 GCC 4.8.x displayed 0 - по крайней мере на 4.8.0 у меня тоже получается -1.12323e-016   -  person Andreas Fester    schedule 17.09.2014
comment
@FrançoisMoisan: Да, инициализация значения числовых типов дает ноль.   -  person Mike Seymour    schedule 17.09.2014
comment
@Andreas Андреас Я думаю, что использовал 4.8.1 или 4.8.2, но у меня было много разных версий, которые я точно не помню.   -  person Morwenn    schedule 17.09.2014
comment
Какие настройки оптимизации вы используете?   -  person T.C.    schedule 17.09.2014
comment
@Т.С. Я попробовал их все (ну, не -Of или -Og, а все уровни оптимизации от -O0 и -O3). И я убедился, что не использую -ffast-math или эквиваленты.   -  person Morwenn    schedule 17.09.2014
comment
Посмотреть на сборку может быть интересно. Вероятно, оба вызова функций были встроены и оптимизированы путем замены сложений вычитаниями для второго вычисления, а не вычисления второй суммы и ее вычитания.   -  person Mike Seymour    schedule 17.09.2014
comment
Интересно, я не могу воспроизвести это с моим MinGW 4.8.2 или 4.9.1.   -  person T.C.    schedule 17.09.2014
comment
Компиляция с 64-битным mingw-w64 gcc с кодом OP дает ожидаемый результат 0   -  person Niall    schedule 17.09.2014
comment
@MatthiasB, похоже, на правильном пути, см. gcc.gnu.org/bugzilla /show_bug.cgi?id=12331   -  person Andreas Fester    schedule 17.09.2014
comment
FWIW, long double дает ожидаемый результат (даже на 32-битной версии).   -  person Niall    schedule 17.09.2014
comment
@Niall Я тестировал на 64-битной системе - возможно, поэтому. Используемый мной дистрибутив не поддерживает кросс-компиляцию в 32-битную систему.   -  person T.C.    schedule 17.09.2014
comment
@Morwenn Ассемблерный код, который вы разместили, - это определенно код из случая, который выдает странный вывод? Существует значительная разница между 32-битным и 64-битным кодом: ваш 64-битный код использует регистры SSE, а 32-битный код — нет.   -  person Andreas Fester    schedule 17.09.2014
comment
@ Андреас Кажется, ты прав. Слишком много компиляторов на моем компьютере, тот, что запущен из Code::Blocks, выдает ошибку (32-битный MinGW-w64), компилятор из моей командной строки был другим: это был 64-битный MinGW-w64 GCC 4.9. 1 компилятор, который дает 0. Я это исправлю и выложу неисправную сборку. Извините за это, я был уверен, что удалил 64-битную версию.   -  person Morwenn    schedule 17.09.2014
comment
Бьюсь об заклад, это результат разных размеров регистров на базовом оборудовании.   -  person Martin York    schedule 23.09.2014


Ответы (2)


Как указал @MatthiasB, похоже, это проблема gcc, временно хранящего 80-битное значение с плавающей запятой в 64-битном регистре/памяти. Рассмотрим следующую упрощенную программу, которая все еще воспроизводит проблему:

#include <cmath>
#include <iostream>

double norm() {
    double res = 4.0 * 4.0 + (-2.0 * -2.0) + (6.0 * 6.0);
    return std::sqrt(res);
}

int main() {
    std::cout << norm() - norm() << '\n';
    return 0;
}

Ассемблерный код основной части norm() - norm() выглядит так (с использованием 32-битного компилятора mingw 4.8.0)

...
call    __Z4normv     ; call norm()
fstpl   -16(%ebp)     ; store result (80 bit) in temporary (64 bit!)
call    __Z4normv     ; call norm() again
fsubrl  -16(%ebp)     ; subtract result (80 bit) from temporary (64 bit!)
...

По сути, я бы счел это ошибкой gcc, но, похоже, это сложная тема< /а> ...

person Andreas Fester    schedule 17.09.2014
comment
В вопросе результат вызова находится в %xmm0, точно не в 80-битном регистре. - person Pascal Cuoq; 17.09.2014
comment
OP опубликовал код сборки после того, как я это сделал - еще не проверял;) - person Andreas Fester; 17.09.2014
comment
Я понимаю это (ОП осторожно сказал, что это редактирование), но теперь, с ассемблерным кодом, это совершенно необъяснимо или, по крайней мере, кажется, что не допускает не надуманное объяснение (наиболее вероятно, что std::sqrt вызвал в конце norm изменяет регистр управления FPU!) - person Pascal Cuoq; 17.09.2014
comment
Да, в сборке OPs оба результата проходят через 64-битные регистры rax и rbx - тогда точность должна быть одинаковой... - person Andreas Fester; 17.09.2014
comment
Еще одно редактирование позже, понятно, почему отображаемое значение не равно нулю. Первый результат проходит через -48(%ebp), а второй остается в 80-битном регистре. - person Pascal Cuoq; 17.09.2014

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

int main()
{
    std::array<double, 3u> arr = { 4.0, -2.0, 6.0 };
    volatile double v1 = norm(arr);
    volatile double v2 = norm(arr);
    std::cout << v1 - v2 << '\n';
}

Это дает вам ожидаемый результат 0.

person MatthiasB    schedule 17.09.2014