Равенство чисел с плавающей запятой после сохранения/загрузки/перемещения

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

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

Например, рассмотрим этот код C:

float* a = (float*)malloc(sizeof(float));
float* b = (float*)malloc(sizeof(float));
*a = 1.0;
*b = 1.0;
int equal = *a == *b;

Есть ли обстоятельства, при которых equal не было бы 1?

Я не знаю ни одной архитектуры, в которой битовый шаблон мог бы измениться. Тем не менее, вы рассматривали NaN? Как правило, когда a = NaN, то a != a, я думаю, из-за природы равенства с плавающей запятой. Если вы сравните битовый шаблон, даже NaN должны казаться равными.

Erik Eidt 30.05.2023 22:15

@ErikEidt Есть несколько битовых шаблонов, соответствующих NaN, но меня здесь больше интересуют не-NaN.

Kai Schmidt 30.05.2023 22:18
1.0 именно самое круглое число, все биты мантиссы очищены, поэтому никакое округление не может его изменить. FPU не искажают числа случайным образом, они просто иногда вводят округление до более низкой точности, чем временные (или действительно сохраняют большую точность, чем требуют правила C, если только вы не используете gcc -ffloat-store или что-то вроде gcc -std=c11 вместо значения по умолчанию gnu11). См. также randomascii .wordpress.com/2012/03/21/… и randomascii.wordpress.com/2012/02/25/…
Peter Cordes 30.05.2023 22:21

Более интересным случаем может быть *a = 3.141592653589793, который должен округлить литерал double до float. Если бы вы затем сделали *a == 3.141592653589793, это могло бы быть ложным, потому что левая сторона преобразовала бы float в double, чтобы соответствовать правой стороне, но с округлением. Но в зависимости от оптимизации компилятора он может пропустить этот шаг и распространить исходное значение, не выполняя округление, поэтому оно по-прежнему сравнивается равным. Но в вашем случае, когда обе стороны были назначены объектам float, тогда *a == *b - это сравнение float с уже совпадающими типами, и оба округлены

Peter Cordes 30.05.2023 22:24

@ErikEidt считает, что с a = NaNa == a ложно в семантике IEEE 754. Результат сравнения является «неупорядоченным», ни больше, ни меньше, ни равно. Это один из способов реализации isnan. Сравнения IEEE FP не основаны на побитовом равенстве. Равные битовые шаблоны NaN неравны, но -0.0 == +0.0. (Во всех остальных случаях это то же самое, что и побитовое равенство, если только ваш FPU не находится в режиме денормалей-равных нулю, тогда все субнормальы сравниваются равными нулю и друг другу.)

Peter Cordes 30.05.2023 22:29

Кроме того, единственная известная мне известная архитектура FPU, которая удивительным образом искажает данные FP, — это x87, которая не является современной. Все современные процессоры x86 поддерживают SSE2, а x86-64 использует его для математики FP, за исключением long double на ISA, которые поддерживают его как 80-битный тип. Большинство современных компиляторов настроены на использование SSE2 для скалярной математики и в 32-битных сборках, предполагая, что им не нужно поддерживать компьютеры до начала 2000-х годов. x87 обычно FLT_EVAL_METHOD == 2 (en.cppreference.com/w/cpp/types/climits/FLT_EVAL_METHOD) или его небрежная версия, в то время как большинство других ISA 0

Peter Cordes 30.05.2023 22:32

@KaiSchmidt, рассмотрите a = NaN, затем a != a, так что это означает тот же самый битовый шаблон NaN !=, за исключением того, что если вы используете целочисленное сравнение битового шаблона, тогда некоторое NaN должно равняться самому себе.

Erik Eidt 30.05.2023 22:33

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

Simon Goater 30.05.2023 23:00

«Есть ли какие-либо обстоятельства, при которых равным не было бы 1?» --> Нет, но вы используете упрощенный случай.

chux - Reinstate Monica 31.05.2023 06:45

Связанный (на самом деле не дубликат): stackoverflow.com/questions/59710531/…

chtz 31.05.2023 09:51

