2
votes

Intuition sur la disposition de la mémoire pour une conception rapide orientée SIMD / données

J'ai récemment regardé des discussions sur la conception orientée données, mais je n'ai jamais compris le raisonnement derrière leur disposition de mémoire choisie à l'unanimité .

Disons que nous avons une animation 3D à rendre, et dans chaque image nous devons re-normaliser nos vecteurs d'orientation.

Le "code scalaire"

Ils affichent toujours du code cela pourrait ressembler à quelque chose comme ceci:

[X,X,X,X,Y,Y,Y,Y,Z,Z,Z,Z,W,W,W,W,X,X,...]
 ^---------cache-line------------^

Jusqu'ici tout va bien ... La mémoire de & scene pourrait ressembler à peu près ainsi:

XXX

"Code compatible SSE"

Chaque présentation montre ensuite la version améliorée emporte-pièce :

[X,X,X,X,X,X,...]
[Y,Y,Y,Y,Y,Y,...]
[Z,Z,Z,Z,Z,Z,...]
[W,W,W,W,W,W,...]

Ce qui, en raison de la disposition de sa mémoire, est non seulement plus efficace en mémoire, mais peut également traiter notre scène 4 objets à la fois.
Mémoire à & xs , & ys , & zs et &ws

let xs = [1, ...]
let ys = [1, ...]
let zs = [1, ...]
let ws = [1, ...]
let scene = [{"camera1", ptr_vec4{&xs[1], &ys[1], &zs[1], &ws[1]}}, ...]

for (o1, o2, o3, o4) in scene
    (o1, o2, o3, o4) = normalize_sse(o1, o2, o3, o4)

Mais pourquoi 4 tableaux séparés?

Si le __m128 (emballé-4-singles) est le type prédominant dans les moteurs,
ce que je crois que c'est;
et si le type a une longueur de 128 bits,
ce qu'il est définitivement;
et si la largeur de ligne du cache / 128 = 4,
ce qu'il fait presque toujours;
et si x86_64 n'est capable d'écrire qu'une ligne de cache complète,
dont je suis presque certain
- pourquoi les données ne sont-elles pas structurées comme suit ?!

Mémoire à &packed_orientations:

[string,X,Y,Z,W,string,X,Y,Z,W,string,X,Y,Z,W,...]

J'ai pas de référence pour tester cela, et je ne comprends pas suffisamment les intrinsèques pour même essayer , mais selon mon intuition, cela ne devrait-il pas être moyen plus rapide? Nous économiserions 4 fois les charges et les écritures de page, simplifierions les allocations et enregistreraient des pointeurs, et le code serait plus simple car au lieu de 4 pointeurs, nous pouvons ajouter des pointeurs. Est-ce que je me trompe?

Merci! :)


7 commentaires

Cela dépend du type d'opérations que vous allez effectuer pour savoir si AoS ou SoA est plus efficace. Pertinente: 1 , 2 < / a>, et al .


@PaulR Eh bien, la conception orientée données consiste à regrouper les données dans le modèle d'accès séquentiel des structures. En fait, si vous vouliez accéder à chaque composant séparément, cela devrait alors devenir une structure. Cette granularité est une sorte d'invariant à ma question. Mais mon idée est assez différente.


Par exemple, parce que vous souhaiterez peut-être effectuer différentes opérations sur différents composants. Considérez les images RGBA: le canal alpha est presque toujours traité différemment des canaux r / g / b. La disposition de la mémoire que vous proposez nécessiterait un décalage et une extraction de bits supplémentaires. C'est plus difficile à coder et plus lent à exécuter que dans la disposition classique par composant.


@gudok bien dans ce cas, si nous devions adhérer strictement au modèle DOD, nous devrions séparer RVB du canal A. Même si nous ne le faisions pas, nous n'aurions pas besoin de travail supplémentaire - le modèle d'accès fonctionnerait toujours, mais vous gaspilleriez certainement 75% de vos lectures et écritures de cache. Intéressant...


@arctiq: gudok en a déjà parlé, mais il existe des algorithmes de traitement d'image qui peuvent être appliqués à un pixel à la fois, et puis il y en a d'autres où il est plus efficace de traiter un plan à la fois. Si vous devez faire les deux types, vous avez un dilemme à savoir s'il faut utiliser AoS ou SoA. Si vous ne souhaitez utiliser qu'un seul type de modèle d'accès, le choix est relativement simple.


