Rendimiento de bucle de código C
performance intel (3)
Tengo un kernel multiply-add dentro de mi aplicación y quiero aumentar su rendimiento.
Uso un Intel Core i7-960 (reloj de 3.2 GHz) y ya he implementado manualmente el kernel usando los intrínsecos de SSE de la siguiente manera:
for(int i=0; i<iterations; i+=4) {
y1 = _mm_set_ss(output[i]);
y2 = _mm_set_ss(output[i+1]);
y3 = _mm_set_ss(output[i+2]);
y4 = _mm_set_ss(output[i+3]);
for(k=0; k<ksize; k++){
for(l=0; l<ksize; l++){
w = _mm_set_ss(weight[i+k+l]);
x1 = _mm_set_ss(input[i+k+l]);
y1 = _mm_add_ss(y1,_mm_mul_ss(w,x1));
…
x4 = _mm_set_ss(input[i+k+l+3]);
y4 = _mm_add_ss(y4,_mm_mul_ss(w,x4));
}
}
_mm_store_ss(&output[i],y1);
_mm_store_ss(&output[i+1],y2);
_mm_store_ss(&output[i+2],y3);
_mm_store_ss(&output[i+3],y4);
}
Sé que puedo usar vectores fp empaquetados para aumentar el rendimiento y ya lo hice con éxito, pero quiero saber por qué el código escalar único no puede cumplir con el rendimiento máximo del procesador.
El rendimiento de este kernel en mi máquina es de ~ 1.6 operaciones FP por ciclo, mientras que el máximo sería de 2 operaciones FP por ciclo (ya que FP add + FP mul se puede ejecutar en paralelo).
Si estoy en lo correcto al estudiar el código ensamblador generado, el programa ideal sería el siguiente, donde la instrucción mov
toma 3 ciclos, la latencia del conmutador desde el dominio de carga al dominio FP para las instrucciones dependientes toma 2 ciclos, la FP multiplicada toma 4 ciclos y el FP agrega 3 ciclos. (Tenga en cuenta que la dependencia de multiplicar -> agregar no incurre en ninguna latencia de conmutación porque las operaciones pertenecen al mismo dominio).
De acuerdo con el rendimiento medido (~ 80% del rendimiento teórico máximo) hay una sobrecarga de ~ 3 instrucciones por 8 ciclos.
Estoy tratando de:
- deshacerse de esta sobrecarga, o
- explicar de dónde viene
Por supuesto, existe el problema con las fallas de caché y la falta de alineación de datos que pueden aumentar la latencia de las instrucciones de movimiento, pero ¿hay algún otro factor que pueda desempeñar un papel aquí? ¿Te gusta registrar leer puestos o algo así?
Espero que mi problema esté claro, ¡gracias de antemano por sus respuestas!
Actualización: el ensamblaje del lazo interno se ve de la siguiente manera:
...
Block 21:
movssl (%rsi,%rdi,4), %xmm4
movssl (%rcx,%rdi,4), %xmm0
movssl 0x4(%rcx,%rdi,4), %xmm1
movssl 0x8(%rcx,%rdi,4), %xmm2
movssl 0xc(%rcx,%rdi,4), %xmm3
inc %rdi
mulss %xmm4, %xmm0
cmp $0x32, %rdi
mulss %xmm4, %xmm1
mulss %xmm4, %xmm2
mulss %xmm3, %xmm4
addss %xmm0, %xmm5
addss %xmm1, %xmm6
addss %xmm2, %xmm7
addss %xmm4, %xmm8
jl 0x401b52 <Block 21>
...
Haciendo esto una respuesta de mi comentario.
En una distribución de Linux que no es de servidor, creo que el temporizador de interrupción generalmente se establece en 250Hz por defecto, aunque eso varía según la distribución, casi siempre es superior a 150. Esa velocidad es necesaria para proporcionar una GUI interactiva de 30 + fps. Ese temporizador de interrupción se usa para adelantarse al código. Eso significa que más de 150 veces por segundo se interrumpe el código y se ejecuta el código del programador y decide a qué darle más tiempo. Parece que lo estás haciendo genial para obtener el 80% de la velocidad máxima, sin problemas allí. Si necesita una mejor instalación diga, Ubuntu Server (100Hz por defecto) y ajuste el kernel (preemption off) un poco
EDITAR: en un sistema central de 2 o más, esto tiene un impacto mucho menor, ya que su proceso será casi definitivamente abofeteado en un solo núcleo y le quedará más o menos para hacer lo suyo.
Muchas gracias por tus respuestas, esto explica mucho. Continuando con mi pregunta, cuando uso instrucciones empaquetadas en lugar de instrucciones escalares, el código que usa intrínsecos sería muy similar:
for(int i=0; i<size; i+=16) {
y1 = _mm_load_ps(output[i]);
…
y4 = _mm_load_ps(output[i+12]);
for(k=0; k<ksize; k++){
for(l=0; l<ksize; l++){
w = _mm_set_ps1(weight[i+k+l]);
x1 = _mm_load_ps(input[i+k+l]);
y1 = _mm_add_ps(y1,_mm_mul_ps(w,x1));
…
x4 = _mm_load_ps(input[i+k+l+12]);
y4 = _mm_add_ps(y4,_mm_mul_ps(w,x4));
}
}
_mm_store_ps(&output[i],y1);
…
_mm_store_ps(&output[i+12],y4);
}
El rendimiento medido de este kernel es de aproximadamente 5.6 operaciones FP por ciclo, aunque esperaría que fuera exactamente 4 veces el rendimiento de la versión escalar, es decir, 4.1.6 = 6,4 operaciones FP por ciclo.
Teniendo en cuenta el movimiento del factor de peso (gracias por señalar eso), el calendario se ve así:
Parece que el programa no cambia, aunque hay una instrucción adicional después de la operación movss
que mueve el valor de peso escalar al registro XMM y luego usa shufps
para copiar este valor escalar en todo el vector. Parece que el vector de ponderación está listo para ser utilizado por los mulps
en el tiempo teniendo en cuenta la latencia de conmutación de la carga al dominio de coma flotante, por lo que esto no debería generar ninguna latencia adicional.
Las movaps
(alineadas, movaps
empaquetadas), addps
y mulps
que se usan en este kernel (verificadas con código ensamblador) tienen la misma latencia y rendimiento que sus versiones escalares, por lo que esto tampoco debería generar latencia adicional.
¿Alguien tiene una idea de dónde se gasta este ciclo extra por cada 8 ciclos, suponiendo que el rendimiento máximo que este kernel puede obtener es de 6.4 FP ops por ciclo y se está ejecutando en 5.6 FP ops por ciclo?
¡Gracias de nuevo por toda su ayuda!
Noté en los comentarios que:
- El ciclo lleva 5 ciclos para ejecutarse.
- Se supone que debe tomar 4 ciclos. (dado que hay 4 adiciones y 4 mulitplies)
Sin embargo, su conjunto muestra 5 instrucciones movssl
SSE. De acuerdo con las tablas de Agner Fog, todas las instrucciones de movimiento SSE de punto flotante son al menos 1 rendimiento recíproco de inst / ciclo para Nehalem.
Como tiene 5 de ellos, no puede hacer mejor que 5 ciclos / iteración .
Entonces, para alcanzar el máximo rendimiento, debe reducir el número de cargas que tiene. Cómo puede hacer eso. No puedo ver de inmediato este caso en particular, pero podría ser posible.
Un enfoque común es usar tiling . Donde agrega niveles de anidación para mejorar la localidad. Aunque se usa principalmente para mejorar el acceso a la memoria caché, también se puede usar en registros para reducir el número de cargas / tiendas que se necesitan.
En definitiva, su objetivo es reducir el número de cargas para que sea menor que el número de add / muls. Entonces este podría ser el camino a seguir.