Этюды для начинающих: Тип float. Как не утонуть?

ЕвгенийП
ЕвгенийП аватар
Offline
Зарегистрирован: 25.05.2015

Коллеги, сегодня мне захотелось поговорить о типе float и его использовании.

Как многие, наверное, знают, аппаратно восьмиразрядные AVR контроллеры плавающую арифметику не поддерживают, а потому, всё, что с нею связано, реализовано программно. Ну, а раз программно, то работает это не так быстро как хотелось бы, да ещё и отжирает приличную память в области программ на библиотеку с программной реализацией всех функций (как только используете float – порядка двух кило памяти «вынь, да положь»).

Поэтому, первый совет по использованию типа float – избегать такового использования без крайней нужды. Ну, вот, как пример, вот нафига библиотека DallasTemperature возвращает результат измерений именно как float (причём больше там float ни для чего не используется)? Руки бы поотрывал таким «писателям». Неужели датчику с паспортной погрешностью в 0,5 градуса нужна точность до семи знаков? Да, ну нафиг – вполне хватило бы возвращать целое число в десятых долях градуса. Так нет же … и два кило программной памяти псу под хвост!

Но иногда без float действительно не обойтись, так что давайте посмотрим, какие грабли там вокруг него разбросаны. Все приводимые примеры, исполнялись на Nano, а компилировались IDE 1.8.1 с опциями «из коробки».

1. Общие сведения о числах с плавающей запятой

В языке С++ определены три типа для работы с такими числами (стандарт ISO/IEC 14882:2014, п. 3.9.1.8):

float — тип с плавающей точкой одинарной точности;
double — тип с плавающей точкой двойной точности;
long double — тип с плавающей точкой повышенной точности.

В нашем случае, все три типа идентичны – все представляют собой 32-битное число одинарной тонности и разницы между ними нет никакой.

Первое важное замечание: в памяти плавающие числа представлены как отдельно мантисса, отдельно порядок и отдельно знак.

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

Точность представления чисел приблизительно семь знаков мантиссы, а наибольшее и наименьшее возможные значения приблизительно ±3,4х10±38.

Из последнего абзаца прямо следует второе важное замечание: числа с плавающей запятой точно представлены быть не могут – почти всегда они представляются с некоторой погрешностью. Например, невозможно точно записать число 0,1 (или 0,2). То, что хранится в памяти, обязательно будет отличаться от точного значения в каком-нибудь далёком разряде.

Также существуют три специальных значения: NaN – «не число» и две бесконечности, положительная и отрицательная. Деление на 0 даёт бесконечность со знаком делимого. Деление на бесконечность даёт ноль с соответствующим знаком.

Вот, собственно всё, что нам важно знать для дальнейшего. Те, кого интересуют подробности, могут почитать стандарт представления плавающих чисел IEEE 754-2008 и/или многочисленные статьи и эссе на эту тему, коими забит Интернет.

Мы же перейдём непосредственно к граблям и вкусняшкам.

2. Неточность представления значений

Давайте запустим вот такую программу:

и полюбуемся на её результат:

Ну, что, вроде бы a и b честно равны 0,2 (строки 2 и 3 результата), но при этом почему-то a не равно b (строки 4 и 5 результата)!

Как такое может быть? Правильно - числа представленные в памяти на самом деле не равны друг друг другу, а строки 2 и 3 вводят нас в заблуждение исключительно из-за округления.

Давайте напечатаем наши a и b с большим количеством знаков после запятой.

Вот теперь всё стало на свои места. a > b, что мы и наблюдаем.

Таким образом, мы выяснили, что выражения «1.2 - 1.0» и «0.2» в плавающей арифметике вовсе не равны друг другу. И это не косяк конкретного компилятора и даже не следствие китайского происхождения Вашей ардуины – это факт, прямо следующий из особенностей реализации плавающей арифметики. Бороться с этим невозможно, остаётся только приспособиться. Вот давайте и начнём приспосабливаться.

Первый шаг в нашем приспособленчестве – это научиться таки сравнивать плавающие числа, чтобы «1.2 - 1.0» и «0.2» всё же получались равными друг другу, а не выносили нам мозг своей экзистенциальной сущностью.

