
Несмотря на свою кажущуюся банальность, темы о вычислении синуса достаточно регулярно появляются на хабре. И каждый раз их авторы или делают сомнительные утверждения, или получают сомнительные результаты. Не в силах более терпеть, я тоже решил поучаствовать и внести свой, не менее сомнительный вклад в этот вопрос.
▍ Введение
Существуют три основных подхода к аппроксимации функций многочленами:
1. Разложение в ряд Тейлора:
Наиболее простой, наиболее известный (потому что его проходят в школе) и наименее эффективный. По сути представляют собой аппроксимацию через конечное количество производных в отдельно взятой точке, откуда и следуют его недостатки — ошибка аппроксимации возрастает по мере удаления от этой точки, далеко не для всех функций быстро сходится, для некоторых функций не сходится вообще, для некоторых функций в некоторых точках не применим вообще.
2. Метод наименьших квадратов:
Чуть менее известный, более сложный, но и более эффективный. Его идея заключается в минимизации площади между функцией и её аппроксимирующим многочленом. Недостатком является тот факт, что из минимизации площади вовсе не следует минимизация максимальных отклонений — поэтому ошибка аппроксимации хоть и осциллирует, но всё равно имеет тенденцию возрастать на краях диапазона.
3. Многочлены Чебышева:
Ещё более сложный, наименее известный, но часто наиболее оптимальный метод. Оптимальность его проистекает из того, что ошибка распределена по всему диапазону и ограничение максимального отклонения задаётся by design. Сложность вытекает из того, что сначала нужно считать коэффициенты для каждого отдельного многочлена Чебышева через далеко не простые интегралы даже для простых функций (в частности, для синуса получаются функции Бесселя, которые далеко не в каждом калькуляторе присутствуют), а затем их пересчитывать для суммарного итогового многочлена с запасом по точности. К счастью, имея в своём распоряжении современные системы компьютерной алгебры, всё это можно сделать лёгким движением руки, даже не вникая особо глубоко в математические основы происходящего.
Следует заметить, что этими тремя методы аппроксимации вовсе не ограничиваются — можно и производные считать в нескольких точках, а не только в одной, и другие разложения в степенные ряды применять, и рациональные многочлены использовать (например, аппроксимацию Паде и его вариации), а также различные варианты мини-макс алгоритмов.
▍ Подготовка
Используя Wolfram Mathematica, определим функцию, возвращающую аппроксимирующий многочлен заданного порядка для произвольной функции:
код ChebyshevApproximation для Mathematica
ChebyshevApproximation[n_, f_, x_] := N[Table[Integrate[
f ChebyshevT[k, x] (2/(\[Pi] Sqrt[1-x^2])), {x, -1, 1}], {k, 0, n}], 20] //
#[[1]]/2 + Sum[#[[k+1]] ChebyshevT[k, x], {k, 1, n}] & // Simplify
Здесь точность полученных коэффициентов равна 20 десятичным знакам — для наших целей этого достаточно. Можно также задать отдельные реализации для чётных/нечётных функций, чтобы ускорить вычисления. Для нечётных функций она будет выглядеть как
код ChebyshevOddApproximation для Mathematica
ChebyshevOddApproximation[n_, f_, x_] := N[Table[2 Integrate[
f ChebyshevT[k, x] (2/(\[Pi] Sqrt[1-x^2])), {x, 0, 1}], {k, 1, n, 2}], 20] //
Sum[#[[((k+1)/2)]] ChebyshevT[k, x], {k, 1, n, 2}] & // Simplify
а для чётных как:
код ChebyshevEvenApproximation для Mathematica
ChebyshevEvenApproximation[n_, f_, x_] := N[Table[2 Integrate[
f ChebyshevT[k, x] (2/(\[Pi] Sqrt[1-x^2])), {x, 0, 1}], {k, 0, n, 2}], 20] //
#[[1]]/2 + Sum[#[[((k+2)/2)]] ChebyshevT[k, x], {k, 2, n, 2}] & //
Simplify
Примечание
В случае, если для выбранной функции аналитическое решение не находится, то можно использовать численное интегрирование, заменив Integrate наNIntegrate. Здесь ситуация осложняется тем, что на границах интервала (-1,1) подынтегральное выражение устремляется в бесконечность — что может потребовать дополнительную настройку параметров интегрирования, прежде всего увеличения точности вычислений и задания исключающих условий для аргумента. Также может потребоваться отбрасывание близких к нулю коэффициентов, являющихся результатом погрешности для чётных/нечётных функций.
код NChebyshevApproximation для Mathematica
NChebyshevApproximation[n_, f_, x_] := N[Simplify[Table[
NIntegrate[f ChebyshevT[k, x] (2/(\[Pi] Sqrt[1-x^2])), {x, -1, 1},
WorkingPrecision ->30, Exclusions->x^2 == 1, MaxRecursion -> 25], {k, 0, n}] //
Map[Sign[#] Max[0, (Abs[#]-10^-20)] &, #] & //
#[[1]]/2 + Sum[#[[k+1]] ChebyshevT[k, x], {k, 1, n}] &], 20]
▍ Начало
Наиболее надёжный способ ускорить вычисление синуса — просто уменьшить степень аппроксимирующего многочлена, пожертвовав точностью. При использовании многочленов Чебышева количество верных знаков ориентировочно соответствует степени многочлена
код для Mathematica
f = Sin[x Pi/2];
p7 = ChebyshevApproximation[7, f, x]
Получили многочлен:
Реально полученную погрешность можно увидеть, если вывести на график разницу между исходной функцией и этим многочленом:
код для Mathematica
Plot[f - p7 // Evaluate, {x, -1, 1},
GridLines -> {(Range[10] - 5)/5, Automatic}, WorkingPrecision -> 20] // Quiet

По графику видно, что на краях диапазона погрешность принимает максимальное значение. Это значит, что sin(90°) с таким многочленом будет равен не единице, а 0.999999414195269. Если важно, чтобы была именно единица — то для этого можно пойти на хитрость и немного расширить диапазон аппроксимации таким образом, чтобы отклонение на краях
код для Mathematica
p71 = Block[{z = 1, p},
Do[p = ChebyshevOddApproximation[7, Sin[x Pi/2/z], x] /. x -> x z // Simplify;
z *= (x /. FindRoot[(p-Sin[x Pi/2])==0, {x, 1}]), 5]; p]
Результат:
Вот как теперь выглядит график отклонений:

Погрешность неизбежно выросла, но совершенно незначительно.
Чтобы получить полноценную функцию синуса, необходимы вспомогательные манипуляции для приведения аргумента к диапазону
код экспорта для Mathematica
CForm[HornerForm[p71] /. x^2 -> xx]
Сами функции будут выглядеть так:
Версия для С#
double Sin7(double x)
{
x *= 0.63661977236758134308; // 2/Pi
bool sign = x < 0.0;
x = sign ? -x : x;
int xf = (int)x;
x -= xf;
if ((xf & 1) == 1)
x = 1 - x;
bool per = ((xf >> 1) & 1) == 1;
double xx = x * x;
double y = x * (1.5707903005870776 + xx * (-0.6458858977085938 +
xx*(0.07941798513358536 - 0.0043223880120647346 * xx)));
return sign^per ? -y : y;
}
Версия для С/С++
double Sin7(double x)
{
x *= 0.63661977236758134308; // 2/Pi
int sign = x < 0.0;
x = sign ? -x : x;
int xf = (int)x;
x -= xf;
if ((xf & 1) == 1)
x = 1 - x;
int per = ((xf >> 1) & 1) == 1;
double xx = x * x;
double y = x * (1.5707903005870776 + xx * (-0.6458858977085938 +
xx*(0.07941798513358536 - 0.0043223880120647346 * xx)));
return sign ^ per ? -y : y;
}
В версии для C/C++ тип bool заменён на int — как показали измерения, компилятор в этом случае генерирует более эффективный код.
Здесь нужно сделать важное замечание. В целях оптимизации для получения целой части аргумента здесь используется не функция Floor(x), а приведение к целому типу (чтобы избежать вызова внешней библиотечной функции). Это приводит к тому, что аргумент не должен превышать максимальное для int значение, а именно 2147483647. Тип int можно заменить на long (а правильнее — на unsigned long), но в зависимости от платформы и компилятора это также может привести к замедлению кода. На достаточно больших значениях аргумента также возрастает погрешность из-за разности порядков у аргумента и периода
▍ Продолжение
При увеличении степени многочлена увеличивается количество вычислений и как следствие, накапливается дополнительная погрешность. Поэтому для близкой к double точности разумно разбить диапазон
код для Mathematica
fs4 = Sin[x Pi/4];
p13 = ChebyshevOddApproximation[13, fs4, x];
fc4 = 1-Cos[x Pi/4];
p12 = ChebyshevEvenApproximation[12, fc4, x];
Row[{CForm[(p13 /. x -> 2 x // HornerForm) /. x^2 -> xx] // Framed,
CForm[(p12 /. x -> 2 x // HornerForm) /. x^2 -> xx] // Framed}]
GraphicsRow[{
Plot[fs4 - p13 // Evaluate, {x, -1, 1},
GridLines -> {(Range[10] - 5)/5, Automatic},
WorkingPrecision -> 20, Background -> Hue[0.14, 0, 0.98]] // Quiet,
Plot[fc4 - p12 // Evaluate, {x, -1, 1},
GridLines -> {(Range[10] - 5)/5, Automatic},
WorkingPrecision -> 20, Background -> Hue[0.14, 0, 0.98]] // Quiet},
ImageSize -> Large, Spacings -> 5]
Графики ошибок:

Здесь ошибка для косинуса получилась чуть больше, чем у синуса — но это не имеет значения, потому что они всё равно замаскируются накопленными погрешностями при вычислении многочлена.
Версия для C#
double Sin13(double x)
{
x *= 0.63661977236758134308; // 2/Pi
bool sign = x < 0.0;
x = sign ? -x : x;
int xf = (int)x;
x -= xf;
if ((xf & 1) == 1)
x = 1 - x;
bool per = ((xf >> 1) & 1) == 1;
double y;
if (x <= 0.5)
{
double xx = x * x;
y = x * (1.5707963267948965822 + xx * (-0.6459640975062407217 +
xx * (0.07969262624592800593 + xx * (-0.0046817541307639977752 +
xx * (0.00016044114022967599853 + xx * (-3.5986097146969802712e-6 +
5.629793865626169033e-8 * xx))))));
}
else
{
x = 1.0 - x;
double xx = x * x;
y = 1.0 - xx * (1.2337005501361513498 + xx * (-0.25366950789986513871 +
xx * (0.020863480734953519901 + xx * (-0.0009192599500952791151 +
xx * (0.000025200135454917479526 - 4.6552987291490935821e-7 * xx)))));
}
return sign^per ? -y : y;
}
Версия для C/C++
Такая реализация уже почти соответствует точности большинству библиотечных реализаций, но всё равно уступает им по точности в несколько бит.
double Sin13(double x)
{
x *= 0.63661977236758134308; // 2/Pi
int sign = x < 0.0;
x = sign ? -x : x;
int xf = (int)x;
x -= xf;
if ((xf & 1) == 1)
x = 1 - x;
int per = (xf >> 1) & 1;
double y;
if (x <= 0.5)
{
double xx = x * x;
y = x * (1.5707963267948965822 + xx * (-0.6459640975062407217 +
xx * (0.07969262624592800593 + xx * (-0.0046817541307639977752 +
xx * (0.00016044114022967599853 + xx * (-3.5986097146969802712e-6 +
5.629793865626169033e-8 * xx))))));
}
else
{
x = 1.0 - x;
double xx = x * x;
y = 1.0 - xx * (1.2337005501361513498 + xx * (-0.25366950789986513871 +
xx * (0.020863480734953519901 + xx * (-0.0009192599500952791151 +
xx * (0.000025200135454917479526 - 4.6552987291490935821e-7 * xx)))));
}
return sign ^ per ? -y : y;
}
Такая реализация уже почти соответствует точности большинству библиотечных реализаций, но всё равно уступает им по точности в несколько бит.
▍ Табличный метод
Наивное представление о табличном методе состоит в том, что в таблице хранятся заранее вычисленные значения для узловых точек, а промежуточные значения получаются интерполяцией между ними. Заранее вычисленные значения действительно имеются, но промежуточные значения вычисляются не интерполяцией, а используя тригонометрические тождества:
где
Оптимальный размер таблицы n выбирается экспериментально, чтобы разница между функциями
код для Mathematica
With[{count = 128, n = 5, m = 6},
fs = Sin[x Pi/2/count];
ps = ChebyshevOddApproximation[n, fs, x];
fc = Cos[x Pi/2/count];
pc = ChebyshevEvenApproximation[m, fc, x];
Row[{CForm[(ps // HornerForm) /. x^2 -> xx] // Framed,
CForm[(pc // HornerForm) /. x^2 -> xx] // Framed}]
GraphicsRow[{
Plot[fs - ps // Evaluate, {x, -1, 1},
GridLines -> {(Range[10] - 5)/5, Automatic},
WorkingPrecision -> 20, Background -> Hue[0.14, 0, 0.98]],
Plot[fc - pc // Evaluate, {x, -1, 1},
GridLines -> {(Range[10] - 5)/5, Automatic},
WorkingPrecision -> 25, Background -> Hue[0.14, 0, 0.98]]
}, ImageSize -> 700, Spacings -> 5] // Quiet
]
Графики ошибок для 5-ой (синус) и 6-ой (косинус) степеней:

Код для получения таблицы тоже предельно прост:
With[{n = 128}, Table[N[Sin[x Pi/2], 18], {x, 0, 1, 1/n}]]
Результат:
{0, 0.0122715382857199261, 0.024541228522912288, 0.0368072229413588323,
0.0490676743274180143, 0.0613207363022085778, 0.0735645635996674235,
0.0857973123444398905, 0.098017140329560602, 0.110222207293883059,
0.122410675199216198, 0.134580708507126186, 0.146730474455361752,
0.158858143333861442, 0.170961888760301226, 0.183039887955140959,
0.195090322016128268, 0.20711137619221855, 0.219101240156869797,
0.23105810828067112, 0.24298017990326389, 0.254865659604514572,
0.266712757474898386, 0.278519689385053105, 0.290284677254462368,
0.302005949319228067, 0.313681740398891477, 0.325310292162262934,
0.336889853392220051, 0.348418680249434568, 0.359895036534988149,
0.371317193951837543, 0.382683432365089772, 0.393992040061048109,
0.405241314004989871, 0.416429560097637183, 0.427555093430282094,
0.438616238538527638, 0.4496113296546066, 0.460538710958240024,
0.471396736825997649, 0.482183772079122749, 0.492898192229784037,
0.503538383725717559, 0.514102744193221727, 0.524589682678468906,
0.534997619887097211, 0.545324988422046422, 0.555570233019602225,
0.565731810783613197, 0.575808191417845301, 0.58579785745643886,
0.595699304492433343, 0.605511041404325514, 0.615231590580626845,
0.624859488142386377, 0.634393284163645498, 0.643831542889791465,
0.653172842953776764, 0.662415777590171761, 0.671558954847018401,
0.680600997795453051, 0.689540544737066925, 0.698376249408972854,
0.707106781186547524, 0.715730825283818654, 0.724247082951466921,
0.732654271672412835, 0.740951125354959091, 0.749136394523459325,
0.757208846506484548, 0.765167265622458926, 0.773010453362736961,
0.780737228572094478, 0.788346427626606262, 0.795836904608883536,
0.80320753148064491, 0.810457198252594792, 0.817584813151583697,
0.824589302785025264, 0.831469612302545237, 0.838224705554838043,
0.844853565249707073, 0.851355193105265142, 0.85772861000027207,
0.863972856121586738, 0.870086991108711419, 0.876070094195406607,
0.88192126434835503, 0.887639620402853948, 0.89322430119551532,
0.898674465693953843, 0.903989293123443332, 0.909167983090522377,
0.914209755703530655, 0.919113851690057744, 0.923879532511286756,
0.928506080473215566, 0.932992798834738888, 0.937339011912574923,
0.941544065183020778, 0.945607325380521326, 0.949528180593036667,
0.953306040354193837, 0.956940335732208865, 0.960430519415565811,
0.963776065795439867, 0.966976471044852109, 0.970031253194543993,
0.972939952205560145, 0.975702130038528544, 0.978317370719627633,
0.980785280403230449, 0.983105487431216327, 0.985277642388941245,
0.987301418157858382, 0.989176509964780973, 0.990902635427780025,
0.992479534598709998, 0.993906970002356042, 0.995184726672196886,
0.996312612182778013, 0.997290456678690216, 0.998118112900149207,
0.998795456205172393, 0.999322384588349501, 0.99969881869620422,
0.999924701839144541, 1}
Значения синуса и косинуса из неё получаются в зависимости от того, индексировать её с начала в конец или наоборот. Стоит уточнить, что здесь по факту размер получается на единицу больше, то есть 129 (из-за замыкающей единицы для
Версия для C#
double[] sincostable = {...};
void SinCos(double x, out double sina, out double cosa)
{
x *= 0.63661977236758134308; // 2/Pi
bool sign = x < 0.0;
x = sign ? -x : x;
int xf = (int)x;
x -= xf;
bool rev = (xf & 1) == 1;
if (rev)
x = 1 - x;
bool per = ((xf >> 1) & 1) == 1;
double z = x * 128;
int zf = (int)z;
z -= zf;
double sint = sincostable[zf];
double cost = sincostable[128 - zf];
double zz = z * z;
double ss = z * (0.012271846303085128928 + zz * (-3.0801968454884792651e-7 +
2.3193461291439683491e-12 * zz));
double cc = 1.0 - zz * (0.000075299105843272081 + zz*(-9.449925567834354484e-10 +
4.7437807891647010749e-15 * zz));
sina = (sign ^ per) ? -sint * cc - cost * ss : sint * cc + cost * ss;
cosa = (rev ^ per) ? sint * ss - cost * cc : cost * cc - sint * ss;
}
Версия для C/C++
const double sincostable[] = {...};
void SinCosByTable(double x, double* sina, double* cosa)
{
x *= 0.63661977236758134308; // 2/Pi
int sign = x < 0.0;
x = sign ? -x : x;
int xf = (int)x;
x -= xf;
int rev = xf & 1;
if (rev)
x = 1 - x;
int per = (xf >> 1) & 1;
double z = x * 128;
int zf = (int)z;
z -= zf;
double sint = sincostable[zf];
double cost = sincostable[128 - zf];
double zz = z * z;
double ss = z * (0.012271846303085128928 + zz * (-3.0801968454884792651e-7 +
2.3193461291439683491e-12 * zz));
double cc = 1.0 - zz * (0.000075299105843272081 + zz * (-9.449925567834354484e-10 +
4.7437807891647010749e-15 * zz));
*sina = (sign ^ per) ? -sint * cc - cost * ss : sint * cc + cost * ss;
*cosa = (rev ^ per) ? sint * ss - cost * cc : cost * cc - sint * ss;
}
Табличный метод можно использовать и по-другому — только для синуса, если разбить интервал
▍ Как тестировать
Когда речь идёт о реализации с меньшей точностью, то можно сравнивать значения с библиотечной реализацией и таким образом считать максимальное отклонение. Однако на точности близкой к double такой подход не прокатит — поскольку библиотечные реализации тоже имеют погрешности. Можно экспортировать значения в Wolfram Mathematica, но это сложнее и имеет свои тонкости, поскольку преобразование числа в формате double к текстовому десятичному виду также может искажать значения младших бит; или же можно экспортировать стороннюю библиотеку с заведомо большей точностью и сравнивать результаты уже в ней. Однако есть и другой путь — считать какую-нибудь функцию, значение которой можно посчитать аналитически, и сравнивать с этим значением полученный результат. У какой реализации будет наименьшее отклонение — та и точнее.
Хорошим кандидатом для этого является функция, суммирующая квадраты синусов с аргументами, равномерно распределённых в диапазоне от
Примечание
Любители математики могут попробовать доказать эти равенства самостоятельно — я же их получил просто используя функцию FullSimplify в Mathematica.
Также нужно учитывать, что некоторые современные оптимизирующие компиляторы (в частности C++ от Intel и Microsoft) могут распознать сумму и вычислять её в 4 параллельных потоках на регистрах AVX, что при замерах производительности может показать значительное превосходство библиотечных реализаций (да, я столкнулся с этим). Чтобы этого избежать, можно на каждом этапе менять знак (то есть считать функцию
Можно рассмотреть и другие сценарии — например, вращать единичный вектор через комплексное умножение, который должен вернуться в исходное состояние, или же выполнять прямое и обратное FFT, и сравнивать полученный результат с исходным — но здесь мы не будем на этом подробно останавливаться, поскольку выбор корректного метода тестирования сильно зависит от конкретной цели использования.
Вариант тестирования для C#
using System;
using System.Diagnostics;
…
using System.Diagnostics;
…
long count = (long)1e9;
Stopwatch sw = new Stopwatch();
double sum, secs;
sum = 0.0;
sw.Restart();
for (long i = -count; i <= count; i++)
{
double x = (double)i * Math.PI / count;
double y = Math.Sin(x);
sum += y * y;
}
secs = sw.ElapsedMilliseconds / 1000.0;
Console.WriteLine("mathsin\t{0}\t{1}", secs, sum - count);
...
// повторить для других реализаций
Вариант тестирования для C/C++
#define _USE_MATH_DEFINES
#include <math.h>
#include <time.h>
...
long n = (long)1e9;
double sum;
double sign;
sum = 0;
sign = 1;
clock_t t = clock();
for (long i = -n; i <= n; i++)
{
double x = (double)i * M_PI / n;
double y = sin(x);
sum += y * y * sign;
sign = -sign;
sign = -sign;
}
t = clock() - t;
std::cout << "mathsin: dur: " << double(t) / CLOCKS_PER_SEC << ", " << sum - n << endl;
...
// повторить для других реализаций
В варианте для C/C++ добавлено немного бесполезных вычислений во избежание автоматической векторизации. Также по-хорошему его можно реализовать на шаблонах, чтобы не дублировать одинаковые куски кода для разных функций.
В обоих случаях время измеряется в секундах с точностью до миллисекунд. Сделано это для того, чтобы привести результаты к общему знаменателю и не зависеть от платформо-зависимых средств и отсутствующем в си std::chrono. Ну и заодно стимулировать выполнять код достаточно долго, чтобы накопилось хотя бы несколько секунд.
▍ Заключение и результаты
При замерах производительности все рассмотренные реализации оказались быстрее библиотечных и на C#, и на C++ с компилятором от Microsoft. На C++ была дополнительно протестирована функция fsincos из математического сопроцессора FPU процессоров x86/x64, которая тоже умеет считать и синус, и косинус одновременно.
Сводная таблица по результатам замеров на Core i7:
реализация \ ускорение (в разы) | C# (.NET 4.8) | C++ (VS2019) | C++ vs. C# |
библиотечная | 1 | 1 | 2.8 |
Sin7 | 6.06 | 2.07 | 1.09 |
Sin13 | 4.08 | 1.25 | 1.1 |
SinCos | 2.62 | 1.21 | 1.3 |
fsincos (FPU) | - | 0.28 | - |
Как обстоят дела с множеством других компиляторов и платформ — это тема отдельного исследования, которым, если честно, заниматься совсем не хочется. Но было бы интересно увидеть результаты от другого независимого источника.
Измерения точности с накоплениями погрешностей также дали вполне ожидаемый результат и на больших
Насколько скорость рассмотренных реализаций стоит нескольких дополнительных бит погрешности — каждый решает сам для себя самостоятельно. Однако лично я сам думаю, что если погрешность в несколько последних бит имеет критически важное значение — то точности double тут явно недостаточно и нужно переходить на другие типы данных. Также для увеличения точности вычислений можно использовать специальные техники — но это тема уже для отдельной статьи.
