Как следует отображать на экране результат деления 3.0 на 10.0 ? Сколько цифр следует вывести, если пользователь не указал точность?
Скорее всего, вы даже не знали, что вывод на экран чисел с плавающей запятой — это сложная проблема, настолько сложная, что по ней написаны десятки научных статей, причём последний прорыв был относительно недавно, в 2016 году. На самом деле, это одна из самых сложных частей поддержки чисел с плавающей запятой в среде выполнения языка.
Давайте продолжим разговор о самой неоптимизированной в мире библиотеке эмуляции плавающей точки при помощи целочисленной арифметики.
Это вторая статья из цикла «Санпросвет о плавающей точке»:
Вывод чисел с плавающей точкой на экран <- вы тут
Три основные проблемы
Спойлер: в моей реализации я намеренно обойду самые сложные части,
но давайте посмотрим, что делает эту задачу такой сложной.
Проблема № 1: нужное количество цифр
Обратите внимание, что я написал числа 3.0 и 10.0 в десятичной системе счисления (база 10), потому что это система, которую используют люди. Но большинство десятичных дробей не имеют точного двоичного представления:
Эта двоичная дробь бесконечна, паттерн 0011 повторяется бесконечно.
Поэтому, когда мы пишем x = 0.3, переменная x на самом деле хранит ближайшее представимое число с плавающей запятой.
Например, в Python плавающая запятая имеет двойную точность (64 бита). Таким образом, x на самом деле хранит 0.299999999999999988897769753748434595763683319091796875.
Вы можете проверить это сами:
from decimal import Decimal
x = 0.3
print(Decimal(x))Если мы создадим вторую переменную y = 0.1 + 0.2, то сохраненное значение будет: 0.3000000000000000444089209850062616169452667236328125.
from decimal import Decimal
y = 0.1 + 0.2
print(Decimal(y))Неудивительно, что x и y различаются: y накопило три ошибки аппроксимации — по одной для каждого операнда и еще одну от сложения:
0.1округляется до0.10000000000000000555111512312578270211815834045410156250.2округляется до0.200000000000000011102230246251565404236316680908203125Их сумма вносит еще одну ошибку округления.
Теперь вопрос: как мы должны вывести x и y? Давайте посмотрим, как это делает Python:

Первое требование — безопасность при обратном преобразовании:
какую бы десятичную строку мы ни вывели, при обратном преобразовании должно быть восстановлено точно то же двоичное число с плавающей запятой.
Если система выводит слишком много цифр, лишние цифры могут быть просто «мусором» от двоичного округления.
Даже еслиxравно0.299999999999999988897769753748434595763683319091796875, нет необходимости печатать все 54 цифры после десятичной запятой. На самом деле мы хотели сохранить0.3, поэтому 54 цифры являются фактически численным мусором. Простого0.3достаточно для определения значенияx, даже еслиxне равно.
Если система выводит слишком мало цифр, результат будет неверным в более строгом смысле: обратное преобразование в двоичную систему может дать другое число с плавающей запятой.
Например, самым коротким десятичным числом, которое однозначно идентифицирует
y, является0.30000000000000004. Вывод только0.3будет неправильным, потому что0.3при обратном преобразовании дает другое двоичное значение.
Таким образом, правило таково:
Нужно вывести самую короткую десятичную строку, которая точно преобразуется в обратном направлении.
Проблема № 2: скорость
Наивный способ найти кратчайший вывод — сгенерировать гораздо больше цифр, чем необходимо, а затем постепенно округлять и проверять, восстанавливается ли исходное число, пока не будет найдена кратчайшая строка, которая проходит весь цикл. Это работает, но крайне неэффективно.
Именно поэтому существуют специализированные алгоритмы:
Dragon4 (классический, точный, но сложный)
Grisu3, Errol3, Ryu (более новые, более быстрые, доказательно минимальные)
Эти алгоритмы являются одними из самых сложных частей языковых сред выполнения (C, Java, Python, JavaScript и т. д.). Кроме того, производительность тесно связана со следующей сложностью.
Проблема № 3: только правильные цифры
Концептуально, чтобы сгенерировать правильные десятичные цифры, мы хотим выразить число с плавающей запятой в виде рационального числа:
Но число с плавающей запятой имеет огромный динамический диапазон. Наибольшее конечное число типа double превышает . Это число имеет 308 десятичных цифр, что намного превышает возможности даже 128-битного целого числа (максимум
). Поэтому нам нужна высокоточная арифметика (большие числа), чтобы выполнить точное деление без переполнения.
Современные алгоритмы, такие как Ryu и Grisu, хитро избегают большинства операций с большими числами: они быстро генерируют цифры с помощью 64/128-битной арифметики целых чисел и прибегают к большим числам только в редких случаях (проверка границ, сложные пограничные случаи). Это обеспечивает как правильность (безопасность при обратном проходе), так и скорость.
Печать точного значения
Тем не менее, для моих нужд мне не важны самые короткие строки или скорость. Вместо этого я хочу печатать точное сохраненное значение числа с плавающей запятой. Почему? Потому что я пишу библиотеку эмуляции плавающей точки, и возможность видеть точные значения значительно упрощает отладку.
Обратите внимание, что даже если большинство десятичных дробей не могут быть точно представлены в двоичной системе, каждая конечная двоичная дробь имеет конечное десятичное разложение. Таким образом, вывод точного значения всегда возможен.
Более десяти лет назад Брюс Доусон опубликовал реализацию на C++, которая выводит точное сохраненное значение float. Его метод использовал только арифметику int32_t для одинарной точности, но полагался на частичную реализацию длинной арифметики для десятичного преобразования (путем повторяющегося деления на 10).
Технически это прекрасно, но я хочу что-то более простое и прозрачное.
Класс Float7
Мне нужен простой, короткий и надежный код. Я не хочу отлаживать вычисления деления/остатка для длинной арифметики. Если вам нравится простой код, давайте вернемся к нашему игрушечному 7-битному беззнаковому числу с плавающей запятой, которое мы создали в предыдущей главе (если вы не читали предыдущую статью, лучше это сделать сейчас):
n_e = 3
n_m = 7-n_e
anchors = [ 0 ]
for e in range(-2**(n_e-1)+1, 2**(n_e-1)+1): # prepare 2**n_e intervals
anchors.append(2**e)
numbers = []
for i in range(len(anchors)-1): # for each interval
for m in range(2**n_m): # populate with 2**n_m numbers
v = anchors[i] + m/2**n_m * (anchors[i+1]-anchors[i])
numbers.append(v)
print(numbers)Это делит диапазон на 8 интервалов, каждый из которых заполнен 16 равномерно распределенными числами. Всего у нас есть 128 значений:
[0.0, 0.0078125, 0.015625, 0.0234375, 0.03125, 0.0390625, 0.046875, 0.0546875, 0.0625, 0.0703125, 0.078125, 0.0859375, 0.09375, 0.1015625, 0.109375, 0.1171875, 0.125, 0.1328125, 0.140625, 0.1484375, 0.15625, 0.1640625, 0.171875, 0.1796875, 0.1875, 0.1953125, 0.203125, 0.2109375, 0.21875, 0.2265625, 0.234375, 0.2421875, 0.25, 0.265625, 0.28125, 0.296875, 0.3125, 0.328125, 0.34375, 0.359375, 0.375, 0.390625, 0.40625, 0.421875, 0.4375, 0.453125, 0.46875, 0.484375, 0.5, 0.53125, 0.5625, 0.59375, 0.625, 0.65625, 0.6875, 0.71875, 0.75, 0.78125, 0.8125, 0.84375, 0.875, 0.90625, 0.9375, 0.96875, 1.0, 1.0625, 1.125, 1.1875, 1.25, 1.3125, 1.375, 1.4375, 1.5, 1.5625, 1.625, 1.6875, 1.75, 1.8125, 1.875, 1.9375, 2.0, 2.125, 2.25, 2.375, 2.5, 2.625, 2.75, 2.875, 3.0, 3.125, 3.25, 3.375, 3.5, 3.625, 3.75, 3.875, 4.0, 4.25, 4.5, 4.75, 5.0, 5.25, 5.5, 5.75, 6.0, 6.25, 6.5, 6.75, 7.0, 7.25, 7.5, 7.75, 8.0, 8.5, 9.0, 9.5, 10.0, 10.5, 11.0, 11.5, 12.0, 12.5, 13.0, 13.5, 14.0, 14.5, 15.0, 15.5]У нас есть 128 чисел , индексированных 8 значениями
и 16 значениями
. Вставим явно значения опорных точек
в строку 11 вышеприведенного фрагмента кода на Python.
Таким образом, мы можем записать общую формулу:
Это отражает различие между денормализованными числами (первый случай) и нормальными (второй случай).
Следовательно, мы можем переписать вышеуказанный фрагмент в следующей форме:
numbers = []
for e in range(-2**(n_e-1), 2**(n_e-1)):
for m in range(2**n_m):
if e == -2**(n_e-1):
e += 1 # subnormal number
else:
m += 2**n_m # normal number
v = m * 2**(e-n_m)
numbers.append(v)
print(numbers) Обратите внимание на строки 5 и 7:
мы увеличиваем
, если число является денормализованным,
и добавляем
к
, если число является нормальным
.
Таким образом, мы получаем уникальную формулу для конечного числа, которое мы генерируем в строке 9. Фактически это означает, что, несмотря на то, что мы фактически храним битов числа
в памяти, это
-битное число без знака. Дополнительный 5-й бит нашего
Float7 не хранится явно, он «скрыт» и может быть восстановлен из значения e.
Это также означает, что мантисса кодирует число с фиксированной запятой с 1 битом для целой части и
битами для дробной части.
Следовательно, . Умножив его на
, мы сдвигаем точку на
двоичных разрядов.
Вместо перечисления всех возможных чисел с плавающей запятой, давайте создадим класс Float7 для представления одного значения:
class Float7:
def __init__(self, uint7):
self.e = uint7 // 16 - 4
self.m = uint7 % 16
if self.e == -4:
self.e += 1 # if subnormal, increment the exponent, the hidden bit = 0
else:
self.m += 16 # if normal, recover the hidden bit = 1
def __float__(self):
return self.m * 2**(self.e-4)
print(float(Float7(93)))Конструктор (строка 2) принимает 7-битный шаблон и разделяет его на экспоненту и мантиссу
, восстанавливая скрытый бит. Оператор
__float__() преобразует наш пользовательский класс в нативные числа с плавающей запятой Python. Наконец, код выводит 93е число с плавающей запятой:
3.625На всякий случай проверим вычисления:
Конструктор получает битовые шаблоны 101 и 1101 для экспоненты и мантиссы. Знаковая интерпретация со смещением говорит нам, что 101 интерпретируется как :
Число является нормальным, поэтому мы должны восстановить скрытый бит 1 для мантиссы:
Наконец, приведение типа __float__() дает нам 3.625, как и должно быть:
Сложение в столбик
Теперь перейдем к выводу на экран. Как мы видели, каждое отдельное число с плавающей запятой можно рассматривать как число с фиксированной запятой в соответствующем интервале. Поэтому можно найти общий знаменатель для всех чисел с плавающей запятой.
Оказывается, что наш Float7 может быть представлен в виде числа с фиксированной запятой, имеющего 4 бита в целой части и 7 бит в дробной части, что можно обозначить как формат Q4.7. Мы можем определить это, учитывая, что мантисса числа с плавающей запятой является числом с фиксированной запятой Q1.4. Максимальная экспонента числа с плавающей запятой равна 3, что эквивалентно сдвигу мантиссы влево на 3 позиции. Минимальная экспонента числа с плавающей запятой равна -3, что эквивалентно сдвигу мантиссы вправо на 3 позиции. Эти величины сдвига нашей мантиссы Q1.4 означают, что все числа с плавающей запятой могут поместиться в число с фиксированной запятой Q4.7, что составляет в общей сложности 11 бит.
Скрытый текст
Мы можем представить все значения float32 без знака, включая денормализованные, с помощью 277-битного формата с фиксированной запятой Q128.149. 277 бит — это огромное число, и чаще всего для его обработки используется длинная арифметика. Я хочу полностью избежать арифметических операций с такими большими числами.
Все, что нам нужно сделать, это создать это число, вставив мантиссу в нужное место, а затем преобразовать большое число с фиксированной запятой в десятичное.
Рассмотрим пример из предыдущего фрагмента кода на Python.
Мантисса хранит число с фиксированной запятой Q1.4 . Экспонента
говорит нам, что нам нужно сдвинуть точку основания вправо на 1 разряд, поэтому представленное число равно
. Мы можем скопировать и вставить этот шаблон в число с фиксированной запятой Q4.7
0011.1010000.
Назовем битовый шаблон , где
обозначает i-й бит. Значение
можно вычислить как сумму битового шаблона, умноженного на соответствующие степени двойки:
Мы можем восстановить его десятичное представление, выполнив то, что мы так любили в начальной школе, — сложение столбцами:
0,0078125 * 0
+ 0,0156250 * 0
+ 0,0312500 * 0
+ 0,0625000 * 0
+ 0,1250000 * 1
+ 0,2500000 * 0
+ 0,5000000 * 1
+ 1,0000000 * 1
+ 2,0000000 * 1
+ 4,0000000 * 0
8,0000000 * 0
= ---------
3,6250000Да, действительно равно
!
Подводя итог, я хочу избежать арифметических операций с длинной арифметикой, явно вычислив (ещё на этапе написания программы!) десятичное представление степеней двойки, участвующих в нашем числе с фиксированной запятой Q4.7. Ну а простое сложение в столбик решает задачу.
Вот полный код:
class Float7:
def __init__(self, uint7):
self.e = uint7 // 16 - 4
self.m = uint7 % 16
if self.e == -4:
self.e += 1 # if subnormal, increment the exponent, the hidden bit = 0
else:
self.m += 16 # if normal, recover the hidden bit = 1
def __float__(self):
return self.m * 2**(self.e-4)
def __str__(self):
return str(Q4_7(self.e + 3, self.m))
class Q4_7: # 11-bit number with 4 bits in the integer part and 7 in the fraction
digits = [
[5,0,0,0,0,0,0,0,0,0,0], # constant array, 11 powers of 2 in base 10
[2,5,0,0,0,0,0,0,0,0,0],
[1,2,5,0,0,0,0,0,0,0,0],
[8,6,2,5,0,0,0,0,0,0,0],
[7,5,1,2,5,0,0,0,0,0,0],
[0,1,3,6,2,5,0,0,0,0,0],
[0,0,0,0,1,2,5,0,0,0,0], # the decimal dot is here
[0,0,0,0,0,0,0,1,2,4,8],
[0,0,0,0,0,0,0,0,0,0,0] # zero padding to avoid extra logic in line 40
]
def __init__(self, offset, uint5):
self.number = [ 0 ]*11 # 11-bit number, binary expansion of uint5 * 2**offset
while uint5 > 0:
self.number[offset] = uint5 % 2
uint5 //= 2
offset += 1
def __str__(self):
string = ''
carry = 0
for position in range(9): # loop from the least significant digit to the most significant
total = sum(bit * dgt for bit, dgt in zip(self.number, self.digits[position])) # sum of 11 digits
digit = str((total + carry) % 10) # current digit to output
carry = (total + carry)//10
string = digit + string
if position==6: # decimal dot position
string = '.' + string
return string
print(Float7(93))В строке 14 битовый шаблон вставляется в нужное место 11-разрядного числа с фиксированной запятой Q4_7. Затем в строках 36-46 функция преобразования строк __str__() просто суммирует столбцы над 11 степенями двойки, хранящимися в массиве digits. Все очень просто!
Вот результат:
03.6250000Удаляем ведущие и конечные нули, и все готово.
Вывод
Мы увидели, что вывод чисел с плавающей запятой — это удивительно сложная проблема. В то время как все более-менее нормальные языки используют сложные алгоритмы (Ryu, Grisu, Dragon4), для отладки реализаций soft-float мы можем пойти на хитрость: представлять значения в виде фиксированной запятой, заранее вычислить десятичные разложения и использовать простое сложение в столбик.
В следующий раз мы перейдем к сложению двух чисел с плавающей запятой. Оставайтесь с на связи!
