Pull to refresh

Comments 30

Доброго дня, мне кажется есть небольшая путаница при разборе таких чисел в 10-м представлении таких чисел. (Разберу для double)
Рассмотрим ряд для 52 бит в двоичной системе:
0.1, 0.11, 0.111 ... 0.111..11{52 еденицы} им соответствуют десятичные:
0.5, 0.75, 0.875 ... 0,999..75{тоже 52 цифры!!!} то есть каждый бит в двоичной дроби - добавляет разряд в десятичной системе. И здесь и есть точное равенство двоичного представления.
Но представление в десятичное системе (как в статье) - обрежет и округлит их до 16 цифр в десятичном представлении. Это мы и видим во всем операциях-представлениям в статье все выводы в консоль - это десятичные представления округленные.
Точность в 16 знаков говорит о том, что изменяя любые цифры в числе выше (0,999..75) после 16-го знака в двоичной системе, нам будет давать то же самое число в переводе в двоичную систему. (На примере из статьи - можно сколько угодно добавлять знаков к десятичному представлению после 16-го символа). Поэтому для все конвертеры в десятичную строку и занимаются округлением.

Если отметить, как в статье, 0.1 в десятичной - бесконечная дробь в двоичной системе, то понятно почему 0.1f != 0.1d. (т.к. 0.00011001100..001{24 цифры для float} != 0.00011001100..001 {52 цифры для double}).

Может немного муторно написал, но хотел пояснить вот что:
0.1f - после перевода компилятором в двоичную будет выглядеть так:
0.000110011001100110011001101. Оно же в 10-й (в точности):
0.100000001490116119384765625
Представим, как это делает ToString():
0.1 - это округление для float
0.10000000149011612 - округление для double, хотя число одно и тоже в двоичном виде и целиком влезает во float и double. (можно проверить (double)(0.1f) == 0.1f).

Я, видимо, зря не заострил внимание на этом моменте в статье. Считать таким образом десятичные цифры бессмысленно, потому что имеют значение только те цифры, которые находятся в пределах погрешности, цифры, лежащие за погрешностью, значения не имеют. Когда, например, пишут, что float имеет точность 6-9 десятичных цифр, это значит, что от 6 до 9 цифр будут точно правильными, а дальше как повезёт. Как, например, для значения 0.1 отклонение начинается с 9-й значащей цифры. Для отдельных значений, являющихся суммами степеней двойки, точность может оказаться существенно выше и даже, как вы правильно заметили, дойти до 52 цифр. Но это - выбросы в статистике, для всех остальных чисел точность будет намного меньше.

Понятно, что не имеет смысла (это только строго математически важно). Я скорее для понимания хотел добавить, что то, что выглядит разным в десятичном представлении, может быть одинаковым в двоичном.

double d = 0.1f;

float f = 0.1f;

Assert.True(d == f);//значения равны

Assert.False(d.ToString() == f.ToString());//представления не равны

Монументально, большое спасибо.

Могу добавить что есть ещё библиотечный тип half

Спасибо за фундаментальный обзор! Но у меня есть глупый вопрос:

> .NET Framework, разумеется, не предоставляет возможности напрямую изменять управляющее слово FPU

А как это слово инициализируется при старте программы? Разве ключи компиляции не позволяют это настроить? И если вдруг да, то как это на практике может работать? Например, что произойдет, если вдруг сторонняя функция изменит предустановленные настройки уже в рантайме?

Оффтопик с соседней улицы

У меня в устаревшем компиляторе Интел (фортран, 2013г) есть вот такой набор настроек FP-вычислений.

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

работает- не трогай

Впрочем, один раз потрогать все же пришлось, когда оптимизатор неожиданно накосячил и

"забыл" вынуть из FPU результат вычисления

Я в одном месте вызывал функцию, которая возвращает результат FP-вычисления, но потом этот результат не использовал (от вызова функции мне нужен был побочный эффект). Пока я все собирал в режиме debug (без оптимизаций), все работало. А при включенной оптимизации начинало глючить - но очень редко и странно. Например генератор случайных чисел примерно один раз в 10 млн. вызовов вместо числа возвращал Nan.

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

