Debido a que la multiplicación de matrices es una operación tan central en muchos algoritmos numéricos , se ha invertido mucho trabajo en hacer que los algoritmos de multiplicación de matrices sean eficientes. Las aplicaciones de la multiplicación de matrices en problemas computacionales se encuentran en muchos campos, incluida la computación científica y el reconocimiento de patrones , y en problemas aparentemente no relacionados, como contar los caminos a través de un gráfico . [1] Se han diseñado muchos algoritmos diferentes para multiplicar matrices en diferentes tipos de hardware, incluidos sistemas paralelos y distribuidos , donde el trabajo computacional se distribuye entre múltiples procesadores (quizás a través de una red).
La aplicación directa de la definición matemática de multiplicación de matrices da un algoritmo que toma tiempo del orden de n 3 operaciones de campo para multiplicar dos n × n matrices sobre ese campo ( Θ ( n 3 ) en notación O grande ). Desde el algoritmo de Strassen en la década de 1960 se conocen mejores límites asintóticos en el tiempo necesario para multiplicar matrices , pero el tiempo óptimo (es decir, la complejidad computacional de la multiplicación de matrices ) sigue siendo desconocido. En abril de 2024 [actualizar], el límite mejor anunciado sobre la complejidad asintótica de un algoritmo de multiplicación de matrices es el tiempo O ( n 2,371552 ) , dado por Williams , Xu, Xu y Zhou. [2] [3] Esto mejora el límite de tiempo O ( n 2.3728596 ) , dado por Alman y Williams. [4] [5] Sin embargo, este algoritmo es un algoritmo galáctico debido a las grandes constantes y no se puede realizar en la práctica.
La definición de multiplicación de matrices es que si C = AB para una matriz A de n × m y una matriz B de m × p , entonces C es una matriz de n × p con entradas
A partir de esto, se puede construir un algoritmo simple que recorre los índices i de 1 a n y j de 1 a p , calculando lo anterior usando un bucle anidado:
Este algoritmo requiere tiempo Θ( nmp ) (en notación asintótica ). [1] Una simplificación común para el propósito del análisis de algoritmos es asumir que todas las entradas son matrices cuadradas de tamaño n × n , en cuyo caso el tiempo de ejecución es Θ( n 3 ) , es decir, cúbico en el tamaño de la dimensión. . [6]
Los tres bucles en la multiplicación iterativa de matrices se pueden intercambiar arbitrariamente entre sí sin afectar la corrección o el tiempo de ejecución asintótico. Sin embargo, el orden puede tener un impacto considerable en el rendimiento práctico debido a los patrones de acceso a la memoria y al uso de la caché del algoritmo; [1] qué orden es mejor también depende de si las matrices se almacenan en orden de fila principal, orden de columna principal o una combinación de ambos.
En particular, en el caso ideal de una caché totalmente asociativa que consta de M bytes y b bytes por línea de caché (es decir,METRO/blíneas de caché), el algoritmo anterior es subóptimo para A y B almacenados en orden de fila principal. Cuando n >METRO/b, cada iteración del bucle interno (un barrido simultáneo a través de una fila de A y una columna de B ) incurre en una pérdida de caché al acceder a un elemento de B . Esto significa que el algoritmo incurre en Θ( n 3 ) errores de caché en el peor de los casos. A partir de 2010 [actualizar], la velocidad de las memorias comparada con la de los procesadores es tal que las pérdidas de caché, en lugar de los cálculos reales, dominan el tiempo de ejecución para matrices importantes. [7]
La variante óptima del algoritmo iterativo para A y B en diseño de fila principal es una versión en mosaico , donde la matriz se divide implícitamente en mosaicos cuadrados de tamaño √ M por √ M : [7] [8]
En el modelo de caché idealizado, este algoritmo incurre sólo en Θ(norte 3/segundo √ M) errores de caché; el divisor b √ M asciende a varios órdenes de magnitud en las máquinas modernas, de modo que los cálculos reales dominan el tiempo de ejecución, en lugar de los errores de caché. [7]
Una alternativa al algoritmo iterativo es el algoritmo de divide y vencerás para la multiplicación de matrices. Esto se basa en la partición del bloque.
que funciona para todas las matrices cuadradas cuyas dimensiones son potencias de dos, es decir, las formas son 2 n × 2 n para algún n . El producto de la matriz ahora es
que consta de ocho multiplicaciones de pares de submatrices, seguidas de un paso de suma. El algoritmo divide y vencerás calcula las multiplicaciones más pequeñas de forma recursiva , utilizando la multiplicación escalar c 11 = a 11 b 11 como caso base.
La complejidad de este algoritmo en función de n viene dada por la recurrencia [6]
teniendo en cuenta las ocho llamadas recursivas a matrices de tamaño n /2 y Θ( n 2 ) para sumar los cuatro pares de matrices resultantes por elementos. La aplicación del teorema maestro para las recurrencias de divide y vencerás muestra que esta recursividad tiene la solución Θ( n 3 ) , igual que el algoritmo iterativo. [6]
Una variante de este algoritmo que funciona para matrices de formas arbitrarias y es más rápida en la práctica [7] divide las matrices en dos en lugar de cuatro submatrices, de la siguiente manera. [9] Dividir una matriz ahora significa dividirla en dos partes de igual tamaño, o lo más cerca posible de tamaños iguales en el caso de dimensiones impares.
La tasa de pérdida de caché de la multiplicación recursiva de matrices es la misma que la de una versión iterativa en mosaico , pero a diferencia de ese algoritmo, el algoritmo recursivo no tiene en cuenta el caché : [9] no se requiere ningún parámetro de ajuste para obtener un rendimiento óptimo del caché, y se comporta bien en un entorno de multiprogramación donde los tamaños de caché son efectivamente dinámicos debido a que otros procesos ocupan espacio en caché. [7] (El algoritmo iterativo simple tampoco tiene en cuenta la memoria caché, pero en la práctica es mucho más lento si el diseño de la matriz no se adapta al algoritmo).
El número de errores de caché incurridos por este algoritmo, en una máquina con M líneas de caché ideal, cada una de tamaño b bytes, está limitada por [9] : 13
Existen algoritmos que proporcionan mejores tiempos de ejecución que los sencillos. El primero en ser descubierto fue el algoritmo de Strassen , ideado por Volker Strassen en 1969 y al que a menudo se hace referencia como "multiplicación rápida de matrices". Se basa en una forma de multiplicar dos matrices de 2 × 2 que requiere sólo 7 multiplicaciones (en lugar de las 8 habituales), a expensas de varias operaciones adicionales de suma y resta. La aplicación de esto de forma recursiva da un algoritmo con un costo multiplicativo de . El algoritmo de Strassen es más complejo y la estabilidad numérica se reduce en comparación con el algoritmo ingenuo, [10] pero es más rápido en los casos en los que n > 100 aproximadamente [1] y aparece en varias bibliotecas, como BLAS . [11] Es muy útil para matrices grandes en dominios exactos, como campos finitos , donde la estabilidad numérica no es un problema.
Dado que el algoritmo de Strassen se utiliza en realidad en software numérico práctico y sistemas de álgebra informática, mejorar las constantes ocultas en la notación O grande tiene sus ventajas. A continuación se muestra una tabla que compara los aspectos clave de la versión mejorada basada en la multiplicación recursiva de matrices de bloques de 2x2 mediante multiplicaciones de matrices de 7 bloques. Como es habitual, indica las dimensiones de la matriz y designa el tamaño de la memoria.
Se sabe que un algoritmo tipo Strassen con un paso de matriz de bloques de 2x2 requiere al menos 7 multiplicaciones de matrices de bloques. En 1976, Probert [15] demostró que dicho algoritmo requiere al menos 15 sumas (incluidas las restas), sin embargo, una suposición oculta era que los bloques y la matriz de bloques de 2x2 están representados en la misma base. Karstadt y Schwartz calcularon en bases diferentes e intercambiaron 3 adiciones por transformaciones de bases menos costosas. También demostraron que no se pueden bajar de 12 adiciones por paso usando diferentes bases. En trabajos posteriores Beniamini et el. [16] aplicaron este truco de cambio de base a descomposiciones más generales que las matrices de bloques de 2x2 y mejoraron la constante principal para sus tiempos de ejecución.
Es una pregunta abierta en la informática teórica hasta qué punto se puede mejorar el algoritmo de Strassen en términos de complejidad asintótica . El exponente de multiplicación de matrices , generalmente denominado , es el número real más pequeño por el cual cualquier matriz sobre un campo se puede multiplicar mediante operaciones de campo. El mejor vinculado actualmente es , de Williams , Xu, Xu y Zhou. [2] [4] Este algoritmo, como todos los demás algoritmos recientes en esta línea de investigación, es una generalización del algoritmo Coppersmith-Winograd, que fue propuesto por Don Coppersmith y Shmuel Winograd en 1990. [17] La idea conceptual de estos Los algoritmos son similares al algoritmo de Strassen: se idea una forma de multiplicar dos k × k -matrices con menos de k 3 multiplicaciones, y esta técnica se aplica de forma recursiva. Sin embargo, el coeficiente constante oculto por la notación O grande es tan grande que estos algoritmos sólo valen la pena para matrices que son demasiado grandes para manejarlas en las computadoras actuales. [18] [19]
El algoritmo de Freivalds es un algoritmo de Monte Carlo simple que , dadas las matrices A , B y C , verifica en tiempo Θ( n 2 ) si AB = C.
En 2022, DeepMind presentó AlphaTensor, una red neuronal que utilizaba una analogía de juego para un solo jugador para inventar miles de algoritmos de multiplicación de matrices, incluidos algunos descubiertos previamente por humanos y otros que no. [20] Las operaciones se restringieron al campo terrestre no conmutativo (aritmética normal) y al campo finito (aritmética mod 2). El mejor algoritmo "práctico" (descomposición explícita de bajo rango de un tensor de multiplicación de matrices) encontrado se ejecutó en O (n 2,778 ). [21] Encontrar descomposiciones de bajo rango de tales tensores (y más allá) es NP-difícil; La multiplicación óptima incluso para matrices de 3x3 sigue siendo desconocida , incluso en el campo conmutativo. [21] En matrices 4x4, AlphaTensor descubrió inesperadamente una solución con 47 pasos de multiplicación, una mejora con respecto a los 49 requeridos con el algoritmo de Strassen de 1969, aunque restringido a la aritmética mod 2. De manera similar, AlphaTensor resolvió matrices de 5x5 con 96 en lugar de los 98 pasos de Strassen. Basándose en el sorprendente descubrimiento de que existen tales mejoras, otros investigadores pudieron encontrar rápidamente un algoritmo 4x4 independiente similar y modificaron por separado el algoritmo 5x5 de 96 pasos de Deepmind hasta 95 pasos en aritmética mod 2 y 97 [22] en aritmética normal. [23] Algunos algoritmos nunca se habían descubierto antes, por ejemplo (4, 5, 5) se mejoraron de 80 a 76 en aritmética normal y mod 2.
El algoritmo de divide y vencerás esbozado anteriormente se puede paralelizar de dos maneras para multiprocesadores de memoria compartida . Estos se basan en el hecho de que las ocho multiplicaciones de matrices recursivas en
se pueden realizar independientemente uno del otro, al igual que las cuatro sumas (aunque el algoritmo necesita "unir" las multiplicaciones antes de hacer las sumas). Explotando todo el paralelismo del problema, se obtiene un algoritmo que puede expresarse en pseudocódigo estilo fork-join : [24]
Procedimiento multiplicar ( C , A , B ) :
El procedimiento add( C , T ) agrega T a C , por elementos:
Aquí, fork es una palabra clave que indica que un cálculo puede ejecutarse en paralelo con el resto de la llamada a la función, mientras que join espera a que se completen todos los cálculos previamente "bifurcados". La partición logra su objetivo únicamente mediante la manipulación del puntero.
Este algoritmo tiene una longitud de ruta crítica de Θ(log 2 n ) pasos, lo que significa que lleva esa misma cantidad de tiempo en una máquina ideal con un número infinito de procesadores; por lo tanto, tiene una aceleración máxima posible de Θ( n 3 /log 2 n ) en cualquier computadora real. El algoritmo no es práctico debido al costo de comunicación inherente al mover datos hacia y desde la matriz temporal T , pero una variante más práctica logra una aceleración Θ( n 2 ) , sin utilizar una matriz temporal. [24]
En las arquitecturas modernas con memoria jerárquica, el costo de cargar y almacenar elementos de la matriz de entrada tiende a dominar el costo de la aritmética. En una sola máquina, esta es la cantidad de datos transferidos entre la RAM y el caché, mientras que en una máquina de múltiples nodos con memoria distribuida es la cantidad transferida entre nodos; en cualquier caso se llama ancho de banda de comunicación . El algoritmo ingenuo que utiliza tres bucles anidados utiliza un ancho de banda de comunicación Ω ( n 3 ) .
El algoritmo de Cannon , también conocido como algoritmo 2D , es un algoritmo que evita la comunicación y divide cada matriz de entrada en una matriz de bloques cuyos elementos son submatrices de tamaño √ M /3 por √ M /3 , donde M es el tamaño de la memoria rápida. [25] El algoritmo ingenuo se utiliza luego sobre las matrices de bloques, calculando productos de submatrices completamente en memoria rápida. Esto reduce el ancho de banda de comunicación a O ( n 3 / √ M ) , que es asintóticamente óptimo (para algoritmos que realizan cálculo Ω ( n 3 ) ). [26] [27]
En un entorno distribuido con p procesadores dispuestos en una malla 2D de √ p por √ p , se puede asignar una submatriz del resultado a cada procesador, y el producto se puede calcular con cada procesador transmitiendo O ( n 2 / √ p ) palabras, lo cual es asintóticamente óptimo asumiendo que cada nodo almacena el mínimo de elementos O ( n 2 / p ) . [27] Esto se puede mejorar mediante el algoritmo 3D, que organiza los procesadores en una malla cúbica 3D, asignando cada producto de dos submatrices de entrada a un solo procesador. Luego, las submatrices resultantes se generan realizando una reducción en cada fila. [28] Este algoritmo transmite O ( n 2 / p 2/3 ) palabras por procesador, lo cual es asintóticamente óptimo. [27] Sin embargo, esto requiere replicar cada elemento de la matriz de entrada p 1/3 veces, por lo que requiere un factor de p 1/3 más de memoria de la necesaria para almacenar las entradas. Este algoritmo se puede combinar con Strassen para reducir aún más el tiempo de ejecución. [28] Los algoritmos "2.5D" proporcionan un equilibrio continuo entre el uso de memoria y el ancho de banda de comunicación. [29] En entornos informáticos distribuidos modernos como MapReduce , se han desarrollado algoritmos de multiplicación especializados. [30]
Existe una variedad de algoritmos para la multiplicación sobre mallas . Para la multiplicación de dos n × n en una malla bidimensional estándar utilizando el algoritmo de 2D Cannon , se puede completar la multiplicación en 3 n -2 pasos, aunque esto se reduce a la mitad de este número para cálculos repetidos. [31] La matriz estándar es ineficiente porque los datos de las dos matrices no llegan simultáneamente y deben rellenarse con ceros.
El resultado es aún más rápido en una malla de alambre cruzado de dos capas, donde solo se necesitan 2 n -1 pasos. [32] El rendimiento mejora aún más para cálculos repetidos que conducen a una eficiencia del 100%. [33] La matriz de malla cruzada puede verse como un caso especial de una estructura de procesamiento no plana (es decir, multicapa). [34]
El algoritmo Coppersmith-Winograd es no es práctico, debido a la gran constante oculta en el límite superior del número de multiplicaciones requeridas.
Incluso si alguien logra probar una de las conjeturas, demostrando así que
ω
= 2
, la corona Es poco probable que el enfoque de producto sea aplicable a los grandes problemas matriciales que surgen en la práctica. [...] las matrices de entrada deben ser astronómicamente grandes para que la diferencia en el tiempo sea evidente.