Cette disposition est parfois appelée AoSoA car c'est comme un tableau de petites pièces SoA, il y a quelques avantages mais pas 4x


Qu'est-ce que IA64 (Itanium) a à voir avec quelque chose ici? SSE est un SIMD x86 / x86-64.


4 Réponses :


3
votes

La quantité de données dont vous avez besoin pour accéder à votre sous-système de mémoire est identique, que vous fassiez 4 tableaux séparés ou que vous suggériez l'entrelacement. Vous ne sauvegardez donc pas les chargements ou les écritures de page (je ne vois pas pourquoi le cas des "tableaux séparés" devrait lire et écrire chaque page ou ligne de cache plus d'une fois).

Vous dispersez davantage les transferts de mémoire - vous pourriez avoir 1 cache L1 raté à chaque itération dans votre cas et 4 cache raté toutes les 4 itérations dans le cas des "tableaux séparés". Je ne sais pas lequel serait préféré.

Quoi qu'il en soit, le point principal est de ne pas avoir de mémoire inutile dans vos caches avec lesquelles vous n'interagissez pas. Dans votre exemple, avoir des valeurs de string qui ne sont ni lues ni écrites, mais toujours poussées à travers les caches, consomme inutilement de la bande passante.


10 commentaires

Merci, les chaînes du code scalaire n'étaient qu'une configuration pour la question. Je suis tout à fait d'accord et je comprends que la suppression des données inutilisées est essentielle pour un code rapide. Je vais devoir réfléchir au reste.


Oh, bien sûr, je comprends! Tous les tableaux dispersés au hasard seront chargés dans L1, puis le pré-extracteur doit simplement garder une trace de 4 pointeurs et essayer de suivre. Wow, peut-il vraiment faire ça?!


@arctiq: Dépend beaucoup du CPU. Les processeurs de serveur haut de gamme sont certainement capables; un processeur bon marché pour les systèmes embarqués pourrait même ne pas avoir de prefetcher.


@arctiq Cela pourrait simplement être clang d'être stupide (pourquoi est-ce que ça clobber et recalculer rax à chaque fois? Je suppose qu'il y a une raison que je ne vois pas), mais je impossible de faire en sorte que votre version entrelacée utilise moins d'instructions que la version "tableaux séparés": godbolt.org/z/WehVhR


Votre exemple godbolt utilise des appels de fonction non en ligne, donc regarder le code-gen semble assez inutile. Peut-être que vous cherchiez autre chose, car le code que vous avez lié ne fait rien avec RAX dans aucune des deux fonctions.


@PeterCordes Comment écririez-vous le code pour générer un assemblage "pertinent" pour comparaison? Je conviens que le code n'est probablement pas exactement représentatif d'un cas d'utilisation réel, mais je ne pouvais pas le masser à ce que mon intuition me dirait (à savoir que l'incrémentation de quatre pointeurs séparés prend plus d'instructions ou de registres que l'incrémentation d'un pointeur à quatre décalages constants ).


@PeterCordes Comme, à ce point, je viens d'abandonner parce que (& tomodify - offset) devrait être simple à calculer pour les membres rgba4 consécutifs (une fois que vous avez la première différence, les autres devraient simplement être "ajouter 128 bits" à chaque fois), mais il utilise plus add < / code> s. Y a-t-il un alias qui me manque ici?


@MaxLanghof: C'est probablement bizarre à cause de volatile à l'intérieur de la boucle. Oh attendez, sortie volatile mm128 *; est un pointeur vers volatile, mais le pointeur lui-même n'est pas volatil, il est donc légal pour gcc / clang d'optimiser la boucle qui modifie il 4 fois dans un magasin. Cela semble juste être bizarre; L'ASM de gcc est plus simple (mais pas seulement un ajout). godbolt.org/z/9_XluC . IDK, je ne sais pas pourquoi vous regardez des boucles qui ne gâchent qu'avec des pointeurs au lieu de en fait lire la mémoire et par ex. additionner les valeurs x , ou la longueur du vecteur somme des carrés.


