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

Я продолжаю работать над проектом PSRayTracing. Как ни стараюсь я положить его на полку, время от времени слышу о чём-то «новом» и задаюсь вопросом: «а можно ли засунуть это в мой трассировщик лучей, чтобы выжать из него ещё немного скорости?». На этот раз такой темой стали аппроксимации Паде. Моя цель заключалась в обеспечении более быстрых (и точных) тригонометрических аппроксимаций.

Увы, это не помогло... однако я обнаружил нечто иное, позволившее существенно ускорить мой трассировщик!

Более быстрая тригонометрия

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

Для текстурирования объектов в PSRayTracing (основанном на информации из книг Ray Tracing in One Weekend редакции приблизительно 2020 года) используется функция std::asin(). При профилировании некоторых сцен я заметил, что к этой функции выполняется существенное количество вызовов, поэтому решил, что неплохо было бы найти её аппроксимацию.

В конечном итоге я написал собственную аппроксимацию на основе рядов Тейлора. Она быстрее, но есть у неё и изъян: когда x на входе меньше -0.8 или больше 0.8, у неё начинаются сильные отклонения. Поэтому для обеспечения корректности мне пришлось при выходе за эти границы возвращаться к использованию std::asin().

The Taylor series arcsin approximation with a fallback for edges
Аппроксимация быстрее, чем эталон (во многих случаях), но сильно некорректна (обратите внимание на Великобританию и Ирландию)

Код на C++ выглядит так:

double _asin_approx_private(const double x)
{
    // Здесь используется аппроксимация на основе ряда Тейлора.
    // См. http://mathforum.org/library/drmath/view/54137.html
    //
    // В случае, когда x=[-1, -0.8) или (0.8, 1.0], к сожалению, возникают большие погрешности
    // по сравнению с arcsin, поэтому в этом случае мы используем реальную функцию
    if ((x < -0.8) || (x > 0.8))
    {
        return std::asin(x);
    }

    // Аппроксимация рядом Тейлора
    constexpr double a = 0.5;
    constexpr double b = a * 0.75;
    constexpr double c = b * (5.0 / 6.0);
    constexpr double d = c * (7.0 / 8.0);

    const double aa = (x * x * x) / 3.0;
    const double bb = (x * x * x * x * x) / 5.0;
    const double cc = (x * x * x * x * x * x * x) / 7.0;
    const double dd = (x * x * x * x * x * x * x * x * x) / 9.0;

    return x + (a * aa) + (b * bb) + (c * cc) + (d * dd);
}

Путём проб и ошибок я нашёл ряд Тейлора четвёртого порядка, обеспечивающий наибольшую производительность на моём «железе». Он был осязаемо быстрее (на +5%), поэтому я оставил его и перешёл к следующей оптимизации.

The raw Taylor series arcsin approximation showing significant error at the edges
Погрешность исходной аппроксимации на основе ряда Тейлора

Аппроксимации Паде

Не помню, откуда я о них узнал, у меня не сохранилось никаких воспоминаний. Подробнее об этих аппроксимациях можно прочитать в статье Википедии. Если вкратце, то это математический инструмент, который может помочь с аппроксимацией функции. Для его вычисления необходимо начать с ряда Тейлора (или Маклорена). Хоть PSRayTracing в основном пишется на C++, для упрощения будет использоваться Python; когда это будет важно, мы вернёмся к нашему любимому компилируемому языку.

Для описанной выше аппроксимации арксинуса на основе ряда Тейлора код на Python будет таким:

def taylor_fourth_order(x: float) -> float:
    return x + (x**3)/6 + (3*x**5)/40 + (5*x**7)/112

Выполнив преобразования, мы получим то, что называется аппроксимацией Паде [3/4]:

def asin_pade_3_4(x):
    a1 = -367.0 / 714.0
    b1 = -81.0 / 119.0
    b2 = 183.0 / 4760.0
    n = 1.0 + (a1 * x**2)
    d = 1.0 + (b1 * x**2) + (b2 * x**4)
    return x * (n / d)

Я также покажу аппроксимации на основе ряда Тейлора пятого порядка, то есть аппроксимацию Паде [5/4]:

