Mentalidade orientada a dados
O design orientado a dados não significa aplicar SoAs em qualquer lugar. Significa simplesmente projetar arquiteturas com foco predominante na representação de dados - especificamente com foco no layout eficiente da memória e no acesso à memória.
Isso poderia levar a representantes de SoA, quando apropriado, da seguinte maneira:
struct BallSoa
{
vector<float> x; // size n
vector<float> y; // size n
vector<float> z; // size n
vector<float> r; // size n
};
... isso geralmente é adequado para lógica em loop vertical que não processa os componentes e o raio de um vetor de centro de esfera simultaneamente (os quatro campos não são simultaneamente quentes), mas sim um de cada vez (um loop através do raio, outros 3 loops através de componentes individuais dos centros das esferas).
Em outros casos, pode ser mais apropriado usar um AoS se os campos forem freqüentemente acessados juntos (se sua lógica em loop estiver percorrendo todos os campos das bolas em vez de individualmente) e / ou se for necessário o acesso aleatório de uma bola:
struct BallAoS
{
float x;
float y;
float z;
float r;
};
vector<BallAoS> balls; // size n
... em outros casos, pode ser adequado usar um híbrido que equilibre os dois benefícios:
struct BallAoSoA
{
float x[8];
float y[8];
float z[8];
float r[8];
};
vector<BallAoSoA> balls; // size n/8
... você pode até comprimir o tamanho de uma bola pela metade usando half-floats para ajustar mais campos de bola em uma linha / página de cache.
struct BallAoSoA16
{
Float16 x2[16];
Float16 y2[16];
Float16 z2[16];
Float16 r2[16];
};
vector<BallAoSoA16> balls; // size n/16
... talvez até o raio não seja acessado quase tão frequentemente quanto o centro da esfera (talvez sua base de código as trate como pontos e raramente como esferas, por exemplo). Nesse caso, você pode aplicar ainda mais uma técnica de divisão de campo quente / frio.
struct BallAoSoA16Hot
{
Float16 x2[16];
Float16 y2[16];
Float16 z2[16];
};
vector<BallAoSoA16Hot> balls; // size n/16: hot fields
vector<Float16> ball_radiuses; // size n: cold fields
A chave para um design orientado a dados é considerar todos esses tipos de representações logo no início de suas decisões de design, para não se prender a uma representação subótima com uma interface pública por trás dela.
Ele destaca os padrões de acesso à memória e os layouts que os acompanham, tornando-os uma preocupação significativamente mais forte que o normal. Em certo sentido, pode até derrubar abstrações. Descobri aplicando mais essa mentalidade que já não olho std::deque
, por exemplo, em termos de requisitos algorítmicos, tanto quanto a representação agregada de blocos contíguos que ela possui e como o acesso aleatório dela funciona no nível da memória. É um pouco focado nos detalhes da implementação, mas detalhes da implementação que tendem a ter tanto ou mais impacto no desempenho quanto a complexidade algorítmica que descreve a escalabilidade.
Otimização prematura
Muito do foco predominante no design orientado a dados parecerá, pelo menos de relance, perigosamente próximo à otimização prematura. A experiência geralmente nos ensina que essas micro otimizações são melhor aplicadas em retrospectiva e com um criador de perfil em mãos.
No entanto, talvez uma mensagem forte a ser tirada do design orientado a dados seja deixar espaço para essas otimizações. É isso que uma mentalidade orientada a dados pode ajudar a permitir:
O design orientado a dados pode deixar espaço para respirar para explorar representações mais eficazes. Não se trata necessariamente de alcançar a perfeição do layout da memória de uma só vez, mas de fazer as considerações apropriadas com antecedência para permitir representações cada vez mais ideais.
Projeto Orientado a Objetos Granular
Muitas discussões de projeto orientadas a dados se opõem às noções clássicas de programação orientada a objetos. No entanto, eu ofereceria uma maneira de encarar isso que não é tão grave quanto descartar completamente a OOP.
A dificuldade com o design orientado a objetos é que muitas vezes nos tenta modelar interfaces em um nível muito granular, deixando-nos presos a uma mentalidade escalar, uma de cada vez, em vez de uma mentalidade em massa paralela.
Como um exemplo exagerado, imagine uma mentalidade de design orientada a objetos aplicada a um único pixel de uma imagem.
class Pixel
{
public:
// Pixel operations to blend, multiply, add, blur, etc.
private:
Image* image; // back pointer to access adjacent pixels
unsigned char rgba[4];
};
Espero que ninguém realmente faça isso. Para tornar o exemplo realmente grosseiro, armazenei um ponteiro de volta na imagem que contém o pixel, para que ele possa acessar os pixels vizinhos para algoritmos de processamento de imagem como desfoque.
O ponteiro de trás da imagem adiciona imediatamente uma sobrecarga gritante, mas mesmo que a excluamos (fazendo apenas a interface pública do pixel fornecer operações que se aplicam a um único pixel), terminamos com uma classe apenas para representar um pixel.
Agora não há nada errado com uma classe no sentido de sobrecarga imediata em um contexto C ++ além desse ponteiro de volta. A otimização de compiladores C ++ é excelente para levar toda a estrutura que construímos e destruí-la em pedacinhos.
A dificuldade aqui é que estamos modelando uma interface encapsulada em um nível muito granular de pixel. Isso nos deixa presos com esse tipo de design e dados granulares, com potencialmente um grande número de dependências de clientes que os acoplam a essa Pixel
interface.
Solução: elimine a estrutura orientada a objetos de um pixel granular e comece a modelar suas interfaces em um nível mais grosso, lidando com um grande número de pixels (no nível da imagem).
Ao modelar no nível da imagem em massa, temos significativamente mais espaço para otimizar. Podemos, por exemplo, representar imagens grandes como blocos coalescidos de 16x16 pixels, que se encaixam perfeitamente em uma linha de cache de 64 bytes, mas permitem um acesso vertical vizinho eficiente de pixels com um passo tipicamente pequeno (se tivermos vários algoritmos de processamento de imagem que precisa acessar pixels vizinhos de maneira vertical) como um exemplo orientado a dados grave.
Projetando em um Nível Mais Grosso
O exemplo acima de interfaces de modelagem no nível da imagem é um exemplo simples, já que o processamento de imagens é um campo muito maduro que foi estudado e otimizado até a morte. Ainda menos óbvio pode ser uma partícula em um emissor de partículas, um sprite x uma coleção de sprites, uma aresta em um gráfico de arestas, ou mesmo uma pessoa x uma coleção de pessoas.
A chave para permitir otimizações orientadas a dados (em previsão ou retrospectiva) geralmente se resume ao design de interfaces em um nível muito mais grosseiro, em massa. A idéia de criar interfaces para entidades únicas é substituída pela criação de coleções de entidades com grandes operações que as processam em massa. Isso tem como alvo imediato e especial os loops de acesso seqüencial que precisam acessar tudo e não podem deixar de ter complexidade linear.
O design orientado a dados geralmente começa com a idéia de coalescer dados para formar dados de modelagem agregados em massa. Uma mentalidade semelhante ecoa nos designs de interface que a acompanham.
Esta é a lição mais valiosa que aprendi do design orientado a dados, pois não sou especialista em arquitetura de computadores o suficiente para encontrar frequentemente o layout de memória ideal para algo na minha primeira tentativa. Torna-se algo que eu repito com um profiler na mão (e algumas vezes com algumas falhas ao longo do caminho em que eu falhei em acelerar as coisas). No entanto, o aspecto do design de interface do design orientado a dados é o que me deixa em busca de representações de dados cada vez mais eficientes.
A chave é projetar interfaces em um nível mais grosso do que normalmente somos tentados. Isso também costuma trazer benefícios colaterais, como atenuar a sobrecarga dinâmica de despacho associada a funções virtuais, chamadas de ponteiro de função, chamadas de dylib e a incapacidade de serem incorporadas. A principal idéia a ser retirada de tudo isso é analisar o processamento em massa (quando aplicável).
ball->do_something();
versusball_table.do_something(ball)
) a menos que você queira fingir uma entidade coerente por meio de um pseudo-ponteiro(&ball_table, index)
.