Производительность С++ std::array против std::vector

Добрый вечер.

Я знаю, что массивы в стиле C или std::array не быстрее векторов. Я постоянно использую векторы (и использую их хорошо). Однако у меня есть ситуация, в которой использование std::array работает лучше, чем с std::vector, и я понятия не имею, почему (проверено с clang 7.0 и gcc 8.2).

Позвольте мне поделиться простым кодом:

#include <vector>
#include <array>

// some size constant
const size_t N = 100;

// some vectors and arrays
using vec = std::vector<double>;
using arr = std::array<double,3>;
// arrays are constructed faster here due to known size, but it is irrelevant
const vec v1 {1.0,-1.0,1.0};
const vec v2 {1.0,2.0,1.0};
const arr a1 {1.0,-1.0,1.0};
const arr a2 {1.0,2.0,1.0};

// vector to store combinations of vectors or arrays
std::vector<double> glob(N,0.0);

Все идет нормально. Приведенный выше код, который инициализирует переменные, не включен в тест. Теперь давайте напишем функцию для объединения элементов (double) v1 и v2 или a1 и a2:

// some combination
auto comb(const double m, const double f)
{
  return m + f;
}

И эталонные функции:

void assemble_vec()
{
    for (size_t i=0; i<N-2; ++i)
    {
        glob[i] += comb(v1[0],v2[0]);
        glob[i+1] += comb(v1[1],v2[1]);
        glob[i+2] += comb(v1[2],v2[2]);
    }  
}

void assemble_arr()
{
    for (size_t i=0; i<N-2; ++i)
    {
        glob[i] += comb(a1[0],a2[0]);
        glob[i+1] += comb(a1[1],a2[1]);
        glob[i+2] += comb(a1[2],a2[2]);
    }  
}

Я пробовал это с clang 7.0 и gcc 8.2. В обоих случаях версия с массивом работает почти в два раза быстрее, чем версия с вектором.

Кто-нибудь знает, почему? Спасибо!

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

NathanOliver 05.02.2019 21:55

У вас включены какие-то флаги оптимизации? Одна из возможностей заключается в том, что компилятор за кулисами творит какую-то внутреннюю магию.

Gilad 05.02.2019 21:57

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

463035818_is_not_a_number 05.02.2019 21:57

Массивы и векторы решают разные задачи. Я не вижу смысла сравнивать их выступления таким образом.

François Andrieux 05.02.2019 21:58
Я знаю, что массивы в стиле C или std::array не быстрее векторов. Это не базовое предположение, с которого я бы начал как программист, поскольку память, выделенная стеком, обычно считается более быстрой, чем динамическая память.
Xirema 05.02.2019 22:01

@Xirema Я слышал, что динамическая память выделение медленнее по сравнению с «выделением» памяти стека. Но я не слышал, чтобы стековая память работала быстрее, чем динамически выделяемая память. Это кеш-хит?

François Andrieux 05.02.2019 22:11

@Xirema, возможно, ты прав!

mfnx 05.02.2019 22:22

@NathanOliver N=100 не имеет ничего общего с эталоном, это просто размер вектора, который содержит комбинации. Я отредактирую вопрос, чтобы было понятно, спасибо. Флаг оптимизации O3 (@Gilad). Я запустил тест на веб-странице быстрых тестов

mfnx 05.02.2019 22:25

Причина различия в том, что a1 и a2 являются константами. Компилятор вычисляет их сумму за время компиляции. К сожалению, gcc и clang недостаточно умны, чтобы вывести comb(v1[0], v2[0]) из цикла (даже если их data() помещается в указатель __restrict — это пропущенная оптимизация).

geza 05.02.2019 23:33

@geza Я не понимаю твоего комментария. Действительно, производительность такая же при удалении констант. Не могли бы вы опубликовать полный ответ? Спасибо!

mfnx 06.02.2019 00:05

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

phuclv 06.02.2019 00:48

@phuclv: ISO C++ требует проверки границ нет на std::vector::operator[] только для std::vector::at(). Псевдонимы на основе типов позволяют компилятору поднять уровень косвенности из цикла, загружая указатели только один раз перед циклом. (Поскольку можно предположить, что хранение double не изменяет членов double*glob.)

Peter Cordes 06.02.2019 05:22

@MFnx: компилятор мог бы выполнить ту же оптимизацию, если бы понял, что v1 и v2 также являются константами. Или он мог бы провести оптимизацию, если бы понял, что v1 и v2 не пересекаются с glob. Таким образом, он может предварительно вычислить их сумму перед циклом. Но оно этого не осознает. Если я попытаюсь помочь, то получение внутреннего указателя данных вектора в локальную переменную указателя __restrict (в основном __restrict сообщает компилятору, что указатель ни с чем не пересекается) тоже не помогает. Это недостающая оптимизация со стороны компиляторов.

geza 06.02.2019 08:26

@geza Хорошо, я понимаю! Значит, ответ Xirema не будет точным?

mfnx 06.02.2019 08:39

@MFnx: в нем отсутствует ключевая часть, поэтому две версии скомпилированы по-разному, а версия массива имеет суммирование a1 и a2 во время компиляции.

