Otimizando alocações de seqüência de caracteres redundantes em C ++

10

Eu tenho um componente C ++ bastante complexo, cujo desempenho se tornou um problema. A criação de perfil mostra que a maior parte do tempo de execução é simplesmente alocada memória para std::strings.

Eu sei que há muita redundância entre essas strings. Um punhado de valores se repete com muita frequência, mas também existem muitos valores exclusivos. As strings são geralmente bastante curtas.

Agora estou pensando se faria sentido reutilizar essas alocações frequentes. Em vez de 1000 ponteiros para 1000 valores distintos "foobar", eu poderia ter 1000 ponteiros para um valor "foobar". O fato de que isso seria mais eficiente em termos de memória é um bom bônus, mas estou mais preocupado com a latência aqui.

Eu acho que uma opção seria manter algum tipo de registro de valores já alocados, mas é possível fazer pesquisas de registro mais rapidamente do que alocações de memória redundantes? Essa é uma abordagem viável?

Muton
fonte
6
Factível? Sim, certamente - outras linguagens fazem isso rotineiramente (por exemplo, Java - procure por internação de String). Uma coisa importante a considerar, no entanto, é que os objetos em cache precisam ser imutáveis, o que std :: string não é.
Hulk
2
Esta pergunta é mais relevante: stackoverflow.com/q/26130941
rwong 11/11
8
Você analisou quais tipos de manipulação de string dominam seu aplicativo? É cópia, extração de sub-string, concatenação, manipulação de caractere por caractere? Cada tipo de operação requer diferentes técnicas de otimização. Além disso, verifique se o seu compilador e a implementação da biblioteca padrão oferecem suporte à "otimização de pequenas cadeias". Por fim, se você usar a cadeia interna de caracteres, o desempenho da função hash também é importante.
Rwong
2
O que você está fazendo com essas cordas? Eles são usados ​​apenas como algum tipo de identificador ou chave? Ou eles são combinados para criar alguma saída? Se sim, como você faz concatenações de strings? Com +operador ou com fluxos de strings? De onde vêm as cordas? Literais no seu código ou entrada externa?
amon
4
<view_view> (C ++ 17)
rwong 14/11

Respostas:

3

Eu me apóio muito em seqüências de caracteres internas, como sugere Basile, onde uma pesquisa de sequência se traduz em um índice de 32 bits para armazenar e comparar. Isso é útil no meu caso, já que às vezes tenho centenas de milhares a milhões de componentes com uma propriedade chamada "x", por exemplo, que ainda precisa ser um nome de seqüência de caracteres fácil de usar, pois geralmente é acessado por scripts por nome.

Eu uso um trie para a pesquisa (experimentei também com, unordered_mapmas meu trie sintonizado, apoiado por pools de memória, pelo menos começou com um desempenho melhor e também foi mais fácil tornar o thread-safe sem bloquear apenas toda vez que a estrutura foi acessada), mas não é tão rápido para construção como criação std::string. O ponto é mais para acelerar as operações subseqüentes, como verificar a igualdade de strings, que, no meu caso, se resume apenas a verificar a igualdade de dois números inteiros e reduzir drasticamente o uso de memória.

Eu acho que uma opção seria manter algum tipo de registro de valores já alocados, mas é possível fazer pesquisas de registro mais rapidamente do que alocações de memória redundantes?

Vai ser difícil fazer uma pesquisa através de uma estrutura de dados muito mais rápido do que um único malloc, por exemplo, se você tem um caso em que está lendo um monte de strings de uma entrada externa como um arquivo, por exemplo, minha tentação seria usar um alocador seqüencial, se possível. Isso vem com a desvantagem de que você não pode liberar memória de uma sequência individual. Toda a memória reunida pelo alocador deve ser liberada de uma só vez ou não. Mas um alocador seqüencial pode ser útil nos casos em que você apenas precisa alocar uma carga de pequenos pedaços de memória de tamanho variável de maneira sequencial direta, apenas para depois jogar tudo fora. Não sei se isso se aplica ao seu caso ou não, mas quando aplicável, pode ser uma maneira fácil de corrigir um ponto de acesso relacionado a alocações freqüentes de memória pequenina (que podem ter mais a ver com falhas de cache e falhas de página do que as subjacentes algoritmo usado por, digamos malloc)).

