Vectorización del cálculo del producto escalar con SSE4

Estoy tratando de mejorar este código con el producto de puntos SSE4, pero me está costando encontrar una solución. Esta función obtiene los parámetros qi y tj que contienen matrices flotantes con 80 celdas cada una y luego calcula el producto escalar. El valor devuelto es un vector con cuatro productos de puntos. Así que lo que intento hacer es calcular cuatro productos escalares de veinte valores en paralelo.

¿Tienes alguna idea de cómo mejorar este código?

inline __m128 ScalarProd20Vec(__m128* qi, __m128* tj)
{
    __m128 res=_mm_add_ps(_mm_mul_ps(tj[0],qi[0]),_mm_mul_ps(tj[1],qi[1]));
    res=_mm_add_ps(res,_mm_add_ps(_mm_mul_ps(tj[2],qi[2]),_mm_mul_ps(tj[3],qi[3])));
    res=_mm_add_ps(res,_mm_add_ps(_mm_mul_ps(tj[4],qi[4]),_mm_mul_ps(tj[5],qi[5])));
    res=_mm_add_ps(res,_mm_add_ps(_mm_mul_ps(tj[6],qi[6]),_mm_mul_ps(tj[7],qi[7])));
    res=_mm_add_ps(res,_mm_add_ps(_mm_mul_ps(tj[8],qi[8]),_mm_mul_ps(tj[9],qi[9])));
    res=_mm_add_ps(res,_mm_add_ps(_mm_mul_ps(tj[10],qi[10]),_mm_mul_ps(tj[11],qi[11])));
    res=_mm_add_ps(res,_mm_add_ps(_mm_mul_ps(tj[12],qi[12]),_mm_mul_ps(tj[13],qi[13])));
    res=_mm_add_ps(res,_mm_add_ps(_mm_mul_ps(tj[14],qi[14]),_mm_mul_ps(tj[15],qi[15])));
    res=_mm_add_ps(res,_mm_add_ps(_mm_mul_ps(tj[16],qi[16]),_mm_mul_ps(tj[17],qi[17])));
    res=_mm_add_ps(res,_mm_add_ps(_mm_mul_ps(tj[18],qi[18]),_mm_mul_ps(tj[19],qi[19])));
    return res;
}

preguntado el 04 de julio de 12 a las 02:07

3 Respuestas

De los cientos de ejemplos de SSE que he visto en SO, su código es uno de los pocos que ya está en muy buena forma desde el principio. No necesita la instrucción de producto punto SSE4. (¡Puedes hacerlo mejor!)

Sin embargo, hay una cosa que puedes probar: (Digo intentarlo porque aún no lo he cronometrado).

Actualmente tiene una cadena de dependencia de datos en res. La adición de vectores es de 3 a 4 ciclos en la mayoría de las máquinas actuales. Por lo tanto, su código tardará un mínimo de 30 ciclos en ejecutarse ya que tiene:

(10 additions on critical path) * (3 cycles addps latency) = 30 cycles

Lo que puede hacer es dividir el nodo res variable de la siguiente manera:

__m128 res0 = _mm_add_ps(_mm_mul_ps(tj[ 0],qi[ 0]),_mm_mul_ps(tj[ 1],qi[ 1]));
__m128 res1 = _mm_add_ps(_mm_mul_ps(tj[ 2],qi[ 2]),_mm_mul_ps(tj[ 3],qi[ 3]));

res0 = _mm_add_ps(res0,_mm_add_ps(_mm_mul_ps(tj[ 4],qi[ 4]),_mm_mul_ps(tj[ 5],qi[ 5]))); 
res1 = _mm_add_ps(res1,_mm_add_ps(_mm_mul_ps(tj[ 6],qi[ 6]),_mm_mul_ps(tj[ 7],qi[ 7])));

res0 = _mm_add_ps(res0,_mm_add_ps(_mm_mul_ps(tj[ 8],qi[ 8]),_mm_mul_ps(tj[ 9],qi[ 9])));
res1 = _mm_add_ps(res1,_mm_add_ps(_mm_mul_ps(tj[10],qi[10]),_mm_mul_ps(tj[11],qi[11])));

res0 = _mm_add_ps(res0,_mm_add_ps(_mm_mul_ps(tj[12],qi[12]),_mm_mul_ps(tj[13],qi[13])));
res1 = _mm_add_ps(res1,_mm_add_ps(_mm_mul_ps(tj[14],qi[14]),_mm_mul_ps(tj[15],qi[15])));

res0 = _mm_add_ps(res0,_mm_add_ps(_mm_mul_ps(tj[16],qi[16]),_mm_mul_ps(tj[17],qi[17])));
res1 = _mm_add_ps(res1,_mm_add_ps(_mm_mul_ps(tj[18],qi[18]),_mm_mul_ps(tj[19],qi[19])));

return _mm_add_ps(res0,res1);

Esto casi corta su ruta crítica a la mitad. Tenga en cuenta que debido a la no asociatividad de punto flotante, esta optimización es ilegal para los compiladores.


Aquí hay una versión alternativa que utiliza la división de nodos en 4 direcciones y las instrucciones AMD FMA4. Si no puede usar las sumas fusionadas y multiplicadas, siéntase libre de dividirlas. Todavía podría ser mejor que la primera versión anterior.

__m128 res0 = _mm_mul_ps(tj[ 0],qi[ 0]);
__m128 res1 = _mm_mul_ps(tj[ 1],qi[ 1]);
__m128 res2 = _mm_mul_ps(tj[ 2],qi[ 2]);
__m128 res3 = _mm_mul_ps(tj[ 3],qi[ 3]);

