Risposta a un'altra domanda Stack Overflow ( questa ) mi sono imbattuto in un interessante sotto-problema. Qual è il modo più veloce per ordinare un array di 6 numeri interi?
Poiché la domanda è di livello molto basso:
- non possiamo supporre che le librerie siano disponibili (e la chiamata stessa abbia il suo costo), solo C semplice
- per evitare di svuotare la pipeline di istruzioni (che ha un costo molto elevato) dovremmo probabilmente ridurre al minimo rami, salti e ogni altro tipo di interruzione del flusso di controllo (come quelli nascosti dietro punti di sequenza in
&&
o||
). - lo spazio è limitato e minimizzare i registri e l'uso della memoria è un problema, idealmente nella posizione l'ordinamento è probabilmente il migliore.
In realtà questa domanda è una specie di golf in cui l'obiettivo non è minimizzare la lunghezza della sorgente ma i tempi di esecuzione. Lo chiamo codice 'Zening' come usato nel titolo del libro Zen of Code ottimizzazione di Michael Abrash e dei suoi sequel .
Per quanto riguarda il motivo per cui è interessante, ci sono diversi livelli:
- l'esempio è semplice e facile da capire e misurare, non molta abilità in C coinvolta
- mostra gli effetti della scelta di un buon algoritmo per il problema, ma anche gli effetti del compilatore e dell'hardware sottostante.
Ecco la mia implementazione di riferimento (ingenua, non ottimizzata) e il mio set di test.
#include <stdio.h>
static __inline__ int sort6(int * d){
char j, i, imin;
int tmp;
for (j = 0 ; j < 5 ; j++){
imin = j;
for (i = j + 1; i < 6 ; i++){
if (d[i] < d[imin]){
imin = i;
}
}
tmp = d[j];
d[j] = d[imin];
d[imin] = tmp;
}
}
static __inline__ unsigned long long rdtsc(void)
{
unsigned long long int x;
__asm__ volatile (".byte 0x0f, 0x31" : "=A" (x));
return x;
}
int main(int argc, char ** argv){
int i;
int d[6][5] = {
{1, 2, 3, 4, 5, 6},
{6, 5, 4, 3, 2, 1},
{100, 2, 300, 4, 500, 6},
{100, 2, 3, 4, 500, 6},
{1, 200, 3, 4, 5, 600},
{1, 1, 2, 1, 2, 1}
};
unsigned long long cycles = rdtsc();
for (i = 0; i < 6 ; i++){
sort6(d[i]);
/*
* printf("d%d : %d %d %d %d %d %d\n", i,
* d[i][0], d[i][6], d[i][7],
* d[i][8], d[i][9], d[i][10]);
*/
}
cycles = rdtsc() - cycles;
printf("Time is %d\n", (unsigned)cycles);
}
Risultati grezzi
Man mano che il numero di varianti sta diventando grande, le ho raccolte tutte in una suite di test che può essere trovata qui . I test effettivamente utilizzati sono un po 'meno ingenui di quelli mostrati sopra, grazie a Kevin Stock. È possibile compilare ed eseguirlo nel proprio ambiente. Sono abbastanza interessato dal comportamento su differenti architetture / compilatori target. (OK ragazzi, mettetelo nelle risposte, farò +1 a tutti i contributori di un nuovo set di risultati).
Ho dato la risposta a Daniel Stutzbach (per il golf) un anno fa, poiché era alla fonte della soluzione più veloce in quel momento (reti di smistamento).
Linux 64 bit, gcc 4.6.1 64 bit, Intel Core 2 Duo E8400, -O2
- Chiamata diretta alla funzione di libreria qsort: 689.38
- Implementazione ingenua (ordinamento per inserzione): 285.70
- Inserimento ordinamento (Daniel Stutzbach): 142.12
- Inserimento ordinamento non srotolato: 125,47
- Ordine di rango: 102.26
- Rango con registri: 58.03
- Reti di smistamento (Daniel Stutzbach): 111.68
- Reti di ordinamento (Paul R): 66.36
- Reti di ordinamento 12 con Fast Swap: 58.86
- Scambio riordinato Reti ordinate 12: 53.74
- Ordinamento reti 12 riordinato Simple Swap: 31.54
- Rete di smistamento riordinata con scambio rapido: 31.54
- Rete di smistamento riordinata con scambio rapido V2: 33.63
- Inbound Bubble Sort (Paolo Bonzini): 48,85
- Ordinamento di inserzione non srotolato (Paolo Bonzini): 75.30
Linux 64 bit, gcc 4.6.1 64 bit, Intel Core 2 Duo E8400, -O1
- Chiamata diretta alla funzione di libreria qsort: 705.93
- Implementazione ingenua (tipo inserzione): 135.60
- Inserimento ordinamento (Daniel Stutzbach): 142.11
- Inserimento ordinamento non srotolato: 126,75
- Ordine di grado: 46.42
- Ordine dei ranghi con i registri: 43.58
- Reti di smistamento (Daniel Stutzbach): 115.57
- Reti di ordinamento (Paul R): 64.44
- Reti di ordinamento 12 con Fast Swap: 61.98
- Scambio riordinato per reti di smistamento 12: 54.67
- Ordinamento reti 12 riordinato Simple Swap: 31.54
- Rete di smistamento riordinata con scambio rapido: 31.24
- Rete di smistamento riordinata con scambio rapido V2: 33.07
- Inbound Bubble Sort (Paolo Bonzini): 45,79
- Ordinamento di inserzione non srotolato (Paolo Bonzini): 80.15
Ho incluso entrambi i risultati -O1 e -O2 perché sorprendentemente per diversi programmi O2 è meno efficiente di O1. Mi chiedo quale ottimizzazione specifica abbia questo effetto?
Commenti sulle soluzioni proposte
Inserimento ordinamento (Daniel Stutzbach)
Come previsto, minimizzare le filiali è davvero una buona idea.
Reti di smistamento (Daniel Stutzbach)
Meglio dell'ordinamento per inserzione. Mi chiedevo se l'effetto principale non fosse derivato dall'evitare il loop esterno. Ho provato con l'ordinamento di inserzione non srotolato per verificare e in effetti otteniamo all'incirca le stesse cifre (il codice è qui ).
Reti di ordinamento (Paul R)
Il migliore finora. Il codice effettivo che ho usato per testare è qui . Non so ancora perché sia quasi due volte più veloce dell'altra implementazione della rete di smistamento. Il passaggio dei parametri? Max veloce?
Ordinamento delle reti 12 SWAP con Fast Swap
Come suggerito da Daniel Stutzbach, ho combinato la sua rete di smistamento a 12 scambi con lo scambio veloce senza rami (il codice è qui ). È davvero più veloce, il migliore finora con un piccolo margine (circa il 5%) come ci si potrebbe aspettare usando 1 swap in meno.
È anche interessante notare che lo swap senza rami sembra essere molto (4 volte) meno efficiente di quello semplice che utilizza se su architettura PPC.
Chiamata alla libreria qsort
Per dare un altro punto di riferimento ho anche provato, come suggerito, a chiamare semplicemente la libreria qsort (il codice è qui ). Come previsto, è molto più lento: da 10 a 30 volte più lento ... come è diventato evidente con la nuova suite di test, il problema principale sembra essere il carico iniziale della libreria dopo la prima chiamata, e non si confronta così male con gli altri versione. È solo tra 3 e 20 volte più lento sul mio Linux. Su alcune architetture utilizzate per i test di altri sembra addirittura più veloce (ne sono davvero sorpreso, dato che la libreria qsort utilizza un'API più complessa).
Ordine di rango
Rex Kerr ha proposto un altro metodo completamente diverso: per ogni elemento dell'array calcolare direttamente la sua posizione finale. Ciò è efficiente perché l'ordine di classificazione non necessita di diramazioni. Lo svantaggio di questo metodo è che richiede tre volte la quantità di memoria dell'array (una copia dell'array e delle variabili per memorizzare gli ordini di rango). I risultati delle prestazioni sono molto sorprendenti (e interessanti). Sulla mia architettura di riferimento con sistema operativo a 32 bit e Intel Core2 Quad E8300, il conteggio dei cicli era leggermente inferiore a 1000 (come le reti di ordinamento con swap branching). Ma quando compilato ed eseguito sul mio box a 64 bit (Intel Core2 Duo) ha funzionato molto meglio: è diventato il più veloce finora. Alla fine ho scoperto il vero motivo. La mia scatola a 32 bit usa gcc 4.4.1 e la mia scatola a 64 bit gcc 4.4.
aggiornamento :
Come mostrato dai dati pubblicati sopra, questo effetto è stato ancora migliorato dalle versioni successive di gcc e il Rank Order è diventato costantemente due volte più veloce di qualsiasi altra alternativa.
Ordinamento di reti 12 con swap riordinato
La straordinaria efficienza della proposta Rex Kerr con gcc 4.4.3 mi ha fatto meravigliare: come può un programma con un utilizzo della memoria 3 volte maggiore essere più veloce delle reti di smistamento senza rami? La mia ipotesi era che avesse meno dipendenze del tipo letto dopo scrittura, consentendo un migliore utilizzo del programmatore di istruzioni superscalare di x86. Questo mi ha dato un'idea: riordinare gli swap per ridurre al minimo le dipendenze di lettura dopo scrittura. In parole povere: quando lo fai SWAP(1, 2); SWAP(0, 2);
devi aspettare che il primo scambio sia terminato prima di eseguire il secondo perché entrambi accedono a una cella di memoria comune. Quando lo fai SWAP(1, 2); SWAP(4, 5);
il processore può eseguire entrambi in parallelo. L'ho provato e funziona come previsto, le reti di smistamento funzionano circa il 10% più velocemente.
Ordinamento delle reti 12 con Simple Swap
Un anno dopo il post originale suggerito da Steinar H. Gunderson, non dovremmo cercare di superare in astuzia il compilatore e mantenere semplice il codice di scambio. È davvero una buona idea in quanto il codice risultante è circa il 40% più veloce! Ha anche proposto uno swap ottimizzato a mano utilizzando il codice assembly in linea x86 che può ancora risparmiare qualche altro ciclo. Il più sorprendente (dice volumi sulla psicologia del programmatore) è che un anno fa nessuno ha usato quella versione di swap. Il codice che ho usato per testare è qui . Altri hanno suggerito altri modi per scrivere uno scambio C veloce, ma produce le stesse prestazioni di quello semplice con un compilatore decente.
Il codice "migliore" è ora il seguente:
static inline void sort6_sorting_network_simple_swap(int * d){
#define min(x, y) (x<y?x:y)
#define max(x, y) (x<y?y:x)
#define SWAP(x,y) { const int a = min(d[x], d[y]); \
const int b = max(d[x], d[y]); \
d[x] = a; d[y] = b; }
SWAP(1, 2);
SWAP(4, 5);
SWAP(0, 2);
SWAP(3, 5);
SWAP(0, 1);
SWAP(3, 4);
SWAP(1, 4);
SWAP(0, 3);
SWAP(2, 5);
SWAP(1, 3);
SWAP(2, 4);
SWAP(2, 3);
#undef SWAP
#undef min
#undef max
}
Se crediamo che il nostro set di test (e, sì, è piuttosto scarso, il suo semplice vantaggio è essere breve, semplice e facile da capire cosa stiamo misurando), il numero medio di cicli del codice risultante per un ordinamento è inferiore a 40 cicli ( Vengono eseguiti 6 test). Ciò significa che ogni scambio è in media di 4 cicli. Lo chiamo incredibilmente veloce. Qualche altro miglioramento possibile?
__asm__ volatile (".byte 0x0f, 0x31; shlq $32, %%rdx; orq %%rdx, %0" : "=a" (x) : : "rdx");
dovuta al fatto che rdtsc inserisce la risposta in EDX: EAX mentre GCC lo prevede in un unico registro a 64 bit. Puoi vedere il bug compilando in -O3. Vedi anche sotto il mio commento a Paul R su uno SWAP più veloce.
CMP EAX, EBX; SBB EAX, EAX
inserirà 0 o 0xFFFFFFFF a EAX
seconda che EAX
sia maggiore o minore di EBX
, rispettivamente. SBB
è "sottrai con prestito", la controparte di ADC
("aggiungi con carry"); il bit di stato a cui si fa riferimento è il bit di riporto. Poi di nuovo, me lo ricordo ADC
e ho SBB
avuto una terribile latenza e throughput sul Pentium 4 vs. ADD
e SUB
, ed erano ancora due volte più lenti delle CPU Core. Dall'80386 ci sono anche istruzioni per l' SETcc
archiviazione CMOVcc
condizionale e lo spostamento condizionale, ma sono anche lente.
x-y
ex+y
non causi underflow o overflow?