As matrizes não contíguas têm desempenho?


12

No C #, quando um usuário cria um List<byte>e adiciona bytes a ele, há uma chance de ficar sem espaço e precisar alocar mais espaço. Aloca o dobro (ou outro multiplicador) do tamanho da matriz anterior, copia os bytes e descarta a referência à matriz antiga. Eu sei que a lista cresce exponencialmente porque cada alocação é cara e isso a limita a O(log n)alocações, onde apenas a adição de 10itens extras a cada vez resultaria em O(n)alocações.

No entanto, para tamanhos de matriz grandes, pode haver muito espaço desperdiçado, talvez quase metade da matriz. Para reduzir a memória, escrevi uma classe semelhante NonContiguousArrayListque usa List<byte>como armazenamento secundário se houvesse menos de 4 MB na lista e alocaria matrizes de bytes adicionais de 4 MB à medida que NonContiguousArrayListaumentasse de tamanho.

Ao contrário List<byte>dessas matrizes, não são contíguas, portanto, não há cópia de dados, apenas uma alocação adicional de 4M. Quando um item é procurado, o índice é dividido por 4M para obter o índice da matriz que contém o item e, em seguida, o módulo 4M para obter o índice dentro da matriz.

Você pode apontar problemas com essa abordagem? Aqui está a minha lista:

  • Matrizes não contíguas não têm localidade de cache, o que resulta em um desempenho ruim. No entanto, no tamanho de um bloco de 4M, parece que haveria localidade suficiente para um bom armazenamento em cache.
  • Acessar um item não é tão simples, há um nível extra de indireção. Isso seria otimizado? Causaria problemas de cache?
  • Como existe um crescimento linear após o limite de 4M, é possível ter muito mais alocações do que normalmente (digamos, no máximo 250 alocações para 1 GB de memória). Nenhuma memória extra é copiada após a 4M, no entanto, não tenho certeza se as alocações extras são mais caras do que copiar grandes pedaços de memória.

8
Você esgotou a teoria (levou em consideração o cache, discutiu a complexidade assintótica), tudo o que resta é conectar os parâmetros (aqui, itens de 4 milhões por sub-lista) e talvez otimizar. Agora é a hora de avaliar, porque sem consertar o hardware e a implementação, há muito poucos dados para discutir o desempenho.

3
Se você estiver trabalhando com mais de 4 milhões de elementos em uma única coleção, espero que a micro-otimização de contêiner seja a menor das suas preocupações de desempenho.
Telastyn

2
O que você descreve é ​​semelhante a uma lista vinculada não rolada (com nós muito grandes). Sua afirmação de que eles não têm localidade de cache está um pouco errada. Apenas uma matriz cabe dentro de uma única linha de cache; digamos 64 bytes. Portanto, a cada 64 bytes, você terá uma falta de cache. Agora considere uma lista vinculada desenrolada cujos nós são precisamente alguns múltiplos de 64 bytes grandes (incluindo o cabeçalho do objeto para coleta de lixo). Você ainda teria apenas uma falta de cache a cada 64 bytes, e nem importaria que os nós não estivessem adjacentes na memória.
Doval

@Doval Não é realmente uma lista vinculada desenrolada, uma vez que os pedaços da 4M são armazenados em uma matriz, portanto, acessar qualquer elemento é O (1) e não O (n / B) onde B é o tamanho do bloco.

2
@ user2313838 Se houvesse 1000 MB de memória e uma matriz de 350 MB, a memória necessária para aumentar a matriz seria 1050 MB, maior do que a disponível, esse é o principal problema, seu limite efetivo é 1/3 do seu espaço total. TrimExcesssó ajudaria quando a lista já estiver criada e, mesmo assim, ainda requer espaço suficiente para a cópia.
Noisecapella

Respostas:


5

Nas escalas que você mencionou, as preocupações são totalmente diferentes daquelas que você mencionou.

