Alguma otimização para acesso aleatório em uma matriz muito grande quando o valor em 95% dos casos é 0 ou 1?

133

Existe alguma otimização possível para acesso aleatório em uma matriz muito grande (atualmente uso uint8_te estou perguntando o que é melhor)

uint8_t MyArray[10000000];

quando o valor em qualquer posição na matriz é

  • 0 ou 1 para 95% de todos os casos,
  • 2 em 4% dos casos,
  • entre 3 e 255 nos outros 1% dos casos?

Então, existe algo melhor do que uma uint8_tmatriz para usar para isso? Deve ser o mais rápido possível fazer um loop sobre toda a matriz em uma ordem aleatória, e isso é muito pesado na largura de banda da RAM; portanto, ao ter mais do que alguns threads fazendo isso ao mesmo tempo para matrizes diferentes, atualmente toda a largura de banda da RAM é rapidamente saturado.

Estou perguntando, pois parece muito ineficiente ter uma matriz tão grande (10 MB) quando se sabe que quase todos os valores, com exceção de 5%, serão 0 ou 1. Portanto, quando 95% de todos os valores na matriz precisaria apenas de 1 bit em vez de 8 bits, isso reduziria o uso de memória em quase uma ordem de magnitude. Parece que deve haver uma solução mais eficiente em termos de memória que reduziria bastante a largura de banda de RAM necessária para isso e, como resultado, também seria significativamente mais rápido para acesso aleatório.

JohnAl
fonte
36
Dois bits (0/1 / veja hashtable) e uma hashtable para valores maiores que 1?
User253751
6
@ user202729 De que depende? Eu acho que isso é uma pergunta interessante para quem precisa fazer algo parecido como eu, então gostaria de ver mais uma solução universal para isso, não uma resposta super específica para o meu código. Se isso depende de alguma coisa, seria bom ter uma resposta explicando do que depende, para que todos que leem possam entender se existe uma solução melhor para o seu próprio caso.
JohnAl
7
Essencialmente, o que você está perguntando é chamado de esparsidade .
Mateen Ulhaq
5
Precisa de mais informações ... Por que o acesso é aleatório e os valores diferentes de zero seguem um padrão?
Ext3h 14/05/19
4
@Iwnotnotististondonotexist Uma etapa de pré-computação seria boa, mas a matriz ainda deve ser modificada de tempos em tempos, para que a etapa de pré-computação não seja muito cara.
JohnAl

Respostas:

155

Uma possibilidade simples que vem à mente é manter uma matriz compactada de 2 bits por valor para os casos comuns e uma matriz separada de 4 bytes por valor (24 bits para o índice do elemento original, 8 bits para o valor real, portanto (idx << 8) | value)). outros.

Quando você pesquisa um valor, primeiro faz uma pesquisa na matriz 2bpp (O (1)); se você encontrar 0, 1 ou 2, é o valor que deseja; se você encontrar 3, significa que você deve procurar na matriz secundária. Aqui, você realizará uma pesquisa binária para procurar o índice de seu interesse deslocado para a esquerda em 8 (O (log (n) com um n pequeno, pois esse deve ser o 1%)) e extrair o valor do 4- byte thingie.

std::vector<uint8_t> main_arr;
std::vector<uint32_t> sec_arr;

uint8_t lookup(unsigned idx) {
    // extract the 2 bits of our interest from the main array
    uint8_t v = (main_arr[idx>>2]>>(2*(idx&3)))&3;
    // usual (likely) case: value between 0 and 2
    if(v != 3) return v;
    // bad case: lookup the index<<8 in the secondary array
    // lower_bound finds the first >=, so we don't need to mask out the value
    auto ptr = std::lower_bound(sec_arr.begin(), sec_arr.end(), idx<<8);
#ifdef _DEBUG
    // some coherency checks
    if(ptr == sec_arr.end()) std::abort();
    if((*ptr >> 8) != idx) std::abort();
#endif
    // extract our 8-bit value from the 32 bit (index, value) thingie
    return (*ptr) & 0xff;
}

void populate(uint8_t *source, size_t size) {
    main_arr.clear(); sec_arr.clear();
    // size the main storage (round up)
    main_arr.resize((size+3)/4);
    for(size_t idx = 0; idx < size; ++idx) {
        uint8_t in = source[idx];
        uint8_t &target = main_arr[idx>>2];
        // if the input doesn't fit, cap to 3 and put in secondary storage
        if(in >= 3) {
            // top 24 bits: index; low 8 bit: value
            sec_arr.push_back((idx << 8) | in);
            in = 3;
        }
        // store in the target according to the position
        target |= in << ((idx & 3)*2);
    }
}

Para uma matriz como a que você propôs, isso deve levar 10000000/4 = 2500000 bytes para a primeira matriz, mais 10000000 * 1% * 4 B = 400000 bytes para a segunda matriz; portanto, 2900000 bytes, ou seja, menos de um terço da matriz original, e a parte mais usada é mantida em conjunto na memória, o que deve ser bom para o cache (pode até caber em L3).

