Double ошибка округления

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

В чём суть ошибки?

Ошибка двойного округления иногда возникает в тех случаях, когда вместо одного округления выполняется два последующих друг за другом округления. Рассмотрим пример в десятичной системе счисления. У нас есть число 1,34999. Если мы сразу округлим его до одного знака после десятичной запятой, то получим 1,3. Но если сначала округлить до двух знаков, а потом до одного, то получим 1,35, а затем 1,4. Как мы видим, результаты получились разными, причём существенно!

Как это связано с моей программой?

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

  1. Вы используете тип данных double размером 64 бита, но на вашем компьютере нет команд, которые работают с double (или компилятор их не поддерживает), а есть только сопроцессор x87. Таким образом, любая операция с double выглядит так: происходит расширение типа с 64-х до 80-ти битов, затем, собственно, необходимые вычисления, в которых результат округляется до 63-х битов дробной части мантиссы (такова структура 80-битового числа с плавающей запятой), а затем происходит второе округление до 52-х битов дробной части мантиссы в double.

Пример программы. Числа для кода я позаимствовал у @Mark Dickinson из англоязычного SO. Вы знаете, что long double размером 80 битов в настоящий момент не поддерживается (например, в VC++ точно), а заменяется на обычный double. Поэтому пример работать не будет, если не указать специальную опцию компилятора, которая заставит его работать через x87. У каждого компилятора своя такая опция (если вообще есть).

#include <stdio.h>

int main (void) {
  double a = 1e16 + 2.9999, b;
  long double x = 1e16L, y = 2.9999L;
  b = (double)(x+y);
  printf ("a = %16.0lf, b = %16.0lfn", a, b);
  return 0;
}

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

  1. Вы используете функции, которые созданы специально для double, но подаёте им на вход числа float. Скажем, есть функция sqrt для double. Если передать ей на вход число float, то произойдёт расширение его до double, затем функция вычислит результат с округлением, а затем выполнится второе округление, если вы присваиваете результат обратно во float. К сожалению, каких-то реальных примеров подобной ошибки мне отыскать не удалось, но чисто теоретически любая подмена float на double и обратно может дать ошибку двойного округления. Это будет хорошо видно на последнем, самом любопытном примере.

  2. Данное наблюдение я подсмотрел в статье “Double Rounding Errors in Floating-Point Conversions”, но сделал свой собственный пример. Общий смысл таков, что константа может быть неверно преобразована в бинарный формат на этапе компиляции, если произойдёт двойное округление. Это может произойти когда когда вы забыли суффикс f для чисел типа float или если пользуетесь VC++ любой версии.

Для демонстрации за основу будет взято число 1+2-23+2-24-2-54. Оно равно 1.000000178813934270660723768742172978818416595458984375. Это число интересно тем, что в двоичном формате оно равно

1,000000000000000000000010111111111111111111111111111111(2)

Полужирным шрифтом я отметил биты 24 и 53-54. Если читатель не знаком с правилами округления чисел в формате IEEE-754, то ему придётся сначала их изучить. Например, здесь. Если мы хотим перевести данное число в формат float, то никаких проблем нет: 24-й бит равен нулю, а значит округление до 23-х бит, необходимых float, произойдёт вниз без вопросов. Получим число 1,00000000000000000000001, что в формате IEEE-754 будет иметь вид 0x3f800001. Если же мы сначала приводим число к типу double, то округление до 52-х бит сначала произойдёт вверх, и мы получим 1,0000000000000000000000110000000000000000000000000000, а затем, из-за того, что 23-й и 24-й биты теперь равны 1, в результате второго округления до float получим 1,00000000000000000000010 (снова округление произошло вверх), что в шестнадцатеричной записи будет иметь вид 0x3f800002. Как видно, мы получили два разных числа.

Рассмотрим демонстрационный код (не плюйтесь, что в коде есть грязные трюки, здесь я хочу просто и без хитростей показать биты числа):

#include <stdio.h>

typedef unsigned int u32;
typedef unsigned long long u64;

int main (void) {
  float a, b;
  double c;
  a = 1.000000178813934270660723768742172978818416595458984375f;
  b = 1.000000178813934270660723768742172978818416595458984375;
  c = 1.000000178813934270660723768742172978818416595458984375;
  printf ("a = %08x, b = %08x, c = %016llxn", *(u32*)&a, *(u32*)&b, *(u64*)&c);
  return 0;
}

Здесь переменная a инициализируется правильно, мы используем суффикс f, инициализация переменной b происходит так, будто программист забыл про суффикс f (к счастью, многие компиляторы ему об этом напомнят), поэтому здесь произойдёт двойное округление (сначала константа превратиться в double, а затем будет присвоена к float), а переменная c здесь просто так, для демонстрации промежуточного округления в double. Функция printf выводит шестнадцатеричный код всех трёх переменных. Компилятор Tiny C выдаёт верный ответ:

a = 3f800001, b = 3f800002, c = 3ff0000030000000

Такой же точно ответ выдали Intel C 15 и Clang.

Компилятор VC 2015 выдал

a = 3f800002, b = 3f800002, c = 3ff0000030000000

Это значит, что он ИГНОРИРУЕТ суффикс f, если он установлен, и в любом случае приводит результат сначала к double, а потом к float, если нужно. По-хорошему, разработчики компилятора заработали этим сильный удар линейкой по пальцам.

Копилятор GCC из MinGW выдал также неверный ответ

a = 3f800001, b = 3f800001, c = 3ff0000030000000

То есть с точки зрения исходного желания программиста ответ верный (и здесь он молодец, что угадал это желание), но с точки зрения Стандарта — нет.

Что же теперь делать?

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

Будьте осторожны, и да минуют вас ошибки двойного округления.

When I run the following code:

double x = 4.35;
double y = x * 100;
System.out.println(y);

I get 434.99999999999994 which is expected due to the nature of floating point precision. However when I run:

double x = 4.35;
double y = x * 1000;
System.out.println(y);

I get 4350.0 in the console. Similarly when I replace the 1000 with 10, I get 43.5, which is peculiar, since there is no rounding error for these numbers. It seems that there is only rounding error when I mulptiply the double by 10 to the power of the number of decimal places. Why is it like that?

Also, when I tried running the three blocks of code using 4.25 as my value for x, I got a nice 425.0 in the console. Does rounding error only apply to some decimal values?