def asin_pade_5_4(x):
    a1 = -1709.0 / 2196.0
    a2 =  69049.0 / 922320.0
    b1 = -2075.0 / 2196.0
    b2 =  1075.0 / 6832.0
    n = 1.0 + (a1 * x**2) + (a2 * x**4)
    d = 1.0 + (b1 * x**2) + (b2 * x**4)
    return x * (n / d)

 Составив график всех трёх, получим следующее:

Comparison of Taylor series and Pade approximants for arcsin

Ого, это уже выглядит намного лучше. Меньше погрешностей! Это не так легко разглядеть, поэтому давайте увеличим правую часть функций:

Zoomed view of Pade approximant errors at the range edges

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

The arcsin half angle transformation formula
Формула преобразования с половинным углом

Может, они кажутся немного непонятными, зато позволяют нам сделать хитрый ход. Когда |x| больше значения, мы можем «телепортироваться» с края функции ближе к центру arcsin(), выполнить вычисления, а затем вернуться назад и использовать результат там. Вот, как выглядит наша новая аппроксимация asin(x) на Python:

def asin_pade_3_4_half_angle_correction(x: float) -> float:
    abs_x = abs(x)

    # Если мы вышли за пределы диапазона, то можно использовать преобразования с половинным углом, чтобы учесть погрешность
    if abs_x > 0.85:
        small_x = math.sqrt(0.5 * (1.0 - abs_x))
        r = (math.pi / 2) - (2.0 * asin_pade_3_4(small_x))
        return -r if x < 0 else r

    # В пределах диапазона мы можем, как обычно, использовать аппроксимацию 3/4
    return asin_pade_3_4(x)

С корректировкой края выглядят примерно так:

Pade approximant errors with half angle transform correction applied

Возможно, это не так легко разглядеть, но пунктирные линии — это график с методикой коррекции на основе преобразования с половинным углом; он очень близок к прямой y=0. Небольшую погрешность можно разглядеть, только приблизив график.

Можно использовать и дополнительную оптимизацию: применить (и адаптировать) аппроксимацию Паде [1/2] внутри тела if, потому что small_x всегда будет меньше квадратного корня 0.075 (равного ~0.27). Аппроксимация Паде [1/2] для asin() вычисляется гораздо быстрее, но только для малых значений x. Чтобы оптимизировать код ещё больше, её даже можно встроить в функцию:

def asin_pade_1_2(x):
    b1 = -1.0 / 6.0
    d = 1.0 + (b1 * x**2)
    return (x / d)

# ...

def asin_pade_3_4_half_angle_correction(x: float) -> float:
    abs_x = abs(x)

    # Если мы вышли за пределы диапазона, то можно использовать преобразования с половинным углом, чтобы учесть погрешность, а также "малую" аппроксимацию Паде
    if abs_x > 0.85:
        z = (1.0 - abs_x) / 2
        b1 = -1.0 / 6.0
        d = 1.0 + (b1 * z)
        small_pade = math.sqrt(z) / d
        r = (math.pi / 2) - (2.0 * small_pade)
        return -r if x < 0 else r

    # В пределах диапазона мы можем, как обычно, использовать аппроксимацию 3/4
    return asin_pade_3_4(x)

Результат всё равно выглядел, как график выше, поэтому я не думаю, что необходимо добавлять ещё одну. В коде на C++ это выглядит так:

constexpr double HalfPi = 1.5707963267948966;

inline double asin_pade_3_4(const double x)
{
   constexpr double a1 = -367.0 / 714.0;
   constexpr double b1 = -81.0 / 119.0;
   constexpr double b2 = 183.0 / 4760.0;

   const double x2 = x * x;
   const double n = 1.0 + (a1 * x2);
   const double d = 1.0 + (b1 * x2) + (b2 * x2 * x2);

   return x * (n / d);
}

double asin_pade_3_4_half_angle_correction(const double x)
{
   const double abs_x = std::abs(x);

   if (abs_x <= 0.85)
   {
       return asin_pade_3_4(x);
   }
   else
   {
       // Края кривой Паде
       const double z = 0.5 * (1.0 - abs_x);
       constexpr double b1 = -1.0 / 6.0;
       const double d = 1.0 + (b1 * z);
       const double pade_result = std::sqrt(z) / d;
       const double r = HalfPi - (2.0 * pade_result);
       return std::copysign(r, x);
   }
}

