Ottimizzazione delle allocazioni di stringhe ridondanti in C ++


10

Ho un componente C ++ abbastanza complesso le cui prestazioni sono diventate un problema. La profilatura mostra che la maggior parte del tempo di esecuzione viene semplicemente impiegata nell'allocazione della memoria per std::strings.

So che c'è molta ridondanza tra quelle stringhe. Una manciata di valori si ripete molto frequentemente ma ci sono anche molti valori univoci. Le stringhe sono in genere piuttosto brevi.

Ora sto solo pensando se avrebbe senso riutilizzare in qualche modo quelle allocazioni frequenti. Invece di 1000 puntatori a 1000 distinti valori "foobar", potrei avere 1000 puntatori a un valore "foobar". Il fatto che questo sia più efficiente in termini di memoria è un bel vantaggio, ma sono principalmente preoccupato per la latenza qui.

Immagino che un'opzione sarebbe quella di mantenere una sorta di registro di valori già allocati, ma è anche possibile rendere le ricerche del registro più veloci delle allocazioni di memoria ridondanti? È un approccio praticabile?


6
Fattibile? Sì, certamente - altre lingue lo fanno abitualmente (es. Java - cerca il string interning). Una cosa importante da considerare, tuttavia, è che gli oggetti memorizzati nella cache devono essere immutabili, cosa che std :: string non lo è.
Hulk,

2
Questa domanda è più rilevante: stackoverflow.com/q/26130941
rwong

8
Hai analizzato quali tipi di manipolazioni di stringhe dominano la tua applicazione? Si tratta di copia, estrazione di sottostringa, concatenazione, manipolazione carattere per carattere? Ogni tipo di operazione richiede diverse tecniche di ottimizzazione. Inoltre, controlla se l'implementazione del compilatore e della libreria standard supporta "l'ottimizzazione delle stringhe di piccole dimensioni". Infine, se si utilizza lo string interning, anche le prestazioni della funzione hash sono importanti.
rwong

2
Cosa stai facendo con quelle corde? Sono solo usati come una sorta di identificatore o chiave? O sono combinati per creare un output? In tal caso, come si eseguono le concatenazioni di stringhe? Con +operatore o con flussi di stringhe? Da dove provengono le stringhe? Letterali nel tuo codice o input esterni?
am

Risposte:


3

Mi appoggio pesantemente alle stringhe internate come suggerisce Basile, dove una ricerca di stringhe si traduce in un indice a 32 bit da memorizzare e confrontare. Questo è utile nel mio caso poiché a volte ho centinaia di migliaia o milioni di componenti con una proprietà denominata "x", ad esempio, che deve ancora essere un nome stringa user-friendly poiché spesso è accessibile dagli script per nome.

Uso un trie per la ricerca (sperimentato anche con unordered_mapma il mio trie sintonizzato supportato da pool di memoria almeno ha iniziato a funzionare meglio ed è stato anche più facile rendere sicuro il thread senza bloccarlo ogni volta che si accedeva alla struttura) ma non è come veloce per la costruzione come la creazione std::string. Il punto è più quello di accelerare le operazioni successive come il controllo dell'uguaglianza delle stringhe che, nel mio caso, si riduce al controllo dell'uguaglianza tra due numeri interi e alla riduzione drastica dell'utilizzo della memoria.

Immagino che un'opzione sarebbe quella di mantenere una sorta di registro di valori già allocati, ma è anche possibile rendere le ricerche del registro più veloci delle allocazioni di memoria ridondanti?

Sarà difficile effettuare una ricerca attraverso una struttura di dati molto più velocemente di una singola malloc, ad esempio Se hai un caso in cui stai leggendo un carico di stringhe da un input esterno come un file, ad esempio, la mia tentazione sarebbe di usare un allocatore sequenziale, se possibile. Ciò ha il rovescio della medaglia che non è possibile liberare memoria di una singola stringa. Tutta la memoria messa in comune dall'allocatore deve essere liberata in una volta o per niente. Ma un allocatore sequenziale può essere utile nei casi in cui hai solo bisogno di allocare un carico di piccoli pezzi di memoria di dimensioni variabili in modo sequenziale, solo per poi buttare via tutto in seguito. Non so se ciò si applichi nel tuo caso o no, ma quando applicabile, può essere un modo semplice per correggere un hotspot correlato a frequenti allocazioni di memoria per adolescenti (che potrebbe avere più a che fare con mancate cache e errori di pagina rispetto al sottostante algoritmo utilizzato da, diciamo, malloc).

