Производительность C ++: std :: array vs std :: vector - PullRequest
0 голосов
/ 05 февраля 2019

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

Я знаю, что массивы в стиле 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.В обоих случаях версия массива работает почти вдвое быстрее, чем векторная версия.

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

Ответы [ 3 ]

0 голосов
/ 06 февраля 2019

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 версии.

0 голосов
/ 06 февраля 2019

Правила псевдонимов C ++ не позволяют компилятору доказать, что glob[i] += stuff не изменяет один из элементов const vec v1 {1.0,-1.0,1.0}; или v2.

const onstd::vector означает, что указатели «управляющего блока» могут считаться не измененными после его создания, но память по-прежнему распределяется динамически, и все, что знает компилятор, - это то, что он фактически имеет const double * в статическом хранилище.

Ничто в реализации std::vector не позволяет компилятору исключать какой-либо другой non-const указатель, указывающий на это хранилище.Например, double *data в блоке управления glob.

C ++ не позволяет разработчикам библиотек предоставлять компилятору информацию о том, что хранилище для разных std::vector sне перекрывается. Они не могут использовать __restrict (даже на компиляторах, которые поддерживают это расширение), потому что это может нарушить работу программ, которые принимают адрес векторного элемента.См. документацию C99 для restrict.


Но с const arr a1 {1.0,-1.0,1.0}; и a2 сами двойники могут помещаться в статическое хранилище только для чтения, и компилятор знает,этот. Поэтому он может оценивать comb(a1[0],a2[0]); и т. Д. во время компиляции .В ответе @ Xirema вы можете видеть константы нагрузки 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 (занято).Похоже, это должна была быть проверка на перекрытие, но, возможно, использовался один и тот же указатель дважды вместо v1 и v2?(784 = 8*100 - 16 = sizeof(double)*N - 16)

В любом случае, он завершает выполнение цикла ..B2.19, который поднимает все вычисления 3 comb(), и, что интересно, выполняет 2 итерации сразу с 4 скалярными нагрузками и сохраняет в glob[i+0..4] и 6 addsd (скалярное двойное) добавить инструкции.

В другом месте в теле функции есть векторизованная версия, которая использует 3x addpd (упакованный двойной), просто сохраняя / перезагружая 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 голосов
/ 06 февраля 2019

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

...