c++ - populares - Diferencia en el rendimiento entre MSVC y GCC para código de multplicación de matriz altamente optimizado
hastags (3)
Veo una gran diferencia en el rendimiento entre el código compilado en MSVC (en Windows) y GCC (en Linux) para un sistema Ivy Bridge. El código hace una multiplicación de matriz densa. Recibo el 70% de los fracasos máximos con GCC y solo el 50% con MSVC. Creo que pude haber aislado la diferencia de cómo ambos convierten los siguientes tres intrínsecos.
__m256 breg0 = _mm256_loadu_ps(&b[8*i])
_mm256_add_ps(_mm256_mul_ps(arge0,breg0), tmp0)
GCC hace esto
vmovups ymm9, YMMWORD PTR [rax-256]
vmulps ymm9, ymm0, ymm9
vaddps ymm8, ymm8, ymm9
MSVC hace esto
vmulps ymm1, ymm2, YMMWORD PTR [rax-256]
vaddps ymm3, ymm1, ymm3
¿Podría alguien explicarme por favor y por qué estas dos soluciones podrían marcar una gran diferencia en el rendimiento?
A pesar de que MSVC usa una instrucción menos, relaciona la carga con la multitensión y tal vez eso la hace más dependiente (¿tal vez la carga no se puede hacer fuera de servicio? Quiero decir que Ivy Bridge puede hacer una carga AVX, un AVX mult y un AVX agregar en un ciclo de reloj, pero esto requiere que cada operación sea independiente.
Tal vez el problema está en otra parte? Puede ver el código de ensamblaje completo para GCC y MSVC para el ciclo interno más abajo. Puede ver el código de C ++ para el bucle aquí Despliegue de bucle para lograr el máximo rendimiento con Ivy Bridge y Haswell
g ++ -S-masm = intel matrix.cpp -O3 -mavx -fopenmp
.L4:
vbroadcastss ymm0, DWORD PTR [rcx+rdx*4]
add rdx, 1
add rax, 256
vmovups ymm9, YMMWORD PTR [rax-256]
vmulps ymm9, ymm0, ymm9
vaddps ymm8, ymm8, ymm9
vmovups ymm9, YMMWORD PTR [rax-224]
vmulps ymm9, ymm0, ymm9
vaddps ymm7, ymm7, ymm9
vmovups ymm9, YMMWORD PTR [rax-192]
vmulps ymm9, ymm0, ymm9
vaddps ymm6, ymm6, ymm9
vmovups ymm9, YMMWORD PTR [rax-160]
vmulps ymm9, ymm0, ymm9
vaddps ymm5, ymm5, ymm9
vmovups ymm9, YMMWORD PTR [rax-128]
vmulps ymm9, ymm0, ymm9
vaddps ymm4, ymm4, ymm9
vmovups ymm9, YMMWORD PTR [rax-96]
vmulps ymm9, ymm0, ymm9
vaddps ymm3, ymm3, ymm9
vmovups ymm9, YMMWORD PTR [rax-64]
vmulps ymm9, ymm0, ymm9
vaddps ymm2, ymm2, ymm9
vmovups ymm9, YMMWORD PTR [rax-32]
cmp esi, edx
vmulps ymm0, ymm0, ymm9
vaddps ymm1, ymm1, ymm0
jg .L4
MSVC / FAc / O2 / openmp / arch: AVX ...
vbroadcastss ymm2, DWORD PTR [r10]
lea rax, QWORD PTR [rax+256]
lea r10, QWORD PTR [r10+4]
vmulps ymm1, ymm2, YMMWORD PTR [rax-320]
vaddps ymm3, ymm1, ymm3
vmulps ymm1, ymm2, YMMWORD PTR [rax-288]
vaddps ymm4, ymm1, ymm4
vmulps ymm1, ymm2, YMMWORD PTR [rax-256]
vaddps ymm5, ymm1, ymm5
vmulps ymm1, ymm2, YMMWORD PTR [rax-224]
vaddps ymm6, ymm1, ymm6
vmulps ymm1, ymm2, YMMWORD PTR [rax-192]
vaddps ymm7, ymm1, ymm7
vmulps ymm1, ymm2, YMMWORD PTR [rax-160]
vaddps ymm8, ymm1, ymm8
vmulps ymm1, ymm2, YMMWORD PTR [rax-128]
vaddps ymm9, ymm1, ymm9
vmulps ymm1, ymm2, YMMWORD PTR [rax-96]
vaddps ymm10, ymm1, ymm10
dec rdx
jne SHORT $LL3@AddDot4x4_
EDITAR:
Hago un benchmark del código claculando las operaciones de coma flotante total como 2.0*n^3
donde n es el ancho de la matriz cuadrada y dividiendo por el tiempo medido con omp_get_wtime()
. Repito el ciclo varias veces. En el resultado a continuación lo repetí 100 veces.
La salida de MSVC2012 en un turbo Intel Xeon E5 1620 (Ivy Bridge) para todos los núcleos es de 3.7 GHz
maximum GFLOPS = 236.8 = (8-wide SIMD) * (1 AVX mult + 1 AVX add) * (4 cores) * 3.7 GHz
n 64, 0.02 ms, GFLOPs 0.001, GFLOPs/s 23.88, error 0.000e+000, efficiency/core 40.34%, efficiency 10.08%, mem 0.05 MB
n 128, 0.05 ms, GFLOPs 0.004, GFLOPs/s 84.54, error 0.000e+000, efficiency/core 142.81%, efficiency 35.70%, mem 0.19 MB
n 192, 0.17 ms, GFLOPs 0.014, GFLOPs/s 85.45, error 0.000e+000, efficiency/core 144.34%, efficiency 36.09%, mem 0.42 MB
n 256, 0.29 ms, GFLOPs 0.034, GFLOPs/s 114.48, error 0.000e+000, efficiency/core 193.37%, efficiency 48.34%, mem 0.75 MB
n 320, 0.59 ms, GFLOPs 0.066, GFLOPs/s 110.50, error 0.000e+000, efficiency/core 186.66%, efficiency 46.67%, mem 1.17 MB
n 384, 1.39 ms, GFLOPs 0.113, GFLOPs/s 81.39, error 0.000e+000, efficiency/core 137.48%, efficiency 34.37%, mem 1.69 MB
n 448, 3.27 ms, GFLOPs 0.180, GFLOPs/s 55.01, error 0.000e+000, efficiency/core 92.92%, efficiency 23.23%, mem 2.30 MB
n 512, 3.60 ms, GFLOPs 0.268, GFLOPs/s 74.63, error 0.000e+000, efficiency/core 126.07%, efficiency 31.52%, mem 3.00 MB
n 576, 3.93 ms, GFLOPs 0.382, GFLOPs/s 97.24, error 0.000e+000, efficiency/core 164.26%, efficiency 41.07%, mem 3.80 MB
n 640, 5.21 ms, GFLOPs 0.524, GFLOPs/s 100.60, error 0.000e+000, efficiency/core 169.93%, efficiency 42.48%, mem 4.69 MB
n 704, 6.73 ms, GFLOPs 0.698, GFLOPs/s 103.63, error 0.000e+000, efficiency/core 175.04%, efficiency 43.76%, mem 5.67 MB
n 768, 8.55 ms, GFLOPs 0.906, GFLOPs/s 105.95, error 0.000e+000, efficiency/core 178.98%, efficiency 44.74%, mem 6.75 MB
n 832, 10.89 ms, GFLOPs 1.152, GFLOPs/s 105.76, error 0.000e+000, efficiency/core 178.65%, efficiency 44.66%, mem 7.92 MB
n 896, 13.26 ms, GFLOPs 1.439, GFLOPs/s 108.48, error 0.000e+000, efficiency/core 183.25%, efficiency 45.81%, mem 9.19 MB
n 960, 16.36 ms, GFLOPs 1.769, GFLOPs/s 108.16, error 0.000e+000, efficiency/core 182.70%, efficiency 45.67%, mem 10.55 MB
n 1024, 17.74 ms, GFLOPs 2.147, GFLOPs/s 121.05, error 0.000e+000, efficiency/core 204.47%, efficiency 51.12%, mem 12.00 MB
Como hemos cubierto el problema de alineación, supongo que es esto: http://en.wikipedia.org/wiki/Out-of-order_execution
Dado que g ++ emite una instrucción de carga independiente, el procesador puede reordenar las instrucciones para precargar los siguientes datos que se necesitarán al mismo tiempo que se agregan y se multiplican. MSVC arrojando un puntero a mul hace que la carga y mul estén vinculados a la misma instrucción, por lo que cambiar el orden de ejecución de las instrucciones no ayuda en nada.
EDITAR: los servidores de Intel con todos los documentos están menos enojados hoy, así que aquí hay más investigación sobre por qué la ejecución fuera de servicio es (parte de) la respuesta.
En primer lugar, parece que su comentario es completamente correcto sobre la posibilidad de que la versión MSVC de la instrucción de multiplicación decodifique para separar μ-ops que pueden ser optimizados por un motor fuera de servicio de la CPU. La parte divertida aquí es que los secuenciadores de microcódigo modernos son programables, por lo que el comportamiento real depende tanto del hardware como del firmware. Las diferencias en el ensamblaje generado parecen ser de GCC y MSVC cada uno tratando de luchar contra diferentes posibles cuellos de botella. La versión de GCC trata de dar libertad de acción al motor fuera de servicio (como ya hemos cubierto). Sin embargo, la versión MSVC termina aprovechando una característica llamada "fusión de micro-op". Esto se debe a las limitaciones de retiro μ-op. El final de la tubería solo puede retirar 3 μ-ops por tick. La fusión de Micro-op, en casos específicos, toma dos μ-ops que deben realizarse en dos unidades de ejecución diferentes (es decir, lectura de memoria y aritmética) y las vincula a un solo μ-op para la mayoría de la tubería. El μ-op fusionado solo se divide en dos operaciones μ reales justo antes de la asignación de la unidad de ejecución. Después de la ejecución, las operaciones se fusionan nuevamente, lo que les permite retirarse como una sola.
El motor fuera de servicio solo ve el μ-op fusionado, por lo que no puede alejar la carga de la multiplicación. Esto hace que la tubería se cuelgue mientras espera que el próximo operando finalice su recorrido en el bus.
TODOS LOS ENLACES !!!: http://download-software.intel.com/sites/default/files/managed/71/2e/319433-017.pdf
http://www.agner.org/optimize/microarchitecture.pdf
http://www.agner.org/optimize/optimizing_assembly.pdf
http://www.agner.org/optimize/instruction_tables.ods (NOTA: Excel se queja de que esta hoja de cálculo está parcialmente corrupta o es incompleta, por lo que se abre bajo su propio riesgo. No parece ser maliciosa, sin embargo, y de acuerdo para el resto de mi investigación, Agner Fog es asombroso. Después de que opté por el paso de recuperación de Excel, encontré que estaba lleno de toneladas de datos geniales)
http://www.syncfusion.com/Content/downloads/ebook/Assembly_Language_Succinctly.pdf
EDICION MUCHO MÁS TARDE: Wow, ha habido una actualización interesante de la discusión aquí. Supongo que estaba equivocado acerca de qué parte de la tubería se ve realmente afectada por la fusión de microoperación. Tal vez haya más ganancia de perf que la esperada a partir de las diferencias en la verificación de la condición del ciclo, donde las instrucciones no fusionadas permiten a GCC intercalar la comparación y saltar con la última carga vectorial y los pasos aritméticos?
vmovups ymm9, YMMWORD PTR [rax-32]
cmp esi, edx
vmulps ymm0, ymm0, ymm9
vaddps ymm1, ymm1, ymm0
jg .L4
MSVC hizo exactamente lo que le pediste. Si desea que se vmovups
una instrucción vmovups
, use el _mm256_loadu_ps
intrínseco.
Puedo confirmar que el uso del código GCC en Visual Studio sí mejora el rendimiento. Hice esto convirtiendo el archivo de objeto GCC en Linux para trabajar en Visual Studio . La eficiencia pasó del 50% al 60% utilizando los cuatro núcleos (y del 60% al 70% para un solo núcleo).
Microsoft ha eliminado el ensamblado en línea del código de 64 bits y también ha roto su desensamblador de 64 bits para que el código no se pueda reproducir sin modificaciones ( pero la versión de 32 bits aún funciona ). Evidentemente, pensaron que los intrínsecos serían suficientes, pero como este caso muestra que están equivocados.
¿Tal vez las instrucciones fusionadas deberían ser intrínsecas por separado?
Pero Microsoft no es el único que produce código intrínseco menos óptimo. Si pones el siguiente código en gcc.godbolt.org , puedes ver lo que hacen Clang, ICC y GCC. ICC dio un rendimiento incluso peor que MSVC. Está usando vinsertf128
pero no sé por qué. No estoy seguro de qué está haciendo Clang pero parece estar más cerca de GCC simplemente en un orden diferente (y más código).
Esto explica por qué Agner Fog escribió en su manual " Optimización de subrutinas en lenguaje ensamblador " en lo que respecta a "desventajas de usar funciones intrínsecas":
El compilador puede modificar el código o implementarlo de una manera menos eficiente de lo que pretendía el programador. Puede ser necesario mirar el código generado por el compilador para ver si está optimizado de la manera prevista por el programador.
Esto es decepcionante para el caso del uso de intrínsecos. Esto significa que uno tiene que escribir códigos de ensamblaje de 64 bits a lo largo del tiempo o encontrar un compilador que implemente los intrínsecos de la forma en que lo hizo el programador. En este caso, solo GCC parece hacer eso (y tal vez Clang).
#include <immintrin.h>
extern "C" void AddDot4x4_vec_block_8wide(const int n, const float *a, const float *b, float *c, const int stridea, const int strideb, const int stridec) {
const int vec_size = 8;
__m256 tmp0, tmp1, tmp2, tmp3, tmp4, tmp5, tmp6, tmp7;
tmp0 = _mm256_loadu_ps(&c[0*vec_size]);
tmp1 = _mm256_loadu_ps(&c[1*vec_size]);
tmp2 = _mm256_loadu_ps(&c[2*vec_size]);
tmp3 = _mm256_loadu_ps(&c[3*vec_size]);
tmp4 = _mm256_loadu_ps(&c[4*vec_size]);
tmp5 = _mm256_loadu_ps(&c[5*vec_size]);
tmp6 = _mm256_loadu_ps(&c[6*vec_size]);
tmp7 = _mm256_loadu_ps(&c[7*vec_size]);
for(int i=0; i<n; i++) {
__m256 areg0 = _mm256_set1_ps(a[i]);
__m256 breg0 = _mm256_loadu_ps(&b[vec_size*(8*i + 0)]);
tmp0 = _mm256_add_ps(_mm256_mul_ps(areg0,breg0), tmp0);
__m256 breg1 = _mm256_loadu_ps(&b[vec_size*(8*i + 1)]);
tmp1 = _mm256_add_ps(_mm256_mul_ps(areg0,breg1), tmp1);
__m256 breg2 = _mm256_loadu_ps(&b[vec_size*(8*i + 2)]);
tmp2 = _mm256_add_ps(_mm256_mul_ps(areg0,breg2), tmp2);
__m256 breg3 = _mm256_loadu_ps(&b[vec_size*(8*i + 3)]);
tmp3 = _mm256_add_ps(_mm256_mul_ps(areg0,breg3), tmp3);
__m256 breg4 = _mm256_loadu_ps(&b[vec_size*(8*i + 4)]);
tmp4 = _mm256_add_ps(_mm256_mul_ps(areg0,breg4), tmp4);
__m256 breg5 = _mm256_loadu_ps(&b[vec_size*(8*i + 5)]);
tmp5 = _mm256_add_ps(_mm256_mul_ps(areg0,breg5), tmp5);
__m256 breg6 = _mm256_loadu_ps(&b[vec_size*(8*i + 6)]);
tmp6 = _mm256_add_ps(_mm256_mul_ps(areg0,breg6), tmp6);
__m256 breg7 = _mm256_loadu_ps(&b[vec_size*(8*i + 7)]);
tmp7 = _mm256_add_ps(_mm256_mul_ps(areg0,breg7), tmp7);
}
_mm256_storeu_ps(&c[0*vec_size], tmp0);
_mm256_storeu_ps(&c[1*vec_size], tmp1);
_mm256_storeu_ps(&c[2*vec_size], tmp2);
_mm256_storeu_ps(&c[3*vec_size], tmp3);
_mm256_storeu_ps(&c[4*vec_size], tmp4);
_mm256_storeu_ps(&c[5*vec_size], tmp5);
_mm256_storeu_ps(&c[6*vec_size], tmp6);
_mm256_storeu_ps(&c[7*vec_size], tmp7);
}