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).

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
- 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.
- 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.
- 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.
- 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
- 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).
- 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.
- 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.
- 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.
- 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:

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.