c++ - turbo - Mayor número de errores de caché al vectorizar el código
tipos de error en c++ (2)
Como puede ver en algunos comentarios, los errores de caché provienen del aumento de rendimiento.
Por ejemplo, con las CPU recientes, podrá ejecutar 2 AVX2 add o mul en cada ciclo, por lo que 512 bits en cada ciclo. El tiempo que necesitará para cargar datos será mayor, ya que requerirá varias líneas de caché.
Además, dependiendo de cómo esté configurado su sistema, hipervínculos, afinidades, etc., su programador puede hacer otras cosas al mismo tiempo contaminando su caché con otros subprocesos / procesos.
Una última cosa. Las CPU son bastante eficientes ahora para reconocer patrones simples como el que tiene con bucles muy pequeños y luego usarán la captación previa automáticamente después de algunas iteraciones. De todos modos, no será suficiente para solucionar el problema de tamaño de caché.
Prueba con diferentes tamaños para N, deberías ver resultados interesantes. Además, alinee sus datos al principio y asegúrese de que si utiliza 2 variables, no se comparte la misma línea de caché.
Vectoricé el producto de puntos entre 2 vectores con SSE 4.2 y AVX 2, como se puede ver a continuación. El código se compiló con GCC 4.8.4 con el indicador de optimización -O2. Como se esperaba, el rendimiento mejoró con ambos (y AVX 2 más rápido que con SSE 4.2), pero cuando perfilé el código con PAPI, descubrí que el número total de fallos (principalmente L1 y L2) aumentó mucho:
Sin vectorización:
PAPI_L1_TCM: 784,112,091
PAPI_L2_TCM: 195,315,365
PAPI_L3_TCM: 79,362
Con SSE 4.2:
PAPI_L1_TCM: 1,024,234,171
PAPI_L2_TCM: 311,541,918
PAPI_L3_TCM: 68,842
Con AVX 2:
PAPI_L1_TCM: 2,719,959,741
PAPI_L2_TCM: 1,459,375,105
PAPI_L3_TCM: 108,140
¿Podría haber algo mal con mi código o este tipo de comportamiento es normal?
Código 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;
}
Código 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;
}
Código no vectorizado:
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;
}
Edición: Ensamblaje del código no vectorizado:
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: A continuación puede encontrar la comparación de los errores de caché L1 entre el código vectorizado y el no vectorizado para las N más grandes (N en la etiqueta x y el caché L1 en la etiqueta y). Básicamente, para N más grandes aún hay más fallas en la versión vectorizada que en la versión no vectorizada.
Rostislav tiene razón al afirmar que el compilador se auto-vectoriza, y de la documentación de GCC en -O2:
"-O2 Optimice aún más. GCC realiza casi todas las optimizaciones compatibles que no implican una compensación de la velocidad espacial". (desde aquí: https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html )
GCC con el indicador -O2 está intentando generar el código más eficiente, sin favorecer ni el tamaño ni la velocidad del código.
Por lo tanto, en términos de ciclos de CPU, el código auto-vectorizado de -O2 requerirá la menor cantidad de vatios para ejecutarse, pero no será el código más rápido ni el más pequeño. Este es el mejor caso para el código que se ejecuta en dispositivos móviles y en sistemas multiusuario, y estos tienden a ser el uso preferido de C ++. Si desea una velocidad máxima absoluta, independientemente de la cantidad de vatios que use, pruebe -O3 o -Rápido si su versión de GCC los admite, o vaya con sus soluciones más rápidas optimizadas a mano.
La causa de esto es probablemente una combinación de dos factores.
Primero, el código más rápido genera más solicitudes a la memoria / caché en la misma cantidad de tiempo, lo que enfatiza los algoritmos de predicción de búsqueda previa. El caché L1 no es muy grande, generalmente de 1 MB a 3 MB, y se comparte entre todos los procesos que se ejecutan en ese CPU Core, por lo que el CPU Core no puede realizar una búsqueda previa hasta que el bloque previamente recuperado ya no esté en uso. Si el código se está ejecutando más rápido, hay menos tiempo para realizar una búsqueda previa entre los bloques, y en el código que indica que las tuberías son más efectivas, se ejecutarán más fallos de caché antes de que la CPU Core se detenga por completo hasta que se completen las recuperaciones pendientes.
Y en segundo lugar, los sistemas operativos modernos generalmente dividen los procesos de un solo hilo entre múltiples núcleos al ajustar la afinidad de subprocesos dinámicamente, con el fin de hacer uso de la memoria caché extra a través de múltiples núcleos, incluso aunque no pueda ejecutar ninguno de los códigos en paralelo, por ejemplo, rellene los núcleos 0 caché con sus datos y luego ejecútelo mientras llena el caché del núcleo 1, luego ejecute en el núcleo 1 mientras rellena el caché del núcleo 0, hasta que finalice. Este pseudo-paralelismo mejora la velocidad general de los procesos de un solo hilo y debería reducir en gran medida las fallas de caché, pero solo se puede hacer en circunstancias muy específicas ... circunstancias específicas para las cuales los buenos compiladores generarán código siempre que sea posible.