Conception orientée données - peu pratique avec plus de 1 à 2 «membres» de structure?

23

L'exemple habituel de Data Oriented Design est avec la structure Ball:

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

puis ils font un algorithme qui itère un std::vector<Ball>vecteur.

Ensuite, ils vous donnent la même chose, mais implémentée dans la conception orientée données:

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

Ce qui est bien et tout si vous allez parcourir tous les rayons en premier, puis toutes les positions et ainsi de suite. Cependant, comment déplacez-vous les boules dans le vecteur? Dans la version originale, si vous en avez un std::vector<Ball> BallsAll, vous pouvez simplement en déplacer n'importe BallsAll[x]lequel BallsAll[y].

Cependant, pour cela pour la version orientée données, vous devez faire la même chose pour chaque propriété (2 fois dans le cas de Ball - rayon et position). Mais cela empire si vous avez beaucoup plus de propriétés. Vous devrez garder un index pour chaque "boule" et lorsque vous essayez de la déplacer, vous devez effectuer le déplacement dans chaque vecteur de propriétés.

Cela ne tue-t-il aucun avantage de performance de la conception orientée données?

lame ulak
la source

Réponses:

23

Une autre réponse a donné un excellent aperçu de la façon dont vous pouvez bien encapsuler le stockage orienté ligne et donner une meilleure vue. Mais puisque vous posez également des questions sur les performances, permettez-moi de répondre à cela: la disposition SoA n'est pas une solution miracle . C'est une assez bonne valeur par défaut (pour l'utilisation du cache; pas tant pour la facilité d'implémentation dans la plupart des langues), mais ce n'est pas tout ce qu'il y a, même pas dans la conception orientée données (quoi que cela signifie exactement). Il est possible que les auteurs de certaines introductions que vous avez lues aient manqué ce point et ne présentent que la mise en page SoA car ils pensent que c'est tout l'intérêt du DOD. Ils auraient tort, et heureusement, tout le monde ne tombe pas dans ce piège .

Comme vous l'avez probablement déjà réalisé, tous les éléments de données primitifs ne bénéficient pas d'être extraits dans leur propre tableau. La mise en page SoA est avantageuse lorsque les composants que vous divisez en tableaux séparés sont généralement accessibles séparément. Mais chaque petit morceau n'est pas accessible isolément, par exemple un vecteur de position est presque toujours lu et mis à jour en gros, donc naturellement vous ne le divisez pas. En fait, votre exemple ne l'a pas fait non plus! De même, si vous accédez généralement à toutes les propriétés d'une balle ensemble, car vous passez la plupart de votre temps à échanger des balles dans votre collection de balles, il est inutile de les séparer.

Cependant, il y a un deuxième côté au DOD. Vous n'obtenez pas tous les avantages du cache et de l'organisation simplement en tournant la disposition de votre mémoire à 90 ° et en faisant le moins pour corriger les erreurs de compilation qui en résultent. Il existe d'autres astuces courantes enseignées sous cette bannière. Par exemple, "traitement basé sur l'existence": si vous désactivez et réactivez fréquemment des balles, n'ajoutez pas d'indicateur à l'objet ball et faites en sorte que la boucle de mise à jour ignore les balles avec l'indicateur défini sur false. Déplacez la balle d'une collection "active" vers une collection "inactive" et faites que la boucle de mise à jour inspecte uniquement la collection "active".

Plus important et pertinent pour votre exemple: si vous passez autant de temps à mélanger le jeu de boules, vous faites peut-être quelque chose de mal. Pourquoi la commande est-elle importante? Pouvez-vous faire en sorte que cela n'ait pas d'importance? Si c'est le cas, vous bénéficierez de plusieurs avantages:

  • Vous n'avez pas besoin de mélanger la collection (le code le plus rapide n'est pas du tout un code).
  • Vous pouvez ajouter et supprimer plus facilement et plus efficacement (swap to end, drop last).
  • Le code restant peut devenir éligible pour d'autres optimisations (comme le changement de mise en page sur lequel vous vous concentrez).

