Числа с плавающей точкой (одинарной или двойной точности) печально известны за нелепые вычислительные ошибки. Например:

Ожидаемый результат: 0.3
Ожидаемый результат: 0.3
Ожидаемый результат: 0.2
Ожидаемый результат: 0.2

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

Почему это происходит?

Ресурсы компьютера ограничены. Например, нет способа сохранить число больше 255 или меньше 0 в одном байте. Если мы хотим сохранить знаковое значение, диапазон становится [-128; 127]. Вещественные числа в памяти компьютера также ограничены по точности. Целые числа вызывают переполнение, когда они выходят за пределы, но значения переменных типа с плавающей точкой вырождаются по мере увеличения их значений. Давайте рассмотрим пример:

import (
	"fmt"
)

func main() {
	fmt.Println(float32(16777215.0) == float32(16777216.0)) // false
	fmt.Println(float32(16777216.0) == float32(16777217.0)) // true
	fmt.Println(float32(16777216.0)) // 1.6777216e+07
	fmt.Println(float32(16777217.0)) // 1.6777216e+07
}

Как можно заметить, даже некоторые целые числа не могут быть адекватно представлены во float32, начиная с некоторой отметки. Тип float64 имеет более широкий диапазон целых чисел, вычисляемых без ошибок. Но что это за отметка? Ну, 16 777 216 = 2²³ × 2. А мантисса числа float32 имеет длину 23 бита. Действительно ли это та отметка, после которой значения целых чисел с плавающей точкой начинают вырождаться? Легко выяснить с помощью следующего кода:

for i := float32(0.0); i <= float32(16777216.0); i += float32(1.0) {
	if i == 16777215.0 {
		fmt.Printin("halt") // breakpoint here
	}

	if i == i+float32(1.0) {
		fmt.Println(i) // breakpoint here
	}
}

Этот небольшой блок инструкций ничего не печатает, пока значение переменной не достигнет 16 777 215. Когда она достигает 16 777 216, то сравнивается с 16 777 216 + 1, и это число не может быть представлено во float32. Поэтому код печатает значение счетчика. По той же причине увеличить его нельзя, и цикл будет продолжать печатать тот же вывод бесконечно.

То же самое можно воспроизвести и в отрицательном направлении. Вот как это выглядит:

for i := float32(0.0); i >= -float32(16777216.0); i -= float32(1.0) {
	if i == -16777215.0 {
		fmt.Printin("halt") // breakpoint here
	}
	
	if i == i-float32(1.0) {
		fmt.Println(i) // breakpoint here
	}
}

Мантисса числа float64 больше (52 бита в длину). Это значит, что вы можете свободно использовать целые числа с плавающей точкой, расположенные в диапазоне [-2⁵³; 2⁵³]. На самом деле, это довольно хороший диапазон, но да, то же самое правило применимо и здесь. Вот код:

package main

import (
	"fmt"
	"math"
)

func main() {
	fmt.Println(math.Pow(2, 53) == math.Pow(2, 53)-1) // false
	fmt.Println(math.Pow(2, 53) == math.Pow(2, 53)+1) // true
}

Почему это приемлемо?

- Добрый день. Я слышал, вы умеете быстро считать в уме. Давайте проверим. Итак, сколько будет 14 * 17?
- 230
- Но это неверно!
- Зато быстро.

Как с этим жить

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

Если вам железно надо оставить float и вы не знаете, какова должна быть точность для округления, у меня есть для вас хитроумное решение.

Безопасные операции

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

  • сложение двух целых чисел в формате с плавающей точкой, значения которых ограничены вышеупомянутыми диапазонами ([-2²⁴, 2²⁴] для float32, [-2⁵³; 2⁵³] для float64); результат также должен быть в указанных пределах;

  • вычитание двух целых чисел (те же правила);

  • умножение двух целых чисел (те же правила);

  • деление двух целых чисел (те же правила);

  • умножение любого числа с плавающей точкой (целого или дробного) на 10 в степени n, где n ≥ 0, n — целое число;

  • деление любого числа с плавающей точкой (целого или дробного) на 10 в степени n, где n ≥ 0, n — целое число;

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

Теория

Предположим, мы хотим прибавить 3,8 к 2,56. Если бы мы сделали это с помощью языка программирования, оператор вернул бы 6,359999999999999. Теперь попробуем сделать это, используя только безопасные операции. Для этого нам придется вспомнить немного базовой школьной математики:

Здесь мы просто складываем два целых числа и делим их на 10². То же самое применимо и к вычитанию. Однако умножение отличается, как можно видеть ниже:

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

