Увеличение количества промахов в кэше при векторизации кода

Я векторизовал точечное произведение между двумя векторами с SSE 4.2 и AVX 2, как вы можете видеть ниже. Код был скомпилирован с GCC 4.8.4 с флагом оптимизации -O2. Как и ожидалось, производительность улучшилась с обоими (и AVX 2 быстрее, чем SSE 4.2), но когда я профилировал код с PAPI, я узнал, что общее количество промахов (в основном L1 и L2) сильно увеличилось:

Без векторизации:

PAPI_L1_TCM: 784,112,091
PAPI_L2_TCM: 195,315,365
PAPI_L3_TCM: 79,362

С SSE 4.2:

PAPI_L1_TCM: 1,024,234,171
PAPI_L2_TCM: 311,541,918
PAPI_L3_TCM: 68,842

С AVX 2:

PAPI_L1_TCM: 2,719,959,741
PAPI_L2_TCM: 1,459,375,105
PAPI_L3_TCM: 108,140

Возможно, что-то не так с моим кодом или это нормальное поведение?

Код AVX 2:

double vec_dotProduct(const vec& vecs, const unsigned int& start_a, const unsigned int& start_b, const int& n) {
    double dot = 0;
    register int i = 0;
    const int loopBound = n-3;

    __m256d vsum, vecPi, vecCi, vecQCi;

    vsum = _mm256_set1_pd(0);

    double * const pA = vecs.x+start_a ;
    double * const pB = vecs.x+start_b ;

    for( ; i<loopBound ;i+=4){
        vecPi  = _mm256_loadu_pd(&(pA)[i]);
        vecCi  = _mm256_loadu_pd(&(pB)[i]);
        vecQCi = _mm256_mul_pd(vecPi,vecCi);
        vsum   = _mm256_add_pd(vsum,vecQCi);
    }

    vsum = _mm256_hadd_pd(vsum, vsum);

    dot = ((double*)&vsum)[0] + ((double*)&vsum)[2];

    for( ; i<n; i++)
        dot += pA[i] * pB[i];

    return dot;
}

Код SSE 4.2:

double vec_dotProduct(const vec& vecs, const unsigned int& start_a, const unsigned int& start_b, const int& n) {
    double dot = 0;
    register int i = 0;

    const int loopBound = n-1;

    __m128d vsum, vecPi, vecCi, vecQCi;

    vsum = _mm_set1_pd(0);

    double * const pA = vecs.x+start_a ;
    double * const pB = vecs.x+start_b ;

    for( ; i<loopBound ;i+=2){
        vecPi  = _mm_load_pd(&(pA)[i]);
        vecCi  = _mm_load_pd(&(pB)[i]);
        vecQCi = _mm_mul_pd(vecPi,vecCi);
        vsum   = _mm_add_pd(vsum,vecQCi);
    }

    vsum = _mm_hadd_pd(vsum, vsum);

    _mm_storeh_pd(&dot, vsum);

    for( ; i<n; i++)
        dot += pA[i] * pB[i];

    return dot;
}

Не-векторизованный код:

double dotProduct(const vec& vecs, const unsigned int& start_a, const unsigned int& start_b, const int& n) {
    double dot = 0;
    register int i = 0;

    for (i = 0; i < n; ++i)
    {
        dot += vecs.x[start_a+i] * vecs.x[start_b+i];
    }
    return dot;
}

