L'implementazione di gcc std :: unordered_map è lenta? In caso affermativo, perché?


100

Stiamo sviluppando un software critico ad alte prestazioni in C ++. Lì abbiamo bisogno di una mappa hash simultanea e ne implementiamo una. Quindi abbiamo scritto un benchmark per capire quanto più lenta viene confrontata la nostra mappa hash simultanea std::unordered_map.

Ma std::unordered_mapsembra essere incredibilmente lento ... Quindi questo è il nostro micro-benchmark (per la mappa simultanea abbiamo generato un nuovo thread per assicurarci che il blocco non venga ottimizzato e nota che non inserisco mai 0 perché faccio anche benchmark con google::dense_hash_map, che necessita di un valore nullo):

boost::random::mt19937 rng;
boost::random::uniform_int_distribution<> dist(std::numeric_limits<uint64_t>::min(), std::numeric_limits<uint64_t>::max());
std::vector<uint64_t> vec(SIZE);
for (int i = 0; i < SIZE; ++i) {
    uint64_t val = 0;
    while (val == 0) {
        val = dist(rng);
    }
    vec[i] = val;
}
std::unordered_map<int, long double> map;
auto begin = std::chrono::high_resolution_clock::now();
for (int i = 0; i < SIZE; ++i) {
    map[vec[i]] = 0.0;
}
auto end = std::chrono::high_resolution_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end - begin);
std::cout << "inserts: " << elapsed.count() << std::endl;
std::random_shuffle(vec.begin(), vec.end());
begin = std::chrono::high_resolution_clock::now();
long double val;
for (int i = 0; i < SIZE; ++i) {
    val = map[vec[i]];
}
end = std::chrono::high_resolution_clock::now();
elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end - begin);
std::cout << "get: " << elapsed.count() << std::endl;

