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

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

SSE Square Root в стиле Intel давал точные результаты, но в моих вычислениях был медленнее, чем стандартный SQRT.

В среднем все вышеперечисленные трюки были побиты стандартным SQRT. Итак, мой вопрос, в чем смысл?

Мой ПК имеет следующий процессор:

Процессор Intel® Core ™ TM i7-6700HQ с тактовой частотой 2,60 ГГц.

Я получил следующие результаты для каждого метода (я исправил тест производительности в соответствии с приведенным ниже предложением в виде полезного комментария, спасибо за этот n.m.):

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

enter image description here

Вы можете найти исходный код ниже для справки.

#include <chrono>
#include <cmath>
#include <deque>
#include <iomanip>
#include <iostream>
#include <immintrin.h>
#include <random>

using f64 = double;
using s64 = int64_t;
using u64 = uint64_t;

static constexpr u64 cycles = 24;
static constexpr u64 sample_max = 1000000;

f64 sse_sqrt(const f64 x) {
    __m128d root = _mm_sqrt_pd(_mm_load_pd(&x));
    return *(reinterpret_cast<f64*>(&root));
}

constexpr f64 carmack_sqrt(const f64 x) {
    union {
        f64 x;
        s64 i;
    } u = {};
    u.x = x;
    u.i = 0x5fe6eb50c7b537a9 - (u.i >> 1);
    f64 xhalf = 0.5 * x;
    u.x = u.x * (1.5 - xhalf * u.x * u.x);
    # u.x = u.x * (1.5 - xhalf * u.x * u.x);
    # u.x = u.x * (1.5 - xhalf * u.x * u.x);
    # ... so on, if you want more precise result ...
    return u.x * x;
}

int main(int /* argc */, char ** /*argv*/) {
    std::random_device r;
    std::default_random_engine e(r());
    std::uniform_real_distribution<f64> dist(1, sample_max);
    std::deque<f64> samples(sample_max);
    for (auto& sample : samples) {
        sample = dist(e);
    }

    // std sqrt
    {
        std::cout << "> Measuring std sqrt.\r\n> Please wait . . .\r\n";
        f64 result = 0;
        auto t1 = std::chrono::high_resolution_clock::now();
        for (auto cycle = 0; cycle < cycles; ++cycle) {
            for (auto& sample : samples) {
                result += std::sqrt(static_cast<f64>(sample));
            }
        }
        auto t2 = std::chrono::high_resolution_clock::now();
        auto dt = t2 - t1;
        std::cout << "> Accumulated result: " << std::setprecision(19) << result << "\n";
        std::cout << "> Total execution time: " <<
        std::chrono::duration_cast<std::chrono::milliseconds>(dt).count() << " ms.\r\n\r\n";
    }

    // sse sqrt
    {
        std::cout << "> Measuring sse sqrt.\r\n> Please wait . . .\r\n";
        f64 result = 0;
        auto t1 = std::chrono::high_resolution_clock::now();
        for (auto cycle = 0; cycle < cycles; ++cycle) {
            for (auto& sample : samples) {
                result += sse_sqrt(static_cast<f64>(sample));
            }
        }
        auto t2 = std::chrono::high_resolution_clock::now();
        auto dt = t2 - t1;
        std::cout << "> Accumulated result: " << std::setprecision(19) << result << "\n";
        std::cout << "> Total execution time: " <<
        std::chrono::duration_cast<std::chrono::milliseconds>(dt).count() << " ms.\r\n\r\n";
    }

    // carmack sqrt
    {
        std::cout << "> Measuring carmack sqrt.\r\n> Please wait . . .\r\n";
        f64 result = 0;
        auto t1 = std::chrono::high_resolution_clock::now();
        for (auto cycle = 0; cycle < cycles; ++cycle) {
            for (auto& sample : samples) {
                result += carmack_sqrt(static_cast<f64>(sample));
           }
        }
        auto t2 = std::chrono::high_resolution_clock::now();
        auto dt = t2 - t1;
        std::cout << "> Accumulated result: " << std::setprecision(19) << result << "\n";
        std::cout << "> Total execution time: " <<
        std::chrono::duration_cast<std::chrono::milliseconds>(dt).count() << " ms.\r\n\r\n";
    }

    std::cout << "> Press any key to exit . . .\r\n";
    std::getchar();
    return 0;
}

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

Я пишу свой собственный игровой движок для одного из моих портфолио. Я ценю ваши добрые ответы и открыта для любых предложений.

Хорошего дня.

6
Rajmund Kail 20 Авг 2018 в 14:11

4 ответа

Лучший ответ

Для удовольствия и получения прибыли?

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

В «реальном» коде нужно использовать предоставленные методы libc, если они у вас есть. Встраиваемым платформам обычно не хватает libc или rolls, поэтому вы должны реализовать свой собственный.

7
hellow 20 Авг 2018 в 11:15

Этот быстрый взаимный квадратный трюк в основном устарел. В SSE имеется приблизительный квадратный корень, который существует с тех пор, как Pentium 3 полностью заменил его на платформе ПК. Другие платформы обычно имеют собственный обратный квадратный корень, например, ARM имеет VRSQRTE и удобную инструкцию, которая также выполняет шаг Ньютона.

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

Как часто, ваш тест не совсем точен. Я случайно провел несколько релевантных тестов, где соответствующие части выглядят так:

std::sqrt на основе:

HMM_INLINE float HMM_LengthVec4(hmm_vec4 A)
{
    float Result = std::sqrt(HMM_LengthSquaredVec4(A));

    return(Result);
}

HMM_INLINE hmm_vec4 HMM_NormalizeVec4(hmm_vec4 A)
{
    hmm_vec4 Result = {0};

    float VectorLength = HMM_LengthVec4(A);

    /* NOTE(kiljacken): We need a zero check to not divide-by-zero */
    if (VectorLength != 0.0f)
    {
        float Multiplier = 1.0f / VectorLength;

#ifdef HANDMADE_MATH__USE_SSE
        __m128 SSEMultiplier = _mm_set1_ps(Multiplier);
        Result.InternalElementsSSE = _mm_mul_ps(A.InternalElementsSSE, SSEMultiplier);        
#else 
        Result.X = A.X * Multiplier;
        Result.Y = A.Y * Multiplier;
        Result.Z = A.Z * Multiplier;
        Result.W = A.W * Multiplier;
#endif
    }

    return (Result);
}

SSE обратный квадратный корень плюс шаг Ньютона:

HMM_INLINE hmm_vec4 HMM_NormalizeVec4_new(hmm_vec4 A)
{
    hmm_vec4 Result;
    // square elements and add them together, result is in every lane
    __m128 t0 = _mm_mul_ps(A.InternalElementsSSE, A.InternalElementsSSE);
    __m128 t1 = _mm_add_ps(t0, _mm_shuffle_ps(t0, t0, _MM_SHUFFLE(2, 3, 0, 1)));
    __m128 sq = _mm_add_ps(t1, _mm_shuffle_ps(t1, t1, _MM_SHUFFLE(0, 1, 2, 3)));
    // compute reciprocal square root with Newton step for ~22bit accuracy
    __m128 rLen = _mm_rsqrt_ps(sq);
    __m128 half = _mm_set1_ps(0.5);
    __m128 threehalf = _mm_set1_ps(1.5);
    __m128 t = _mm_mul_ps(_mm_mul_ps(sq, half), _mm_mul_ps(rLen, rLen));
    rLen = _mm_mul_ps(rLen, _mm_sub_ps(threehalf, t));
    // multiply elements by the reciprocal of the vector length
    __m128 normed = _mm_mul_ps(A.InternalElementsSSE, rLen);
    // normalize zero-vector to zero, not to NaN
    __m128 zero = _mm_setzero_ps();
    Result.InternalElementsSSE = _mm_andnot_ps(_mm_cmpeq_ps(A.InternalElementsSSE, zero), normed);

    return (Result);
}

Обратный квадратный корень из SSE без шага Ньютона:

HMM_INLINE hmm_vec4 HMM_NormalizeVec4_lowacc(hmm_vec4 A)
{
    hmm_vec4 Result;
    // square elements and add them together, result is in every lane
    __m128 t0 = _mm_mul_ps(A.InternalElementsSSE, A.InternalElementsSSE);
    __m128 t1 = _mm_add_ps(t0, _mm_shuffle_ps(t0, t0, _MM_SHUFFLE(2, 3, 0, 1)));
    __m128 sq = _mm_add_ps(t1, _mm_shuffle_ps(t1, t1, _MM_SHUFFLE(0, 1, 2, 3)));
    // compute reciprocal square root without Newton step for ~12bit accuracy
    __m128 rLen = _mm_rsqrt_ps(sq);
    // multiply elements by the reciprocal of the vector length
    __m128 normed = _mm_mul_ps(A.InternalElementsSSE, rLen);
    // normalize zero-vector to zero, not to NaN
    __m128 zero = _mm_setzero_ps();
    Result.InternalElementsSSE = _mm_andnot_ps(_mm_cmpeq_ps(A.InternalElementsSSE, zero), normed);

    return (Result);
}

(quick-bench)

results

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

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

9
Dietrich Epp 20 Авг 2018 в 11:57

Суть методики Carmack заключалась в том, чтобы извлечь лучшую производительность из целочисленных операций, чем можно было получить с плавающей запятой операции в 1990-х годах. С тех пор производительность с плавающей запятой значительно улучшилась! Как вы можете видеть в своих собственных тестах. Не было бы практической причины использовать эту технику в новом коде, если только вы не столкнулись с подобным ограничением в вашем оборудовании, которого нет у i7.

6
Gaius 20 Авг 2018 в 11:23

Ограничение использования стандартной библиотеки из-за объема проекта

В чем смысл реализации пользовательских математических функций в C ++ (например, SQRT)?

В дополнение к тому, что уже упоминалось в других ответах, выбор проекта, реализующего свои собственные (пользовательские) математические функции, может быть обусловлен:

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

Одним из примеров может быть ASIL секретный проект, придерживающийся стандарт ISO 26262, скажем, с использованием компилятора, который обеспечивает адекватную квалификацию в отношении правильно компилирует исходный код проекта , но это не обеспечивает адекватной квалификации для поставляемой стандартной библиотеки , где, например, математическая библиотека может быть связана только объектом, а не исходным кодом (для последнего соответствующие проекты и квалификация исходного кода могут быть написаны самим проектом).

6
dfri 20 Авг 2018 в 11:51
51929504