Localidade do cache

  • Existem dois conceitos relacionados:
    1. Localidade, a reutilização de dados na mesma linha de cache (local espacial) visitada recentemente (local temporal)
    2. Pré-busca automática de cache (streaming).
  • Nas escalas mencionadas (cem MBs para gigabytes, em blocos de 4 MB), os dois fatores têm mais a ver com o padrão de acesso ao elemento de dados do que com o layout da memória.
  • Minha previsão (sem noção) é que, estatisticamente, pode não haver muita diferença de desempenho do que uma alocação de memória contígua gigante. Sem ganho, sem perda.

Padrão de acesso ao elemento de dados

  • Este artigo ilustra visualmente como os padrões de acesso à memória afetarão o desempenho.
  • Em resumo, lembre-se de que, se seu algoritmo estiver com gargalo na largura de banda da memória, a única maneira de melhorar o desempenho é fazer um trabalho mais útil com os dados que já estão carregados no cache.
  • Em outras palavras, mesmo que YourList[k]e YourList[k+1]com uma alta probabilidade de ser consecutiva (uma em quatro milhões de chances de não ser), esse fato não ajudará o desempenho se você acessar sua lista completamente aleatoriamente ou em grandes passos imprevisíveis, por exemplowhile { index += random.Next(1024); DoStuff(YourList[index]); }

Interação com o sistema GC

Custos indiretos de deslocamento de endereço

  • O código C # típico já está realizando muitos cálculos de deslocamento de endereço, portanto, a sobrecarga adicional do seu esquema não seria pior do que o código C # típico que trabalha em uma única matriz.
    • Lembre-se de que o código C # também faz a verificação do intervalo da matriz; e esse fato não impede que o C # atinja um desempenho comparável no processamento de matriz com o código C ++.
    • O motivo é que o desempenho é principalmente prejudicado pela largura de banda da memória.
    • O truque para maximizar o utilitário a partir da largura de banda da memória é usar as instruções SIMD para operações de leitura / gravação na memória. Nem o C # típico nem o C ++ típico fazem isso; você precisa recorrer a bibliotecas ou complementos de idioma.

Para ilustrar o motivo:

  • Fazer cálculo de endereço
  • (No caso do OP, carregue o endereço base do pedaço (que já está no cache) e faça mais cálculos de endereço)
  • Ler / gravar no endereço do elemento

O último passo ainda leva a maior parte do tempo.