По сравнению с исходным способом аппроксимации он более сложен, но обладает и преимуществами:

  1. В большом диапазоне значений [-0.85, 0.85] будут использоваться упрощённые вычисления

  2. В пограничных случаях он использует более быстрые вычисления, чем std::asin()

  3. Меньше погрешность

Давайте подставим этот код в PSRayTracing и посмотрим, повысилась ли скорость. Мы используем стандартную сцену (финальный рендер из книги 2):

Final render of the Ray Tracing in One Weekend Book 2 scene

Измерения

asin() используется в модели земного шара. Все изображения обычно выглядят одинаково (за исключением небольшого шума). Для тестирования мы будем выполнять рендеринг изображения 1080p с 250 сэмплами на пиксель и задействуем несколько ядер. Тестирование выполнялось на M4 Mac Mini (в macOS Tahoe с компилятором GCC15 и флагом оптимизации -O3). Выполнив по несколько прогонов, возьмём медиану:

С std::asin() для рендеринга сцены потребовалось примерно 111 секунд:

ben@Benjamins-Mac-mini build_gcc15 % ./PSRayTracing -j 4 -n 250 -s 1920x1080 -o render_std_asin.png
Scene: book2::final_scene
Render size: 1920x1080
Samples per pixel: 250
Max number of ray bounces: 50
Number of render threads: 4
  Copy per thread: on
Saving to: render_std_asin.png
Seed: `ASDF`
Rendering: [==================================================] 100% 111s
Render took 110.891 seconds

Старой аппроксимации asin() потребовалось примерно 105 секунд (ускорение на ~5%):

ben@Benjamins-Mac-mini build_gcc15 % ./PSRayTracing -j 4 -n 250 -s 1920x1080 - o render_asin_taylor.png
...
Render took 104.674 seconds

А это новое решение с аппроксимацией Паде выполнялось... то же количество времени:

ben@Benjamins-Mac-mini build_gcc_15 % ./PSRayTracing -j 4 -n 250 -s 1920x1080 -o render_asin_pade.png
...
Render took 104.87 seconds

Новый метод asin() лучше (с точки зрения корректности), но различий в производительности практически нет. 

Промпт LLM

Невозможно спорить, что ИИ-инструментарий сильно изменился за последние три с лишним года. На CppCon 2023 Андрей Александреску выступил с замечательным докладом «Роботы хотят забрать у вас работу: исследование генеративного ИИ для C++», рекомендую его послушать. После всей описанной выше работы и размышлений я решил спросить у LLM (в данном случае Gemini): «Какую быструю аппроксимацию asin(x) можно использовать в C++?». Он выдал мне следующее:

constexpr double HalfPi = 3.1415926535897932385 / 2.0;

double fast_asin_cg(const double x)
{
    // Исходные коэффициенты Minimax
    constexpr double a0 =  1.5707288;
    constexpr double a1 = -0.2121144;
    constexpr double a2 =  0.0742610;
    constexpr double a3 = -0.0187293;

    // Убираем знак
    const double abs_x = fabs(x);

    // Вычисляем многочлен по схеме Горнера
    double p = a3 * abs_x + a2;
    p = p * abs_x + a1;
    p = p * abs_x + a0;

    // Применяем sqrt и смещение на pi/2
    const double x_diff = sqrt(1.0 - abs_x);
    const double result = HalfPi - (x_diff * p);

    // Возвращаем знак
    return copysign(result, x);
}

Слишком просто, чтобы быть правдой. В качестве источника ИИ указал на документацию по asin в Nvidia Cg Toolkit; этот продукт не обновлялся с 2012 года. Эталонная реализация содержала вышеприведённый код в переписанном виде. Можно с лёгкостью портировать его в Python/Jupyter, чтобы проверить:

def asin_cg(x: float) -> float:
    '''
    Fast branchless asin(x) approximation.
    Based on Abramowitz and Stegun formula 4.4.45
    '''
    # https://developer.download.nvidia.com/cg/asin.html
    # https://personal.math.ubc.ca/~cbm/aands/page\_81.htm

    # Исходные коэффициенты Minimax Абрамовица и Стиган
    a0 =  1.5707288
    a1 = -0.2121144
    a2 =  0.0742610
    a3 = -0.0187293

    abs_x = abs(x)

    # Вычисляем многочлен по схеме Горнера
    p = a3 * abs_x + a2
    p = p * abs_x + a1
    p = p * abs_x + a0

    result = (math.pi / 2) - math.sqrt(1.0 - abs_x) * p

    # Нативным образом восстанавливаем знак
    return math.copysign(result, x)

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