Donc, au lieu de lancer aveuglément SoA sur tout, pensez à vos données et à la façon dont vous les traitez. Si vous constatez que vous traitez les positions et les vitesses en une seule boucle, parcourez les maillages, puis mettez à jour les repères, essayez de diviser votre disposition de mémoire en ces trois parties. Si vous trouvez que vous accédez aux composants x, y, z de la position de manière isolée, transformez peut-être vos vecteurs de position en SoA. Si vous vous retrouvez à mélanger les données plus qu'à faire quelque chose d'utile, arrêtez peut-être de les mélanger.

Communauté
la source
18

État d'esprit orienté données

Une conception orientée données ne signifie pas appliquer des SoAs partout. Cela signifie simplement concevoir des architectures avec un accent prédominant sur la représentation des données - en particulier avec un accent sur une disposition et un accès mémoire efficaces.

Cela pourrait éventuellement conduire à des représentants SoA le cas échéant, comme suit:

struct BallSoa
{
   vector<float> x;        // size n
   vector<float> y;        // size n
   vector<float> z;        // size n
   vector<float> r;        // size n
};

... cela convient souvent à la logique en boucle verticale qui ne traite pas simultanément les composantes du vecteur central d'une sphère et le rayon (les quatre champs ne sont pas simultanément chauds), mais à la place un par un (une boucle à travers le rayon, 3 autres boucles à travers les composants individuels des centres de sphères).

