Ошибка Round-дважды в методе .NET Double.ToString

Математически рассмотрим для этого вопроса рациональное число

8725724278030350 / 2**48

где ** в знаменателе обозначает возведение в степень, т.е. знаменатель 2 до 48 -й степени. (Дробь не находится в младших членах, сводится к 2.) Это число точно представляется как System.Double. Его десятичное разложение есть

31.0000000000000'49'73799150320701301097869873046875 (exact)

где апострофы не представляют отсутствующие цифры, а просто отмечают суки, где округление до 15 соответственно. 17.

Обратите внимание на следующее: если это число округлено до 15 цифр, результат будет 31 (за ним следует тринадцать 0 s), потому что следующие цифры (49...) начинаются с 4 (что означает round вниз). Но если число сначала округлено до 17 цифр, а затем округлено до 15 цифр, результат может быть 31.0000000000001. Это связано с тем, что первое округление округляется, увеличивая цифры 49... до 50 (terminates) (следующие цифры были 73...), а второе округление затем снова округлялось (когда правило округления средней точки гласит: "округлить от нуля" ).

(Разумеется, есть много других чисел с вышеуказанными характеристиками.)

Теперь, оказывается, стандартное строковое представление .NET этого числа "31.0000000000001". Вопрос: не является ли это ошибкой?. По стандартным строковым представлениям мы подразумеваем String, созданный методом экземпляров параметров Double.ToString(), который, конечно, идентичен тому, что создается ToString("G").

Интересно отметить, что если вы набросаете вышеприведенный номер на System.Decimal, тогда вы получите точно decimal 31! См. этот вопрос о переполнении стека для обсуждения удивительного факта, что отбрасывание Double в decimal включает в себя первое округление до 15 цифр. Это означает, что кастинг на decimal делает правильный раунд до 15 цифр, тогда как вызов ToSting() делает неправильный.

Подводя итог, мы имеем число с плавающей запятой, которое при выводе на пользователя 31.0000000000001, но при преобразовании в decimal (где 29 цифры доступны), становится 31 точно. Это печально.

Вот несколько С# -кодов для проверки проблемы:

static void Main()
{
  const double evil = 31.0000000000000497;
  string exactString = DoubleConverter.ToExactString(evil); // Jon Skeet, http://csharpindepth.com/Articles/General/FloatingPoint.aspx 

  Console.WriteLine("Exact value (Jon Skeet): {0}", exactString);   // writes 31.00000000000004973799150320701301097869873046875
  Console.WriteLine("General format (G): {0}", evil);               // writes 31.0000000000001
  Console.WriteLine("Round-trip format (R): {0:R}", evil);          // writes 31.00000000000005

  Console.WriteLine();
  Console.WriteLine("Binary repr.: {0}", String.Join(", ", BitConverter.GetBytes(evil).Select(b => "0x" + b.ToString("X2"))));

  Console.WriteLine();
  decimal converted = (decimal)evil;
  Console.WriteLine("Decimal version: {0}", converted);             // writes 31
  decimal preciseDecimal = decimal.Parse(exactString, CultureInfo.InvariantCulture);
  Console.WriteLine("Better decimal: {0}", preciseDecimal);         // writes 31.000000000000049737991503207
}

В приведенном выше коде используется метод Skeet ToExactString. Если вы не хотите использовать его материал (может быть найден через URL-адрес), просто удалите строки кода, зависящие от exactString. Вы все еще можете увидеть, как рассматриваемый Double (evil) закруглен и сбрасывается.

Сложение:

ОК, поэтому я проверил еще несколько номеров, и вот таблица:

  exact value (truncated)       "R" format         "G" format     decimal cast
 -------------------------  ------------------  ----------------  ------------
 6.00000000000000'53'29...  6.0000000000000053  6.00000000000001  6
 9.00000000000000'53'29...  9.0000000000000053  9.00000000000001  9
 30.0000000000000'49'73...  30.00000000000005   30.0000000000001  30
 50.0000000000000'49'73...  50.00000000000005   50.0000000000001  50
 200.000000000000'51'15...  200.00000000000051  200.000000000001  200
 500.000000000000'51'15...  500.00000000000051  500.000000000001  500
 1020.00000000000'50'02...  1020.000000000005   1020.00000000001  1020
 2000.00000000000'50'02...  2000.000000000005   2000.00000000001  2000
 3000.00000000000'50'02...  3000.000000000005   3000.00000000001  3000
 9000.00000000000'54'56...  9000.0000000000055  9000.00000000001  9000
 20000.0000000000'50'93...  20000.000000000051  20000.0000000001  20000
 50000.0000000000'50'93...  50000.000000000051  50000.0000000001  50000
 500000.000000000'52'38...  500000.00000000052  500000.000000001  500000
 1020000.00000000'50'05...  1020000.000000005   1020000.00000001  1020000

