Эффект выравнивания кода при синхронизации главных циклов в сборке

Допустим, у меня есть следующий основной цикл

.L2:
    vmulps          ymm1, ymm2, [rdi+rax]
    vaddps          ymm1, ymm1, [rsi+rax]
    vmovaps         [rdx+rax], ymm1
    add             rax, 32
    jne             .L2

То, как я бы это рассчитал, это поместить в еще один длинный цикл, как это

;align 32              
.L1:
    mov             rax, rcx
    neg             rax
align 32
.L2:
    vmulps          ymm1, ymm2, [rdi+rax]
    vaddps          ymm1, ymm1, [rsi+rax]
    vmovaps         [rdx+rax], ymm1
    add             rax, 32
    jne             .L2
    sub             r8d, 1                 ; r8 contains a large integer
    jnz             .L1

Я обнаружил, что выбранное мной выравнивание может значительно повлиять на время (до +-10%). Мне не понятно, как выбрать выравнивание кода. Есть три места, где я могу подумать, где я могу выровнять код

  1. При входе в функцию (см., Например, triad_fma_asm_repeat в коде ниже)
  2. В начале внешнего цикла (.L1 выше), который повторяет мой основной цикл
  3. В начале моего основного цикла (.L2 выше).

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

Я прочитал раздел 11.5 "Выравнивание кода" в руководстве по сборке Agner Fog для оптимизации, но мне все еще не ясно, как лучше настроить мой код для тестирования производительности. Он приводит пример синхронизации внутреннего цикла, который я на самом деле не выполняю.

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

Я хотел бы знать, есть ли интеллектуальный метод выбора выравнивания? Должен ли я выровнять внутреннюю и внешнюю петлю? Просто внутренний цикл? Вход в функцию тоже? Имеет ли значение использование коротких или длинных NOP?

В основном меня интересует Haswell, затем SNB/IVB, а затем Core2.


Я попробовал и NASM, и YASM и обнаружил, что это одна из областей, где они значительно различаются. NASM вставляет только однобайтовые инструкции NOP, где YASM вставляет многобайтовые NOP. Например, выровняв внутренний и внешний цикл выше 32 байтам, NASM вставил 20 инструкций NOP (0x90), где в качестве YASM вставил следующее (из objdump)

  2c:   66 66 66 66 66 66 2e    data16 data16 data16 data16 data16 nopw  %cs:0x0(%rax,%rax,1)
  33:   0f 1f 84 00 00 00 00 
  3a:   00 
  3b:   0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)

До сих пор я не заметил существенной разницы в производительности с этим. Похоже, что выравнивание не имеет значения, а длина инструкции. Но Агнер пишет в разделе выравнивания кода:

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


Если вы хотите поиграть с выравниванием и сами увидеть эффекты, приведенные ниже, вы можете найти как сборку, так и C-код, который я использую. замещать double frequency = 3.6 с эффективной частотой вашего процессора. Вы можете отключить турбо.

