Оптимизировать простую операцию трафарета, сохраняя переменные в регистрах - PullRequest
0 голосов
/ 23 февраля 2019

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

void stencil(double * input, double * output){

    unsigned int idx = 1;
    output[0] = input[0] + input[1];

    for(; idx < SIZE - 1; idx++){
        output[idx] = input[idx-1] + input[idx] + input[idx+1];
    }

    output[idx] = input[idx-1] + input[idx];
}

Моя реализация выглядит так:

void stencil(double * input, double * output){

    unsigned int idx = 0;
    double x , y = 0, z;
    z = input[idx];

    for(; idx < SIZE - 1; idx++){
        x = y;
        y = z;
        z = input[idx + 1];
        output[idx] = x + y + z;
    }

    output[idx] = y + z;
}

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

Однако программа, похоже, не улучшилась с точки зрения скорости и производительности.Я использую gcc на AMD Opteron(tm) Processor 6320 ЦП и компилирую код со следующими флагами: -march=native -O3 -Wall -std=c99.

Я пробовал с родной и без нее, сгенерированная сборка отличается, но я не могу получить лучшую производительность.Сгенерированная сборка БЕЗ -march=native флага выглядит так:

stencil:
.LFB7:
        .cfi_startproc
        subl    $1, %edx
        movsd   (%rdi), %xmm1
        je      .L4
        movq    %rsi, %rcx
        xorpd   %xmm0, %xmm0
        xorl    %eax, %eax
        jmp     .L3
        .p2align 4,,10
        .p2align 3
.L6:
        movapd  %xmm1, %xmm0
        movapd  %xmm2, %xmm1
.L3:
        addl    $1, %eax
        addsd   %xmm1, %xmm0
        addq    $8, %rcx
        movl    %eax, %r8d
        movsd   (%rdi,%r8,8), %xmm2
        leaq    0(,%r8,8), %r9
        addsd   %xmm2, %xmm0
        movsd   %xmm0, -8(%rcx)
        cmpl    %edx, %eax
        jne     .L6
.L2:
        addsd   %xmm2, %xmm1
        movsd   %xmm1, (%rsi,%r9)
        ret
.L4:
        movapd  %xmm1, %xmm2
        xorl    %r9d, %r9d
        xorpd   %xmm1, %xmm1
        jmp     .L2

И С флагом -march=native выглядит так :

stencil:
.LFB20:
        .cfi_startproc
        vmovsd  (%rdi), %xmm1
        vxorpd  %xmm0, %xmm0, %xmm0
        leaq    144(%rdi), %rdx
        leaq    136(%rsi), %rax
        xorl    %ecx, %ecx
        .p2align 4,,10
        .p2align 3
.L2:
        vaddsd  %xmm1, %xmm0, %xmm0
        vmovsd  -136(%rdx), %xmm4
        prefetcht0      (%rdx)
        addl    $8, %ecx
        prefetchw       (%rax)
        addq    $64, %rdx
        addq    $64, %rax
        vaddsd  %xmm1, %xmm4, %xmm1
        vaddsd  %xmm4, %xmm0, %xmm0
        vmovsd  %xmm0, -200(%rax)
        vmovsd  -192(%rdx), %xmm3
        vaddsd  %xmm3, %xmm1, %xmm1
        vaddsd  %xmm3, %xmm4, %xmm4
        vmovsd  %xmm1, -192(%rax)
        vmovsd  -184(%rdx), %xmm2
        vaddsd  %xmm2, %xmm4, %xmm4
        vaddsd  %xmm2, %xmm3, %xmm3
        vmovsd  %xmm4, -184(%rax)
        vmovsd  %xmm4, -184(%rax)
        vmovsd  -176(%rdx), %xmm0
        vaddsd  %xmm0, %xmm3, %xmm3
        vaddsd  %xmm0, %xmm2, %xmm2
        vmovsd  %xmm3, -176(%rax)
        vmovsd  -168(%rdx), %xmm1
        vaddsd  %xmm1, %xmm2, %xmm2
        vaddsd  %xmm1, %xmm0, %xmm0
        vmovsd  %xmm2, -168(%rax)
        vmovsd  -160(%rdx), %xmm2
        vaddsd  %xmm2, %xmm0, %xmm0
        vaddsd  %xmm2, %xmm1, %xmm1
        vmovsd  %xmm0, -160(%rax)
        vmovsd  -152(%rdx), %xmm0
        vaddsd  %xmm0, %xmm1, %xmm1
        vaddsd  %xmm0, %xmm2, %xmm2
        vmovsd  %xmm1, -152(%rax)
        vmovsd  -144(%rdx), %xmm1
        vaddsd  %xmm1, %xmm2, %xmm2
        vmovsd  %xmm2, -144(%rax)
        cmpl    $1399999992, %ecx
        jne     .L2
        movabsq $11199999944, %rdx
        movabsq $11199999936, %rcx
        addq    %rdi, %rdx
        addq    %rsi, %rcx
        xorl    %eax, %eax
        jmp     .L3
        .p2align 4,,7
        .p2align 3