EDIT: Dividing 4.35 by 10 also gave me a wacky decimal (0.43499999999999994 to be exact). Also, if I set x to 43.5, there were no rounding errors at all. Multiplying by 10 gave me 435.0.

EDIT 2: I think some people are misinterpreting my question. I’m asking why this happens and not how to avoid running into these errors.

30.12.2019Java

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

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

public class Main {

    public static void main(String[] args)

    {

        double a = 0.7;

        double b = 0.9;

        double x = a + 0.1;

        double y = b - 0.1;

        System.out.println("x = " + x);

        System.out.println("y = " + y );

        System.out.println(x == y);

    }

}

Выход:

x = 0.7999999999999999
y = 0.8
false

Здесь ответ не тот, который мы ожидали, потому что округление выполняется компилятором java.


Причина ошибки округления

Типы данных с плавающей запятой и типа double используют спецификацию IEEE с плавающей запятой 754. Это означает, что числа представлены в такой форме:

SIGN FRACTION * 2 ^ EXP 

0.15625 = (0.00101) 2 , который в формате с плавающей запятой представлен как: 1.01 * 2 ^ -3
Не все дроби могут быть представлены точно как дробь степени двойки. В качестве простого примера, 0,1 = (0,000110011001100110011001100110011001100110011001100110011001…) 2 и, следовательно, не может быть сохранено внутри переменной с плавающей запятой.

Другой пример:

public class Main {

    public static void main(String[] args)

    {

        double a = 0.7;

        double b = 0.9;

        double x = a + 0.1;

        double y = b - 0.1;

        System.out.println("x = " + x);

        System.out.println("y = " + y );

        System.out.println(x == y);

    }

}

Выход:

x = 0.7999999999999999
y = 0.8
false

Другой пример:

public class Main {

    public static void main(String args[])

    {

        double a = 1.0;

        double b = 0.10;

        double x = 9 * b;

        a = a - (x);

        System.out.println("a = " + a);

    }

}

Выход:

a = 0.09999999999999998

Как исправить ошибки округления?

  • Округление результата: функция Round () может быть использована для минимизации любых неточностей арифметической памяти с плавающей запятой. Пользователь может округлять числа до количества знаков после запятой, которое требуется для расчета. Например, при работе с валютой вы, скорее всего, округлите до 2 десятичных знаков.
  • Алгоритмы и функции: используйте численно устойчивые алгоритмы или разработайте свои собственные функции для обработки таких случаев. Вы можете усекать / округлять цифры, в которых вы не уверены, что они верны (вы также можете рассчитать числовую точность операций)
  • Класс BigDecimal: Вы можете использовать класс java.math.BigDecimal , который предназначен для обеспечения точности, особенно в случае больших дробных чисел.

    Следующая программа показывает, как можно устранить ошибку:

    public class Main {

        public static void main(String args[])

        {

            double a = 1.0;

            double b = 0.10;

            double x = 9 * b;

            a = a - (x);

            System.out.println("a = " + Math.round(a*1.0)/1.0);

        }

    }

    Выход:

    0.1

    Теперь мы получаем ожидаемый результат без ошибок.

    Если вы как GeeksforGeeks и хотели бы внести свой вклад, вы также можете написать статью с помощью contribute.geeksforgeeks.org или по почте статьи contribute@geeksforgeeks.org. Смотрите свою статью, появляющуюся на главной странице GeeksforGeeks, и помогите другим вундеркиндам.

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

    Рекомендуемые посты:

    • rint () в Java (округление до int)
    • Ошибки V / S Исключения в Java
    • Взаимодействие потоков и ошибки согласованности памяти в Java
    • Java.util.LinkedList.poll (), pollFirst (), pollLast () с примерами на Java
    • Java.lang.Short toString () метод в Java с примерами
    • Java lang.Long.numberOfLeadingZeros () метод в Java с примерами
    • Java.util.Collections.rotate () Метод в Java с примерами
    • Java.util.Collections.disjoint () Метод в Java с примерами
    • Класс Java.util.concurrent.RecursiveTask в Java с примерами
    • Java.util.concurrent.Phaser класс в Java с примерами
    • Java lang.Long.reverse () метод в Java с примерами
    • Методы класса Java.util.BitSet в Java с примерами | Набор 2
    • Java.util.LinkedList.offer (), offerFirst (), offerLast () в Java
    • Java.util.LinkedList.peek (), peekfirst (), peeklast () в Java
    • Java lang.Long.numberOfTrailingZeros () метод в Java с примерами

    Ошибки округления в Java

    0.00 (0%) 0 votes

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


Друзья, для вашего удобства статья также доступна в формате презентации на видео (почти 34 минуты), такой формат больше подойдёт тем читателям, которым трудно выстраивать нужные математические образы в голове, так как в презентации много иллюстративного материала. Информация на видео полностью идентична содержанию статьи. Действуйте, пожалуйста, как вам удобно.


Повторюсь, что это не научная, а научно-популярная статья, прочитав которую, вы кратко познакомитесь вот с чем.

  • Трансцендентные элементарные функции (exp, sin, log, cosh и другие), работающие с арифметикой с плавающей запятой, округляются некорректно, иногда они допускают ошибку в последнем бите.
  • Причина ошибок не всегда кроется в лени или низкой квалификации разработчиков, а в одном фундаментальном обстоятельстве, преодолеть которое современная наука пока не смогла.
  • Существуют «костыли», которые позволяют худо-бедно справляться с обсуждаемой проблемой в некоторых случаях.
  • Некоторые функции, которые должны делать вроде бы одно и то же, могут выдавать различный результат в одной и той же программе, например, exp2(x) и pow(2.0, x).

Чтобы понять содержание статьи, вам нужно быть знакомым с форматом чисел с плавающей запятой IEEE-754. Будет достаточно, если вы хотя бы просто понимаете что, например, вот это: 0x400921FB54442D18 – число пи в формате с удвоенной точностью (binary64, или double), то есть просто понимаете, что я имею в виду этой записью; я не требую уметь «на лету» делать подобные преобразования. А про режимы округления я вам напомню в этой статье, это важная часть повествования. Ещё желательно знать «программистский» английский, потому что будут термины и цитаты из западной литературы, но можете обойтись и онлайн-переводчиком.

Сначала примеры, чтобы вы сразу поняли, в чём состоит предмет разговора. Сейчас я дам код на C++, но если это не ваш язык, то уверен, вы всё равно без труда поймёте что написано. Посмотрите, пожалуйста, на этот код:

#include <stdio.h>
#include <cmath>

