Ho eseguito un benchmark su diverse strutture di dati molto recentemente nella mia azienda, quindi sento di dover dire una parola. È molto complicato valutare correttamente qualcosa.
Analisi comparativa
Sul web troviamo raramente (se mai) un benchmark ben progettato. Fino ad oggi ho trovato solo benchmark che venivano fatti alla maniera dei giornalisti (abbastanza velocemente e nascondendo dozzine di variabili sotto il tappeto).
1) È necessario considerare il riscaldamento della cache
La maggior parte delle persone che eseguono benchmark ha paura della discrepanza del timer, quindi eseguono le loro cose migliaia di volte e impiegano tutto il tempo, stanno solo attenti a prendere le stesse migliaia di volte per ogni operazione, e quindi considerano che siano comparabili.
La verità è che nel mondo reale non ha molto senso, perché la cache non sarà calda e l'operazione verrà probabilmente chiamata solo una volta. Pertanto è necessario eseguire il benchmark utilizzando RDTSC e le cose del tempo chiamandole una sola volta. Intel ha realizzato un documento che descrive come utilizzare RDTSC (utilizzando un'istruzione cpuid per svuotare la pipeline e chiamandola almeno 3 volte all'inizio del programma per stabilizzarla).
2) Misura di precisione RDTSC
Consiglio anche di fare questo:
u64 g_correctionFactor; // number of clocks to offset after each measurement to remove the overhead of the measurer itself.
u64 g_accuracy;
static u64 const errormeasure = ~((u64)0);
#ifdef _MSC_VER
#pragma intrinsic(__rdtsc)
inline u64 GetRDTSC()
{
int a[4];
__cpuid(a, 0x80000000); // flush OOO instruction pipeline
return __rdtsc();
}
inline void WarmupRDTSC()
{
int a[4];
__cpuid(a, 0x80000000); // warmup cpuid.
__cpuid(a, 0x80000000);
__cpuid(a, 0x80000000);
// measure the measurer overhead with the measurer (crazy he..)
u64 minDiff = LLONG_MAX;
u64 maxDiff = 0; // this is going to help calculate our PRECISION ERROR MARGIN
for (int i = 0; i < 80; ++i)
{
u64 tick1 = GetRDTSC();
u64 tick2 = GetRDTSC();
minDiff = std::min(minDiff, tick2 - tick1); // make many takes, take the smallest that ever come.
maxDiff = std::max(maxDiff, tick2 - tick1);
}
g_correctionFactor = minDiff;
printf("Correction factor %llu clocks\n", g_correctionFactor);
g_accuracy = maxDiff - minDiff;
printf("Measurement Accuracy (in clocks) : %llu\n", g_accuracy);
}
#endif
Questo è un misuratore di discrepanza e richiederà il minimo di tutti i valori misurati, per evitare di ottenere un -10 ** 18 (64 bit primi valori negativi) di volta in volta.
Si noti l'uso di elementi intrinseci e non di assembly in linea. Il primo assembly inline è raramente supportato dai compilatori oggigiorno, ma molto peggio di tutto, il compilatore crea una barriera di ordinamento completa attorno all'assembly inline perché non può analizzare staticamente l'interno, quindi questo è un problema per confrontare le cose del mondo reale, specialmente quando si chiama roba solo una volta. Quindi un intrinseco è adatto qui, perché non interrompe il riordinamento libero delle istruzioni del compilatore.
3) parametri
L'ultimo problema è che le persone di solito testano per troppe poche variazioni dello scenario. Le prestazioni di un contenitore sono influenzate da:
- allocator
- dimensione del tipo contenuto
- costo di realizzazione dell'operazione di copia, operazione di assegnazione, operazione di spostamento, operazione di costruzione, di tipo contenuto.
- numero di elementi nel contenitore (dimensione del problema)
- il tipo ha 3 operazioni banali
- il tipo è POD
Il punto 1 è importante perché i contenitori allocano di volta in volta, ed è molto importante se allocano usando il CRT "nuovo" o qualche operazione definita dall'utente, come l'allocazione del pool, la lista libera o altro ...
( per le persone interessate alla parte 1, unisciti al thread misterioso su gamedev sull'impatto sulle prestazioni dell'allocatore di sistema )
Il punto 2 è perché alcuni contenitori (diciamo A) perderanno tempo a copiare cose in giro, e più grande è il tipo più grande sarà l'overhead. Il problema è che quando si confronta con un altro contenitore B, A può vincere su B per i tipi piccoli e perdere per i tipi più grandi.
Il punto 3 è uguale al punto 2, tranne per il fatto che moltiplica il costo per qualche fattore di ponderazione.
Il punto 4 è una questione di grande O mista a problemi di cache. Alcuni contenitori di cattiva complessità possono ampiamente superare i contenitori di bassa complessità per un numero limitato di tipi (come map
vs. vector
, perché la loro località nella cache è buona, ma map
frammenta la memoria). E poi ad un certo punto di incrocio, perderanno, perché la dimensione complessiva contenuta inizia a "trapelare" nella memoria principale e causare errori nella cache, oltre al fatto che la complessità asintotica può iniziare a farsi sentire.
Il punto 5 riguarda i compilatori che sono in grado di elide cose che sono vuote o banali in fase di compilazione. Ciò può ottimizzare notevolmente alcune operazioni, poiché i contenitori sono basati su modelli, quindi ogni tipo avrà il proprio profilo di prestazioni.
Punto 6 come il punto 5, i POD possono trarre vantaggio dal fatto che la costruzione della copia è solo una memcpy e alcuni contenitori possono avere un'implementazione specifica per questi casi, utilizzando specializzazioni di template parziali, o SFINAE per selezionare algoritmi in base ai tratti di T.
Sulla mappa piatta
Apparentemente la mappa piatta è un wrapper vettoriale ordinato, come Loki AssocVector, ma con alcune modernizzazioni supplementari in arrivo con C ++ 11, sfruttando la semantica dello spostamento per accelerare l'inserimento e l'eliminazione di singoli elementi.
Questo è ancora un contenitore ordinato. La maggior parte delle persone di solito non ha bisogno della parte di ordinazione, quindi l'esistenza di unordered..
.
Hai considerato che forse hai bisogno di un flat_unorderedmap
? che sarebbe qualcosa di simile google::sparse_map
o qualcosa di simile: una mappa hash di indirizzi aperti.
Il problema delle mappe hash degli indirizzi aperte è che al momento rehash
devono copiare tutto ciò che è intorno al nuovo terreno pianeggiante esteso, mentre una mappa standard non ordinata deve solo ricreare l'indice hash, mentre i dati allocati rimangono dove si trovano. Lo svantaggio ovviamente è che la memoria è frammentata come l'inferno.
Il criterio di un rehash in una mappa hash a indirizzo aperto è quando la capacità supera la dimensione del vettore bucket moltiplicata per il fattore di carico.
Un tipico fattore di carico è 0.8
; quindi, devi preoccuparti di questo, se puoi pre-dimensionare la tua hash map prima di riempirla, pre-dimensiona sempre a: intended_filling * (1/0.8) + epsilon
questo ti darà la garanzia di non dover mai rimescolare e ricopiare tutto in modo spurio durante il riempimento.
Il vantaggio delle mappe degli indirizzi chiusi ( std::unordered..
) è che non devi preoccuparti di questi parametri.
Ma boost::flat_map
è un vettore ordinato; pertanto, avrà sempre una complessità asintotica log (N), che è meno buona della mappa hash dell'indirizzo aperto (tempo costante ammortizzato). Dovresti considerare anche questo.
Risultati benchmark
Questo è un test che coinvolge diverse mappe (con int
chiave e __int64
/ somestruct
come valore) e std::vector
.
informazioni sui tipi testati:
typeid=__int64 . sizeof=8 . ispod=yes
typeid=struct MediumTypePod . sizeof=184 . ispod=yes
Inserimento
MODIFICARE:
I miei risultati precedenti includevano un bug: hanno effettivamente testato l'inserimento ordinato, che ha mostrato un comportamento molto veloce per le mappe piatte.
Ho lasciato questi risultati più avanti in questa pagina perché sono interessanti.
Questo è il test corretto:
Ho verificato l'implementazione, non esiste un ordinamento differito implementato nelle mappe piatte qui. Ogni inserimento ordina al volo, quindi questo benchmark mostra le tendenze asintotiche:
map: O (N * log (N))
hashmaps: O (N)
vector e flatmap: O (N * N)
Avvertenza : di seguito i 2 test per std::map
ed entrambi flat_map
i messaggi sono difettosi e in realtà testano l' inserimento ordinato (rispetto all'inserimento casuale per altri contenitori. Sì, è confuso, scusa):
Possiamo vedere che l'inserimento ordinato, si traduce in una spinta all'indietro ed è estremamente veloce. Tuttavia, dai risultati non classificati del mio benchmark, posso anche dire che questo non è vicino all'assoluta ottimalità per un back-insertion. A 10k elementi, si ottiene una perfetta ottimalità di back-insertion su un vettore pre-riservato. Il che ci dà 3 milioni di cicli; osserviamo 4.8M qui per l'inserimento ordinato nel flat_map
(quindi 160% dell'ottimale).
Analisi: ricorda che questo è un 'inserimento casuale' per il vettore, quindi l'enorme miliardo di cicli deriva dal dover spostare metà (in media) dei dati verso l'alto (un elemento per un elemento) ad ogni inserimento.
Ricerca casuale di 3 elementi (orologi rinormalizzati a 1)
in taglia = 100
di dimensioni = 10000
Iterazione
oltre la taglia 100 (solo tipo MediumPod)
oltre la taglia 10000 (solo tipo MediumPod)
Granello di sale finale
Alla fine volevo tornare su "Benchmarking §3 Pt1" (l'allocatore di sistema). In un recente esperimento che sto facendo sulle prestazioni di una mappa hash di indirizzi aperti che ho sviluppato , ho misurato un divario di prestazioni di oltre il 3000% tra Windows 7 e Windows 8 su alcuni std::unordered_map
casi d'uso ( discusso qui ).
Il che mi fa venir voglia di mettere in guardia il lettore sui risultati di cui sopra (sono stati realizzati su Win7): il tuo chilometraggio può variare.
i migliori saluti