Наглядное объяснение чисел с плавающей запятой

Автор оригинала: Fabien Sanglard
  • Перевод
image

В начале 90-х создание трёхмерного игрового движка означало, что вы заставите машину выполнять почти не свойственные ей задачи. Персональные компьютеры того времени предназначались для запуска текстовых процессоров и электронных таблиц, а не для 3D-вычислений с частотой 70 кадров в секунду. Серьёзным препятствием стало то, что, несмотря на свою мощь, ЦП не имел аппаратного устройства для вычислений с плавающей запятой. У программистов было только АЛУ, перемалывающее целые числа.

При написании книги Game Engine Black Book: Wolfenstein 3D я хотел наглядно показать, насколько велики были проблемы при работе без плавающей запятой. Мои попытки разобраться в числах с плавающей запятой при помощи каноничных статей мозг воспринимал в штыки. Я начал искать другой способ. Что-нибудь, далёкое от $(-1)^S * 1.M * 2^{(E-127)}$ и их загадочных экспонент с мантиссами. Может быть, в виде рисунка, потому что их мой мозг воспринимает проще.

В результате я написал эту статью и решил добавить её в книгу. Не буду утверждать, что это моё изобретение, но пока мне не приходилось видеть такого объяснения чисел с плавающей запятой. Надеюсь, статья поможет тем, у кого, как и у меня, аллергия на математические обозначения.

Как обычно объясняют числа с плавающей запятой


Цитирую Дэвида Голдберта (David Goldbert):

Для многих людей арифметика с плавающей запятой кажется каким-то тайным знанием.

Полностью с ним согласен. Однако важно понимать принципы её работы, чтобы полностью осознать её полезность при программировании 3D-движка. В языке C значения с плавающей запятой — это 32-битные контейнеры, соответствующие стандарту IEEE 754. Они предназначены для хранения и выполнения операций над аппроксимациями вещественных чисел. Пока я видел только такое их объяснение. 32 бита разделены на три части:

  • S (1 бит) для хранения знака
  • E (8 бит) для экспоненты
  • M (23 бита) для мантиссы


Внутренности числа с плавающей запятой.


Три части числа с плавающей запятой.

Пока всё нормально. Пойдём дальше. Способ интерпретации чисел обычно объясняется с помощью такой формулы:

$(-1)^S * 1,M * 2^{(E-127)}$


Именно это объяснение чисел с плавающей запятой все ненавидят.

И здесь я обычно начинаю терять терпение. Возможно, у меня аллергия на математическую нотацию, но когда я это читаю, в моём мозгу ничего не «щёлкает». Такое объяснение похоже на способ рисования совы:



Другой способ объяснения


Хоть это изложение и верно, такой способ объяснения чисел с плавающей запятой обычно не даёт нам никакого понимания. Я виню эту ужасную запись в том, что она разочаровала тысячи программистов, испугала их до такой степени, что они больше никогда не пытались понять, как же на самом деле работают вычисления с плавающей запятой. К счастью, их можно объяснить иначе. Воспринимайте экспоненту как окно (Window) или интервал между двумя соседними целыми степенями двойки. Мантиссу воспринимайте как смещение (Offset) в этом окне.


Три части числа с плавающей запятой.

