Понимание микроархитектурных причин, по которым более длинный код выполняется в 4 раза быстрее (архитектура AMD Zen 2)

У меня есть следующий код C ++ 17, который я компилирую с VS 2019 (версия 16.8.6) в режиме x64:

struct __declspec(align(16)) Vec2f { float v[2]; };
struct __declspec(align(16)) Vec4f { float v[4]; };

static constexpr std::uint64_t N = 100'000'000ull;

const Vec2f p{};
Vec4f acc{};

// Using virtual method:
for (std::uint64_t i = 0; i < N; ++i)
    acc += foo->eval(p);

// Using function pointer:
for (std::uint64_t i = 0; i < N; ++i)
    acc += eval_fn(p);

В первом цикле foo - это std::shared_ptr, а eval() - виртуальный метод:

__declspec(noinline) virtual Vec4f eval(const Vec2f& p) const noexcept
{
    return { p.v[0], p.v[1], p.v[0], p.v[1] };
}

Во втором цикле eval_fn - указатель на функцию ниже:

__declspec(noinline) Vec4f eval_fn_impl(const Vec2f& p) noexcept
{
    return { p.v[0], p.v[1], p.v[0], p.v[1] };
}

Наконец, у меня есть две реализации operator+= для Vec4f:

  • Один реализован с использованием явного цикла:

    Vec4f& operator+=(Vec4f& lhs, const Vec4f& rhs) noexcept
    {
        for (std::uint32_t i = 0; i < 4; ++i)
            lhs.v[i] += rhs.v[i];
        return lhs;
    }
    
  • И один реализован с помощью встроенного SSE:

    Vec4f& operator+=(Vec4f& lhs, const Vec4f& rhs) noexcept
    {
        _mm_store_ps(lhs.v, _mm_add_ps(_mm_load_ps(lhs.v), _mm_load_ps(rhs.v)));
        return lhs;
    }
    

Вы можете найти полный (автономный, только для Windows) код теста ниже.

Вот сгенерированный код для двух циклов, а также время выполнения в миллисекундах (для 100 миллионов итераций) при выполнении на процессоре AMD Threadripper 3970X (архитектура Zen 2):

  • С внутренней реализацией SSE operator+=(Vec4f&, const Vec4f&):

    // Using virtual method: 649 ms
    $LL4@main:
      mov rax, QWORD PTR [rdi]            // fetch vtable base pointer (rdi = foo)
      lea r8, QWORD PTR p$[rsp]           // r8 = &p
      lea rdx, QWORD PTR $T3[rsp]         // not sure what $T3 is (some kind of temporary, but why?)
      mov rcx, rdi                        // rcx = this
      call    QWORD PTR [rax]             // foo->eval(p)
      addps   xmm6, XMMWORD PTR [rax]
      sub rbp, 1
      jne SHORT $LL4@main
    
    // Using function pointer: 602 ms
    $LL7@main:
      lea rdx, QWORD PTR p$[rsp]          // rdx = &p
      lea rcx, QWORD PTR $T2[rsp]         // same question as above
      call    rbx                         // eval_fn(p)
      addps   xmm6, XMMWORD PTR [rax]
      sub rsi, 1
      jne SHORT $LL7@main
    
  • С реализацией явного цикла operator+=(Vec4f&, const Vec4f&):

    // Using virtual method: 167 ms [3.5x to 4x FASTER!]
    $LL4@main:
      mov rax, QWORD PTR [rdi]
      lea r8, QWORD PTR p$[rsp]
      lea rdx, QWORD PTR $T5[rsp]
      mov rcx, rdi
      call    QWORD PTR [rax]
      addss   xmm9, DWORD PTR [rax]
      addss   xmm8, DWORD PTR [rax+4]
      addss   xmm7, DWORD PTR [rax+8]
      addss   xmm6, DWORD PTR [rax+12]
      sub rbp, 1
      jne SHORT $LL4@main
    
    // Using function pointer: 600 ms
    $LL7@main:
      lea rdx, QWORD PTR p$[rsp]
      lea rcx, QWORD PTR $T4[rsp]
      call    rbx
      addps   xmm6, XMMWORD PTR [rax]
      sub rsi, 1
      jne SHORT $LL7@main
    

(Насколько я могу судить, в архитектуре AMD Zen 2 инструкции addss и addps имеют задержку в 3 цикла, и до двух таких инструкций могут выполняться одновременно.)

Случай, который меня озадачивает, - это использование виртуального метода и явной реализации цикла operator+=:

Почему он в 3,5–4 раза быстрее трех других вариантов?

Какие архитектурные эффекты здесь задействованы? Меньше зависимостей между регистрами при последующих итерациях цикла? Или какое-то невезение с кешированием?


Полный исходный код:

#include <Windows.h>
#include <cstdint>
#include <cstdio>
#include <memory>
#include <xmmintrin.h>

struct __declspec(align(16)) Vec2f
{
    float v[2];
};

struct __declspec(align(16)) Vec4f
{
    float v[4];
};

Vec4f& operator+=(Vec4f& lhs, const Vec4f& rhs) noexcept
{
#if 0
    _mm_store_ps(lhs.v, _mm_add_ps(_mm_load_ps(lhs.v), _mm_load_ps(rhs.v)));
#else
    for (std::uint32_t i = 0; i < 4; ++i)
        lhs.v[i] += rhs.v[i];
#endif
    return lhs;
}

std::uint64_t get_timer_freq()
{
    LARGE_INTEGER frequency;
    QueryPerformanceFrequency(&frequency);
    return static_cast<std::uint64_t>(frequency.QuadPart);
}

