sistema lineal más rápido para matrices cuadradas pequeñas (10x10)


9

Estoy muy interesado en optimizar al máximo la resolución de sistemas lineales para matrices pequeñas (10x10), a veces llamadas matrices pequeñas . ¿Hay una solución lista para esto? La matriz se puede suponer no singular.

Este solucionador se ejecutará más de 1 000 000 de veces en microsegundos en una CPU Intel. Estoy hablando del nivel de optimización utilizado en los juegos de computadora. No importa si lo codifico en ensamblaje y arquitectura específica, o estudio reducciones de precisión o confiabilidad y uso hacks de punto flotante (uso el indicador de compilación -ffast-math, no hay problema). ¡La solución incluso puede fallar durante aproximadamente el 20% del tiempo!

ParcialPivLu de Eigen es el más rápido en mi punto de referencia actual, superando a LAPACK cuando está optimizado con -O3 y un buen compilador. Pero ahora estoy a punto de crear un solucionador lineal personalizado. Cualquier consejo sería muy apreciado. Haré que mi solución sea de código abierto y reconoceré ideas clave en publicaciones, etc.

Relacionado: Velocidad de resolución del sistema lineal con matriz diagonal de bloques ¿Cuál es el método más rápido para invertir millones de matrices? https://stackoverflow.com/q/50909385/1489510


77
Esto parece un objetivo elástico. Supongamos que utilizamos el Skylake-X Xeon Platinum 8180 más rápido con un rendimiento máximo teórico de 4 TFLOP de precisión simple, y que un sistema de 10x10 requiere aproximadamente 700 (aproximadamente 2n ** 3/3) operaciones de punto flotante para ser resuelto. Entonces, un lote de 1M de tales sistemas podría resolverse teóricamente en 175 microsegundos. Ese es un número que no puede exceder la velocidad de la luz. ¿Puede compartir qué rendimiento está logrando actualmente con su código existente más rápido? Por cierto, ¿los datos son de precisión simple o doble precisión?
njuffa

@njuffa sí, pretendía alcanzar cerca de 1 ms, pero micro es otra historia. Para micro, consideré explotar la estructura inversa incremental en el lote mediante la detección de matrices similares, que ocurren a menudo. El rendimiento actual es de 10-500 ms, según el procesador. La precisión es doble o incluso doble compleja. La precisión individual es más lenta.
rfabbri

@njuffa Puedo reducir o aumentar la precisión de la velocidad
rfabbri

2
Parece que la precisión / exactitud no es tu prioridad. Para su objetivo, ¿quizás sea útil un método iterativo truncado en un número relativamente pequeño de evaluaciones? Especialmente si tienes una conjetura inicial razonable.
Spencer Bryngelson

1
¿Pivotes? ¿Podría hacer una factorización QR en lugar de la eliminación gaussiana? ¿Intercalan sus sistemas para poder usar las instrucciones SIMD y hacer varios sistemas a la vez? ¿Escribe programas de línea recta sin bucles y sin direccionamiento indirecto? ¿Qué precisión quieres y cómo condicionaré tu sistema? ¿Tienen alguna estructura que pueda ser explotada?
Carl Christian

Respuestas:


7

El uso de un tipo de matriz Eigen donde el número de filas y columnas se codifica en el tipo en tiempo de compilación le da una ventaja sobre LAPACK, donde el tamaño de la matriz se conoce solo en tiempo de ejecución. Esta información adicional permite que el compilador realice el desenrollado de bucle completo o parcial, eliminando muchas instrucciones de ramificación. Si está buscando usar una biblioteca existente en lugar de escribir sus propios núcleos, probablemente sea esencial tener un tipo de datos donde el tamaño de la matriz se pueda incluir como parámetros de plantilla de C ++. La única otra biblioteca que conozco que hace esto es Blaze , por lo que podría valer la pena comparar con Eigen.

Si decide implementar su propia implementación, es posible que lo que hace PETSc para su formato CSR de bloque sea un ejemplo útil, aunque PETSc en sí mismo probablemente no sea la herramienta adecuada para lo que tiene en mente. En lugar de escribir un bucle, escriben explícitamente cada operación para pequeñas multiplicaciones de matriz-vector (vea este archivo en su repositorio). Esto garantiza que no hay instrucciones de bifurcación como las que podría obtener con un bucle. Las versiones del código con instrucciones AVX son un buen ejemplo de cómo usar realmente extensiones vectoriales. Por ejemplo, esta función usa el__m256dtipo de datos para operar simultáneamente en cuatro dobles al mismo tiempo. Puede obtener un aumento de rendimiento apreciable al escribir explícitamente todas las operaciones utilizando extensiones de vector, solo para la factorización LU en lugar de la multiplicación de matriz-vector. En lugar de escribir el código C a mano, sería mejor usar un script para generarlo. También puede ser divertido ver si hay una diferencia de rendimiento apreciable al reordenar algunas de las operaciones para aprovechar mejor la canalización de instrucciones.

También puede obtener algo de kilometraje de la herramienta STOKE , que explorará al azar el espacio de posibles transformaciones del programa para encontrar una versión más rápida.