Dans d'autres cas, il pourrait être plus approprié d'utiliser un AoS si les champs sont fréquemment accédés ensemble (si votre logique en boucle parcourt tous les champs de balles plutôt qu'individuellement) et / ou si l'accès aléatoire d'une balle est nécessaire:

struct BallAoS
{
    float x;
    float y;
    float z;
    float r;
};
vector<BallAoS> balls;        // size n

... dans d'autres cas, il pourrait être approprié d'utiliser un hybride qui équilibre les deux avantages:

struct BallAoSoA
{
    float x[8];
    float y[8];
    float z[8];
    float r[8];
};
vector<BallAoSoA> balls;      // size n/8

... vous pourriez même compresser la taille d'une balle à moitié en utilisant des demi-flottants pour ajuster plus de champs de balle dans une ligne / page de cache.

struct BallAoSoA16
{
    Float16 x2[16];
    Float16 y2[16];
    Float16 z2[16];
    Float16 r2[16];
};
vector<BallAoSoA16> balls;    // size n/16

... peut-être même que le rayon n'est pas accessible presque aussi souvent que le centre de la sphère (peut-être que votre base de code les traite souvent comme des points et rarement comme des sphères, par exemple). Dans ce cas, vous pouvez appliquer davantage une technique de division de champ chaud / froid.

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

La clé d'une conception orientée données est de prendre en compte tous ces types de représentations dès le début de vos décisions de conception, pour ne pas vous piéger dans une représentation sous-optimale avec une interface publique derrière.

Il met en lumière les modèles d'accès à la mémoire et les dispositions d'accompagnement, ce qui en fait une préoccupation beaucoup plus forte que d'habitude. Dans un sens, il peut même quelque peu abattre les abstractions. J'ai trouvé en appliquant cet état d'esprit plus que je ne regarde plus std::deque, par exemple, en termes de ses exigences algorithmiques autant que la représentation agrégée de blocs contigus qu'il a et comment l'accès aléatoire de celui-ci fonctionne au niveau de la mémoire. Il met un peu l'accent sur les détails d'implémentation, mais les détails d'implémentation qui ont tendance à avoir autant ou plus d'impact sur les performances que la complexité algorithmique décrivant l'évolutivité.

Optimisation prématurée

Une grande partie de l'objectif prédominant de la conception orientée données apparaîtra, du moins en un coup d'œil, comme dangereusement proche d'une optimisation prématurée. L'expérience nous enseigne souvent que de telles micro-optimisations sont mieux appliquées avec le recul et avec un profileur en main.

Pourtant, un message fort à tirer de la conception orientée données est peut-être de laisser de la place à de telles optimisations. C'est ce qu'un état d'esprit orienté données peut permettre:

La conception orientée données peut laisser une marge de manœuvre pour explorer des représentations plus efficaces. Il ne s'agit pas nécessairement d'atteindre la perfection de la disposition de la mémoire d'un seul coup, mais plutôt de faire les considérations appropriées à l'avance pour permettre des représentations de plus en plus optimales.

Conception granulaire orientée objet

De nombreuses discussions sur la conception orientée données s'opposeront aux notions classiques de programmation orientée objet. Pourtant, je proposerais une façon de voir les choses qui n'est pas aussi hardcore que de rejeter complètement la POO.

La difficulté avec la conception orientée objet est qu'elle nous tentera souvent de modéliser des interfaces à un niveau très granulaire, nous laissant piégés avec un état d'esprit scalaire, un à la fois au lieu d'un état d'esprit en vrac parallèle.

À titre d'exemple exagéré, imaginez un état d'esprit de conception orienté objet appliqué à un seul pixel d'une image.

class Pixel
{
public:
    // Pixel operations to blend, multiply, add, blur, etc.

private:
    Image* image;          // back pointer to access adjacent pixels
    unsigned char rgba[4];
};

Espérons que personne ne le fasse réellement. Pour rendre l'exemple vraiment grossier, j'ai stocké un pointeur arrière sur l'image contenant le pixel afin qu'il puisse accéder aux pixels voisins pour les algorithmes de traitement d'image comme le flou.

Le pointeur de retour d'image ajoute immédiatement un surcoût flagrant, mais même si nous l'excluons (en ne faisant que l'interface publique du pixel fournir des opérations qui s'appliquent à un seul pixel), nous nous retrouvons avec une classe juste pour représenter un pixel.

Maintenant, il n'y a rien de mal avec une classe dans le sens de surcharge immédiate dans un contexte C ++ à part ce pointeur arrière. L'optimisation des compilateurs C ++ est excellente pour prendre toute la structure que nous avons construite et l'effacer vers le bas.

La difficulté ici est que nous modélisons une interface encapsulée à un niveau trop granulaire d'un pixel. Cela nous laisse piégés par ce type de conception et de données granulaires, avec potentiellement un grand nombre de dépendances client les couplant à cette Pixelinterface.

Solution: effacez la structure orientée objet d'un pixel granulaire et commencez à modéliser vos interfaces à un niveau plus grossier traitant un nombre important de pixels (au niveau de l'image).

En modélisant au niveau de l'image en vrac, nous avons beaucoup plus d'espace à optimiser. Nous pouvons, par exemple, représenter de grandes images comme des tuiles coalescentes de 16x16 pixels qui s'intègrent parfaitement dans une ligne de cache de 64 octets mais permettent un accès vertical voisin efficace de pixels avec une petite foulée typiquement (si nous avons un certain nombre d'algorithmes de traitement d'image qui besoin d'accéder aux pixels voisins de façon verticale) comme exemple hardcore orienté données.

Concevoir à un niveau plus grossier

L'exemple ci-dessus de modélisation des interfaces au niveau de l'image est une sorte d'exemple évident car le traitement d'image est un domaine très mature qui a été étudié et optimisé à mort. Pourtant, moins évident pourrait être une particule dans un émetteur de particules, un sprite contre une collection de sprites, un bord dans un graphique de bords, ou même une personne contre une collection de personnes.

La clé pour permettre des optimisations axées sur les données (de manière prospective ou rétrospective) va souvent se résumer à la conception d'interfaces à un niveau beaucoup plus grossier, en vrac. L'idée de concevoir des interfaces pour des entités uniques est remplacée par la conception de collections d'entités avec de grandes opérations qui les traitent en vrac. Cela cible particulièrement et immédiatement les boucles d'accès séquentielles qui ont besoin d'accéder à tout et ne peuvent s'empêcher d'avoir une complexité linéaire.