Se você precisar de endereçamento de mais de 24 bits, precisará ajustar o "armazenamento secundário"; uma maneira trivial de estendê-lo é ter uma matriz de ponteiros de 256 elementos para alternar entre os 8 bits principais do índice e encaminhar para uma matriz classificada indexada de 24 bits, como acima.


Referência rápida

#include <algorithm>
#include <vector>
#include <stdint.h>
#include <chrono>
#include <stdio.h>
#include <math.h>

using namespace std::chrono;

/// XorShift32 generator; extremely fast, 2^32-1 period, way better quality
/// than LCG but fail some test suites
struct XorShift32 {
    /// This stuff allows to use this class wherever a library function
    /// requires a UniformRandomBitGenerator (e.g. std::shuffle)
    typedef uint32_t result_type;
    static uint32_t min() { return 1; }
    static uint32_t max() { return uint32_t(-1); }

    /// PRNG state
    uint32_t y;

    /// Initializes with seed
    XorShift32(uint32_t seed = 0) : y(seed) {
        if(y == 0) y = 2463534242UL;
    }

    /// Returns a value in the range [1, 1<<32)
    uint32_t operator()() {
        y ^= (y<<13);
        y ^= (y>>17);
        y ^= (y<<15);
        return y;
    }

    /// Returns a value in the range [0, limit); this conforms to the RandomFunc
    /// requirements for std::random_shuffle
    uint32_t operator()(uint32_t limit) {
        return (*this)()%limit;
    }
};

struct mean_variance {
    double rmean = 0.;
    double rvariance = 0.;
    int count = 0;

    void operator()(double x) {
        ++count;
        double ormean = rmean;
        rmean     += (x-rmean)/count;
        rvariance += (x-ormean)*(x-rmean);
    }

    double mean()     const { return rmean; }
    double variance() const { return rvariance/(count-1); }
    double stddev()   const { return std::sqrt(variance()); }
};

std::vector<uint8_t> main_arr;
std::vector<uint32_t> sec_arr;

uint8_t lookup(unsigned idx) {
    // extract the 2 bits of our interest from the main array
    uint8_t v = (main_arr[idx>>2]>>(2*(idx&3)))&3;
    // usual (likely) case: value between 0 and 2
    if(v != 3) return v;
    // bad case: lookup the index<<8 in the secondary array
    // lower_bound finds the first >=, so we don't need to mask out the value
    auto ptr = std::lower_bound(sec_arr.begin(), sec_arr.end(), idx<<8);
#ifdef _DEBUG
    // some coherency checks
    if(ptr == sec_arr.end()) std::abort();
    if((*ptr >> 8) != idx) std::abort();
#endif
    // extract our 8-bit value from the 32 bit (index, value) thingie
    return (*ptr) & 0xff;
}

void populate(uint8_t *source, size_t size) {
    main_arr.clear(); sec_arr.clear();
    // size the main storage (round up)
    main_arr.resize((size+3)/4);
    for(size_t idx = 0; idx < size; ++idx) {
        uint8_t in = source[idx];
        uint8_t &target = main_arr[idx>>2];
        // if the input doesn't fit, cap to 3 and put in secondary storage
        if(in >= 3) {
            // top 24 bits: index; low 8 bit: value
            sec_arr.push_back((idx << 8) | in);
            in = 3;
        }
        // store in the target according to the position
        target |= in << ((idx & 3)*2);
    }
}

volatile unsigned out;

int main() {
    XorShift32 xs;
    std::vector<uint8_t> vec;
    int size = 10000000;
    for(int i = 0; i<size; ++i) {
        uint32_t v = xs();
        if(v < 1825361101)      v = 0; // 42.5%
        else if(v < 4080218931) v = 1; // 95.0%
        else if(v < 4252017623) v = 2; // 99.0%
        else {
            while((v & 0xff) < 3) v = xs();
        }
        vec.push_back(v);
    }
    populate(vec.data(), vec.size());
    mean_variance lk_t, arr_t;
    for(int i = 0; i<50; ++i) {
        {
            unsigned o = 0;
            auto beg = high_resolution_clock::now();
            for(int i = 0; i < size; ++i) {
                o += lookup(xs() % size);
            }
            out += o;
            int dur = (high_resolution_clock::now()-beg)/microseconds(1);
            fprintf(stderr, "lookup: %10d µs\n", dur);
            lk_t(dur);
        }
        {
            unsigned o = 0;
            auto beg = high_resolution_clock::now();
            for(int i = 0; i < size; ++i) {
                o += vec[xs() % size];
            }
            out += o;
            int dur = (high_resolution_clock::now()-beg)/microseconds(1);
            fprintf(stderr, "array:  %10d µs\n", dur);
            arr_t(dur);
        }
    }

    fprintf(stderr, " lookup |   ±  |  array  |   ±  | speedup\n");
    printf("%7.0f | %4.0f | %7.0f | %4.0f | %0.2f\n",
            lk_t.mean(), lk_t.stddev(),
            arr_t.mean(), arr_t.stddev(),
            arr_t.mean()/lk_t.mean());
    return 0;
}

