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

Давным давно на лекциях ПЯВУ (Программирование на языках высокого уровня) нам рассказали о вещественных числах. Первая информация была поверхностная. Ближе познакомился с ними уже после того, как закончил учёбу в университете, и это знакомство заставило сильно задуматься. А произошло это знакомство после того как мы в расчётах не влезли в тип данных double.

Досталась мне программа, написанная на языке C++ с использованием компилятора Borland Turbo C++. Для вычислений в ней использовался тип данных double, т.е. вещественный тип двойной точности. В определенные моменты времени программа этот самый double переполняла и успешно падала. В работе программы вычислялся факториал, а максимальный факториал, который может поместиться в double это 170! ? 7,3306. Вычисление факториала 171!?1,2309 вызвало переполнение типа данных double. Именно проблема с переполнением и привела к исследованию современного положения в вычислениях с вещественными числами. Подробнее об этом далее в статье.

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

С языком программирования всё просто и стандартизировано. На нашем ненавистном любимом языке C++ есть три вещественных типа данных: float, double и long double, соответсвенно одинарной, двойной и больше чем двойной точности. Причем стандартом языка сказано, что “the type long double provides at least as much precision as double”. То есть long double должен быть не меньше чем double. Именно этой лазейкой в стандарте разработчики компилятора Borland Turbo C++ и воспользовались, приравняв long double к double.

С архитектурой x86 тоже не всё гладко. Для простейших математических операций (сложение, вычитание, умножение, деление, сдвиги, вычисление математических функций sin, cos, и т.п.) разработчики процессоров предусматривают соответствующие регистры. Регистры условно можно разделить на те, которые работают с целочисленными числами и на те, которые работают с вещественными числами. Бывают процессорные архитектуры, в которых отсутствуют регистры для работы с вещественными числами. Например, ARMv7. В таких случаях время операций над вещественными числами возрастает на несколько порядков, так как эти операции теперь нужно эмулировать программно с использованием целочисленных регистров и операций сложения, вычитания и сдвига. Вычисление, например, тригонометрических функций программно могло замедлить вычисления на несколько порядков, так как такие функции приближенно вычисляются с использованием математических рядов.

Лирическое отступление. Именно с такой проблемой мы столкнулись на одном из проектов. Необходимо было подсчитывать количество людей, проходящих под камерой. Использовали встроенное решение с ARMv7 для обработки видео в режиме реального времени. Распознавали и считали прошедших людей. А обработка изображения — это работа с вещественными числами, которых в используемой архитектуре как раз и не было. Пришлось переходить на более продвинутое аппаратное решение, но это уже другая история. Вернемся обратно.

Широко используемая x86 архитектура до выхода процессора 80486, тоже не имела вещественных регистров. Старожилы помнят наверное такую вещь как математический сопроцессор, который устанавливался рядом с обычным процессором и имел соответствующее обозначение (8087, 80287 или 80387) и работал без активного охлаждения и даже без радиатора. Именно появление сопроцессора 8087 послужило толчком к появлению стандарта IEEE 754-1985, о нем поразмышляем позже.

image

Эти сопроцессоры добавляли три абстрактных вещественных типа данных, восемь 80-битных регистров и кучу ассемблерных команд для работы с ними. Теперь, условно, за один такт можно было вещественные числа сложить, вычесть, умножить, разделить, а также извлечь корень, посчитать тригонометрическую функцию и т.п. Ускорение вычислений достигло 500% на специфических задачах. А на задачах обработки текста никакого ускорения не было, потому и ставили этот сопроцессор за 150$ опционально. Музыку тогда редко кто на компьютере слушал, а видео так вообще было не для широкого пользователя.

Начиная с процессоров серии 80486 сопроцессор стали интегрировать в сам процессор. Кроме Intel486SX, этот процессор вышел позже и имел отключенный сопроцессор. Физически от остальных процессоров он особо не отличался. Видимо, Intel решила реализовать и бракованные экземпляры с ошибками в области сопроцессора.

Рассмотрим подробнее вещественные регистры математического сопроцессора. Хотя, на самом деле, это регистр одного вида. Большой, 80-ти битный, и в наличии их 8 штук стеком. Но программисту доступны три вида абстракции вещественных чисел: короткий (одинарный) формат (single precision), длинный (double precision) и расширенный формат представления чисел (extended precision). Здесь русский перевод терминов дан из книги [1]. Характеристики вещественных чисел представлены в таблице:
image