Окно сообщает нам, между какими двумя последовательными степенями двойки будет число: [0,1], [1,2], [2,4], [4,8] и так далее (вплоть до [$2^{127}$,$2^{128}$]. Смещение разделяет окно на $2^{23} = 8388608$ сегментов. С помощью окна и смещения можно аппроксимировать число. Окно — это отличный механизм защиты от выхода за границы. Достигнув максимума в окне (например, в [2,4]), можно «переплыть» вправо и представить число в пределах следующего окна (например, [4,8]). Ценой этого будет только небольшое снижение точности, потому что окно становится в два раза больше.

Викторина: сколько точности теряется, когда окно закрывает больший интервал? Давайте возьмём пример с окном [0,1], в котором 8388608 смещений накладываются на интервал размером 1, что даёт нам точность $\frac{(1-0)}{8388608}=0,00000011920929$. В окне [2048,4096] 8388608 смещений накладываются на интервал $(4096-2048) = 2048$, что даёт нам точность $\frac{(4096-2048)}{8388608}=0,0002$.

На рисунке ниже показано, как кодируется число 6,1. Окно должно начинаться с 4 и заканчиваться следующей степенью двойки, т.е. 8. Смещение находится примерно посередине окна.


Значение 6,1 аппроксимированное с помощью числа с плавающей запятой.

Давайте возьмём ещё один пример с подробным вычислением представлением в виде числа с плавающей точкой хорошо известного всем нам значения: 3,14.

  • Число 3,14 положительно $\rightarrow S=0$.
  • Число 3,14 находится между степенями двойки 2 и 4, то есть окно числа с плавающей запятой должно начинаться с $2^1$ $\rightarrow E=128$ (см. формулу, где окно — это $2^{(E-127)}$).
  • Наконец, есть $2^{23}$ смещений, которыми можно выразить расположение 3,14 внутри интервала [2-4]. Оно находится в $\frac{3,14 -2 }{4 - 2} = 0,57$ внутри интервала, что даёт нам смещение $M = 2^{23}*0,57 = 4781507$

В двоичном виде это преобразуется в следующее:

  • S = 0 = 0b
  • E = 128 = 10000000b
  • M = 4781507 = 10010001111010111000011b


Двоичное представление с плавающей точкой числа 3,14.

То есть значение 3,14 аппроксимируется как 3,1400001049041748046875.

Соответствующее значение в непонятной формуле:

$3,14 = (-1)^0 * 1,57 * 2^{(128-127)}$


И, наконец, графическое представление с окном и смещением:


Окно и смещение числа 3,14.

Интересный факт: если модули операций с плавающей запятой были такими медленными, почему в языке C в результате использовали типы float и double? Ведь в машине, на которой изобретался язык (PDP-11), не было модуля операций с плавающей запятой! Дело в том, что производитель (DEC) пообещал Деннису Ритчи и Кену Томпсону, что в следующей модели он будет. Они были любителями астрономии и решили добавить в язык эти два типа.

Интересный факт: те, кому в 1991 году действительно нужен был аппаратный модуль операций с плавающей запятой, могли его купить. Единственными, кому он мог понадобиться в то время, были учёные (по крайней мере, так Intel понимала потребности рынка). На рынке они позиционировались как «математические сопроцессоры». Их производительность была средней, а цена огромной (200 долларов 1993 года — это 350 долларов в 2016 году.). В результате уровень продаж оказался посредственным.



Надеюсь, статья была вам полезна!
Поддержать автора
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 44

    +7

    Более-менее понятно. Интересное следствие: из такой формы следует, что уже у сравнительно небольших чисел, порядка 2^24, падает точность представления даже целой части (потому что мантисса делит всегда на 2^23 элемента, и если элементов между двумя точками будет больше, то каждому второму не достанется своего значения):


    #include <iostream>
    
    using namespace std;
    
    int main() {
      cout << (16777215.f == 16777216.f) << endl;
      cout << (16777216.f == 16777217.f) << endl;
    }

    Выведет 0 и 1, т.е. во float-представлении 16777216 равняется 16777217. Мне кажется, этот пример даже интереснее известного многим 0.1+0.2.


    Интересно было бы почитать в доступной форме, как производятся математические операции с float-числами в таком виде и как можно эффективно перевести число из обычной строки во float.

      +1
      В свете вашего комментария стоит отметить, что в javascript все числа — это double. Там, конечно, диапазон целых значений, вычисляемых без погрешности, больше, но он заметно меньше, чем 2^64.
        0

        После некоторых трюков там можно оперировать 32-битными целыми числами

        +1
        Есть ещё интересный пример:

        1/3 + 1/3 + 1/3 = ?
        Складываем 1/3 сначала 3 раза, затем 30 раз, 300 раз и т.д.:

        float floatValue = 1F / 3F;
        double doubleValue = 1D / 3D;
        decimal decimalValue = 1M / 3M;
        for (int i = 0; i <= 6; i++) {
            float floatResult = 0;
            double doubleResult = 0;
            decimal decimalResult = 0;
            int times = Convert.ToInt32(3*Math.Pow(10,i));
            for (int j = 1; j <= times; j++) {
                floatResult += floatValue;
                doubleResult += doubleValue;
                decimalResult += decimalValue;
            }
            Console.WriteLine("sum 1/3 times: {0}" , times);
            Console.WriteLine("flt = {0}", floatResult);
            Console.WriteLine("dbl = {0}", doubleResult);
            Console.WriteLine("dec = {0}", decimalResult);
            Console.WriteLine();
        }
        Console.WriteLine("flt = {0}", floatValue*3000000);
        Console.WriteLine("dbl = {0}", doubleValue*3000000);
        Console.WriteLine("dec = {0}", decimalValue*3000000);

        Угадайте какой будет результат
        sum 1/3 times: 3
        flt = 1
        dbl = 1
        dec = 0,9999999999999999999999999999

        sum 1/3 times: 30
        flt = 9,999999
        dbl = 10
        dec = 9,999999999999999999999999997

        sum 1/3 times: 300
        flt = 100,0002
        dbl = 99,9999999999997
        dec = 99,99999999999999999999999972

        sum 1/3 times: 3000
        flt = 999,9764
        dbl = 1000,00000000004
        dec = 999,9999999999999999999999720

        sum 1/3 times: 30000
        flt = 9999,832
        dbl = 10000,0000000003
        dec = 9999,999999999999999999997203

        sum 1/3 times: 300000
        flt = 100165,5
        dbl = 99999,9999996892
        dec = 99999,99999999999999999972026

        sum 1/3 times: 3000000
        flt = 976144,6
        dbl = 1000000,00004317
        dec = 999999,9999999999999999720256

        flt = 1000000
        dbl = 1000000
        dec = 999999,9999999999999999999999


        Видно, что при сложении 1/3 три раза float и double дают более точный результат, чем decimal. Правда при большом количестве сложений decimal точнее. С другой стороны, если не складывать 1/3 три миллиона раз, а сразу умножить, то decimal менее точный.

        Всё это как-то связано с 60-ричной системой счисления. Я думаю, что при работе с долями (весами) или с географическими координатами арифметика с плавающей запятой может быть точнее. Если кто-то может рассказать подробней было бы интересно.
          0

          А вот это интересно, для работы бух софта это учитывается? Хотя там 1/3 не бывает в принципе.

            +1
            Там используют фиксированную точку (или просто int).
        • НЛО прилетело и опубликовало эту надпись здесь
            +2

            Там не в знаках считается, а в битах. В статье сказано, что мантисса составляет 23 бита, так что максимальная достижимая точность на целых числах будет, когда между 2^E и 2^(E+1) (отбросим для простоты -127) будет 2^23 чисел, тогда каждому числу можно противопоставить одно значение мантиссы. Если же этих чисел больше, то можно найти такое число, которому уже не сопоставишь своё выделенное значение мантиссы, вот я для примера выбрал участок, где 2^24 чисел, т.е. в два раза больше. Поэтому у чётных чисел есть своё значение мантиссы, а нечётным уже не хватает, и происходит «округление».

          0

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

            0
            операции с плавающей запятой присутствовали в компьютерах с самого начала. Их не было только в специализированных компьютерах и самых дешевых компьютерах общего назначения.
              +1
              Они с самого начала были в «больших» компьютерах (предназначенных в первую очередь для учёных), но очень долго отсутствовали в ПК (предназначенных в первую очередь для секретарш).
            +8
            Мне как раз кажется понятной формула, и кажутся непонятными все эти объяснения с картинками. Формула сразу даёт понимание того, как с этими числами работать, то есть как складывать, умножать и делить, пользуясь их бинарным представлением.
            К тому же тема не раскрыта до конца, потому что, кроме описанной здесь нормализованной формы, есть также денормализованная форма и разные нечисла, типа NaN и Inf.
              0
              Я, конечно, могу ошибаться, но каждому — своё.
              Лично я односимвольные регистрочувствительные имена переменных/классов без обильных комментариев воспринимаю достаточно посредственно. Так что мне эта статья была небесполезна.
                +1
                С формулой понятно, как оперировать этими значениями.
                С картинкой получилось как-то более явно увидеть грабли, спрятанные в арифметике с плавающей точкой.
                  +4
                  Это вы увидели только первый слой граблей. А там внизу еще целая куча. И основная — поддержка точности. Точность теряется на всех этапах вычислений, так что конечный результат может не иметь ничего общего с действительностью, несмотря на то, что все используемые в расчетах формулы были правильными.
                  0
                  Формула ужасна, объяснение, а особенно картинка, отличные. Наверное, нужно быть математиком, чтобы сходу понять, как эти числа делить глядя только на формулу.
                    0
                    Проблема такой математической записи, что она понятна только тем кто понимает. Простите за каламбур. Математическая запись точна, отбрасывает всё лишнее, но обывателю в большинстве случаев вообще не понятна.
                    Я например смог понять как с ней работать только после прочтения этой статьи, где было всё объяснено человеческим языком и показано на конкретном примере. Теперь смотря на формулу, всё становится на свои места и понятно, почему она такая и как с ней работать. Но это требует не математического, а «человеческого» объяснения.

                    Другой пример — производная функции
                    Производная функции — предел отношения приращения функции к приращению независимой переменной при стремлении последнего к нулю (если этот предел существует)

                    Определение точно? Точно. Понятно? Нет. В ответ на это определение надо выяснять, что теперь делать с этим произведением и зачем оно вообще надо. Если же сказать, что производная функции — это скорость изменения функции в конкретной точке, становится понятно, но при этом не точно. Аналогичная история происходит в этой статье. Формула нужна для точного понимания, а картинки для первичного понимания сути.
                      0
                      Я с вами согласен в том смысле, что формула не даёт понимания, когда смотришь на неё в первый раз. Но для объяснения принципа представления вещественных чисел в формате IEEE 754 в нормализованной форме достаточно разобрать один-два примера.
                      От статьи на хабря я обычно жду полноты, то есть, хорошо было бы написать про денормализованные числа, про нечисла и правила их обработки, про разные варианты, предусмотренные стандартом, включая децимальный формат, а также 128- и 256-битные числа, про 80-битный формат, который был принят в x86 изначально, про разные исторические и необычные форматы (типа модульного логарифмического). Вот это было бы интересно.
                    0
                    экспонента — расстояние между шагами
                    мантиса — номер шага

                    в статье не увидел, почему записывают
                    3,14 = 1,57 *2 ( 2 в степени 1 равно 2, то есть экспонента 128-127 = 1)

                    вместо
                    3,14 = 3,14*1 (2 в степени 0 равно 1, то есть экспонента 127-127 = 0)
                      +1
                      в двоичном виде мантиса 1.M, то есть выигрывают 1н бит, а в десятичном может быть 1.M или 9.M
                      0
                      2в23 * 0,57 = 4781506.56
                        +2
                        Нет окна [0, 1]. Есть только [1/2, 1), [1/4, 1/2) и так далее. 0 в нормализованном представлении отобразить нельзя, поэтому он искусственно принят как (E=0, M=0).
                          +1
                          Не могу согласиться с утверждением, что у процессора PDP-11 не было модулей плавающей точки. Был FIS (Floating Instruction Set) и кое-где FPP (Floating Point Processor)
                            0
                            Статья доходчиво объясняет сложную для понимая вещь. Спасибо за перевод автору!
                            Но остался для меня один непонятный момент:

                            То есть значение 3,14 аппроксимируется как 3,1400001049041748046875.


                            Я не понял, как вычисляется вот эта часть 0,0000001049041748046875?
                              0
                              (1)10010001111....011b = 13170115d /4/2/2.../2 = 3,1400001049041748046875.
                                +1
                                M = 2^23*0.57 = 8388608 *0.57 = 4781506,56
                                4781507 — 4781506,56 = 0.44
                                0.44*2/8388608 = 0,0000001049041748046875
                                +2

                                А есть процессоры или теории с другим представлением чисел с запятой? Вообще описанное представление оптимально или исрользуется по-традиции?

                                  0
                                  del
                                  +1
                                  В языке C значения с плавающей запятой — это 32-битные контейнеры, соответствующие стандарту IEEE 754

                                  неправда:
                                  типов с плавающей запятой несколько
                                  они платформозависимы
                                  IEEE 754 в С99 носит рекомендательный характер, а С11 ссылается на более поздние стандарты
                                    0
                                    Как же вы будете читать статьи типа такой:
                                    sites.math.washington.edu/~morrow/336_12/papers/ben.pdf

                                    Если простая матзапись числа с плавающей запятой непонятна?
                                      0
                                      Может нубский вопрос, а чем интересно такое представление отличается в плане скорости/точности?
                                      (-1)^S * 0.S * 10 ^ (E-127)
                                        0
                                        Возможно, вы имели в виду: (-1)^S * 0.M * 10 ^ (E-127)
                                        Такое представление отличается тем, что приведение в него неоднозначно: 0.001 можно записать как 0.001*10^0 или как 0.01*10^-1 или как 0.1*10^-10
                                        При этом приведение к форме (-1)^S * 1.M * 10 ^ (E-127) однозначно, потому что ведущая единица в числе только одна.
                                          0
                                          Да, ошибся. Ну хорошо, допустим:
                                          (-1)^S * 1.M * 10 ^ (E-127)
                                          Чем это хуже чем:
                                          (-1)^S * 1.M * 2 ^ (E-127)
                                          ?
                                          С основанием 10 всё понимается в разы легче, по крайней мере для нас, 10-чных людей.
                                            +1
                                            Хотя понял что с основанием 1.M не получится представить 2…
                                              +1

                                              Что там можно представить — вопрос вторичный, т.к. это решаемо в любом случае. Старшую единицу сделали неявной просто потому, что оказалось можно сэкономить 1 бит, вряд ли кто-то специально к этому стремился заранее.
                                              А двоичные порядки потому что процессор вообще обычно двоичный, и создавать какую-то специальную недвоичную логику ради малопонятных целей резона нет. А там где надо вывести в понятном виде человеку — двоичные порядки легко конвертируются в десятичные соответствующей программой.

                                              0
                                          0
                                          Число 3,14 находится между степенями двойки 2 и 4, то есть окно числа с плавающей запятой должно начинаться с 2^1 →E=128 (см. формулу, где окно — это 2(E−127)).

                                          откуда тут взялось что 2^1 →E=128 ??? как объяснить не привлекая формулу?
                                            0
                                            Не привлекая формулу, можно считать так:
                                            Окно | Е

                                            [1,2] | 127
                                            [2,4] | 128
                                            [4,8] | 129
                                            0
                                            Тут было бы уместно рассмотреть такую задачу.

                                            Представление чисел с плавающей точкой имеет погрешность. Число пи имеет погрешность. 2*pi имеет еще большую погрешность, 3*pi еще больше, и так далее. Каким должно быть N в формуле N*pi, чтобы cos(N*pi) посчитанный на ieee-754 дал 0 или 1? (N в этой задаче — целое, разумеется)
                                              0
                                              Есть сайт с подробным объяснением работы чисел с плавающей запятой (как на русском, так и на английском). Хорошие пример, объясняются тонкости и неожиданные для математика свойства. Вот здесь. Сайт сырой, а вот именно этот материал в основе изложен полностью.
                                                0
                                                Что-то меня эта тема с «окнами» ввела в еще большее заблуждение. По логике автора оригинальной статьи получается, что чем большее число мы хотим представить, тем ниже будет точность. Это если я правильно понял «викторину».
                                                  0
                                                  А разве не так?
                                                    0
                                                    Все так. Взял бумажку, освежил знания по привычным формулам и понял, что точность теряется.
                                                    0
                                                    В хроме нажал F12, в консоле вписал:
                                                    Number.MAX_SAFE_INTEGER
                                                    получил число: 9007199254740991.
                                                    Это двойная точность = 64 бит, знак 1, мантиса 52, експонента 11.
                                                    Если к этому числу, например, добавить 1н, то будет происходить потеря точности.
                                                    А в пределах до этого числа, целые числа не теряют точность.
                                                    Всё что влезет в мантису, не теряет точность, даже дробные значения.
                                                    Но много чего не представимо в 52 бита. Например двоичный результат от 0.1+0.2.
                                                    Значение усекается до 52 бит и следовательно точность теряется.

                                                    Кстати (0.1+0.2)-0.3 = 5.551115123125783e-17

                                                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                                  Самое читаемое