В случае сложения и вычитания степень — это максимальная длина дробной части среди обоих операндов. В умножении степень — это сумма длин дробных частей обоих операндов. Но с делением всё не так просто. Есть три случая:

  • числитель имеет большую длину дробной части, чем знаменатель:

  • знаменатель имеет большую длину дробной части, чем числитель:

  • дробные части обоих чисел одинаковы по длине:

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

Нахождение длины дробной части

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

import (
	"strconv"
	"strings"
)

func GetPartsLength(number float64) (int, int) {
	numStr := strconv.FormatFloat(number, 'f', -1, 64)
	numStr = strings.TrimPrefix(numStr, "-")
	parts := strings.Split(numStr, ".")

	if len(parts) > 1 {
		return len(parts[0]), len(parts[1])
	}

	if len(parts) == 1 {
		return len(parts[0]), 0
	}

	return -1, -1
}

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

func BenchmarkGetPartsLength(b *testing.B) {
	for i := 0; i < b.N; i++ {
		safecomp.GetPartsLength(-19367.5889)
	}
}

Результат не очень воодушевляющий:

Running tool: /usr/bin/go test -benchmem -run=A$ \
-bench ABenchmarkGetPartsLength$ ieee-754/safecomp

goos: linux
goarch: amd64
pkg: ieee-754/safecomp
cpu: AMD Ryzen 5 5600H with Radeon Graphics

BenchmarkGetPartsLength-12  7736520   263.0 ns/op  72 B/op   3 allocs/op

PASS ok ieee-754/safecomp 2.196s

Строковые операции, как правило, весьма затратны в плане использования ЦП и ОЗУ. Кроме того, они часто вызывают выделение динамической памяти. Должен быть другой способ, потому что strconv.FormatFloat должен каким-то образом знать, где разместить точку в строковом представлении числа типа float. Поэтому я решил посмотреть, что происходит внутри этой функции. Стэк вызовов выглядит следующим образом:

Функция ryuFtoaShortest реализует алгоритм Ryū. Она заполняет структуру типа decimalSlice:

type decimalslice struct {
	d      []byte
	nd, dp int
	neg    bool
}

nd означает «количество цифр». Оно хранит общую длину числа, исключая знак и точку. dp означает «десятичная часть». Оно хранит длину десятичной части, поэтому длина дробной части равна nd - dp.

Теперь пришло время заставить функции служить нашей цели. Они находятся в /usr/lib/go/src/strconv. Конечно, расположение зависит от пути установки, версии языка, ОС и т. д. Необходимые функции используют другие процедуры из пакета, поэтому было бы слишком утомительно копировать-вставлять только то, что нам нужно. Вот почему я решил перенести весь пакет в свой проект. И вот к чему это привело:

[zergon321@astroemeria ieee-754]$ go run main.go
package command-line-arguments
  imports ieee-754/alt
  alt/bytealg.go:10:8: use of internal package internal/bytealg not allowed

Упс. Что ж, я просто удалил файл bytealg.go и всё, что от него зависело. К счастью, функция genericFtoa к таковому не относилась. Все мои действия привели к написанию GetPartsLength, что показано ниже:

package alt

import "math"

func GetPartsLength(val float64, fmt byte, prec, bitSize int) (int, int) {
	var bits uint64
	var flt *floatInfo
	switch bitSize {
	case 32:
		bits = uint64(math.Float32bits(float32(val)))
		flt = &float32info
	case 64:
		bits = math.Float64bits(val)
		flt = &float64info
	default:
		panic("strconv: illegal AppendFloat/FormatFloat bitSize")
	}

	exp := int(bits>>flt.mantbits) & (1<<flt.expbits - 1)
	mant := bits & (uint64(1)<<flt.mantbits - 1)

	switch exp {
	case 1<<flt.expbits - 1:
		// Inf, NaN
		return -1, -1

	case 0:
		// denormalized
		exp++

	default:
		// add implicit top bit
		mant |= uint64(1) << flt.mantbits
	}
	exp += flt.bias

	var digs decimalSlice
	// Negative precision means "only as much as needed to be exact."
	shortest := prec < 0
	if shortest {
		// Use Ryu algorithm.
		var buf [32]byte
		digs.d = buf[:]
		ryuFtoaShortest(&digs, mant, exp-int(flt.mantbits), flt)
	} else if fmt != 'f' {
		// Fixed number of digits.
		digits := prec
		switch fmt {
		case 'e', 'E':
			digits++
		case 'g', 'G':
			if prec == 0 {
				prec = 1
			}
			digits = prec
		default:
			// Invalid mode.
			digits = 1
		}
		var buf [24]byte
		if bitSize == 32 && digits <= 9 {
			digs.d = buf[:]
			ryuFtoaFixed32(&digs, uint32(mant), exp-int(flt.mantbits), digits)
		} else if digits <= 18 {
			digs.d = buf[:]
			ryuFtoaFixed64(&digs, mant, exp-int(flt.mantbits), digits)
		}
	}

	return digs.dp, digs.nd - digs.dp
}

