web-dev-qa-db-fra.com

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

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 d'origine, si vous avez un std::vector<Ball> BallsAll, vous pouvez simplement déplacer n'importe quel BallsAll[x] à n'importe quel 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?

24
ulak blade

ne autre réponse a donné un excellent aperçu de la façon dont vous encapsulez bien le stockage orienté ligne et donnez une meilleure vue. Mais puisque vous posez également des questions sur les performances, permettez-moi de répondre à cela: La mise en page 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 généralement accédez à tous les propriétés d'une balle ensemble, parce que vous passez la plupart de votre temps à échanger des balles dans votre collection de balles, il y a 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.

24
user7043

É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 la disposition de la mémoire efficace et l'accès à la mémoire.

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 de 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 tout autant que de la représentation agrégée des blocs contigus qu'il possède et de la façon dont l'accès aléatoire fonctionne au niveau de la mémoire. Il met quelque 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 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 OOP.

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 scalaire, un à la fois état d'esprit au lieu d'un état d'esprit parallèle en vrac.

À 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 interface Pixel.

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 manière 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 Edge 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).

21
user204677

Ce que vous avez décrit est un problème de mise en œuvre. OO la conception est expressément pas 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 volume et move, qui modifient simplement les valeurs respectives dans la structure par colonne sous-jacente. 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.

7
user2313838

Réponse courte: vous avez parfaitement raison, et les 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 sur colonnes") et "Arrays-of-Structs" pour d'autres types d'opérations ("opérations sur lignes ", comme ceux que vous avez mentionnés ci-dessus). Le même principe a influencé les architectures de bases de données, il y a bases de données orientées colonnes vs les bases de données orientées lignes classiques

Donc, la seconde 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 cette performance (je pense que dans la programmation de jeux, où le l'article ci-dessus vient souvent de vous).

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

5
Doc Brown