Я векторизовал скалярное произведение между 2 векторами с 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 на x-метке и промахов L1 на y-метке). В основном, для больших N еще больше промахов в векторизованной версии, чем в не векторизованной версии.
Ростислав прав, что компилятор выполняет автоматическую векторизацию, и из документации GCC по -O2:
«-O2 Оптимизируйте еще больше. GCC выполняет почти все поддерживаемые оптимизации, которые не требуют компромисса со скоростью пространства». (Отсюда: https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html)
GCC с флагом -O2 пытается создать наиболее эффективный код, не отдавая предпочтения ни размеру кода, ни скорости.
Таким образом, с точки зрения циклов ЦП, авто-векторизованный код -O2 потребует наименьшего количества ватт для запуска, но не будет самым быстрым или наименьшим кодом. Это лучший случай для кода, который выполняется на мобильных устройствах и в многопользовательских системах, и они, как правило, являются предпочтительным использованием C ++. Если вам нужна абсолютная максимальная скорость независимо от того, сколько ватт он использует, попробуйте -O3 или -Ofast, если ваша версия GCC поддерживает их, или используйте оптимизированные для рук более быстрые решения.
Причиной этого, вероятно, является сочетание двух факторов.
Во-первых, более быстрый код генерирует больше запросов к памяти / кэшу за то же время, что подчеркивает алгоритмы прогнозирования перед выборкой. Кэш L1 не очень большой, обычно 1–3 МБ, и используется всеми запущенными процессами на этом ядре ЦП, поэтому ядро ЦП не может выполнять предварительную выборку, пока ранее предварительно выбранный блок больше не используется. Если код работает быстрее, для предварительной выборки между блоками остается меньше времени, а в коде, эффективно транслирующем линии, будет происходить больше промахов кэша до полной остановки ядра ЦП до завершения ожидающих выборок.
И, во-вторых, современные операционные системы обычно разделяют однопоточные процессы между несколькими ядрами, динамически регулируя сродство потоков, чтобы использовать дополнительный кэш для нескольких ядер, даже если он не может выполнять какой-либо код параллельно — например, Заполните кэш ядра 0 своими данными, а затем запустите его, заполняя кэш ядра 1, затем запустите ядро 1, одновременно заполняя кэш ядра 0, циклически перебирая, пока не завершите. Этот псевдопараллельность улучшает общую скорость однопоточных процессов и должна значительно уменьшить количество кеш-ошибок, но может быть выполнена только в очень специфических обстоятельствах … особых обстоятельствах, для которых хорошие компиляторы будут генерировать код всякий раз, когда это возможно.
Как вы можете видеть в некоторых комментариях, ошибки в кеше происходят из-за увеличения производительности.
Например, с недавними процессорами вы сможете выполнять 2 AVX2 add или mul в каждом цикле, так что 512 бит в каждом цикле. Время, необходимое для загрузки данных, будет выше, так как для этого потребуется несколько строк кэша.
Кроме того, в зависимости от того, как настроена ваша система, гиперпоточности, аффинности и т. Д., Ваш планировщик может одновременно выполнять другие действия, загрязняя кэш-память другими потоками / процессами.
Последнее, что нужно. Процессоры теперь достаточно эффективны, чтобы распознавать простые шаблоны как шаблоны с очень маленькими циклами, а затем автоматически использовать предварительную выборку после нескольких итераций. В любом случае этого будет недостаточно для решения проблемы размера кэша.
Попробуйте разные размеры для N, вы должны увидеть интересные результаты.
Кроме того, сначала выровняйте данные и убедитесь, что если вы используете 2 переменные, они не разделяют одну и ту же строку кэша.