Разрешена ли такая оптимизация с плавающей запятой?

90

Я попытался проверить, где floatтеряет способность точно представлять большие целые числа. Итак, я написал этот небольшой фрагмент:

int main() {
    for (int i=0; ; i++) {
        if ((float)i!=i) {
            return i;
        }
    }
}

Этот код работает со всеми компиляторами, кроме clang. Clang генерирует простой бесконечный цикл. Godbolt .

Это разрешено? Если да, то это проблема QoI?

геза
источник
@geza Мне было бы интересно услышать получившееся число!
nada
5
gccвыполняет ту же оптимизацию с бесконечными циклами, если вы -Ofastвместо этого компилируете , поэтому такая оптимизация gccсчитается небезопасной, но может это сделать.
12345ieee
3
g ++ также генерирует бесконечный цикл, но он не оптимизирует работу внутри него. Вы можете ucomiss xmm0,xmm0сравнить это (float)iс собой. Это была ваша первая подсказка о том, что ваш исходный код на C ++ не означает то, что вы думали. Вы утверждаете, что у вас есть этот цикл для печати / возврата 16777216? С каким компилятором / версией / параметрами это было? Потому что это будет ошибка компилятора. gcc правильно оптимизирует ваш код jnpкак ветвь цикла ( godbolt.org/z/XJYWeu ): продолжайте цикл до тех пор, пока операнды != не были NaN.
Питер Кордес
4
В частности, это -ffast-mathопция, которая неявно включена, -Ofastчто позволяет GCC применять небезопасные оптимизации с плавающей запятой и, таким образом, генерировать тот же код, что и Clang. MSVC ведет себя точно так же: без /fp:fastнего он генерирует кучу кода, который приводит к бесконечному циклу; с /fp:fast, он выдает одну jmpинструкцию. Я предполагаю, что без явного включения небезопасных оптимизаций FP эти компиляторы будут зависеть от требований IEEE 754 в отношении значений NaN. Довольно интересно, что Clang этого не делает. Его статический анализатор лучше. @ 12345ieee
Коди Грей
1
@geza: Если код сделал то, что вы планировали, проверяя, когда математическое значение (float) iотличается от математического значения i, то результат (значение, возвращаемое в returnоператоре) будет 16 777 217, а не 16 777 216.
Эрик Постпищил

Ответы:

49

Как отметил @Angew , !=оператору нужен один и тот же тип с обеих сторон. (float)i != iприводит к продвижению RHS в плавание, так что мы и сделали (float)i != (float)i.


g ++ также генерирует бесконечный цикл, но не оптимизирует работу изнутри. Вы можете видеть, что он преобразует int-> float в cvtsi2ssи ucomiss xmm0,xmm0сравнивает (float)iс собой. (Это была ваша первая подсказка о том, что ваш исходный код на C ++ не означает того, что вы думали, как объясняет ответ @Angew.)

x != xверно только тогда, когда оно "неупорядочено", потому что xбыло NaN. ( INFINITYсравнивает себя в математике IEEE, но NaN этого не делает. NAN == NANложно, NAN != NANверно).

