Производительность С++: std :: array vs std :: vector
4 MFnx [2019-02-05 23:52:00]
Добрый вечер.
Я знаю, что массивы в стиле 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. В обоих случаях версия массива работает почти вдвое быстрее, чем векторная версия.
Кто-нибудь знает почему? Спасибо!
c++ performance benchmarking stdvector stdarray
3 ответа
5 Xirema [2019-02-06 00:24:00]
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
.
2 Peter Cordes [2019-02-06 12:25:00]
C++ правила наложения имен не позволяют компилятору доказать, что glob[i] += stuff
не изменяет один из элементов const vec v1 {1.0,-1.0,1.0};
или v2
.
const
на std::vector
означает, что указатели "управляющего блока" могут быть предположительно не изменены после того, как они сконструированы, но память все еще распределяется динамически, и все, что знает компилятор, - это то, что он эффективно имеет const double *
в статическом хранилище.
Ничто в реализации std::vector
позволяет компилятору исключать какой-либо другой non-const
указатель, указывающий на это хранилище. Например, double *data
в блоке управления glob
.
C++ не предоставляет реализаторам библиотеки возможность предоставить компилятору информацию о том, что хранилище для разных std::vector
не перекрывается. Они не могут использовать __restrict
(даже на компиляторах, которые поддерживают это расширение), потому что это может сломать программы, которые принимают адрес векторного элемента. См. Документацию C99 для restrict
.
Но с const arr a1 {1.0,-1.0,1.0};
и a2
, сами двойники могут помещаться в статическое хранилище только для чтения, и компилятор это знает. Поэтому он может оценивать comb(a1[0],a2[0]);
и так далее во время компиляции. В ответе @Xirema вы можете увидеть константы .LC1
asm .LC1
и .LC2
. (Только две константы, потому что a1[0]+a2[0]
и a1[2]+a2[2]
равны 1.0+1.0
. Тело цикла использует xmm2
в качестве операнда источника для addsd
дважды, а другую константу - один раз.)
Но разве компилятор не может делать суммы однажды вне цикла во время выполнения?
Нет, опять же из-за потенциального алиасинга. Он не знает, что хранилища в glob[i+0..3]
не изменят содержимое v1[0..2]
, поэтому он перезагружается из v1 и v2 каждый раз через цикл после сохранения в glob
.
(Однако нет необходимости перезагружать указатели блока управления vector<>
, потому что строгие правила псевдонимов на основе типов позволяют предположить, что сохранение double
не изменяет double*
.)
Компилятор мог проверить, что glob.data() + 0.. N-3
не перекрывается ни с одним из v1/v1.data() + 0.. 2
, и сделать другую версию цикла для этого случая, вывод трех результатов comb()
из цикла.
Это полезная оптимизация, которую делают некоторые компиляторы при автоматической векторизации, если они не могут доказать отсутствие псевдонимов; в вашем случае это явно пропущенная оптимизация, поскольку gcc не проверяет перекрытие, потому что это заставит функцию работать намного быстрее. Но вопрос в том, может ли компилятор разумно догадаться, что стоило испускать asm, который проверяет во время выполнения на перекрытие и имеет 2 разные версии одного и того же цикла. При оптимизации по профилю он знал бы, что цикл горячий (работает много итераций), и на него стоит потратить дополнительное время. Но без этого компилятор, возможно, не захочет слишком рисковать вздутием кода.
ICC19 (Intel компилятор) фактически делает что - то подобное здесь, но это странно: если вы посмотрите на начало assemble_vec
(на компилятор исследователя Godbolt), это загрузить указатель данных от glob
, а затем добавляет 8 и снова вычитает указатель, производя константу 8
. Затем он разветвляется во время выполнения 8 > 784
(не -8 < 784
), а затем -8 < 784
(-8 < 784
). Похоже, это должна была быть проверка на перекрытие, но, возможно, он использовал один и тот же указатель дважды вместо v1 и v2? (784 = 8*100 - 16 = sizeof(double)*N - 16
)
Как бы то ни было, он заканчивает тем, что ..B2.19
цикл ..B2.19
который поднимает все 3 вычисления comb()
, и, что интересно, выполняет 2 итерации сразу с 4 скалярными нагрузками и сохраняет в glob[i+0..4]
, и 6 addsd
(скалярный двойной) добавить инструкции.
В другом месте в теле функции есть векторизованная версия, которая использует 3x addpd
(упакованный double), просто сохраняя/перезагружая 128-битные векторы, которые частично перекрываются. Это приведет к остановке пересылки в хранилище, но выполнение по порядку может скрыть это. Просто очень странно, что он выполняет ветвление во время выполнения вычисления, которое будет каждый раз давать один и тот же результат, и никогда не использует этот цикл. Пахнет как ошибка.
Если бы glob[]
был статическим массивом, у вас все равно была бы проблема. Потому что компилятор не может знать, что v1/v2.data()
не указывает на этот статический массив.
Я думал, что если вы получили к нему доступ через double *__restrict g = &glob[0];
, не было бы проблемы вообще. Это обещает компилятору, что g[i] +=...
не повлияет на значения, к которым вы обращаетесь через другие указатели, такие как v1[0]
.
На практике это не позволяет поднять comb()
для gcc, clang или ICC -O3
. Но это для MSVC. (Я читал, что MSVC не выполняет строгую оптимизацию псевдонимов на основе типов, но он не перезагружает glob.data()
внутри цикла, поэтому он каким-то образом выяснил, что хранение double не изменит указатель. Но MSVC делает определить поведение *(int*)my_float
для определения типов, в отличие от других реализаций C++.)
Для тестирования я положил это на Godbolt
//__attribute__((noinline))
void assemble_vec()
{
double *__restrict g = &glob[0]; // Helps MSVC, but not gcc/clang/ICC
// std::vector<double> &g = glob; // actually hurts ICC it seems?
// #define g glob // so use this as the alternative to __restrict
for (size_t i=0; i<N-2; ++i)
{
g[i] += comb(v1[0],v2[0]);
g[i+1] += comb(v1[1],v2[1]);
g[i+2] += comb(v1[2],v2[2]);
}
}
Мы получаем это от MSVC вне цикла
movsd xmm2, QWORD PTR [rcx] # v2[0]
movsd xmm3, QWORD PTR [rcx+8]
movsd xmm4, QWORD PTR [rcx+16]
addsd xmm2, QWORD PTR [rax] # += v1[0]
addsd xmm3, QWORD PTR [rax+8]
addsd xmm4, QWORD PTR [rax+16]
mov eax, 98 ; 00000062H
Тогда мы получим эффектно выглядящий цикл.
Так что это пропущенная оптимизация для gcc/clang/ICC.
0 Dmytro Dadyka [2019-02-06 00:16:00]
Я думаю, дело в том, что вы используете слишком маленький размер хранилища (шесть двойных), это позволяет компилятору в случае std::array
полностью исключать хранение в ОЗУ путем помещения значений в регистры. Компилятор может хранить переменные стека в регистрах, если это более оптимально. Это уменьшает доступ к памяти вдвое (остается только запись в glob
). В случае std::vector
компилятор не может выполнить такую оптимизацию, поскольку используется динамическая память. Попробуйте использовать значительно большие размеры для a1, a2, v1, v2