Votre "alternative 2" se compile très bien avec gcc godbolt.org/z/62L2Yn . 4x movups charge avec des décalages croissants dans le mode d'adressage, doublant chacun d'eux avec addps même, même , puis 4x movups magasins avec le même mode d'adressage. vs la version des tableaux séparés incrémentant 4 pointeurs. (Et avec des contrôles de chevauchement avec 2 versions de la boucle.)


@PeterCordes Je ne faisais que jouer avec les pointeurs et non la mémoire parce que la discussion portait sur la question de savoir si le calcul des adresses était meilleur d'une manière ou d'une autre (c'est-à-dire que l'accès à la mémoire après est le même dans tous les cas), et les "4 pointeurs séparés" case aurait certainement perdu celui-là (même si j'ai gâché le volatile). Si je ne peux pas faire en sorte que que se comporte comme prévu, c'est clairement au-dessus de ma tête. Et oui, ce gcc "alternatif 2" est ce que je cherchais. Je suppose que la morale de l'histoire est "vérifiez votre asm résultant".



1
votes

Une des choses qui n'a pas encore été mentionnée est que l'accès à la mémoire a un peu de latence. Et bien sûr, lors de la lecture à partir de 4 pointeurs, le résultat est disponible lorsque la dernière valeur arrive. Ainsi, même si 3 valeurs sur 4 sont dans le cache, la dernière valeur devra peut-être provenir de la mémoire et bloquer toute votre opération.

C'est pourquoi SSE ne prend même pas en charge ce mode. Toutes vos valeurs doivent être contiguës en mémoire, et pendant un certain temps, elles ont dû être alignées (afin qu'elles ne puissent pas franchir une limite de ligne de cache).

Fait important, cela signifie que votre exemple (Structure of Arrays) ne fonctionne pas dans le matériel SSE . Vous ne pouvez pas utiliser l'élément [1] de 4 vecteurs différents en une seule opération. Vous pouvez utiliser des éléments [0] à [3] à partir d'un seul vecteur.


3 commentaires

Le point du code est que vous saisissez 4 x à la fois dans un mm128 , 4 y à la fois etc., puis vous faites de même opération (qui implique normalement 1 x , 1 y etc.) quatre fois à la fois. (À aucun moment vous ne mettez x [0] , y [0] etc. dans le même mm128 .) La question est de savoir comment organiser au mieux ces groupes de 4 en mémoire.


Point intéressant sur la latence. Vous vous méprenez, chaque champ d'orientation est dans un __mm128 différent, donc pour addition: [x1, x2, x3, x4] + [y1, y2, y3, y4]; au lieu de [x1, y1, z1, w1] + [x2, y2, z2, w2]. Désolé, le deuxième exemple n'aurait aucun sens pour une étape de normalisation, mais c'est en quelque sorte une conséquence bienvenue de la mise en page.


@arctiq: Ok, alors vous dites que l'appel à normalize_sse (o1, o2, o3, o4) normalise en fait 4 tableaux, pas un seul 4 vecteurs? Max a un point ci-dessus - c'est l'implémentation qui dicte la meilleure disposition de la mémoire. J'ai ici un code AVX particulièrement gnarly où j'ai une matrice partiellement transposée qui n'est ni ligne-principale ni colonne-principale. Cela n'a de sens qu'une fois que vous avez compris comment cette matrice est utilisée, lorsque soudainement les accès deviennent séquentiels.



1
votes

J'ai implémenté un benchmark simple pour les deux méthodes.

Résultat: La mise en page par bandes est au mieux 10% plus rapide que la mise en page standard *. Mais avec SSE4.1, nous pouvons faire beaucoup mieux.

* Lorsque compilé avec gcc -Ofast sur un processeur i5-7200U .

La structure est em> un peu plus facile à travailler, mais beaucoup moins polyvalent. Cela pourrait cependant avoir un petit avantage dans un scénario réel, une fois que l'allocateur est suffisamment occupé.

Disposition par bandes

Durée 4624 ms

/* -----------------------------------------------------------------------------
        AoS layout [{X,y,Z,w},{X,y,Z,w},{X,y,Z,w},{X,y,Z,w},...]
----------------------------------------------------------------------------- */

using AoS_scene = std::vector<__m128>;