(código e dados sempre atualizados no meu Bitbucket)

O código acima preenche uma matriz de 10 milhões de elementos com dados aleatórios distribuídos como OP especificado em suas postagens, inicializa minha estrutura de dados e, em seguida:

  • realiza uma pesquisa aleatória de 10 milhões de elementos com minha estrutura de dados
  • faz o mesmo através da matriz original.

(observe que, no caso de pesquisa seqüencial, a matriz sempre vence em grande escala, pois é a pesquisa mais amigável ao cache que você pode fazer)

Esses dois últimos blocos são repetidos 50 vezes e cronometrados; no final, a média e o desvio padrão para cada tipo de pesquisa são calculados e impressos, juntamente com a aceleração (lookup_mean / array_mean).

Compilei o código acima com o g ++ 5.4.0 ( -O3 -static, mais alguns avisos) no Ubuntu 16.04 e o executei em algumas máquinas; a maioria deles está executando o Ubuntu 16.04, alguns Linux mais antigos, outros mais recentes. Eu não acho que o sistema operacional deva ser relevante nesse caso.

            CPU           |  cache   |  lookup s)   |     array s)  | speedup (x)
Xeon E5-1650 v3 @ 3.50GHz | 15360 KB |  60011 ±  3667 |   29313 ±  2137 | 0.49
Xeon E5-2697 v3 @ 2.60GHz | 35840 KB |  66571 ±  7477 |   33197 ±  3619 | 0.50
Celeron G1610T  @ 2.30GHz |  2048 KB | 172090 ±   629 |  162328 ±   326 | 0.94
Core i3-3220T   @ 2.80GHz |  3072 KB | 111025 ±  5507 |  114415 ±  2528 | 1.03
Core i5-7200U   @ 2.50GHz |  3072 KB |  92447 ±  1494 |   95249 ±  1134 | 1.03
Xeon X3430      @ 2.40GHz |  8192 KB | 111303 ±   936 |  127647 ±  1503 | 1.15
Core i7 920     @ 2.67GHz |  8192 KB | 123161 ± 35113 |  156068 ± 45355 | 1.27
Xeon X5650      @ 2.67GHz | 12288 KB | 106015 ±  5364 |  140335 ±  6739 | 1.32
Core i7 870     @ 2.93GHz |  8192 KB |  77986 ±   429 |  106040 ±  1043 | 1.36
Core i7-6700    @ 3.40GHz |  8192 KB |  47854 ±   573 |   66893 ±  1367 | 1.40
Core i3-4150    @ 3.50GHz |  3072 KB |  76162 ±   983 |  113265 ±   239 | 1.49
Xeon X5650      @ 2.67GHz | 12288 KB | 101384 ±   796 |  152720 ±  2440 | 1.51
Core i7-3770T   @ 2.50GHz |  8192 KB |  69551 ±  1961 |  128929 ±  2631 | 1.85

Os resultados são ... misturados!

  1. Em geral, na maioria dessas máquinas, existe algum tipo de aceleração, ou pelo menos elas estão em pé de igualdade.
  2. Os dois casos em que o array realmente supera a pesquisa de "estrutura inteligente" estão em máquinas com muito cache e não muito ocupadas: o Xeon E5-1650 acima (cache de 15 MB) é uma máquina de construção noturna, no momento bastante inativa; o Xeon E5-2697 (cache de 35 MB) é uma máquina para cálculos de alto desempenho, também em um momento ocioso. Faz sentido, a matriz original se encaixa completamente em seu enorme cache, de modo que a estrutura de dados compacta apenas adiciona complexidade.
  3. No lado oposto do "espectro de desempenho" - mas onde novamente a matriz é um pouco mais rápida, há o humilde Celeron que alimenta meu NAS; possui tão pouco cache que nem a matriz nem a "estrutura inteligente" se encaixam nela. Outras máquinas com cache suficientemente pequeno têm desempenho semelhante.
  4. O Xeon X5650 deve ser tomado com cautela - são máquinas virtuais em um servidor de máquina virtual de soquete duplo bastante ocupado; pode muito bem ser que, embora nominalmente tenha uma quantidade razoável de cache, durante o tempo do teste seja impedido por máquinas virtuais completamente independentes várias vezes.
Matteo Italia
fonte
7
@ JohnAl Você não precisa de uma estrutura. A uint32_tvai ficar bem. A exclusão de um elemento do buffer secundário obviamente o deixará classificado. A inserção de um elemento pode ser feita com std::lower_bounde depois insert(em vez de anexar e reorganizar a coisa toda). As atualizações tornam a matriz secundária em tamanho muito mais atraente - eu certamente começaria com isso.
Martin Bonner apoia Monica
6
@ JohnAl Porque o valor é que (idx << 8) + valvocê não precisa se preocupar com a parte do valor - basta usar uma comparação direta. Ele vai sempre comparar menos do que ((idx+1) << 8) + vale inferior a((idx-1) << 8) + val
Martin Bonner suporta Monica
3
@ JohnAl: se isso puder ser útil, adicionei uma populatefunção que deve ser preenchida main_arre de sec_arracordo com o formato lookupesperado. Eu realmente não experimentá-lo, por isso não espere que ele realmente funciona corretamente :-); de qualquer forma, deve lhe dar uma idéia geral.
Matteo Italia
6
Estou dando este +1 apenas para o benchmarking. É bom ver uma pergunta sobre eficiência e também com resultados para vários tipos de processadores! Agradável!
Jack Aidley
2
@JohnAI Você deve criar um perfil para o seu caso de uso real e nada mais. A velocidade da sala branca não importa.
Jack Aidley
33

