Страшилки преобразований. Часть 2

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

// Любой уважающий себя современный микропроцессор:

>>> 0.1 == 0.1
True
>>> 0.1 + 0.1 == 0.2 
True
>>> 0.1 + 0.2 == 0.3
False

// ЧТО?

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

Итак:

package main

//go:noinline
func foo(a, b, c float64) float64 {
	return a*b + c
}

//go:noinline
func bar(a, b, c float64) float64 {
	return barmul(a, b) + c
}

//go:noinline
func barmul(a, b float64) float64 {
	return a * b
}

func main() {
	a := 0.6666666666666
	b := 0.3333333333
	c := 0.2222222

	println(foo(a, b, c) == bar(a, b, c))
}

Предлагаю ответить на вопрос, возможна ли ситуация когда программа распечатает false? Если да, то когда. Предлагаю остановиться тут и подумать.

А если заменить строку в функции foo на такую: return float64(a*b) + c? Сейчас?

А ты чо поменял то?

Значение переменных a, b, c специально подобраны, но таких значений дающих false много, просто первые который дали разный результат (дело не в них). Можете попробовать найти и другие.

Однажды я наткнулся на интересный issue в репозитории Go. Если не разбираться, то выглядит как магия, человек нашёл пример кода который при запуске на разных платформах выдаёт разные результаты. Как видите я отвечал в треде, но сейчас вижу что ответ, на самом деле, плохой. Сильно много деталей, а подвода к ним нет.

Я привёл ссылку на issue чтобы вы могли посмотреть оригинал “волшебного” кода, но хочу заметить что там совсем никудышный пример, он переусложнён, или недоупрощён если это часть какой-то программы которая у него сломалась. Можно сильно упростить, нам необходима только конструкция вида a * b + c, то есть пример с функциями foo/bar это точно такой же, но без лишних деталей.

Вернёмся к выражению a * b + c. Если вы попробовали запустить на Go Playground, то увидите что результат true. Но если вы, как и автор issue, запустите этот пример на Apple M1 ARM64, то результат будет false.

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

Так выглядит функция foo, в регистрах F0, F1, F2, лежат значения переменных a, b, c. Результат помещается обратно в F0, где его будет ждать вызывающий код:

TEXT	main.foo(SB)
FMADDD	F0, F2, F1, F0
RET

И ассемблерный код для функции bar, в котором происходит сначала вызов функции barmul перемножающий два числа в регистрах F1 и F0, а далее инструкция добавления значения F2 к F0:

TEXT	main.bar
CALL	main.barmul
ADDSD	F2, F0
RET

TEXT	main.barmul
MULSD	F1, F0
RET

Ну и какая разница? А разница в том, что математика представления чисел в компьютере не даёт возможности точно представить все возможные числа. И перед тем как блоку работы с floating-point (FP) сохранить значение, его нужно округлить.

Разберём ещё раз пример, но уже с этой информацией.

Функция foo берёт значения a, b, c, выполняет инстркцию FMADD, получает результат, окруляет его до ближайнего значения которое может быть сохранено согласно стандарту IEEE 754 и помещает результат в F0.

Функция bar берёт значения a, b, передаёт их функции barmul, barmul умножает a на b, получает рузльутт, округляет его до ближайшего значения которое может быть представлено согласно стандарту IEEE 754 и помещает результат в F0. Далее функция bar, прибаляет значение F2 к F0, поулчает результат, округляет его до ближайнего значения и сохраняет результат в F0.

Рзное кол-во округлений и приводит к разному результату. Дело раскрыто. Эти инструкции называются FMA (Fused Multiply–Add) которые нужны для выполнения совмещённой операции умножения-сложения. Главная цель которой повысить точность таких часто используемых выражений как a*b+c (инструкция FMADDD делает именно это).

На псевдокоде:

Вариант 1 (c FMA):   round(a · b + c)
Вариант 2 (без FMA): round(round(a · b) + c)

Язык Go, со слов разработчиков, выбрал именно неявное использование FMA инструкций потому что это генерирует код дающий более точные результаты. Часть спецификации по этому моменту:

An implementation may combine multiple floating-point operations into a single fused operation, possibly across statements, and produce a result that differs from the value obtained by executing and rounding the instructions individually. An explicit floating-point type conversion rounds to the precision of the target type, preventing fusion that would discard that rounding.

For instance, some architectures provide a “fused multiply and add” (FMA) instruction that computes xy + z without rounding the intermediate result xy. These examples show when a Go implementation can use that instruction:

// FMA allowed for computing r, because x*y is not explicitly rounded:
r  = x*y + z
r  = z;   r += x*y
t  = x*y; r = t + z
*p = x*y; r = *p + z
r  = x*y + float64(z)

// FMA disallowed for computing r, because it would omit rounding of x*y:
r  = float64(x*y) + z
r  = z; r += float64(x*y)
t  = float64(x*y); r = t + z

Заметьте, что конструкция float64(...), имеет дополнительную семантику, это не только конверсия типа но и запрет на использование FMA.

Альтернативное введение

Классический разговор с Chat GPT.

У тебя есть конструкция x := a * b + c на языке Go, все переменные типа float64. Если может ли получиться разный результат в переменной x на разных платформах, если да, то как его избежать?

… Используйте функции для фиксированного порядка Например, явно разбивайте выражения на отдельные шаги:

mul := a * b

x := mul + c

Это помогает убедиться, что порядок выполнения не изменится …

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