Le allocazioni di dimensioni fisse sono più facili da accelerare senza i vincoli sequenziali dell'allocatore che impediscono di liberare blocchi di memoria specifici da riutilizzare in seguito. Ma rendere l'allocazione di dimensioni variabili più veloce dell'allocatore predefinito è piuttosto difficile. Fondamentalmente rendere qualsiasi tipo di allocatore di memoria più veloce di quanto mallocsia generalmente estremamente difficile se non si applicano vincoli che ne restringono l'applicabilità. Una soluzione consiste nell'utilizzare un allocatore di dimensioni fisse per, per esempio, tutte le stringhe che sono 8 byte o meno se ne hai una barca e stringhe più lunghe sono un caso raro (per il quale puoi semplicemente utilizzare l'allocatore predefinito). Ciò significa che vengono sprecati 7 byte per le stringhe da 1 byte, ma dovrebbe eliminare gli hotspot relativi all'allocazione, se, diciamo, il 95% delle volte, le stringhe sono molto brevi.

Un'altra soluzione che mi è appena venuta in mente è quella di utilizzare elenchi collegati non srotolati che potrebbero sembrare pazzi ma ascoltarmi.

inserisci qui la descrizione dell'immagine

L'idea qui è di rendere ogni nodo srotolato una dimensione fissa anziché una dimensione variabile. Quando lo fai, puoi utilizzare un allocatore di blocchi di dimensioni fisse estremamente veloce che raggruppa la memoria, allocando blocchi di dimensioni fisse per stringhe di dimensioni variabili collegate tra loro. Ciò non ridurrà l'uso della memoria, tenderà ad aggiungerlo a causa del costo dei collegamenti, ma puoi giocare con le dimensioni srotolate per trovare un equilibrio adatto alle tue esigenze. È una specie di idea stravagante, ma dovrebbe eliminare gli hotspot relativi alla memoria poiché ora è possibile raggruppare efficacemente la memoria già allocata in blocchi contigui e avere comunque i vantaggi di liberare le stringhe singolarmente. Ecco un semplice vecchio allocatore fisso che ho scritto (uno illustrativo che ho realizzato per qualcun altro, privo di lanugine legata alla produzione) che puoi usare liberamente:

#ifndef FIXED_ALLOCATOR_HPP
#define FIXED_ALLOCATOR_HPP

class FixedAllocator
{
public:
    /// Creates a fixed allocator with the specified type and block size.
    explicit FixedAllocator(int type_size, int block_size = 2048);

    /// Destroys the allocator.
    ~FixedAllocator();

    /// @return A pointer to a newly allocated chunk.
    void* allocate();

    /// Frees the specified chunk.
    void deallocate(void* mem);

private:
    struct Block;
    struct FreeElement;

    FreeElement* free_element;
    Block* head;
    int type_size;
    int num_block_elements;
};

#endif

#include "FixedAllocator.hpp"
#include <cstdlib>

struct FixedAllocator::FreeElement
{
    FreeElement* next_element;
};

struct FixedAllocator::Block
{
    Block* next;
    char* mem;
};

FixedAllocator::FixedAllocator(int type_size, int block_size): free_element(0), head(0)
{
    type_size = type_size > sizeof(FreeElement) ? type_size: sizeof(FreeElement);
    num_block_elements = block_size / type_size;
    if (num_block_elements == 0)
        num_block_elements = 1;
}

FixedAllocator::~FixedAllocator()
{
    // Free each block in the list, popping a block until the stack is empty.
    while (head)
    {
        Block* block = head;
        head = head->next;
        free(block->mem);
        free(block);
    }
    free_element = 0;
}