int main() {
  float x = 0.00296957581304013729095458984375f;  // Аргумент, записанный точно.
  float z;
  z = exp2f(x);  // z = 2**x одним способом.
  printf ("%.8fn", z);  // Вывод результата с округлением до 8 знаков после точки.
  z = powf(2.0f, x);  // z = 2**x другим способом
  printf ("%.8fn", z);  // Такой же вывод.
  return 0;
}

Число x намеренно записано с таким количеством значащих цифр, чтобы оно было точнопредставимым в типе float, то есть чтобы компилятор преобразовал его в бинарный код без округления. Ведь вы прекрасно знаете, что некоторые компиляторы не умеют округлять без ошибок (если не знаете, укажите в комментариях, я напишу отдельную статью с примерами). Далее в программе нам нужно вычислить 2x, но давайте сделаем это двумя способами: функция exp2f(x), и явное возведение двойки в степень powf(2.0f, x). Результат, естественно, будет различным, потому что я же сказал выше, что не могут элементарные функции работать правильно во всех случаях, а я специально подобрал пример, чтобы это показать. Вот что будет на выходе:

1.00206053
1.00206041

Эти значения мне выдали четыре компилятора: Microsoft C++ (19.00.23026), Intel C++ 15.0, GCC (6.3.0) и Clang (3.7.0). Они отличаются одним младшим битом. Вот шестнадцатеричный код этих чисел:

0x3F804385  // Правильно
0x3F804384  // Неправильно

Запомните, пожалуйста, этот пример, именно на нём мы рассмотрим суть проблемы чуть ниже, а пока, чтобы у вас сложилось более ясное впечатление, посмотрите, пожалуйста, примеры для типа данных с удвоенной точностью (double, binary64) с некоторыми другими элементарными функциями. Привожу результаты в таблице. У правильных ответов (когда они есть) стоят символы «*» на конце.

Надеюсь, у вас не сложилось впечатления, что я специально взял какие-то прямо совсем уникальные тесты, которые с трудом можно отыскать? Если сложилось, то давайте состряпаем на коленках полный перебор всех возможных дробных аргументов для функции 2x для типа данных float. Понятно, что нас интересуют только значения x между 0 и 1, потому что другие аргументы будут давать результат, отличающийся только значением в поле экспоненты и интереса не представляют. Сами понимаете:

$2^x = 2^{[x]}cdot2^{{x}}.$

Написав такую программу (скрытый текст будет ниже), я проверил функцию exp2f и то, сколько ошибочных значений она выдаёт на интервале x от 0 до 1.

Из программы ниже понятно, что число проверяемых аргументов x составило 197612997 штук. Получается, что, например, Microsoft С++ неверно вычисляет функцию 2x для почти одного процента из них. Не радуйтесь, уважаемые любители GCC и Clang, просто именно эта функция в данных компиляторах реализована правильно, но полно ошибок в других.

Код полного перебора

#include <stdio.h>
#include <cmath>

    // Этими макросами мы обращаемся к битовому представлению чисел float и double
#define FAU(x) (*(unsigned int*)(&x))
#define DAU(x) (*(unsigned long long*)(&x))

    // Эта функция вычисляет 2**x точно до последнего бита для 0<=x<=1.
    // Страшный вид, возможно, не даёт сразу увидеть, что 
    // здесь вычисляется аппроксимирующий полином 10-й степени.
    // Промежуточные расчёты делаются в double (ошибки двойного округления тут не мешают).
    // Не нужно пытаться оптимизировать этот код через FMA-инструкции, 
    // практика показывает, что будет хуже, но... процессоры бывают разными.
float __fastcall pow2_minimax_poly_double (float x) {
  double a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10;
  DAU(a0) = 0x3ff0000000000001;
  DAU(a1) = 0x3fe62e42fefa3763;
  DAU(a2) = 0x3fcebfbdff845acb;
  DAU(a3) = 0x3fac6b08d6a26a5b;
  DAU(a4) = 0x3f83b2ab7bece641;
  DAU(a5) = 0x3f55d87e23a1a122;
  DAU(a6) = 0x3f2430b9e07cb06c;
  DAU(a7) = 0x3eeff80ef154bd8b;
  DAU(a8) = 0x3eb65836e5af42ac;
  DAU(a9) = 0x3e7952f0d1e6fd6b;
  DAU(a10)= 0x3e457d3d6f4e540e;
  return (float)(a0+(a1+(a2+(a3+(a4+(a5+(a6+(a7+(a8+(a9+a10*x)*x)*x)*x)*x)*x)*x)*x)*x)*x);
} 

int main() {
  unsigned int n = 0;  // Счётчик ошибок.
  // Цикл по всем возможным значениям x в интервале (0,1)
  // Старт цикла: 0x33B8AA3B = 0.00000008599132428344091749750077724456787109375
  // Это минимальное значение, для которого 2**x > 1.0f
  // Конец цикла: 0x3F800000 = 1.0 ровно.
  for (unsigned int a=0x33B8AA3B; a<0x3F800000; ++a) {  
   float x;
    FAU(x) = a;
    float z1 = exp2f (x);	// Подопытная функция.
    float z2 = pow2_minimax_poly_double (x);	// Точный ответ.
    if (FAU(z1) != FAU(z2)) {	// Побитовое сравнение.
      // Закомментируйте это, чтобы не выводить все ошибки на экран (их может быть много).
      //fprintf (stderr, "2**(0x%08X) = 0x%08X, but correct is 0x%08Xn", a, FAU(z1), FAU(z2));
      ++n;
    }		
  }
  const unsigned int N = 0x3F800000-0x33B8AA3B;  // Сколько всего аргументов было проверено.
  printf ("%u wrong results of %u arguments (%.2lf%%)n", n, N, (float)n/N*100.0f);
  return 0;
} 

Не буду утомлять читателя этими примерами, здесь главное было показать, что современные реализации трансцендентных функций могут неправильно округлять последний бит, причём разные компиляторы ошибаются в разных местах, но ни один не будет работать правильно. Кстати, Стандарт IEEE-754 эту ошибку в последнем бите разрешает (о чём скажу ниже), но мне всё равно кажется странным вот что: ладно double, это большой тип данных, но ведь float можно проверить полным перебором! Так ли сложно это было сделать? Совсем не сложно, и я уже показал пример.

