2012-05-04 15 views
41

Cercavo di capire il modo più veloce per fare moltiplicazione matriciale e provato 3 modi diversi:Perché la moltiplicazione della matrice è più veloce con numpy che con ctypes in Python?

  • attuazione Pure pitone: sorprese qui.
  • Implementazione di Numpy con numpy.dot(a, b)
  • Interfaccia con C utilizzando il modulo ctypes in Python.

Questo è il codice C che si trasforma in una libreria condivisa:

#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 ; 
} 

E il codice Python che lo chiama:

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 

avrei scommesso che la versione con C sarebbe stato più veloce ... e avrei perso! Qui di seguito è il mio punto di riferimento che sembra dimostrare che io sia fatto in modo errato, o che numpy è stupidamente veloce:

benchmark

mi piacerebbe capire il motivo per cui la versione numpy è più veloce rispetto alla versione ctypes, io' Non parlo nemmeno della pura implementazione di Python poiché è abbastanza ovvia.

+5

Bella domanda: risulta np.dot() è anche più veloce di un'implementazione ingenua della GPU in C. – user2398029

+1

Una delle cose più importanti che rende il tuo lento C matmul lento è il modello di accesso alla memoria. 'b [k * n + j];' all'interno del ciclo interno (su 'k') ha un passo di' n', quindi tocca una linea cache diversa su ogni accesso. E il tuo ciclo non può auto-vectorize con SSE/AVX. ** Risolvilo trasposando 'b' in avanti, il che costa O (n^2) tempo e si ripaga da solo in carenze di cache ridotte mentre fai carichi O (n^3) da' b'. ** Ciò farebbe comunque essere un'implementazione ingenua senza il blocco della cache (aka loop tiling), però. –

+0

Poiché si utilizza un 'int sum' (per qualche motivo ...), il ciclo potrebbe effettivamente vectorize senza' -ffast-math' se il ciclo interno stava accedendo a due array sequenziali. FP math non è associativo, quindi i compilatori non possono riordinare le operazioni senza '-ffast-math', ma l'intero matematico è associativo (e ha una latenza inferiore rispetto a FP, il che aiuta se non hai intenzione di ottimizzare il tuo ciclo con accumulatori multipli o altre cose che nascondono la latenza). I costi di conversione 'float' ->' int' sono gli stessi di un 'add' FP (in realtà si usa FP aggiungendo ALU su CPU Intel), quindi non ne vale la pena nel codice ottimizzato. –

risposta

18

Non ho molta familiarità con Numpy, ma la fonte è su Github. Parte dei prodotti punto sono implementati in https://github.com/numpy/numpy/blob/master/numpy/core/src/multiarray/arraytypes.c.src, che presumo sia tradotto in specifiche implementazioni C per ogni tipo di dati. Ad esempio:

