Minha pergunta é: como não estou repetindo linearmente uma matriz contígua por vez nesses casos, estou imediatamente sacrificando os ganhos de desempenho ao alocar componentes dessa maneira?
As chances são de que você receba menos erros de cache em geral com matrizes "verticais" separadas por tipo de componente do que intercalando os componentes anexados a uma entidade em um bloco de tamanho variável "horizontal", por assim dizer.
O motivo é que, primeiro, a representação "vertical" tenderá a usar menos memória. Você não precisa se preocupar com o alinhamento de matrizes homogêneas alocadas de forma contígua. Com tipos não homogêneos alocados em um conjunto de memórias, você precisa se preocupar com o alinhamento, pois o primeiro elemento da matriz pode ter um tamanho e requisitos de alinhamento totalmente diferentes do segundo. Como resultado, muitas vezes você precisará adicionar preenchimento, como um exemplo simples:
// Assuming 8-bit chars and 64-bit doubles.
struct Foo
{
// 1 byte
char a;
// 1 byte
char b;
};
struct Bar
{
// 8 bytes
double opacity;
// 8 bytes
double radius;
};
Digamos que queremos intercalar Foo
e Bar
armazená-los um ao lado do outro na memória:
// Assuming 8-bit chars and 64-bit doubles.
struct FooBar
{
// 1 byte
char a;
// 1 byte
char b;
// 6 bytes padding for 64-bit alignment of 'opacity'
// 8 bytes
double opacity;
// 8 bytes
double radius;
};
Agora, em vez de usar 18 bytes para armazenar Foo e Bar em regiões de memória separadas, são necessários 24 bytes para fundi-los. Não importa se você troca o pedido:
// Assuming 8-bit chars and 64-bit doubles.
struct BarFoo
{
// 8 bytes
double opacity;
// 8 bytes
double radius;
// 1 byte
char a;
// 1 byte
char b;
// 6 bytes padding for 64-bit alignment of 'opacity'
};
Se você usar mais memória em um contexto de acesso seqüencial sem melhorar significativamente os padrões de acesso, geralmente ocorrerá mais falhas de cache. Além disso, o passo para passar de uma entidade para a próxima aumenta e para um tamanho variável, fazendo com que você salte de tamanho variável na memória para passar de uma entidade para a próxima apenas para ver quais têm os componentes que você possui. re interessado.
Portanto, é mais provável que o uso de uma representação "vertical", como o armazenamento de tipos de componentes, seja ideal do que as alternativas "horizontais". Dito isto, o problema com falhas de cache na representação vertical pode ser exemplificado aqui:
Onde as setas simplesmente indicam que a entidade "possui" um componente. Podemos ver que, se tentarmos acessar todos os componentes de movimento e renderização de entidades que possuem ambos, acabamos pulando por toda parte na memória. Esse tipo de padrão de acesso esporádico pode fazer com que você carregue dados em uma linha de cache para acessar, digamos, um componente de movimento, acesse mais componentes e tenha esses dados antigos despejados, apenas para carregar novamente a mesma região de memória que já foi despejada para outro movimento componente. Portanto, pode ser um desperdício carregar exatamente as mesmas regiões de memória mais de uma vez em uma linha de cache apenas para percorrer e acessar uma lista de componentes.
Vamos limpar um pouco essa bagunça para que possamos ver mais claramente:
Observe que, se você encontrar esse tipo de cenário, geralmente leva muito tempo depois que o jogo começou a rodar, depois que muitos componentes e entidades foram adicionados e removidos. Em geral, quando o jogo começa, você pode adicionar todas as entidades e componentes relevantes, e nesse ponto eles podem ter um padrão de acesso seqüencial muito ordenado e com boa localidade espacial. Depois de muitas remoções e inserções, você pode acabar tendo algo como a bagunça acima.
Uma maneira muito fácil de melhorar essa situação é simplesmente ordenar rapidamente seus componentes com base no ID / índice da entidade que os possui. Nesse ponto, você obtém algo como isto:
E esse é um padrão de acesso muito mais amigável ao cache. Não é perfeito, pois podemos ver que precisamos pular alguns componentes de renderização e movimento aqui e ali, já que nosso sistema está interessado apenas em entidades que possuem os dois , e algumas entidades têm apenas um componente de movimento e outras apenas um componente de renderização , mas você pelo menos acaba processando alguns componentes contíguos (mais na prática, normalmente, pois muitas vezes você anexa componentes de interesse relevantes, como talvez mais entidades em seu sistema que tenham um componente de movimento tenham um componente de renderização do que não).
Mais importante, depois de classificá-las, você não carregará dados de uma região de memória em uma linha de cache apenas para recarregá-los em um único loop.
E isso não requer um design extremamente complexo, apenas uma passagem de classificação de tempo linear de vez em quando, talvez depois de inserir e remover um monte de componentes para um tipo de componente específico; nesse momento, você pode marcá-lo como precisando ser classificado. Uma classificação de raiz razoavelmente implementada (você pode até paralelizar, o que eu faço) pode classificar um milhão de elementos em cerca de 6ms no meu quad-core i7, como exemplificado aqui:
Sorting 1000000 elements 32 times...
mt_sort_int: {0.203000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
mt_sort: {1.248000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
mt_radix_sort: {0.202000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
std::sort: {1.810000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
qsort: {2.777000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
A descrição acima é para classificar um milhão de elementos 32 vezes (incluindo o tempo para os memcpy
resultados antes e depois da classificação). E eu suponho que na maioria das vezes você não terá mais de um milhão de componentes para classificar, portanto, você poderá facilmente esgueirar-se agora e ali, sem causar interrupções visíveis na taxa de quadros.