web-dev-qa-db-fra.com

Pourquoi la multiplication matricielle est-elle plus rapide avec numpy qu'avec les ctypes en Python?

J'essayais de trouver le moyen le plus rapide de multiplier la matrice et j'ai essayé 3 façons différentes:

  • Pure python: pas de surprise ici.
  • Implémentation de Numpy utilisant numpy.dot(a, b)
  • Interfaçage avec C en utilisant le module ctypes en Python.

Voici le code C qui est transformé en bibliothèque partagée:

#include <stdio.h>
#include <stdlib.h>

void matmult(float* a, float* b, float* c, int n) {
    int i = 0;
    int j = 0;
    int k = 0;

    /*float* c = malloc(nay * sizeof(float));*/

    for (i = 0; i < n; i++) {
        for (j = 0; j < n; j++) {
            int sub = 0;
            for (k = 0; k < n; k++) {
                sub = sub + a[i * n + k] * b[k * n + j];
            }
            c[i * n + j] = sub;
        }
    }
    return ;
}

Et le code Python qui l'appelle:

def C_mat_mult(a, b):
    libmatmult = ctypes.CDLL("./matmult.so")

    dima = len(a) * len(a)
    dimb = len(b) * len(b)

    array_a = ctypes.c_float * dima
    array_b = ctypes.c_float * dimb
    array_c = ctypes.c_float * dima

    suma = array_a()
    sumb = array_b()
    sumc = array_c()

    inda = 0
    for i in range(0, len(a)):
        for j in range(0, len(a[i])):
            suma[inda] = a[i][j]
            inda = inda + 1
        indb = 0
    for i in range(0, len(b)):
        for j in range(0, len(b[i])):
            sumb[indb] = b[i][j]
            indb = indb + 1

    libmatmult.matmult(ctypes.byref(suma), ctypes.byref(sumb), ctypes.byref(sumc), 2);

    res = numpy.zeros([len(a), len(a)])
    indc = 0
    for i in range(0, len(sumc)):
        res[indc][i % len(a)] = sumc[i]
        if i % len(a) == len(a) - 1:
            indc = indc + 1

    return res

J'aurais parié que la version utilisant C aurait été plus rapide ... et j'aurais perdu! Ci-dessous est mon benchmark qui semble montrer que je l'ai mal fait ou que numpy est stupidement rapide:

benchmark

J'aimerais comprendre pourquoi la version numpy est plus rapide que la version ctypes, je ne parle même pas de l'implémentation pure Python car elle est un peu évident.

48
Charles Menguy

Je ne connais pas trop Numpy, mais la source est sur Github. Une partie des produits scalaires est implémentée dans https://github.com/numpy/numpy/blob/master/numpy/core/src/multiarray/arraytypes.c.src , ce qui, je suppose, est traduit en implémentations C spécifiques pour chaque type de données. Par exemple:

/**begin repeat
 *
 * #name = BYTE, UBYTE, SHORT, USHORT, INT, UINT,
 * LONG, ULONG, LONGLONG, ULONGLONG,
 * FLOAT, DOUBLE, LONGDOUBLE,
 * DATETIME, TIMEDELTA#
 * #type = npy_byte, npy_ubyte, npy_short, npy_ushort, npy_int, npy_uint,
 * npy_long, npy_ulong, npy_longlong, npy_ulonglong,
 * npy_float, npy_double, npy_longdouble,
 * npy_datetime, npy_timedelta#
 * #out = npy_long, npy_ulong, npy_long, npy_ulong, npy_long, npy_ulong,
 * npy_long, npy_ulong, npy_longlong, npy_ulonglong,
 * npy_float, npy_double, npy_longdouble,
 * npy_datetime, npy_timedelta#
 */
static void
@name@_dot(char *ip1, npy_intp is1, char *ip2, npy_intp is2, char *op, npy_intp n,
           void *NPY_UNUSED(ignore))
{
    @out@ tmp = (@out@)0;
    npy_intp i;

    for (i = 0; i < n; i++, ip1 += is1, ip2 += is2) {
        tmp += (@out@)(*((@type@ *)ip1)) *
               (@out@)(*((@type@ *)ip2));
    }
    *((@type@ *)op) = (@type@) tmp;
}
/**end repeat**/

Cela semble calculer les produits scalaires unidimensionnels, c'est-à-dire sur les vecteurs. Dans mes quelques minutes de navigation sur Github, je n'ai pas pu trouver la source des matrices, mais il est possible qu'il utilise un seul appel à FLOAT_dot pour chaque élément de la matrice de résultats. Cela signifie que la boucle de cette fonction correspond à votre boucle la plus interne.

Une différence entre eux est que la "foulée" - la différence entre les éléments successifs dans les entrées - est explicitement calculée une fois avant d'appeler la fonction. Dans votre cas, il n'y a pas de foulée et le décalage de chaque entrée est calculé à chaque fois, par ex. a[i * n + k]. Je me serais attendu à ce qu'un bon compilateur optimise cela à quelque chose de similaire à la foulée Numpy, mais peut-être qu'il ne peut pas prouver que l'étape est une constante (ou qu'elle n'est pas optimisée).