3. Сравнение плавающих чисел

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

Но это касается «универсального», применимого везде и всегда метода, т.е. «сферического коня в вакууме». Мы же с Вами всегда решаем конкретную задачу, и исходя их это задачи, примерно представляем какая точность для нас приемлема и желательна, и потому всегда можем определить некую величину eps, о которой можем сказать, что если два числа различаются на величину меньшую, чем eps, то мы будем считать их равными. Тогда сравнения будут выглядеть так: 

1static const float eps = 0.0001; // взято для примера
2 
3// Сравнение на равенство
4if (fabs(a - b) < eps) { ... } // а == b
5 
6// Сравнение на a больше b
7if (a >= b + eps) { ... } // а > b 

Функция fabs, использованная в строке 4, возвращает свой аргумент, взятый «по модулю» и нужна здесь для того, чтобы результат сравнение не зависел то того, что больше (на величину меньшую, чем eps) a или b.

Ещё раз - eps Вы выбираете сами, исходя из сущности задачи. Например, если a и b - температуры. полученные с далласовского датчика, погрешность которого, как известно ±0,5 градуса, то eps можно взять 1 или, скажем, 0.75.

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

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

4. Как не накапливать погрешность

Как мы уже видели на примере вычитания единицы из 1,2, любая операция с плавающей точкой выполняется с некоторой погрешностью. Она небольшая, но проблема в том, что если операции выполнять много раз, то она имеет очень неприятную привычку накапливаться. А ведь многие программы для микроконтроллеров предназначены для работы в безостановочном режиме сутками, неделями и месяцами – за такое время можно такую погрешность накопить, что будь здоров.

Для примера, предположим, что нам нужно что-то делать с величинами 0.000002, 0.000004, 0.000006, 0.000008 и т.д. до 2.0 – т.е. в общей сложности с миллионом значений. Как получать эти значения? Можно завести переменную, равную 0, к ней прибавить 0.000002, сделать с результатом что нужно, прибавить ещё 0.000002, опять сделать с результатом что нужно и так миллион раз. Запишем это:

1float fi = 0;
2for (long i = 1; i <= 1000000L; i++) {
3    fi += 0.000002;
4    // здесь что-то делаем с этой fi
5}

Что мы здесь имеем. Переменна fi получается последовательным прибавлением шага 0.000002 к старому значению. Значит, чем больше раз мы это сделаем, тем большую погрешность накопим. Окончательное (самое последнее) значение fi будет получено путём миллиона операций сложения!

Давайте попробуем другой подход. Теперь мы не будем прибавлять шаг миллион раз, а вместо этого, на каждом шаге будем брать величину, полученную умножением 0.000002 на номер текущего шага. Запишем это:

1float fi = 0;
2for (long i = 1; i <= 1000000L; i++) {
3    fi = 0.000002 * i;
4    // здесь что-то делаем с этой fi
5}

Здесь на любом шаге, наше значение fi всегда получается в результате одной операции умножения. Потому, никакая погрешность накапливаться не будет и погрешность значения fi на любом шаге буде равна погрешности одной операции умножения – независимо от количества шагов!

Давайте сравним погрешности в конечном значении переменной fi для первого и второго подходов.

Как видите, при первом подходе (где fi получалась накапливающим сложением) погрешность за миллион шагов составила почти 2%!!! А при втором подходе, погрешность оказалось пренебрежимо малой, такой, что при нашей печати и вовсе обратилась в 0.

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

Возможно, кому-то проблема накопления погрешности покажется искусственной и надуманной. Напрасно! Именно с этой ошибкой связаны наиболее эпичные техногенные катастрофы, вызванные ошибками в программном обеспечении. Например, именно накопление погрешности в системе управления американским ракетным комплексом Patriot стало причиной того, что древняя как Фортран советская ракета 8К14, выпущенная иракскими военными, сумела долететь до американской военной базы, охраняемой Patriot'ами и угробить почти 30 военнослужащих, прихватив ещё под сотню ранеными.

5. Знаковые нули, бесконечности и не числа

Как уже говорилось, ноль здесь может быть положительным и отрицательным. Само по себе это не страшно, но знак может повлиять на дальнейшие вычисления. Также, есть три специальных значения: положительная бесконечность, отрицательная бесконечность и «не число» - NaN.