/**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 
@[email protected]_dot(char *ip1, npy_intp is1, char *ip2, npy_intp is2, char *op, npy_intp n, 
      void *NPY_UNUSED(ignore)) 
{ 
    @[email protected] tmp = (@[email protected])0; 
    npy_intp i; 

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

Questo sembra calcolare i prodotti punto unidimensionale, cioè su vettori. Nei pochi minuti di navigazione con Github non sono stato in grado di trovare l'origine per le matrici, ma è possibile che utilizzi una chiamata a FLOAT_dot per ciascun elemento nella matrice dei risultati. Ciò significa che il ciclo in questa funzione corrisponde al tuo ciclo più interno.

Una differenza tra di loro è che il "passo" - la differenza tra gli elementi successivi negli ingressi - viene esplicitamente calcolato una volta prima di chiamare la funzione. Nel tuo caso non c'è alcun passo e l'offset di ogni input viene calcolato ogni volta, ad es. a[i * n + k]. Mi sarei aspettato un buon compilatore per ottimizzarlo su qualcosa di simile al passo di Numpy, ma forse non può dimostrare che il passo sia una costante (o che non sia ottimizzata).

Numpy potrebbe anche fare qualcosa di intelligente con effetti cache nel codice di livello superiore che chiama questa funzione. Un trucco comune consiste nel pensare se ciascuna riga è contigua, o ogni colonna - e provare prima a ripetere su ogni parte contigua. Sembra difficile essere perfettamente ottimali, per ogni prodotto di punti, una matrice di input deve essere attraversata da righe e l'altra da colonne (a meno che non sia stata memorizzata in ordine di importanza maggiore). Ma può almeno farlo per gli elementi di risultato.

Numpy contiene anche il codice per scegliere l'implementazione di alcune operazioni, tra cui "punto", da diverse implementazioni di base. Ad esempio, può utilizzare una libreria BLAS. Dalla discussione sopra sembra che sia usato CBLAS. Questo è stato tradotto da Fortran in C. Penso che l'implementazione utilizzata nel test sia quella che si trova qui: http://www.netlib.org/clapack/cblas/sdot.c.

Si noti che questo programma è stato scritto da una macchina per un'altra macchina da leggere. Ma si può vedere in basso che si sta usando un loop srotolato per elaborare 5 elementi alla volta:

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); 
} 

Questo fattore srotolamento è probabile che sia stato scelto dopo profilatura diversi. Ma un vantaggio teorico è che vengono eseguite più operazioni aritmetiche tra ciascun punto di diramazione, e il compilatore e la CPU hanno più scelta su come programmarli in modo ottimale per ottenere il maggior numero possibile di pipeline di istruzioni.

+1

Mi sono sbagliato di nuovo, sembra che vengano chiamate le routine in Numpy in '/ linalg/blas_lite.c'. il primo 'daxpy_' è il ciclo interno srotolato per i prodotti punto su float, e si basa sul codice da molto tempo fa. Controlla il commento qui: * "tempi costanti di un vettore più un vettore.utilizza cicli srotolati per incrementi pari a uno. Jack dongarra, linpack, 3/11/78 modificato 12/3/93, dichiarazioni di array (1) modificate in array (\ *) "* – jozzas

+2

La mia ipotesi è che nessuno di questi algoritmi sia effettivamente utilizzato per float, double, single complex o double complex. NumPy richiede ATLAS, che ha le sue versioni di 'daxpy' e' dgemm'. Esistono versioni per float e complessi; per numeri interi e simili, NumPy probabilmente ricade sul modello C che hai collegato. –

2

I ragazzi che hanno scritto NumPy ovviamente sanno quello che stanno facendo.

Esistono molti modi per ottimizzare la moltiplicazione della matrice. Ad esempio, ordinare che attraversi la matrice influisce sui modelli di accesso alla memoria, che influiscono sulle prestazioni.
Il buon utilizzo di SSE è un altro modo per ottimizzare, che NumPy probabilmente impiega.
Ci possono essere più modi, che gli sviluppatori di NumPy sanno e io no.

BTW, hai compilato il tuo codice C con l'ottimizzazione?

Si può provare la seguente ottimizzazione per C. Funziona in parallelo, e suppongo che NumPy faccia qualcosa seguendo le stesse linee.
NOTA: funziona solo per le taglie pari. Con un lavoro extra, puoi rimuovere questa limitazione e mantenere il miglioramento delle prestazioni.

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; 
     } 
    } 
} 
+0

Sì, ho provato con diversi livelli di ottimizzazione alla compilazione, ma questo non ha cambiato il risultato molto rispetto a numpy –

+0

Una buona implementazione di moltiplicazione avrebbe battuto qualsiasi livello di ottimizzazione. Immagino che l'ottimizzazione non sarebbe affatto peggiore. – ugoren

+2

Questa risposta fa un sacco di supposizioni su cosa fa Numpy. Tuttavia, fa a malapena a nessuno di essi, scaricando il lavoro su una libreria BLAS, quando invece è disponibile. Le prestazioni della moltiplicazione della matrice dipendono fortemente dall'implementazione BLAS. –

1

La ragione più comune dato per il vantaggio della velocità di Fortran a codice numerico, per quanto ne so, è che il linguaggio rende più facile per rilevare aliasing - il compilatore può dire che le matrici di essere moltiplicati non condividono la stessa memoria, che può aiutare a migliorare il caching (non c'è bisogno di essere sicuri che i risultati vengano immediatamente riportati nella memoria "condivisa"). Questo è il motivo per cui C99 ha introdotto restrict.

Tuttavia, in questo caso, mi chiedo se anche il codice numpy stia riuscendo a utilizzare alcuni special instructions che il codice C non sia (poiché la differenza sembra particolarmente grande).

2

Numpy è anche codice altamente ottimizzato. C'è un saggio su parti di esso nel libro Beautiful Code.

I ctypes devono passare attraverso una traduzione dinamica da C a Python e viceversa che aggiunge un sovraccarico. In Numpy la maggior parte delle operazioni con le matrici vengono eseguite completamente al suo interno.

+0

+1 per il mio prossimo libro da leggere! –

+0

Numpy non è esso stesso codice ottimizzato. Utilizza * il codice ottimizzato, ad es. ATLAS. –

+0

@mohawkjohn Sono entrambi. – Keith

9

Il linguaggio utilizzato per implementare una determinata funzionalità è una cattiva misura delle prestazioni di per sé. Spesso, utilizzare un algoritmo più adatto è il fattore decisivo.

Nel tuo caso, stai utilizzando l'approccio ingenuo alla moltiplicazione delle matrici come insegnato a scuola, che è in O (n^3). Tuttavia, puoi fare molto meglio per determinati tipi di matrici, ad es. matrici quadrate, matrici di riserva e così via.

Dai un'occhiata allo Coppersmith–Winograd algorithm (moltiplicazione matrice quadrata in O (n^2.3737)) per un buon punto di partenza sulla moltiplicazione di matrici veloci. Vedi anche la sezione "Riferimenti", che elenca alcuni indicatori per metodi ancora più veloci.


Per un esempio più terroso di miglioramento delle prestazioni sorprendenti, provare a dare una veloce strlen() e confrontarlo con l'implementazione di glibc. Se non riesci a superarlo, leggi l'origine strlen() di glibc, ha commenti abbastanza buoni.

+0

+1 Per usare la notazione big-oh e l'analisi (ricordo sempre che il metodo naive n^3 vs Strassen alg con è circa n^2.8). Di nuovo, il buon modo di controllare la velocità di un alg è grande, oh, non la lingua. –

+0

Probabilmente più importante in questo caso, l'ingenuo C matmul dell'OP non è bloccato nella cache e non traspone nemmeno uno degli input. Fa scorrere le righe in una matrice e le colonne nell'altra, quando sono entrambe in ordine di riga principale, quindi ottiene enormi errori di cache. (Una trasposizione è O (n^2) lavora in primo piano per fare in modo che i prodotti a punti vettoriale di riga * colonna facciano accessi sequenziali, che consentono anche loro di auto-vettorizzare con SSE/AVX/qualsiasi cosa se si utilizza '-ffast-math'.) –

25

NumPy utilizza un metodo BLAS altamente ottimizzato e accuratamente calibrato per la moltiplicazione della matrice (vedere anche: ATLAS). La funzione specifica in questo caso è GEMM (per la moltiplicazione della matrice generica). Puoi cercare l'originale cercando dgemm.f (è in Netlib).

L'ottimizzazione, a proposito, va oltre le ottimizzazioni del compilatore. In alto, Philip ha menzionato Coppersmith – Winograd. Se ricordo bene, questo è l'algoritmo che viene utilizzato per la maggior parte dei casi di moltiplicazione di matrici in ATLAS (anche se un commentatore nota che potrebbe essere l'algoritmo di Strassen).

In altre parole, l'algoritmo matmult è l'implementazione banale. Ci sono modi più veloci per fare la stessa cosa.

+3

A proposito, 'np.show_config()' mostra a che pacchetto/blas è collegato. – denis

+3

Tu e Philip fate il punto giusto (il problema è che l'implementazione dell'OP è lenta), ma suppongo che NumPy usi l'algoritmo di Strassen o qualche variante piuttosto che Coppersmith-Winograd, che ha costanti così grandi che di solito non è utile nella pratica . –

Problemi correlati