Sto costruendo una classe di albero AVL che avrà un numero massimo fisso di elementi. Così ho pensato, invece di allocare ciascun elemento da solo, assegnerei l'intero blocco in una sola volta e userei una bitmap per assegnare nuova memoria quando necessario.Prestazioni degli allocatori personalizzati
Il mio codice di allocazione/deallocazione:
avltree::avltree(UINT64 numitems)
{
root = NULL;
if (!numitems)
buffer = NULL;
else {
UINT64 memsize = sizeof(avlnode) * numitems + bitlist::storagesize(numitems);
buffer = (avlnode *) malloc(memsize);
memmap.init(numitems, buffer + numitems);
memmap.clear_all();
freeaddr = 0;
}
}
avlnode *avltree::newnode(keytype key)
{
if (!buffer)
return new avlnode(key);
else
{
UINT64 pos;
if (freeaddr < memmap.size_bits)
pos = freeaddr++;
else
pos = memmap.get_first_unset();
memmap.set_bit(pos);
return new (&buffer[pos]) avlnode(key);
}
}
void avltree::deletenode(avlnode *node)
{
if (!buffer)
delete node;
else
memmap.clear_bit(node - buffer);
}
Per poter utilizzare nuovo standard/delete, devo costruire l'albero con numItems == 0. Per poter usare il mio allocatore, mi basta passare il numero di articoli. Tutte le funzioni sono in linea per le massime prestazioni.
Questo è tutto a posto, ma il mio allocatore è il 20% più lento del nuovo/cancella. Ora, so quanto siano complessi gli allocatori di memoria, non c'è modo che il codice possa essere eseguito più velocemente di una ricerca di array + un bit set, ma questo è esattamente il caso qui. Cosa c'è di peggio: il mio deallocator è più lento anche se rimuovo tutto il codice da esso?!?
Quando controllo l'output dell'assieme, il percorso del codice del mio allocatore è guidato con istruzioni PTR di QWORD che trattano con bitmap, avltree o avlnode. Non sembra essere molto diverso per il nuovo/percorso di cancellazione.
Per esempio, la produzione di assemblaggio di avltree :: newnode:
;avltree::newnode, COMDAT
mov QWORD PTR [rsp+8], rbx
push rdi
sub rsp, 32
;if (!buffer)
cmp QWORD PTR [rcx+8], 0
mov edi, edx
mov rbx, rcx
jne SHORT [email protected]
; return new avlnode(key);
mov ecx, 24
call [email protected][email protected] ; operator new
jmp SHORT [email protected]
;[email protected]:
;else {
; UINT64 pos;
; if (freeaddr < memmap.size_bits)
mov r9, QWORD PTR [rcx+40]
cmp r9, QWORD PTR [rcx+32]
jae SHORT [email protected]
; pos = freeaddr++;
lea rax, QWORD PTR [r9+1]
mov QWORD PTR [rcx+40], rax
; else
jmp SHORT [email protected]
[email protected]:
; pos = memmap.get_first_unset();
add rcx, 16
call [email protected]@@QEAA_KXZ ; bitlist::get_first_unset
mov r9, rax
[email protected]:
; memmap.set_bit(pos);
mov rcx, QWORD PTR [rbx+16] ;data[bindex(pos)] |= bmask(pos);
mov rdx, r9 ;return pos/(sizeof(BITINT) * 8);
shr rdx, 6
lea r8, QWORD PTR [rcx+rdx*8] ;data[bindex(pos)] |= bmask(pos);
movzx ecx, r9b ;return 1ull << (pos % (sizeof(BITINT) * 8));
mov edx, 1
and cl, 63
shl rdx, cl
; return new (&buffer[pos]) avlnode(key);
lea rcx, QWORD PTR [r9+r9*2]
; File c:\projects\vvd\vvd\util\bitlist.h
or QWORD PTR [r8], rdx ;data[bindex(pos)] |= bmask(pos)
; 195 : return new (&buffer[pos]) avlnode(key);
mov rax, QWORD PTR [rbx+8]
lea rax, QWORD PTR [rax+rcx*8]
; [email protected]:
test rax, rax
je SHORT [email protected]
; avlnode constructor;
mov BYTE PTR [rax+4], 1
mov QWORD PTR [rax+8], 0
mov QWORD PTR [rax+16], 0
mov DWORD PTR [rax], edi
; 196 : }
; 197 : }
; [email protected]:
mov rbx, QWORD PTR [rsp+48]
add rsp, 32 ; 00000020H
pop rdi
ret 0
[email protected]@@[email protected]@[email protected] ENDP ; avltree::newnode
_TEXT ENDS
Ho controllato più volte l'uscita della compilation, quando costruisco la mia avltree con default/allocatore personalizzato e rimane lo stesso in questa particolare regione di codice. Ho provato a rimuovere/sostituire tutte le parti rilevanti senza alcun effetto significativo.
Per essere onesti, mi aspettavo che il compilatore aggiungesse tutto questo in quanto ci sono pochissime variabili. Speravo che tutto, tranne gli oggetti avlnode, venissero inseriti nei registri, ma non sembra essere il caso.
Tuttavia, la differenza di velocità è chiaramente misurabile. Non sto chiamando 3 secondi per 10 milioni di nodi inseriti lentamente, ma mi aspettavo che il mio codice fosse più veloce, non più lento di un allocatore generico (2,5 secondi). Ciò vale soprattutto per il deallocator più lento, che è più lento anche quando tutto il codice viene rimosso da esso.
Perché è più lento?
Modifica: Grazie a tutti per gli eccellenti pensieri su questo. Ma vorrei sottolineare ancora una volta che il problema non è tanto nel mio metodo di allocazione quanto nel modo subottimale di usare le variabili: l'intera classe avltree contiene solo 4 variabili UINT64, solo la bitlist ha 3.
Tuttavia, nonostante ciò, il compilatore non lo ottimizza nei registri. Insiste su istruzioni PW di QWORD che sono ordini di grandezza più lenti. È perché sto usando le lezioni? Dovrei passare a C/variabili semplici?
Gratta che. Che stupido. Ho anche tutto il codice del gioco, le cose non possono essere nei registri.
Inoltre, sono a una perdita totale perché il mio deallocator sarebbe ancora più lento, anche se rimuovo TUTTO il codice da esso. Eppure QueryPerformanceCounter mi dice proprio questo. È assurdo pensare che: anche lo stesso deallocator viene chiamato per il nuovo/delete code path e deve cancellare il nodo ... Non deve fare nulla per il mio allocatore personalizzato (quando spoglio il codice).
Edit2: Ora ho rimosso completamente la bitlist e implementato il tracciamento dello spazio libero tramite un elenco collegato singolarmente. La funzione avltree :: newnode ora è molto più compatta (21 istruzioni per il mio percorso di allocazione personalizzato, 7 di queste sono operazioni PW di QWORD che si occupano di avltree e 4 sono usate per il costruttore di avlnode). Il risultato finale (tempo) è diminuito da ~ 3 secondi a ~ 2,95 secondi per 10 milioni di allocazioni.
Edit3: Ho anche riscritto l'intero codice in modo che ora tutto sia gestito dalla lista concatenata. Ora la classe avltree ha solo due membri rilevanti: root e first_free. La differenza di velocità rimane.
Edit4: Riorganizzare il codice e guardando i dati di performance, queste cose sono ciò che ha aiutato di più:
- Come sottolineato da tutti i contribuenti, avendo una bitmap in c'era semplicemente male. Rimosso a favore dell'elenco di slot gratuiti collegati singolarmente.
- Località del codice: aggiungendo le funzioni dipendenti (quelle di gestione avl) in una classe locale della funzione anziché averle dichiarate globalmente ha aiutato circa il 15% con la velocità del codice (3 secondi -> 2,5 secondi)
- dimensione struttura avlnode : solo l'aggiunta di
#pragma pack(1)
prima dichiarazione struct una diminuzione del tempo di esecuzione di un ulteriore 20% (2,5 sec -> 2 sec)
Edit 5:
Dal momento che questo querstion sembra essere stato molto popolare, Ho pubblicato il codice completo finale come risposta sotto. Sono abbastanza soddisfatto delle sue prestazioni.
Sembra che tu stia facendo una ricerca lineare con "get_first_unset" una volta che è stato riempito, questo danneggerà le prestazioni. La scelta della struttura dati tradizionale per questo caso sarebbe una lista libera collegata separatamente con il puntatore successivo che sovrappone i dati allocati, evitando così la ricerca. L'allocatore standard probabilmente ha un paio di allocatori di pool specializzati per oggetti di lunghezza fissa, sebbene debba ancora soffrire di un sovraccarico aggiuntivo per gestire il caso generale (inferenza, blocco, ecc.) Della dimensione dell'oggetto. – doynax
Sì, ma per il benchmark la ricerca lineare non viene mai chiamata a causa del collegamento freeaddr. – velis