Outra opção poderia ser

  • verifique se o resultado é 0, 1 ou 2
  • caso contrário, faça uma pesquisa regular

Em outras palavras, algo como:

unsigned char lookup(int index) {
    int code = (bmap[index>>2]>>(2*(index&3)))&3;
    if (code != 3) return code;
    return full_array[index];
}

onde bmapusa 2 bits por elemento com o valor 3 que significa "outro".

Essa estrutura é trivial para atualização, usa 25% mais memória, mas a maior parte é pesquisada apenas em 5% dos casos. Obviamente, como sempre, se é uma boa ideia ou não depende de muitas outras condições, a única resposta é experimentar o uso real.

6502
fonte
4
Eu diria que é um bom compromisso obter o máximo possível de hits do cache (já que a estrutura reduzida pode caber no cache mais facilmente), sem perder muito tempo de acesso aleatório.
Meneldal 14/0518
Eu acho que isso pode ser melhorado ainda mais. Eu tive sucesso no passado com um problema semelhante, mas diferente, em que explorar a predição de ramificação ajudou muito. Pode ajudar a dividir o if(code != 3) return code;emif(code == 0) return 0; if(code==1) return 1; if(code == 2) return 2;
kutschkem 16/05
@kutschkem: nesse caso, __builtin_expect& co ou PGO também podem ajudar.
Matteo Italia
23

Este é mais um "comentário longo" do que uma resposta concreta

A menos que seus dados sejam algo conhecido, duvido que alguém possa DIRETAMENTE responder à sua pergunta (e não conheço nada que corresponda à sua descrição, mas não sei TUDO sobre todos os tipos de padrões de dados para todos. tipos de casos de uso). Dados esparsos são um problema comum na computação de alto desempenho, mas geralmente é "temos uma matriz muito grande, mas apenas alguns valores são diferentes de zero".

Para padrões não conhecidos como o que eu acho que é o seu, ninguém SABE diretamente o que é melhor, e isso depende dos detalhes: quão aleatório é o acesso aleatório - o sistema está acessando grupos de itens de dados ou é completamente aleatório? um gerador uniforme de números aleatórios. Os dados da tabela são completamente aleatórios ou existem sequências de 0 e sequências de 1, com uma dispersão de outros valores? A codificação de comprimento de execução funcionaria bem se você tiver seqüências razoavelmente longas de 0 e 1, mas não funcionará se você tiver "tabuleiro de damas de 0/1". Além disso, você teria que manter uma tabela de "pontos de partida", para poder trabalhar rapidamente no local relevante.

Eu sei há muito tempo que alguns grandes bancos de dados são apenas uma tabela grande na RAM (dados de assinantes de troca telefônica neste exemplo) e um dos problemas é que os caches e as otimizações da tabela de páginas no processador são bastante inúteis. O chamador é tão raramente o mesmo que alguém que ligou recentemente para alguém, que não há dados pré-carregados de qualquer tipo, é puramente aleatório. Tabelas de páginas grandes são a melhor otimização para esse tipo de acesso.

Em muitos casos, comprometer-se entre "velocidade e tamanho pequeno" é uma daquelas coisas que você deve escolher na engenharia de software [em outra engenharia, não é necessariamente um compromisso]. Portanto, "desperdiçar memória para código mais simples" é frequentemente a escolha preferida. Nesse sentido, a solução "simples" provavelmente é melhor para velocidade, mas se você tiver um uso "melhor" para a RAM, a otimização do tamanho da tabela forneceria desempenho suficiente e uma boa melhoria no tamanho. Existem várias maneiras diferentes de conseguir isso - como sugerido em um comentário, um campo de 2 bits em que os dois ou três valores mais comuns são armazenados e, em seguida, algum formato de dados alternativo para os outros valores - uma tabela de hash seria minha primeira abordagem, mas uma lista ou árvore binária pode funcionar também - novamente, isso depende dos padrões de onde você "não é 0, 1 ou 2". Novamente, depende de como os valores estão "dispersos" na tabela - eles estão em clusters ou são mais de um padrão distribuído uniformemente?

Mas um problema é que você ainda está lendo os dados da RAM. Você está gastando mais código processando os dados, incluindo algum código para lidar com o "isso não é um valor comum".

O problema com os algoritmos de compactação mais comuns é que eles são baseados em sequências de desempacotamento, portanto você não pode acessá-los aleatoriamente. E a sobrecarga de dividir seus grandes dados em pedaços de, digamos, 256 entradas por vez, e descompactar os 256 em uma matriz uint8_t, buscar os dados desejados e depois jogar fora os dados não compactados é altamente improvável de lhe dar uma boa desempenho - supondo que isso tenha alguma importância, é claro.