В первом столбце указано точное (хотя и усеченное) значение, которое представляет Double. Во втором столбце представлено строковое представление из строки формата "R". В третьем столбце представлено обычное строковое представление. И, наконец, четвертый столбец дает System.Decimal, который получается из преобразования этого Double.

Мы заключаем следующее:

  • Круглые до 15 цифр ToString() и округлые до 15 цифр путем преобразования в decimal не согласуются во многих случаях
  • Преобразование в decimal также во многих случаях неверно округляется, и ошибки в этих случаях не могут быть описаны как ошибки "round-two"
  • В моих случаях ToString(), кажется, дает большее число, чем decimal, когда они не согласны (независимо от того, какой из двух раундов правильно)

Я только экспериментировал с такими случаями, как выше. Я не проверял, есть ли ошибки округления с числами других "форм".

Ответы

Ответ 1

Итак, из ваших экспериментов кажется, что Double.ToString не делает правильного округления.

Это довольно неудачно, но не особенно удивительно: правильное округление двоичных к десятичным преобразованиям является нетривиальным, а также потенциально довольно медленным, требующим многоточечной арифметики в угловых случаях. См. David Gay dtoa.c code здесь для одного примера того, что связано с правильно округленным преобразованием double-to-string и string-to-double. (Python в настоящее время использует вариант этого кода для своих преобразований float-to-string и string-to-float.)

Даже текущий стандарт IEEE 754 для арифметики с плавающей запятой рекомендует, но не требует, чтобы преобразования из двоичных типов с плавающей запятой в десятичные строки всегда были правильно округлены. Здесь приведен фрагмент из раздела 5.12.2 "Внешние десятичные последовательности символов, представляющие конечные числа".

Может существовать предел, определенный реализацией, по числу значимые цифры, которые могут быть преобразованы с правильным округлением в и из поддерживаемых двоичных форматов. Этот предел H должен быть таким, что H ≥ M + 3, и должно быть, что H неограничен.

Здесь M определяется как максимум Pmin(bf) по всем поддерживаемым двоичным форматам bf, а так как Pmin(float64) определяется как 17, а .NET поддерживает формат float64 с помощью типа Double M должен быть не менее 17 в .NET. Короче говоря, это означает, что если .NET будет следовать стандарту, он будет обеспечивать корректное округленное преобразование строк, по меньшей мере, до 20 значащих цифр. Таким образом, похоже, что .NET Double не соответствует этому стандарту.

В ответ на вопрос "Это ошибка", так как я бы хотел, чтобы это была ошибка, действительно нет никаких претензий относительно точности или соответствия IEEE 754 в любом месте, которое я могу найти в документация формата форматирования для .NET. Таким образом, это может считаться нежелательным, но мне было бы трудно назвать его реальной ошибкой.


EDIT: Jeppe Stig Nielsen указывает, что страница System.Double в MSDN заявляет, что

Двойной соответствует стандарту IEC 60559: 1989 (IEEE 754) для двоичных арифметика с плавающей запятой.

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

Учитывая это, я с радостью повышу свою оценку до "возможной ошибки".

Ответ 2

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

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

31.0000000000000480 = 0x403f00000000000e
31.0000000000000497 = 0x403f00000000000e
31.0000000000000515 = 0x403f00000000000e

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

Есть два дополнительных аспекта, которые следует учитывать при прямом и обратном преобразовании IEEE 754 в строки, особенно в среде .NET.

Сначала (я не могу найти первичный источник) из Википедии мы имеем:

Если десятичная строка, содержащая не более 15 значащих десятичных знаков, преобразуется к двойной точности IEEE 754, а затем преобразован обратно в тот же число значащих десятичных чисел, то окончательная строка должна соответствовать оригинал; и если двойная точность IEEE 754 преобразуется в десятичная строка с не менее чем 17 значащими десятичными знаками, а затем преобразованная назад, чтобы удвоить, то окончательное число должно соответствовать оригиналу.

