Design Orientado a Dados - impraticável com mais de 1-2 membros da estrutura?

23

O exemplo usual do Design Orientado a Dados é com a estrutura Ball:

struct Ball
{
  float Radius;
  float XYZ[3];
};

e então eles criam algum algoritmo que itera um std::vector<Ball>vetor.

Em seguida, eles oferecem a mesma coisa, mas implementados no Data Oriented Design:

struct Balls
{
  std::vector<float> Radiuses;
  std::vector<XYZ[3]> XYZs;
};

O que é bom e tudo se você quiser percorrer todos os raios primeiro, depois todas as posições e assim por diante. No entanto, como você move as bolas no vetor? Na versão original, se você tiver um std::vector<Ball> BallsAll, você pode simplesmente mover qualquer um BallsAll[x]para qualquer BallsAll[y].

No entanto, para fazer isso na versão Orientada a Dados, você deve fazer o mesmo para todas as propriedades (2 vezes no caso de Ball - raio e posição). Mas fica pior se você tiver muito mais propriedades. Você precisará manter um índice para cada "bola" e, quando tentar movê-la, precisará fazer a movimentação em todos os vetores de propriedades.

Isso não prejudica o desempenho do Data Oriented Design?

lâmina de ulak
fonte

Respostas:

23

Outra resposta deu uma excelente visão geral sobre como você encapsularia bem o armazenamento orientado a linhas e proporcionaria uma visão melhor. Mas como você também pergunta sobre desempenho, deixe-me abordar isso: o layout do SoA não é uma bala de prata . É um padrão muito bom (para uso de cache; não muito para facilitar a implementação na maioria dos idiomas), mas não é tudo o que existe, nem mesmo no design orientado a dados (o que isso significa exatamente). É possível que os autores de algumas apresentações que você leu tenham perdido esse ponto e apresentem apenas o layout SoA, porque acham que esse é o objetivo do DOD. Eles estariam errados e, felizmente, nem todos caem nessa armadilha .

Como você provavelmente já percebeu, nem todos os dados primitivos se beneficiam de ser extraídos em sua própria matriz. O layout SoA é vantajoso quando os componentes que você divide em matrizes separadas geralmente são acessados ​​separadamente. Mas nem todas as partes minúsculas são acessadas isoladamente, por exemplo, um vetor de posição quase sempre é lido e atualizado por atacado, portanto, naturalmente, você não o divide. De fato, seu exemplo também não fez isso! Da mesma forma, se você geralmente acessa todas as propriedades de uma bola juntas, porque passa a maior parte do tempo trocando bolas na sua coleção de bolas, não há sentido em separá-las.

No entanto, há um segundo lado no DOD. Você não obtém todas as vantagens de cache e organização apenas girando o layout da memória em 90 ° e fazendo o mínimo para corrigir os erros de compilação resultantes. Existem outros truques comuns ensinados sob esse banner. Por exemplo "processamento baseado em existência": se você desativar bolas com freqüência e reativá-las, não adicione uma sinalização ao objeto bola e faça com que o loop de atualização ignore as bolas com a sinalização definida como false. Mova a bola de uma coleção "ativa" para uma coleção "inativa" e faça o loop de atualização inspecionar apenas a coleção "ativa".

Mais importante e relevante para o seu exemplo: se você gastar tanto tempo mexendo no conjunto de bolas, talvez esteja fazendo algo errado. Por que o pedido importa? Você pode fazer isso não importa? Nesse caso, você obteria vários benefícios:

  • Você não precisa embaralhar a coleção (o código mais rápido é nenhum código).
  • Você pode adicionar e excluir com mais facilidade e eficiência (alternar para o final, soltar por último).
  • O código restante pode se tornar elegível para otimizações adicionais (como a alteração de layout em que você se concentra).

Então, em vez de jogar cegamente o SoA em tudo, pense nos seus dados e em como você os processa. Se você achar que processa as posições e velocidades em um loop, passe pelas malhas e atualize os pontos de vida, tente dividir seu layout de memória nessas três partes. Se você achar que acessa os componentes x, y, z da posição isoladamente, talvez transforme seus vetores de posição em um SoA. Se você se encontra embaralhando dados mais do que realmente fazendo algo útil, talvez pare de embaralhá-los.

Comunidade
fonte
18

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

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

Thomas Owens
fonte
5

O que você descreveu é um problema de implementação. O design do OO não está expressamente preocupado com implementações.

Você pode encapsular seu contêiner Ball orientado a colunas atrás de uma interface que expõe uma exibição orientada a linhas ou colunas. Você pode implementar um objeto Ball com métodos como volumee move, que apenas modificam os respectivos valores na estrutura subjacente em colunas. Ao mesmo tempo, seu contêiner Ball pode expor uma interface para operações eficientes em colunas. Com modelos / tipos apropriados e um compilador embutido inteligente, você pode usar essas abstrações com custo zero de tempo de execução.

Com que frequência você acessará os dados em colunas e os modificará em linhas? Em casos de uso típicos para armazenamento de colunas, a ordem das linhas não tem efeito. Você pode definir uma permutação arbitrária das linhas adicionando uma coluna de índice separada. Alterar a ordem exigiria apenas a troca de valores na coluna do índice.

