Гегелевские законы диалектики применимы, пожалуй, для всех сфер человеческой деятельности, и вычислительная техника – тому не только не исключение, но и один из наиболее ярких примеров. На любом уровне, от электронных схем и до программных реализаций, разработчики пытаются совладать с парой взаимно отрицающих параметров вычислительных систем – точностью и быстродействием. Одна из наиболее интересных и животрепещущих историй этого связана с представлением вещественных чисел.
Как известно, стандарт IEEE 754 декларирует представление вещественного числа в виде числа с плавающей точкой. Такая форма основана на экспоненциальной записи вещественных чисел, которая в общем виде представляет собой нечто вроде:

где m – мантисса, n – основание системы исчисления, e – порядок.
Для однозначности предполагается, что мантисса нормализована – таким образом, что ее целая часть в терминах выбранной системы исчисления является цифрой, отличной от нуля.
Возвращаясь к стандарту, вспоминаем, что он для представления чисел с плавающей точкой одинарной точности отводит 32 бита. 1 бит здесь отводится на знак (s), 8 – на порядок (e), 23 – на мантиссу (m). Поскольку мы имеем дело, все-таки, с двоичной системой исчисления, то очевидно, что целая часть нормализованной мантиссы в нашем случае может быть равной только единице, посему она благополучно отбрасывается, и в выделенных 23-х битах хранится лишь дробная часть двоичного числа. Кроме того, для учета отрицательных степеней значение порядка считается смещенным на 127, т.е. фактическое его значение 0 соответствует степени −127, а 255 – степени 128. Со знаковым битом все просто: если он равен нулю, то знак – плюс, если единице – минус. Сведя все это воедино, можно записать наше число в таком виде:

Крайние порядки (0 и 255, т.е. степени −127 и 128) зарезервировали для специальных значений (бесконечностей, нечисел (NaN), и субнормальных чисел). Кроме того, поскольку точный нуль в таком виде представить не удастся, для него ввели частный случай, когда все биты мантиссы и порядка равны нулю. Тут случился конфуз, ибо с учетом знака стало возможным существование двух нулей: +0 и −0, но дело замяли, провозгласив их равенство (хотя если очень захотеть, то вывести нуль на чистую воду можно с помощью команды сопроцессора FXAM). Впрочем, приключения на этом не закончились, и связаны они были все с тем же злополучным нулем.
Наименьшее положительное число, которое может быть представлено в рамках наших ограничений (дробная часть равна нулю):

А следующее после него (в дробной части младший бит – единица):

То есть, расстояние между нулем и минимальным положительным числом составляет 2−126, а между минимальным положительным числом и следующим после него – 2−149! (Здесь нам открывается огромная черная дыра потери значимости (underflow) около нуля).
Для того чтобы заполнить этот пробел, был выдуман обходной маневр, который привел к появлению так называемых денормализованных (или, по свежему IEEE 754-2008, – субнормальных) чисел. Для их представления используется зарезервированная степень −127 (нулевое значение фактического порядка); если при этом мантисса ненулевая, то ее целая часть вместо единицы считается равной нулю, а показатель степени – равным −126. Денормализованное число тогда можно записать так:

Такой финт ушами (gradual undeflow) позволил избежать резкой потери точности в окрестностях нуля (flush to zero), хотя и вынудил производителей процессоров немного попотеть, что аукается до сих пор (к чему я, собственно, и веду). Известны случаи, когда обработка якобы тишины при ЦОС нагружает процессор сильнее, чем обработка обычного сигнала; а один и тот же код в графическом движке приводит при определенных условиях к провисанию быстродействия едва ли не на порядок (Van Verth, Bishop. Essential Mathematics for Games and Interactive Applications). Виной и тому, и другому – те самые маленькие денормализованные числа, любая работа с которыми, оказывается, производится большинством процессоров посредством специального микрокода - более медленного, чем стандартные инструкции математического сопроцессора. Чтобы убедиться в этом, достаточно набросать на коленке код, подобный приведенному ниже, и, запустив его (естественно, в режиме без оптимизации генерируемого кода), сравнить два выведенных числа.
#include <iostream>
#include <windows.h>
using namespace std;
float some( float val )
{
return val;
}
int main()
{
DWORD start = 0;
// обычное число с плавающей точкой
float f1 = 1.1f;
// денормализованное число
float f2 = 1e-40f;
start = GetTickCount();
for ( int i = 0; i < 10000000; i++ )
{
float f = some( f1 );
}
// время, затраченное на работу с обычным числом
cout << GetTickCount() - start << endl;
start = GetTickCount();
for ( int i = 0; i < 10000000; i++ )
{
float f = some( f2 );
}
// время, затраченное на работу с денормализованным числом
cout << GetTickCount() - start << endl;
cin.get();
}
Очевидный выход из сложившейся ситуации – в случаях, когда предлагаемая денормализованными числами точность не является необходимой, просто обнулять их, таким образом совершая программный flush to zero. При обработке звука, к примеру, этим отбрасывается фоновый шум низкой амплитуды, который, будучи неразличимым для слуха, просто загружает процессор. Ну а для графических движков подобная точность не нужна тем паче.
