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