geza 06.02.2019 09:02
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
7
15
5 892
2

Ответы 2

Я думаю, дело в том, что вы используете слишком маленький размер хранилища (шесть двойников), это позволяет компилятору, в случае с std::array, полностью исключить хранение в ОЗУ путем размещения значений в регистрах. Компилятор может хранить стековые переменные в регистрах, если это более оптимально. Это уменьшает количество обращений к памяти вдвое (остается только запись на glob). В случае std::vector компилятор не может выполнить такую ​​оптимизацию, поскольку используется динамическая память. Попробуйте использовать значительно большие размеры для a1, a2, v1, v2.

GCC (и, возможно, Clang) оптимизируют массивы, но не векторы.

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

Ниже показано, что GCC выдает в виде сборки для функций assemble_vec и assemble_arr после включения оптимизации:

[-snip-]
//==============
//Vector Version
//==============
assemble_vec():
        mov     rax, QWORD PTR glob[rip]
        mov     rcx, QWORD PTR v2[rip]
        mov     rdx, QWORD PTR v1[rip]
        movsd   xmm1, QWORD PTR [rax+8]
        movsd   xmm0, QWORD PTR [rax]
        lea     rsi, [rax+784]
.L23:
        movsd   xmm2, QWORD PTR [rcx]
        addsd   xmm2, QWORD PTR [rdx]
        add     rax, 8
        addsd   xmm0, xmm2
        movsd   QWORD PTR [rax-8], xmm0
        movsd   xmm0, QWORD PTR [rcx+8]
        addsd   xmm0, QWORD PTR [rdx+8]
        addsd   xmm0, xmm1
        movsd   QWORD PTR [rax], xmm0
        movsd   xmm1, QWORD PTR [rcx+16]
        addsd   xmm1, QWORD PTR [rdx+16]
        addsd   xmm1, QWORD PTR [rax+8]
        movsd   QWORD PTR [rax+8], xmm1
        cmp     rax, rsi
        jne     .L23
        ret

//=============
//Array Version
//=============
assemble_arr():
        mov     rax, QWORD PTR glob[rip]
        movsd   xmm2, QWORD PTR .LC1[rip]
        movsd   xmm3, QWORD PTR .LC2[rip]
        movsd   xmm1, QWORD PTR [rax+8]
        movsd   xmm0, QWORD PTR [rax]
        lea     rdx, [rax+784]
.L26:
        addsd   xmm1, xmm3
        addsd   xmm0, xmm2
        add     rax, 8
        movsd   QWORD PTR [rax-8], xmm0
        movapd  xmm0, xmm1
        movsd   QWORD PTR [rax], xmm1
        movsd   xmm1, QWORD PTR [rax+8]
        addsd   xmm1, xmm2
        movsd   QWORD PTR [rax+8], xmm1
        cmp     rax, rdx
        jne     .L26
        ret
[-snip-]

Между этими секциями кода есть несколько различий, но критическая разница заключается в метках .L23 и .L26 соответственно, где для векторной версии числа складываются вместе с помощью менее эффективных кодов операций по сравнению с версией массива, которая использует (подробнее) инструкции SSE. Векторная версия также требует больше операций поиска в памяти по сравнению с версией массива. Эти факторы в сочетании друг с другом приведут к тому, что код для версии std::array будет выполняться быстрее, чем для версии std::vector.

Хороший ответ! Clang сделал версию с массивом примерно на 67% быстрее, gcc — на 100%.

mfnx 05.02.2019 22:30

Clang с -stdlib=libc++ (вместо обычного libstdc++) может оптимизировать alloc/free для std::vector, если у вас установлен libc++ (libcxx.llvm.org). Я думаю, что это включает случаи, когда окончательный результат не используется, и цикл по массиву/вектору также можно оптимизировать.

Peter Cordes 06.02.2019 05:14

Но в любом случае, asm, который вы здесь показываете, выглядит так, как будто версия vec не доказала, что glob и исходные массивы не перекрываются, поэтому на самом деле он перезагружает v1[0] и повторно выполняет добавление на случай, если хранилище на glob[i] изменило его.

Peter Cordes 06.02.2019 05:18

И, как прокомментировал вопрос @geza, да, компилятор оптимизирует a1[] и a2[] и загружает суммы из .LC1[rip]. Это ключевой момент. Версия array не использует «более эффективные коды операций», они оба чисто скалярные. (movapd - это то, как вы копируете регистр xmm, независимо от того, интересуетесь ли вы всем этим или только скалярным двойным или плавающим внизу. movsd xmm,xmm выполняет слияние с зависимостью от выходного регистра, поэтому компиляторы никогда не использовали бы это, кроме как перетасовка)

Peter Cordes 06.02.2019 05:26

@PeterCordes Это очень интересно! Я подтвердил то, что вы сказали, изменив функцию расчески на m + c*f, где c — переменная, которая изменяется для каждой итерации. Производительность обеих версий теперь равна.

mfnx 06.02.2019 08:49

Другие вопросы по теме