As alocações de tamanho fixo são mais fáceis de acelerar, sem as restrições sequenciais do alocador que impedem que você libere pedaços específicos de memória para reutilização posterior. Mas tornar a alocação de tamanho variável mais rápida que o alocador padrão é bastante difícil. Basicamente, criar qualquer tipo de alocador de memória mais rápido do que mallocgeralmente é extremamente difícil, se você não aplicar restrições que restrinjam sua aplicabilidade. Uma solução é usar um alocador de tamanho fixo para, digamos, todas as cadeias de caracteres com 8 bytes ou menos, se você tiver uma carga de barco delas e cadeias mais longas forem um caso raro (para o qual você pode apenas usar o alocador padrão). Isso significa que 7 bytes são desperdiçados para cadeias de caracteres de 1 byte, mas devem eliminar pontos de acesso relacionados à alocação, se, digamos, 95% do tempo, suas cadeias de caracteres são muito curtas.

Outra solução que me ocorreu é usar listas vinculadas não desenroladas que podem parecer loucuras, mas que me escutam.

insira a descrição da imagem aqui

A idéia aqui é tornar cada nó desenrolado um tamanho fixo em vez de tamanho variável. Ao fazer isso, você pode usar um alocador de bloco de tamanho fixo extremamente rápido que agrupa memória, alocando blocos de tamanho fixo para cadeias de tamanho variável vinculadas. Isso não reduzirá o uso de memória, tenderá a aumentar por causa do custo dos links, mas você pode jogar com o tamanho não enrolado para encontrar um equilíbrio adequado às suas necessidades. É uma idéia meio maluca, mas deve eliminar os pontos ativos relacionados à memória, pois agora você pode agrupar efetivamente a memória já alocada em blocos contíguos e volumosos e ainda ter os benefícios de liberar seqüências individualmente. Aqui está um simples e antigo alocador fixo que escrevi (um ilustrativo que criei para outra pessoa, desprovido de problemas relacionados à produção) que você pode usar livremente:

#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;
}

fonte
0

Uma vez na construção do compilador, usamos algo chamado data-chair (em vez de data-bank, uma tradução coloquial em alemão para DB). Isso simplesmente criou um hash para uma string e o usou para alocação. Portanto, qualquer string não era um pedaço de memória no heap / stack, mas um código de hash para essa cadeira de dados. Você poderia substituir Stringpor essa classe. Precisa de algum retrabalho de código, no entanto. E é claro que isso só é utilizável para seqüências de E / S.

qwerty_so
fonte
E quanto à cópia na gravação. Se você alterar a sequência, recalcula o hash e o restaura. Ou isso não funcionaria?
Jerry Jeremias
@JerryJeremiah Isso depende da sua aplicação. Você pode alterar a sequência representada pelo hash e, ao recuperar a representação do hash, obtém o novo valor. No contexto do compilador, você criaria um novo hash para uma nova string.
Qwerty_so
0

Observe como a alocação de memória e a memória real usada estão relacionadas ao desempenho ruim:

O custo de realmente alocar a memória é, obviamente, muito alto. Portanto, std :: string já pode usar alocação in-loco para pequenas seqüências de caracteres, e a quantidade de alocações reais pode ser menor do que você imagina primeiro. Caso o tamanho desse buffer não seja grande o suficiente, você pode se inspirar na classe de string do Facebook ( https://github.com/facebook/folly/blob/master/folly/FBString.h ), que usa 23 caracteres internamente antes da alocação.

Também vale a pena notar o custo de usar muita memória. Este talvez seja o maior ofensor: você pode ter bastante RAM em sua máquina; no entanto, os tamanhos de cache ainda são pequenos o suficiente para prejudicar o desempenho ao acessar a memória que ainda não está armazenada em cache. Você pode ler sobre isso aqui: https://en.wikipedia.org/wiki/Locality_of_reference

Asger
fonte
0

Em vez de tornar as operações de cadeia mais rápidas, outra abordagem é reduzir o número de operações de cadeia. Seria possível substituir seqüências de caracteres por uma enumeração, por exemplo?

Outra abordagem que pode ser útil é usada no cacau: há casos em que você tem centenas ou milhares de dicionários, todos com a mesma chave. Então, eles permitem que você crie um objeto que é um conjunto de chaves de dicionário e existe um construtor de dicionário que aceita esse objeto como argumento. O dicionário se comporta da mesma forma que qualquer outro dicionário, mas quando você adiciona um par de chave / valor com uma chave nesse conjunto de chaves, a chave não é duplicada, mas apenas um ponteiro para a chave no conjunto de chaves é armazenado. Portanto, esses milhares de dicionários precisam de apenas uma cópia de cada sequência de teclas nesse conjunto.

gnasher729
fonte