boost :: flat_map e le sue prestazioni rispetto a map e unordered_map


103

È risaputo nella programmazione che la località della memoria migliora molto le prestazioni a causa dei colpi di cache. Di recente ho scoperto boost::flat_mapquale sia un'implementazione vettoriale di una mappa. Non sembra essere così popolare come il tuo tipico map/ unordered_mapquindi non sono stato in grado di trovare alcun confronto delle prestazioni. Come si confronta e quali sono i migliori casi d'uso?

Grazie!


È importante notare che boost.org/doc/libs/1_70_0/doc/html/boost/container/… afferma che l'inserimento casuale richiede tempo logaritmico, il che implica che il popolamento di un boost :: flat_map (inserendo n elementi casuali) richiede O (n log n ) tempo. Sta mentendo, come è evidente dai grafici nella risposta di @ v.oddou di seguito: l'inserimento casuale è O (n) e n di essi richiede tempo O (n ^ 2).
Don Hatch,

@DonHatch Che ne dici di segnalarlo qui: github.com/boostorg/container/issues ? (potrebbe essere un conteggio del numero di confronti, ma questo è effettivamente fuorviante se non accompagnato da un conteggio del numero di mosse)
Marc Glisse

Risposte:


188

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:

  1. allocator
  2. dimensione del tipo contenuto
  3. costo di realizzazione dell'operazione di copia, operazione di assegnazione, operazione di spostamento, operazione di costruzione, di tipo contenuto.
  4. numero di elementi nel contenitore (dimensione del problema)
  5. il tipo ha 3 operazioni banali
  6. 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 mapvs. vector, perché la loro località nella cache è buona, ma mapframmenta 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_mapo qualcosa di simile: una mappa hash di indirizzi aperti.

Il problema delle mappe hash degli indirizzi aperte è che al momento rehashdevono 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) + epsilonquesto 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 intchiave e __int64/ somestructcome 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: inserimento casuale 100

inserimento casuale 10000

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::maped entrambi flat_mapi messaggi sono difettosi e in realtà testano l' inserimento ordinato (rispetto all'inserimento casuale per altri contenitori. Sì, è confuso, scusa):
inserto misto di 100 elementi senza prenotazione

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).

inserto misto di 10000 elementi senza prenotazione 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

ricerca rand all'interno di un contenitore di 100 elementi

di dimensioni = 10000

ricerca rand all'interno di un contenitore di 10000 elementi

Iterazione

oltre la taglia 100 (solo tipo MediumPod)

Iterazione su 100 baccelli medi

oltre la taglia 10000 (solo tipo MediumPod)

Iterazione oltre 10000 baccelli medi

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_mapcasi 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


1
oh, in quel caso ha senso. Le garanzie a tempo ammortizzato costante di Vector si applicano solo al momento dell'inserimento alla fine. L'inserimento in posizioni casuali dovrebbe avere una media di O (n) per inserto perché tutto ciò che segue il punto di inserimento deve essere spostato in avanti. Quindi ci aspetteremmo un comportamento quadratico nel benchmark che esplode abbastanza velocemente, anche per il piccolo N. Le implementazioni in stile AssocVector probabilmente rimandano l'ordinamento fino a quando non è richiesta una ricerca, per esempio, piuttosto che l'ordinamento dopo ogni inserimento. Difficile dirlo senza vedere il tuo benchmark.
Billy ONeal

1
@BillyONeal: Ah, abbiamo ispezionato il codice con un collega e abbiamo trovato il colpevole, il mio inserimento "casuale" è stato ordinato perché ho usato uno std :: set per assicurarmi che le chiavi inserite fossero uniche. Questa è una semplice imbecillità, ma l'ho risolto con random_shuffle, lo sto ricostruendo ora e alcuni nuovi risultati appariranno presto come modifica. Quindi il test nel suo stato attuale dimostra che "l'inserimento ordinato" è dannatamente veloce.
v.oddou

3
"Intel ha una carta" ← e qui è
isomorphismes

5
Forse mi manca qualcosa di ovvio, ma non capisco perché la ricerca casuale sia più lenta flat_maprispetto a std::map- qualcuno è in grado di spiegare questo risultato?
boicottaggio

1
Lo spiegherei come un sovraccarico specifico dell'implementazione del boost di questo periodo, e non come un carattere intrinseco del flat_mapcome contenitore. Perché la Aska::versione è più veloce della std::mapricerca. Dimostrando che c'è spazio per l'ottimizzazione. Le prestazioni attese sono asintoticamente le stesse, ma forse leggermente migliori grazie alla località della cache. Con set di dimensioni elevate dovrebbero convergere.
v.oddou

6

Dai documenti sembra che questo sia analogo a quello di Loki::AssocVectorcui sono un utente abbastanza pesante. Poiché è basato su un vettore, ha le caratteristiche di un vettore, vale a dire:

  • Gli iteratori vengono invalidati ogni volta che sizecresce oltre capacity.
  • Quando cresce oltre capacityha bisogno di riallocare e spostare gli oggetti, cioè l'inserimento non è garantito a tempo costante tranne che per il caso speciale di inserimento al endmomentocapacity > size
  • La ricerca è più veloce di quella a std::mapcausa della località della cache, una ricerca binaria che ha le stesse caratteristiche di prestazioni del std::mapresto
  • Utilizza meno memoria perché non è un albero binario collegato
  • Non si restringe mai a meno che tu non glielo dica forzatamente (poiché ciò innesca la riallocazione)

L'utilizzo migliore è quando si conosce il numero di elementi in anticipo (in modo da poterlo utilizzare in reserveanticipo) o quando l'inserimento / la rimozione è raro ma la ricerca è frequente. L'invalidazione dell'iteratore lo rende un po 'complicato in alcuni casi d'uso, quindi non sono intercambiabili in termini di correttezza del programma.


1
false :) le misurazioni sopra mostrano che la mappa è più veloce di flat_map per le operazioni di ricerca, immagino che boost ppl debba correggere l'implementazione, ma in teoria hai ragione.
NoSenseEtAl
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.