Costumo ler as documentações do mecanismo de jogo do ECS, que são uma boa arquitetura para usar o cache da CPU com sabedoria.
Mas não consigo descobrir como podemos nos beneficiar do cache da CPU.
Se os componentes forem salvos em uma matriz (ou pool), na memória contígua, é uma boa maneira de usar o cache da CPU, MAS apenas se lermos os componentes sequencialmente.
Quando usamos sistemas, eles precisam de uma lista de entidades, que é uma lista de entidades que possuem componentes com tipos específicos.
Mas essas listas fornecem os componentes de maneira aleatória, não sequencial.
Então, como projetar um ECS para maximizar o acerto do cache?
EDIT:
Por exemplo, um sistema Physic precisa de uma lista de entidades para entidades que possuam os componentes RigidBody e Transform (existe um pool para RigidBody e um pool para componentes Transform).
Portanto, seu loop para atualizar entidades será assim:
for (Entity eid in entitiesList) {
// Get rigid body component
RigidBody *rigidBody = entityManager.getComponentFromEntity<RigidBody>(eid);
// Get transform component
Transform *transform = entityManager.getComponentFromEntity<Transform>(eid);
// Do something with rigid body and transform component
}
O problema é que o componente RigidBody da entidade1 pode estar no índice 2 do seu pool e o componente Tranform da entidade1 no índice 0 do seu pool (porque algumas entidades podem ter alguns componentes e não o outro e devido à adição / exclusão de entidades / componentes aleatoriamente).
Portanto, mesmo que os componentes sejam contíguos na memória, eles são lidos aleatoriamente e, portanto, haverá mais falta de cache, não?
A menos que haja uma maneira de pré-buscar os próximos componentes no loop?
Respostas:
O artigo de Mick West explica o processo de linearização completa dos dados dos componentes da entidade. Ele trabalhou para a série Tony Hawk, anos atrás, em um hardware muito menos impressionante do que temos hoje, para melhorar significativamente o desempenho. Ele basicamente usou matrizes globais pré-alocadas para cada tipo distinto de dados da entidade (posição, pontuação e outros enfeites) e faz referência a cada matriz em uma fase distinta de sua
update()
função em todo o sistema . Você pode supor que os dados de cada entidade estariam no mesmo índice de matriz em cada uma dessas matrizes globais; portanto, se o player for criado primeiro, ele poderá ter seus dados[0]
em cada matriz.Ainda mais específico para a otimização do cache, os slides da Christer Ericsson para C e C ++.
Para dar um pouco mais de detalhes, você deve tentar usar blocos de memória contíguos (mais facilmente alocados como matrizes) para cada tipo de dados (por exemplo, posição, xy e z), para garantir uma boa localidade de referência, utilizando cada um desses blocos de dados em distintos
update()
fases por uma questão de localidade temporal, ou seja, para garantir que o cache não seja liberado pelo algoritmo LRU do hardware antes de você reutilizar os dados que pretende reutilizar, em uma determinadaupdate()
chamada. Como você sugeriu, o que você não quer fazer é alocar suas entidades e componentes como objetos discretosnew
, pois dados de tipos diferentes em cada instância de entidade serão intercalados, reduzindo a localidade de referência.Se você tiver interdependências entre componentes (dados), de forma que você absolutamente não pode ter alguns dados separados dos dados associados (por exemplo, Transform + Physics, Transform + Renderer), poderá optar por replicar os dados do Transform nas matrizes Physics e Renderer , assegurando que todos os dados pertinentes se ajustem à largura da linha de cache para cada operação crítica ao desempenho.
Lembre-se também de que o cache L2 e L3 (se você pode assumi-los na sua plataforma de destino) faz muito para aliviar os problemas que o cache L1 pode sofrer, como uma largura de linha restritiva. Portanto, mesmo com uma falha de L1, essas são redes de segurança que geralmente impedem chamadas para a memória principal, que é de magnitude mais lenta que as chamadas para qualquer nível de cache.
Nota sobre a gravação de dados A gravação não chama a memória principal. Por padrão, os sistemas atuais têm o cache de write-back ativado: a gravação de um valor apenas o grava no cache (inicialmente), não na memória principal, para que você não seja prejudicado por isso. Somente quando os dados são solicitados da memória principal (não ocorrerá enquanto estiver no cache) e estiver obsoleto, a memória principal será atualizada do cache.
fonte
std::vector
é basicamente uma matriz redimensionável dinamicamente e, portanto, também é contígua (de fato nas versões mais antigas do C ++ e de jure nas versões mais recentes do C ++). Algumas implementações destd::deque
também são "suficientemente contíguas" (embora não sejam da Microsoft).