Если программист выбирал для использования, например, короткий формат (32 бита), то сопроцессор вставлял число в 80-ти битный регистр, производил над ним операции, а потом возвращал обратно число в уменьшенном размере, если в процессе работы происходил выход за пределы короткого формата, то возвращался NaN (not a number — не число).

Дальнейшее развитие x86 архитектуры добавило кучу расширений (MMX, SSE, SSE2, SSE3, SSSE3, SSE4, SSE5, AVX, AVX2, AVX-512 и др.), а вместе с расширениями новые регистры длинной 128, 256, 512 бит[2], и кучу новых ассемблерных команд для работы с ними. Эти расширения предоставляют возможность для работы только с вещественными числами одинарной и двойной точности, например, каждый 512 битный регистр способен работать либо с четырьмя 64-битными числами двойной точности, либо с восемью 32-битными числами одинарной точности.

От размышлений на тему архитектуры перейдём к компиляторам. На языке программирования C++ тип данных float соответствует 32-х битным вещественным числам x86 архитектуры, double 64-х битным, а вот с long double всё намного интереснее. Как было сказано выше многие разработчики компиляторов пользуются допущением стандарта и делают тип long double равным double. Но “железо” x86-ое позволяет оперировать расширенным 80-ти битным форматом. И есть компиляторы, которые позволяют ими воспользоваться. Рассмотрим компиляторы подробнее.

Как ни странно, но среди игнорирующих 80-ти битный расширенный формат представления данных много известных и широко применяемых компиляторов, вот неполный список: Microsoft Visual C++, C++ Builder, Watcom C++, Comeau C/C++. А вот список компиляторов поддерживающих расширенный формат довольно интересен: Intel C++, GCC, Clang, Oracle Solaris Studio. Рассмотрим компиляторы подробнее.

В компиляторе от Intel не могло не быть расширенного формата — как же производитель оставит свое железо без соответствующего инструмента? Пользование компилятором не бесплатное. Компилятор широко используется в научных расчетах и в высокопроизводительных многопроцессорных системах.

Свободный компилятор GCC легко поддерживает расширенный формат под операционной системой Linux. С Windows всё интереснее. Существует две адаптации компилятора под операционную Windows: MinGW и Cygwin. Обе могут манипулировать расширенным форматом, но MinGW использует runtime от Microsoft и это означает, что вещественные числа, которые превышают 64-х битный double увидеть/вывести куда-либо не удастся. С Cygwin всё немного лучше, так как портирование более комплексное.

Clang аналогично GCC, поддерживает расширенный формат.

Ну и немного об Oracle Solaris Studio, ранее Sun Studio. Под конец существования корпорация Sun, сделала доступными многие свои технологии. Включая свой компилятор. Изначально он был разработан для ОС Solaris с процессорами архитектуры SPARC. Позднее операционную систему вместе с компилятором портировали и под x86-ую архитектуру. Компилятор вместе с IDE доступен под операционной системой Linux. К сожалению этот компилятор “подзаброшен” и не поддерживает последних веяний языка C++.

Для решения озвученной в начале статьи проблемы переполнения формата double, после всех размышлений, страданий и исканий, было решено полностью переписать код и использовать особенности компилятора GCC Cygwin. Был использован тип данных long double для хранения данных. Производительность аналогичных систем использующих 64-х и 80-ти битные вещественные числа отличается. При использовании 64-х битных вещественных чисел компилятор старается все оптимизировать и использовать самые быстрые “новейшие” расширения x86 архитектуры. При переходе на 80-ти битные числа задействуется “древняя” “сопроцессорная” часть архитектуры.

Конечно же, можно было решить проблему переполнения, задействовав программный метод обработки больших вещественных чисел, но тогда падение производительности было бы значительным, так как программа рассчитывала математические модели содержащие тригонометрические функции, извлечение корня и вычисление факториала. Работа по расчету модели с использованием расширенного формата занимала около 8 — 12 часов процессорного времени, в зависимости от входных параметров.

В конце статьи немного поразмышляем о стандарте IEEE 754 [3,4,5]. Первая версия стандарта, как было отмечено, вышла благодаря именно математическому сопроцессору 8087. Последующие версии данного стандарта выходили в 1997 и 2008 годах. Именно стандарт 2008 года наиболее интересен. В нём описаны вещественные числа четверной точности (квадрупольной, quadruple-precision floating-point format)[6]. Именно этот формат хранения данных оптимально подошел бы для вышеописанной задачи. Но он не реализован в доступной процессорной архитектуре распространенных компьютеров. С другой стороны, x86 архитектура давно уже имеет регистры (128, 256, 512 бит) нужного размера, но они служат для быстрой работы с несколькими числами одинарной и двойной точности. Я встречал в интернете информацию о том, что корпорация Intel собиралась в будущих процессорах внедрить поддержку четверной точности, но видимо это осталось только на бумаге.