Бесконечности получаются либо когда результат операции выходит за границы возможно, либо при делении на 0. С ними можно продолжать операции, например, число, разделённое на бесконечность, даст 0, сложение бесконечности с числом даст ту же бесконечность. Операции с двумя бесконечностями (например, деление их друг на друга) недопустимы, результат – NaN.

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

Обычно появление в расчётах значений типа NaN и бесконечностей, является следствием ошибок. Для контроля таких ситуаций. Стандарт предоставляет специальные функции, позволяющий проверить является ли число бесконечность или NaN. Функции эти: isnan(f) и  isfinite(f). Первая возвращает истину, если аргумент является NaN, а вторая возвращает истину, если аргумент не является ни бесконечностью, ни NaN. Для использования этих функций требуется включить #include <math.h>

Если у Вас возникла проблема и Вам нужно искать ошибку, то для печати значений типа float в монитор порта лучше не использовать напрямую Serial.print, а делать это через dtostrf (как в моём примере ниже), т.к. Serial.print плохо печатает как раз такие специальные значения, например, он никогда не печатает знак перед нулём, даже, если ноль отрицательный.

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

 

vvadim
Offline
Зарегистрирован: 23.05.2012

спасибо за статью, как всегда интересно доходчиво написана.

arduino328
Offline
Зарегистрирован: 01.09.2016

ЕвгенийП пишет:

Неужели датчику с паспортной погрешностью в 0,5 градуса нужна точность до семи знаков?

Наоборот: точность датчика 0,5 градуса, то есть эталонную температуру 50 С он может показать как 50,5 С. Погрешность же равна единице младшего разряда, поэтому разрешение датчика 9 или 12 бит имеет значение.

OlegK
OlegK аватар
Offline
Зарегистрирован: 26.11.2014

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

andriano
andriano аватар
Offline
Зарегистрирован: 20.06.2015

Евгений, что-то Вы сегодны невнимательны:

1. Мантисса - 24 разряда. Врочем, вероятно, Вы имели в виду десятичные, ну так тогда об этом надо было сказать.

2. При подсчете процентов погрешности - они обычно относятся к полученному числу, а не к единице. В Вашем случае погрешность составляет 0.9%, а не 1.81%.

 

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

ЕвгенийП
ЕвгенийП аватар
Offline
Зарегистрирован: 25.05.2015

Олег, не думаю, что там можно что-то кардинально сделать, но я посмотрю сегодня попозже и отпишусь.

ЕвгенийП
ЕвгенийП аватар
Offline
Зарегистрирован: 25.05.2015

andriano, это у Вас "от многих знаний" :))) Я аот никогда не слышал от "целевой аудитории" вопроса "Сколько бит занимает мантисса?", а вот вопрос "скольким знакам после запятой можно доверять?"  слышу постоянно, и именно на него и отвечал :)

ЕвгенийП
ЕвгенийП аватар
Offline
Зарегистрирован: 25.05.2015

OlegK пишет:

как следует поступать, в случаях, если требуются такого рода вычисления, как показаны ниже?

Олег,

Поскольку у Вас все показатели степеней целые и неотрицательные, я бы ушёл от pow с её суммированием рядов и пользовался бы умножением. Причём и умножение делал бы не в лоб (n-1 умножение для возведения в степень n), а половинным делением, т.е. для возведения в степень n у меня уходило бы приблизительно log2n умножений. Таким образом, удалось бы обойтись наименьшим количеством операций и, соответственно, накопить наименьшую погрешность.

Вот функция iPow, которая это делает:

01//
02//  Возведение числа f в целую, неотрицательную степень n
03//
04float iPow(const float f, const int n) {
05    if (n <= 0) return 1;
06    if (n == 1) return f;
07    float f2 = iPow(f, n / 2);
08    f2 *= f2;
09    return (n & 1) ? f2 * f : f2; // если n - нечётно, надо ещё раз домножить на f
10}

Осталось только заменить pow на iPow в Вашем скетче.