Поэтому, в отношении соответствия стандарту, преобразование строки 31.0000000000000497 в double не обязательно будет одинаковым при преобразовании обратно в строку (слишком много десятичных знаков).

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

Кроме того, документация по Double.ToString() показывает, что она определяется спецификатором числового формата текущих настроек культуры.

Возможное полное объяснение:

Я подозреваю, что двойное округление происходит примерно так: начальная десятичная строка создается на 16 или 17 значащих цифр, потому что это требуемая точность для конверсии "туда и обратно", дающая промежуточный результат 31.00000000000005 или 31.000000000000050. Затем из-за настроек культуры по умолчанию результат округляется до 15 значащих цифр, 31.00000000000001, поскольку 15 значащих десятичных цифр являются минимальной точностью для всех удвоений.

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

Ответ 3

Вопрос: Разве это не ошибка?

Да. См. этот PR на GitHub. Причина округления дважды AFAK - для вывода "симпатичного" формата, но он вводит ошибку, как вы уже обнаружили здесь. Мы попытались его исправить - удалите прецизионное преобразование на 15 цифр, прямо перейдите к преобразованию точности 17 цифр. Плохая новость - это потрясающее изменение и многое сломает. Например, один из тестовых примеров сломается:

10:12:26 Assert.Equal() Failure 10:12:26 Expected: 1.1 10:12:26 Actual: 1.1000000000000001

Фиксирование повлияет на большой набор существующих библиотек, поэтому, наконец, этот PR уже закрыт. Тем не менее, команда .NET Core по-прежнему ищет шанс исправить эту ошибку. Добро пожаловать, чтобы присоединиться к обсуждению.

Ответ 4

У меня более простое подозрение: виновником, скорее всего, является оператор pow = > **; Хотя ваш номер точно представлен как двойной, для удобства (силовому оператору требуется много работы для правильной работы), мощность рассчитывается по экспоненциальной функции. Это одна из причин, по которой вы можете оптимизировать производительность умножая число раз вместо использования pow(), потому что pow() очень дорогой.

Таким образом, он не дает вам правильного 2 ^ 48, но что-то немного неправильное и поэтому у вас проблемы с округлением. Проверьте, что именно возвращается 2 ^ 48. Забастовкa >

EDIT: Извините, я просмотрел только проблему и дал неправильное подозрение. Там есть известная проблема с двойным округлением на процессорах Intel. Более старый код использует внутренний 80-битный формат FPU вместо инструкций SSE, который, вероятно, чтобы вызвать ошибку. Значение записывается точно в регистр 80 бит, а затем округленный дважды, поэтому Джеппе уже нашел и аккуратно объяснил проблему.

Это ошибка? Ну, процессор делает все правильно, это просто проблема в том, что Intel FPU внутренне имеет более высокую точность для плавающих точек операции.

ДАЛЬНЕЙШАЯ РЕДАКТИРОВКА И ИНФОРМАЦИЯ: "Двойное округление" является известной проблемой и явно упоминается в "Справочнике по арифметике с плавающей точкой" Жан-Мишеля Мюллера и др. и др. в главе "Необходимость для пересмотра "под" 3.3.1 Типичная проблема: "двойное округление" на стр. 75:

Используемый процессор может предложить внутреннюю точность, которая шире чем точность переменных программы (типичный пример это формат с двойным расширением, доступный на платформах Intel, когда переменные программы являются одноточечными или с двойной точностью числа с плавающей запятой). Иногда это может иметь странные побочные эффекты, поскольку мы увидим в этом разделе. Рассмотрим программу C [...]

#include <stdio.h>

int main(void) 
{
  double a = 1848874847.0;
  double b = 19954562207.0;
  double c;
  c = a * b;
  printf("c = %20.19e\n", c);
  return 0;
}

32bit: GCC 4.1.2 20061115 для Linux/Debian

С Compilerswitch или с -mfpmath = 387 (80 бит-FPU): 3.6893488147419103232e + 19 -march = pentium4 -mfpmath = sse (SSE) или 64-бит: 3.6893488147419111424e + 19

Как объясняется в книге, решение для несоответствия - это двойное округление с 80 бит и 53 бит.