В коде нашего перебора есть «самописная» функция правильного вычисления 2x с помощью аппроксимирующего полинома 10-й степени, и написана она была за несколько минут, потому что такие полиномы выводятся автоматически, например, в системе компьютерной алгебры Maple. Достаточно задать условие, чтобы полином обеспечивал 54 бита точности (именно для этой функции, 2x). Почему 54? А вот скоро узнаете, сразу после того как я расскажу суть проблемы и поведую о том, почему для типа данных учетверённой точности (binary128) в принципе сейчас невозможно создать быстрые и правильные трансцендентные функции, хотя попытки атаковать эту проблему в теории уже есть.

Округление по умолчанию и проблема, с ним связанная

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

О том, что такое «округлить вверх» (к плюс бесконечности), «округлить вниз» (к минус бесконечности) или «округлить к нулю» вы легко вспомните по названию (если что, есть википедия). Основные сложности у программистов возникают с округлением «к ближайшему, но в случае равного удаления от ближайших — к тому, у которого последняя цифра чётная». Да, именно так переводится этот режим округления, который западной литературе называют короче: «Round nearest ties to even».

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

  1. Округлить 1,001001. Третий бит после запятой равен 1, но дальше есть ещё 6-й бит, равный 1, значит округление будет вверх, потому что исходное число ближе к 1,01, чем к 1,00.
  2. Округлить 1,001000. Теперь округляем вниз, потому что мы находимся ровно посередине между 1,00 и 1,01, но именно у первого варианта последний бит будет чётным.
  3. Округлить 1,011000. Мы посередине между 1,01 и 1,10. Придётся округлять вверх, потому что чётный последний бит именно у большего числа.
  4. Округлить 1,010111. Округляем вниз, потому что третий бит равен нулю и мы ближе к 1,01, чем к 1,10.

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

1,00

1

0000000000000000000000000000000000001

Вам сейчас очевидно, что округление должно быть вверх, то есть к числу 1,01. Однако вы смотрите на число, в котором после запятой 40 битов. А что если ваш алгоритм не смог обеспечить 40 битов точности и достигает, скажем, только 30 битов? Тогда он выдаст другое число:

1,00

1

000000000000000000000000000

Не подозревая, что на 40-й позиции (которую алгоритм рассчитать не в состоянии) будет заветная единичка, вы округлите это число книзу и получите 1,00, что неправильно. Вы неправильно округлили последний бит — в этом и состоит предмет нашего обсуждения. Из сказанного выходит, что для того чтобы получить всего лишь 2-й бит правильным, вам придётся вычислять функцию до 40 битов! Вот это да! А если «паровоз» из нулей окажется ещё длиннее? Вот об этом и поговорим в следующем разделе.

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

Суть проблемы округления последнего значащего бита

Проблема проявляется по двум причинам. Первая — намеренный отказ от трудоёмкого вычисления в пользу скорости. В этом случае лишь бы заданная точностью соблюдалась, а какие там будут биты в ответе — дело второстепенное. Вторая причина — Table Maker’s Dilemma, которая является основным предметом нашего разговора. Рассмотрим обе причины подробнее.

Первая причина

Вы, конечно, понимаете, что вычисление трансцендентных функций реализовано какими-то приближёнными методами, например, методом аппроксимирующих полиномов или даже (редко) разложением в ряд. Чтобы вычисления происходили как можно быстрее, разработчики соглашаются выполнить как можно меньшее количество итераций численного метода (или взять полином как можно меньшей степени), лишь бы алгоритм допускал погрешность не превосходящую половину ценности последнего бита мантиссы. В литературе это пишется как 0.5ulp (ulp = unit in the last place).

Например, если речь идёт о числе x типа float на интервале (0,5; 1) величина ulp = 2-23. На интервале (1;2) ulp = 2-22. Иными словами, если x находится на интервале (0;1) то 2x будет на интервале (1,2), и чтобы обеспечить точность 0.5ulp, нужно, грубо говоря, выбрать EPS = 2-23 (так мы будем обозначать константу «эпсилон», в простонародье именуемую «погрешность», или «точность», кому как нравится, не придирайтесь, пожалуйста).

Для прикладных расчётов этого достаточно, а то что последние биты могут не совпадать с абсолютным результатом, это для почти 100% программистов не важно, потому что им важно не то, какие будут биты, а то, какая будет точность.

Для тех кто не понимает, приведу пример в десятичной системе счисления. Перед вами два числа: 1,999999 и 2,0. Допустим, что первое — это то, что получил программист, а второе — это эталон, что должно было бы получиться, будь у нас безграничные возможности. Разница между ними всего лишь одна миллионная, то есть ответ рассчитан с погрешностью EPS=10-6. Однако ни одной правильной цифры в этом ответе нет. Плохо ли это? Нет, с точки зрения прикладной программы это фиолетово, программист округлит ответ, скажем, до двух знаков после запятой и получит 2,00 (например, речь шла о валюте, $2,00), ему больше и не надо, а то, что он заложил в свою программу EPS=10-6, то молодец, взял запас на погрешность промежуточных вычислений и решил задачу правильно.

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

Напомню, это было первое направление проблемы: последние биты ответа могут быть неправильными потому, что это намеренное решение. Главное — оставить точность 0.5ulp (или выше). Поэтому численный алгоритм подбирается только из этого условия, лишь бы он работал предельно быстро. При этом Стандарт разрешает реализацию элементарных функций без корректного округления последнего бита. Цитирую [1, раздел 12.1] (англ.):

The 1985 version of the IEEE 754 Standard for Floating-Point Arithmetic did not specify anything concerning the elementary function. This was because it has been believed for years that correctly rounded functions would be much too slow at least for some input arguments. The situation changed since then and the 2008 version of the standard recommends (yet does not require) that some functions be correctly rounded.

Далее перечисляются эти функции, которые рекомендуется, но не требуется округлять корректно:

список функций

Вторая причина

Наконец-то мы перешли к теме разговора: Table Maker’s Dilemma (сокращённо TMD). Её название я не смог адекватно перевести на русский язык, оно было введено Уильямом Кэхэном (отцом-основателем IEEE-754) в статье [2]. Возможно, если вы прочитаете статью, то поймёте, почему название именно такое. Если кратко, то суть дилеммы в том, нам нужно получить абсолютно точное округление функции z=f(x), как если бы у нас в распоряжении была бесконечная битовая запись идеально посчитанного результата z. Но всем ясно, что бесконечную последовательность мы не можем получить. А сколько битов тогда взять? Выше я показал пример, когда нам нужно увидеть 40 битов результата, чтобы получить хотя бы 2 корректных бита после округления. А суть проблемы TMD в том, что мы заранее не знаем, до скольких битов рассчитать величину z, чтобы получить правильными столько битов после округления, сколько нам требуется. А что если их будет сто или тысяча? Мы не знаем заранее!