Sugestão pessoal

  • Você pode fornecer uma CopyRangefunção, que se comportaria como uma Array.Copyfunção, mas operaria entre duas instâncias da sua NonContiguousByteArray, ou entre uma instância e outra normal byte[]. essas funções podem usar o código SIMD (C ++ ou C #) para maximizar a utilização da largura de banda da memória e, em seguida, o código C # pode operar no intervalo de cópias sem a sobrecarga de desreferenciamento múltiplo ou cálculo de endereço.

Preocupações de usabilidade e interoperabilidade

  • Aparentemente, você não pode usar isso NonContiguousByteArraycom bibliotecas em C #, C ++ ou em idioma estrangeiro que esperam matrizes de bytes contíguas ou matrizes de bytes que possam ser fixadas.
  • No entanto, se você escrever sua própria biblioteca de aceleração C ++ (com P / Invoke ou C ++ / CLI), poderá passar uma lista de endereços base de vários blocos de 4 MB para o código subjacente.
    • Por exemplo, se você precisar conceder acesso a elementos começando em (3 * 1024 * 1024)e terminando em (5 * 1024 * 1024 - 1), isso significa que o acesso se estenderá por chunk[0]e chunk[1]. Você pode então construir uma matriz (tamanho 2) de matrizes de bytes (tamanho 4M), fixar esses endereços de partes e passá-los ao código subjacente.
  • Outra preocupação de usabilidade é que você não será capaz de implementar a IList<byte>interface com eficiência: Inserte Removelevará muito tempo para processar porque eles exigirão O(N)tempo.
    • De fato, parece que você não pode implementar nada além de IEnumerable<byte>, ou seja, pode ser verificado sequencialmente e é isso.

2
Parece que você perdeu a principal vantagem da estrutura de dados, que permite criar listas muito grandes, sem ficar sem memória. Ao expandir a Lista <T>, ele precisa de uma nova matriz duas vezes maior que a antiga e ambas precisam estar presentes na memória ao mesmo tempo.
precisa saber é o seguinte

6

Vale ressaltar que o C ++ já possui uma estrutura equivalente ao Standard std::deque,. Atualmente, é recomendado como a opção padrão para a necessidade de uma sequência de coisas de acesso aleatório.

A realidade é que a memória contígua é quase completamente desnecessária quando os dados ultrapassam um determinado tamanho - uma linha de cache tem apenas 64 bytes e o tamanho da página é de apenas 4-8 KB (valores típicos atualmente). Quando você começa a falar sobre alguns MB, ele realmente sai da janela como uma preocupação. O mesmo vale para o custo de alocação. O preço de processar todos esses dados - mesmo que apenas os leia - diminui o preço das alocações de qualquer maneira.

O único outro motivo para se preocupar com isso é a interface com as APIs C. Mas você não pode obter um ponteiro para o buffer de uma lista, portanto não há preocupação aqui.


Isso é interessante, eu não sabia que dequetinha uma implementação semelhante
noisecapella

Quem está atualmente recomendando std :: deque? Você pode fornecer uma fonte? Sempre pensei que std :: vector era a opção padrão recomendada.
Teimpz

std::dequeé de fato altamente desencorajado, em parte porque a implementação da biblioteca padrão da Microsoft é muito ruim.
Sebastian Redl

3

Quando os blocos de memória são alocados em diferentes pontos no tempo, como nas sub-matrizes da estrutura de dados, eles podem estar localizados distantes um do outro na memória. Se isso é um problema ou não, depende da CPU e é muito difícil prever mais. Você tem que testar.

Essa é uma excelente ideia e que usei no passado. Obviamente, você deve usar apenas potências de dois para os tamanhos de seu sub-array e deslocamento de bits para divisão (pode acontecer como parte da otimização). Achei esse tipo de estrutura um pouco mais lento, pois os compiladores podem otimizar um único indício de matriz mais facilmente. Você precisa testar, pois esses tipos de otimizações mudam o tempo todo.

A principal vantagem é que você pode correr mais perto do limite superior de memória em seu sistema, desde que use esses tipos de estruturas de forma consistente. Desde que você amplie suas estruturas de dados e não produza lixo, evite coletas de lixo extras que ocorreriam em uma Lista comum. Para uma lista gigante, isso pode fazer uma enorme diferença: a diferença entre continuar executando e ficar sem memória.

As alocações extras são um problema apenas se os blocos da sub-matriz forem pequenos, porque há sobrecarga de memória em cada alocação de matriz.

Eu criei estruturas semelhantes para dicionários (tabelas de hash). O dicionário fornecido pela estrutura .net tem o mesmo problema que a lista. Os dicionários são mais difíceis, pois você também precisa evitar repetições.


Um coletor de compactação pode compactar pedaços próximos um do outro.
precisa saber é

@DeadMG Eu estava me referindo à situação em que isso não pode ocorrer: há outros pedaços no meio, que não são lixo. Com a Lista <T>, você tem memória contígua garantida para sua matriz. Com uma lista em partes, a memória é contígua apenas em uma parte, a menos que você tenha a situação de compactação que você mencionou. Mas uma compactação também pode exigir a movimentação de muitos dados, e grandes matrizes vão para o Large Object Heap. É complicado.
precisa saber é o seguinte

2

Com um tamanho de bloco de 4M, nem mesmo um único bloco é contíguo na memória física; é maior que um tamanho de página típico da VM. Localidade não significativa nessa escala.

Você precisará se preocupar com a fragmentação do heap: se as alocações ocorrerem de modo que seus blocos não sejam contíguos no heap, quando eles forem recuperados pelo GC, você terminará com um heap que pode ser fragmentado demais para caber em um bloco. alocação subsequente. Geralmente, essa é uma situação pior, pois ocorrerão falhas em locais não relacionados e possivelmente forçarão a reinicialização do aplicativo.


GCs compactadores são livres de fragmentação.
precisa saber é

Isso é verdade, mas a compactação LOH está disponível apenas no .NET 4.5 se bem me lembro.
precisa saber é o seguinte

A compactação de heap também pode gerar mais sobrecarga do que o comportamento de cópia na realocação do padrão List.
user2313838

Um objeto grande o suficiente e de tamanho adequado é efetivamente livre de fragmentação de qualquer maneira.
DeadMG

2
@DeadMG: A verdadeira preocupação com a compactação de GC (com esse esquema de 4 MB) é que ele pode estar gastando tempo inútil vasculhando esses bolos de 4 MB. Como resultado, isso pode resultar em grandes pausas no GC. Por esse motivo, ao usar esse esquema de 4 MB, é importante monitorar estatísticas vitais do GC para ver o que está fazendo e tomar ações corretivas.
Rwong

1

Giro algumas das partes mais centrais da minha base de código (um mecanismo ECS) em torno do tipo de estrutura de dados que você descreveu, embora use blocos contíguos menores (mais como 4 kilobytes em vez de 4 megabytes).

insira a descrição da imagem aqui

Ele usa uma lista livre dupla para obter inserções e remoções em tempo constante, com uma lista grátis de blocos gratuitos prontos para serem inseridos (blocos que não estão cheios) e uma lista sub-livre dentro do bloco para índices nesse bloco pronto para ser recuperado após a inserção.

Vou cobrir os prós e contras dessa estrutura. Vamos começar com alguns contras, porque existem vários deles:

Contras

  1. Demora cerca de quatro vezes mais tempo para inserir algumas centenas de milhões de elementos nessa estrutura do que std::vector(uma estrutura puramente contígua). E sou bastante decente em micro-otimizações, mas há apenas mais trabalho conceitualmente a ser feito, pois o caso comum tem que primeiro inspecionar o bloco livre na parte superior da lista de blocos gratuitos, acessar o bloco e exibir um índice livre a partir do bloco. lista livre, escreva o elemento na posição livre e, em seguida, verifique se o bloco está cheio e, se estiver, pop-lo da lista livre de blocos. Ainda é uma operação de tempo constante, mas com uma constante muito maior do que retornar std::vector.
  2. Demora cerca de duas vezes o tempo ao acessar elementos usando um padrão de acesso aleatório, dada a aritmética extra para indexação e a camada extra de indireção.
  3. O acesso seqüencial não é mapeado com eficiência para um design de iterador, pois o iterador precisa executar ramificações adicionais cada vez que é incrementado.
  4. Possui um pouco de sobrecarga de memória, geralmente em torno de 1 bit por elemento. 1 bit por elemento pode não parecer muito, mas se você estiver usando isso para armazenar um milhão de números inteiros de 16 bits, será 6,25% mais uso de memória que um array perfeitamente compacto. No entanto, na prática, isso tende a usar menos memória do que a std::vectormenos que você esteja compactando vectorpara eliminar o excesso de capacidade que ela reserva. Também geralmente não uso para armazenar esses elementos pequeninos.

Prós

  1. O acesso sequencial usando uma for_eachfunção que processa intervalos de elementos de processamento de retorno de chamada em um bloco quase rivaliza com a velocidade do acesso sequencial std::vector(apenas 10% de diferença); portanto, não é muito menos eficiente nos casos de uso mais críticos para mim ( a maior parte do tempo gasto em um mecanismo ECS está em acesso seqüencial).
  2. Permite remoções em tempo constante do meio, com a estrutura desalocando os blocos quando eles ficam completamente vazios. Como resultado, geralmente é bastante decente garantir que a estrutura de dados nunca use significativamente mais memória do que o necessário.
  3. Ele não invalida índices para elementos que não são removidos diretamente do contêiner, pois apenas deixa buracos para trás usando uma abordagem de lista livre para recuperar esses buracos após a inserção subsequente.
  4. Você não precisa se preocupar tanto com a falta de memória, mesmo que essa estrutura contenha um número épico de elementos, uma vez que apenas solicita pequenos blocos contíguos que não representam um desafio para o sistema operacional encontrar um grande número de contíguos não utilizados. Páginas.
  5. Ela se presta bem à concorrência e à segurança de threads sem bloquear toda a estrutura, pois as operações geralmente são localizadas em blocos individuais.

Agora, um dos maiores profissionais para mim foi tornar trivial criar uma versão imutável dessa estrutura de dados, assim:

insira a descrição da imagem aqui

Desde então, isso abriu todos os tipos de portas para escrever mais funções desprovidas de efeitos colaterais, o que tornou muito mais fácil obter segurança de exceção, segurança de rosca etc. essa estrutura de dados em retrospectiva e por acidente, mas sem dúvida um dos melhores benefícios que acabou tendo, pois facilitou muito a manutenção da base de código.

Matrizes não contíguas não têm localidade de cache, o que resulta em um desempenho ruim. No entanto, no tamanho de um bloco de 4M, parece que haveria localidade suficiente para um bom armazenamento em cache.

A localidade de referência não é algo para se preocupar em blocos desse tamanho, muito menos em blocos de 4 kilobytes. Uma linha de cache tem apenas 64 bytes normalmente. Se você deseja reduzir as falhas de cache, concentre-se em alinhar esses blocos corretamente e favorecer padrões de acesso mais seqüenciais quando possível.

Uma maneira muito rápida de transformar um padrão de memória de acesso aleatório em um padrão seqüencial é usar um conjunto de bits. Digamos que você tenha um monte de índices e eles estejam em ordem aleatória. Você pode simplesmente percorrê-los e marcar bits no bitset. Em seguida, você pode percorrer seu conjunto de bits e verificar quais bytes são diferentes de zero, verificando, digamos, 64 bits de cada vez. Depois de encontrar um conjunto de 64 bits dos quais pelo menos um bit está definido, você pode usar as instruções do FFS para determinar rapidamente quais bits estão definidos. Os bits informam quais índices você deve acessar, mas agora você obtém os índices classificados em ordem sequencial.

Isso tem alguma sobrecarga, mas pode ser uma troca interessante em alguns casos, especialmente se você repetir esses índices várias vezes.

Acessar um item não é tão simples, há um nível extra de indireção. Isso seria otimizado? Causaria problemas de cache?

Não, não pode ser otimizado. O acesso aleatório, pelo menos, sempre custará mais com essa estrutura. Muitas vezes, isso não aumenta muito a perda de cache, pois você tenderá a obter alta localidade temporal com a matriz de ponteiros para blocos, especialmente se os caminhos comuns de execução de caso usarem padrões de acesso seqüencial.

Como existe um crescimento linear após o limite de 4M, é possível ter muito mais alocações do que normalmente (digamos, no máximo 250 alocações para 1 GB de memória). Nenhuma memória extra é copiada após a 4M, no entanto, não tenho certeza se as alocações extras são mais caras do que copiar grandes pedaços de memória.

Na prática, a cópia geralmente é mais rápida, porque é um caso raro, ocorrendo apenas algo como o log(N)/log(2)tempo total, simplificando simultaneamente o caso comum barato e barato, onde você pode simplesmente escrever um elemento no array muitas vezes antes que ele fique cheio e precise ser realocado novamente. Normalmente, você não obtém inserções mais rápidas com esse tipo de estrutura, porque o trabalho comum de caso é mais caro, mesmo que não precise lidar com o caso raro e caro de realocar matrizes enormes.

O principal recurso dessa estrutura para mim, apesar de todos os contras, é o uso reduzido de memória, sem ter que me preocupar com o OOM, podendo armazenar índices e indicadores que não são invalidados, simultaneidade e imutabilidade. É bom ter uma estrutura de dados em que você possa inserir e remover coisas em tempo constante enquanto ela se limpa e não invalida indicadores e indicadores na estrutura.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.