No final, você provavelmente terá que implementar uma ou algumas das idéias nos comentários / respostas para testar, ver se isso ajuda a resolver seu problema ou se o barramento de memória ainda é o principal fator limitante.

Mats Petersson
fonte
Obrigado! No final, só estou interessado em saber o que é mais rápido quando 100% da CPU está ocupada fazendo loop sobre essas matrizes (threads diferentes em matrizes diferentes). Atualmente, com uma uint8_tmatriz, a largura de banda da RAM fica saturada depois que ~ 5 threads estão trabalhando nisso ao mesmo tempo (em um sistema de canal quádruplo), portanto, o uso de mais de 5 threads não oferece mais nenhum benefício. Eu gostaria que ele usasse> 10 threads sem encontrar problemas de largura de banda da RAM, mas se o lado da CPU do acesso se tornar tão lento que 10 threads sejam menos executados que 5 threads antes, isso obviamente não seria um progresso.
JohnAl
@JohnAl Quantos núcleos você possui? Se você está vinculado à CPU, não faz sentido ter mais threads do que núcleos. Além disso, talvez seja hora de olhar para a programação da GPU?
Martin Bonner apoia Monica
@MartinBonner No momento, tenho 12 tópicos. E eu concordo, isso provavelmente funcionaria muito bem em uma GPU.
JohnAl
2
@ JohnAI: Se você está simplesmente executando várias versões do mesmo processo ineficiente em vários threads, sempre verá um progresso limitado. Haverá maiores vitórias no design do seu algoritmo para processamento paralelo do que no aprimoramento de uma estrutura de armazenamento.
Jack Aidley
13

O que eu fiz no passado é usar um hashmap na frente de um bitset.

Isso reduz pela metade o espaço em comparação com a resposta de Matteo, mas pode ser mais lento se as pesquisas de "exceção" forem lentas (ou seja, existem muitas exceções).

Muitas vezes, no entanto, "cache é rei".

o11c
fonte
2
Como exatamente um hashmap reduziria pela metade o espaço em comparação com a resposta de Matteo ? O que deve estar nesse hashmap?
JohnAl
1
@JohnAl Usando um bitet de 1 bit = bitvec em vez de um bitvec de 2 bits.
o11c 14/05/19
2
@ o11c Não sei se entendi direito. Você quer ter uma matriz de valores de 1 bit em que 0significa olharmain_arr e 1significa olhar parasec_arr (no caso do código Matteos)? No entanto, isso precisaria de mais espaço do que a resposta de Matteos, já que é uma matriz adicional. Eu não entendo bem como você faria isso usando apenas metade do espaço em comparação com a resposta Matteos.
JohnAl
1
Você poderia esclarecer isso? Você procura primeiro os casos expecionais e depois o bitmap? Nesse caso, suspeito que a pesquisa lenta no hash sobrecarregue as economias na redução do tamanho do bitmap.
Martin Bonner apoia Monica
Eu pensei que isso era chamado de hashlinking - mas o Google não mostra hits relevantes, portanto deve ser outra coisa. A maneira como geralmente funcionava era dizer uma matriz de bytes que mantinha valores cuja grande maioria estava, digamos, entre 0 e 255. Então você usaria 255 como um sinalizador e, se tivesse um elemento 255, procuraria o valor verdadeiro em uma tabela de hash associada. Alguém pode se lembrar do que foi chamado? (Acho que li sobre isso em um IBM TR antigo.) De qualquer forma, você também pode organizá-lo da maneira que o @ o11c sugere - sempre procure primeiro no hash, se não estiver lá, procure na sua matriz de bits.
Davidbak 14/05
11

A menos que haja um padrão para seus dados, é improvável que exista uma otimização sensata de velocidade ou tamanho e - supondo que você esteja direcionando um computador normal - 10 MB não é tão importante assim.

Há duas suposições em suas perguntas:

  1. Os dados estão sendo mal armazenados porque você não está usando todos os bits
  2. Armazená-lo melhor tornaria as coisas mais rápidas.

Eu acho que essas duas suposições são falsas. Na maioria dos casos, a maneira apropriada de armazenar dados é armazenar a representação mais natural. No seu caso, é para isso que você procurou: um byte para um número entre 0 e 255. Qualquer outra representação será mais complexa e, portanto, todas as outras coisas iguais, mais lentas e propensas a erros. Para se desviar desse princípio geral, você precisa de um motivo mais forte do que seis bits "desperdiçados" em 95% dos seus dados.

Para sua segunda suposição, será verdade se, e somente se, alterar o tamanho da matriz resultar em substancialmente menos falhas de cache. Se isso acontecerá, pode ser determinado definitivamente apenas pela criação de perfil do código de trabalho, mas acho que é altamente improvável que faça uma diferença substancial. Como você acessará aleatoriamente a matriz em ambos os casos, o processador terá dificuldade em saber quais bits de dados armazenar em cache e manter em ambos os casos.