Например, как я сказал, для функции 2x, для типа данных float, где дробная часть мантиссы имеет всего 23 бита, нам надо выполнить расчёт с точностью 2-54, чтобы округление произошло правильно для всех без исключения возможных аргументов x. Эту оценку нетрудно получить полным перебором, но для большинства других функций, особенно для типа double или long double (ставь «класс», если знаешь что это), подобные оценки неизвестны.

Давайте уже разбираться с тем, почему так происходит. Я намеренно привёл самый первый пример в этой статье с типом данных float и просил вас его запомнить, потому что в этом типе всего 32 бита и проще будет на него смотреть, в остальных типах данных ситуация аналогична.

Мы начали с числа x = 0.00296957581304013729095458984375, это точнопредставимое число в типе данных float, то есть оно записано так, чтобы его конвертирование в двоичную систему float выполнялось без округления. Мы вычисляем 2x, и если бы у нас был калькулятор с бесконечной точностью, то мы должны были бы получить (Чтобы вы могли проверять меня, расчёт выполнен в онлайн-системе WolframAlpha):

1,0020604729652405753669743044108123031635398201893943954577320057…

Переведём это число в двоичную систему, скажем, 64 бита будет достаточно:

1,00000000100001110000100

1

000000000000000000000000000001101111101

Бит округления (24-й бит после запятой) подчёркнут. Вопрос: куда округлять? Вверх или вниз? Ясное дело, вверх, вы знаете это, потому что видите достаточно битов и можете принять решение. Но посмотрите внимательно…

После бита округления у нас 29 нулей. Это означает, что мы очень-очень близко находимся к середине между двумя ближайшими числами и достаточно только чуть-чуть сдвинуться вниз, как направление округления изменится. Но вот вопрос: куда будет этот сдвиг? Численный алгоритм может последовательно шаг за шагом подбираться к точному значению с разных сторон и до тех пор, пока мы не пройдём все эти 29 нулей и не достигнем точности, которая превысит ценность самого последнего нуля в этом «паровозе», мы не будем знать направление округления. А вдруг в действительности правильный ответ должен быть таким:

1,00000000100001110000100

0

11111111111111111111111111111?

Тогда округление будет вниз.

Мы этого не знаем, пока наша точность не достигнет 54-го бита после запятой. Когда 54-й бит будет известен точно, мы будем знать точно, к какому из двух ближайших чисел мы на самом деле ближе. Подобные числа называются hardest-to-round-points [1, раздел 12.3] (критические для округления точки), а число 54 называется hardness-to-round (трудоёмкость округления) и в цитируемой книге обозначается буквой m.

Трудоёмкость округления (m) — это число битов, минимально необходимое для того, чтобы для всех без исключения аргументов некоторой функции f(x) и для заранее выбранного диапазона функция f(x) округлялась корректно до последнего бита (для разных режимов округления может быть разное значение m). Иными словами, для типа данных float и для аргумента x из диапазона (0;1) для режима округления к «ближайшему чётному» трудоёмкость округления m=54. Это значит что абсолютно для всех x из интервала (0;1) мы можем заложить в алгоритм одну и ту же точность ESP=2-54, и все результаты будут округлены корректно до 23-х битов после двоичной запятой.

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

Итак, независимо от режима округления, у нас возможны две ситуации: либо в области округления возникает «паровоз» из нулей, либо «паровоз» из единиц. Задача правильного алгоритма расчёта трансцендентной функции f(x) в том, чтобы уточнять значение этой функции до тех пор, пока точность не превысит ценность последнего бита этого «паровоза», и пока не станет точно понятно, что в результате последующих флуктуаций численного алгоритма расчёта f(x) нули не превратятся в единицы, или наоборот. Как только всё стабилизировалось, и алгоритм «вышел» на такую точность, которая находится за пределами «паровоза», тогда мы можем округлять так, как если бы у нас было бесконечное число битов. И это округление будет с корректным последним битом. Но как этого добиться?

«Костыли»

Как было сказано, основная проблема — заставить алгоритм преодолеть паровоз из нулей или единиц, которые идут сразу после бита округления. Когда паровоз преодолён и мы видим его целиком, тогда это эквивалентно тому, что эти нули или единицы уже рассчитаны точно, и мы уже точно знаем, в какую сторону теперь произойдёт округление. Но если мы не знаем длину паровоза, то как же тогда проектировать алгоритм?

Первый «костыль»

Читателю может показаться, что ответ очевиден: взять арифметику с бесконечной точностью и заложить заведомо избыточное число битов, а если будет мало, то заложить ещё и пересчитать. В общем-то правильно. Так и делается, когда скорость и ресурсы компьютера не играют особой роли. У этого подхода есть имя: «многоуровневая стратегия Зива» (Ziv’s multilevel strategy) [1, раздел 12.3]. Суть её предельно проста. Алгоритм должен поддерживать расчёты на нескольких уровнях: быстрый предварительный расчёт (в большинстве случаев он же оказывается финальным), более медленный, но более точный расчёт (спасает в большинстве критических случаев), ещё более медленный, но ещё более точный расчёт (когда совсем «худо» пришлось) и так далее.

В подавляющем большинстве случаев достаточно взять точность чуть выше чем 0.5ulp, но если появился «паровоз», то увеличиваем её. Пока «паровоз» сохраняется, наращиваем точность до тех пор, пока не будет строго понятно, что дальнейшие флуктуации численного метода на этот «паровоз» не повлияют. Так, скажем, в нашем случае если мы достигли ESP=2-54, то на 54-й позиции появляется единица, которая как бы «охраняет» паровоз из нулей и гарантирует, что уже не произойдёт вычитания величины, больше либо равной 2-53 и нули не превратятся в единицы, утащив за собой бит округления в ноль.

Это было научно-популярное изложение, всё то же самое с теоремой Зива (Ziv’s rounding test), где показано как быстро, одним действием проверять достигли ли мы желаемой точности, можно прочитать в [1, глава 12], либо в [3, раздел 10.5].

