c++ - ¿Debo usar SIMD o extensiones de vectores o algo más?
design gcc (3)
Actualmente estoy desarrollando un framework de aplicaciones 3D de código abierto en c ++ (con c ++ 11 ). Mi propia biblioteca de matemáticas está diseñada como la biblioteca matemática XNA , también con SIMD en mente. Pero actualmente no es realmente rápido, y tiene problemas con los alineamientos de memoria, pero más sobre eso en una pregunta diferente.
Hace algunos días me pregunté por qué debería escribir mi propio código SSE . El compilador también puede generar código altamente optimizado cuando la optimización está activada. También puedo usar la " extensión vectorial " de GCC . Pero esto no es realmente portátil.
Sé que tengo más control cuando uso mi propio código SSE, pero a menudo este control no es ético.
Un gran problema de SSE es el uso de memoria dinámica que, con la ayuda de grupos de memoria y diseño orientado a datos, es limitada en la medida de lo posible.
Ahora a mi pregunta:
¿Debo usar SSE desnudo? Quizás encapsulado.
__m128 v1 = _mm_set_ps(0.5f, 2, 4, 0.25f); __m128 v2 = _mm_set_ps(2, 0.5f, 0.25f, 4); __m128 res = _mm_mul_ps(v1, v2);
¿O debería el compilador hacer el trabajo sucio?
float v1 = {0.5f, 2, 4, 0.25f}; float v2 = {2, 0.5f, 0.25f, 4}; float res[4]; res[0] = v1[0]*v2[0]; res[1] = v1[1]*v2[1]; res[2] = v1[2]*v2[2]; res[3] = v1[3]*v2[3];
¿O debería usar SIMD con código adicional? Como una clase de contenedor dinámico con operaciones SIMD, que necesita
load
adicional e instrucciones de lastore
.Pear3D::Vector4f* v1 = new Pear3D::Vector4f(0.5f, 2, 4, 0.25f); Pear3D::Vector4f* v2 = new Pear3D::Vector4f(2, 0.5f, 0.25f, 4); Pear3D::Vector4f res = Pear3D::Vector::multiplyElements(*v1, *v2);
El ejemplo anterior usa una clase imaginaria con usos
float[4]
interno y usastore
yload
en cada método comomultiplyElements(...)
. Los métodos usan SSE interna.
No quiero usar otra biblioteca, porque quiero aprender más sobre SIMD y el diseño de software a gran escala. Pero los ejemplos de la biblioteca son bienvenidos.
PD: Esto no es un problema real más una cuestión de diseño.
Sugeriría usar el código simd desnudo en una función estrechamente controlada. Como no lo utilizará para la multiplicación de vectores primarios debido a la sobrecarga, esta función probablemente debería tomar la lista de objetos Vector3 que deben manipularse, según el DOD. Donde hay uno, hay muchos.
Sugiero que aprenda sobre plantillas de expresiones (implementaciones personalizadas de operadores que usan objetos proxy). De esta forma, puede evitar hacer cargas / almacenar muertes por rendimiento en cada operación individual, y hacerlas solo una vez para todo el cálculo.
Bueno, si quieres usar extensiones SIMD, un buen enfoque es usar los intrínsecos de SSE (por supuesto, mantente lejos del ensamblado en línea, pero afortunadamente no lo incluiste como alternativa, de todos modos). Pero para la limpieza, debe encapsularlos en una clase de vector agradable con operadores sobrecargados:
struct aligned_storage
{
//overload new and delete for 16-byte alignment
};
class vec4 : public aligned_storage
{
public:
vec4(float x, float y, float z, float w)
{
data_[0] = x; ... data_[3] = w; //don''t use _mm_set_ps, it will do the same, followed by a _mm_load_ps, which is unneccessary
}
vec4(float *data)
{
data_[0] = data[0]; ... data_[3] = data[3]; //don''t use _mm_loadu_ps, unaligned just doesn''t pay
}
vec4(const vec4 &rhs)
: xmm_(rhs.xmm_)
{
}
...
vec4& operator*=(const vec4 v)
{
xmm_ = _mm_mul_ps(xmm_, v.xmm_);
return *this;
}
...
private:
union
{
__m128 xmm_;
float data_[4];
};
};
Ahora lo bueno es que, debido a la unión anónima (UB, lo sé, pero muéstrame una plataforma con SSE donde esto no funciona) puedes usar la matriz de flotador estándar siempre que sea necesario (como operator[]
o inicialización (don '' t use _mm_set_ps
)) y solo use SSE cuando corresponda. Con un compilador moderno, la encapsulación no tiene costo (me sorprendió lo bien que VC10 optimizó las instrucciones SSE para un montón de cálculos con esta clase de vectores, sin temor a movimientos innecesarios en variables de memoria temporales, ya que VC8 parecía gustar incluso sin encapsulación).
La única desventaja es que debe cuidar la alineación correcta, ya que los vectores no alineados no le compran nada y pueden ser incluso más lentos que los que no son SSE. Pero, afortunadamente, el requisito de alineación del __m128
se propagará en el vec4
(y en cualquier clase circundante) y solo tendrá que ocuparse de la asignación dinámica, para lo cual C ++ tiene buenas posibilidades. Solo necesita hacer una clase base cuyo operator new
funciones de operator delete
(en todos los estilos, por supuesto) estén sobrecargadas correctamente y de las cuales derivará su clase vectorial. Para usar su tipo con contenedores estándar, por supuesto, también necesita especializar std::allocator
(y tal vez std::get_temporary_buffer
y std::return_temporary_buffer
para completar), ya que usará el operator new
global operator new
contrario.
Pero la verdadera desventaja es que también debe preocuparse por la asignación dinámica de cualquier clase que tenga su vector SSE como miembro, lo cual puede ser tedioso, pero puede volver a automatizarse un poco derivando también esas clases de aligned_storage
y poniendo el total std::allocator
specialization messer en una práctica macro.
JamesWynn tiene un punto que esas operaciones a menudo se combinan en algunos bloques especiales de cómputo pesado (como filtrado de texturas o transformación de vértices), pero por otro lado el uso de esas encapsulaciones de vectores SSE no introduce ninguna sobrecarga sobre un float[4]
estándar float[4]
-implementación de una clase vectorial. Necesita obtener esos valores de la memoria en los registros de todos modos (ya sea la pila x87 o un registro SSE escalar) para hacer cualquier cálculo, entonces ¿por qué no tomar todos de una vez (que en mi humilde opinión no debería ser más lento que mover un solo valor si está alineado correctamente) y calcular en paralelo. Por lo tanto, puede cambiar libremente una implementación de SSE para una que no sea SSE sin inducir ningún gasto (corríjanme si mi razonamiento es incorrecto).
Pero si asegurar la alineación para todas las clases con vec4
como miembro es demasiado tedioso para usted (que en mi humilde opinión es la única desventaja de este enfoque), también puede definir un tipo de vector SSE especializado que utilice para cálculos y use un estándar no -SSE vector para almacenamiento.
EDITAR: Ok, para ver el argumento de arriba, que va por aquí (y parece bastante razonable al principio), tomemos un montón de cálculos, que se ven muy limpios, debido a operadores sobrecargados:
#include "vec.h"
#include <iostream>
int main(int argc, char *argv[])
{
math::vec<float,4> u, v, w = u + v;
u = v + dot(v, w) * w;
v = abs(u-w);
u = 3.0f * w + v;
w = -w * (u+v);
v = min(u, w) + length(u) * w;
std::cout << v << std::endl;
return 0;
}
y vea lo que VC10 piensa al respecto:
...
; 6 : math::vec<float,4> u, v, w = u + v;
movaps xmm4, XMMWORD PTR _v$[esp+32]
; 7 : u = v + dot(v, w) * w;
; 8 : v = abs(u-w);
movaps xmm3, XMMWORD PTR __xmm@0
movaps xmm1, xmm4
addps xmm1, XMMWORD PTR _u$[esp+32]
movaps xmm0, xmm4
mulps xmm0, xmm1
haddps xmm0, xmm0
haddps xmm0, xmm0
shufps xmm0, xmm0, 0
mulps xmm0, xmm1
addps xmm0, xmm4
subps xmm0, xmm1
movaps xmm2, xmm3
; 9 : u = 3.0f * w + v;
; 10 : w = -w * (u+v);
xorps xmm3, xmm1
andnps xmm2, xmm0
movaps xmm0, XMMWORD PTR __xmm@1
mulps xmm0, xmm1
addps xmm0, xmm2
; 11 : v = min(u, w) + length(u) * w;
movaps xmm1, xmm0
mulps xmm1, xmm0
haddps xmm1, xmm1
haddps xmm1, xmm1
sqrtss xmm1, xmm1
addps xmm2, xmm0
mulps xmm3, xmm2
shufps xmm1, xmm1, 0
; 12 : std::cout << v << std::endl;
mov edi, DWORD PTR __imp_?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A
mulps xmm1, xmm3
minps xmm0, xmm3
addps xmm1, xmm0
movaps XMMWORD PTR _v$[esp+32], xmm1
...
Incluso sin analizar a fondo cada instrucción y su uso, estoy bastante seguro de decir que no hay cargas o tiendas innecesarias, excepto las que están al principio (Ok, las dejé sin inicializar), que de todos modos son necesarias para obtener desde la memoria a los registros informáticos, y al final, que es necesario como en la siguiente expresión v
se va a apagar. Ni siquiera almacenó nada en u
y w
, ya que son solo variables temporales que no uso más. Todo está perfectamente alineado y optimizado. Incluso logró mezclar a la perfección el resultado del producto de puntos para la siguiente multiplicación, sin dejar el registro de XMM, aunque la función de dot
devuelve un float
usando un _mm_store_ss
real después de los haddps
.
Así que incluso yo, siendo un poco exagerado en cuanto a las capacidades del compilador, tengo que decir que no es muy útil pagar tus propios intrínsecos en funciones especiales en comparación con el código limpio y expresivo que obtienes por encapsulación. Aunque es posible que puedas crear ejemplos geniales en los que la elaboración manual de los intrínsecos puede, de hecho, evitar algunas instrucciones, pero primero tienes que ser más listo que el optimizador.
EDITAR: Ok, Ben Voigt señaló otro problema de la unión además de la incompatibilidad de la disposición de la memoria (probablemente no problemática), que es violar las reglas de alias estrictas y el compilador puede optimizar las instrucciones para acceder a diferentes miembros de la unión de forma que código inválido No he pensado en eso todavía. No sé si causa problemas en la práctica, sin duda necesita investigación.
Si realmente es un problema, desafortunadamente necesitamos soltar el data_[4]
y usar el __m128
solo. Para la inicialización ahora tenemos que recurrir a _mm_set_ps
y _mm_loadu_ps
nuevamente. El operator[]
vuelve un poco más complicado y podría necesitar alguna combinación de _mm_shuffle_ps
y _mm_store_ss
. Pero para la versión no const, debe usar algún tipo de objeto proxy que delegue una asignación en las instrucciones de SSE correspondientes. Tiene que investigarse de qué manera el compilador puede optimizar esta sobrecarga adicional en las situaciones específicas de entonces.
O solo utiliza el vector SSE para los cálculos y simplemente crea una interfaz para la conversión hacia y desde vectores que no son SSE en un todo, que luego se usa en los periféricos de los cálculos (ya que a menudo no necesita acceder a componentes individuales en el interior) largos cómputos). Esta parece ser la forma en que glm maneja este problema. Pero no estoy seguro de cómo lo maneja Eigen .
Pero como sea que lo aborde, aún no es necesario realizar instrismas de SSE sin utilizar los beneficios de la sobrecarga del operador.