La conception orientée données commence souvent par l'idée de fusionner les données pour former des agrégats modélisant les données en masse. Un état d'esprit similaire fait écho aux conceptions d'interface qui l'accompagnent.

C'est la leçon la plus précieuse que j'ai tirée de la conception orientée données, car je ne suis pas assez averti en architecture informatique pour trouver souvent la disposition de mémoire la plus optimale pour quelque chose lors de mon premier essai. Cela devient quelque chose que je répète avec un profileur à la main (et parfois avec quelques ratés en cours de route où je n'ai pas réussi à accélérer les choses). Pourtant, l'aspect de conception d'interface de la conception orientée données est ce qui me laisse la place pour rechercher des représentations de données de plus en plus efficaces.

La clé est de concevoir des interfaces à un niveau plus grossier que ce que nous sommes généralement tentés de faire. Cela a également souvent des avantages secondaires comme l'atténuation de la surcharge de répartition dynamique associée aux fonctions virtuelles, les appels de pointeur de fonction, les appels dylib et l'impossibilité pour ceux d'être alignés. L'idée principale à retirer de tout cela est d'examiner le traitement en bloc (le cas échéant).

Thomas Owens
la source
5

Ce que vous avez décrit est un problème de mise en œuvre. La conception OO n'est pas expressément concernée par les implémentations.

Vous pouvez encapsuler votre conteneur Ball orienté colonne derrière une interface qui expose une vue orientée ligne ou colonne. Vous pouvez implémenter un objet Ball avec des méthodes telles que volumeet move, qui modifient simplement les valeurs respectives dans la structure sous-jacente par colonne. Dans le même temps, votre conteneur Ball pourrait exposer une interface pour des opérations efficaces par colonne. Avec des modèles / types appropriés et un compilateur intelligent intégré, vous pouvez utiliser ces abstractions avec un coût d'exécution nul.

À quelle fréquence allez-vous accéder aux données par colonne par rapport à leur modification par ligne? Dans des cas d'utilisation typiques pour le stockage de colonnes, l'ordre des lignes n'a aucun effet. Vous pouvez définir une permutation arbitraire des lignes en ajoutant une colonne d'index distincte. Changer l'ordre ne nécessiterait que l'échange de valeurs dans la colonne d'index.

Un ajout / retrait efficace d'éléments pourrait être réalisé avec d'autres techniques:

  • Conservez une image bitmap des lignes supprimées au lieu de déplacer les éléments. Compactez la structure lorsqu'elle devient trop clairsemée.
  • Regroupez les lignes en morceaux de taille appropriée dans une structure de type B-Tree afin que l'insertion ou la suppression dans des positions arbitraires ne nécessite pas de modifier la structure entière.

Le code client verrait une séquence d'objets Ball, un conteneur mutable d'objets Ball, une séquence de rayons, une matrice Nx3, etc. il n'a pas à se soucier des moindres détails de ces structures complexes (mais efficaces). C'est ce que l'abstraction d'objet vous achète.

user2313838
la source
+1 L'organisation AoS est parfaitement modifiable en une belle API orientée entité, bien qu'il soit vrai qu'elle devient plus moche à utiliser ( ball->do_something();contre ball_table.do_something(ball)) à moins que vous ne vouliez simuler une entité cohérente via un pseudo-pointeur (&ball_table, index).
1
Je vais un peu plus loin: la conclusion d'utiliser SoA peut être tirée uniquement des principes de conception OO. L'astuce est que vous avez besoin d'un scénario dans lequel les colonnes sont un objet plus fondamental que les lignes. Les balles ne sont pas un bon exemple ici. Au lieu de cela, considérez un terrain avec diverses propriétés comme la hauteur, le type de sol ou les précipitations. Chaque propriété est modélisée comme un objet ScalarField, qui a ses propres méthodes comme gradient () ou divergence () qui peuvent renvoyer d'autres objets Field. Vous pouvez encapsuler des éléments comme la résolution de la carte et différentes propriétés sur le terrain peuvent fonctionner avec différentes résolutions.
16807
4

Réponse courte: vous avez parfaitement raison, et des articles comme celui-ci manquent complètement ce point.

La réponse complète est: l'approche "Structure-Of-Arrays" de vos exemples peut présenter des avantages en termes de performances pour certains types d'opérations ("opérations de colonnes") et "Arrays-of-Structs" pour d'autres types d'opérations ("opérations de lignes ", comme ceux que vous avez mentionnés ci-dessus). Le même principe a influencé les architectures de bases de données, il existe des bases de données orientées colonnes par rapport aux bases de données classiques orientées lignes

Donc, la deuxième chose à considérer pour choisir une conception est le type d'opérations dont vous avez le plus besoin dans votre programme et si celles-ci bénéficieront de la disposition différente de la mémoire. Cependant, la première chose à considérer est si vous avez vraiment besoin de cette performance (je pense que dans la programmation de jeux, où l'article ci-dessus vient de vous, vous avez souvent cette exigence).