void* FixedAllocator::allocate()
{
    // Common case: just pop free element and return.
    if (free_element)
    {
        void* mem = free_element;
        free_element = free_element->next_element;
        return mem;
    }

    // Rare case when we're out of free elements.
    // Create new block.
    Block* new_block = static_cast<Block*>(malloc(sizeof(Block)));
    new_block->mem = malloc(type_size * num_block_elements);
    new_block->next = head;
    head = new_block;

    // Push all but one of the new block's elements to the free stack.
    char* mem = new_block->mem;
    for (int j=1; j < num_block_elements; ++j)
    {
        void* ptr = mem + j*type_size;
        FreeElement* element = static_cast<FreeElement*>(ptr);
        element->next_element = free_element;
        free_element = element;
    }
    return mem;
}

void FixedAllocator::deallocate(void* mem)
{
    // Just push a free element to the stack.
    FreeElement* element = static_cast<FreeElement*>(mem);
    element->next_element = free_element;
    free_element = element;
}


0

Una volta nella costruzione del compilatore abbiamo usato qualcosa chiamato data-chair (invece di data-bank, una traduzione colloquiale tedesca per DB). Questo ha semplicemente creato un hash per una stringa e usato quello per l'allocazione. Quindi qualsiasi stringa non era un pezzo di memoria su heap / stack ma un codice hash in questa sedia dati. Si potrebbe sostituire Stringcon una tale classe. Ha bisogno di un po 'di rielaborazione del codice, però. E ovviamente questo è utilizzabile solo per le stringhe r / o.


Che dire di copia su scrittura. Se cambiate la stringa, ricalcolate l'hash e lo ripristinate. O non funzionerebbe?
Jerry Jeremiah,

@JerryJeremiah Dipende dalla tua applicazione. È possibile modificare la stringa rappresentata dall'hash e quando si recupera la rappresentazione hash si ottiene il nuovo valore. Nel contesto del compilatore dovresti creare un nuovo hash per una nuova stringa.
qwerty_so

0

Si noti come l'allocazione di memoria e la memoria effettiva utilizzate siano entrambe correlate a scarse prestazioni:

Il costo per l'allocazione effettiva della memoria è, ovviamente, molto elevato. Pertanto std :: string potrebbe già utilizzare l'allocazione sul posto per stringhe di dimensioni ridotte e la quantità di allocazioni effettive potrebbe pertanto essere inferiore a quanto si potrebbe supporre. Nel caso in cui la dimensione di questo buffer non sia abbastanza grande, allora potresti essere ispirato ad esempio dalla classe di stringhe di Facebook ( https://github.com/facebook/folly/blob/master/folly/FBString.h ) che utilizza 23 caratteri internamente prima di allocare.

Vale anche la pena notare il costo dell'utilizzo di molta memoria. Questo è forse il più grande offensore: potresti avere molta RAM nel tuo computer, tuttavia, le dimensioni della cache sono ancora abbastanza piccole da compromettere le prestazioni quando accedi alla memoria che non è già memorizzata nella cache. Puoi leggere qui: https://en.wikipedia.org/wiki/Locality_of_reference


0

Invece di rendere più veloci le operazioni sulle stringhe, un altro approccio è quello di ridurre il numero di operazioni sulle stringhe. Ad esempio, sarebbe possibile sostituire le stringhe con un enum?

Un altro approccio che potrebbe essere utile è usato in Cocoa: ci sono casi in cui hai centinaia o migliaia di dizionari, tutti con la stessa chiave. Quindi ti permettono di creare un oggetto che è un insieme di chiavi del dizionario, e c'è un costruttore di dizionari che accetta un tale oggetto come argomento. Il dizionario si comporta come qualsiasi altro dizionario, ma quando si aggiunge una coppia chiave / valore con una chiave in quel set di chiavi, la chiave non viene duplicata ma viene memorizzato solo un puntatore alla chiave nel set di chiavi. Quindi queste migliaia di dizionari hanno bisogno solo di una copia di ogni stringa di chiave in quel set.

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.