Страшилки преобразований. Часть 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 не особенные: это просто первые, на которых результаты разошлись. Таких значений много — можете попробовать найти и другие.

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

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

Вернёмся к выражению a * b + c. Если вы попробовали запустить на Go Playground, то увидите, что результат true. Но если вы, как и автор issue, запустите этот пример на Apple M1 ARM64, то результат будет false. А начиная с Go 1.25 можно обойтись и без мака: на amd64 соберите пример с GOAMD64=v3 (GOAMD64=v3 go run main.go, процессор должен поддерживать AVX2 и FMA), и компилятор тоже воспользуется FMA — результат будет false.

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

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

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

И ассемблерный код для функции bar. Сначала она сохраняет c в стек (вызов другой функции может испортить регистры), вызывает barmul, которая перемножает числа из регистров F0 и F1, затем загружает c обратно и прибавляет его к F0:

TEXT	main.bar
FMOVD	F2, main.c+16(FP)
CALL	main.barmul
FMOVD	main.c+16(FP), F2
FADDD	F2, F0, F0
RET

TEXT	main.barmul
FMULD	F1, F0, 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 (с FMA):   round(a · b + c)
Вариант 2 (без FMA): round(round(a · b) + c)

Язык Go разрешил компилятору неявно использовать FMA инструкции: они обычно дают и более точный результат, и более быстрый код. А когда результаты на разных процессорах разошлись, разработчики ответили, что это сознательный выбор: «Go has chosen to generate faster code on average». Побитово одинаковый результат при этом можно получить явно — с помощью конструкции, про которую ниже. (Java, например, выбрала наоборот, и это тоже не идеально.) Часть спецификации по этому моменту:

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.

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

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

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

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

mul := a * b

x := mul + c

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

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