Проблема этого подхода очевидна. Нужно проектировать алгоритм вычисления каждой трансцендентной функции f(x) так, чтобы по ходу пьесы можно было увеличивать точность расчётов. Для программной реализации это ещё не так страшно, например, метод Ньютона позволяет, грубо говоря, удваивать число точных битов после запятой на каждой итерации. Можно удваивать до тех пор, пока не станет «достаточно», хотя это довольно трудоёмкий процесс, надо признаться и не везде метод Ньютона оправдан, потому что требует вычисления обратной функции f-1(x), что в некоторых случаях может оказаться ничуть не проще вычисления самой f(x). Для аппаратной реализации «стратегия Зива» совершенно не годится. Алгоритм, зашитый в процессоре, должен выполнить ряд действий с уже предустановленным числом битов и это достаточно проблематично реализовать, если мы этого числа заранее не знаем. Взять запас? А сколько?

Вероятностный подход к решению проблемы [1, раздел 12.6] позволяет оценить величину m (напомню, это число битов, которого достаточно для корректного округления). Оказывается, что длина «паровоза» в вероятностном смысле чуть больше длины мантиссы числа. Таким образом, в большинстве случаев будет достаточно брать m чуть более чем вдвое больше величины мантиссы и только в очень редких случаях придётся брать ещё больше. Цитирую авторов указанной работы: «we deduce that in practice, m must be slightly greater than 2p» (у них p — длина мантиссы вместе с целой частью, то есть p=24 для float). Далее в тексте они показывают, что вероятность ошибки при такой стратегии близка к нулю, но всё-таки положительна, и подтверждают это экспериментами.

Тем не менее, всё равно остаются случаи, когда величину m приходится брать ещё больше, и худший случай заранее неизвестен. Теоретические оценки худшего случая существуют [1, раздел 12.7.2], но они дают немыслимые миллионы битов, это никуда не годится. Вот таблица из цитируемой работы (это для функции exp(x) на интервале от -ln(2) до ln(2)):

Второй «костыль»

На практике величина m не будет такой ужасно-большой. И чтобы определить худший случай применяется второй «костыль», который называется «исчерпывающий предподсчёт». Для типа данных float (32 бита), если функция f имеет один аргумент (x), то мы можем запросто «прогнать» все возможные значения x. Проблема возникнет только с функциями, у которых больше одного аргумента (среди них pow(x, y)), для них ничего такого придумать не удалось. Проверив все возможные значения x, мы вычислим свою константу m для каждой функции f(x) и для каждого режима округления. Затем алгоритмы расчёта, которые нужно реализовать аппаратно, проектируются так, чтобы обеспечить точность 2-m. Тогда округление f(x) будет гарантированно-корректным во всех случаях.

Для типа double (64 бита) простой перебор практически невозможен. Однако ведь перебирают! Но как? Ответ даётся в [4]. Расскажу о нём очень кратко.

Область определения функции f(x) разбивается на очень маленькие сегменты так, чтобы внутри каждого сегмента можно было заменить f(x) на линейную функцию вида b-ax (коэффициенты a и b, конечно же, разные для разных сегментов). Размер этих сегментов вычисляется аналитически, чтобы такая линейная функция действительно была бы почти неотличима от исходной в каждом сегменте.

Далее после некоторых операций масштабирования и сдвига мы приходим к следующей задаче: может ли прямая линия b-ax пройти «достаточно близко» от целочисленной точки?

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

Тем не менее, перебор аргументов функции f(x) сокращается во много-много раз и делает возможным обнаруживать критические точки для чисел типа double (binary64) и long double (80 битов!). Делается это на суперкомпьютерах и, конечно же, видеокартах… в свободное от майнинга время. Тем не менее, что делать с типом данных binary128 пока никто не знает. Напомню, что дробная часть мантиссы таких чисел занимает 112 битов. Поэтому в иностранной литературе по данному поводу пока можно отыскать только полуфилософские рассуждения, начинающиеся с «we hope…» («мы надеемся…»).

Подробности метода, который позволяет быстро определить прохождение линии вблизи от целочисленных точек, здесь излагать неуместно. Желающим познать процесс более тщательно рекомендую смотреть в сторону проблемы поиска расстояния между прямой и Z2, например, в статье [5]. В ней описан усовершенствованный алгоритм, который по ходу построения напоминает знаменитый алгоритм Евклида для нахождения наибольшего общего делителя. Приведу одну и ту же картинку из [4] и [5], где изображена дальнейшая трансформация задачи:

image

Существуют обширные таблицы, содержащие худшие случаи округления на разных интервалах для каждой трансцендентной функции. Они есть в [1 раздел 12.8.4] и в [3, раздел 10.5.3.2], а также в отдельных статьях, например, в [6].

Я приведу несколько примеров, взяв случайные строки из таких таблиц. Подчёркиваю, это не худшие случаи для всех x, а только для каких-то небольших интервалов, смотрите первоисточник, если заинтересовались.

Как читать таблицу? Величина x указана в шестнадцатеричной нотации числа с плавающей запятой double. Сначала, как положено, идёт лидирующая единица, затем 52 бита дробной части мантиссы и буква P. Эта буква означает «умножить на два в степени» далее идёт степень. Например, P-23 означает, что указанную мантиссу нужно умножить на 2-23.

Далее представьте, что вычисляется функция f(x) с бесконечной точностью и у неё отрезают (без округления!) первые 53 бита. Именно эти 53 бита (один из них — до запятой) указываются в столбце f(x). Последующие биты указаны в последнем столбце. Знак «степени» у битовой последовательности в последнем столбце означает число повторений бита, то есть, например, 10531011 означает, что сначала идёт бит, равный 1, затем 53 нуля и далее 1011. Потом троеточие, которое означает, что остальные биты нам, в общем-то, и не нужны вовсе.

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

Зачем это нужно?

Прекрасный вопрос! Ведь я выше неоднократно высказался о том, что почти 100% программистам не нужно знать элементарную функцию с точностью до корректно-округлённого последнего бита (часто им и половина битов не нужна), зачем учёные гоняют суперкомпьютеры и составляют таблицы ради решения «бесполезной» задачи?

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

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

Список источников

[1] Jean-Michel Muller, “Elementary Functions: Algorithms and Implementation”, 2016

[2] William Kahan, “A Logarithm Too Clever by Half”, 2004

[3] Jean-Michel Muller, “Handbook of floating-point arithmetic”, 2018

[4] Vincent Lefèvre, Jean-Michel Muller, “Toward Correctly Rounded Transcendentals”, IEEE TRANSACTIONS ON COMPUTERS, VOL. 47, NO. 11, NOVEMBER 1998. pp. 1235-1243

