
0,1 + 0,2 ≠ 0,3 или почему компьютеры не умеют считать
Откройте консоль любого языка — Python, JavaScript, Go, Rust — и введите:
>>> 0.1 + 0.2
0.30000000000000004
Не 0.3. Не ошибка округления на экране. Не баг интерпретатора. Это ровно то число, которое получил процессор.
Первая реакция предсказуема: «что за ерунда?». Вторая — «ну ладно, где-то на двадцатом знаке, кого это волнует». Третья приходит, когда баланс клиента уходит в минус на долю копейки, а через миллион транзакций набегает расхождение, которое не закрыть бухгалтерской проводкой. Или когда зенитная ракета промахивается мимо цели на полкилометра.
Это не аномалия, а прямое следствие того, как устроена арифметика в каждом процессоре на планете. Разберёмся, почему так получается — и что с этим делать.
Почему 0,1 — это не 0,1
Мы привыкли к десятичной системе, и в ней 0,1 — тривиальная дробь. Одна десятая, что тут сложного. Но процессор работает в двоичной системе, и там 0,1 — бесконечная периодическая дробь:
0.0001100110011001100110011001100110011... (период 0011)
Точно такая же ситуация, как 1/3 в десятичной системе — 0.333... — только мы к ней привыкли и не удивляемся. Число 0,1 в двоичной системе нельзя записать конечным количеством цифр. Когда процессор помещает его в 64-битный регистр, бесконечный «хвост» обрезается. Получается число, очень близкое к 0,1, но не равное ему.
Когда два таких приближения складываются, ошибки округления не компенсируют друг друга — они складываются. Отсюда и 0.30000000000000004.
Проверим на трёх языках:
// Rust
let a: f64 = 0.1;
let b: f64 = 0.2;
println!("{:.20}", a + b); // 0.30000000000000004441
println!("{}", (a + b) == 0.3); // false
// Go
fmt.Printf("%.20f\n", 0.1+0.2) // 0.30000000000000004441
fmt.Println(0.1+0.2 == 0.3) // false
// Zig
const sum = @as(f64, 0.1) + @as(f64, 0.2);
// sum == 0.30000000000000004441
// sum == 0.3 → false
Три разных языка, три компилятора, один результат. Это не свойство языка — это свойство железа.
Анатомия числа: как устроен IEEE 754
До 1985 года каждый производитель процессоров реализовывал арифметику с плавающей точкой по-своему. Программа, дающая верный результат на VAX, могла выдавать мусор на IBM 370. Уильям Кахан, профессор из Беркли, возглавил работу над единым стандартом — IEEE 754. За это он получил премию Тьюринга в 1989 году.
Стандарт определяет, как именно число хранится в памяти. Два основных формата:
- float32 (single): 1 бит знака + 8 бит экспоненты + 23 бита мантиссы. Точность — около 7 десятичных цифр.
- float64 (double): 1 бит знака + 11 бит экспоненты + 52 бита мантиссы. Точность — около 15 десятичных цифр.
Число хранится в нормализованной форме:
(-1)^знак × 1.мантисса × 2^(экспонента - смещение)
Обратите внимание на единицу перед точкой. Она не хранится явно — подразумевается. Это «бесплатный» бит, увеличивающий точность на один разряд. Благодаря ему 23-битная мантисса float32 фактически хранит 24 бита информации.
Визуально для float64 это выглядит так:
┌──────┬─────────────┬────────────────────────────────────────────────────┐
│ знак │ экспонента │ мантисса │
│ 1 bit│ 11 bits │ 52 bits │
└──────┴─────────────┴────────────────────────────────────────────────────┘
63 62-52 51-0
Экспонента хранится со смещением (bias): для float64 смещение равно 1023. Экспонента 01111111111 (1023 в двоичной) означает 2^0 = 1. Это позволяет представлять как очень большие, так и очень маленькие числа — от ≈ 10^-308 до ≈ 10^308.
Последняя крупная ревизия — IEEE 754-2019 — уточнила правила округления, но фундаментальная схема осталась неизменной с 1985 года.
Специальные значения: NaN, бесконечность и −0
IEEE 754 резервирует особые битовые комбинации для значений, которые не являются обычными числами. Каждое из них — источник неожиданностей.
NaN — число, которое не число
NaN (Not a Number) — результат бессмысленных операций: 0.0 / 0.0, sqrt(-1), ∞ - ∞. Главное свойство — NaN не равен ничему, включая самого себя:
let nan = f64::NAN;
println!("{}", nan == nan); // false
println!("{}", nan < 0.0); // false
println!("{}", nan > 0.0); // false
println!("{}", nan == 0.0); // false
Любое сравнение с NaN возвращает false (кроме !=). Это значит, что x != x — работающий способ проверки на NaN, хотя в продакшене лучше использовать f64::is_nan(), math.IsNaN() или std.math.isNan().
NaN «отравляет» вычисления — любая арифметическая операция с ним даёт NaN:
nan := math.NaN()
fmt.Println(nan + 1.0) // NaN
fmt.Println(nan * 100.0) // NaN
Если NaN проникает в цепочку вычислений, он тихо превращает весь результат в мусор. Никаких исключений, никаких паник — просто NaN на выходе.
Бесконечность
+∞ и -∞ возникают при переполнении или делении ненулевого числа на ноль:
println!("{}", 1.0_f64 / 0.0); // inf
println!("{}", -1.0_f64 / 0.0); // -inf
println!("{}", f64::MAX * 2.0); // inf (переполнение)
В отличие от NaN, бесконечности ведут себя предсказуемо: ∞ + 1 = ∞, ∞ > любое конечное число = true. Но ∞ - ∞ = NaN — и вот тут снова ловушка.
Отрицательный ноль
Два нуля — +0.0 и -0.0 — по стандарту считаются равными:
let pos: f64 = 0.0;
let neg: f64 = -0.0;
println!("{}", pos == neg); // true
println!("{}", 1.0 / pos); // inf
println!("{}", 1.0 / neg); // -inf
Два «равных» значения дают разные результаты при делении. Отрицательный ноль нужен для сохранения знака при underflow (когда число слишком мало, чтобы быть представленным, но его знак всё ещё важен). На практике это периодически ломает сравнения и хеширование.
Потеря точности: когда 1 + 1 = 1
Мантисса конечна — а значит, у чисел с плавающей точкой есть предел различимости. Для достаточно больших чисел соседние представимые значения отстоят друг от друга больше, чем на единицу.
Для float32 граница — 2^24 = 16 777 216:
let val: f32 = 16_777_217.0; // 2^24 + 1
println!("{}", val); // 16777216 — единица потеряна!
var f32 float32 = 16777217
fmt.Printf("%.0f\n", f32) // 16777216
Число 16 777 217 не может быть точно представлено в float32 — оно округляется обратно до 16 777 216. Для float32 все целые числа свыше 2^24 теряют точность. Это критично для игровых движков и GPU-вычислений, где float32 используется повсеместно: на координатах порядка 10^7 объекты начинают «дрожать».
Для float64 порог выше — 2^53 ≈ 9 × 10^15, — но принцип тот же:
let big: f64 = 1e16;
println!("{}", big + 1.0 == big); // true — единица бесследно исчезла
Прибавляем единицу к числу, а результат — то же самое число. Не ошибка, не баг — у процессора просто не хватает бит мантиссы, чтобы различить 10 000 000 000 000 000 и 10 000 000 000 000 001.
Катастрофическое сокращение
Есть ещё одна коварная ситуация: вычитание близких чисел. Если a ≈ b, то a - b может потерять все значащие цифры.
Пример. Допустим, мы вычисляем (1 + x) - 1 для маленького x:
>>> x = 1e-15
>>> (1 + x) - 1
1.1102230246251565e-15 # ожидали 1e-15
Относительная ошибка — 11 %. При сложении 1 + 1e-15 мантисса выравнивается, и младшие биты x уходят за пределы разрядности. Оставшиеся биты при вычитании единицы дают совсем не то, что было изначально.
Это называют catastrophic cancellation — катастрофическое сокращение. Классический пример из учебников: наивное вычисление дискриминанта b² - 4ac при b² ≈ 4ac может дать результат с нулевой точностью. Алгебраически выражение верное, но численно оно бесполезно.
Суммирование Кахана: как ловить потерянные биты
Когда вы складываете миллион чисел, ошибка округления накапливается с каждым шагом. Наивное суммирование миллиона слагаемых 0.1 даёт результат, заметно отличающийся от ожидаемых 100 000.0.
Уильям Кахан (тот самый, что стандартизировал IEEE 754) предложил хитрый приём: хранить отдельную переменную-компенсатор, которая «ловит» биты, потерянные при сложении.
Идея: каждый раз, когда при сложении теряется маленький кусочек (потому что sum уже большое, а слагаемое маленькое), мы вычисляем, что именно потерялось, и добавляем это на следующем шаге.
// Наивное суммирование
let mut naive = 0.0_f64;
for _ in 0..1_000_000 {
naive += 0.1;
}
// Суммирование Кахана
let mut sum = 0.0_f64;
let mut comp = 0.0_f64; // компенсатор
for _ in 0..1_000_000 {
let y = 0.1 - comp; // скорректированное слагаемое
let t = sum + y; // промежуточная сумма
comp = (t - sum) - y; // потерянные биты
sum = t;
}
Как работает строка comp = (t - sum) - y? Выражение t - sum — это то, что фактически добавилось к sum. Вычитая y, мы получаем разницу между тем, что хотели добавить, и тем, что добавилось. Эта разница — потерянные биты, которые мы учтём на следующей итерации.
Результат на практике:
Ожидание: 100000.00000000000000000000
Наивная сумма: 100000.00000133288860134780
Kahan сумма: 100000.00000000000000000000
Ошибка наивной: ~1.3e-06
Ошибка Kahan: ~0 (на уровне машинного эпсилона)
Разница — девять порядков. Четыре дополнительные строки кода — и суммирование из приблизительного становится практически точным.
Полные примеры на Rust, Go и Zig: float_demo.rs, float_demo.go, float_demo.zig.
Реальные катастрофы
Всё описанное выше — не академические упражнения. Ошибки плавающей точки приводили к реальным потерям — человеческим и финансовым.
Patriot, 1991
Во время войны в Персидском заливе зенитный комплекс Patriot, защищавший базу в Дахране (Саудовская Аравия), не смог перехватить иракскую ракету Scud. Погибли 28 американских солдат.
Системные часы Patriot хранили время в десятых долях секунды и представляли 0,1 как 24-битное число с фиксированной точкой. За 100 часов непрерывной работы набежала ошибка в 0,34 секунды — за это время Scud пролетал более 500 метров. Система навелась на пустое место.
Ванкуверская фондовая биржа, 1982
Индекс биржи пересчитывался после каждой сделки с округлением до трёх знаков после запятой. За 22 месяца ошибки округления превратили стартовое значение 1000 в 524,881 — почти вдвое ниже реального. После пересчёта с корректным округлением индекс оказался 1098,892.
Ariane 5, 1996
Ракета взорвалась через 37 секунд после старта. Причина — преобразование 64-битного числа с плавающей точкой (горизонтальная скорость) в 16-битное целое привело к переполнению. Код был унаследован от Ariane 4, где значения скорости были меньше и в 16 бит помещались. На Ariane 5 ускорение оказалось сильнее. Убытки — около 370 миллионов долларов.
Три случая — три разных десятилетия — одна и та же причина: разработчики не учли ограничения числового представления.
Новые форматы: от bfloat16 до fp8
Машинное обучение перевернуло ландшафт числовых форматов. В классическом программировании float64 — стандарт по умолчанию. Для нейросетей всё иначе: точность менее критична (веса модели и так приблизительные), а пропускная способность памяти — критична. Меньше бит на число — больше чисел в кеше — быстрее обучение.
bfloat16 (Brain Float 16) — формат, разработанный Google Brain: 8 бит экспоненты (как у float32) и 7 бит мантиссы. Диапазон как у float32, но точность — всего 2–3 десятичных цифры. Поддерживается в TPU, GPU NVIDIA (с Ampere), Intel Xeon, Apple M2+.
fp8 — два варианта, стандартизированных совместно ARM, Intel и NVIDIA:
- E4M3 (4 бита экспоненты, 3 бита мантиссы) — для прямого прохода нейросети;
- E5M2 (5 бит экспоненты, 2 бита мантиссы) — для обратного прохода (градиентам нужен больший диапазон).
NVIDIA H100 стал первым GPU с аппаратной поддержкой fp8, а Blackwell добавил fp4.
Тренд очевиден: точность обменивается на скорость. IEEE рабочая группа P3109 занимается стандартизацией 8-битных форматов для ML. Языки тоже не стоят на месте: Rust добавляет типы f16 и f128 (пока nightly), а Zig уже поддерживает f16, f32, f64, f80 и f128 на уровне языка.
Плавающая точка в Rust, Go и Zig
Все три языка используют IEEE 754, но различаются в том, насколько строго защищают программиста от ловушек.
Rust: система типов как защитник
Rust отказывается от неявных преобразований. f32 и f64 — разные типы, и компилятор не даст их сложить без явного as:
let a: f32 = 1.0;
let b: f64 = 2.0;
// let c = a + b; // ← ошибка компиляции!
let c = (a as f64) + b; // OK
Ещё жёстче — с равенством. f64 реализует PartialEq, но не Eq, потому что NaN нарушает рефлексивность (NaN != NaN). Следствие: f64 нельзя использовать как ключ HashMap — ошибка компиляции. Система типов буквально не даёт вам выстрелить себе в ногу.
Go: простота с подвохами
Go проще. float64 — тип по умолчанию для любого литерала с точкой. Сравнение через == компилируется без предупреждений:
if 0.1+0.2 == 0.3 { // компилируется, но false
fmt.Println("равны")
}
Go не мешает делать ошибки — но предоставляет инструменты для их обнаружения: math.IsNaN(), math.IsInf(), math.Copysign().
Zig: контроль на этапе компиляции
Zig выделяется типом comptime_float — числа с плавающей точкой произвольной точности, которые вычисляются на этапе компиляции:
const x = 0.1 + 0.2; // comptime_float — произвольная точность
// x == 0.3 → true (на этапе компиляции!)
const runtime_x: f64 = x; // конвертация в f64 происходит здесь
// runtime_x == 0.3 → зависит от представления f64
Это позволяет выполнять точные вычисления в compile-time и терять точность только при переходе к runtime-типу. Кроме того, Zig поддерживает f16, f32, f64, f80, f128 — весь спектр без сторонних библиотек.
Практические правила
Несколько принципов, которые избавят от большинства проблем с плавающей точкой.
Никогда не сравнивайте через ==. Используйте эпсилон-окрестность:
fn approx_eq(a: f64, b: f64, eps: f64) -> bool {
(a - b).abs() < eps
}
// Для чисел разного масштаба — относительное сравнение:
fn rel_eq(a: f64, b: f64, eps: f64) -> bool {
(a - b).abs() / a.abs().max(b.abs()) < eps
}
Для денег — никаких float. Храните суммы в целых числах (центы, копейки) или используйте десятичную арифметику: decimal.Decimal в Python, BigDecimal в Java, shopspring/decimal в Go.
Остерегайтесь вычитания близких чисел. Если a ≈ b, результат a - b может иметь относительную ошибку 100 %. Переформулируйте выражение алгебраически, чтобы избежать такого вычитания.
При суммировании массивов — алгоритм Кахана или попарное суммирование (pairwise summation), которое обеспечивает ошибку O(log n) вместо O(n) наивного подхода.
Знайте границы своего типа. float32 теряет точность на целых числах свыше 2^24 ≈ 16,7 миллиона. float64 — свыше 2^53 ≈ 9 × 10^15. Если ваши ID, таймстемпы или счётчики могут достигнуть этих значений — не храните их в float.
Заключение
Плавающая точка — это компромисс: 64 бита покрывают диапазон от 10^-308 до 10^308, но платят за это неточностью представления. Каждый программист рано или поздно натыкается на 0.1 + 0.2 ≠ 0.3 — и важно, чтобы это произошло в консоли разработчика, а не в продакшене, на бирже или на поле боя.
IEEE 754 — не враг. Это инженерный компромисс, принятый сорок лет назад и работающий до сих пор. Он предсказуем — если знать его правила. А незнание этих правил — одна из тех ошибок, которые обходятся дороже всего.
Источники
- IEEE 754-2019 Standard — актуальная ревизия стандарта
- What Every Computer Scientist Should Know About Floating-Point Arithmetic (David Goldberg, 1991) — классическая статья
- GAO Report: Patriot Missile Defense (1992) — отчёт о сбое Patriot
- Ariane 5 Flight 501 Failure Report — отчёт о катастрофе Ariane 5
- Vancouver Stock Exchange Index Error — ошибка биржевого индекса
- Kahan Summation Algorithm (Wikipedia) — алгоритм компенсированного суммирования
- bfloat16 Floating-Point Format (Wikipedia) — формат Brain Float 16
- FP8 Formats for Deep Learning (Micikevicius et al., 2022) — спецификация fp8
- IEEE P3109: Standard for Arithmetic Formats for Machine Learning — стандартизация форматов для ML
- 0.30000000000000004.com — как разные языки обрабатывают 0.1 + 0.2