Сложение вещественных чисел с разными знаками

Рейтинг: 6Ответов: 3Опубликовано: 15.03.2023

Пытаюсь понять, как происходит сложение вещественных чисел в формате binary32 (он же single precision или просто float в большинстве языков программирования), но немного не понимаю, почему ожидаемый результат отличается от реального.

Есть 2 вещественных числа, которые представлены в формате binary32 стандарта IEEE754: -987.6543 и 1234.5678. С самим стандартом ознакомлен, поэтому основные принципы понимаю.

Числа имеют разные порядки, поэтому я изначально поднял порядок числа -987.6543 на одно значение, чтобы порядки обоих операндов были равны. При изменении порядка числа -987.6543 самый младший бит был отсечён, так как при сдвиге вышел за пределы установленной разрядной сетки, а бит нормализации перешёл в дробную часть мантиссы, таким образом, число утратило свою нормализованную форму (числа предварительно записаны в двоичном формате, и все операции выполняются непосредственно в двоичной системе счисления).

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

Ну и сама мантисса получилась немного другой после в конечном итоге, так как результат для мантиссы должен быть 11101101110100111011011, а у меня получается 11101101110100111011000. Не могу понять природы двух последних единиц, которые располагаются в младших битах мантиссы. Помогите найти ошибку, пожалуйста!

Ход моих действий следующий:

  1. Представление двух вещественных чисел в формате binary32:
  • -987.6543 — 1 10001000 11101101110100111100000
  • 1234.5678 — 0 10001001 00110100101001000101011
  1. порядок числа -987.6543 (привожу его к порядку числа 1234.5678), что влечёт за собой сдвиг внутри мантиссы. До изменения порядка мантисса была 1.11101101110100111100000, а после изменения у нас произошёл сдвиг на 1 позицию вправо. Отсекаем лишний бит и получаем 0.11110110111010011110000.

  2. Представляю дробную часть мантиссы отрицательного числа в дополнительном коде: 0.00001001000101100010000

  3. Произвожу сложение дробных частей мантисс: 00110100101001000101011 + 00001001000101100010000 = 00111101101110100111011. Если сложить целые части, то получается 1. Это один из моментов, который сбивает с толку. Целые части в данном случае не учитывается и если числа имеют разные знаки, то он всегда денормализован? Объясните этот момент, пожалуйста.

Но даже если я считаю результат денормализованым и совершаю сдвиги до тех пор, пока не получу нормализованную форму, я всё равно получаю в качестве конечного результата 11101101110100111011000, так как освободившееся пространство заполняю нулями. Все калькуляторы указывают на то, что результатом для финальной мантиссы должно быть 11101101110100111011011. По какому принципу добавили единицы? Это округление? Или что здесь происходит за кулисами? Что я не учёл?

Ответы

▲ 5Принят

Вы уверены насчёт всех калькуляторов? Пожалуйста, огласите весь список.

Я проверил ваши вычисления в Go, процессор Intel Core i7, ОС Windows 10: https://go.dev/play/p/XSHAXUF1843

Вот вывод программы:

x:       -987.6543 11000100011101101110100111100000 <sign: 1, 1.11101101110100111100000 * 2^(10001000-127)>
y:       1234.5677 01000100100110100101001000101011 <sign: 0, 1.00110100101001000101011 * 2^(10001001-127)>
x+y:     246.91345 01000011011101101110100111011000 <sign: 0, 1.11101101110100111011000 * 2^(10000110-127)>

Мнение Go (читай, аппаратуры x86_64) и ваши вычисления совпадают: мантисса (без ведущей единицы) равна 11101101110100111011000

Того же мнения, кстати, придерживается калькулятор http://weitz.de/ieee/: бинарное значение суммы равно 0b01000011011101101110100111011000 - тоже никаких единиц введите сюда описание изображения

Кстати, обратите внимание, что 1234.5678 в двоичном представлении округляется до 1234.5677

UPDATE

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

Например, если складывать по правилам 32-х битных целых, мантисса суммы должна быть 11101101110100111011000, но некоторые калькуляторы показали мантиссу 11101101110100111011011. Такая мантисса получится, если вести вычисления в binary64 или binary128, а затем обрезать мантиссу до 23-х бит.