Операции с плавающей запятой могут быть неожиданными или трудными для понимания, но они никогда не бывают случайными. Ни один правильно спроектированный модуль с плавающей запятой никогда не будет «слегка искажать» значение без какой-либо причины. Единственный раз, когда значение может измениться просто из-за того, что оно было перемещено, это если оно перемещено в объект с более низкой точностью. Это может быть проблемой, особенно если вы этого не осознавали — например, если вы не осознавали, что исходный объект имеет более высокую точность, чем обычно.

Steve Summit 31.05.2023 13:14

@SteveSummit Я согласен с тем, что сохранение/загрузка не может изменить значение с плавающей запятой (в отличие от целых чисел с нулем со знаком), но здесь также задействованы преобразования (возможно, когда вы имели в виду в своем последнем предложении). Смотрите мой ответ для деталей.

vinc17 02.06.2023 13:24
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
2
12
85
3
Перейти к ответу Данный вопрос помечен как решенный

Ответы 3

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

В этом случае да, простое перемещение их не приведет к изменению значений. При выполнении операций над значениями ваша интуиция может нуждаться в разъяснении. В частности, результат любой операции должен быть таким же, как если бы FPU выполнял операцию с полной (то есть произвольной) точностью, а затем только округлял результат, чтобы он соответствовал соответствующему (например, 32-битному двоичному числу с плавающей запятой). Следовательно, для ЦП, который реализует IEEE754 с плавающей запятой, любое округление будет детерминированным — это причина оператора 0.1 + 0.2 != 0.3.

Обратите внимание, что, используя C, вы усложнили это с помощью двух механизмов; 1. C не требует семантики IEEE754; 2. C будет автоматически преобразовывать между значениями float и double (т. е. 32-битные и 64-битные двоичные числа IEEE754 с плавающей запятой на распространенных архитектурах). Эти две особенности могут означать, что код может не делать того, что ожидает наивный читатель.

В качестве более полного примера я поместил следующий код в обозреватель компилятора Godbolt :

#include <math.h>

int explicit_nan(float * restrict a, float * restrict b, float val) {
    *a = val;
    *b = val;
    return isnan(val) ? 1 : *a == *b;
}

int implicit_nan(float * restrict a, float * restrict b, float val) {
    *a = val;
    *b = val;
    return *a == *b;
}

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

char data[sizeof(float) + 1];
explicit_nan((float*)(data), (float*)(data+1), 1.f);

Это приводит к тому, что запись в b изменяет значение a. По-видимому, вас не волнует этот случай, но компилятор C не может быть таким снисходительным.

Можно увидеть, что код explicit_nan компилируется в код, который всегда возвращает 1 (путем установки EAX), в то время как код implicit_nan выполняет только сравнение переданного параметра, чтобы NaN обрабатывались правильно в небольшом объеме кода.

C указывает, что округление (чрезмерно точных временных значений, если таковые имеются randomascii.wordpress.com/2012/03/21/…) до фактического типа C (float или double) происходит на границах выражений, но на практике компиляторы неаккуратны об этом. например GCC, ориентированный на 32-битный x86 с математикой x87 FP, сохраняет дополнительную точность во всех операторах при оптимизации, если только вы не используете gcc -ffloat-store, gcc -std=c99, c11 или что-то еще (вместо gnu99 по умолчанию). Так что этот ответ немного оптимистичен в своих предположениях.

Peter Cordes 01.06.2023 23:03

Но да, простое копирование данных не округляет их, если они уже могут быть представлены в виде float. Переключение double или long double в float может изменить значение, если оно еще не было круглым числом.

Peter Cordes 01.06.2023 23:05

@PeterCordes извините, мне некоторое время не приходилось думать о 80-битных числах с плавающей запятой x86 FPU! Я также интерпретирую комментарий OP «современные архитектуры» как означающий x86-64 / ARM64 (и, возможно, RISC-V / Power), а не DSP не-754, но вопрос несколько расплывчатый.

Sam Mason 02.06.2023 00:11

Да, как обсуждалось в комментариях к вопросу, все современные архитектуры имеют FLT_EVAL_METHOD == 0, используя такие вещи, как SSE2 для скалярной математики, а не x87, что делает его почти полностью непроблемным, за исключением случаев смешивания разных типов в исходном коде C. Но я думаю, что эффекты, о которых слышали OP и их коллеги, вызваны FLT_EVAL_METHOD == 2 x87.