Из современных архитектур, которые железно поддерживают четверную точность можно выделить архитектуры SPARC V8 и V9. Хотя они и появились ещё в 1990 и 1993 годах соответственно, но физическая реализация четверной точности появилась только в 2004-м году. В 2015 году IBM выпустила спецификацию POWER9 CPU (ISA 3.0), в которой есть поддержка четверных вещественных чисел.

Точность четверных вещественных чисел широкому кругу пользователей излишна. Она, в основном, используется в научных расчетах. Например в астрофизических вычислениях. Именно этим можно объяснить, что выпускавшиеся в 70-80-х годах компьютеры IBM360 имели поддержку вещественных чисел размером 128 бит, но, конечно же, не соответствующих современному стандарту IEEE 754. Пользовались этой машиной в основном в научных расчётах.

Так же скажем пару слов о российском разработчике процессоров МЦСТ. Данная компания разрабатывает и производит процессоры архитектуры SPARC. Но, что интересно, сначала они разработали и выпустили процессоры “старой” архитектуры SPARC V8 (МЦСТ-R150 в 2001 году и МЦСТ R500 в 2004-м) без поддержки вещественных чисел четверной точности, хотя новая архитектура SPARC V9 уже давно была. И только в 2011 году выпустили процессор МЦСТ R1000 с архитектурой SPARC V9 с поддержкой вещественных чисел четверной точности.

Ещё пару слов о стандарте IEEE 754. В интернете есть интересная статья[3], в которой, весьма эмоционально, описываются проблемы и недостатки существующего стандарта. В статье[4] также описывается стандарт и его проблемы. Кроме того, в ней сказано о потребности новых подходов в представлении вещественных чисел. В двух вышеуказанных статьях описаны многие недостатки представления чисел, со своей стороны добавлю вот что. В программировании есть такой термин как “костыль” это нечто неправильное, но помогающее в данный момент времени решить текущую проблему не самым оптимальным образом. Так вот, вещественные числа соответствующие стандарту IEEE754 являются костылем.

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