(EDIT: l'intero codice sorgente può essere trovato qui: http://pastebin.com/vPqf7eya )

Il risultato per std::unordered_mapè:

inserts: 35126
get    : 2959

Per google::dense_map:

inserts: 3653
get    : 816

Per la nostra mappa simultanea supportata a mano (che esegue il blocco, sebbene il benchmark sia a thread singolo, ma in un thread di spawn separato):

inserts: 5213
get    : 2594

Se compilo il programma di benchmark senza il supporto di pthread ed eseguo tutto nel thread principale, ottengo i seguenti risultati per la nostra mappa simultanea supportata a mano:

inserts: 4441
get    : 1180

Compilo con il seguente comando:

g++-4.7 -O3 -DNDEBUG -I/tmp/benchmap/sparsehash-2.0.2/src/ -std=c++11 -pthread main.cc

Quindi, in particolare, gli inserti std::unordered_mapsembrano essere estremamente costosi: 35 secondi contro 3-5 secondi per altre mappe. Anche il tempo di ricerca sembra essere piuttosto alto.

La mia domanda: perché è questo? Ho letto un'altra domanda su stackoverflow in cui qualcuno chiede, perché std::tr1::unordered_mapè più lento della sua implementazione. La risposta con il punteggio più alto afferma che è std::tr1::unordered_mapnecessario implementare un'interfaccia più complicata. Ma non riesco a vedere questo argomento: usiamo un approccio bucket nella nostra concurrent_map, std::unordered_maputilizza anche un approccio bucket ( google::dense_hash_mapnon lo fa, ma std::unordered_mapdovrebbe essere almeno altrettanto veloce della nostra versione sicura della concorrenza supportata a mano?). A parte questo non riesco a vedere nulla nell'interfaccia che forzi una funzione che fa funzionare male la mappa hash ...

Quindi la mia domanda: è vero che std::unordered_mapsembra essere molto lento? Se no: cosa c'è che non va? Se sì: qual è il motivo.

E la mia domanda principale: perché inserire un valore in un std::unordered_mapcosì terribile costoso (anche se riserviamo abbastanza spazio all'inizio, non funziona molto meglio - quindi il rehashing sembra non essere il problema)?

MODIFICARE:

Prima di tutto: sì, il benchmark presentato non è impeccabile - questo perché ci abbiamo giocato molto ed è solo un trucco (ad esempio la uint64distribuzione per generare int non sarebbe in pratica una buona idea, escludere 0 in un ciclo è un po 'stupido ecc ...).

Al momento la maggior parte dei commenti spiega che posso rendere unordered_map più veloce preallocando abbastanza spazio per esso. Nella nostra applicazione questo non è possibile: stiamo sviluppando un sistema di gestione del database e abbiamo bisogno di una mappa hash per memorizzare alcuni dati durante una transazione (ad esempio informazioni di blocco). Quindi questa mappa può essere qualsiasi cosa da 1 (l'utente fa solo un inserimento e si impegna) a miliardi di voci (se si verificano scansioni complete della tabella). È semplicemente impossibile preallocare spazio sufficiente qui (e allocare solo molto all'inizio consumerà troppa memoria).

Inoltre, mi scuso per non aver espresso la mia domanda abbastanza chiara: non sono davvero interessato a rendere veloce unordered_map (usare la mappa hash densa di Google per noi funziona bene), semplicemente non capisco da dove provengono queste enormi differenze di prestazioni . Non può essere solo una preallocazione (anche con abbastanza memoria preallocata, la mappa densa è un ordine di grandezza più veloce di unordered_map, la nostra mappa simultanea sostenuta a mano inizia con un array di dimensione 64, quindi uno più piccolo di unordered_map).

Allora qual è la ragione di questa cattiva prestazione di std::unordered_map? O diversamente chiesto: si potrebbe scrivere un'implementazione std::unordered_mapdell'interfaccia conforme allo standard e (quasi) veloce come la mappa hash densa di Google? O c'è qualcosa nello standard che impone all'implementatore di scegliere un modo inefficiente per implementarlo?

MODIFICA 2:

Dal profilo vedo che molto tempo viene utilizzato per le divioni intere. std::unordered_mapusa numeri primi per la dimensione dell'array, mentre le altre implementazioni usano potenze di due. Perché std::unordered_mapusa i numeri primi? Per funzionare meglio se l'hashish è cattivo? Per un buon hash non fa differenza.

MODIFICA 3:

Questi sono i numeri per std::map:

inserts: 16462
get    : 16978

Sooooooo: perché gli inserti in un sono std::mappiù veloci degli inserti in un std::unordered_map... intendo WAT? std::mapha una località peggiore (albero vs array), ha bisogno di fare più allocazioni (per insert vs per rehash + plus ~ 1 per ogni collisione) e, cosa più importante: ha un'altra complessità algoritmica (O (logn) vs O (1))!


1
La maggior parte dei contenitori in std sono MOLTO prudenti con le loro stime, darei un'occhiata al conteggio dei bucket che stai utilizzando (specificato nel costruttore) e aumentalo per una stima migliore per il tuo SIZE.
Ylisar

Hai provato concurrent_hash_map da Intel TBB? threadingbuildingblocks.org/docs/help/reference/…
MadScientist

1
@ MadScientist Abbiamo considerato TBB. Il problema è la licenza: è un progetto di ricerca e non siamo ancora sicuri di come lo pubblicheremo (decisamente open source - ma se vogliamo consentirne l'utilizzo in un prodotto commerciale, la GPLv2 è troppo restrittiva). Inoltre è un'altra dipendenza. Ma forse lo useremo in un secondo momento, fino ad ora possiamo vivere bene senza di esso.
Markus Pilman

1
Eseguirlo sotto un profiler, ad esempio valgrind, può essere intuitivo.
Maxim Egorushkin

1
La località in una tabella hash è nella migliore delle ipotesi leggermente migliore della località in un albero, almeno se la funzione hash è "casuale". Questa funzione hash ti assicura di accedere raramente agli oggetti nelle vicinanze negli orari vicini. L'unico vantaggio che hai è che l'array hashtable è un blocco contiguo. Ciò può essere vero comunque per un albero, se l'heap non è frammentato e si costruisce l'albero tutto in una volta. Una volta che la dimensione è maggiore della cache, le differenze di località faranno poca o nessuna differenza per le prestazioni.
Steve314

Risposte:


87

Ho trovato il motivo: è un problema di gcc-4.7 !!

Con gcc-4.7

inserts: 37728
get    : 2985

Con gcc-4.6

inserts: 2531
get    : 1565

Quindi std::unordered_mapin gcc-4.7 è guasto (o la mia installazione, che è un'installazione di gcc-4.7.0 su Ubuntu - e un'altra installazione che è gcc 4.7.1 su debian testing).

Inoltrerò una segnalazione di bug .. fino ad allora: NON usare std::unordered_mapcon gcc 4.7!


C'è qualcosa nel delta da 4.6 che lo causerebbe?
Mark Canlas

30
C'è già un rapporto nella mailing list. La discussione sembra indicare "soluzioni" alla max_load_factorgestione, che hanno portato alla differenza nelle prestazioni.
jxh

Cattivo tempismo per questo bug! Stavo ottenendo prestazioni molto scadenti con unordered_map ma sono contento che sia stato segnalato e "corretto".
Bo Lu

+1 - Che schifo BBBBBUG .. Mi chiedo cosa succede con gcc-4.8.2
ikh

2
Eventuali aggiornamenti su questo bug? Esiste ancora per le versioni successive di GCC (5+)?
rph

21

Immagino che tu non abbia dimensionato correttamente il tuo unordered_map, come suggerito da Ylisar. Quando le catene crescono troppo a lungo unordered_map, l'implementazione di g ++ si ridurrà automaticamente a una tabella hash più grande, e questo sarebbe un grosso ostacolo alle prestazioni. Se ricordo bene, il unordered_mapvalore predefinito è (primo più piccolo più grande di) 100.

Non avevo chronosul mio sistema, quindi ho cronometrato con times().

template <typename TEST>
void time_test (TEST t, const char *m) {
    struct tms start;
    struct tms finish;
    long ticks_per_second;

    times(&start);
    t();
    times(&finish);
    ticks_per_second = sysconf(_SC_CLK_TCK);
    std::cout << "elapsed: "
              << ((finish.tms_utime - start.tms_utime
                   + finish.tms_stime - start.tms_stime)
                  / (1.0 * ticks_per_second))
              << " " << m << std::endl;
}

Ho usato un SIZEdi 10000000e ho dovuto cambiare un po 'le cose per la mia versione di boost. Nota inoltre, ho pre-dimensionato la tabella hash in modo che corrisponda SIZE/DEPTH, dove DEPTHè una stima della lunghezza della catena del bucket a causa delle collisioni hash.

Modifica: Howard mi fa notare nei commenti che il fattore di carico massimo per unordered_mapè 1. Quindi, DEPTHcontrolla quante volte il codice verrà rehash.

#define SIZE 10000000
#define DEPTH 3
std::vector<uint64_t> vec(SIZE);
boost::mt19937 rng;
boost::uniform_int<uint64_t> dist(std::numeric_limits<uint64_t>::min(),
                                  std::numeric_limits<uint64_t>::max());
std::unordered_map<int, long double> map(SIZE/DEPTH);

void
test_insert () {
    for (int i = 0; i < SIZE; ++i) {
        map[vec[i]] = 0.0;
    }
}

void
test_get () {
    long double val;
    for (int i = 0; i < SIZE; ++i) {
        val = map[vec[i]];
    }
}

int main () {
    for (int i = 0; i < SIZE; ++i) {
        uint64_t val = 0;
        while (val == 0) {
            val = dist(rng);
        }
        vec[i] = val;
    }
    time_test(test_insert, "inserts");
    std::random_shuffle(vec.begin(), vec.end());
    time_test(test_insert, "get");
}

Modificare:

Ho modificato il codice in modo da poterlo cambiare DEPTHpiù facilmente.

#ifndef DEPTH
#define DEPTH 10000000
#endif

Quindi, per impostazione predefinita, viene scelta la dimensione peggiore per la tabella hash.

elapsed: 7.12 inserts, elapsed: 2.32 get, -DDEPTH=10000000
elapsed: 6.99 inserts, elapsed: 2.58 get, -DDEPTH=1000000
elapsed: 8.94 inserts, elapsed: 2.18 get, -DDEPTH=100000
elapsed: 5.23 inserts, elapsed: 2.41 get, -DDEPTH=10000
elapsed: 5.35 inserts, elapsed: 2.55 get, -DDEPTH=1000
elapsed: 6.29 inserts, elapsed: 2.05 get, -DDEPTH=100
elapsed: 6.76 inserts, elapsed: 2.03 get, -DDEPTH=10
elapsed: 2.86 inserts, elapsed: 2.29 get, -DDEPTH=1

La mia conclusione è che non c'è molta differenza significativa nelle prestazioni per qualsiasi dimensione iniziale della tabella hash oltre a renderla uguale all'intero numero previsto di inserimenti univoci. Inoltre, non vedo la differenza di prestazioni dell'ordine di grandezza che stai osservando.


6
std::unordered_mapha un fattore di carico massimo predefinito di 1. Quindi, ad eccezione del numero iniziale di bucket, la tua PROFONDITÀ viene ignorata. Se lo desideri puoi map.max_load_factor(DEPTH).
Howard Hinnant

@ HowardHinnant: Grazie per queste informazioni. Quindi DEPTHviene ignorato, ma controlla comunque la frequenza con cui la mappa verrà modificata in una mappa più grande. La risposta è stata aggiornata e grazie ancora
jxh

@ user315052 Sì, lo so che posso migliorarlo dandogli una dimensione sana all'inizio - ma non posso farlo nel nostro software (è un progetto di ricerca - un DBMS - e lì non posso sapere quanto inserirò - può variare tra 0 e 1 miliardo ...). Ma anche con la preallicazione è più lento della nostra mappa e molto più lento di Google dense_map - Mi chiedo ancora cosa fa la grande differenza.
Markus Pilman

@MarkusPilman: non so come i miei risultati siano paragonabili ai tuoi, perché non hai mai fornito quanto grande SIZEstavi lavorando. Posso dire che unordered_mapè due volte più veloce con DEPTHimpostato su 1e correttamente preallocato.
jxh

1
@MarkusPilman: I miei tempi sono già in pochi secondi. Pensavo che i tuoi tempi fossero in millisecondi. Se gli inserimenti DEPTHimpostati su 1richiedono meno di 3secondi, in che modo questo è un ordine di grandezza più lento?
jxh

3

Ho eseguito il tuo codice utilizzando un computer a 64 bit / AMD / 4 core (2,1 GHz) e mi ha dato i seguenti risultati:

MinGW-W64 4.9.2:

Utilizzando std :: unordered_map:

inserts: 9280 
get: 3302

Utilizzando std :: map:

inserts: 23946
get: 24824

VC 2015 con tutti i flag di ottimizzazione che conosco:

Utilizzando std :: unordered_map:

inserts: 7289
get: 1908

Utilizzando std :: map:

inserts: 19222 
get: 19711

Non ho testato il codice usando GCC ma penso che possa essere paragonabile alle prestazioni di VC, quindi se questo è vero, allora GCC 4.9 std :: unordered_map è ancora rotto.

[MODIFICARE]

Quindi sì, come qualcuno ha detto nei commenti, non c'è motivo di pensare che le prestazioni di GCC 4.9.x sarebbero paragonabili alle prestazioni di VC. Quando avrò la modifica, testerò il codice su GCC.

La mia risposta è solo per stabilire una sorta di base di conoscenza per altre risposte.


"Non ho testato il codice utilizzando GCC ma penso che possa essere paragonabile alle prestazioni di VC." Affermazione totalmente infondata, senza alcun benchmark paragonabile a quello riscontrato nel post originale. Questa "risposta" non risponde alla domanda in alcun senso, per non parlare della risposta alla domanda "perché".
4ae1e1

2
"Non ho testato il codice utilizzando GCC" ... com'è che sei riuscito ad acquisire e utilizzare MinGW pur sapendone così poco? MinGW è fondamentalmente un porto di monitoraggio da vicino di GCC.
underscore_d
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.