Основы IEEE 754
Сначала давайте рассмотрим основы организации номеров IEEE 754.
Мы сосредоточимся на одинарной точности (32 бита), но все можно сразу же обобщить на другие точности.
Формат:
- 1 бит: знак
- 8 бит: показатель степени
- 23 бита: дробь
Или, если вам нравятся картинки:
Источник .
Знак простой: 0 - положительный, а 1 - отрицательный, конец истории.
Показатель экспоненты составляет 8 бит, поэтому он колеблется от 0 до 255.
Показатель степени называется смещенным, потому что он имеет смещение -127
, например:
0 == special case: zero or subnormal, explained below
1 == 2 ^ -126
...
125 == 2 ^ -2
126 == 2 ^ -1
127 == 2 ^ 0
128 == 2 ^ 1
129 == 2 ^ 2
...
254 == 2 ^ 127
255 == special case: infinity and NaN
Соглашение о ведущих битах
(Далее следует вымышленный гипотетический рассказ, не основанный на каких-либо реальных исторических исследованиях.)
При разработке стандарта IEEE 754 инженеры заметили, что все числа, за исключением 0.0
, имеют 1
двоичную единицу в качестве первой цифры. Например:
25.0 == (binary) 11001 == 1.1001 * 2^4
0.625 == (binary) 0.101 == 1.01 * 2^-1
оба начинаются с этой надоедливой 1.
части.
Поэтому было бы расточительным позволять этой цифре занимать один бит точности почти для каждого отдельного числа.
По этой причине они создали «соглашение о начальных битах»:
всегда предполагайте, что число начинается с единицы
Но как тогда бороться 0.0
? Что ж, решили создать исключение:
- если показатель равен 0
- а дробь равна 0
- тогда число представляет плюс или минус
0.0
так что байты 00 00 00 00
также представляют 0.0
, что выглядит хорошо.
Если бы мы учитывали только эти правила, то наименьшее ненулевое число, которое может быть представлено, было бы:
- показатель степени: 0
- фракция: 1
что выглядит примерно так в шестнадцатеричной дроби из-за соглашения о начальных битах:
1.000002 * 2 ^ (-127)
где .000002
22 нуля с буквой 1
на конце.
Мы не можем взять fraction = 0
, иначе было бы это число 0.0
.
Но потом инженеры, имевшие к тому же острое эстетическое чутье, подумали: разве это не уродливо? Что мы прыгаем прямо0.0
к чему-то, что даже не является истинной степенью двойки? Не могли бы мы как-нибудь представить еще меньшие числа? (Хорошо, это было немного больше, чем «уродливое»: на самом деле люди получали плохие результаты своих вычислений, см. «Как субнормальные факторы улучшают вычисления» ниже).
Субнормальные числа
Инженеры немного почесали затылки и, как обычно, вернулись с другой хорошей идеей. Что, если мы создадим новое правило:
Если показатель степени равен 0, то:
- ведущий бит становится 0
- экспонента фиксируется на -126 (не -127, как если бы у нас не было этого исключения)
Такие числа называются субнормальными числами (или денормальными числами, что является синонимом).
Из этого правила сразу следует, что число такое, что:
- показатель степени: 0
- фракция: 0
по-прежнему 0.0
, что довольно элегантно, поскольку означает, что на одно правило меньше.
Так 0.0
на самом деле субнормальный номер в соответствии с нашим определением!
Таким образом, с помощью этого нового правила наименьшее ненормальное число будет:
- показатель степени: 1 (0 будет субнормальным)
- фракция: 0
что представляет собой:
1.0 * 2 ^ (-126)
Тогда наибольшее субнормальное число:
- показатель степени: 0
- дробь: 0x7FFFFF (23 бита 1)
что равно:
0.FFFFFE * 2 ^ (-126)
где .FFFFFE
снова 23 бита, единица справа от точки.
Это довольно близко к наименьшему ненормальному числу, что звучит нормально.
И наименьшее ненулевое субнормальное число:
- показатель степени: 0
- фракция: 1
что равно:
0.000002 * 2 ^ (-126)
что тоже выглядит довольно близко к 0.0
!
Не сумев найти какой-либо разумный способ представить числа меньшие, чем это, инженеры были счастливы и вернулись к просмотру картинок кошек в Интернете или что-то еще, что они делали в 70-х.
Как видите, субнормальные числа являются компромиссом между точностью и длиной представления.
В качестве самого крайнего примера, наименьшее ненулевое субнормальное:
0.000002 * 2 ^ (-126)
по существу имеет точность одного бита вместо 32 бита. Например, если мы разделим его на два:
0.000002 * 2 ^ (-126) / 2
мы реально достигаем 0.0
ровно!
Визуализация
Всегда полезно иметь геометрическую интуицию относительно того, что мы изучаем, так что начнем.
Если мы нанесем числа с плавающей запятой IEEE 754 на линию для каждого заданного показателя степени, это будет выглядеть примерно так:
+---+-------+---------------+-------------------------------+
exponent |126| 127 | 128 | 129 |
+---+-------+---------------+-------------------------------+
| | | | |
v v v v v
-------------------------------------------------------------
floats ***** * * * * * * * * * * * *
-------------------------------------------------------------
^ ^ ^ ^ ^
| | | | |
0.5 1.0 2.0 4.0 8.0
Из этого мы видим, что:
- для каждой экспоненты нет перекрытия между представленными числами
- для каждого показателя у нас есть одно и то же число 2 ^ 32 чисел (здесь представлено 4
*
)
- внутри каждой экспоненты точки расположены на одинаковом расстоянии
- более крупные показатели охватывают более крупные диапазоны, но с более разбросанными точками
Теперь давайте полностью опустим это до степени 0.
Без субнормальных явлений это гипотетически выглядело бы так:
+---+---+-------+---------------+-------------------------------+
exponent | ? | 0 | 1 | 2 | 3 |
+---+---+-------+---------------+-------------------------------+
| | | | | |
v v v v v v
-----------------------------------------------------------------
floats * **** * * * * * * * * * * * *
-----------------------------------------------------------------
^ ^ ^ ^ ^ ^
| | | | | |
0 | 2^-126 2^-125 2^-124 2^-123
|
2^-127
С субнормальными это выглядит так:
+-------+-------+---------------+-------------------------------+
exponent | 0 | 1 | 2 | 3 |
+-------+-------+---------------+-------------------------------+
| | | | |
v v v v v
-----------------------------------------------------------------
floats * * * * * * * * * * * * * * * * *
-----------------------------------------------------------------
^ ^ ^ ^ ^ ^
| | | | | |
0 | 2^-126 2^-125 2^-124 2^-123
|
2^-127
Сравнивая два графика, мы видим, что:
субнормальные удваивают длину диапазона экспоненты 0
, от [2^-127, 2^-126)
до[0, 2^-126)
Расстояние между числами с плавающей запятой в субнормальном диапазоне такое же, как и для [0, 2^-126)
.
диапазон [2^-127, 2^-126)
имеет половину количества точек, которое он имел бы без субнормальных значений.
Половина этих баллов идет на заполнение другой половины диапазона.
в диапазоне [0, 2^-127)
есть некоторые точки с субнормальными значениями, но без них нет.
Это отсутствие очков [0, 2^-127)
не очень элегантно и является основной причиной существования субнормальных существ!
поскольку точки расположены на одинаковом расстоянии:
- диапазон
[2^-128, 2^-127)
имеет половину баллов, чем [2^-127, 2^-126)
- [2^-129, 2^-128)
имеет половину баллов, чем[2^-128, 2^-127)
- и так далее
Это то, что мы имеем в виду, когда говорим, что субнормальные значения - это компромисс между размером и точностью.
Пример исполняемого C
Теперь давайте поиграемся с реальным кодом, чтобы проверить нашу теорию.
Практически на всех современных и настольных компьютерах C float
представляет числа с плавающей запятой одинарной точности IEEE 754.
В частности, это относится к моему ноутбуку Lenovo P51 с Ubuntu 18.04 amd64.
При таком предположении все утверждения передаются следующей программе:
subnormal.c
#if __STDC_VERSION__ < 201112L
#error C11 required
#endif
#ifndef __STDC_IEC_559__
#error IEEE 754 not implemented
#endif
#include <assert.h>
#include <float.h> /* FLT_HAS_SUBNORM */
#include <inttypes.h>
#include <math.h> /* isnormal */
#include <stdlib.h>
#include <stdio.h>
#if FLT_HAS_SUBNORM != 1
#error float does not have subnormal numbers
#endif
typedef struct {
uint32_t sign, exponent, fraction;
} Float32;
Float32 float32_from_float(float f) {
uint32_t bytes;
Float32 float32;
bytes = *(uint32_t*)&f;
float32.fraction = bytes & 0x007FFFFF;
bytes >>= 23;
float32.exponent = bytes & 0x000000FF;
bytes >>= 8;
float32.sign = bytes & 0x000000001;
bytes >>= 1;
return float32;
}
float float_from_bytes(
uint32_t sign,
uint32_t exponent,
uint32_t fraction
) {
uint32_t bytes;
bytes = 0;
bytes |= sign;
bytes <<= 8;
bytes |= exponent;
bytes <<= 23;
bytes |= fraction;
return *(float*)&bytes;
}
int float32_equal(
float f,
uint32_t sign,
uint32_t exponent,
uint32_t fraction
) {
Float32 float32;
float32 = float32_from_float(f);
return
(float32.sign == sign) &&
(float32.exponent == exponent) &&
(float32.fraction == fraction)
;
}
void float32_print(float f) {
Float32 float32 = float32_from_float(f);
printf(
"%" PRIu32 " %" PRIu32 " %" PRIu32 "\n",
float32.sign, float32.exponent, float32.fraction
);
}
int main(void) {
assert(float32_equal(0.5f, 0, 126, 0));
assert(float32_equal(1.0f, 0, 127, 0));
assert(float32_equal(2.0f, 0, 128, 0));
assert(isnormal(0.5f));
assert(isnormal(1.0f));
assert(isnormal(2.0f));
assert(0.5f == 0x1.0p-1f);
assert(1.0f == 0x1.0p0f);
assert(2.0f == 0x1.0p1f);
assert(float32_equal(-0.5f, 1, 126, 0));
assert(float32_equal(-1.0f, 1, 127, 0));
assert(float32_equal(-2.0f, 1, 128, 0));
assert(isnormal(-0.5f));
assert(isnormal(-1.0f));
assert(isnormal(-2.0f));
assert(float32_equal( 0.0f, 0, 0, 0));
assert(float32_equal(-0.0f, 1, 0, 0));
assert(!isnormal( 0.0f));
assert(!isnormal(-0.0f));
assert(0.0f == -0.0f);
assert(FLT_MIN == 0x1.0p-126f);
assert(float32_equal(FLT_MIN, 0, 1, 0));
assert(isnormal(FLT_MIN));
float largest_subnormal = float_from_bytes(0, 0, 0x7FFFFF);
assert(largest_subnormal == 0x0.FFFFFEp-126f);
assert(largest_subnormal < FLT_MIN);
assert(!isnormal(largest_subnormal));
float smallest_subnormal = float_from_bytes(0, 0, 1);
assert(smallest_subnormal == 0x0.000002p-126f);
assert(0.0f < smallest_subnormal);
assert(!isnormal(smallest_subnormal));
return EXIT_SUCCESS;
}
GitHub вверх по течению .
Скомпилируйте и запустите с:
gcc -ggdb3 -O0 -std=c11 -Wall -Wextra -Wpedantic -Werror -o subnormal.out subnormal.c
./subnormal.out
C ++
В дополнение к раскрытию всех API C, C ++ также предоставляет некоторые дополнительные субнормальные функции, которые не так легко доступны в C <limits>
, например:
denorm_min
: Возвращает минимальное положительное субнормальное значение типа T
В C ++ весь API шаблонизирован для каждого типа с плавающей запятой, и это намного лучше.
Реализации
x86_64 и ARMv8 реализуют IEEE 754 непосредственно на оборудовании, в которое преобразуется код C.
В некоторых реализациях субнормалы кажутся менее быстрыми, чем нормальные: почему изменение 0,1f на 0 снижает производительность в 10 раз? Это упоминается в руководстве по ARM, см. Раздел «Подробности ARMv8» в этом ответе.
ARMv8 подробности
Справочное руководство по архитектуре ARM ARMv8 DDI 0487C.a manual A1.5.4 "Flush-to-zero" описывает настраиваемый режим, в котором субнормальные значения округляются до нуля для повышения производительности:
Производительность обработки с плавающей запятой может быть снижена при выполнении вычислений с использованием денормализованных чисел и исключений потери значимости. Во многих алгоритмах эту производительность можно восстановить без значительного влияния на точность конечного результата, заменив денормализованные операнды и промежуточные результаты нулями. Чтобы разрешить эту оптимизацию, реализации с плавающей запятой ARM позволяют использовать режим Flush-to-zero для различных форматов с плавающей запятой следующим образом:
Для AArch64:
Если FPCR.FZ==1
, то режим Flush-to-Zero используется для всех входов и выходов одинарной и двойной точности всех инструкций.
Если FPCR.FZ16==1
, то режим Flush-to-Zero используется для всех входов и выходов половинной точности инструкций с плавающей запятой, кроме: - Преобразования между числами половинной точности и одинарной точности. - Преобразования между половинной точностью и двойной точностью. числа.
A1.5.2 «Стандарты с плавающей запятой и терминология» Таблица A1-3 «Терминология с плавающей запятой» подтверждает, что субнормальные и денормальные числа являются синонимами:
This manual IEEE 754-2008
------------------------- -------------
[...]
Denormal, or denormalized Subnormal
C5.2.7 «FPCR, регистр управления с плавающей запятой» описывает, как ARMv8 может опционально вызывать исключения или устанавливать биты флага всякий раз, когда ввод операции с плавающей запятой является субнормальным:
FPCR.IDE, бит [15] Входное разрешение прерывания исключительной ситуации с ненормальной плавающей точкой. Возможные значения:
0b0 Выбрана неотработанная обработка исключений. Если возникает исключительная ситуация с плавающей запятой, бит FPSR.IDC устанавливается в 1.
0b1 Выбрана обработка захваченного исключения. Если возникает исключительная ситуация с плавающей запятой, PE не обновляет бит FPSR.IDC. Программа обработки прерываний может решить, устанавливать ли бит FPSR.IDC в 1.
D12.2.88 «MVFR1_EL1, AArch32 Media and VFP Feature Register 1» показывает, что денормальная поддержка фактически не является обязательной, и предлагает немного определить, есть ли поддержка:
FPFtZ, биты [3: 0]
Сброс до нулевого режима. Указывает, поддерживает ли реализация с плавающей запятой только режим работы Flush-to-Zero. Определенные значения:
0b0000 Не реализовано, или оборудование поддерживает только режим работы Flush-to-Zero.
0b0001 Аппаратное обеспечение поддерживает полную арифметику денормализованных чисел.
Все остальные значения зарезервированы.
В ARMv8-A допустимые значения - 0b0000 и 0b0001.
Это говорит о том, что когда субнормальные функции не реализованы, реализации просто возвращаются к нулевому значению.
Бесконечность и NaN
Любопытно? Я писал кое-что по адресу:
Как субнормальные факторы улучшают вычисления
TODO: дополнительно понять, как этот скачок ухудшает результаты вычислений / как субнормальные значения улучшают результаты вычислений.
Актуальная история
Чарльз Северанс « Интервью со стариком с плавающей точкой » (1998) представляет собой краткий исторический обзор реального мира в форме интервью с Уильямом Каханом, предложенный Джоном Коулманом в комментариях.