Также ТС провёл эксперимент по вычислению суммы в base64 и получил мантиссу 1110110111010011101101100100010110100001110010110000, в то время как онлайн калькулятор выдал мантиссу 1110110111010011101101100100010110100001110010101100. Причина расхождения та же: калькулятор вёл вычисления в binary128, а потом уменьшил разрядность до binary64.

UPDATE 2

Вспомнил про такой инструмент, как GNU MPFR - библиотека вещественных вычислений произвольной точности. Сама библиотека на Си, и непосредственно на Си ей пользоваться несколько неудобно, зато есть отличная обёртка для Python, gmpy2.

Сделал демонстрационный блокнот Google Colab, который складывает числа из примера и выводит результат с различной точностью. Для вычислений использовалась точность 256 бит (размер мантиссы): слагаемые в десятичном представлении и соответствующие мантиссы

-987.6543000000000000000000000000000000000000000000000000000000000000000000000004
 -1.111011011101001111000000000110100011011011100010111010110001110001000011001011001010010101111010011110000110110000100010011010000000100111010100100101010001100000101010100110010011000010111110000011011110110100
101000100011001110011100000011101011111011100

1234.5678
 1.001101001010010001010110110101011100111110101010110011011001111010000011111001000010010110101110111001100011000111111000101000001001000000101101111000000000110100011011011100010111010110001110001000011001011001010010101111010011110000110110000100010011010

Результат:

246.9134999999999999999999999999999999999999999999999999999999999999999999999994
 1.111011011101001110110110010001011010000111001010110000001000001100010010011011101001011110001101010011111101111100111011011001000101101000011100101011000000100000110001001001101110100101111000110101001111110111110011101101100100010110100001110010101100000
▲ 4

Попытаюсь расскрыть тему как я ее понимаю, анализ строил на основе калькулятора, который собственно и дает результат с 11 на конце.

На мой взляд проблема не в сложении чисел точно. Так как я просто работал с конечным результатом 246.9135 (246.9135 = 246 + 0.9134999999999991 так части разбиваются в исходниках калькулятора, дробная получается путем вычета целой части, что влечет появление девяток, после 4х девяток анализ числа заканчивается, но это просто заметка)

Сам расчет мантисы дробной части в калькуляторе происходит справа в столбике (решил не переносить сюда). Там видно, что мы считаем только тысячные разряды (.913) так что 5 которая теоретически становится 4-кой с 9-ми в периоде нас не интересует.

Мантиса у нас получается такой (результаты из столбика расчетов)

1110 1001 1101 1011📌 0010 00 (22 bit)

Тут я пометил те самые биты 11 которые считаю мы и обсуждаем.

Далее, у нас есть еще Characteristic (Binary) или запись целого числа 11110110 (8 bit)

Как я понимаю происходит их стыковка, точнее первые 16 бит дробной мантисы Мантисы

1110100111011011001000 добавляюся после 1.1110110 те же 8 бит записанные как дробь. И мы получаем научную нотацию:

1.11101101110100111011011×27

Которая визуально соответствует IEEE-754 за исключением точки и ведущей единицы которая отбрасывается за своей очевидностью:

11101101110100111011011 Из чего я делаю вывод, что они имеют общую природу.

Что касается экспоненты, то, насколько я понимаю она высчитывается так:

   0111111 
 +     111 
  10000110 = Exponent

Добавляем 7 так как 27 в научной нотации

Число знака или Sign Bit = 0, так как наше число положительное (при отрицательном 1)

И таким образом, у меня не возникает противоречий и сомнений в подсчетах, но возможно я что-то упустил.

▲ 4

Большое спасибо всем, кто подбросил правильные мысли и направил в нужное русло! Готов констатировать, что правильных ответов оказалось фактически несколько, так как во всех суждениях было рациональное зерно.