совсем в другом месте при следующем обращении к FPU

Даже не знаю, сколько я слез пролил с этим багом, пока один мудрый человек не посоветовал включить контроль FPU-стека. После чего мое "UB" локализовалось в нужной точке, и дальше все стало просто.

Но после чтения Вашей статьи возникло подозрение, что на некоторые из описанных в ней эффектов (о которых я в общем-то знал, но избегал неприятностей другими способами) можно повлиять с помощью этих настроек?

Или нет?

С фортраном никогда не имел дела, но по моему небольшому опыту работы с интеловскими компиляторами для C++ я знаю, что они нацелены на то, чтобы выжать максимум именно из интеловских процессоров. В то время как компиляторы Microsoft стараются не использовать функции, доступные только на этих процессорах, чтобы код был более переносимый. Как пример, в C++ есть тип long double, который, согласно спецификации языка, совпадает с double или обеспечивает более высокую точность. В компиляторах Microsoft long double - это double, в компиляторах Intel это 80-битный тип, соответствующий по формату регистрам FPU.

> А как это слово инициализируется при старте программы?

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

> Разве ключи компиляции не позволяют это настроить?

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

>  Например, что произойдет, если вдруг сторонняя функция изменит предустановленные настройки уже в рантайме?

По сути, функция _controlfp, использующаяся в примерах, это и есть тот случай, когда сторонняя функция изменяет настройки. Возможно, они вернутся обратно после вызова какого-то метода .NET. По опыту работы с нативными приложениями знаю, что некоторые функции WindowsAPI (например, CreateWindow) переводят FPU в режим 53-битной мантиссы, поэтому результаты расчётов до вызова этой функции и после могут различаться.

Как пример, в C++ есть тип long double, который, согласно спецификации языка, совпадает с double или обеспечивает более высокую точность. В компиляторах Microsoft long double - это double, в компиляторах Intel это 80-битный тип, соответствующий по формату регистрам FPU.

Боюсь, я совсем от жизни отстал. Я по наивности думал, что если я у себя в фортране напишу что-то вроде:

real (kind = 16) :: my_internal_real_var
real (kind = c_long_double) :: real_var_to_pass

то это как правило будет одно и то же, то есть 128-битный real. Даже прикидывал, как я этим смогу воспользоваться когда-нибудь в будущем...Получается, что все ровно наоборот, и как правило это будет совсем не одно и то же?

Расставаться с иллюзиями (что "мир устроен правильно и мудро" (с)) всегда неприятно, но все равно спасибо, что вернули мечтателя к суровой реальности ;-)

UPD:

Ну да, все печально - страница из справки фортрана

т.е. наличие прямых соответствий не только от компилятора зависит, но еще и от ОС...Просто от нас, программистов на фортране, все это скрывают (пока не понадобится связка с другим языком)...

Вообще, простые типы делятся на фундаментальные (base types) и обобщённые (generic types; не путать с generics - обобщениями, т.е. параметризованными типами). Фундаментальные типы - это типы, которые в любой реализации остаются одинаковыми, а обобщённые - те, которые могут меняться от реализации к реализации, чтобы наилучшим образом соответствовать конкретной среде. Так, в Паскале типы SmallInt и LongInt фундаментальные, это всегда и везде будет 16-битное и 32-битное целые соответственно. А тип Integer - обобщённый, в 16-разрядных версиях он был 16-битным, в 32-разрядных стал 32-битным. Не знаю, как с этим обстоят дела конкретно в Фортране, но общий подход сейчас такой.

простые типы делятся на фундаментальные и обобщённые (...). Не знаю, как с этим обстоят дела конкретно в Фортране, но общий подход сейчас такой.

В старом фортране часто писали просто "integer", и это было похоже на паскалевский  Integer (если не указать при компиляции разрядность такого "обобщенного" типа явно, то умолчание могло зависеть от реализации). Сейчас такой стиль тоже поддерживается для совместимости, но чаще все-таки пишут количество байт прямым текстом, например "INTEGER(KIND=8)". В теории, это должно обеспечить лучшую переносимость программ.