Jack Aidley
fonte
8

Se os dados e acessos forem uniformemente distribuídos aleatoriamente, o desempenho provavelmente dependerá de qual fração dos acessos evitar uma falta de cache no nível externo. A otimização exigirá o conhecimento de qual tamanho de matriz pode ser acomodada de maneira confiável no cache. Se seu cache for grande o suficiente para acomodar um byte para cada cinco células, a abordagem mais simples pode ser manter um byte nos cinco valores codificados de base três no intervalo de 0 a 2 (existem 243 combinações de 5 valores, portanto caber em um byte), juntamente com uma matriz de 10.000.000 de bytes que seria consultada sempre que um valor base-3 indicar "2".

Se o cache não for tão grande, mas puder acomodar um byte por 8 células, não seria possível usar um valor de byte para selecionar dentre todas as 6.561 combinações possíveis de oito valores de base 3, mas como o único efeito de alterar 0 ou 1 para 2 seria causar uma pesquisa desnecessária; a correção não exigiria suporte a todos os 6.561. Em vez disso, pode-se focar nos 256 valores mais "úteis".

Especialmente se 0 for mais comum que 1 ou vice-versa, uma boa abordagem pode ser usar 217 valores para codificar as combinações de 0 e 1 que contêm 5 ou menos 1's, 16 valores para codificar xxxx0000 a xxxx1111, 16 para codificar 0000xxxx a 1111xxxx e um para xxxxxxxx. Restariam quatro valores para qualquer outro uso que se possa encontrar. Se os dados forem distribuídos aleatoriamente conforme descrito, uma pequena maioria de todas as consultas atingiria bytes que continham apenas zeros e uns (em cerca de 2/3 de todos os grupos de oito, todos os bits seriam zeros e uns e cerca de 7/8 de aqueles teriam seis ou menos 1 bits); a grande maioria daqueles que não aterrissariam em um byte que continha quatro x's e teriam 50% de chance de pousar em um zero ou um. Portanto, apenas uma em cada quatro consultas exigiria uma pesquisa de grande variedade.

Se os dados forem distribuídos aleatoriamente, mas o cache não for grande o suficiente para manipular um byte por oito elementos, pode-se tentar usar essa abordagem com cada byte manipulando mais de oito itens, mas a menos que exista uma forte tendência a 0 ou a 1 , a fração de valores que podem ser manipulados sem precisar fazer uma pesquisa na grande matriz diminuirá à medida que o número manipulado por cada byte aumentar.

supercat
fonte
7

Vou acrescentar à resposta do @ o11c , pois as palavras dele podem ser um pouco confusas. Se eu precisar apertar o último bit e o ciclo da CPU, faça o seguinte.

Começaremos construindo uma árvore de pesquisa binária equilibrada que contém os 5% de casos "algo mais". Para cada pesquisa, você percorre a árvore rapidamente: possui 10000000 elementos: 5% dos quais estão na árvore: portanto, a estrutura de dados da árvore contém 500000 elementos. Caminhar isso no tempo O (log (n)) fornece 19 iterações. Não sou especialista nisso, mas acho que existem algumas implementações com eficiência de memória por aí. Vamos adivinhar:

  • Árvore balanceada, para que a posição da subárvore possa ser calculada (os índices não precisam ser armazenados nos nós da árvore). Da mesma maneira que um heap (estrutura de dados) é armazenado na memória linear.
  • Valor de 1 byte (2 a 255)
  • 3 bytes para o índice (10000000 leva 23 bits, o que cabe 3 bytes)

Total, 4 bytes: 500000 * 4 = 1953 kB. Se encaixa no cache!

Para todos os outros casos (0 ou 1), você pode usar um vetor de bits. Observe que você não pode deixar de fora os 5% de outros casos para acesso aleatório: 1,19 MB.

A combinação desses dois usa aproximadamente 3.099 MB. Usando esta técnica, você salvará um fator 3.08 de memória.

No entanto, isso não supera a resposta de @Matteo Italia (que usa 2,76 MB), uma pena. Existe algo que possamos fazer extra? A parte que consome mais memória são os 3 bytes de índice na árvore. Se conseguirmos reduzir para 2, economizaríamos 488 kB e o uso total de memória seria: 2.622 MB, que é menor!

Como vamos fazer isso? Temos que reduzir a indexação para 2 bytes. Novamente, 10000000 leva 23 bits. Precisamos ser capazes de eliminar 7 bits. Podemos simplesmente fazer isso particionando o intervalo de 10000000 elementos em 2 ^ 7 (= 128) regiões de 78125 elementos. Agora podemos construir uma árvore equilibrada para cada uma dessas regiões, com 3906 elementos em média. A escolha da árvore correta é feita por uma simples divisão do índice de destino por 2 ^ 7 (ou um deslocamento de bits>> 7 ). Agora, o índice necessário para armazenar pode ser representado pelos 16 bits restantes. Observe que há alguma sobrecarga no comprimento da árvore que precisa ser armazenada, mas isso é insignificante. Observe também que esse mecanismo de divisão reduz o número necessário de iterações para percorrer a árvore, agora reduz para 7 iterações a menos, porque eliminamos 7 bits: restam apenas 12 iterações.