;nasm/yasm -f elf64 align_asm.asm`
global triad_fma_asm_repeat
;RDI x, RSI y, RDX z, RCX n, R8 repeat
;z[i] = y[i] + 3.14159*x[i]
pi: dd 3.14159

section .text
align 16
triad_fma_asm_repeat:

    shl             rcx, 2
    add             rdi, rcx
    add             rsi, rcx
    add             rdx, rcx
    vbroadcastss    ymm2, [rel pi]
    ;neg                rcx

;align 32
.L1:
    mov             rax, rcx
    neg             rax
align 32
.L2:
    vmulps          ymm1, ymm2, [rdi+rax]
    vaddps          ymm1, ymm1, [rsi+rax]
    vmovaps         [rdx+rax], ymm1
    add             rax, 32
    jne             .L2
    sub             r8d, 1
    jnz             .L1
    vzeroupper
    ret

global triad_fma_store_asm_repeat
;RDI x, RSI y, RDX z, RCX n, R8 repeat
;z[i] = y[i] + 3.14159*x[i]

align 16
    triad_fma_store_asm_repeat:
    shl             rcx, 2
    add             rcx, rdx
    sub             rdi, rdx
    sub             rsi, rdx
    vbroadcastss    ymm2, [rel pi]

;align 32
.L1:
    mov             r9, rdx
align 32
.L2:
    vmulps          ymm1, ymm2, [rdi+r9]
    vaddps          ymm1, ymm1, [rsi+r9]
    vmovaps         [r9], ymm1
    add             r9, 32
    cmp             r9, rcx
    jne             .L2
    sub             r8d, 1
    jnz             .L1
    vzeroupper
    ret

Вот код C, который я использую для вызова процедур сборки и определения времени

//gcc -std=gnu99 -O3        -mavx align.c -lgomp align_asm.o -o align_avx
//gcc -std=gnu99 -O3 -mfma -mavx2 align.c -lgomp align_asm.o -o align_fma
#include <stdio.h>
#include <string.h>
#include <omp.h>

float triad_fma_asm_repeat(float *x, float *y, float *z, const int n, int repeat);
float triad_fma_store_asm_repeat(float *x, float *y, float *z, const int n, int repeat);

float triad_fma_repeat(float *x, float *y, float *z, const int n, int repeat)
{
    float k = 3.14159f;
    int r;
    for(r=0; r<repeat; r++) {
        int i;
        __m256 k4 = _mm256_set1_ps(k);
        for(i=0; i<n; i+=8) {
            _mm256_store_ps(&z[i], _mm256_add_ps(_mm256_load_ps(&x[i]), _mm256_mul_ps(k4, _mm256_load_ps(&y[i]))));
        }
    }
}

int main (void )
{
    int bytes_per_cycle = 0;
    double frequency = 3.6;
    #if (defined(__FMA__))
    bytes_per_cycle = 96;
    #elif (defined(__AVX__))
    bytes_per_cycle = 48;
    #else
    bytes_per_cycle = 24;
    #endif
    double peak = frequency*bytes_per_cycle;

    const int n =2048;

    float* z2 = (float*)_mm_malloc(sizeof(float)*n, 64);
    char *mem = (char*)_mm_malloc(1<<18,4096);
    char *a = mem;
    char *b = a+n*sizeof(float);
    char *c = b+n*sizeof(float);

    float *x = (float*)a;
    float *y = (float*)b;
    float *z = (float*)c;

    for(int i=0; i<n; i++) {
        x[i] = 1.0f*i;
        y[i] = 1.0f*i;
        z[i] = 0;
    }
    int repeat = 1000000;    
    triad_fma_repeat(x,y,z2,n,repeat);   

    while(1) {
        double dtime, rate;

        memset(z, 0, n*sizeof(float));
        dtime = -omp_get_wtime();
        triad_fma_asm_repeat(x,y,z,n,repeat);
        dtime += omp_get_wtime();
        rate = 3.0*1E-9*sizeof(float)*n*repeat/dtime;
        printf("t1     rate %6.2f GB/s, efficency %6.2f%%, error %d\n", rate, 100*rate/peak, memcmp(z,z2, sizeof(float)*n));

        memset(z, 0, n*sizeof(float));
        dtime = -omp_get_wtime();
        triad_fma_store_asm_repeat(x,y,z,n,repeat);
        dtime += omp_get_wtime();
        rate = 3.0*1E-9*sizeof(float)*n*repeat/dtime;
        printf("t2     rate %6.2f GB/s, efficency %6.2f%%, error %d\n", rate, 100*rate/peak, memcmp(z,z2, sizeof(float)*n));

        puts("");
    }
}

Меня беспокоит следующее утверждение в руководстве NASM

Последнее предупреждение: ALIGN и ALIGNB работают относительно начала раздела, а не начала адресного пространства в конечном исполняемом файле. Например, выравнивание по 16-байтовой границе, когда секция, в которой вы находитесь, будет выровнено только по 4-байтовой границе, является пустой тратой усилий. Опять же, NASM не проверяет, являются ли характеристики выравнивания раздела подходящими для использования ALIGN или ALIGNB.

Я не уверен, что сегмент кода получает абсолютный 32-байтовый выровненный адрес или только относительный.

2 ответа

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

И ALIGN, и ALIGNB действительно вызывают макрос SECTALIGN неявно. Смотрите раздел 4.11.13 для деталей.

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

Предупреждение о ALIGN не проверка тогда, вероятно, применима только к более неясным случаям, например, при сборке в форматы, которые не поддерживают выравнивание раздела, при указании выравнивания, большего, чем поддерживается разделом, или когда SECTALIGN OFF был вызван для отключения SECTALIGN,

В идеале ваш цикл должен (примерно) выполняться за одну итерацию за такт, имея четыре операции перехода (add/jne - один). Критический вопрос - предсказуемость ветви внутреннего цикла. До 16 итераций это должно быть предсказано в временном коде, будучи всегда одинаковым, но после этого вы можете бороться. Во-первых, чтобы ответить на ваш вопрос, ключевые выравнивания для синхронизации должны гарантировать, что ни код после jne .L2, ни первая инструкция после.L2 не пересекают 32-байтовую границу. Я предполагаю, что реальный вопрос заключается в том, как заставить его работать быстрее, и, если мое предположение о> 16 итерациях верное, ключевой целью является сделать предсказание ветвлений эффективным. Сократить временные интервалы должно быть легко - достаточно иметь несколько предсказуемых ветвей. Однако ускорение выполнения финального кода зависит от того, как изменяются реальные значения rax, и это будет зависеть также от процедуры, вызывающей цикл.

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