tx. Ya uso Eigen como Map <const Matrix <complex, 10, 10>> AA (A) con éxito. revisará las otras cosas.
rfabbri

Eigen también tiene AVX e incluso un encabezado complex.h para ello. ¿Por qué PETSc para esto? Es difícil competir con Eigen en este caso. Especialicé a Eigen aún más para mi problema y con una estrategia de pivote aproximada que, en lugar de tomar el máximo sobre una columna, intercambia un pivote inmediatamente cuando encuentra otro que es 3 órdenes de magnitud más grande.
rfabbri

1
@rfabbri No estaba sugiriendo que use PETSc para esto, solo que lo que hacen en esa instancia en particular podría ser instructivo. He editado la respuesta para aclarar eso.
Daniel Shapero

4

Otra idea podría ser utilizar un enfoque generativo (un programa que escribe un programa). Cree un (meta) programa que escupe la secuencia de instrucciones C / C ++ para realizar LU ** sin pivotar en un sistema de 10x10 ... básicamente tomando el nido de bucle k / i / j y aplanándolo en O (1000) más o menos líneas de aritmética escalar Luego alimente ese programa generado en cualquier compilador de optimización. Lo que creo que es algo interesante aquí, es que eliminar los bucles expone cada dependencia de datos y subexpresión redundante, y le da al compilador la máxima oportunidad de reordenar las instrucciones para que se asignen bien al hardware real (por ejemplo, número de unidades de ejecución, riesgos / paradas, por lo que en).

Si conoce todas las matrices (o incluso solo algunas de ellas), puede mejorar el rendimiento llamando a funciones / intrínsecas SIMD (SSE / AVX) en lugar de código escalar. Aquí estaría explotando el vergonzoso paralelismo entre las instancias, en lugar de perseguir cualquier paralelismo dentro de una sola instancia. Por ejemplo, podría realizar 4 LU de doble precisión simultáneamente utilizando intrínsecos AVX256, al empacar 4 matrices "a través" del registro y realizar las mismas operaciones ** en todas ellas.

** De ahí el enfoque en LU sin pivotar. Pivotar arruina este enfoque de dos maneras. Primero, introduce ramas debido a la selección dinámica, lo que significa que sus dependencias de datos no son tan perfectamente conocidas. En segundo lugar, significa que diferentes "ranuras" SIMD tendrían que hacer cosas diferentes, porque la instancia A podría pivotar de manera diferente a la instancia B. Por lo tanto, si persigue algo de esto, sugeriría pivotar estáticamente sus matrices antes del cálculo (permuta la entrada más grande) de cada columna a diagonal).


Como las matrices son tan pequeñas, quizás se pueda eliminar el giro si están preescaladas. Ni siquiera pre pivotando las matrices. Todo lo que necesitamos es que las entradas estén dentro de 2-3 órdenes de magnitud entre sí.
rfabbri

2

Su pregunta lleva a dos consideraciones diferentes.

Primero, debe elegir el algoritmo correcto. Por lo tanto, se debe considerar la cuestión de si las matrices tienen alguna estructura. Por ejemplo, cuando las matrices son simétricas, una descomposición de Cholesky es más eficiente que LU. Cuando solo necesita una cantidad limitada de precisión, un método iterativo puede ser más rápido.

10×10

En total, la respuesta a su pregunta depende en gran medida del hardware y las matrices que considere. Probablemente no haya una respuesta definitiva y tenga que probar algunas cosas para encontrar un método óptimo.


Hasta ahora, Eigen ya se optimiza mucho, usa SEE, AVX, etc. y probé métodos iterativos en una prueba preliminar y no me ayudaron. Probé Intel MKL pero no mejor que Eigen con banderas GCC optimizadas. Actualmente estoy tratando de fabricar algo mejor y más simple que Eigen y hacer pruebas más detalladas con métodos iterativos.
rfabbri

1

Intentaría la inversión en bloque.

https://en.wikipedia.org/wiki/Invertible_matrix#Blockwise_inversion

Eigen utiliza una rutina optimizada para calcular el inverso de una matriz 4x4, que probablemente sea lo mejor que obtendrá. Intenta usar eso tanto como sea posible.

http://www.eigen.tuxfamily.org/dox/Inverse__SSE_8h_source.html

Arriba a la izquierda: 8x8. Arriba a la derecha: 8x2. Abajo a la izquierda: 2x8. Abajo a la derecha: 2x2. Invierta el 8x8 utilizando el código de inversión 4x4 optimizado. El resto son productos matriciales.

EDITAR: Usar bloques 6x6, 6x4, 4x6 y 4x4 ha demostrado ser un poco más rápido de lo que describí anteriormente.

using namespace Eigen;