Это не идеально, но работает. Результат анализа производительности теперь намного лучше:

Running tool: /usr/bin/go test -benchmem -run=A$ \
-bench ABenchmarkGetPartsLength$ ieee-754/test

goos: linux
goarch: amd64
pkg: ieee-754/test
cpu: AMD Ryzen 5 5600H with Radeon Graphics

BenchmarkGetPartsLength-12   25748916   47.21 ns/op   0 B/op   0 allocs/op

PASS ok ieee-754/test 1.266s

Реализация алгоритмов

Теперь необходимо реализовать арифметические операции.

// Функция сложения.
func Sum(a, b float64) float64 {
	aFracLen := alt.GetPartsLength(a, 'f', -1, 64)
	bFracLen := alt.GetPartsLength(b, 'f', -1, 64)
	
	aModifier := math.Pow10(aFracLen)
	bModifier := math.Pow10(bFracLen)
	modifier := math.Max(aModifier, bModifier)
	
	a *= modifier
	b *= modifier
	
	return (a + b) / modifier
}
// Функция вычитания.
func Sub(a, b float64) float64 {
	aFracLen := alt.GetPartsLength(a, 'f', -1, 64)
	bFracLen := alt.GetPartsLength(b, 'f', -1, 64)
	
	aModifier := math.Pow10(aFracLen)
	bModifier := math.Pow10(bFracLen)
	
	modifier := math.Max(aModifier, bModifier)
	a *= modifier
	b *= modifier
	
	return (a - b) / modifier
}
// Функция умножения.
func Mul(a, b float64) float64 {
	aFracLen := alt.GetPartsLength(a, 'f', -1, 64)
	bFracLen := alt.GetPartsLength(b, 'f', -1, 64)
	
	aModifier := math.Pow10(aFracLen)
	bModifier := math.Pow10(bFracLen)
	
	modifier := aModifier * bModifier
	a *= aModifier
	b *= bModifier
	
	return a * b / modifier
}
// Функция деления.
func Div(a, b float64) float64 {
	aFracLen := alt.GetPartsLength(a, 'f', -1, 64)
	bFracLen := alt.GetPartsLength(b, 'f', -1, 64)

	aModifier := math.Pow10(aFracLen)
	bModifier := math.Pow10(bFracLen)

	minModifier := math.Min(aModifier, bModifier)
	maxModifier := math.Max(aModifier, bModifier)
	modifier := maxModifier / minModifier

	a *= aModifier
	b *= bModifier
	result := a / b

	if aModifier > bModifier {
		return result / modifier
	}

	if bModifier > aModifier {
		return result * modifier
	}

	return result
}

Наконец, давайте сравним по производительности обычную операцию с числами с плавающей запятой и безопасную:

func BenchmarkSafeMulFloat64(b *testing.B) {
	for i := 0; i < b.N; i++ {
		safecomp.Mul(2090.5, 8.61)
	}
}

func BenchmarkRegularMulFloat64(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = 2090.5 * 8.61
	}
}

func BenchmarkMulDecimal(b *testing.B) {
	x := decimal.NewFromFloat(2090.5)
	у := decimal.NewFromFloat(8.61)

	for i := 0; i < b.N; i++ {
		x.Mul(y)
	}
}

Результат:

Running tool: /usr/bin/go test -benchmem -run=A$ \
-bench л(BenchmarkSafeMulFloat64|BenchmarkRegularMulFloat64| \
BenchmarkMulDecimal)$ github.com/zergon321/safecomp

goos: linux
goarch: amd64
pkg: github.com/zergon321/safecomp
cpu: AMD Ryzen 5 5600H with Radeon Graphics

BenchmarkSafeMulFloat64-12   16182450    74.24 ns/op   0 B/op   0 allocs/op
BenchmarkRegularMulFloat64-12 1000000000  0.2418 ns/op  0 B/op  0 allocs/op
BenchmarkMulDecimal-12      15515397    114.8 ns/op    80 B/op  2 allocs/op

PASS
ok github.com/zergon321/safecomp 3.408s