[5] Vincent Lefèvre. “New Results on the Distance Between a Segment and Z2”. Application to the Exact Rounding. 17th IEEE Symposium on Computer Arithmetic — Arith’17, Jun 2005, Cape Cod, MA,
United States. pp.68-75

[6] Vincent Lefèvre, Jean-Michel Muller, “Worst Cases for Correct Rounding of the Elementary Functions in Double Precision”, Rapport de recherche (INSTITUT NA TIONAL DE RECHERCHE EN INFORMA TIQUE ET EN AUTOMA TIQUE) n˚4044 — Novembre 2000 — 19 pages.

Добавлено 1 мая 2021 в 13:50

Целочисленные типы отлично подходят для подсчета целых чисел, но иногда нам нужно хранить очень большие числа или числа с дробной частью. Переменная типа с плавающей точкой (запятой) – это переменная, которая может содержать действительное число, например 4320,0, -3,33 или 0,01226. «Плавающая» в названии «с плавающей точкой» указывает на то, что десятичная точка может «плавать»; то есть она может поддерживать переменное количество цифр до и после себя.

Существует три разных типа данных с плавающей точкой: float, double и long double. Как и в случае с целыми числами, C++ не определяет фактические размеры этих типов (но гарантирует минимальные размеры). В современных архитектурах представление с плавающей точкой почти всегда соответствует двоичному формату IEEE 754. В этом формате float составляет 4 байта, double – 8, а long double может быть эквивалентно double (8 байтов), 80 битам (часто с дополнением до 12 байтов) или 16 байтам.

Типы данных с плавающей запятой всегда идут со знаком (могут содержать положительные и отрицательные значения).

Категория Тип Минимальный размер Типовой размер
С плавающей запятой float 4 байта 4 байта
  double 8 байт 8 байт
  long double 8 байт 8, 12 или 16 байт

Ниже показан пример определения чисел с плавающей запятой:

float fValue;
double dValue;
long double ldValue;

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

int x{5};      // 5 означает целое число
double y{5.0}; // 5.0 - литерал с плавающей точкой (отсутствие суффикса по умолчанию означает тип double)
float z{5.0f}; // 5.0 - литерал с плавающей точкой, суффикс f означает тип float

Обратите внимание, что по умолчанию литералы с плавающей точкой по умолчанию имеют тип double. Суффикс f используется для обозначения литерала типа float.

Лучшая практика


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

Предупреждение


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

Печать чисел с плавающей точкой

Теперь рассмотрим следующую простую программу:

#include <iostream>
 
int main()
{
	std::cout << 5.0 << 'n';
	std::cout << 6.7f << 'n';
	std::cout << 9876543.21 << 'n';
}

Результаты работы этой, казалось бы, простой программы могут вас удивить:

5
6.7
9.87654e+06

В первом случае std::cout напечатал 5, хотя мы ввели 5.0. По умолчанию std::cout не будет печатать дробную часть числа, если она равна 0.

Во втором случае число печатается так, как мы и ожидали.

В третьем случае напечаталось число в экспоненциальном представлении (если вам нужно освежить в памяти экспоненциальное представление, смотрите урок «4.7 – Введение в экспоненциальную запись»).

Диапазоны значений типов с плавающей точкой

Предполагая, что используется представление IEEE 754:

Размер Диапазон значений Точность
4 байта от ±1,18 × 10-38 до ±3,4 × 1038 6-9 значащих цифр, обычно 7
8 байт от ±2,23 × 10-308 до ±1,80 × 10308 15-18 значащих цифр, обычно 16
80 бит (обычно используется 12 или 16 байт) от ±3,36 × 10-4932 до ± 1,18 × 104932 18-21 значащая цифра
16 байт от ±3,36 × 10-4932 до ± 1,18 × 104932 33-36 значащих цифр

80-битный тип с плавающей запятой – это своего рода историческая аномалия. На современных процессорах он обычно реализуется с использованием 12 или 16 байтов (что является более естественным размером для обработки процессорами).

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

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

Рассмотрим дробь 1/3. Десятичное представление этого числа – 0,33333333333333… с тройками, уходящими в бесконечность. Если бы вы писали это число на листе бумаги, ваша рука в какой-то момент устала бы, и вы, в конце концов, прекратили бы писать. И число, которое у вас осталось, будет близко к 0,3333333333…. (где 3-ки уходят в бесконечность), но не совсем.

На компьютере число бесконечной длины потребует для хранения бесконечной памяти, но обычно у нас есть только 4 или 8 байтов. Эта ограниченная память означает, что числа с плавающей запятой могут хранить только определенное количество значащих цифр – и что любые дополнительные значащие цифры теряются. Фактически сохраненное число будет близко к необходимому, но не точно.

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

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

Следующая программа показывает усечение std::cout до 6 цифр:

#include <iostream>
 
int main()
{
    std::cout << 9.87654321f << 'n';
    std::cout << 987.654321f << 'n';
    std::cout << 987654.321f << 'n';
    std::cout << 9876543.21f << 'n';
    std::cout << 0.0000987654321f << 'n';
 
    return 0;
}

Эта программа выводит:

9.87654
987.654
987654
9.87654e+006
9.87654e-005

Обратите внимание, что каждое из напечатанных значений имеет только 6 значащих цифр.

Также обратите внимание, что std::cout в некоторых случаях переключился на вывод чисел в экспоненциальном представлении. В зависимости от компилятора показатель степени обычно дополняется до минимального количества цифр. Не беспокойтесь, 9.87654e+006 – это то же самое, что 9.87654e6, только с некоторым количеством дополнительных нулей. Минимальное количество отображаемых цифр показателя степени зависит от компилятора (Visual Studio использует 3, некоторые другие в соответствии со стандартом C99 используют 2).

Число цифр точности переменной с плавающей запятой зависит как от размера (у float точность меньше, чем у double), так и от конкретного сохраняемого значения (некоторые значения имеют большую точность, чем другие). Значения float имеют точность от 6 до 9 цифр, при этом большинство значений float имеют не менее 7 значащих цифр. Значения double имеют от 15 до 18 цифр точности, при этом большинство значений double имеют не менее 16 значащих цифр. Значения long double имеет минимальную точность 15, 18 или 33 значащих цифр в зависимости от того, сколько байтов этот тип занимает.