template<typename Scalar, int tl_size, int br_size>
Matrix<Scalar, tl_size + br_size, tl_size + br_size> blockwise_inversion(const Matrix<Scalar, tl_size, tl_size>& A, const Matrix<Scalar, tl_size, br_size>& B, const Matrix<Scalar, br_size, tl_size>& C, const Matrix<Scalar, br_size, br_size>& D)
{
    Matrix<Scalar, tl_size + br_size, tl_size + br_size> result;

    Matrix<Scalar, tl_size, tl_size> A_inv = A.inverse().eval();
    Matrix<Scalar, br_size, br_size> DCAB_inv = (D - C * A_inv * B).inverse();

    result.topLeftCorner<tl_size, tl_size>() = A_inv + A_inv * B * DCAB_inv * C * A_inv;
    result.topRightCorner<tl_size, br_size>() = -A_inv * B * DCAB_inv;
    result.bottomLeftCorner<br_size, tl_size>() = -DCAB_inv * C * A_inv;
    result.bottomRightCorner<br_size, br_size>() = DCAB_inv;

    return result;
}

template<typename Scalar, int tl_size, int br_size>
Matrix<Scalar, tl_size + br_size, tl_size + br_size> my_inverse(const Matrix<Scalar, tl_size + br_size, tl_size + br_size>& mat)
{
    const Matrix<Scalar, tl_size, tl_size>& A = mat.topLeftCorner<tl_size, tl_size>();
    const Matrix<Scalar, tl_size, br_size>& B = mat.topRightCorner<tl_size, br_size>();
    const Matrix<Scalar, br_size, tl_size>& C = mat.bottomLeftCorner<br_size, tl_size>();
    const Matrix<Scalar, br_size, br_size>& D = mat.bottomRightCorner<br_size, br_size>();

    return blockwise_inversion<Scalar,tl_size,br_size>(A, B, C, D);
}

template<typename Scalar>
Matrix<Scalar, 10, 10> invert_10_blockwise_8_2(const Matrix<Scalar, 10, 10>& input)
{
    Matrix<Scalar, 10, 10> result;

    const Matrix<Scalar, 8, 8>& A = input.topLeftCorner<8, 8>();
    const Matrix<Scalar, 8, 2>& B = input.topRightCorner<8, 2>();
    const Matrix<Scalar, 2, 8>& C = input.bottomLeftCorner<2, 8>();
    const Matrix<Scalar, 2, 2>& D = input.bottomRightCorner<2, 2>();

    Matrix<Scalar, 8, 8> A_inv = my_inverse<Scalar, 4, 4>(A);
    Matrix<Scalar, 2, 2> DCAB_inv = (D - C * A_inv * B).inverse();

    result.topLeftCorner<8, 8>() = A_inv + A_inv * B * DCAB_inv * C * A_inv;
    result.topRightCorner<8, 2>() = -A_inv * B * DCAB_inv;
    result.bottomLeftCorner<2, 8>() = -DCAB_inv * C * A_inv;
    result.bottomRightCorner<2, 2>() = DCAB_inv;

    return result;
}

template<typename Scalar>
Matrix<Scalar, 10, 10> invert_10_blockwise_6_4(const Matrix<Scalar, 10, 10>& input)
{
    Matrix<Scalar, 10, 10> result;

    const Matrix<Scalar, 6, 6>& A = input.topLeftCorner<6, 6>();
    const Matrix<Scalar, 6, 4>& B = input.topRightCorner<6, 4>();
    const Matrix<Scalar, 4, 6>& C = input.bottomLeftCorner<4, 6>();
    const Matrix<Scalar, 4, 4>& D = input.bottomRightCorner<4, 4>();

    Matrix<Scalar, 6, 6> A_inv = my_inverse<Scalar, 4, 2>(A);
    Matrix<Scalar, 4, 4> DCAB_inv = (D - C * A_inv * B).inverse().eval();

    result.topLeftCorner<6, 6>() = A_inv + A_inv * B * DCAB_inv * C * A_inv;
    result.topRightCorner<6, 4>() = -A_inv * B * DCAB_inv;
    result.bottomLeftCorner<4, 6>() = -DCAB_inv * C * A_inv;
    result.bottomRightCorner<4, 4>() = DCAB_inv;

    return result;
}

Aquí están los resultados de una ejecución de referencia utilizando un millón de Eigen::Matrix<double,10,10>::Random()matrices y Eigen::Matrix<double,10,1>::Random()vectores. En todas mis pruebas, mi inverso es siempre más rápido. Mi rutina de resolución implica calcular el inverso y luego multiplicarlo por un vector. A veces es más rápido que Eigen, a veces no. Mi método de marcado de banco puede ser defectuoso (no desactivó el turbo boost, etc.). Además, las funciones aleatorias de Eigen pueden no ser representativas de datos reales.

  • Eigen pivote parcial inverso: 3036 milisegundos
  • Mi inverso con bloque superior de 8x8: 1638 milisegundos
  • Mi inverso con bloque superior 6x6: 1234 milisegundos
  • Eigen pivote parcial resolver: 1791 milisegundos
  • Mi solución con el bloque superior de 8x8: 1739 milisegundos
  • Mi solución con el bloque superior 6x6: 1286 milisegundos

Estoy muy interesado en ver si alguien puede optimizar esto aún más, ya que tengo una aplicación de elementos finitos que invierte un millón de matrices de 10x10 (y sí, necesito coeficientes individuales de la inversa, por lo que resolver directamente un sistema lineal no siempre es una opción) .

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.