Observe que teoricamente você pode repetir o processo para cortar os próximos 8 bits, mas isso exigiria a criação de 2 ^ 15 árvores balanceadas, com ~ 305 elementos em média. Isso resultaria em 2,143 MB, com apenas 4 iterações para percorrer a árvore, o que é uma aceleração considerável em comparação com as 19 iterações que iniciamos.

Como conclusão final: isso supera a estratégia de vetor de 2 bits com um pouquinho de uso de memória, mas é uma luta toda a ser implementada. Mas se puder fazer a diferença entre ajustar o cache ou não, pode valer a pena tentar.

Martijn Courteaux
fonte
1
Esforço valente!
Davidbak 15/05
1
Tente o seguinte: como 4% dos casos têm o valor 2 ... crie um conjunto de casos excepcionais (> 1). Crie uma árvore como descrito para casos realmente excepcionais (> 2). Se presente no conjunto e na árvore, use o valor na árvore; se presente no conjunto e não na árvore, use o valor 2; caso contrário (não presente no conjunto), procure no seu vetor de bits. A árvore conterá apenas 100000 elementos (bytes). O conjunto contém 500000 elementos (mas nenhum valor). Isso reduz o tamanho e justifica seu aumento de custo? (100% das pesquisas buscam em conjunto; 5% das pesquisas também precisam procurar na árvore.) #
187 davidbak
Você sempre deseja usar uma matriz classificada em CFBS quando tiver uma árvore imutável, para que não haja alocação para os nós, apenas os dados.
O11c 01/06/19
5

Se você executar apenas operações de leitura, seria melhor não atribuir um valor a um único índice, mas a um intervalo de índices.

Por exemplo:

[0, 15000] = 0
[15001, 15002] = 153
[15003, 26876] = 2
[25677, 31578] = 0
...

Isso pode ser feito com uma estrutura. Você também pode definir uma classe semelhante a essa se gostar de uma abordagem OO.

class Interval{
  private:
    uint32_t start; // First element of interval
    uint32_t end; // Last element of interval
    uint8_t value; // Assigned value

  public:
    Interval(uint32_t start, uint32_t end, uint8_t value);
    bool isInInterval(uint32_t item); // Checks if item lies within interval
    uint8_t getValue(); // Returns the assigned value
}

Agora você só precisa percorrer uma lista de intervalos e verificar se o índice está em um deles, o que pode consumir muito menos memória em média, mas custa mais recursos da CPU.

Interval intervals[INTERVAL_COUNT];
intervals[0] = Interval(0, 15000, 0);
intervals[1] = Interval(15001, 15002, 153);
intervals[2] = Interval(15003, 26876, 2);
intervals[3] = Interval(25677, 31578, 0);
...

uint8_t checkIntervals(uint32_t item)

    for(int i=0; i<INTERVAL_COUNT-1; i++)
    {
        if(intervals[i].isInInterval(item) == true)
        {
            return intervals[i].getValue();
        }
    }
    return DEFAULT_VALUE;
}

Se você solicitar os intervalos por tamanho decrescente, aumenta a probabilidade de encontrar o item que você procura mais cedo, o que diminui ainda mais o uso médio de memória e recursos da CPU.

Você também pode remover todos os intervalos com um tamanho de 1. Coloque os valores correspondentes em um mapa e verifique-os apenas se o item que você está procurando não foi encontrado nos intervalos. Isso também deve elevar um pouco o desempenho médio.

Detonar
fonte
4
Ideia interessante (+1), mas estou um pouco cético de que isso justificaria a sobrecarga, a menos que existam muitas execuções longas de 0 e / ou longas. Na verdade, você está sugerindo o uso de uma codificação de dados de execução. Pode ser bom em algumas situações, mas provavelmente não é uma boa abordagem geral para esse problema.
John Coleman
Certo. Em particular para acesso aleatório, isso é quase certamente mais lento que uma matriz simples ou unt8_t, mesmo que consiga muito menos memória.
leftaroundabout
4

Há muito tempo, eu me lembro ...

Na universidade, temos a tarefa de acelerar um programa traçador de raios, que deve ler repetidamente por algoritmo a partir de matrizes de buffer. Um amigo me disse para sempre usar leituras de RAM que são múltiplos de 4Bytes. Então mudei a matriz de um padrão de [x1, y1, z1, x2, y2, z2, ..., xn, yn, zn] para um padrão de [x1, y1, z1,0, x2, y2, z2 , 0, ..., xn, yn, zn, 0]. Significa adicionar um campo vazio após cada coordenada 3D. Após alguns testes de desempenho: foi mais rápido. Resumindo a história: leia vários RAMs de 4 bytes da matriz e talvez também da posição inicial correta, para ler um pequeno cluster onde o índice pesquisado está nele e ler o índice pesquisado desse pequeno cluster na CPU. (No seu caso, você não precisará inserir campos de preenchimento, mas o conceito deve ser claro)