Кстати, 128-битных целых (KIND=16) в интеловском фортране 2013 года нет, только real.

У компилятора C# нету опций относительно срезания углов с вещественными числами, потому что он следует букве стандарта IEEE754. А компилятор C++ можно попросить пренебречь достоверностью в ущерб скорости (fast math), но это скорее вольность языка.

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

Можете почитать вот тут. Если кратко, то всё сломается, и придётся ремонтировать :)

Это вы ещё си++ не смотрели, у которого в настройках компилятора аж целых 3 (три) режима работы с вещественными числами.

Смотрел, просто решил, что писать ещё и об этом - совсем статью раздувать. Там, помнится, можно указывать, использовать SSE или FPU, а ещё есть опция, которая позволяет компилятору ради скорости максимально упрощать выражения. С пометкой, что такое упрощение иногда может приводить к неправильным результатам. В частности, код для расчёта машинного эпсилон с этой опцией выдаёт 0, так как компилятор решает, что в выражении 1 + d/2 > 1 слева и справа можно сократить единицу, и упрощает его до d/2 > 0, а потом решает, что при сравнении с нулём на 2 делить не обязательно, и выражение превращается в d>0. В итоге деление идёт до тех пор, пока переменная не обратится в ноль.

...есть опция, которая позволяет компилятору ради скорости максимально упрощать выражения (...) код для расчёта машинного эпсилон с этой опцией выдаёт 0

Это в не-интеловском компиляторе? Я проверял в интел-фортране в режиме Floating Point Model = Fast; Floating Point Speculation = Fast (вроде бы это аналог?) с оптимизацией на максимальную скорость. Там этот код корректно работает. Хотя проще наверно все-таки встроенную функцию вызвать:

r=EPSILON(r)

Конечно это фортран, но по идее, интеловский С++ должен так же себя вести?

Это я писал про компилятор из MS Visual Studio. Там есть опция /fp:fast. В справке про неё написано следующее:

Параметр /fp:fast позволяет компилятору изменять порядок, объединять или упрощать операции с плавающей запятой, чтобы оптимизировать код с плавающей запятой для скорости и пространства. Компилятор может пропускать округление при инструкциях присваивания, typecasts или вызовах функций. Он может переупорядочение операций или алгебраические преобразования, например, с помощью ассоциативных и распределительных законов. Он может изменить порядок кода, даже если такие преобразования приводят к заметно отличающимся поведением округления. Из-за этой расширенной оптимизации результат некоторых вычислений с плавающей запятой может отличаться от результатов, созданных другими /fp вариантами. Специальные значения (NaN, +infinity, -infinity, -0,0) не могут распространяться или вести себя строго в соответствии со стандартом IEEE-754. Сокращения с плавающей запятой могут создаваться в ./fp:fast Компилятор по-прежнему привязан к базовой архитектуре в /fp:fast, и с помощью параметра могут быть доступны дополнительные оптимизации /arch .

В /fp:fastкомпилятор создает код, предназначенный для выполнения в среде с плавающей запятой по умолчанию, и предполагает, что среда с плавающей запятой не используется или не изменяется во время выполнения. То есть предполагается, что код: оставляет исключения с плавающей запятой маской, не считывает и не записывает регистры состояния с плавающей запятой и не изменяет режимы округления.

/fp:fast предназначен для программ, которые не требуют строгого порядка исходного кода и округления выражений с плавающей запятой и не используют стандартные правила для обработки специальных значений, таких как NaN. Если код с плавающей запятой требует сохранения порядка и округления исходного кода или использует стандартное поведение специальных значений, используйте /fp:precise. Если код обращается к среде с плавающей запятой или изменяет ее для изменения режимов округления, распаковки исключений с плавающей запятой или проверка состояния с плавающей запятой, используйте ./fp:strict