void print_scene(AoS_scene const &scene)
{
        // This is likely undefined behavior. Data might need to be stored
        // differently, but this is simpler to index.
        auto &&punned_data = reinterpret_cast<float const *>(scene.data());
        auto scene_size = std::size(scene);

        // Limit to 8 lines
        for(size_t j = 0lu; j < std::min(scene_size, 8lu); ++j) {
                for(size_t i = 0lu; i < 4lu; ++i) {
                        printf("%10.3e ", punned_data[j * 4lu + i]);
                }
                printf("\n");
        }
        if(scene_size > 8lu) {
                printf("(%lu more)...\n", scene_size - 8lu);
        }
        printf("\n");
}

void normalize(AoS_scene &scene)
{
        // Euclidean norm, SIMD 4 x 4D-vectors at a time.
        for(size_t i = 0lu; i < scene.size(); i += 4lu) {
                __m128 vec = scene[i];
                __m128 dot = _mm_dp_ps(vec, vec, 255);
                __m128 norms = _mm_sqrt_ps(dot);
                scene[i] = _mm_div_ps(vec, norms);
        }
}

float randf()
{
        std::random_device random_device;
        std::default_random_engine random_engine{random_device()};
        std::uniform_real_distribution<float> distribution(-10.0f, 10.0f);
        return distribution(random_engine);
}

