Нет числа мемам и шуткам про то, как в программировании 0,2 + 0,2 равно не 0,4, а 0,40000009... Все привыкли к подобным ограничениям, проистекающим из стандарта IEEE754. Но как мы к нему пришли, что из себя представляют FPU-модули для работы с плавающей запятой, как ARM-процессоры до недавнего времени обходились без них? Да и откуда вообще в математике возникла концепция плавающей запятой? Попробуем разобраться во всём этом, а заодно попробуем на практике в коде.

С чего всё начиналось

Представьте себе математика начала XX века, пытающегося объять необъятное: ему приходится иметь дело с числами поистине космического масштаба и в то же время с величинами настолько малыми, что они ускользают от человеческого восприятия. Как ухватить эти крайности, подчинить их строгим законам математики? Именно такой вызов бросили числа учёным и инженерам. И те приняли его, выдвинув революционную идею — числа с плавающей запятой.

В 1914 году Леонардо Торрес-и-Кеведо, испанский инженер и изобретатель, предложил использовать экспоненциальную запись для представления чисел в вычислительных машинах. Это была простая, но гениальная идея. Представьте себе число как комбинацию двух частей — мантиссы и экспоненты. Мантисса отвечает за точность, а экспонента — за масштаб. Передвигая запятую влево или вправо, мы можем менять масштаб числа, сохраняя его точность. Это как волшебная лупа, позволяющая разглядеть мельчайшие детали или охватить взглядом целую Вселенную.

Но путь от идеи до воплощения оказался долгим и тернистым. Только в 1950-х, когда появились первые электронные компьютеры, плавающая запятая из абстрактной идеи начала превращаться в практический инструмент. Уильям Кэхэн, профессор математики и один из пионеров компьютерной науки, взялся за решение проблем точности и согласованности вычислений. Его работа стала фундаментом для будущего стандарта IEEE 754, который по сей день остаётся «конституцией» мира плавающей запятой.

От теории к практике: первые шаги

Первые компьютеры напоминали неуклюжих гигантов. Они оперировали целыми числами и понятия не имели о дробях или экспонентах. Программирование под них было сродни попытке объясниться с иностранцем, не зная его языка. Но потребности научного мира росли, и вместе с ними росло желание научить компьютеры говорить на языке математики.

В середине 1950-х случилась тихая революция: IBM представила IBM 704 — первый коммерческий компьютер, способный работать с числами с плавающей запятой. Это было всё равно, что дать ребенку первый учебник иностранного языка. Компьютер начал постигать азы нового языка, на котором говорит Вселенная.

Но на пути к универсальному языку чисел оставалось ещё немало преград. Каждая компьютерная система имела свои особенности, свои «диалекты» плавающей запятой. Программисты, переходя с одной модели компьютеров на другую, чувствовали себя как путешественники в чужой стране, пытающиеся понять местные обычаи. Нужен был единый стандарт, общий язык, на котором могли бы говорить все компьютеры мира.

IBM-704, 1957 год

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

Аппаратная реализация: рождение FPU

Но в 1960-х появились первые аппаратные FPU-модули — специализированные блоки внутри процессора, оптимизированные для операций с плавающей запятой. Как будто строителям дали кирпичи и бетон вместо песка: процесс ускорился в разы, а конструкции стали прочнее и надёжнее. FPU превратились в своеобразные «математические сопроцессоры», разгружающие основной процессор от сложных вычислений.

1970-е годы стали золотым веком аппаратной поддержки плавающей запятой. Компании-производители процессоров начали создавать специализированные математические сопроцессоры. Эти чипы были похожи на миниатюрных гениев математики, способных молниеносно справляться с самыми сложными вычислениями. 

Процессор Intel 8087, выпущенный в 1980 году, стал настоящей легендой компьютерного мира. Он превратил персональные компьютеры из простых текстовых машинок в мощные инструменты для научных расчётов и инженерного проектирования. С ним обычный ПК мог соперничать по вычислительной мощности с большими мейнфреймами. У обычного человека вдруг появился личный гений-математик, готовый в любой момент решить самую сложную задачу.

Intel 8087

Стандарт IEEE 754: универсальный язык чисел