Это примерно в 313 раз медленнее обычной встроенной операции, но зато точно. Большая часть времени выполнения тратится на получение длины дробной части для обоих операндов. Возможно, это можно оптимизировать ещё больше. Также я поместил фрагмент кода умножения с самой популярной на Go реализацией Decimal. Как видите, безопасный метод вычисления float немного эффективнее (он дает те же результаты с //go:noinline).

Заключение

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

Комментарии (10)


  1. SpiderEkb
    07.06.2024 08:31
    +4

    имеет смысл применить шаблон Мартина Фаулера для валютных величин

    Там есть серьезная логическая ошибка

    Абсолютно правильный подход в том, что с деньгами работаем в миноритарных единицах, но совершенно неправильно

    Represents monetary values as integers, in cents. This avoids floating point rounding errors.

    Такой подход приводит к откидыванию дробной части. Условно говоря, у вас в результате вычисления могут получиться 3.3 копейки. Или 3.6 копеек. В данном подходе результат всегда будет 3 копейки. А на самом деле есть правила - в первом случае 3 копейки, во втором - 4 копейки. Т.е. требуется округление.

    Посему, есть формат хранения результата - он целочисленный, если точнее, то это не integer, но decimal(n, 0) - n знаков, 0 после запятой. Но все вычисления идут с максимальной точностью (например, decimal(63, 31)). И на нужном этапе происходит "фиксация результата" с округлением.

    Проблема неточного представления - не компьютерная, но математическая. 1/3 - это не 0.3. Это 0.(3). Вне зависимости от того, считаете вы на калькуляторе, компьютере или на бумажке столбиком.

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

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

    И тут помогает именно формат с фиксированной точкой с корректной поддержкой точности промежуточного результата и операций с округлением.


  1. GospodinKolhoznik
    07.06.2024 08:31

    В этой статье используется JavaScript

    Исправлено. Сначала неправильно понял: JS используется не в самой статье, а в той статье, куда ведет ссылка.


  1. SUNsung
    07.06.2024 08:31
    +2

    Неделя статей про плавающие точки прям.

    Хотите лайфхак для чисел с плавающей точкой? Не используйте их!

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

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

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

    А еще лайфхак - когда минимальное число 0 можно использовать unsigned числа!!

    И на последок от гурру оптимизаций - когда максимум не превышает определенное значение, то выделять память можно под это значение (int8, int16).


    1. SpiderEkb
      07.06.2024 08:31
      +2

      Нужно хранить деньги? Максимальное смешение два знака, дроби от копеек вам ни один банк не выдаст, то есть умножаем все суммы на 100 и получаем целое число копеек.

      Если бы все так просто было...

      В большинстве валют мажоритарная единица содержит 100 миноритарных. Но есть и исключения. Например:

      Бельгийский франк (BEF), испанская песета (ESP), японская йена (JPY), греческая драхма (GRD), португальский эскудо (PTE), корейский вон (KRW) и еще ряд специальных валют вообще не имеют миноритарных единиц. Только мажоритарные.

      А есть валюты типа тунисского динара (TND), оманского реала (OMR), бахрейнского динара (BHD) и еще некоторых, у которых 1000 миноритарных единиц в мажоритарной.

      Плюс курсы валют могут указываться с более высокой точностью

      Так что

      Максимальное смешение два знака

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


    1. Zenitchik
      07.06.2024 08:31

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

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


  1. dr-harm
    07.06.2024 08:31
    +3

    сложение двух целых чисел в формате с плавающей точкой, значения которых ограничены вышеупомянутыми диапазонами ([-2²⁴, 2²⁴] для float32, [-2⁵³; 2⁵³] для float64); результат также должен быть в указанных пределах;

    Степени доступных диапазонов должны быть на 1 ниже (т.е. 23 и 52 соответственно). Вы же сами складываете 2²⁴ и 1 и получаете ошибку округления.

    умножение любого числа с плавающей точкой (целого или дробного) на 10 в степени n, где n ≥ 0, n — целое число;

    деление любого числа с плавающей точкой (целого или дробного) на 10 в степени n, где n ≥ 0, n — целое число;

    Раньше же речь шла о float32 и float64, откуда взялась возможность умножения на 10 и почему не на 2?


    1. zergon321 Автор
      07.06.2024 08:31

      Полагаю, что при умножении на 10 у нас меняется только порядок, а не мантисса. Я не могу сказать, я не математик и не системный инженер. Этот алгоритм как-то сам пришёл мне в голову


      1. Zenitchik
        07.06.2024 08:31
        +1

        10 у нас меняется только порядок, а не мантисса.

        Это не так, потому что мантисса у вас двоичная. И число 10 - тоже представлено как 1010.


  1. Zenitchik
    07.06.2024 08:31

    Автор не оттуда начинает. А начинать надо с того, что многие десятичные дроби непредставимы точно в двоичной системе.

    Какая разница, точно или неточно мы умножаем дробное число на целое (у автора - на 10), если ошибка уже произошла на этапе преобразования этого самого дробного числа из десятичной системы в двоичную?

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


    1. zergon321 Автор
      07.06.2024 08:31

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