gcc7.4 и более ранние версии правильно оптимизируют ваш код jnpкак ветвь цикла ( https://godbolt.org/z/fyOhW1 ): продолжайте цикл до тех пор, пока операнды x != x не были NaN. (gcc8 и более поздние jeверсии также проверяют выход из цикла, не выполняя оптимизацию на основании того факта, что это всегда будет верно для любого ввода, отличного от NaN). x86 FP сравнивает установленный PF с неупорядоченным.


И, кстати, это означает, что оптимизация clang также безопасна : ей просто нужно, чтобы CSE (float)i != (implicit conversion to float)iбыл таким же, и доказать, что i -> floatэто никогда не NaN для возможного диапазона int.

(Хотя при условии, что этот цикл попадет в UB с переполнением со знаком, ему разрешено испускать буквально любой asm, который он хочет, включая ud2недопустимую инструкцию или пустой бесконечный цикл, независимо от того, каким было тело цикла на самом деле.) Но игнорирование UB с переполнением со знаком. , эта оптимизация на 100% легальна.


GCC не может оптимизировать тело цикла даже с тем, -fwrapvчтобы сделать целочисленное переполнение со знаком четко определенным (как двойное дополнение). https://godbolt.org/z/t9A8t_

Даже включение -fno-trapping-mathне помогает. (По умолчанию GCC, к сожалению, включен,
-ftrapping-mathхотя его реализация в GCC не работает / содержит ошибки .) Преобразование int-> float может вызвать неточное исключение FP (для чисел, слишком больших для точного представления), поэтому с исключениями, которые могут быть размаскированы, разумно не оптимизировать тело цикла. (Поскольку преобразование 16777217в число с плавающей точкой может иметь заметный побочный эффект, если неточное исключение разоблачено.)

Но с -O3 -fwrapv -fno-trapping-math, это 100% упущенная оптимизация, чтобы не компилировать это в пустой бесконечный цикл. Без #pragma STDC FENV_ACCESS ONнего состояние липких флагов, которые записывают замаскированные исключения FP, не является наблюдаемым побочным эффектом кода. Нет int-> floatпреобразование может привести к NaN, поэтому x != xне может быть правдой.


Все эти компиляторы оптимизированы для реализаций C ++, использующих одинарную точность IEEE 754 (binary32) floatи 32-разрядные int.

Цикл с исправленной ошибкой(int)(float)i != i будет иметь UB в реализациях C ++ с узкими 16-битными intи / или более широкими float, потому что вы попадете в UB со знаком целочисленного переполнения до достижения первого целого числа, которое не может быть точно представлено как float.

Но UB с другим набором вариантов, определенных реализацией, не имеет никаких негативных последствий при компиляции для реализации, такой как gcc или clang, с x86-64 System V ABI.


Кстати, вы можете статически вычислить результат этого цикла из FLT_RADIXи FLT_MANT_DIG, определенных в <climits>. Или, по крайней мере, теоретически, если на floatсамом деле подходит для модели с плавающей запятой IEEE, а не для какого-либо другого вида представления действительного числа, такого как Posit / unum.

Я не уверен, насколько стандарт ISO C ++ говорит о floatповедении и будет ли формат, не основанный на полях экспоненты фиксированной ширины и значимости, соответствовать стандартам.


В комментариях:

@geza Мне было бы интересно услышать получившееся число!

@nada: это 16777216

Вы утверждаете, что у вас есть этот цикл для печати / возврата 16777216?

Обновление: поскольку этот комментарий был удален, я думаю, что нет. Вероятно, OP просто цитирует floatперед первым целым числом, которое не может быть точно представлено как 32-битное float. https://en.wikipedia.org/wiki/Single-precision_floating-point_format#Precision_limits_on_integer_values, то есть то, что они надеялись проверить с помощью этого ошибочного кода.

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

(Все более высокие значения с плавающей запятой являются точными целыми числами, но они кратны 2, затем 4, затем 8 и т. Д. Для значений экспоненты, превышающих ширину мантиссы. Могут быть представлены многие более высокие целочисленные значения, но 1 единица на последнем месте (значения) больше 1, поэтому они не являются смежными целыми числами. Наибольшее конечное число floatнаходится чуть ниже 2 ^ 128, что слишком велико для четных int64_t.)

Если какой-либо компилятор выйдет из исходного цикла и напечатает его, это будет ошибкой компилятора.

Питер Кордес
источник
3
@SombreroChicken: нет, я сначала изучил электронику (по некоторым учебникам, которые лежал у моего отца; он был профессором физики), затем цифровой логики и после этого занялся процессорами / программным обеспечением. : P В общем, мне всегда нравилось понимать вещи с нуля, или, если я начинаю с более высокого уровня, мне нравится узнавать хотя бы что-то об уровне ниже, что влияет на то, как / почему все работает на уровне, на котором я думать о. (например, как работает asm и как его оптимизировать, зависит от конструктивных ограничений процессора / архитектуры процессора. Что, в свою очередь, исходит из физики + математики.)
Питер Кордес
1
Возможно, GCC не сможет оптимизировать даже с frapw, но я уверен, что GCC 10 -ffinite-loopsбыл разработан для таких ситуаций.
MCCCS
64

Обратите внимание, что для встроенного оператора !=требуется, чтобы его операнды были одного и того же типа, и при необходимости для этого используются рекламные акции и преобразования. Другими словами, ваше состояние эквивалентно:

(float)i != (float)i

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

Чтобы правильно проверить, что вы хотите проверить, вы должны вернуть результат в int:

if ((int)(float)i != i)
Энгью больше не гордится SO
источник
8
@ Джурис Это УБ. Там нет не один определенного результата. Компилятор может понять, что он может заканчиваться только на UB, и решить полностью удалить цикл.
Иск Фонда Моники
4
@opa ты имеешь в виду static_cast<int>(static_cast<float>(i))? reinterpret_castочевидно UB там
Caleth
6
@NicHartley: Вы говорите, что (int)(float)i != iэто UB? Как вы пришли к такому выводу? Да, это зависит от свойств, определенных реализацией (потому что floatэто не обязательно, чтобы быть двоичным 32 по стандарту IEEE754), но для любой данной реализации он четко определен, если не floatможет точно представлять все положительные intзначения, поэтому мы получаем целочисленное переполнение со знаком UB. ( en.cppreference.com/w/cpp/types/climits определяет FLT_RADIXи FLT_MANT_DIGопределяет это). В общем, печать определяется реализацией, например std::cout << sizeof(int), не UB ...
Питер Кордес
2
@Caleth: reinterpret_cast<int>(float)это не совсем UB, это просто синтаксическая ошибка / неверный формат . Было бы неплохо, если бы этот синтаксис позволял использовать тип float intв качестве альтернативы memcpy(который четко определен), но reinterpret_cast<>, я думаю, работает только с типами указателей.
Питер Кордес
2
@Peter Только для NaN, x != xэто правда. Смотри вживую по колиру . В Си тоже.
Дедупликатор