Por curiosidad, decidí comparar mi propia función de multiplicación de matrices con la implementación de BLAS ... Estaba por decir lo menos sorprendido con el resultado:
Implementación personalizada, 10 pruebas de multiplicación de matrices 1000x1000:
Took: 15.76542 seconds.
Implementación BLAS, 10 ensayos de multiplicación de matrices 1000x1000:
Took: 1.32432 seconds.
Esto está utilizando números de punto flotante de precisión simple.
Mi implementación:
template<class ValT>
void mmult(const ValT* A, int ADim1, int ADim2, const ValT* B, int BDim1, int BDim2, ValT* C)
{
if ( ADim2!=BDim1 )
throw std::runtime_error("Error sizes off");
memset((void*)C,0,sizeof(ValT)*ADim1*BDim2);
int cc2,cc1,cr1;
for ( cc2=0 ; cc2<BDim2 ; ++cc2 )
for ( cc1=0 ; cc1<ADim2 ; ++cc1 )
for ( cr1=0 ; cr1<ADim1 ; ++cr1 )
C[cc2*ADim2+cr1] += A[cc1*ADim1+cr1]*B[cc2*BDim1+cc1];
}
Tengo dos preguntas:
- Dado que una multiplicación matriz-matriz dice: nxm * mxn requiere n * n * m multiplicaciones, entonces en el caso anterior 1000 ^ 3 o 1e9 operaciones. ¿Cómo es posible que BLAS en mi procesador de 2.6Ghz realice operaciones 10 * 1e9 en 1.32 segundos? Incluso si las multiplicaciones fueran una sola operación y no se hiciera nada más, debería tomar ~ 4 segundos.
- ¿Por qué mi implementación es mucho más lenta?
Respuestas:
Un buen punto de partida es el gran libro La ciencia de la programación de cálculos matriciales de Robert A. van de Geijn y Enrique S. Quintana-Ortí. Proporcionan una versión de descarga gratuita.
BLAS se divide en tres niveles:
El nivel 1 define un conjunto de funciones de álgebra lineal que operan solo en vectores. Estas funciones se benefician de la vectorización (por ejemplo, del uso de SSE).
Las funciones de nivel 2 son operaciones matriz-vector, por ejemplo, algún producto matriz-vector. Estas funciones podrían implementarse en términos de funciones de Nivel1. Sin embargo, puede aumentar el rendimiento de estas funciones si puede proporcionar una implementación dedicada que haga uso de alguna arquitectura de multiprocesador con memoria compartida.
Las funciones de nivel 3 son operaciones como el producto matriz-matriz. Nuevamente, podría implementarlos en términos de funciones de Nivel2. Pero las funciones de Level3 realizan operaciones O (N ^ 3) en datos O (N ^ 2). Entonces, si su plataforma tiene una jerarquía de caché, puede aumentar el rendimiento si proporciona una implementación dedicada que sea optimizada para caché / compatible con caché . Esto está muy bien descrito en el libro. El principal impulso de las funciones de Level3 proviene de la optimización de la caché. Este impulso supera significativamente el segundo impulso del paralelismo y otras optimizaciones de hardware.
Por cierto, la mayoría (o incluso todas) de las implementaciones BLAS de alto rendimiento NO se implementan en Fortran. ATLAS se implementa en C. GotoBLAS / OpenBLAS se implementa en C y sus partes críticas de rendimiento en Assembler. Solo la implementación de referencia de BLAS se implementa en Fortran. Sin embargo, todas estas implementaciones de BLAS proporcionan una interfaz Fortran de modo que se puede vincular contra LAPACK (LAPACK obtiene todo su rendimiento de BLAS).
Los compiladores optimizados juegan un papel menor a este respecto (y para GotoBLAS / OpenBLAS el compilador no importa en absoluto).
En mi humilde opinión, la implementación de BLAS no utiliza algoritmos como el algoritmo Coppersmith-Winograd o el algoritmo Strassen. No estoy exactamente seguro de la razón, pero esta es mi suposición:
Editar / Actualizar:
El documento nuevo e innovador para este tema son los documentos BLIS . Están excepcionalmente bien escritos. Para mi conferencia "Conceptos básicos de software para computación de alto rendimiento", implementé el producto matriz-matriz después de su artículo. De hecho, implementé varias variantes del producto matriz-matriz. Las variantes más simples están escritas completamente en C simple y tienen menos de 450 líneas de código. Todas las demás variantes simplemente optimizan los bucles
El rendimiento general del producto matriz-matriz solo depende de estos bucles. Aproximadamente el 99,9% del tiempo se pasa aquí. En las otras variantes usé intrínsecos y código ensamblador para mejorar el rendimiento. Puedes ver el tutorial pasando por todas las variantes aquí:
ulmBLAS: Tutorial sobre GEMM (Producto Matrix-Matrix)
Junto con los documentos de BLIS, resulta bastante fácil comprender cómo las bibliotecas como Intel MKL pueden obtener ese rendimiento. ¡Y por qué no importa si usa almacenamiento principal en filas o columnas!
Los puntos de referencia finales están aquí (llamamos a nuestro proyecto ulmBLAS):
Puntos de referencia para ulmBLAS, BLIS, MKL, openBLAS y Eigen
Otra edición / actualización:
También escribí un tutorial sobre cómo se usa BLAS para problemas de álgebra lineal numérica, como resolver un sistema de ecuaciones lineales:
Factorización LU de alto rendimiento
(Esta factorización LU la utiliza, por ejemplo, Matlab para resolver un sistema de ecuaciones lineales).
Espero encontrar tiempopara extender el tutorial para describir y demostrar cómo realizar una implementación paralela altamente escalable de la factorización LU como en PLASMA .Ok, aquí tienes: Codificación de una factorización de LU paralela optimizada de caché
PD: También hice algunos experimentos para mejorar el rendimiento de uBLAS. En realidad, es bastante simple impulsar (sí, jugar con las palabras :)) el rendimiento de uBLAS:
Experimentos sobre uBLAS .
Aquí un proyecto similar con BLAZE :
Experimentos en BLAZE .
fuente
Entonces, en primer lugar, BLAS es solo una interfaz de aproximadamente 50 funciones. Hay muchas implementaciones competitivas de la interfaz.
En primer lugar, mencionaré cosas que en gran medida no están relacionadas:
La mayoría de las implementaciones dividen cada operación en operaciones matriciales o vectoriales de pequeñas dimensiones de la manera más o menos obvia. Por ejemplo, una gran multiplicación de matrices de 1000x1000 puede dividirse en una secuencia de multiplicaciones de matrices de 50x50.
Estas operaciones de pequeña dimensión de tamaño fijo (llamadas kernels) están codificadas en código de ensamblaje específico de la CPU utilizando varias características de la CPU de su destino:
Además, estos núcleos se pueden ejecutar en paralelo entre sí utilizando varios subprocesos (núcleos de CPU), en el patrón de diseño de reducción de mapa típico.
Eche un vistazo a ATLAS, que es la implementación de BLAS de código abierto más utilizada. Tiene muchos kernels competidores diferentes, y durante el proceso de construcción de la biblioteca ATLAS se ejecuta una competencia entre ellos (algunos incluso están parametrizados, por lo que el mismo kernel puede tener configuraciones diferentes). Prueba diferentes configuraciones y luego selecciona la mejor para el sistema de destino en particular.
(Sugerencia: es por eso que si está usando ATLAS, es mejor construir y ajustar la biblioteca a mano para su máquina en particular y luego usar una prediseñada).
fuente
Primero, hay algoritmos más eficientes para la multiplicación de matrices que el que está usando.
En segundo lugar, su CPU puede realizar más de una instrucción a la vez.
Su CPU ejecuta 3-4 instrucciones por ciclo, y si se utilizan las unidades SIMD, cada instrucción procesa 4 flotantes o 2 dobles. (por supuesto, esta cifra tampoco es precisa, ya que la CPU normalmente solo puede procesar una instrucción SIMD por ciclo)
En tercer lugar, su código está lejos de ser óptimo:
fuente
restrict
(sin alias) es el valor predeterminado, a diferencia de C / C ++. (Y desafortunadamente ISO C ++ no tiene unarestrict
palabra clave, por lo que debe usarla__restrict__
en compiladores que la proporcionen como una extensión).No sé específicamente sobre la implementación de BLAS, pero hay algoritmos más eficientes para la multiplicación de matrices que tienen una complejidad mejor que O (n3). Uno bien conocido es el algoritmo de Strassen
fuente
La mayoría de los argumentos para la segunda pregunta - ensamblador, división en bloques, etc. (pero no menos de N ^ 3 algoritmos, están realmente sobredesarrollados) - juegan un papel. Pero la baja velocidad de su algoritmo es causada esencialmente por el tamaño de la matriz y la desafortunada disposición de los tres bucles anidados. Sus matrices son tan grandes que no caben a la vez en la memoria caché. Puede reorganizar los bucles de manera que se haga todo lo posible en una fila en la caché, de esta manera reduciendo drásticamente las actualizaciones de la caché (por cierto, la división en bloques pequeños tiene un efecto analógico, mejor si los bucles sobre los bloques se organizan de manera similar). A continuación, se muestra una implementación de modelo para matrices cuadradas. En mi computadora, su consumo de tiempo fue de 1:10 en comparación con la implementación estándar (como la suya). En otras palabras: nunca programe una multiplicación de matrices a lo largo del "
Un comentario más: esta implementación es incluso mejor en mi computadora que reemplazar todo por la rutina BLAS cblas_dgemm (¡pruébalo en tu computadora!). Pero mucho más rápido (1: 4) es llamar directamente a dgemm_ de la biblioteca Fortran. Creo que esta rutina no es de hecho Fortran sino código ensamblador (no sé qué hay en la biblioteca, no tengo las fuentes). No me queda muy claro por qué cblas_dgemm no es tan rápido, ya que, que yo sepa, es simplemente un contenedor para dgemm_.
fuente
Esta es una aceleración realista. Para ver un ejemplo de lo que se puede hacer con el ensamblador SIMD sobre el código C ++, vea algunos ejemplos de funciones de matriz de iPhone : eran 8 veces más rápidas que la versión C y ni siquiera el ensamblaje "optimizado", todavía no hay revestimiento de tuberías y hay son operaciones de pila innecesarias.
Además, su código no es " restringido correcto " - ¿cómo sabe el compilador que cuando modifica C, no modifica A y B?
fuente
-fstrict-aliasing
. También hay una mejor explicación de "restringir" aquí: cellperformance.beyond3d.com/articles/2006/05/…Con respecto al código original en MM Multiply, la referencia de memoria para la mayoría de las operaciones es la principal causa del mal rendimiento. La memoria funciona a 100-1000 veces más lento que el caché.
La mayor parte de la aceleración proviene del empleo de técnicas de optimización de bucle para esta función de triple bucle en MM multiplicar. Se utilizan dos técnicas principales de optimización de bucle; desenrollar y bloquear. Con respecto al desenrollado, desenrollamos los dos bucles más externos y los bloqueamos para la reutilización de datos en la caché. El desenrollado del bucle externo ayuda a optimizar el acceso a los datos temporalmente al reducir el número de referencias de memoria a los mismos datos en diferentes momentos durante toda la operación. Bloquear el índice de bucle en un número específico ayuda a retener los datos en la caché. Puede optar por optimizar la caché L2 o la caché L3.
https://en.wikipedia.org/wiki/Loop_nest_optimization
fuente
Por muchas razones.
Primero, los compiladores de Fortran están altamente optimizados y el lenguaje les permite ser como tales. C y C ++ son muy flexibles en términos de manejo de matrices (por ejemplo, el caso de punteros que se refieren a la misma área de memoria). Esto significa que el compilador no puede saber de antemano qué hacer y se ve obligado a crear un código genérico. En Fortran, sus casos están más simplificados y el compilador tiene un mejor control de lo que sucede, lo que le permite optimizar más (por ejemplo, utilizando registros).
Otra cosa es que Fortran almacena las cosas en columnas, mientras que C almacena los datos en filas. No he comprobado su código, pero tenga cuidado con el rendimiento del producto. En C, debe escanear por filas: de esta manera escanea su matriz a lo largo de la memoria contigua, reduciendo las pérdidas de caché. La falta de caché es la primera fuente de ineficiencia.
En tercer lugar, depende de la implementación de blas que esté utilizando. Algunas implementaciones pueden estar escritas en ensamblador y optimizadas para el procesador específico que está utilizando. La versión de netlib está escrita en fortran 77.
Además, está realizando muchas operaciones, la mayoría de ellas repetidas y redundantes. Todas esas multiplicaciones para obtener el índice son perjudiciales para el rendimiento. Realmente no sé cómo se hace esto en BLAS, pero hay muchos trucos para evitar operaciones costosas.
Por ejemplo, puede volver a trabajar su código de esta manera
Pruébelo, estoy seguro de que guardará algo.
En su pregunta # 1, la razón es que la multiplicación de matrices escala como O (n ^ 3) si usa un algoritmo trivial. Hay algoritmos que escalan mucho mejor .
fuente