Performance and error profile of the Nvidia Cg asin approximation

Кривая идеально накладывается на функцию arcsin(). А погрешность практически отсутствует. Однако настоящей проверкой будет сам трассировщик лучей:

ben@Benjamins-Mac-mini build_gcc_15_new_asin_cg % ./PSRayTracing -j 4 -n 
...
Render took 101.462 seconds

Ничего себе! Это существенно быстрее, чем все остальные методики. А при сравнении рендера с результатом использования std::asin() они оказались неразличимы. Мы нашли более качественную реализацию asin()

Ещё измерения

Это заставило меня выполнить бенчмаркинг этой реализации на нескольких чипах и операционных системах.

Intel i7-10750H, Ubuntu 24.04 (с GCC 14 и clang 19):

./test_gcc_O3 "ASDF" "100" "10000000"
std::asin() time:  29197.9 ms
asin_cg() time:    19839.8 ms
Verification sums:
  std::asin():  -34549.5
  asin_cg():    -34551.1
  Difference:   1.60886
  Error:        -0.00465669 %
Speedup: 1.47169x faster

./test_clang_O3 "ASDF" "100" "10000000"
std::asin() time:  29520.7 ms
asin_cg() time:    19044.3 ms
...
Speedup: 1.55011x faster

Intel i7-10750H, Windows 11 (с MSVC 2022):

C:\Users\Benjamin\Projects\PSRayTracing\experiments\asin_cg_approx>test_msvc_O2.exe ASDF 100 10000000
std::asin() time:  12458.1 ms
asin_cg() time:     6562.1 ms
...
Speedup: 1.8985x faster

 Apple M4, macOS Tahoe (с GCC 15 в Homebrew и clang 17):

./test_gcc_O3 "ASDF" "100" "10000000"
std::asin() time:  10469 ms
asin_cg() time:    10251 ms
...
Speedup: 1.02126x faster

./test_clang_O3 "ASDF" "100" "10000000"
std::asin() time:  12650 ms
asin_cg() time:    12073.2 ms
...
Speedup: 1.04777x faster

Во всех случаях лидером оказывалась CG-аппроксимация asin(). На чипе Intel он быстрее на крайне малую долю. Мне было бы любопытно протестировать её на системе x86_64 с процессором AMD, но это упражнение я оставлю читателям. Предполагаю, что там эта аппроксимация проявит себя столь же замечательно. На чипе Apple M4 разрыв не такой уж большой, но всё равно заметный (и воспроизводимый). Любое различие больше 2% важно; я имею в виду старый доклад Николаса Ормрода.

Выводы

Думаю, изначально я пошёл по пути рядов Тейлора, потому что пробовал их с sin() и cos(), а потом предположил, что их можно применить и к другим тригонометрическим функциям. Я не догадался сразу посмотреть, не решил ли кто-то уже мою задачу нахождения более быстрого арксинуса в компьютерной графике.

Хуже всего то, что решение существовало ещё до появления LLM. Сейчас у меня не получается воспроизвести запрос, но я ввёл в поисковый движок что-то вроде "fast c++ asin approximation cg". Первым результатом была ссылка на страницу документации Nvidia Cg Toolkit. Я обнаружил её только несколько дней назад.

Меня удивило, что мне никто об этом не сказал. Я даже указал ускоренный asin() в README, как моё достижение, но никто и не подумал меня исправить... Я знаю, что этот проект (и статьи) известны в кругах разработчиков на C++ и создателей компьютерной графики. Даже более опытные и умные люди ни слова мне не сказали.

Этот потрясающий фрагмент кода ждал меня в документации к мёртвому ПО, которое, в свою очередь, позаимствовало исходную формулу из учебника по математике из 60-х. Ещё меня расстроило то, что при попытке поисков не обнаружилось никаких бенчмарков. Надеюсь, теперь эта информация станет известнее.

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

Ну и ещё красивые графики.