res0 = _mm_macc_ps(tj[ 4],qi[ 4],res0);
res1 = _mm_macc_ps(tj[ 5],qi[ 5],res1);
res2 = _mm_macc_ps(tj[ 6],qi[ 6],res2);
res3 = _mm_macc_ps(tj[ 7],qi[ 7],res3);

res0 = _mm_macc_ps(tj[ 8],qi[ 8],res0);
res1 = _mm_macc_ps(tj[ 9],qi[ 9],res1);
res2 = _mm_macc_ps(tj[10],qi[10],res2);
res3 = _mm_macc_ps(tj[11],qi[11],res3);

res0 = _mm_macc_ps(tj[12],qi[12],res0);
res1 = _mm_macc_ps(tj[13],qi[13],res1);
res2 = _mm_macc_ps(tj[14],qi[14],res2);
res3 = _mm_macc_ps(tj[15],qi[15],res3);

res0 = _mm_macc_ps(tj[16],qi[16],res0);
res1 = _mm_macc_ps(tj[17],qi[17],res1);
res2 = _mm_macc_ps(tj[18],qi[18],res2);
res3 = _mm_macc_ps(tj[19],qi[19],res3);

res0 = _mm_add_ps(res0,res1);
res2 = _mm_add_ps(res2,res3);

return _mm_add_ps(res0,res2);

Respondido 04 Jul 12, 03:07

Ahora que lo pienso. Hay 40 cargas de memoria. A menos que esté usando un procesador Sandy Bridge, tiene un cuello de botella de 40 ciclos. Entonces, el código del OP ya puede ser óptimo. - Mística

Acerca de la asociatividad de punto flotante: la oveja negra, a menudo infravalorada e incomprendida, de las banderas del compilador -ffast-math hace maravillas a veces. Y los AMD pueden hacer dos cargas de memoria L1 por ciclo desde casi los albores de la humanidad, pero desafortunadamente son muy lentos en cualquier otro lugar. - Gunther piez

Muchas gracias por tu ayuda. El resultado de mi prueba indica que mi código funciona tan rápido como su idea (como mencionó en el comentario). AMD FMA4 parece interesante, pero estas instrucciones no están disponibles en mi máquina y el código debe ser compatible con SSE2. Lo intentaré con -ffast-math. - martín s

@martins, ¿por qué SSE4 está en la pregunta si solo puede usar SSE2? - huón

Está bien, mi declaración no fue lo suficientemente exacta. tenemos una bandera para SSE3 y SSE4 pero SSE2 es el mínimo requerido. - martín s

En primer lugar, la optimización más importante que puede hacer es asegurarse de que su compilador tenga activadas todas sus configuraciones de optimización.


Los compiladores son bastante inteligentes, por lo que si lo escribes como un bucle, es probable que lo desenrolle:

__128 res = _mm_setzero();
for (int i = 0; i < 10; i++) {
  res = _mm_add_ps(res, _mm_add_ps(_mm_mul_ps(tj[2*i], qi[2*i]), _mm_mul_ps(tj[2*i+1], qi[2*i+1])));
}
return res;

(Con GCC necesitas pasar -funroll-loops, y luego lo desplegará para hacer 5 iteraciones a la vez).

También puede definir una macro y desplegarla a mano, si la versión de bucle es más lenta, por ejemplo:

__128 res = _mm_setzero();

#define STEP(i) res = _mm_add_ps(res, _mm_add_ps(_mm_mul_ps(tj[2*i], qi[2*i]), _mm_mul_ps(tj[2*i+1], qi[2*i+1])))

STEP(0); STEP(1); STEP(2); STEP(3); STEP(4);
STEP(5); STEP(6); STEP(7); STEP(8); STEP(9);

#undef STEP

return res;

Incluso podría ejecutar el ciclo de 0 a 20 (o hacer lo mismo con la versión macro), es decir:

__128 res = _mm_setzero();
for (int i = 0; i < 20; i++) {
  res = _mm_add_ps(res, _mm_mul_ps(tj[i], qi[i]));
}
return res;

(Con GCC y -funroll-loops esto se desenrolla para hacer 10 iteraciones a la vez, es decir, lo mismo que el bucle de dos a la vez anterior).

Respondido 04 Jul 12, 02:07

Sus datos no están organizados en la memoria en un formato adecuado para las instrucciones especializadas del producto de puntos SSE4 (dpps) . Esas instrucciones esperan que las dimensiones de un solo vector sean adyacentes, así:

| dim0 | dim1 | dim2 | ... | dim19 |

mientras que sus datos parecen tener los vectores intercalados entre sí:

| v0-dim0 | v1-dim0 | v2-dim0 | v3-dim0 | v0-dim1 | ...

Su enfoque general actual parece apropiado: es posible que pueda mejorar las cosas reordenando las instrucciones de modo que los resultados de las multiplicaciones no se usen inmediatamente después de generarse, pero en realidad el compilador debería poder resolverlo por sí mismo.

Respondido 04 Jul 12, 02:07

dpps solo es (potencialmente) útil para productos escalares de longitud 4, no dentro de un ciclo que agrega resultados a través de múltiples vectores. No hay necesidad de nada horizontal dentro el lazo. dpps decodifica en múltiples uops para mezclar y sumar horizontalmente, y aún tendría que sumar sus resultados escalares si fuera a usarlo en un bucle. Como en Skylake, 3p01 + p5. O en Ryzen, son 8 uops con un rendimiento de reloj de 1 por 4. Entonces es mucho más lento que usar acumuladores vectoriales. - pedro cordes

No es la respuesta que estás buscando? Examinar otras preguntas etiquetadas or haz tu propia pregunta.