Talvez também outros múltiplos possam ser a chave em sistemas mais novos.

Não sei se isso funcionará no seu caso, portanto, se não funcionar: desculpe. Se funcionar, eu ficaria feliz em saber sobre alguns resultados dos testes.

PS: Ah, e se houver algum padrão de acesso ou índices acessados ​​nas proximidades, você poderá reutilizar o cluster em cache.

PPS: Pode ser que o fator múltiplo seja mais parecido com 16Bytes ou algo assim, faz muito tempo, que eu me lembro exatamente.

Horitsu
fonte
Você provavelmente está pensando em solteiros, que geralmente têm 32 ou 64 bytes, mas isso não ajuda muito aqui, pois o acesso é aleatório.
205 Surt
3

Olhando para isso, você pode dividir seus dados, por exemplo:

  • um conjunto de bits que é indexado e representa o valor 0 (std :: vector seria útil aqui)
  • um conjunto de bits que é indexado e representa o valor 1
  • um std :: vector para os valores de 2, contendo os índices que se referem a esse valor
  • um mapa para os outros valores (ou std :: vector>)

Nesse caso, todos os valores aparecem até um determinado índice; portanto, você pode remover um dos conjuntos de bits e representar o valor que está faltando nos outros.

Isso economizará um pouco de memória para este caso, mas pioraria o pior. Você também precisará de mais energia da CPU para fazer as pesquisas.

Certifique-se de medir!

JVApen
fonte
1
Um conjunto de bits para uns / zeros. Um conjunto de índices para dois. E uma matriz associativa esparsa para o resto.
Red.Wave
Esse é o breve resumo
JVApen
Deixe o OP conhecer os termos, para que ele possa procurar implementações alternativas de cada um.
Red.Wave
2

Como Mats menciona em sua resposta aos comentários, é difícil dizer qual é realmente a melhor solução sem saber especificamente que tipo de dados você tem (por exemplo, existem longas execuções de zeros e assim por diante) e qual é o seu padrão de acesso como ("aleatório" significa "em todo o lugar" ou apenas "não estritamente de maneira completamente linear" ou "todos os valores exatamente uma vez, apenas aleatoriamente" ou ...).

Dito isto, existem dois mecanismos que vêm à mente:

  • Matrizes de bits; ou seja, se você tivesse apenas dois valores, poderia compactar trivialmente sua matriz por um fator de 8; se você tiver 4 valores (ou "3 valores + tudo o resto"), poderá compactar por um fator de dois. O que pode não valer a pena e precisaria de benchmarks, especialmente se você tiver padrões de acesso realmente aleatórios que escapam dos caches e, portanto, não alteram o tempo de acesso.
  • (index,value)ou (value,index)mesas. Ou seja, tenha uma tabela muito pequena para o caso de 1%, talvez uma tabela para o caso de 5% (que só precisa armazenar os índices, pois todos têm o mesmo valor) e uma grande matriz de bits compactados para os dois casos finais. E com "tabela" quero dizer algo que permite uma pesquisa relativamente rápida; ou seja, talvez um hash, uma árvore binária e assim por diante, dependendo do que você tem disponível e de suas necessidades reais. Se essas subtabelas se encaixam nos caches de primeiro / segundo nível, você pode ter sorte.
AnoE
fonte
1

Eu não estou muito familiarizado com C, mas em C ++ você pode usar char não assinado para representar um número inteiro no intervalo de 0 a 255.

Comparado ao int normal (novamente, eu sou do mundo Java e C ++ ) no qual são necessários 4 bytes (32 bits), um caracter não assinado requer 1 byte (8 bits). portanto, isso pode reduzir o tamanho total da matriz em 75%.

Adi
fonte
Provavelmente já é esse o caso com o uso de uint8_t - 8 significa 8 bits.
Peter Mortensen
-4

Você descreveu sucintamente todas as características de distribuição de sua matriz; atire a matriz .

Você pode facilmente substituir a matriz por um método aleatório que produz a mesma saída probabilística que a matriz.

Se a consistência for importante (produzindo o mesmo valor para o mesmo índice aleatório), considere usar um filtro de bloom e / ou mapa de hash para rastrear hits repetidos. Se os acessos de sua matriz forem realmente aleatórios, isso é totalmente desnecessário.

Dúthomhas
fonte
18
Eu suspeito que o "acesso aleatório" estava sendo usado aqui para indicar que os acessos são imprevisíveis, não que eles sejam realmente aleatórios. (ou seja, ele é destinado, no sentido de "arquivos de acesso aleatório")
Michael Kay
Sim, isso é provável. OP não é claro, no entanto. Se os acessos do OP não forem de forma alguma aleatórios, é indicada alguma forma de matriz esparsa, conforme as outras respostas.
Dúthomhas 14/05
1
Eu acho que você tem razão, já que o OP indicou que ele repetiria toda a matriz em uma ordem aleatória. Para o caso em que apenas as distribuições precisam ser observadas, esta é uma boa resposta.
Ingo Schalk-Schupp