https://learn.microsoft.com/ru-ru/cpp/build/reference/fp-specify-floating-point-behavior?view=msvc-170&f1url=%3FappId%3DDev16IDEF1%26l%3DRU-RU%26k%3Dk(VC.Project.VCCLCompilerTool.floatingPointModel)%26rd%3Dtrue

FPU в современных процессорах эмулируется на SSE.

Забудьте про FPU.

FPU может работать с 80-битной точностью, а SSE — нет, соответственно и проэмулировать его никак не получится.

Тогда откройте даташит Intel и почитайте, что регистры FPU алиасятся на MMX (которые есть часть SSE), и одновременно они работать не могут. Также можете посмотреть историю даташитов с таймингами инструкций и увидите, что в какой-то момент FPU стали намного медленнее чем MMX - в этот момент от FPU откусили добрую половину транзисторов, а FPU стал легаси.

Современные компиляторы больше не генерят инструкции под FPU. По крайней мере без серьёзных плясок с бубном (попробуйте заставить gcc тот же сгенерировать код под x87)

Спасибо, я более чем хорошо чем хорошо знаком с интеловским даташитом, и о MMX-ах речь вроде не шла. Современных компиляторов, которые могут генерить код для FPU, в том числе в 64-битном режиме — полно, тот же D. FPU станет легаси только тогда, когда SSE/AVX начнёт quadruple precision поддерживать, а этого пока что увы не наблюдается.


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

Отличный сборник заблуждений для начинающих программистов... Из которых сделано немало ошибочных выводов.

Какие конкретно выводы вы считаете ошибочными?

Прошу прощения за долгое ожидание, не всегда есть достаточно времени подойти к компу. Итак, порция первая:

В своём базовом варианте стандарт IEEE 754 содержит описание двух форматов хранения вещественных чисел: число одинарной точности (размер 4 байта, минимальное отличное от нуля значение 1.4e-45, максимальное значение 3.4e38, точность 6-9 десятичных знаков) и число двойной точности (размер 8 байт, минимальное отличное от нуля число 4.9e-324, максимальное число 1.8e308, точность 15-17 десятичных знаков). В .NET (Framework) им соответствуют типы System.Single (в C# для него имеется синоним float) и System.Double (синоним double).

Для двоичных чисел некорректно говорить о десятичных знаках.

Начнём с простого вопроса: что будет, если сравнить одинаковые вещественные числа, хранящиеся в переменных различного типа?

Console.WriteLine("Equality of double and float");
f = 0.109344482421875f;
d = 0.109344482421875;
Console.WriteLine($"f == d for 0.109344482421875: {f == d}");
f = 0.1f;
d = 0.1;
Console.WriteLine($"f == d for 0.1: {f == d}");

Тут следовало бы сказать, что за числа в первом и во втором случае. В первом - число 3583/2^15, то есть представимое точно при наличии хотя бы 15 знаков в мантиссе. Во втором случае число не является представимым точно и поэтому для мантиссы разной длины будет отсечение в разных местах с разными последствиями. Поэтому в абзаце

Обе платформы показывают одинаковый результат, и этот результат прямо противоположен тому, который ожидается интуитивно. Давайте разберём подробно, какие результаты мог бы ожидать человек, знакомый с вещественными числами только поверхностно. Точность значения типа float составляет 6-9 десятичных знаков, а типа double – 15-17. В числе 0.109344482421875 мы имеем пятнадцать знаков, поэтому при записи в переменную типа double это значение сохранится без изменений, а при записи в переменную типа float будут отброшены как минимум шесть знаков, и в лучшем случае будет записано значение 0.109344482. При сравнении же значение типа float будет расширено до double, недостающие разряды будут заполнены нулями, и будут сравниваться числа 0.10934448200000 и 0.109344482421875, в результате мы получим false. А число 0.1 будет записано в обе переменные без усечений, поэтому и при сравнении получим, что значения равны.

идёт балабольство чистой воды только ради того, чтобы написать портянку текста.

Для двоичных чисел некорректно говорить о десятичных знаках.

Вы можете переадресовать свои претензии к разработчикам справки Microsoft, откуда я взял эти цифры: https://learn.microsoft.com/ru-ru/dotnet/csharp/language-reference/builtin-types/floating-point-numeric-types?devlangs=csharp&f1url=%3FappId%3DDev16IDEF1%26l%3DRU-RU%26k%3Dk(double_CSharpKeyword)%3Bk(TargetFrameworkMoniker-.NETFramework%2CVersion%253Dv4.8)%3Bk(DevLang-csharp)%26rd%3Dtrue

Говорить о точности в смысле десятичных знаков можно, надо только понимать, что скрывается за этим утверждением. Никто не говорит о том, что десятичные знаки там хранятся в каком-то виде, речь совсем о другом. Мы берём произвольное вещественное число и представляем его, например, в виде числа одинарной точности с минимально возможной погрешностью. Записываем получившееся значение как десятичное число. Так вот, точность 6-9 знаков означает, что как минимум 6 десятичных знаков у исходного и у получившегося чисел совпадают, а у большинства чисел совпадёт не больше 9 знаков ("большинство" тут, скорее всего, означает применение правила трёх сигм или аналогичного).

Тут следовало бы сказать, что за числа в первом и во втором случае. В первом - число 3583/2^15, то есть представимое точно при наличии хотя бы 15 знаков в мантиссе. Во втором случае число не является представимым точно и поэтому для мантиссы разной длины будет отсечение в разных местах с разными последствиями. 

Там так и сказано, только позже. Статья ориентирована на человека, который до этого никогда не вникал в тонкости внутреннего представления вещественных чисел. Поэтому я выбрал такой способ изложения: сначала привёл процитированные вами кажущиеся правдоподобными, но неправильные рассуждения (предупредив, что они неправильные), потом изложил теорию, а потом уже объяснил, как там всё на самом деле.

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

Последняя операция сравнения добавлена в пример для того, чтобы показать, что, хотя -0 и 0 различимы, их сравнение даёт интуитивно ожидаемый положительный результат.

IEEE754 требует считать +0 и -0 равными числами, однако в .NET Framework была бага, когда при сравнении структур, где были среди прочего поля float/double использовалось побитовое сравнение, что приводило к тому, что такие структуры не были равны, несмотря на то, что сравнение попарно полей было равно, если среди значений были +0 и -0 в соответствующей паре.

Возможно, в ранних версиях .NET Framework такая ошибка и в самом деле была. Но даже если и так, то сейчас этой ошибки нет, сравнения выполняются правильно (в т.ч. и сравнение NaN с самим собой, которое тоже дало бы неверный результат). О том, что свои утверждения я проверял только на .NET Framework 4.8, я предупредил в начале статьи. Вы же не будете утверждать, что наличие ошибок в каких-то старых версиях делает неверными мои утверждения насчёт текущей версии, свободной от этих ошибок?

Резюмирую. На данный момент имеем следующие ваши претензии к статье:
1. Про количество десятичных знаков - выше объяснил, почему считаю свои утверждения корректными.
2. Претензию не к выводам, а к стилю изложения.
3. Претензию к тому, что я ничего не написал про ошибку, которая отсутствует в современных версиях .NET Framework.

На данный момент я так и не увидел от вас ни одного подтверждения ваших слов об ошибочных выводах, которых, как вы утверждаете, "немало".

Признаю свою ошибку с тем, что поторопился по существу.

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

Поэтому первая мысль - "хрень какая-то" и закрыть - оказалась поспешной.

Значение типа decimal занимает в памяти 8 байт – 64 бита. Из них 1 бит отводится на знак, 96 – на целочисленное базовое значение B, 8 бит отводится на степень делителя F, 23 бита не используются. 

Наверное всё-таки 16 байт -128 бит, или четыре int.

Спасибо, сейчас поправлю.

это что за платформа такая .Net? Программирую еще с начиная с .NET Framework 1.1 и не слышал о такой классификации, есть .NET Framework и .Net Core, а .Net это что за зверь или автору лень 5 дополнительных символов писать?

Sign up to leave a comment.

Articles