.L4:
        vmovaps %xmm2, %xmm1
.L3:
        vaddsd  %xmm0, %xmm1, %xmm0
        vmovsd  (%rdx,%rax), %xmm2
        vaddsd  %xmm2, %xmm0, %xmm0
        vmovsd  %xmm0, (%rcx,%rax)
        addq    $8, %rax
        vmovaps %xmm1, %xmm0
        cmpq    $56, %rax
        jne     .L4
        vaddsd  %xmm2, %xmm1, %xmm1
        movabsq $11199999992, %rax
        vmovsd  %xmm1, (%rsi,%rax)
        ret

У кого-нибудь естьЛюбое предложение о том, как заставить GCC сохранять переменные в регистрах, чтобы сделать код быстрее?Или любой другой способ сделать мой код эффективным, обходя кеш?

Ответы [ 2 ]

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

Это хорошая идея, но компиляторы уже сделают это для вас, если они знают, что это безопасно. Используйте double *restrict output и const double *restrict input, чтобы обещать компилятору, который хранит output[], не делатьизмените то, что будет считываться из input[].

Но автоматическая векторизация с SIMD является еще более важной оптимизацией , производящей 2 или 4 double результатов на инструкцию.GCC и ICC уже сделают это в -O3 после проверки на совпадение.(Но clang не может автоматически векторизовать это, просто развернув его с помощью скаляра [v]addsd, избегая ненужных перезагрузок.

К сожалению, ваша оптимизированная версия побеждает авто-векторизацию! (Это ошибка компилятора, т.е.ошибка пропущенной оптимизации, когда она знает, что вывод не перекрывается, поэтому перечитывание источника из памяти эквивалентно).


Похоже, что gcc довольно хорошо справляется с оригинальной версией, с -O3 -march=native (особенно при настройке на Intel, где более широкие векторы с AVX того стоят.) Я вычисляю 4 double результатов параллельно с 3-мя не выровненными нагрузками и 2 vaddpd ymm.

. Проверяетперекрытие перед использованием векторизованного цикла. Вы можете использовать double *restrict output и input, чтобы пообещать, что указатели не перекрываются, поэтому не требуется резервный цикл.


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

Если векторизация с использованием 128-битных векторов имеет смысл сохранить вектор in[idx+1..2] для использования в качестве вектора in[idx+ -1..1] на следующей итерации.GCC фактически делает это.

Но когда вы создаете 4 результата на инструкцию, ни один из 3 входных векторов из одной итерации напрямую не пригодится для следующей.Хотя, возможно, было бы полезно сохранить некоторую полосу пропускания порта загрузки с помощью случайного перемешивания для создания одного из 3 векторов из результата загрузки.Я бы попробовал это, если бы я вручную векторизовал с __m256d встроенными функциями.Или с float с 128-битными __m128 векторами.


#define SIZE 1000000

void stencil_restrict(double *restrict input, double *restrict output)
{
    int idx = 1;
    output[0] = input[0] + input[1];

    for(; idx < SIZE - 1; idx++){
        output[idx] = input[idx-1] + input[idx] + input[idx+1];
    }

    output[idx] = input[idx-1] + input[idx];
}

компилируется в этот asm с gcc8.3 -O3 -Wall -std=c99 -march=broadwell -masm=intel, из проводника компилятора Godbolt (-ffast-math в этом случае не требуется и не имеет значения для внутреннего цикла.)

stencil_restrict:
    vmovsd  xmm0, QWORD PTR [rdi]
    vaddsd  xmm0, xmm0, QWORD PTR [rdi+8]
    xor     eax, eax
    vmovsd  QWORD PTR [rsi], xmm0           # first iteration

### Main loop
.L12:
    vmovupd ymm2, YMMWORD PTR [rdi+8+rax]         # idx +0 .. +3
    vaddpd  ymm0, ymm2, YMMWORD PTR [rdi+rax]     # idx -1 .. +2
    vaddpd  ymm0, ymm0, YMMWORD PTR [rdi+16+rax]  # idx +1 .. +4
    vmovupd YMMWORD PTR [rsi+8+rax], ymm0         # store idx +0 .. +3
    add     rax, 32                             # byte offset += 32
    cmp     rax, 7999968
    jne     .L12

  # cleanup of last few elements
    vmovsd  xmm1, QWORD PTR [rdi+7999976]
    vaddsd  xmm0, xmm1, QWORD PTR [rdi+7999968]
    vaddsd  xmm1, xmm1, QWORD PTR [rdi+7999984]
    vunpcklpd       xmm0, xmm0, xmm1
    vaddpd  xmm0, xmm0, XMMWORD PTR [rdi+7999984]
    vmovups XMMWORD PTR [rsi+7999976], xmm0
    vmovsd  xmm0, QWORD PTR [rdi+7999984]
    vaddsd  xmm0, xmm0, QWORD PTR [rdi+7999992]
    vmovsd  QWORD PTR [rsi+7999992], xmm0
    vzeroupper
    ret

К сожалению, gcc использует режимы индексированной адресации, поэтому инструкции vaddpd систочник памяти раскладывается в 2 мопа для внешнего интерфейса семейства SnB (включая ваш Broadwell Xeon E5-2698 v4). Режимы Micro Fusion и Addressing

    vmovupd ymm2, YMMWORD PTR [rdi+8+rax]         # 1 uop, no micro-fusion
    vaddpd  ymm0, ymm2, YMMWORD PTR [rdi+rax]     # 2 uops.  (micro-fused in decoders/uop cache, unlaminates)
    vaddpd  ymm0, ymm0, YMMWORD PTR [rdi+16+rax]  # 2 uops.  (ditto)
    vmovupd YMMWORD PTR [rsi+8+rax], ymm0         # 1 uop (stays micro-fused, but can't use the port 7 store AGU)
    add     rax, 32                             # 1 uop
    cmp     rax, 7999968                         # 0 uops, macro-fuses with JNE
    jne     .L12                                 # 1 uop

Анализ пропускной способности, см. https://agner.org/optimize/ и Какие соображения относятся к прогнозированию задержки для операций на современных суперскалярных процессорах икак я могу вычислить их вручную?

Цикл GCC - это 8 мопов в слитых доменах для этапа выдачи / переименования переднего плана для отправки в неработающий бэкэнд.Это означает, что максимальная пропускная способность внешнего интерфейса составляет 1 итерацию на 2 цикла.

[v]addpd в Intel до того, как Skylake сможет работать только на порту 1, в отличие от [v]mulpd или FMA с удвоенной пропускной способностью.(Skylake отбросил выделенный модуль добавления FP и запускает добавление FP одинаково для mul и fma.) Так что это также узкое место с 2 циклами на итерацию.

У нас есть 3 загрузки + 1 хранилище, каждый из которых нуждается в одном изпорт 2 или 3. (Хранилища в режиме индексированной адресации не могут использовать AGU хранилища на порте 7).Так что это еще один 2 цикла на узкое место итерации.Но не совсем; невыровненные загрузки, которые пересекают границы строк кэша, стоят дороже.Эксперименты показывают, что Intel Skylake (и, вероятно, также Broadwell) воспроизводит загруженные мопы, которые, как обнаружено, разделены на строки кэша, поэтому они снова запускаются для получения данных из второй строки кэша. Как можно точно оценить скорость невыровненного доступа на x86_64 .

Наши данные выровнены по 8 байтов, но мы делаем 32-байтовые нагрузки, равномерно распределенные по всем 8-байтовым смещениям в пределах 64-байтовой строки.В 5 из этих 8 начальных элементов разделение строки кэша отсутствует.На остальных 3 есть.Таким образом, средняя стоимость действительно составляет 3 * (8+3)/8 = 4.125 загрузок мопов за одну итерацию.Я не знаю, нужно ли переиграть адреса магазина.Возможно нет;это важно, когда данные фиксируются из буфера хранилища в L1d, что имеет значение, а не для адреса хранилища или данных хранилища.(До тех пор, пока он не разделен по границе 4К, что будет произойдет с неправильно выровненным выводом).

Предполагается, что выходное выравнивание любого, кроме output[1], выровнено по 32 байта.Asm хранит output[0] вне цикла, а затем фактически делает output[i*4 + 1], поэтому каждое другое хранилище будет разделено строкой кэша.

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

В любом случае фактическим узким местом GCC является пропускная способность порта 2 / порта 3. В среднем 5,125 мопов за итерацию для этих 2 портов = теоретическая максимальная средняя пропускная способность 1 итерации (4 двойных) за 2,5625 цикла .

Использование неиндексированного хранилища будет иметьуменьшило это узкое место.

Но при этом игнорируются штрафы за разделение 4k, что составляет ~ 100 циклов на Бродвелле, и предполагается идеальная предварительная выборка HW, которая может поддерживать ~ 12,5 байт / цикл в каждом направлении (загружается и сохраняется). Таким образом, более вероятно, что это будет узким местом в пропускной способности памяти, если данные не были уже горячими в кэше L2. L1d может поглощать избыточные нагрузки тех же байтов, но все еще существует значительная не избыточная пропускная способность.


Небольшая развертка позволила бы незапланированному исполнению видеть впереди себя и помогать поглощать пузыри из-за промахов кэша, когда предварительная выборка HW не идет в ногу.Если бы он использовал неиндексированный режим адресации для хранилища, он мог бы использовать порт 7, уменьшая нагрузку на порты 2/3.Это позволило бы нагрузкам опережать добавления, надеясь поглотить пузырьки при пересечении


Повторное использование данных в регистрах с 128-битными векторами

Внутренний цикл из gcc8.3 -O3 -Wall -std=c99 -march=broadwell -mno-avx

 # prologue to reach an alignment boundary somewhere?
.L12:
    movupd  xmm2, XMMWORD PTR [rdi+rax]
    movupd  xmm1, XMMWORD PTR [rdi+8+rax]
    addpd   xmm0, xmm2
    addpd   xmm0, xmm1
    movups  XMMWORD PTR [rsi+rax], xmm0
    add     rax, 16
    movapd  xmm0, xmm1                   # x = z
    cmp     rax, 7999992
    jne     .L12

Это регрессия против gcc7.4, которая позволяет избежать копирования регистра.(Но gcc7 теряет накладные расходы цикла на счетчике, отдельном от индекса массива.)

 # prologue to reach an alignment boundary so one load can be aligned.

# r10=input and r9=input+8  or something like that
# r8=output
.L18:                                       # do {
    movupd  xmm0, XMMWORD PTR [r10+rdx]
    add     ecx, 1
    addpd   xmm0, xmm1                        # x+y
    movapd  xmm1, XMMWORD PTR [r9+rdx]      # z for this iteration, x for next
    addpd   xmm0, xmm1                        # (x+y) + z
    movups  XMMWORD PTR [r8+rdx], xmm0
    add     rdx, 16
    cmp     ecx, r11d
    jb      .L18                            # } while(i < max);

Это, вероятно, все же медленнее, чем 256-битные векторы AVX, в среднем.

С AVX для 128-битные векторы (например, настройка на Piledriver), он мог бы избежать отдельной нагрузки movupd xmm0 и использовать vaddpd xmm0, xmm1, [r10+rdx].

Оба они не используют выровненные хранилища, но также не могут воспользоватьсяскладывание загрузки в операнд памяти на addpd после нахождения известного выравнивания в input: /


Фактические эксперименты с производительностью на Skylake показывают, что реальная производительность довольно близка к той, которую я предсказывал, если данныепомещается в кэш L1d.

Интересный факт: со статическими буферами, такими как глобальные double in[SIZE+10];, gcc создаст версию цикла, которая использует неиндексированные режимы адресации.Это дает ускорение от ~ 800 мс до ~ 700 мс для многократного запуска его в цикле с SIZE = 1000.Более подробная информация будет обновлена ​​позже.

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

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

Вот пример с развертыванием цикла уровня 4.

void stencil(double * input, double * output){

    double x, y, z, w, u, v ;
    x=0.0;
    y=input[0];
    int idx=0;
    for(; idx < SIZE - 5; idx+=4){
      z=input[idx+1];
      w=input[idx+2];
      u=input[idx+3];
      v=input[idx+4];

      output[idx]  =x+y+z;
      output[idx+1]=y+z+w;
      output[idx+2]=z+w+u;
      output[idx+3]=w+u+v;

      x=u;
      y=v;
    }
    z=input[idx+1];
    w=input[idx+2];
    u=input[idx+3];

    output[idx]  =x+y+z;
    output[idx+1]=y+z+w;
    output[idx+2]=z+w+u;
    output[idx+3]=w+u;
}

Существует одно чтение и запись в память по значению idx и 1 копия регистра на каждые два значения idx.

Можно попробовать разные уровни развёртывания, но всегда есть 2 копии регистра на одну итерацию, и 4 кажется хорошим компромиссом.

Если размер не кратен 4, требуется пролог.

void stencil(double * input, double * output){

    double x, y, z, w, u, v ;
    int idx=0;
    int remain=SIZE%4;

    x=0.0;y=input[0]
    switch (remain) {
    case 3: z=input[++idx]; output[idx-1]=x+y+z; x=y; y=z;
    case 2: z=input[++idx]; output[idx-1]=x+y+z; x=y; y=z;
    case 1: z=input[++idx]; output[idx-1]=x+y+z; x=y; y=z;
    }

    for(; idx < SIZE - 5; idx+=4){
      z=input[idx+1];
      ....

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

Вы также можете попробовать использовать -funroll-loops в исходном коде.Компиляторы очень хороши и могут обеспечить лучшее решение.

...