Изменить: сборка не-векторизованного кода:

   0x000000000040f9e0 <+0>:     mov    (%rcx),%r8d
   0x000000000040f9e3 <+3>:     test   %r8d,%r8d
   0x000000000040f9e6 <+6>:     jle    0x40fa1d <dotProduct(vec const&, unsigned int const&, unsigned int const&, int const&)+61>
   0x000000000040f9e8 <+8>:     mov    (%rsi),%eax
   0x000000000040f9ea <+10>:    mov    (%rdi),%rcx
   0x000000000040f9ed <+13>:    mov    (%rdx),%edi
   0x000000000040f9ef <+15>:    vxorpd %xmm0,%xmm0,%xmm0
   0x000000000040f9f3 <+19>:    add    %eax,%r8d
   0x000000000040f9f6 <+22>:    sub    %eax,%edi
   0x000000000040f9f8 <+24>:    nopl   0x0(%rax,%rax,1)
   0x000000000040fa00 <+32>:    mov    %eax,%esi
   0x000000000040fa02 <+34>:    lea    (%rdi,%rax,1),%edx
   0x000000000040fa05 <+37>:    add    $0x1,%eax
   0x000000000040fa08 <+40>:    vmovsd (%rcx,%rsi,8),%xmm1
   0x000000000040fa0d <+45>:    cmp    %r8d,%eax
   0x000000000040fa10 <+48>:    vmulsd (%rcx,%rdx,8),%xmm1,%xmm1
   0x000000000040fa15 <+53>:    vaddsd %xmm1,%xmm0,%xmm0
   0x000000000040fa19 <+57>:    jne    0x40fa00 <dotProduct(vec const&, unsigned int const&, unsigned int const&, int const&)+32>
   0x000000000040fa1b <+59>:    repz retq 
   0x000000000040fa1d <+61>:    vxorpd %xmm0,%xmm0,%xmm0
   0x000000000040fa21 <+65>:    retq   

Edit2: Ниже вы можете найти сравнение промахов кеша L1 между векторизованным и не-векторизованным кодом для большего N (N на промах х-метки и L1 на метке y). В принципе, для большего N в векторизованной версии все еще больше пропусков, чем в не-вексеризованной версии.

введите описание изображения здесь

Ответы

Ответ 1

Ростислав прав, что компилятор авто-векторизации, и из документации GCC на -O2:

"- O2 Оптимизируйте еще больше. GCC выполняет почти все поддерживаемые оптимизации, которые не связаны с компрометацией космической скорости." (отсюда: https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html)

GCC с флагом -O2 пытается сгенерировать наиболее эффективный код без предпочтения размера или скорости кода.

Итак, с точки зрения циклов ЦП, автоинъектированный код -O2 потребует наименьшего количества ватт, но не будет самым быстрым или наименьшим кодом. Это лучший пример для кода, который работает на мобильных устройствах и в многопользовательских системах, и они, как правило, предпочитают использование С++. Если вы хотите абсолютную максимальную скорость, независимо от того, сколько ватт она использует, попробуйте -O3 или -Ofast, если ваша версия GCC поддерживает их, или пойдите с вашими оптимизированными вручную оптимизированными решениями.

Причиной этого, вероятно, является комбинация двух факторов.

Во-первых, более быстрый код генерирует больше запросов в память/кеш за тот же промежуток времени, что подчеркивает алгоритмы предсказания предварительной выборки. Кэш L1 не очень большой, обычно 1 МБ - 3 МБ, и используется для всех запущенных процессов на этом ядре ЦП, поэтому ядро ​​ЦП не может выполнить предварительную выборку до тех пор, пока ранее не будет использован предварительно выделенный блок. Если код работает быстрее, тем меньше времени на предварительную выборку между блоками, и в коде, который эффективно работает с каналами, больше пропусков кэша будет выполняться до того, как процессорное ядро ​​остановится полностью, пока ожидающие выборки не будут завершены.

И во-вторых, современные операционные системы обычно делят однопоточные процессы между несколькими ядрами, динамически изменяя аффинность потоков, чтобы использовать дополнительный кеш на нескольких ядрах, хотя он не может запускать какой-либо из кода параллельно - например, заполните кеш-память ядра 0 вашими данными, а затем запустите их при заполнении кеша 1-го ядра, а затем запустите ядро ​​1, заправляя кеш-память ядра 0, округлите до завершения. Этот псевдо- parallelism улучшает общую скорость однопоточных процессов и должен значительно уменьшать промахи в кэше, но может быть выполнен только в особых обстоятельствах... конкретные обстоятельства, при которых хорошие компиляторы будут генерировать код, когда это возможно.

Ответ 2

Как вы можете видеть в некоторых комментариях, промахи в кеше исходят из повышения производительности.

Например, с недавними процессорами вы сможете выполнить 2 AVX2 add или mul в каждом цикле, чтобы 512 бит в каждом цикле. Время, затрачиваемое на загрузку данных, будет выше, так как потребуется несколько строк кэша.

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

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

Попробуйте разные размеры для N, вы должны увидеть интересные результаты. Кроме того, сначала выровняйте свои данные и убедитесь, что если вы используете 2 переменных, они не используют одну и ту же строку кэша.