Numpy peut également faire quelque chose d'intelligent avec des effets de cache dans le code de niveau supérieur qui appelle cette fonction. Une astuce courante consiste à déterminer si chaque ligne est contiguë ou chaque colonne - et essayez d'abord d'itérer sur chaque partie contiguë. Il semble difficile d'être parfaitement optimal, pour chaque produit scalaire, une matrice d'entrée doit être traversée par des lignes et l'autre par des colonnes (à moins qu'elles ne soient stockées dans un ordre majeur différent). Mais il peut au moins le faire pour les éléments de résultat.

Numpy contient également du code pour choisir l'implémentation de certaines opérations, y compris "dot", à partir de différentes implémentations de base. Par exemple, il peut utiliser une bibliothèque BLAS . D'après la discussion ci-dessus, il semble que CBLAS soit utilisé. Cela a été traduit de Fortran en C. Je pense que l'implémentation utilisée dans votre test serait celle trouvée ici: http://www.netlib.org/clapack/cblas/sdot.c .

Notez que ce programme a été écrit par une machine pour une autre machine à lire. Mais vous pouvez voir en bas qu'il utilise une boucle déroulée pour traiter 5 éléments à la fois:

for (i = mp1; i <= *n; i += 5) {
stemp = stemp + SX(i) * SY(i) + SX(i + 1) * SY(i + 1) + SX(i + 2) * 
    SY(i + 2) + SX(i + 3) * SY(i + 3) + SX(i + 4) * SY(i + 4);
}

Il est probable que ce facteur de déroulement ait été choisi après plusieurs profils. Mais un avantage théorique de cela est que davantage d'opérations arithmétiques sont effectuées entre chaque point de branche, et le compilateur et le processeur ont plus de choix sur la façon de les planifier de manière optimale pour obtenir autant de pipelining d'instructions que possible.

21
Edmund

NumPy utilise une méthode BLAS hautement optimisée et soigneusement ajustée pour la multiplication matricielle (voir aussi: ATLAS ). La fonction spécifique dans ce cas est GEMM (pour la multiplication matricielle générique). Vous pouvez rechercher l'original en recherchant dgemm.f (c'est dans Netlib).

L'optimisation, au fait, va au-delà des optimisations du compilateur. Ci-dessus, Philip a mentionné Coppersmith – Winograd. Si je me souviens bien, c'est l'algorithme qui est utilisé pour la plupart des cas de multiplication matricielle dans ATLAS (bien qu'un commentateur note qu'il pourrait s'agir de l'algorithme de Strassen).

En d'autres termes, votre algorithme matmult est l'implémentation triviale. Il existe des moyens plus rapides de faire la même chose.

30
Doctor Mohawk

Le langage utilisé pour implémenter une certaine fonctionnalité est en soi une mauvaise mesure des performances. Souvent, l'utilisation d'un algorithme plus approprié est le facteur décisif.

Dans votre cas, vous utilisez l'approche naïve de la multiplication matricielle telle qu'enseignée à l'école, qui est en O (n ^ 3). Cependant, vous pouvez faire beaucoup mieux pour certains types de matrices, par exemple matrices carrées, matrices de rechange et ainsi de suite.

Jetez un oeil à algorithme de Coppersmith – Winograd (multiplication de matrice carrée dans O (n ^ 2.3737)) pour un bon point de départ sur la multiplication matricielle rapide. Voir également la section "Références", qui répertorie certains pointeurs vers des méthodes encore plus rapides.


Pour un exemple plus concret de gains de performances étonnants, essayez d'écrire une strlen() rapide et de la comparer à l'implémentation de la glibc. Si vous ne parvenez pas à le battre, lisez la source strlen() de glibc, il a d'assez bons commentaires.

9
Philip

Numpy est également un code hautement optimisé. Il y a un essai sur certaines parties de celui-ci dans le livre Beautiful Code .

Les ctypes doivent passer par une traduction dynamique de C vers Python et retour qui ajoute une surcharge. Dans Numpy, la plupart des opérations matricielles sont entièrement internes à lui.

2
Keith

Les gars qui ont écrit NumPy savent évidemment ce qu'ils font.

Il existe de nombreuses façons d'optimiser la multiplication matricielle. Par exemple, l'ordre dans lequel vous parcourez la matrice affecte les modèles d'accès à la mémoire, qui affectent les performances.
Une bonne utilisation de SSE est une autre façon d'optimiser, que NumPy utilise probablement.
Il y a peut-être plus de façons que les développeurs de NumPy connaissent et moi non.

BTW, avez-vous compilé votre code C avec l'optiomisation?

Vous pouvez essayer l'optimisation suivante pour C. Cela fonctionne en parallèle, et je suppose que NumPy fait quelque chose dans le même sens.
REMARQUE: ne fonctionne que pour les tailles paires. Avec un travail supplémentaire, vous pouvez supprimer cette limitation et conserver l'amélioration des performances.

for (i = 0; i < n; i++) {
        for (j = 0; j < n; j+=2) {
            int sub1 = 0, sub2 = 0;
            for (k = 0; k < n; k++) {
                sub1 = sub1 + a[i * n + k] * b[k * n + j];
                sub1 = sub1 + a[i * n + k] * b[k * n + j + 1];
            }
            c[i * n + j]     = sub;
            c[i * n + j + 1] = sub;
        }
    }
}
2
ugoren

La raison la plus courante donnée à l'avantage de vitesse de Fortran en code numérique, afaik, est que le langage facilite la détection aliasing - le compilateur peut dire que les matrices multipliées ne partagent pas la même mémoire, ce qui peut aider à améliorer la mise en cache (pas besoin d'être sûr que les résultats sont réécrits immédiatement dans la mémoire "partagée"). C'est pourquoi C99 a introduit restreindre .

Cependant, dans ce cas, je me demande si le code numpy parvient également à utiliser instructions spéciales que le code C n'est pas (car la différence semble particulièrement importante).

1
andrew cooke