La plupart des langages OO actuels utilisent une disposition de mémoire "Array-Of-Struct" pour les objets et les classes. Obtenir les avantages de l'OO (comme la création d'abstractions pour vos données, l'encapsulation et la portée plus locale des fonctions de base) est généralement lié à ce type de disposition de mémoire. Donc, tant que vous ne faites pas de calcul haute performance, je ne considérerais pas SoA comme l'approche principale.

Doc Brown
la source
3
DOD ne signifie pas toujours une disposition Structure-of-Array (SoA). C'est commun, car il correspond souvent au modèle d'accès, mais quand une autre mise en page fonctionne mieux, utilisez-la par tous les moyens. Le DOD est beaucoup plus général (et plus flou), plus comme un paradigme de conception qu'une façon spécifique de disposer les données. De plus, bien que l'article auquel vous faites référence soit loin d'être la meilleure ressource et ait ses défauts, il ne fait pas la publicité des dispositions SoA. Les «A» et les «B» peuvent être entièrement décrits Balltout comme ils peuvent être des floats ou des vec3s individuels (qui seraient eux-mêmes soumis à une transformation SoA).
2
... et la conception orientée ligne que vous mentionnez est toujours incluse dans DOD. C'est ce qu'on appelle un tableau de structures (AoS), et la différence par rapport à ce que la plupart des ressources appellent "la méthode OOP" (pour le meilleur ou le plus simple) n'est pas dans la disposition en ligne par rapport à la colonne, mais simplement comment cette disposition est mappée en mémoire (de nombreux petits objets liés via des pointeurs par rapport à un grand tableau continu de tous les enregistrements). En résumé, -1 parce que même si vous soulevez de bons points contre les idées fausses d'OP, vous déformez l'ensemble du jazz DOD plutôt que de corriger la compréhension d'OP de DOD.
@delnan: merci pour votre commentaire, vous avez probablement raison d'avoir utilisé le terme "SoA" au lieu de "DOD". J'ai modifié ma réponse en conséquence.
Doc Brown
Beaucoup mieux, downvote supprimé. Consultez la réponse de user2313838 pour savoir comment SoA peut être unifié avec de belles API orientées «objet» (dans le sens d'abstractions, d'encapsulation et de «portée plus locale des fonctions de base»). Cela vient plus naturellement pour la mise en page AoS (puisque le tableau peut être un conteneur générique stupide plutôt que d'être marié au type d'élément) mais c'est faisable.
Et ce github.com/BSVino/JaiPrimer/blob/master/JaiPrimer.md qui a une conversion automatique de SoA vers / depuis AoS Exemple: reddit.com/r/rust/comments/2t6xqz/… et puis il y a ceci: news. ycombinator.com/item?id=10235766
Jerry Jeremiah