int main()
{
        // Scene description, e.g. cameras, or particles, or boids etc.
        std::vector<__m128> scene(40'000);

        for(size_t i = 0lu; i < std::size(scene); ++i) {
                scene[i] = _mm_set_ps(randf(), randf(), randf(), randf());
        }

        // Print, normalize 100'000 times, print again

        // Compiler is hopefully not smart enough to realize
        // idempotence of normalization
        using std::chrono::steady_clock;
        using std::chrono::duration_cast;
        using std::chrono::milliseconds;
        // >:(

        print_scene(scene);
        printf("Working...\n");

        auto begin = steady_clock::now();
        for(int j = 0; j < 100'000; ++j) {
                normalize(scene);
                //break;
        }
        auto end = steady_clock::now();
        auto duration = duration_cast<milliseconds>(end - begin);

        printf("Time %lu ms\n", duration.count());
        print_scene(scene);

        return 0;
}
Memory usage summary: heap total: 746552, heap peak: 713736, stack peak: 2720
         total calls   total memory   failed calls
 malloc|          5         746552              0
realloc|          0              0              0  (nomove:0, dec:0, free:0)
 calloc|          0              0              0
   free|          2         672816
Histogram for block sizes:
    0-15              1  20% =========================
 1024-1039            1  20% =========================
32816-32831           1  20% =========================
   large              2  40% ==================================================

Disposition SoA

Durée 4982 ms

#include <chrono>
#include <cstdio>
#include <random>
#include <vector>
#include <xmmintrin.h>

/* -----------------------------------------------------------------------------
        SoA layout [X,X,X,X,...], [y,y,y,y,...], [Z,Z,Z,Z,...], ...
----------------------------------------------------------------------------- */

struct SoA_scene {
        size_t size;
        float *xs;
        float *ys;
        float *zs;
        float *ws;
};

void print_scene(SoA_scene const &scene)
{
        // This is likely undefined behavior. Data might need to be stored
        // differently, but this is simpler to index.

        // Limit to 8 lines
        for(size_t j = 0lu; j < std::min(scene.size, 8lu); ++j) {
                printf("%10.3e ", scene.xs[j]);
                printf("%10.3e ", scene.ys[j]);
                printf("%10.3e ", scene.zs[j]);
                printf("%10.3e ", scene.ws[j]);
                printf("\n");
        }
        if(scene.size > 8lu) {
                printf("(%lu more)...\n", scene.size - 8lu);
        }
        printf("\n");
}

void normalize(SoA_scene &scene)
{
        // Euclidean norm, SIMD 4 x 4D-vectors at a time.
        for(size_t i = 0lu; i < scene.size; i += 4lu) {
                __m128 xs = _mm_load_ps(&scene.xs[i]);
                __m128 ys = _mm_load_ps(&scene.ys[i]);
                __m128 zs = _mm_load_ps(&scene.zs[i]);
                __m128 ws = _mm_load_ps(&scene.ws[i]);

                __m128 xxs = _mm_mul_ps(xs, xs);
                __m128 yys = _mm_mul_ps(ys, ys);
                __m128 zzs = _mm_mul_ps(zs, zs);
                __m128 wws = _mm_mul_ps(ws, ws);

                __m128 xx_yys = _mm_add_ps(xxs, yys);
                __m128 zz_wws = _mm_add_ps(zzs, wws);

                __m128 xx_yy_zz_wws = _mm_add_ps(xx_yys, zz_wws);

                __m128 norms = _mm_sqrt_ps(xx_yy_zz_wws);

                __m128 normed_xs = _mm_div_ps(xs, norms);
                __m128 normed_ys = _mm_div_ps(ys, norms);
                __m128 normed_zs = _mm_div_ps(zs, norms);
                __m128 normed_ws = _mm_div_ps(ws, norms);

                _mm_store_ps(&scene.xs[i], normed_xs);
                _mm_store_ps(&scene.ys[i], normed_ys);
                _mm_store_ps(&scene.zs[i], normed_zs);
                _mm_store_ps(&scene.ws[i], normed_ws);
        }
}

float randf()
{
        std::random_device random_device;
        std::default_random_engine random_engine{random_device()};
        std::uniform_real_distribution<float> distribution(-10.0f, 10.0f);
        return distribution(random_engine);
}

int main()
{
        // Scene description, e.g. cameras, or particles, or boids etc.
        // Has to be a multiple of 4!   -- No edge case handling.
        auto scene_size = 40'000lu;
        std::vector<float> xs(scene_size);
        std::vector<float> ys(scene_size);
        std::vector<float> zs(scene_size);
        std::vector<float> ws(scene_size);

        for(size_t i = 0lu; i < scene_size; ++i) {
                xs[i] = randf();
                ys[i] = randf();
                zs[i] = randf();
                ws[i] = randf();
        }

        SoA_scene scene{
                scene_size,
                std::data(xs),
                std::data(ys),
                std::data(zs),
                std::data(ws)
        };
        // Print, normalize 100'000 times, print again

        // Compiler is hopefully not smart enough to realize
        // idempotence of normalization
        using std::chrono::steady_clock;
        using std::chrono::duration_cast;
        using std::chrono::milliseconds;
        // >:(

        print_scene(scene);
        printf("Working...\n");

        auto begin = steady_clock::now();
        for(int j = 0; j < 100'000; ++j) {
                normalize(scene);
        }
        auto end = steady_clock::now();
        auto duration = duration_cast<milliseconds>(end - begin);

        printf("Time %lu ms\n", duration.count());
        print_scene(scene);

        return 0;
}


5 commentaires

Un inconvénient majeur de l'entrelacement sur la largeur du vecteur est que vous devez modifier la disposition pour tirer parti de vecteurs plus larges. (AVX, AVX512). Mais oui, lorsque vous vectorisez manuellement, cela peut en valoir la peine si toutes vos boucles (importantes) utilisent toujours tous les membres de la structure. Sinon, le point de Max s'applique: une boucle qui ne touche que x et y gaspillera de la bande passante sur les membres z et w .


* (scene.xs + j) . C ++ a une syntaxe plus simple pour cela: scene.xs [j] .


J'écris souvent des trucs comme _mm_load_ps (scene.xs + i) , plutôt que & scene.xs [i] , mais les deux sont valides. (Bien sûr, dans le code que j'écris moi-même, j'ai tendance à utiliser des incréments de pointeur dans le C ++, car c'est généralement ce que je veux dans l'asm. Les processeurs Intel ne peuvent conserver les modes d'adressage indexés micro-fusionnés que dans des cas limités, sans VEX load + instructions ALU. Micro fusion et modes d'adressage )


@PeterCordes puisque les variables sont des pointeurs, il me semble incohérent de prétendre qu'elles sont des tableaux à un endroit et de revenir à l'arithmétique des pointeurs à un autre. Pensez aux nouveaux arrivants, mec! C ++ est assez déroutant comme il est. :)