Увлекательные материалы и источники:


  1. Юров В.И. Assembler. Учебник для вузов. 2-е изд. – СПб.: Питер 2005
  2. x86
  3. Юровицкий В.М. IEEE754-тика угрожает человечеству
  4. Яшкардин В. IEEE 754 — стандарт двоичной арифметики с плавающей точкой
  5. Статьи в википедии посвященные стандарту IEEE 754: раз, два и три.
  6. Статья в википедии посвященная четверной точности вещественных чисел

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


  1. FGV
    12.04.2018 15:24
    +1

    Так вот, вещественные числа соответствующие стандарту IEEE754 являются костылем.

    а примеры некостылей можно?


  1. geher
    12.04.2018 15:37

    Причем стандартом языка сказано, что “the type long double provides at least as much precision as double”.

    На микроконтроллерах ATmega328 и не только все еще интереснее.
    Там по количеству разрядов double совпадает с float.


    1. scalpelism
      12.04.2018 16:52
      +2

      Если мне не изменяет память, в атмегах нет регистров для работы с вещественными числами.

      Там по количеству разрядов double совпадает с float.
      Это зависит от компилятора.


      1. geher
        13.04.2018 11:12

        Если мне не изменяет память, в атмегах нет регистров для работы с вещественными числами.

        Вроде нет, но я не об аппаратной части, а о типах с плавающей точкой.
        Ведь в том же x86 факт реализации или не реализации long double в разных компиляторах (аппаратная часть одна, а реализация отличается) тоже никак не зависит от аппаратной части


        Это зависит от компилятора.

        Не подскажете ли компилятор с нормальным double для ATmega.
        А то точность вычислений с плавающей точкой никакая (мало разрядов после точки).
        Приходится извращаться вместо простых арифметических операций с double.


        1. scalpelism
          13.04.2018 13:41

          Не подскажете ли компилятор с нормальным double для ATmega.
          Похоже на то, что IAR поддерживает:
          Floating-point values are represented by 32- and 64-bit numbers in standard IEEE 754 format. If you use the compiler option --64bit_doubles, you can make the compiler use 64-bit doubles. The data type float is always represented using 32 bits.
          Правда он платный, с триалом на месяц или ограничением по объему бинарника.


  1. Cheater
    12.04.2018 17:33
    +2

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


    Немного странно, что вы не рассматривали способ «провести рефакторинг кода так, чтобы код не использовал значения больше DBL_MAX». Что у вас за задача такая, в которой нужны числа с плавающей точкой от 64 до 80 бит и нельзя обойтись значениями до DBL_MAX? Какая-то библиотека отдаёт данные в расширенном формате? Или в коде захардкожена обработка именно расширенных значений, причём так, что это не изменить, не порушив всю архитектуру?


    1. Cheater
      12.04.2018 21:11

      Или речь идёт всё ещё про проблему с факториалом из начала статьи, в котором double переполнялся, и для борьбы с этим вы применяете long double? А long double не переполняется? :) Я не видел вашу задачу, но даже задача «безопасно вычислить большой факториал» — проблема довольно нетривиальная. Подобную «грязную работу» лучше скидывать на специализированную библиотеку, например на gmplib.


      1. old_bear
        12.04.2018 22:23

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


        1. nikolayv81
          13.04.2018 13:52

          Очень много кода пишется с использованием «поверхностных оценок исходя из опыта», а в теорию погрешностей и т.п. начинают вникать только тогда когда появляются проблемы с несовпадением результатов и «ожиданий».
          Есть очень жизненный пример из «другой области» мост в Волгограде.
          Но если для строителей это обычно всё-же проблемы с безопасностью построенного объекта, то для программистов/разработчиков, это просто небольшая вероятность «небольших багов» в продукте, и в итоге из-за разного уровня потерь в «случае чего» таким серьёзным анализом алгоритмов мало кто занимается «сразу».


  1. lorc
    12.04.2018 20:44

    Вообще-то ARMv7 поддерживает float point. Правда, опционально, в качестве расширения. Так что наличие VFP зависит только от производителя SoC.


  1. RolexStrider
    12.04.2018 22:21

    Последующие версии данного стандарта выходили в 1997 и 2008 годах

    От того же комитетета, который в своё время не нашел внутри себя консенсуса, по причине чего Intel по-сути «стукнули кулаком по столу» и сказали: «Не можете никак определиться — так пусть будет по-нашему, ибо бизнес не ждет пока вы там в своей „академической среде“ договоритесь?


    1. VEG
      13.04.2018 10:18

      Интересно было бы почитать какие варианты предлагались, какие были у них плюсы или минусы по сравнению с тем что в итоге реализовал Intel.


  1. homm
    13.04.2018 01:28

    например, каждый 512 битный регистр способен работать либо с четырьмя 64-битными числами двойной точности, либо с восемью 32-битными числами одинарной точности.

    У вас всё хорошо с математикой?


  1. paluke
    13.04.2018 10:15

    В компиляторе от Borland размер long double всегда был 80 бит (10 байт).
    docwiki.embarcadero.com/RADStudio/Tokyo/en/Constants_And_Internal_Representation


    1. nikolayv81
      13.04.2018 13:56

      Тоже удивило это утверждение, т.к. в Borland Pascal-е (и Delphi в последствии) был тип extended использующий сопроцессор.


  1. Antervis
    13.04.2018 11:24
    +1

    А что в этой статье полезного? Вы просто описали историю вещественных типов данных

    MMX, SSE, SSE2, SSE3, SSSE3, SSE4, SSE5, AVX, AVX2, AVX-512 и др.… Эти расширения предоставляют возможность для работы только с вещественными числами одинарной и двойной точности

    Неправда. Все основные целочисленные операции поддерживаются с SSE2/AVX2/AVX-512BW соответственно


    1. homm
      13.04.2018 11:28

      Более того, в MMX только целые и есть. А SSE5 вообще что за зверь )


  1. iga2iga
    13.04.2018 13:38

    Могу ошибаться, просто в статье был упомянут C++ Builder, который игнорирует 80 бит… У Delphi в до-XE'шную эпоху компилятор всегда использовал «сопроцессор» и соответственно был тип Extended (80 bit). Builder вроде тоже и аналогом у него являлся/является long double. Сейчас в XE используется только SSE, хотя надо бы проверить, что сгенерит компилятор если поставить тип extended. Аж интересно стало.


    1. iga2iga
      13.04.2018 13:52

      Сам себя поправлю 32 бит компилятор использует только «сопроцессор» и extended доступен. 64 бит компилятор использует только double тип даже если указать extended и только sse.