Теперь хотелось бы перейти к самой сути вопроса. Действительно, было интересно наблюдать за тем, как различные калькуляторы не могут прийти к единому результату одной и той же арифметической операции и предлагают минимум 2 альтернативных решения. Я не могу сказать, что какие-то программы допускают ошибку в вычислительном процессе, но если мы хотим прийти к максимально точному результату (пусть даже приближённому), то необходимо действовать через расширение двоичной разрядной сетки в ходе вычислений.

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

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

Да, стоит признать, что обычное представление некоторого вещественного числа по канонам стандарта IEEE754 и произведение между 2 числами любой арифметической операции — это далеко не одно и то же! Сам стандарт крайне рекомендует прийти к выбору правильной стратегии округления, если мы хотим достичь максимальной точности. Она не всегда нужна, поэтому погрешности тоже имеют место и это вполне нормально. Тем не менее, в нашем примере мы имеем место с операндами, которые имеют по модулю разные порядки. Но ещё более критичным моментом является не это, а то, что порядок итогового результата отличался от порядка приведённых операндов на 3 значения.

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

Моё предложение заключается в том, чтобы на начальном этапе высчитать разницу между порядком бóльшего операнда и порядком результирующего числа. Как уже писалось чуть выше, мы сдвинули мантиссу на 3 шага влево, чтобы прийти к нормализованной форме. Но что критичного для точности произошло в результате этого сдвига? Мы получили 3 незаполненных младших бита мантиссы, места которых освободились и должны быть чем-то заполнены. С потолка мы придумывать значения не будем, поэтому и заполняем их нулями. Вот здесь всё и скрывалось...

Я решил эту проблему путём компенсирующего расширения мантиссы на 3 разряда, которые у нас освобождаются в ходе нормализации результата. Изначально представил исходные числа так, будто бы хранимая часть мантиссы имеет длину не 23 бита (для binary32), а 26 бит.

Мантиссы получились следующие:

  • 1234.5678 — 00110100101001000101011011
  • -987.6543 — 11101101110100111100000000

Таким образом, все вычисления производились уже внутри 26-разрядной битовой сетки, а не 23-разрядной, как изначально.

Выравниваю порядок числа -987.6543 с числом 1234.5678 (увеличиваю порядок операнда -987.6543 на 1 значение): 11110110111010011110000000. Здесь у нас значение бита нормализации перешло в дробную (хранимую) часть мантиссы, а младший бит мы отсекли, так как в результате сдвига он не помещается в нашу расширенную разрядную сетку.

Перевожу значение отрицательного числа в дополнительный код: 00001001000101100010000000. А затем просто складываю мантиссы в рамках единого порядка: 00110100101001000101011011 + 00001001000101100010000000 = 00111101101110100111011011. Приводим число к нормализованному виду через понижение порядка (сдвигаем мантиссу до тех пор, пока разряд со значением 1 не встанет на место неявного бита нормализации). Для этого мы и сдвигаем мантиссу на 3 шага влево. Получаем итоговую мантиссу 1.11101101110100111011011, где 11101101110100111011011 будет храниться явно. Да, у нас снова освободилось 3 разряда в хвосте нашего числа, но эти разряды нам уже совершенно неинтересны, так как результат мы будем записывать в 23-разрядную мантиссу формата binary32.

Итак, вывод! Если мы хотим получить наиболее точный результат, то все вычисления необходимо проводить в расширенной разрядной сетке, величина которой будет больше фиксированного размера для выбранного формата представления на количественную разницу между порядком большего операнда и порядком результирующего числа (благодарю Stanislav Volodarskiy за наводку). Но ещё больше мне нравится мысль (и скорее всего так и работает большая часть калькуляторов) о том, что если нам нужен максимально точный результат для заданного формата, то нам проще проводить вычисления в более расширенном формате, а результат записывать (ту часть, которая поместится) уже в целевом формате. Единственная проблема заключается в том, что если мы захотим представить результат с наименьшей погрешностью в binary128 или binary256, то проще будет воспользоваться расширением разрядной сетки на необходимое количество бит, так как есть риск дойти до предела. На эту мысль меня подтолкнул уважаемый Pak Uula, за что ему очень сильно благодарен!

Буду рад любым комментариям и замечаниям!