J'écris généralement du code de type C lors de l'optimisation. Comme je l'ai dit, j'écris normalement du code qui fait des incréments de pointeur, sans prétendre que quelque chose est un tableau. Si vous essayez de faire ressembler votre code à des tableaux, utilisez bien sûr & arr [i] , mais cela me semble bruyant par rapport à arr + i . Si l'expression implique un accès mémoire, alors j'ai des crochets. Comme _mm_set1_ps (arr [i]) . Si ce n'est pas le cas (calculant simplement un pointeur, même si je le transmets à une charge ou à un stockage intrinsèque), alors pas de crochets. Identique à asm avec la syntaxe NASM (err, si vous ignorez LEA: P).



2
votes

Un inconvénient majeur de l'entrelacement sur la largeur du vecteur est que vous devez modifier la disposition pour tirer parti de vecteurs plus larges. (AVX, AVX512).

Mais oui, lorsque vous vectorisez uniquement manuellement (sans boucles qu'un compilateur pourrait vectoriser automatiquement avec son choix de largeur de vecteur), cela pourrait peut-être en valoir la peine si tout vos boucles (importantes) utilisent toujours tous les membres de la structure.

Sinon, le point de Max s'applique: une boucle qui ne touche que x et y gaspillera bande passante sur les membres z et w .


Ce ne sera pas moyen em> plus rapide, cependant ; avec une quantité raisonnable de déroulement de boucle, l'indexation de 4 tableaux ou l'incrémentation de 4 pointeurs est à peine pire que 1. La prélecture matérielle sur les processeurs Intel peut suivre un flux avant + 1 flux arrière par page 4k, donc 4 flux d'entrée est fondamentalement bien.

(Mais L2 est associatif à 4 voies dans Skylake, contre 8 auparavant, donc plus de 4 flux d'entrée, tous avec le même alignement par rapport à une page de 4k, provoqueraient des erreurs de conflit / une défaite de la prélecture. Donc, avec plus de 4 grands / les tableaux alignés sur les pages, un format entrelacé pourrait éviter ce problème.)

Pour les petits tableaux, où l'ensemble de la chose entrelacée tient dans une page 4k, oui, c'est un avantage potentiel. Sinon, c'est à peu près le même nombre total de pages touchées et d'éventuels ratés TLB, avec un 4x plus souvent au lieu de venir par groupe de 4. Cela pourrait bien être mieux pour la prélecture TLB, si elle peut faire une page à l'avance à la place d'être submergé par plusieurs ratés TLB venant en même temps.


Modification de la structure SoA:

Cela peut aider à informer le compilateur que la mémoire pointée par chacun des pointeurs ne se chevauche pas. La plupart des compilateurs C ++ (y compris les 4 compilateurs x86 majeurs, gcc / clang / MSVC / ICC) prennent en charge __restrict comme mot-clé avec la même sémantique que C99 restrict . Ou pour la portabilité, utilisez #ifdef / #define pour définir un mot-clé restrict comme vide ou __restrict ou autre, comme approprié pour le compilateur.

struct SoA_scene {
        size_t size;
        float *__restrict xs;
        float *__restrict ys;
        float *__restrict zs;
        float *__restrict ws;
};

Cela peut certainement aider avec la vectorisation automatique, sinon le compilateur ne sait pas que xs [i] = foo; code> ne change pas la valeur de ys [i + 1] pour la prochaine itération.

Si vous lisez ces variables dans des variables locales (le compilateur est donc sûr que ce pointeur les affectations ne modifient pas le pointeur lui-même dans la structure), vous pouvez les déclarer comme float * __ restrict xs = soa.xs; et ainsi de suite.

Le format entrelacé évite par nature cette possibilité d'alias.


2 commentaires

Je me demande: si vous utilisez std::vector (pas d'allocateur personnalisé) au lieu de float * ( __restrict ), le compilateur comprend l'impossibilité d'aliasing?


@MaxLanghof: cela peut dépendre de la qualité de votre bibliothèque C ++. Sauf non, std :: vector ne peut pas utiliser __restrict en interne car cela briserait le code qui a float * en un std :: vector. Je ne pense pas qu'une implémentation de bibliothèque std :: vector puisse faire quoi que ce soit sur les compilateurs actuels pour lui dire qu'elle n'a pas d'alias avec le stockage pour d'autres vecteurs std :: du même type.