К сожалению, прямо сейчас у меня нет под рукой никакого пакета вычислений с большой точностью (хоть бы Mapple какой завалялся), поэтому за «точное» значение я взял результат Вашего скетча, выполненного на PC с честным 64-битным double. Если у Вас есть какой-нибудь пакет многократной точности, Вы можете более адекватно всё проверить.

Итак, результаты:

Подход

Результат

Абсолютная разница с «точным»

64-битный double (считаем это точным результатом)

530,37388977

0,00000000

float (Ваш скетч как есть)

530,37377929

0,00011048

iPow   (Ваш скетч, но вместо pow используется iPow)

530,37390136

0,00001159

Как видите, абсолютная погрешность уменьшилась в 9,5 раз, т.е. почти на порядок.

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

Вот такой тест

1uint32_t start = millis();
2volatile float ww = 42.979;
3for (long i=0; i < 1000L; i++) srand(GetTXK(ww));
4uint32_t d = millis() - start;
5Serial.begin(115200);
6Serial.println(d);

показал 2 530 мс для версии с pow и 419 мс для версии с iPow, т.е. быстродействие увеличилось в шесть раз!

arduinec
Offline
Зарегистрирован: 01.09.2015

arduino328 пишет:

ЕвгенийП пишет:

Неужели датчику с паспортной погрешностью в 0,5 градуса нужна точность до семи знаков?

Наоборот: точность датчика 0,5 градуса, то есть эталонную температуру 50 С он может показать как 50,5 С. Погрешность же равна единице младшего разряда, поэтому разрешение датчика 9 или 12 бит имеет значение.

Формально правы оба.
Согласно Википедии: Погрешность измерения - отклонение измеренного значения величины от её истинного (действительного) значения. Погрешность измерения является характеристикой точности измерения.
Далее в Википедии приводится формула для вычисления погрешности: это разность между максимальным и минимальным измеренными значениями (для неизменной величины), делённая на 2. Для датчика температуры ds18b20 это как раз единицы младшего разряда.

OlegK
OlegK аватар
Offline
Зарегистрирован: 26.11.2014

Евгений, спасибо за мастер-класс. Настоящий профи всегда найдёт, что улучшить. ))
С Вашего позволения я использую это решение в своём проекте?

ЕвгенийП
ЕвгенийП аватар
Offline
Зарегистрирован: 25.05.2015

Да не за что. Я от вас тоже немало по схемотехнике почерпнул.

andriano
andriano аватар
Offline
Зарегистрирован: 20.06.2015

ЕвгенийП пишет:

К сожалению, прямо сейчас у меня нет под рукой никакого пакета вычислений с большой точностью (хоть бы Mapple какой завалялся), поэтому за «точное» значение я взял результат Вашего скетча, выполненного на PC с честным 64-битным double. Если у Вас есть какой-нибудь пакет многократной точности, Вы можете более адекватно всё проверить.

Евгений, Вы напрасно прибедняетесь: и результат у Вас корректный, и рекомендации - совершенно правильные (для Arduino).

Хотя вопрос на самом деле очень сложный. На ПК, например, в тех же самых условиях рекомендации будут прямо противоположными: считать через возведение в степень (разумеется, при адекватном использовании компилятором возможностей FPU).

DetSimen
DetSimen аватар
Offline
Зарегистрирован: 25.01.2017

Надобы к ардуине i80387 прикрутить на досуге.

andriano
andriano аватар
Offline
Зарегистрирован: 20.06.2015

DetSimen пишет:

Надобы к ардуине i80387 прикрутить на досуге.

Стековую машину?

Неоптимально.

Лучше где-нибудь блок SSE отковырять.

DetSimen
DetSimen аватар
Offline
Зарегистрирован: 25.01.2017

andriano пишет:

Стековую машину?

Неоптимально.

не скажите.  Эта стековая машина мне диплом написала, вернее насчитала. Хоть и глубина у ней всего 8, но дифуры решает с легкостью (правда долго на 287-м).  И на Спектруме, кста, тоже стек был (с обратной польской записью) для плавающих вычислений. :)  Да и на многих первых советских инженерных калькуляторах тоже. Если ручками программировать, там чюдеса творить можно, хоть и стек всего глубиной 8 значений. Я, кстати, после диплома ни разу сопроцессор вручную не программировал. :)