Комментарии 58
Во-первых хочу заметить, что одна из целей статьи показать, что от «перемены мест слагаемых сумма меняется» и показать методы улучшения точности. Если можно вычислить точнее в одно и тоже время, почему так не сделать?
Ну а если говорить по синус, то частый пример — численное дифференцирование функции. Оно требуется в решении дифференциальных уравнений и оптимизации функции. Самый простой пример. cos(x) = (sin(x + dx) — sin(x)/dx. Ясно, что при уменьшении dx, ошибка в последнем знаке будет давать гораздо большую ошибку функции cos. Вычислении численного градиента, например, используется при минимизации функций. Минимизация с неточным градиентом может потребовать больше шагов и/или привести к неточному решению. Вычисление производных более высокого порядка, которое может быть нужно для численного решения дифференциальных уравнений, даст ещё большую ошибку. К тому же для многих уравнений ошибка накапливается и спустя какое-то время становится существенной.
Из самых простых примеров дифференциальных уравнений — это обычный маятник. Только в первом приближении уравнение его движения линейно (Маятник).
Если Вас не интересует точность double, то, конечно, можно использовать числа float. Для них все перечисленные методы работают абсолютно так же. А последний знак там всего лишь 0.000 000 119
А насчет точности, просто со времен курсового проекта по термодинамике приучили, что хватает четырех значащих цифр после запятой, вот и проецирую на всё.
Я подробно буду обсуждать это в одной из следующих статей.
vector<double> xv;
xv.resize(8 * 2000000);
// Линейное заполнение массива значениями от 0 до M_PI/2
for (int i = 0; i < xv.size(); i++) {
// Максимальное значение в массиве изменять здесь.
xv[i] = (M_PI / 2) * i / double(xv.size());
}
Почему? Потому, что его вычисление через ряд Тейлора — далеко не оптимальный метод. Есть и другие методы, такие, как:
1) Разложение в ряд по полиномам Чебышева
2) Приближение минимакс-полиномом
3) CORDIC
4) Различные варианты, основанные на табличной интерполяции
Также, если нам нужны синусы не произвольных углов, а, допустим, надо рассчитать таблицу синуса по равноотстоящим углам — то для этого есть специальные методы, основанные на рекуррентных соотношениях. Например:
y[n] = a*y[n-1] — y[n-2]
Результатом применения этого рекуррентного соотношения являются значения функции синус (или косинус) на равноотстоящих углах x[i] = x[i-1]+d.
Разумеется, в этой формуле накапливаются ошибки округления, поэтому придуманы более продвинутые рекуррентные формулы, гарантирующие стабильность, имеющие меньшие погрешности и т.д.
В общем, в области синуса уже много что копано и перекопано. Чтобы пытаться что-то там улучшить, надо сначала изучить то, что уже сделано.
1) Если дословно понимать утверждение 4 то это очень плохой метод. Интерполяция между табличными значениями даёт очень плохую точность при больших затратах времени расчёта. Я думаю, что вы имели ввиду не интерполяцию, а разложение в ряд Тейлора вокруг табличных значений. Этот метод требует существенно меньше памяти, чем кубическая интерполяция, а точность даёт несравненно выше. О нём я расскажу в следующей статье.
2) Рекуррентные формулы не имеют практической ценности. Даже если абсолютно убрать накопление ошибки, я правда не знаю таких методов, то их использование целесообразно только в таком сценарии: сначала рассчитывается таблица, а потом по этой таблице берутся значения sin. Это таблицу всегда можно/нужно предрасчитать перед началом работы. Если у вас в программе синус считается от силы раз 10, то смысл предрасчитывать таблицу. А если триллионы раз, то ограничение по производительность будет как раз доступ к этой таблице (извлечение из памяти) и т.п., а не её расчёт в начале программы.
3) Ряд тейлора или полиномы Чебышева/минимакс-полиномом это всё равно полиномы. Принципиальной разницы быть не должно в самом коде. Если вы думаете по-другому — пишите.
4) CORDIC. Просто по определению метода угол вращения должен быть arctg(1/(2^(i−1))) Т.е. предполагая, что для малых x arctan(x) ~= x мы получим что нужно 54 шага, чтобы выйти из точности double. Очевидно, что ряд Тейлора сходится быстрее.
При написании статьи я внимательно просмотрел исходный код нескольких математических библиотек. Выбрал glibc и пишу свои статьи на основе практик оттуда. В следующей статье я буду давать конкретные ссылки на исходный код. Если вы считаете что libm от GNU это плохая практика и вы можете сделать лучше, то я призываю Вас не ограничиваться комментарием к моей статье а написать свою. Это всем будет интересно.
Ну и напоследок, повторю, что написал в начале статьи. Она предназначена для того, чтобы показать «хорошие» не очевидные практики работы с числами с плавающей точкой. Я считаю, что «I did my best».
2) Рекуррентные формулы широко используются в разного рода синтезаторах, где надо считать и комбинировать большое количество синусов.
3) Раз вам важно только то, что это полиномы, то не стоит говорить про ряд Тейлора, ибо он почти никогда не применяется для целей аппроксимации функций.
4) CORDIC предназначен для вычислений без использования операции умножения и на соответствующем оборудовании он обойдет вычисления по полиномам (не говоря уже о никуда не годном ряде Тейлора). Но да, на современном железе CORDIC безнадежно устарел.
На мой взгляд, задача на практическое вычисление функций обязана начинаться с четкого определения требуемой точности. Например, если нам нужен синус для рисования кругов на экране, то считать его до последнего ulp в double полностью бессмысленно, аппроксимации с точностью ±0.0001 будет более чем достаточно. Соответственно, постановка в виде «считать как можно быстрее с как можно большей точностью» является некорректной. Более того, расчет с точностью ±1ulp обычно нужен только разработчикам библиотек, никакие практические цели такой точности не требуют. Таким образом, если возникла проблема «что-то это считает слишком медленно, надо ускорить», то первое, что надо сделать — это уменьшить точность.
В общем, пока статья не тянет на «хорошие практики работы с плавающей точкой», скорее на набор частых заблуждений. Ни слова не сказано про выбор точности, хотя это является определяющим фактором финальной производительности. Использован ряд Тейлора без надписи жирными буквами, что это только для примера, для продакшена его использовать ни в коем случае нельзя.
Вы во многом правы в этой фразе:
Более того, расчет с точностью ±1ulp обычно нужен только разработчикам библиотек, никакие практические цели такой точности не требуют
но не во всём. Я как раз разработчик библиотек. И мне бывает нужна точность, которая на десятки (иногда сотни) порядков выше 1ulp по причине, указанной в этой статье. Один из практических смыслов такой точности — чтобы округление результата было корректным до последнего бита, чтобы обеспечить полную повторяемость результата не зависимо ни от компилятора, ни от вычислительной машины. А в обыденной жизни да, я согласен, что это не нужно, из-за последнего бита даже ракета не упадёт… хотя, не знаю. Знаю только, что из-за разницы даже в последнем бите бывает, что некоторые программы работают слишком по-разному, но это из-за наличия в них кучи других ошибок. Ошибка в округлении последнего бита является лишь катализатором для их проявления.
CORDIC предназначен для вычислений без использования операции умножения и на соответствующем оборудовании он обойдет вычисления по полиномам (не говоря уже о никуда не годном ряде Тейлора). Но да, на современном железе CORDIC безнадежно устарел.
CORDIC может быть и устарел, а сам метод Shift-and-Add, лежащий в его основе — нет. Сейчас вообще всё идёт по направлению комбинации всех существующих методов. В зависимости от ситуации, каждый метод может оказаться хорошим, поэтому однозначно говорить, что уже можно сбросить со счетов, а что нельзя — не следует. Даже ряд Тейлора, хоть и плохая идея, но, например, для функции exp(x) около нуля, если нужно быстро на коленках собрать хоть что-то, вполне себе рабочий вариант. А ещё лучше он подходит для стартовой точки в методе Ньютона, потому что благодаря простым коэффициентам легко переводится на арифметику с фиксированной запятой без лишней возни. Каждому методу — своя ситуация, где он хорош. Хотя в нашей общей практике, да, не применяется.
Я думаю, что вы имели ввиду не интерполяцию, а разложение в ряд Тейлора вокруг табличных значений.Не знаю, что имел в виду автор исходного комментария, но есть и другой способ вычисления синуса/косинуса с табличными значениями — через комплексное умножение, как это используется в быстром преобразовании Фурье. Например, зная значения для sin|cos(2) из таблицы, для вычисления sin|cos(2.1) достаточно посчитать sin|cos(0.1) через ряд Тейлора, а затем комплексно перемножить с sin|cos(2).
In[1]:= Cos[2.1] + I Sin[2.1]
Out[1]= -0.504846 + 0.863209 I
In[2]:= (Cos[2] + I Sin[2]) (Cos[0.1] + I Sin[0.1])
Out[2]= -0.504846 + 0.863209 I
In[3]:= (Cos[2] + I Sin[2]) (Cos[0.05] + I Sin[0.05]) (Cos[0.05] + I Sin[0.05])
Out[3]= -0.504846 + 0.863209 I
Ряд тейлора или полиномы Чебышева/минимакс-полиномом это всё равно полиномы. Принципиальной разницы быть не должно в самом коде. Если вы думаете по-другому — пишите.
Я думаю очень по-другому. Всё дело в том, что степени этих полиномов будут различными. Минимакс-полином может оказаться вдвое короче ряда Тейлора (в зависимости от выбранного отрезка). Например, для функции exp2(x) на отрезке [0,1] если нужна точность 54 бита, то хватает минимакс-полинома 10-й степени. А ряд Тейлора будет намного длиннее, 15-я степень. При этом его реализация будет намного сложнее. В результате моих экспериментов, Тейлор проиграл Минимаксу по скорости в 8 раз. Вы только вдумайтесь. Да, код можно ускорить, ну, раза в 3. А куда деть 15-ю степень? Поэтому разница всё-таки есть.
Пусть ypi истинное значение синуса, вычисленное в точке xi. Истинным назовём значение вычисленное с помощью функции sin(float128). yni — значение полученное с помощью представленной функции sin_e1-5. N — общее количество точек.
1) Среднее ошибки SUM(yni — ypi)/N
2) STD Среднеквадратичное отклонение величины (wiki, вторая сверху формула)
Введём ещё переменную yoi. Это значение синуса, вычисленное с помощью встроенной функции библиотеки glibc sin(double).
Представьте, если значение yoi и yni не совпадают. Тогда (цитирую код функции).
if( <результат встроенной и нашей функции не совпадает побитово >) {
if( fabsq(q - dold.x) <= fabsq(q - dnew.x) )
co++;
else
cn++;
}
увеличиваем счётчик правильности функции для встроенной или нашей функции. Где q это ypi
3) Лучше наша. cn / N
4) Лучше libm. co / N
Я буду очень благодарен, если вы напишите своё мнение о понятности этого комментария. Если всё ОК, то я добавлю это в исходную статью.
Благодарю вас за упоминания моих статей и за вашу работу. Действительно тема очень актуальна (в узких кругах) и хорошо, что ею интересуются. Будь у меня больше времени, я обязательно дал бы несколько конкретных советов о том, как было бы лучше сделать то, к чему вы пришли в этой статье, но этого времени нет, кроме того, вы обещали продолжение и возможно там будет как раз то, что нужно.
У меня есть желание поделиться ссылкой: существует книга Jean-Michel Muller, “Elementary Functions: Algorithms and Implementation”, 2016, в этой книге огромное количество знаний о вычислении элементарных функций. В принципе, её достаточно чтобы знать о них почти всё, что сейчас применяют в этой области, за исключением каких-то продвинутых алгоритмов, изобретённых недавно. В том числе и общую теорию. Например, там сказано, что не следует вычислять синус на отрезке от 0 до Pi/2, потому что это невыгодно, отрезок нужно уменьшать минимум до [0, Pi/256] и уже дальше особыми формулами распространять на весь период. Также есть целая глава, где сказано, что синус (и любую другую элементарную функцию на монотонном интервале) можно вычислить используя только сдвиги и сложения, а также небольшую таблицу предподсчитанных заранее констант (Shift-and-Add Algorithms).
Ещё рекомендую для ознакомления библиотеку libquadmath где почти все элементарные функции реализованы для float128 и весь код хорошо виден. Посмотрите как там реализован синус (файл sincosq_kernel.c), там сразу одновременно вычисляется синус и косинус. Метод не очень хитрый, но не очевидный: там и редукция аргумента, и minimax-полином (если я верно понял, это именно он), и финальная корректировка с помощью таблиц. В общем, я так понял, вы хотели рассказать об этом в следующей статье? Мне просто показалось, что это очень большой труд — всё это рассказать, и однажды это хотел сделать я, но прикинул, что нужно для этого ещё пару лет потренироваться на тех статьях, что я пишу сейчас. Поэтому элементарные функции решил пока оставить. Очень сложная для популярного изложения (для меня) тема. Само только вдумчивое чтение упомянутой книги далось не с одного месяца.
Я буду очень рад, если вы сможете сделать эту работу, на русском языке этого и правда не хватает катастрофически. Я согласен, что действительно нужно обязательно показать сначала, что разложением в ряд Тейлора считать не вполне хорошая идея, и есть методы получше, потому что большинство как раз об этом и не знает. Я догадываюсь, какой следующий метод вы опишите, но не буду делать спойлеры :)
PS. А ещё я начал бы с экспоненты или 2 в степени x, эти функции кажутся намного проще. Но это уже как вам угодно.
Так же я Вам очень благодарен в том, что вы единственный, кто предоставил ссылку на код. Давайте же его посмотрим вместе. А именно на 126 строчку вот этого файла sinq_kernel.c. Я понимаю, что у вас мало времени, но, пожалуйста, потратьте ещё пару минут и посмотрите на него и одновременно посмотрите на 2-ую формулу моей статьи. Они одинаковы! Просто точь-в-точь!
Конечно, я тут спорить не готов в силу того, что в своей работе до тригонометрических функций ещё не добрался. Превосходно, если они одинаковые (хотя сами реализации и разбивка отрезка точно разная), но я вам в одном из комментариев выше привел пример, что ряд Тейлора может проигрывать minimax-полиному. Далее я всецело доверяюсь вашему мнению, потому что как уже сказано ранее, не чувствую в себе уверенность в том, чтобы спорить, когда сам ещё до сложной реализации элементарных тригонометрических функций не добрался. Повторюсь, что я только ЗА то, чтобы вы смогли завершить свой труд, потому что я в себе таких сил сейчас не вижу. А то я думал, что мне придётся это делать через год-два, а вот глядишь, может и не придётся :)
1) «Такая точность не нужна» в различных вариациях. Безусловно она нужна не всегда. Но, если бы она никогда не была нужна, зачем вообще тогда придумали числа double? Зачем в С они по умолчанию (напр константа 1.0 это double, а 1.0f float (32-bit)). sin(x) это double, а sinf float (32-bit). Зачем nvidia добавляет в свои топовые карты поддержку double, если это ни кому не нужно?
2) «Ряд Тейлора безнадёжно устарел» в различных вариациях. Утверждение, конечно, сильное но совершенно не обоснованное. Никто (кроме ArtemKaravaev) не написал код лучше, либо не дал ссылку. А ссылка от Артёма приводит к полиному, который выглядит как ряд Тейлора и считается как ряд Тейлора. От того, что его назвать по-другому не изменится ничего.
3) «Статья ничего нового не рассказывает». Может быть, но если всё до нельзя банально, то почему никто из комментаторов не ответил на вопрос в конце? Почему никто не объяснил в двух словах почему у sin_e4 и sin_e5 результаты разные? И так, чтобы всем стало понятно.
Хочу сказать спасибо и обратиться к тем, кому понравилась эта статья. Если у Вас есть желание меня поддержать, 10 минут времени и базовые знания программирования, то я буду очень благодарен, если вы перейдёте по ссылке от Артёма (sinq_kernel.c) и напишите в комментариях является ли формула в строке 126 одинаковой с второй формулой в моей статье или нет.
Во-вторых, я не заявляю, что любой полином выглядит как ряд Тейлора. Я говорю, что какие бы вы полиномы не использовали, Вам не удастся достичь вычислительной сложности (меньше сложений/умножений), лучше чем у ряда Тейлора при такой же «математической» (все операции проводятся абсолютно точно) точности при приведении конечного выражения к каноническому виду. Считаете по-другому — так приведите контрпример.
P.S. Есть один способ улучшения точности последнего члена разложения. О нём я поговорю позже.
в строке 126 ряд Тейлора вычисляется по схеме ГорнераОчевидно, что нет. Видно, что, например, SIN2 отличается от 1/5! в 8-ми последних десятичных знаках (или 6-ти шестнадцатеричных) или SIN8 ≈ 2.81068755e-15 заметно отличается от 1/17! ≈ 2.81145725e-15. Любая аппроксимация синуса полиномом будет похожа на ряд Тейлора, особенно в первых членах, вот только в этих небольших отличиях и скрывается принципиальная разница в точности аппроксимации.
Я говорю, что какие бы вы полиномы не использовали, Вам не удастся достичь вычислительной сложности (меньше сложений/умножений), лучше чем у ряда Тейлора при такой же «математической» (все операции проводятся абсолютно точно) точности при приведении конечного выражения к каноническому виду.Это тоже не соответствует действительности. Членов ряда Тейлора для достижения требуемой точности надо гораздо больше. Про это уже выше ArtemKaravaev написал.
По первому вопросу DrSmile вы реально думаете, что читатели (или я) не понимают, что небольшая вариация коэффициентов в разложении может уменьшить общую ошибку? Это абсолютно банальное утверждение по вашему в статье поменяет что? Она поменяет схему оптимизации скорости? Она поменяет вывод что, сумму надо вести с мелких элементов? С другой стороны, если вы всё прекрасно знаете, то вы не можете не понимать, что количество и значение этих коэффициентов зависит от пределов вычисления синуса. Для [0, pi/2] одна, для [0, pi/256] совершенно другая. И что в самом начале это давать просто нерационально. Откройте код и посмотрите следующий метод, который я буду описывать.
Я поставил цель при написании этой статьи не самопиар, а планомерное объяснение шаблонов работы с числами с плавающей точкой и не только. Я правда призываю Вас не тратить ни свои ни мои силы на написание этих бесполезных комментариев, а взять и написать статью лучше. Получить много кармы от радостных подписчиков. Я, правда, считаю, что подбор этих коэффициентов выходит за рамки данного цикла. Как я написал в PS я только покажу подбор последнего члена. Это реально можно сделать аналитически красиво.
К сожалению я вижу в Ваших комментариях только желание принизить данную работу. У каждого, конечно, есть право на своё мнение и желания. У меня тоже есть право больше не отвечать на ваши комментарии, чем я и воспользуюсь.
Точные… вычисления ...
double sin...Если честно, ждал более точного, нежели double. Например чего-нибудь, где требуемая точность задается.
Просто вопрос: если в прикладной задаче хватает точности IEEE double, то целесообразно ли переписывать реализацию стандартных функций?
1) У неё есть одна неприятная особенность, связанная с точностью intel fsin. Вызывать её просто так нельзя (см. график и «Conclusion» в статье). Те, кто считают CORDIS новым, современным методом, прочитайте, пожалуйста первый параграф документа по ссылке.
2) Она медленнее. На моём железе (i5 mobile) ~ в 4 раза. Через некоторое время я отредактирую исходную статью (полный текст программы) и добавлю туда ассемблерный вызов синуса. Вы сможете у себя проверит разницу в производительности.
Чуть более сложно: Функция fsin некоторых случаях даже точнее за счёт использования расширенных регистров. Но в некоторых случаях она даёт неприемлемый результат, и в добавок ко всему, сильно медленнее. Я очень подозреваю, что данная функция оставлена только для совместимости с ранними программами. Косвенно на это указано во втором параграфе документа.
P.S. Статья обновлена. Можно сравнивать.
А можете объяснить, почему оптимизированная версия программы быстрее аж в 10000 раз?
Простите, не смог пройти мимо. Избавился от sign
.
double sin_e2(double x) {
double result = 0;
double xx = -x * x;
double pw = x;
double fti = 1.0;
for(int i = 1; i < 25; i += 2) {
fti /= i;
result += pw * fti;
fti /= ( i + 1 );
pw *= xx;
}
return result;
}
array<double, 25> fc;
…
double res = fc[25];
Точно не double res = fc[24];
?
Точные и быстрые вычисления для чисел с плавающей точкой на примере функции синуса. Введение и часть 1