У нас с коллегой есть разногласия по поводу того, что может произойти при сравнении двух чисел с плавающей запятой, которые не подвергались математической обработке. т. е. числа могли быть перемещены по регистрам памяти и/или процессора, но с ними не выполнялись никакие математические операции. Возможно, они были помещены в список, а затем удалены или выполнены другие различные операции.
Мой опыт привел меня к мысли, что выполнение неарифметических операций над числами с плавающей запятой никогда не должно изменять их или подвергаться тем же ошибкам округления, что и арифметические операции. Мой коллега утверждает, что части процессора, обрабатывающей числа с плавающей запятой, для некоторых современных архитектур разрешено слегка искажать число, так что проверка на равенство не выполняется, даже когда значение сохраняется/загружается/перемещается.
Например, рассмотрим этот код 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?
@ErikEidt Есть несколько битовых шаблонов, соответствующих NaN, но меня здесь больше интересуют не-NaN.
1.0 именно самое круглое число, все биты мантиссы очищены, поэтому никакое округление не может его изменить. FPU не искажают числа случайным образом, они просто иногда вводят округление до более низкой точности, чем временные (или действительно сохраняют большую точность, чем требуют правила C, если только вы не используете gcc -ffloat-store или что-то вроде gcc -std=c11 вместо значения по умолчанию gnu11). См. также randomascii .wordpress.com/2012/03/21/… и randomascii.wordpress.com/2012/02/25/…Более интересным случаем может быть *a = 3.141592653589793, который должен округлить литерал double до float. Если бы вы затем сделали *a == 3.141592653589793, это могло бы быть ложным, потому что левая сторона преобразовала бы float в double, чтобы соответствовать правой стороне, но с округлением. Но в зависимости от оптимизации компилятора он может пропустить этот шаг и распространить исходное значение, не выполняя округление, поэтому оно по-прежнему сравнивается равным. Но в вашем случае, когда обе стороны были назначены объектам float, тогда *a == *b - это сравнение float с уже совпадающими типами, и оба округлены
@ErikEidt считает, что с a = NaNa == a ложно в семантике IEEE 754. Результат сравнения является «неупорядоченным», ни больше, ни меньше, ни равно. Это один из способов реализации isnan. Сравнения IEEE FP не основаны на побитовом равенстве. Равные битовые шаблоны NaN неравны, но -0.0 == +0.0. (Во всех остальных случаях это то же самое, что и побитовое равенство, если только ваш FPU не находится в режиме денормалей-равных нулю, тогда все субнормальы сравниваются равными нулю и друг другу.)
Кроме того, единственная известная мне известная архитектура 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
@KaiSchmidt, рассмотрите a = NaN, затем a != a, так что это означает тот же самый битовый шаблон NaN !=, за исключением того, что если вы используете целочисленное сравнение битового шаблона, тогда некоторое NaN должно равняться самому себе.
Я бы не осмелился предположить, что они достаточно неизменны в небезопасном для типов языке, который может делать неявные продвижения к другим типам. Я не могу привести пример, где это может произойти, но я просто говорю, что не стал бы делать такое предположение.
«Есть ли какие-либо обстоятельства, при которых равным не было бы 1?» --> Нет, но вы используете упрощенный случай.
Связанный (на самом деле не дубликат): stackoverflow.com/questions/59710531/…
Операции с плавающей запятой могут быть неожиданными или трудными для понимания, но они никогда не бывают случайными. Ни один правильно спроектированный модуль с плавающей запятой никогда не будет «слегка искажать» значение без какой-либо причины. Единственный раз, когда значение может измениться просто из-за того, что оно было перемещено, это если оно перемещено в объект с более низкой точностью. Это может быть проблемой, особенно если вы этого не осознавали — например, если вы не осознавали, что исходный объект имеет более высокую точность, чем обычно.
@SteveSummit Я согласен с тем, что сохранение/загрузка не может изменить значение с плавающей запятой (в отличие от целых чисел с нулем со знаком), но здесь также задействованы преобразования (возможно, когда вы имели в виду в своем последнем предложении). Смотрите мой ответ для деталей.





Я предполагаю, что вы имеете в виду двоичные числа с плавающей запятой 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 по умолчанию). Так что этот ответ немного оптимистичен в своих предположениях.
Но да, простое копирование данных не округляет их, если они уже могут быть представлены в виде float. Переключение double или long double в float может изменить значение, если оно еще не было круглым числом.
@PeterCordes извините, мне некоторое время не приходилось думать о 80-битных числах с плавающей запятой x86 FPU! Я также интерпретирую комментарий OP «современные архитектуры» как означающий x86-64 / ARM64 (и, возможно, RISC-V / Power), а не DSP не-754, но вопрос несколько расплывчатый.
Да, как обсуждалось в комментариях к вопросу, все современные архитектуры имеют FLT_EVAL_METHOD == 0, используя такие вещи, как SSE2 для скалярной математики, а не x87, что делает его почти полностью непроблемным, за исключением случаев смешивания разных типов в исходном коде C. Но я думаю, что эффекты, о которых слышали OP и их коллеги, вызваны FLT_EVAL_METHOD == 2 x87.
Убеждение вашего коллеги может быть основано на правилах 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).
То, что вы написали, эквивалентно
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 не требует, чтобы преобразование некоторого значения в какой-либо тип всегда давало один и тот же результат, и обратите внимание, что, в частности, это не тот случай, когда изменяется режим округления).
Спасибо, это доходит до сути того, что я хотел знать.
Я не знаю ни одной архитектуры, в которой битовый шаблон мог бы измениться. Тем не менее, вы рассматривали NaN? Как правило, когда a = NaN, то a != a, я думаю, из-за природы равенства с плавающей запятой. Если вы сравните битовый шаблон, даже NaN должны казаться равными.