Мы можем переопределить точность по умолчанию, которую показывает std::cout, используя функцию манипулятора вывода с именем std::setprecision(). Манипуляторы вывода изменяют способ вывода данных и определяются в заголовке iomanip.

#include <iostream>
#include <iomanip> // для манипулятора вывода std::setprecision()
 
int main()
{
    std::cout << std::setprecision(16); // показать с точностью 16 цифр
    std::cout << 3.33333333333333333333333333333333333333f <<'n'; // суффикс f означает float
    std::cout << 3.33333333333333333333333333333333333333 << 'n'; // отсутствие суффикса означает double
    return 0;
}

Вывод программы:

3.333333253860474
3.333333333333334

Поскольку с помощью std::setprecision() мы устанавливаем точность в 16 цифр, каждое из приведенных выше чисел печатается с 16 цифрами. Но, как видите, числа определенно неточны до 16 цифр! А поскольку числа float менее точны, чем числа double, число ошибок у float больше.

Проблемы с точностью влияют не только на дробные числа, они влияют на любое число со слишком большим количеством значащих цифр. Рассмотрим большое число:

#include <iomanip> // для std::setprecision()
#include <iostream>
 
int main()
{
    float f { 123456789.0f };          // f имеет 10 значащих цифр
    std::cout << std::setprecision(9); // чтобы показать 9 цифр в f
    std::cout << f << 'n';
    return 0;
}

Вывод программы:

123456792

123456792 больше, чем 123456789. Значение 123456789.0 имеет 10 значащих цифр, но значения float обычно имеют точность 7 цифр (и результат 123456792 точен только до 7 значащих цифр). Мы потеряли точность! Когда теряется точность из-за того, что число не может быть точно сохранено, это называется ошибкой округления.

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

Лучшая практика


Если нет ограничений по использованию памяти, отдавайте предпочтение использованию double вместо float, поскольку неточность float часто приводит к погрешностям.

Ошибки округления затрудняют сравнение чисел с плавающей запятой

С числами с плавающей запятой сложно работать из-за неочевидных различий между двоичными (как хранятся данные) и десятичными (как мы думаем) числами. Рассмотрим дробь 1/10. В десятичном формате ее легко представить как 0,1, и мы привыкли думать о 0,1 как о легко представимом числе с 1 значащей цифрой. Однако в двоичном формате 0,1 представлен бесконечной последовательностью: 0,00011001100110011… Из-за этого, когда мы присваиваем 0,1 числу с плавающей точкой, мы сталкиваемся с проблемами точности.

Эффект от этого можно увидеть в следующей программе:

#include <iomanip> // для std::setprecision()
#include <iostream>
 
int main()
{
    double d{0.1};
    std::cout << d << 'n'; // использовать точность cout по умолчанию, равную 6
    std::cout << std::setprecision(17);
    std::cout << d << 'n';
    return 0;
}

Эта программ выводит следующее:

0.1
0.10000000000000001

Как и ожидалось, в первой строке std::cout печатает 0,1.

Во второй строке, где std::cout показывает нам 17-значную точность, мы видим, что d на самом деле не совсем равно 0,1! Это связано с тем, что из-за ограниченной памяти double пришлось усекать приближение. В результате получается число с точностью до 16 значащих цифр (что гарантирует тип double), но это число не равно 0,1. Ошибки округления могут сделать число немного меньше или немного больше, в зависимости от того, где происходит усечение.

Ошибки округления также могут иметь неожиданные последствия:

#include <iomanip> // для std::setprecision()
#include <iostream>
 
int main()
{
    std::cout << std::setprecision(17);
 
    double d1{ 1.0 };
    std::cout << d1 << 'n';
	
    double d2{ 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 }; // должно быть равно 1.0
    std::cout << d2 << 'n';
}
1
0.99999999999999989

Хотя можно было ожидать, что d1 и d2 должны быть равны, мы видим, что это не так. Если бы мы сравнивали d1 и d2 в программе, программа, вероятно, не работала бы так, как ожидалось. Поскольку числа с плавающей запятой имеют тенденцию быть неточными, их сравнение обычно проблематично – мы обсудим эту тему (и решения) подробнее в уроке «5.6 – Операторы отношения и сравнение значений с плавающей запятой».

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

Ключевые выводы


Ошибки округления возникают, когда число не может быть сохранено точно. Это может произойти даже с простыми числами, например, 0,1. Таким образом, ошибки округления могут происходить постоянно. Ошибки округления – не исключение, а правило. Никогда не предполагайте, что ваши числа с плавающей запятой точны.

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

NaN и Inf

Существует две особые категории чисел с плавающей запятой. Первая – Inf, которая представляет бесконечность. Inf может быть положительной или отрицательной. Вторая – NaN, что означает «Not a Number» (не число). Существует несколько различных типов NaN (которые мы здесь обсуждать не будем). NaN и Inf доступны только в том случае, если компилятор для чисел с плавающей запятой использует определенный формат (IEEE 754). Если используется другой формат, следующий код приводит к неопределенному поведению.

Ниже приведена программа, показывающая все эти три категории чисел с плавающей точкой:

#include <iostream>
 
int main()
{
    double zero {0.0};
    double posinf { 5.0 / zero };  // положительная бесконечность
    std::cout << posinf << 'n';
 
    double neginf { -5.0 / zero }; // отрицательная бесконечность
    std::cout << neginf << 'n';
 
    double nan { zero / zero };   // не число (математически неверно)
    std::cout << nan << 'n';
 
    return 0;
}

И результаты работы этой программы при использовании Visual Studio 2008 в Windows:

1.#INF
-1.#INF
1.#IND

INF означает бесконечность, а IND означает неопределенность. Обратите внимание, что результаты печати Inf и NaN зависят от платформы, поэтому ваши результаты могут отличаться.

Лучшая практика


Вообще избегайте деления на 0, даже если ваш компилятор поддерживает это.

Заключение

Подводя итог, вы должны помнить две вещи о числах с плавающей запятой:

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

Теги

C++ / CppdoublefloatInf / infinity / бесконечностьiomanipLearnCpplong doubleNaN / Not a Number / не числоДля начинающихМанипулятор выводаОбучениеОшибка округленияПрограммированиеЧисловые типы с плавающей точкой

Понравилась статья? Поделить с друзьями:
  • Dotnetfx40 full x86 x64 ошибка
  • Dotnet new console ошибка сегментирования
  • Dotnet exe ошибка
  • Dors 750 ir red ошибка сканера
  • Dors 620 ошибка stcr