Supera la multiplicación de matrices de NumPy con 150 líneas de código C
(salykova.github.io)- La multiplicación de matrices de NumPy depende de bibliotecas BLAS externas, pero esta implementación busca llevar el rendimiento de un solo hilo y multihilo al nivel de BLAS usando solo C puro y FMA3·AVX2
- El centro del rendimiento es una estructura que divide $C$ en bloques pequeños y en la que un microkernel 16×6 repite rank-1 updates dentro de registros
YMMpara reducir los accesos a memoria - En matrices de tamaño arbitrario, el manejo de bordes puede convertirse fácilmente en un cuello de botella, por lo que se combinan almacenamientos con máscara y buffers con padding de ceros para evitar la degradación de rendimiento de las cargas con máscara
- La reutilización de caché se asegura con blocking
k_c,m_c,n_c, y el rendimiento máximo real depende en gran medida del ajuste de cantidad de hilos, tamaño del kernel y tamaño de los tiles - AVX-512 se excluyó para lograr soporte en una gama más amplia de CPU, así que en CPUs con AVX-512 BLAS puede ser más rápido, y la comparación con OpenBLAS también se realizó con AVX-512 desactivado
Objetivo de implementación y referencia de comparación
- El código de la implementación está publicado en sgemm.c, y optimiza la multiplicación de matrices FP32 multihilo en procesadores modernos
- NumPy depende de bibliotecas BLAS externas para operaciones de álgebra lineal como la multiplicación de matrices
- Algunos ejemplos son Intel MKL, Accelerate, BLIS, GotoBLAS y OpenBLAS
- OpenBLAS, GotoBLAS y BLIS están escritos en C/FORTRAN/Assembly e incluyen implementaciones de multiplicación de matrices optimizadas manualmente para cada microarquitectura de CPU
- El objetivo es una implementación de multiplicación de matrices escrita en C puro, sin ensamblador de bajo nivel, que cumpla las siguientes condiciones
- Funcione con tamaños de matriz arbitrarios
- Se ejecute en procesadores x86-64 modernos
- Compita con bibliotecas BLAS existentes
- El código sea simple y fácil de extender
- Los materiales de referencia son Fast Multidimensional Matrix Multiplication on CPU from Scratch de Simon Boehm, Matrix Multiplication de Sergey Slotin, Can you multiply a matrix? de Geohot, y artículos relacionados con GotoBLAS y BLIS
Condiciones del benchmark y cálculo de FLOPS
- El entorno de prueba es AMD Ryzen 7 9700X, 32 GB DDR5 6000 MHz CL36, OpenBLAS 0.3.26, GCC 13.3 y Ubuntu 24.04.1 LTS
- Se usan los flags de compilación
-O3 -march=native -mno-avx512f -fopenmp - Para una comparación justa, al instalar OpenBLAS hay que configurar el
TARGETadecuado y desactivar las instrucciones AVX-512- Los procesadores Zen4/5 se compilan con
make TARGET=ZEN - De lo contrario, OpenBLAS usa instrucciones AVX-512 por defecto
- Los procesadores Zen4/5 se compilan con
- La multiplicación de matrices FP32 de OpenBLAS se ejecuta con la API
cblas_sgemm - El benchmark usa matrices cuadradas
- Se evalúa desde
m=n=k=200hastam=n=k=10000en incrementos de200 - La multiplicación de matrices se repite
n_iterveces, y se usa el tiempo de ejecución mediano para medir el rendimiento
- Se evalúa desde
- Al multiplicar una matriz $A$ de $M \times K$ por una matriz $B$ de $K \times N$, el total de operaciones es $2MNK$ FLOP
- El rendimiento se calcula como
FLOPS=(2*m*n*k)/exec_time
- El rendimiento se calcula como
Límite teórico y base SIMD
- Las CPUs x86-64 modernas procesan varios datos en paralelo mediante extensiones SIMD
- Las instrucciones principales son AVX2 y FMA
- Ambas usan registros
YMMde 256 bits - Cada registro
YMMpuede contener 8 floats de 32 bits
- Ambas usan registros
- La instrucción FMA
VFMADD231PSrealiza una operación packed single de la formaYMM1 = YMM2 * YMM3 + YMM1 - En Ryzen 9700X, el throughput de fused multiply-add es de 0.5 ciclos/instrucción, es decir, 2 instrucciones por ciclo
- En teoría, Ryzen 9700X puede ejecutar 32 FLOP por ciclo en un solo núcleo
- La fórmula es
8 floats × 2(add+mul) × 2(1/TP) - Suponiendo una frecuencia sostenida de 4.7 GHz en 8 núcleos, el pico teórico multihilo se estima en 1203 FLOPS
- La fórmula es
Implementación básica y microkernel
- Las matrices se almacenan en orden column-major
A[row][col]se accede en un puntero de C comoptr[col*M + row]
- La implementación más simple recorre todas las filas y columnas de $C$ y calcula para cada elemento el producto punto de una fila de $A$ y una columna de $B$
- La clave de una implementación de alto rendimiento es un microkernel que divide $C$ en submatrices de $m_R \times n_R$ y calcula cada submatriz de forma eficiente
- El kernel inicializa $\bar{C}$ en cero dentro de registros y luego itera sobre la dimensión $K$
- Trae a registros el vector columna de $\bar{A}$ y el vector fila de $\bar{B}$
- Calcula el producto externo de ambos vectores y lo suma a los acumuladores de $\bar{C}$
- Cada paso es un rank-1 update
- Este enfoque reduce la cantidad de elementos traídos a registros a $(m_R+n_R)K$, frente a los $2K m_R n_R$ accesos a memoria del enfoque naive
- Como las CPUs AVX tienen 16 registros YMM, el tamaño del kernel debe cumplir la siguiente restricción
- $(m_R/8) \cdot n_R + m_R/8 + 1 \le 16$
- $m_R$ debe ser múltiplo de 8
- En teoría, cuanto más grandes y similares sean $m_R$ y $n_R$, mayor será la reducción de accesos a memoria, pero en un Ryzen 9700X real el kernel 16×6 muestra el mejor rendimiento
- La implementación usa intrinsics de
immintrin.h__m256es un tipo vectorial de 256 bits que representa el contenido de un registroYMM_mm256_loadu_pscarga el vector columna deA_mm256_broadcast_ssdifunde un valor escalar deBa un vector de 8 floats_mm256_fmadd_psactualiza el acumulador_mm256_storeu_psguarda el resultado en memoria
- El ensamblador generado incluye instrucciones SIMD FMA como
vfmadd231psyvbroadcastss
Padding para matrices de tamaño arbitrario
- El kernel 16×6 básico funciona directamente cuando $M$ y $N$ son múltiplos de 16 y 6, respectivamente
- En las zonas de borde, si la cantidad de columnas $n$ es menor que 6, el loop de almacenamiento se ejecuta solo hasta
j < n - Cuando la cantidad de filas $m$ es menor que 16, como
_mm256_storeu_psguarda 8 elementos a la vez, se necesita almacenamiento con máscara_mm256_maskstore_psguarda en memoria solo los elementos cuyos bits de máscara estén activados- La máscara se genera según la cantidad de filas superpuestas $m$
- Si en los bordes también se manejan las cargas con
_mm256_maskload_ps, el rendimiento del kernel puede caer mucho- Las instrucciones adicionales para calcular la máscara agregan overhead
- Como $n$ no es una constante en tiempo de compilación, al compilador le resulta difícil desenrollar el loop de forma eficiente
- En su lugar, si $m \neq m_R$, se copia $\bar{A}$ a un buffer y se rellena con ceros; si $n \neq n_R$, también se copia $\bar{B}$ a un buffer y se completa con ceros
- La implementación relacionada está en matmul_pad.h
Cache blocking y reutilización de datos
- Entre los registros y la DRAM existe una jerarquía de caché de CPU, y las CPUs de escritorio modernas suelen usar cachés L1, L2 y L3
- La caché es más rápida que la DRAM, pero su capacidad es limitada, por lo que no es posible mantener todos $A$, $B$ y $C$ completos en caché
- La técnica de dividir las matrices en bloques pequeños, cargarlos en caché y reutilizar los mismos datos en varios rank-1 updates se llama cache blocking o tiling
- El cache blocking de un solo hilo tiene una forma de 5 loops anidados similar a la estructura de BLIS
- El loop más externo crea bloques $C_j$ y $B_j$ a lo largo de la dimensión $N$
- El siguiente loop crea bloques $A_j$ y $B_p$ a lo largo de la dimensión $K$
- $B_p$ se empaqueta para convertirse en $\tilde{B}_p$ y, si hace falta, se rellena con ceros para apuntar a la reutilización en caché L3
- El siguiente loop crea bloques $C_i$ y $A_j$ a lo largo de la dimensión $M$, y $A_j$ se empaqueta para convertirse en $\tilde{A}_j$
- Los últimos dos loops dividen los bloques de caché en paneles de $m_R \times k_c$ y $k_c \times n_R$, y los pasan al kernel
- Los $\tilde{A}_j$ y $\tilde{B}_p$ empaquetados se almacenan de manera diferente
- Los paneles internos de $\tilde{A}_j$ se almacenan en column-major
- Los paneles internos de $\tilde{B}_p$ se almacenan en row-major
- Los parámetros de cache blocking deben ajustarse al tamaño de caché de cada modelo de CPU
- $k_c \times n_c$ sirve como punto de partida para llenar la caché L3
- $m_c \times k_c$ sirve como punto de partida para llenar la caché L2
- $k_c \times n_R$ sirve como punto de partida para llenar la caché L1
- En la práctica, valores más grandes que los teóricos suelen dar mejor rendimiento, y como la CPU administra automáticamente la distribución en caché, hay que diseñar los loops y patrones de acceso a nivel de algoritmo
- La implementación está en matmul_cache.h
Microoptimizaciones del kernel
- En vez de definir los acumuladores como un arreglo tipo
__m256 C_buffer[6][2], se declaran explícitamente variables de acumulación desplegadas - Esto ayuda a GCC a optimizar mejor el código y a evitar spilling de registros
- El cálculo de máscaras también se modificó para usar instrucciones vectoriales
- Se usa un arreglo estático
mask[32]junto con_mm256_cvtepi8_epi32y_mm_loadu_si64
- Se usa un arreglo estático
- Esta implementación está en matmul_micro.h
Estrategia multihilo
- Se paralelizan tanto las operaciones aritméticas como el empaquetado
- Los loops 5.º, 4.º y 3.º por fuera del microkernel iteran en unidades de tamaño de bloque de caché
- Para mantener ocupados todos los hilos, la cantidad de iteraciones debe ser al menos igual a la cantidad de hilos
- Las dimensiones de la matriz de entrada deben ser aproximadamente de al menos
cantidad de hilos × tamaño del bloque de caché
- En Ryzen 9700X, los tamaños de bloque de caché que dieron buen rendimiento en un solo hilo fueron $n_c=1535$ y $m_c=1024$
- Para aprovechar los 8 núcleos, se necesita una dimensión mínima de tamaño $\max(m_c,n_c) \times 8 = 1535 \times 8 = 12280$
- En cambio, los dos últimos loops iteran sobre bloques pequeños $m_R$, $n_R$, por lo que son adecuados para paralelizar
- Generalmente $m_R$ y $n_R$ son menores que 20
- Si $m_c$ y $n_c$ se eligen como múltiplos de la cantidad de núcleos, el trabajo puede repartirse de manera uniforme
- En Ryzen 9700X, el mejor rendimiento se obtiene paralelizando juntos los dos loops internos con
#pragma omp parallel for collapse(2) num_threads(NTHREADS) - En procesadores con muchos núcleos, especialmente en entornos de más de 16 núcleos, se puede considerar paralelismo anidado y paralelizar 2 o 3 loops
- El empaquetado de $\tilde{A}$ y $\tilde{B}$ también se paraleliza con OpenMP
pack_blockAse paraleliza recorriendomcen unidades deMRpack_blockBse paraleliza recorriendoncen unidades deNR
- En la implementación multihilo, los parámetros que mostraron buen rendimiento en Ryzen 9700X son los siguientes
- $m_c = m_R \times \text{cantidad de hilos} \times 5$
- $n_c = n_R \times \text{cantidad de hilos} \times 50$
- La implementación multihilo final está en matmul_parallel.h
1 comentarios
Opiniones de Hacker News
Si el punto de este artículo es que, por lo general, queda margen de rendimiento, más bien se queda corto al estimar cuánto se puede mejorar. Y eso aun cuando el esfuerzo que se invierte en las bibliotecas de multiplicación de matrices es mucho mayor que en la mayoría del software.
Si no se trata de código ya muy optimizado, es común lograr mejoras de 10 a 1000 veces o más sobre el código existente sin demasiado esfuerzo. En orden aproximado de importancia, lo más importante es si la elección del algoritmo es adecuada y si se puede eliminar el trabajo en sí; también pesa mucho si se pueden reducir operaciones costosas como idas y vueltas al kernel o
malloc.La vectorización con intrínsecos vectoriales explícitos está bien, pero muchas veces basta con reorganizar los datos de arreglos de estructuras a estructuras de arreglos para obtener el mismo código de máquina. La eficiencia de caché también es importante y, en código paralelo, se vuelve más complejo cuando no hay aislamiento de datos por hilo, como ocurre con el false sharing. Por último, también son posibles optimizaciones específicas del hardware, como usar intrínsecos o ensamblador escrito a mano.
Cambiamos la consulta para que el join ocurriera en el servidor remoto y por la red solo se enviaran 5 a 10 filas, y se aceleró de inmediato. Siempre hay overhead fijo y latencia, pero si mandas por una conexión de red muchos más datos de los necesarios, al final el rendimiento se arruina. También vale la pena leer “It's the latency, stupid”, sobre el impacto de la latencia: http://www.stuartcheshire.org/rants/latency.html
En general, estoy de acuerdo con las consideraciones anteriores y con ese orden aproximado.
Muchas entrevistas, en vez de evaluar cómo razonar por qué una implementación es lenta, medirla con benchmarks y corregirla, se han vuelto trivias de memorización de algoritmos oscuros al estilo “porque Google lo hace así”.
Los patrones de programación comunes no se especializan lo suficiente para el hardware y dejan mucho rendimiento sobre la mesa. Este artículo es un ejemplo interesante, y otra demostración clásica es “There's plenty of room at the top”.
https://www.science.org/doi/10.1126/science.aam9744
Para entender esto, los artículos del repositorio de BLIS son casi la referencia canónica. No entiendo por qué alguien pensaría que un BLAS optimizado no rinde, y con matrices lo suficientemente grandes deberías esperar más del 90% del pico de la CPU.
La última vez que lo revisé, OpenBLAS en serie era en general parecido a MKL, y BLAS implementa GEMM como bloque básico de álgebra lineal, no
matmul. Tampoco entiendo por qué usar numpy en vez de un framework de benchmarks, y en Zen creo que habría que comparar contra el BLAS de AMD, es decir, la implementación basada en BLIS. Antes BLIS tenía una historia mejor que OpenBLAS en cuanto a paralelización, y AMD BLIS también tiene un cambio de implementación para dimensiones “pequeñas”; no sé si eso existe actualmente en OpenBLAS.Para vectorizar el microkernel no son estrictamente necesarios los intrínsecos SIMD; un compilador de C decente lo vectoriza por completo y también desenrolla los bucles. El microkernel en C puro de BLIS alcanza más del 80% del rendimiento de una implementación optimizada a mano para Haswell con tamaños de bloque adecuados. La diferencia probablemente se deba al prefetch, aunque no lo entiendo con precisión.
Incluso los compiladores modernos no logran hacer bien la autovectorización y el desenrollado de bucles con una tasa de éxito del 100%.
El artículo y la implementación se ven bien, pero me intriga cuál es el “secreto”. OpenBLAS lleva décadas optimizado en ensamblador+C para este problema exacto; ¿cómo puede ganarle?
Entra en detalle sobre caché y demás, pero me pregunto si BLAS no aprovecha estas cosas, o si está mejor ajustado para un procesador específico.
La diferencia de rendimiento fue mucho más marcada en el throughput pico que en el throughput promedio, y casi no hay aplicaciones donde el pico sea lo importante. El código de benchmark mostrado parece pasar por el asignador de Python del lado de numpy, mientras que la implementación en C no pasa por el asignador, así que ahí es donde primero revisaría si hay un error o una inconsistencia en el microbenchmark. Muchas rutinas de numpy admiten operaciones in-place, así que habría que ver explícitamente un benchmark de versiones in-place en ambos lados.
numpy también tiene verificaciones de límites y manejo de errores que se ejecutan independientemente de la implementación subyacente, lo que explica por qué con matrices pequeñas puede ser muy lento incluso frente a listas de Python puras. Si se agregan unos miles de ciclos de sobrecarga pura, es difícil hacerlo rápido.
Esta implementación adopta un enfoque bastante metódico para saturar las cachés relevantes y, aunque en cierto sentido sea obvio, las mejoras de ingeniería claras merecen destacarse en discusiones como esta. OpenBLAS también ha tenido mucha gente trabajando en él, pero es poco probable que hayan pensado en todo. Para explicarlo bien haría falta un análisis profundo del código de ambos lados.
Para implementaciones en C++ y C, ver los enfoques de metaprogramación [2], [3]. Lo realmente sorprendente es que muchos lenguajes modernos como Matlab, Julia y Mojo todavía dependan de OpenBLAS, aunque claro, cada uno tendrá sus razones.
[1] Numeric age for D: Mir GLAS is faster than OpenBLAS and Eigen (2016):
http://blog.mir.dlang.io/glas/benchmark/openblas/2016/09/23/...
[2] Vastly outperforming LAPACK with C++ metaprogramming (2018):
https://wordsandbuttons.online/vastly_outperforming_lapack_w...
[3] Outperforming LAPACK with C metaprogramming (2018):
https://wordsandbuttons.online/outperforming_lapack_with_c_m...
-march=nativepuede darle ventaja porque compila para el modelo exacto de CPU. Lo más probable es que numpy haya sido compilado para un objetivo x86-64 más genérico y antiguo.En CPUs Ryzen,
-march=nativeprobablemente use v4, mientras que numpy apuntaría a v1 o v2.https://en.wikipedia.org/wiki/X86-64#Microarchitecture_level...
El artículo es bueno y también es excelente que hayan hecho que el benchmark sea fácil de reproducir. En mi Xeon W-2245 de 16 núcleos a 3.90 GHz,
matmul.chizo la multiplicación de matrices 8192x8192 en 1.41 segundos congcc -O3y en 1.47 segundos conclang -O2, mientras que NumPy tardó 1.07 segundos.Creo que con un kernel AVX-512 sería mucho más rápido. Otra razón por la que el rendimiento puede no ser ideal es OpenMP; por experiencia, administrar explícitamente un pool de hilos con
pthreadspuede reducir la sobrecarga. También sería mejor usarsysconf(_SC_NPROCESSORS_ONLN)en vez de hardcodear la cantidad de CPUs.No hay razón para darle una carga distinta a un lado usando Python y al otro C. Se podría haber hecho una comparación justa escribiendo ambos en C: uno llamando a una biblioteca BLAS y el otro llamando a esta implementación.
La sobrecarga no es enorme, pero como se dijo en otras partes de este hilo, es importante llamarlo correctamente. Enfrentar código numpy ingenuo contra código C ajustado claramente no es una comparación justa.
Aunque no es una ruta crítica, me molesta la ineficiencia en la generación de máscaras, es decir, el uso de
bit_mask. Formas más eficientes serían crear un arreglo constante global con la forma{-1,-1,...,0,0,...}y cargar desde los offsets de elemento16-m,8-m, o comparar un vector constante{0,1,2,3,4,...}contramym-8difundidosPero esto aplica solo a una columna de la matriz, y el bucle posterior de
maskload/maskstoretarda mucho más, así que es una observación muy menor. En particular, el store sigue siendo lento incluso en Zen 4[1], y las instrucciones AVX-512 son 6 veces más rápidas aunque la única diferencia sea que reciben la máscara desde registros de máscara. clang de todos modos autovectoriza el shift, así que probablemente sea apenas 2 o 3 veces más lento que mi sugerencia[1]: https://uops.info/table.html?search=vmaskmovps&cb_lat=on&cb_...
Aprecio mucho el feedback que aporta nuevas perspectivas. Si mal no recuerdo, cuando probé “crear un arreglo constante global y cargarlo” era un poco más lento que hacer shifts de la máscara de bits, pero lo voy a probar de nuevo para estar seguro. La idea de “comparar un vector constante
{0, 1, 2, 3, 4, ...}contramym-8difundidos” es buena; voy a intentarlaint8_ty, al cargar, hacer extensión de signo de bytes aint32_t. La combinación_mm_loadu_si64/_mm256_cvtepi8_epi32debería compilarse como una sola instrucciónvpmovsxbdcon operando de memoriaAsí, si lo alineas correctamente con
alignas(32), todo el arreglo constante cabe en una sola línea de caché. En el caso de uso del texto original se necesitan dos máscaras, así que la segunda instrucciónvpmovsxbdserá un acierto seguro en caché L1D, lo cual encaja bien¿Qué tal tinyBLAS de jart?
https://hacks.mozilla.org/2024/04/llamafiles-progress-four-m...
Y https://justine.lol/matmul/
Más allá de los benchmarks, ¿cuál es el motivo para hacer multihilo la multiplicación de matrices en sí? En la práctica, ¿no sería más conveniente usar múltiples hilos en el algoritmo que usa la multiplicación?
Pero en el código HPC, por lo general GEMM no es el cuello de botella
Todavía solo lo hojeé, pero este artículo tiene muchos detalles y explicaciones. Parece un texto bastante bueno sobre cómo se implementa la multiplicación rápida de matrices teniendo en cuenta consideraciones de arquitectura, así que lo agregué a mi lista de lectura