В 1985 году произошло событие, навсегда изменившее мир вычислений: родился стандарт IEEE 754. Если раньше каждый производитель компьютеров говорил на своём «диалекте» плавающей запятой, то теперь у всех появился общий язык. Стандарт чётко определил правила представления чисел, выполнения операций и обработки исключительных ситуаций. Это было похоже на появление эсперанто в мире разноязыких компьютеров.

IEEE 754 ввёл несколько форматов представления чисел, различающихся точностью и диапазоном. Одинарная точность (32 бита) стала универсальным выбором для большинства задач. Двойная точность (64 бита) предназначалась для ситуаций, требующих повышенной точности и широкого диапазона. А расширенная точность (80 бит) использовалась в особых случаях, когда каждый бит был на счету.

Стандарт определил структуру числа с плавающей запятой: знак, экспонента, мантисса. Универсальный шаблон, по которому любое число могло быть представлено в двоичном виде. Появились специальные значения — положительная и отрицательная бесконечность, а также NaN (Not a Number). Они позволили компьютерам корректно реагировать на ситуации, которые раньше приводили к ошибкам и сбоям.

Современное применение: от научных расчётов до нейросетей

Числа с плавающей запятой — вездесущие трудяги цифрового мира. Они незаметно делают свою работу в недрах процессоров, графических ускорителей, микроконтроллеров. Без них немыслимы современные научные исследования, инженерное проектирование, создание реалистичной графики и спецэффектов в кино и играх.

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

Выбор правильного формата чисел с плавающей запятой для нейросетей — это настоящее искусство. Одинарная точность (FP32) долгое время была стандартом, обеспечивая оптимальный баланс между точностью и производительностью. Но с ростом размеров нейросетей и объёмов данных возникла потребность в ускорении вычислений. И тут на сцену вышла половинная точность (FP16), позволяющая уместить в памяти вдвое больше чисел и ускорить обработку на специализированных ускорителях. Некоторые исследователи идут ещё дальше, экспериментируя с форматами вроде bfloat16 или даже int8, где точность приносится в жертву скорости.

ARM и плавающая запятая

История отношений между ARM-процессорами и плавающей запятой напоминает непростой роман. Изначально ARM разрабатывалась как энергоэффективная архитектура для встраиваемых систем и мобильных устройств. В таких применениях производительность в операциях с плавающей запятой не была приоритетом — всё равно что гоночный болид для поездок за хлебом. Поэтому первые ARM-процессоры обходились без встроенного FPU, выполняя операции с плавающей запятой программно, медленно и не всегда точно.

Но времена менялись, и амбиции ARM росли. В 1995 году появился первый ARM-совместимый FPU-сопроцессор, подключаемый к процессору опционально. Это было похоже на первое свидание после долгой дружбы — волнующее, но немного неловкое. Потребовалось ещё почти десять лет, чтобы отношения перешли на новый уровень. В 2005 году появилась архитектура ARMv7, с её приходом модуль для работы с числами с плавающей запятой VFP (Vector Floating Point), поддерживающий одинарную и двойную точность, стал практически повсеместным в ARM-процессорах, закрепившись как стандарт. 

Сегодня процессоры ARM используют не только в смартфонах и планшетах, но и в ноутбуках, серверах и даже суперкомпьютерах. Такие гиганты как Amazon, Microsoft и Google строят на ARM-процессорах системы для высокопроизводительных вычислений и машинного обучения. И в этом им помогает продвинутая поддержка плавающей запятой, сочетающая высокую производительность и энергоэффективность. 

ARM 1 на материнской плате – первый коммерческий процессор на архитектуре RISC, выпущенный 26 апреля 1985 года

Преобразование числа: магия под капотом

Для обычного пользователя числа с плавающей запятой выглядят совершенно обыденно. Вот число пи, 3,14 — что может быть проще? Но под капотом компьютера происходит настоящая магия преобразования. Представьте себе этот процесс как работу невидимого переводчика, который переводит числа с человеческого языка на язык двоичных кодов.