std::uint64_t read_timer()
{
    LARGE_INTEGER count;
    QueryPerformanceCounter(&count);
    return static_cast<std::uint64_t>(count.QuadPart);
}

struct Foo
{
    __declspec(noinline) virtual Vec4f eval(const Vec2f& p) const noexcept
    {
        return { p.v[0], p.v[1], p.v[0], p.v[1] };
    }
};

using SampleFn = Vec4f (*)(const Vec2f&);

__declspec(noinline) Vec4f eval_fn_impl(const Vec2f& p) noexcept
{
    return { p.v[0], p.v[1], p.v[0], p.v[1] };
}

__declspec(noinline) SampleFn make_eval_fn()
{
    return &eval_fn_impl;
}

int main()
{
    static constexpr std::uint64_t N = 100'000'000ull;

    const auto timer_freq = get_timer_freq();
    const Vec2f p{};
    Vec4f acc{};

    {
        const auto foo = std::make_shared<Foo>();
        const auto start_time = read_timer();
        for (std::uint64_t i = 0; i < N; ++i)
            acc += foo->eval(p);
        std::printf("foo->eval: %llu ms\n", 1000 * (read_timer() - start_time) / timer_freq);
    }

    {
        const auto eval_fn = make_eval_fn();
        const auto start_time = read_timer();
        for (std::uint64_t i = 0; i < N; ++i)
            acc += eval_fn(p);
        std::printf("eval_fn: %llu ms\n", 1000 * (read_timer() - start_time) / timer_freq);
    }

    return acc.v[0] + acc.v[1] + acc.v[2] + acc.v[3] > 0.0f ? 1 : 0;
}

person François Beaune    schedule 02.03.2021    source источник
comment
Можете ли вы использовать лучшее соглашение о вызовах, например __vectorcall, которое передает / возвращает __m128 в регистрах XMM? (Если это даже поможет, если Vec4f не обрабатывается так же, как __m128). Интересно, является ли пересылка хранилища проблемой, если вызываемый объект выполняет скалярные хранилища. (обновление, это то, что предлагает Гарольд).   -  person Peter Cordes    schedule 02.03.2021


Ответы (1)


Я тестирую это на процессоре Intel Haswell, но результаты производительности аналогичны, и я предполагаю, что причина также аналогична, но относитесь к этому с недоверием. Конечно, между Haswell и Zen 2 есть различия, но, насколько мне известно, эффект, который я виню в этом, должен относиться к ним обоим.

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

В качестве эксперимента измените код в виртуальном методе, чтобы использовать хранилище векторов. Например:

__declspec(noinline) virtual Vec4f eval(const Vec2f& p) const noexcept
{
    Vec4f r;
    auto pv = _mm_load_ps(p.v);
    _mm_store_ps(r.v, _mm_shuffle_ps(pv, pv, _MM_SHUFFLE(1, 0, 1, 0)));
    return r;
}

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

Загрузка 16 байтов из 8 байтов Vec2f не вполне законна, и при необходимости ее можно обойти. Имея только SSE (1), это немного раздражает, SSE3 был бы хорош для _mm_loaddup_pd (он же movddup).

Этой проблемы не существовало бы, если бы MSVC возвращал результат Vec4f через регистр, а не через выходной указатель, но я не знаю, как убедить его сделать это, кроме изменения типа возвращаемого значения на __m128. __vectorcall также помогает, но заставляет MSVC возвращать структуру в нескольких регистрах, которые затем повторно объединяются в вызывающей программе с дополнительным перемешиванием. Это немного беспорядочно и медленнее, чем любой из быстрых вариантов, но все же быстрее, чем версия с ошибкой переадресации магазина.

person harold    schedule 02.03.2021
comment
Для справки re: current uarches: Могут ли современные реализации x86 выполнять переадресацию из более чем одного предыдущего хранилища? - очевидно, что Atom в порядке сделать это, но ничто текущее не может. (т.е. ничего с OoO exec). - person Peter Cordes; 02.03.2021
comment
64-битный код может предполагать SSE2, где загрузка 64-битного XMM может выполняться с помощью movsd. Некоторые раздражающие кастинги с внутренними особенностями, но выполнимые. (Или, по крайней мере, я так думал. Я думал, что встроенные функции должны сохранять псевдонимы, но, к сожалению, реализация _mm_load_sd( (const double*)ptr) GCC в emmintrin.h просто отменяет этот указатель без использования may_alias typedef, чтобы удвоить способ, которым это делают некоторые другие встроенные функции загрузки / сохранения. ) - person Peter Cordes; 02.03.2021
comment
@PeterCordes и SSE (1) имеют movlps, который я хотел использовать изначально, но внутреннее свойство странно с его аргументом __m64* .. Я не думаю, что это важный аспект вопроса, хотя - person harold; 02.03.2021
comment
Верно, но SSE1 movlps - худший выбор в asm, потому что вам нужно / вы хотите сломать ложную зависимость от регистра назначения. В отличие от SSE2 movsd / movq, которые представляют собой чистые нагрузки с нулевым расширением, поэтому единственная проблема - это исходный уровень C ++ и вырвать компилятор для отправки, поэтому безопасно генерировать только эту инструкцию. (Внутренние функции Intel - довольно мусор для узких загрузок / хранилищ; они должны были быть void* с четко определенной семантикой, безопасной для псевдонимов, с самого начала: /) - person Peter Cordes; 02.03.2021
comment
Спасибо @PeterCordes и @harold за отличные идеи. Ошибка переадресации магазина выглядит как виноватая здесь. Мне удалось устранить снижение производительности, используя __vectorcall (или /Gv) или используя векторные инструкции для хранения возвращаемых значений из виртуального метода / функции. - person François Beaune; 02.03.2021