Peter Cordes 02.06.2023 01:07

Убеждение вашего коллеги может быть основано на правилах C и C++.

C и C++ позволяют вычислять выражения с плавающей запятой с большей точностью, чем номинальные типы операндов.

Например, учитывая все float переменные, d = a + b - c; можно вычислить с помощью double арифметики. Правила требуют, чтобы при сохранении результата в d он преобразовывался обратно в номинальный тип float. Однако в промежуточных вычислениях можно использовать double. Например, если a равно 230, b равно 1, а c равно 230, это выражение даст 0 при вычислении с использованием float арифметики, потому что сложение 230 и 1 даст 230 (поскольку 230+1 не представимо в формат, обычно используемый для float, поэтому он округляется до представимого значения), а затем вычитание 230 дает 0. Однако, если используется арифметика double, то сложение дает 230+1, а вычитание 230 дает 1.

Это не отличается от того факта, что при наличии всех переменных unsigned char выражение оценивается с использованием int арифметики и может давать результаты, отличные от тех, которые были бы получены с использованием только unsigned char арифметики. Например, при a = 100, b = 3, d = 3 * a / b ; даст 100, поскольку используется int арифметика. Если бы использовалась арифметика unsigned char, 3 * a дало бы 44 (300−256), а деление на 3 дало бы 14. Так что это не только проблема с плавающей запятой; это проблема того, как язык программирования оценивает выражения.

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

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

Его также можно оценить с помощью long double арифметики; это FLT_EVAL_METHOD == 2 (en.cppreference.com/w/cpp/types/climits/FLT_EVAL_METHOD ), что вы получите на 32-битной x86, если биты контроля точности x87 оставлены в полной 64-битной мантиссе по умолчанию точность, а не округление до 53-битных мантисс, таких как double. randomascii.wordpress.com/2012/03/21/… есть некоторая информация об историческом поведении MSVC для x87. К счастью, большинство современных ISA (включая x86-64) могут эффективно выполнять FLT_EVAL_METHOD == 0 (оценку как тип C).

Peter Cordes 02.06.2023 22:25
Ответ принят как подходящий

То, что вы написали, эквивалентно

float a = 1.0;
float b = 1.0;
int equal = a == b;

(использование указателей ничего не меняет в отношении стандарта C). Итак, для переменной a1.0 интерпретируется в некотором формате оценки F (в зависимости от FLT_EVAL_METHOD, см. ISO C17 5.2.4.2.2p9), затем преобразуется в float и сохраняется в a. То же самое для b. Как правило, сохранение/чтение значений не должно их изменять, если только это явно не указано (например, стандарт ISO C17 прямо говорит в 6.2.6.2p3, что в реализациях, поддерживающих отрицательные целые нули, отрицательный нуль может стать нормальным нулем, когда хранится).

Чтобы ответить на вопрос, сначала рассмотрим преобразование константы 1.0 (в виде строки) в F в обеих строках. ISO C17 говорит в 6.4.4.2p5: «Все плавающие константы одной и той же исходной формы должны преобразовываться в один и тот же внутренний формат с одним и тем же значением». Таким образом, вы получите одинаковое значение (в формате оценки F) в обоих случаях. Но обратите внимание, что если бы у вас были 1.0 и 1.00 соответственно, вы могли бы получить разные значения (маловероятно, в частности, потому что 1.0 точно представим и достаточно прост, но не запрещен стандартом C).

Затем рассмотрим преобразование полученного значения в float. ISO C17 6.3.1.5p1 гласит: «Когда значение реального плавающего типа преобразуется в реальный плавающий тип, если преобразованное значение может быть точно представлено в новом типе, оно остается неизменным». Это случай значения 1, если 1.0 (используется в примере) был преобразован в 1, так что equal в этом случае будет 1. Но если 1.0 было преобразовано в какое-то другое значение, не представимое в float, я думаю, что equal может быть 0 (стандарт C не требует, чтобы преобразование некоторого значения в какой-либо тип всегда давало один и тот же результат, и обратите внимание, что, в частности, это не тот случай, когда изменяется режим округления).

Спасибо, это доходит до сути того, что я хотел знать.

Kai Schmidt 02.06.2023 15:57

Другие вопросы по теме