Первым делом определяется знак числа — плюс или минус. Затем целая и дробная части преобразуются в двоичную систему счисления. Следующий шаг — нормализация числа. Двоичная запятая сдвигается так, чтобы слева от неё осталась только одна единица. Это похоже на то, как мы выделяем главную мысль в предложении, отодвигая менее важные детали. Число обретает вид «1,мантисса × 2^экспонента». Напомню, что мантисса отвечает за точность, экспонента — за масштаб.

Наконец, все части упаковываются в единую структуру согласно формату IEEE 754. Знак, экспонента и мантисса занимают строго отведённые им биты, образуя двоичное представление исходного числа.

В университетах на курсах компьютерной архитектуры, студенты обычно выполняют все эти операции вручную на бумаге, однако мы можем упростить этот этап и провести эксперимент по реализации стандарта IEEE754 в коде на Python:

def float_to_ieee754(number, precision=30):
    # Вложенная функция для конвертации числа с плавающей запятой в бинарное представление
    def convert_to_binary(decimal_number, places):
        # Разделяем целую и дробную части числа
        whole, dec = str(decimal_number).split(".")
        whole = int(whole)
        # Конвертируем целую часть в бинарный вид и добавляем запятую
        result = (str(bin(whole)) + ".").replace('0b', '')

        # Конвертируем дробную часть
        for _ in range(places):
            dec = str('0.') + str(dec)
            temp = '%1.20f' % (float(dec) * 2)
            whole, dec = temp.split(".")
            result += whole
        return result

    # Определяем знак числа (0 для положительных, 1 для отрицательных)
    sign = 0
    if number < 0:
        sign = 1
        number = number * (-1)
    
    # Получаем бинарное представление числа
    binary_representation = convert_to_binary(number, places=precision)

    # Находим позицию запятой и первой единицы в бинарной строке
    dot_place = binary_representation.find('.')
    one_place = binary_representation.find('1')

    # Убираем запятую и корректируем позиции, если первая единица правее запятой
    if one_place > dot_place:
        binary_representation = binary_representation.replace(".", "")
        one_place -= 1
        dot_place -= 1
    # Убираем запятую и корректируем позиции, если первая единица левее запятой
    elif one_place < dot_place:
        binary_representation = binary_representation.replace(".", "")
        dot_place -= 1
    
    # Формируем мантиссу, начиная с первой единицы
    mantissa = binary_representation[one_place + 1:]

    # Вычисляем экспоненту и переводим её в сдвиговый формат
    exponent = dot_place — one_place
    exponent_bits = exponent + 127

    # Конвертируем экспоненту в 8-битное бинарное представление
    exponent_bits = bin(exponent_bits).replace("0b", '').zfill(8)

    # Ограничиваем мантиссу первыми 23 битами
    mantissa = mantissa[0:23]

    # Формируем окончательное 32-битное представление IEEE 754
    ieee754_binary = str(sign) + exponent_bits + mantissa

    # Переводим бинарное представление в шестнадцатеричное
    hex_representation = '0x%0*X' % ((len(ieee754_binary) + 3) // 4, int(ieee754_binary, 2))

    return (hex_representation, ieee754_binary)

if __name__ == "__main__":
    # Пример с положительным числом
    print(float_to_ieee754(263.3))
    # Пример с отрицательным числом
    print(float_to_ieee754(-263.3))

Заключение

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

История чисел с плавающей запятой — это история человеческой изобретательности, стремления к точности и желания объять необъятное. От первых вычислительных машин до современных суперкомпьютеров и нейронных сетей, плавающая запятая прошла долгий путь, став универсальным языком науки и техники. И кто знает, какие ещё тайны и возможности скрывает в себе эта элегантная математическая абстракция? Возможно, новые форматы и архитектуры позволят нам эффективнее решать задачи будущего — от моделирования сложных систем до создания искусственного интеллекта, превосходящего человеческий разум. А может быть, когда-нибудь мы научимся обходиться без плавающей запятой вовсе, используя принципиально новые подходы к вычислениям.

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

Комментарии (22)


  1. SIISII
    20.08.2024 07:23
    +3

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


    1. askv
      20.08.2024 07:23
      +5

      Его ещё почему-то назвали процессором, хотя в то время называли сопроцессором.


      1. HardWrMan
        20.08.2024 07:23
        +3

        При всей его "сопроцессорности", он сидел на шине параллельно, собственно, процессору 8086 и поддерживал все машинные циклы, необходимые для арбитража шины. При этом, он вычленял из потока опкодов свои команды, которые сам процессор молотил как NOPы по сути. Т.е. команды сопроцессору дополняли таблицу опкодов процессора. Ну и у процессора был ещё префикс LOCK, запрещающий сопроцессору использовать шину в пределах действия префикса, что гарантировало процессору доступность шины для завершения залоченных команд (сопроцессор мог занимать шину достаточно надолго).


      1. SIISII
        20.08.2024 07:23
        +2

        Ну, он сопроцессором и был -- в частности, он не мог самостоятельно выбирать команды, это делал настоящий процессор (8086). Собственно, сопроцессоры, которые не являлись обязательными, но могли наращивать возможности основного процессора -- вещь не новая; скажем, у многих процессоров PDP-11 были необязательные FPU, которые тем или иным путём следили за выборкой команд и перехватывали управление, когда видели свою команду. Это, конечно, не микропроцессоры, но идея довольно близкая.


        1. askv
          20.08.2024 07:23
          +1

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


          1. SIISII
            20.08.2024 07:23
            +3

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

            Как в 8086 -- честно говоря, не помню, а поднимать документацию и разбираться лениво :) Но, по идее, тоже должно быть прерывание.


            1. askv
              20.08.2024 07:23
              +1

              Да, наверняка прерывания. Я просто подзабыл уже, что такой механизм существует :)


            1. HardWrMan
              20.08.2024 07:23
              +3

              У х86 есть исключения #InvalidOpcode (вектор 6) и #CoprocessorNotAvaiable (вектор 7).


          1. voidptr0
            20.08.2024 07:23

              EVERYTHING YOU ALWAYS WANTED TO KNOW ABOUT MATH COPROCESSORS

            https://docs.google.com/document/d/1SLnsJjShN-8lkj2LxcH979TF3_kzxVu_QIAZtOWbj4U/edit


  1. askv
    20.08.2024 07:23

    В ранних версиях турбо-паскаля sin(1000) существенно не попадал в диапазон [-1,1].


  1. qiper
    20.08.2024 07:23
    +1

    Одинарная точность (32 бита) стала универсальным выбором для большинства задач. Двойная точность (64 бита) предназначалась для ситуаций, требующих повышенной точности и широкого диапазона. А расширенная точность (80 бит) использовалась в особых случаях, когда каждый бит был на счету.

    Когда каждый бит на счету, используется их минимальное количество, а не максимальное)


    1. SIISII
      20.08.2024 07:23

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


  1. Pi-man
    20.08.2024 07:23
    +5

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


  1. S_WW
    20.08.2024 07:23

    откуда вообще в математике возникла концепция плавающей запятой?

    В математике плавающей запятой никогда не было. Она появилась в вычислительной технике.


    1. askv
      20.08.2024 07:23

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


      1. S_WW
        20.08.2024 07:23

        Что такое "вычисления через логарифмы"? Если вы говорите об умножении чисел путём сложения их логарифмов, то там этой "концепции" и близко нет. Плавающая запятая - это хранение числа в виде двух чисел: мантиссы и порядка. Для математики в этом нет необходимости, она работает с абстрактными числами независимо от способа их записи и хранения.


        1. askv
          20.08.2024 07:23

          А как, вы думаете, физики, астрономы записывали большие (маленькие) числа при вычислениях?


          1. S_WW
            20.08.2024 07:23

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


            1. askv
              20.08.2024 07:23

              Я отвечал на Ваше утверждение "Она появилась в вычислительной технике." Как я понял, Вы уже сами от него отказались.


              1. S_WW
                20.08.2024 07:23

                Формат чисел с плавающей точкой появился в вычислительной технике. А физики используют экспоненциальную запись чисел.


                1. askv
                  20.08.2024 07:23

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


  1. S_WW
    20.08.2024 07:23
    +1

    Долго читал ожидая, когда же начнётся что-то интересное или полезное. Увы, так и не дождался.