A adição / remoção eficiente de elementos pode ser alcançada com outras técnicas:

  • Mantenha um bitmap de linhas excluídas em vez de mover elementos. Compacte a estrutura quando estiver muito escassa.
  • Agrupe linhas em pedaços de tamanho apropriado em uma estrutura do tipo B-Tree para que a inserção ou remoção em posições arbitrárias não exija a modificação de toda a estrutura.

O código do cliente veria uma sequência de objetos Ball, um contêiner mutável de objetos Ball, uma sequência de raios, uma matriz Nx3, etc; não precisa se preocupar com os detalhes feios dessas estruturas complexas (mas eficientes). É isso que a abstração do objeto compra.

user2313838
fonte
A organização AoS +1 é perfeitamente corrigível para uma API orientada a entidades, embora seja mais feio usar ( ball->do_something();versus ball_table.do_something(ball)) a menos que você queira fingir uma entidade coerente por meio de um pseudo-ponteiro (&ball_table, index).
1
Vou dar um passo adiante: a conclusão do uso do SoA pode ser alcançada exclusivamente a partir dos princípios de design do OO. O truque é que você precisa de um cenário em que as colunas sejam um objeto mais fundamental que as linhas. Bolas não são um bom exemplo aqui. Em vez disso, considere um terreno com várias propriedades, como altura, tipo de solo ou precipitação. Cada propriedade é modelada como um objeto ScalarField, que possui seus próprios métodos, como gradiente () ou divergência (), que podem retornar outros objetos de campo. Você pode encapsular itens como resolução de mapa, e diferentes propriedades no terreno podem funcionar com diferentes resoluções.
16807
4

Resposta curta: você está totalmente correto e artigos como este estão completamente ausentes deste ponto.

A resposta completa é: a abordagem "Estrutura das matrizes" dos seus exemplos pode ter vantagens de desempenho para algum tipo de operação ("operações de coluna") e "Matrizes de estruturas" para outros tipos de operações ("operações de linha ", como os que você mencionou acima). O mesmo princípio influenciou as arquiteturas de banco de dados; existem bancos de dados orientados a colunas versus os bancos de dados orientados a linhas clássicos

Portanto, a segunda coisa a considerar para escolher um design é o tipo de operação que você mais precisa no seu programa e se essas serão beneficiadas pelo layout de memória diferente. No entanto, a primeira coisa a considerar é se você realmente precisa desse desempenho (acho que na programação de jogos, onde o artigo acima é de você, muitas vezes, tem esse requisito).

A maioria das linguagens OO atuais usam um layout de memória "Matriz de estrutura" para objetos e classes. Obter as vantagens do OO (como criar abstrações para seus dados, encapsulamento e escopo mais local de funções básicas), normalmente está vinculado a esse tipo de layout de memória. Portanto, desde que você não faça computação de alto desempenho, eu não consideraria o SoA como a abordagem principal.

Doc Brown
fonte
3
DOD nem sempre significa o layout da estrutura da matriz (SoA). É comum, porque muitas vezes corresponde ao padrão de acesso, mas quando outro layout funciona melhor, use-o. O DOD é muito mais geral (e confuso), mais parecido com um paradigma de design do que com uma maneira específica de organizar dados. Além disso, embora o artigo que você menciona esteja longe de ser o melhor recurso e tenha suas falhas, ele não anuncia layouts de SoA. Os "A" e "B" s podem ser apresentados com todos os recursos Ballda mesma maneira que podem ser floats ou s individuais vec3(que estariam sujeitos à transformação de SoA).
2
... e o design orientado para linhas que você menciona está sempre incluído no DOD. Ele é chamado de matriz de estruturas (AoS), e a diferença para o que a maioria dos recursos chama de "o caminho da OOP" (para melhor ou para mais) não está no layout de linha versus coluna, mas simplesmente como esse layout é mapeado para a memória (muitos objetos pequenos vinculados por ponteiros versus uma grande tabela contínua de todos os registros). Em resumo, -1 porque, embora você levante bons pontos contra os equívocos do OP, deturpa todo o jazz do DOD em vez de corrigir o entendimento do OP sobre o DOD.
@ delnan: obrigado pelo seu comentário, você provavelmente está certo de que eu deveria ter usado o termo "SoA" em vez de "DOD". Eu editei minha resposta de acordo.
Doc Brown
Muito melhor, voto negativo removido. Confira a resposta do usuário2313838 para saber como o SoA pode ser unificado com boas APIs orientadas a "objetos" (no sentido de abstrações, encapsulamento e "escopo mais local das funções básicas"). É mais natural para o layout do AoS (já que o array pode ser um contêiner genérico idiota em vez de ser casado com o tipo de elemento), mas é viável.
E este github.com/BSVino/JaiPrimer/blob/master/JaiPrimer.md que tem conversão automática de SoA para / de AoS Exemplo: reddit.com/r/rust/comments/2t6xqz/… e, em seguida, há: notícias. ycombinator.com/item?id=10235766
Jerry Jeremiah