Meilleures pratiques d'allocation / initialisation de mémoire multicœur portable / NUMA

17

Lorsque des calculs limités en bande passante mémoire sont effectués dans des environnements de mémoire partagée (par exemple, filetés via OpenMP, Pthreads ou TBB), il existe un dilemme sur la façon de garantir que la mémoire est correctement répartie sur la mémoire physique , de sorte que chaque thread accède principalement à la mémoire sur un bus mémoire "local". Bien que les interfaces ne soient pas portables, la plupart des systèmes d'exploitation ont des moyens de définir l'affinité des threads (par exemple pthread_setaffinity_np()sur de nombreux systèmes POSIX, sched_setaffinity()Linux, SetThreadAffinityMask()Windows). Il existe également des bibliothèques telles que hwloc pour déterminer la hiérarchie de la mémoire, mais malheureusement, la plupart des systèmes d'exploitation ne fournissent pas encore de moyens de définir des stratégies de mémoire NUMA. Linux est une exception notable, avec libnumapermettant à l'application de manipuler la politique de mémoire et la migration des pages à la granularité des pages (en ligne principale depuis 2004, donc largement disponible). D'autres systèmes d'exploitation s'attendent à ce que les utilisateurs observent une politique implicite de «premier contact».

Travailler avec une politique de «première touche» signifie que l'appelant doit créer et distribuer des threads avec l'affinité qu'il envisage d'utiliser plus tard lors de la première écriture dans la mémoire fraîchement allouée. (Très peu de systèmes sont configurés de manière à malloc()trouver réellement des pages, il promet simplement de les trouver lorsqu'elles sont réellement défaillantes, peut-être par des threads différents.) Cela implique que l'allocation utilisant calloc()ou initialisant immédiatement la mémoire après l'allocation à l'aide memset()est nuisible car elle aura tendance à défaillir toute la mémoire sur le bus mémoire du cœur exécutant le thread d'allocation, ce qui conduit à la bande passante mémoire la plus défavorable lorsque la mémoire est accessible à partir de plusieurs threads. Il en va de même pour l' newopérateur C ++ qui insiste pour initialiser de nombreuses nouvelles allocations (par exemplestd::complex). Quelques observations sur cet environnement:

  • L'allocation peut être rendue «collective de threads», mais maintenant l'allocation devient mélangée dans le modèle de thread, ce qui n'est pas souhaitable pour les bibliothèques qui peuvent avoir à interagir avec des clients utilisant différents modèles de thread (peut-être chacun avec leurs propres pools de threads).
  • Le RAII est considéré comme une partie importante du C ++ idiomatique, mais il semble nuire activement aux performances de la mémoire dans un environnement NUMA. Le placement newpeut être utilisé avec de la mémoire allouée via malloc()ou des routines de libnuma, mais cela change le processus d'allocation (ce qui, je pense, est nécessaire).
  • EDIT: Ma déclaration précédente sur l'opérateur newétait incorrecte, elle peut prendre en charge plusieurs arguments, voir la réponse de Chetan. Je crois qu'il y a toujours un souci d'obtenir des bibliothèques ou des conteneurs STL pour utiliser l'affinité spécifiée. Plusieurs champs peuvent être compressés et il peut être gênant de s'assurer que, par exemple, un std::vectorréalloue avec le gestionnaire de contexte correct actif.
  • Chaque thread peut allouer et fausser sa propre mémoire privée, mais l'indexation dans les régions voisines est plus compliquée. (Considérons un produit matriciel-vecteur clairsemé avec une partition en ligne de la matrice et des vecteurs; l'indexation de la partie non possédée de x nécessite une structure de données plus compliquée lorsque x n'est pas contigu dans la mémoire virtuelle.)yUNEXXX

Est-ce que des solutions à l'allocation / initialisation NUMA sont considérées comme idiomatiques? Ai-je omis d'autres problèmes critiques?

(Je ne veux pas pour mon C ++ exemples pour impliquer l'accent sur cette langue, mais le C ++ langage code des décisions sur la gestion de la mémoire qu'une langue comme C n'a pas, donc il a tendance à être une plus grande résistance en suggérant que les programmeurs C ++ font les les choses différemment.)

Jed Brown
la source

Réponses:

7

Une solution à ce problème que j'ai tendance à préférer est de désagréger les threads et les tâches (MPI) au niveau du contrôleur de mémoire. C'est-à-dire, supprimez les aspects NUMA de votre code en ayant une tâche par socket CPU ou contrôleur de mémoire, puis des threads sous chaque tâche. Si vous le faites de cette façon, vous devriez pouvoir lier toute la mémoire à ce socket / contrôleur en toute sécurité via la première touche ou l'une des API disponibles, quel que soit le thread qui effectue le travail d'allocation ou d'initialisation. Le message passant entre les sockets est généralement assez bien optimisé, au moins en MPI. Vous pouvez toujours avoir plus de tâches MPI que cela, mais en raison des problèmes que vous soulevez, je recommande rarement aux gens d'en avoir moins.

Bill Barth
la source
1
Il s'agit d'une solution pratique, mais même si nous obtenons rapidement plus de cœurs, le nombre de cœurs par nœud NUMA est assez stagnant aux alentours de 4. Donc, sur l'hypothétique nœud à 1000 cœurs, allons-nous exécuter 250 processus MPI? (Ce serait génial, mais je suis sceptique.)
Jed Brown
Je ne suis pas d'accord que le nombre de cœurs par NUMA stagne. Sandy Bridge E5 en a 8. Magny Cours en a 12. J'ai un nœud Westmere-EX avec 10. Interlagos (ORNL Titan) en a 20. Knights Corner en aura plus de 50. Je suppose que les cœurs par NUMA gardent rythme avec la loi de Moore, plus ou moins.
Bill Barth
Magny Cours et Interlagos ont deux matrices dans différentes régions NUMA, donc 6 et 8 cœurs par région NUMA. Revenez en 2006 où deux sockets de Clovertown à quatre cœurs partageraient la même interface (chipset Blackford) en mémoire et il ne me semble pas que le nombre de cœurs par région NUMA augmente si rapidement. Blue Gene / Q étend un peu plus cette vue plate de la mémoire et peut-être que Knight's Corner franchira une autre étape (bien qu'il s'agisse d'un appareil différent, nous devrions peut-être comparer les GPU à la place, où nous en avons 15 (Fermi) ou maintenant 8 ( Kepler) SM visualisant la mémoire plate).
Jed Brown
Bon appel aux puces AMD. J'avais oublié. Pourtant, je pense que vous allez voir une croissance continue dans ce domaine pendant un certain temps.
Bill Barth
6

Cette réponse est en réponse à deux idées fausses liées à C ++ dans la question.

  1. "La même chose s'applique au nouvel opérateur C ++ qui insiste sur l'initialisation de nouvelles allocations (y compris les POD)"
  2. "L'opérateur C ++ new ne prend qu'un seul paramètre"

Ce n'est pas une réponse directe aux problèmes multicœurs que vous mentionnez. Il suffit de répondre aux commentaires qui classent les programmeurs C ++ en tant que fanatiques C ++ afin que la réputation soit maintenue;).

Point 1. C ++ "new" ou allocation de pile n'insiste pas sur l'initialisation de nouveaux objets, qu'ils soient POD ou non. Le constructeur par défaut de la classe, tel que défini par l'utilisateur, a cette responsabilité. Le premier code ci-dessous montre les déchets imprimés, que la classe soit POD ou non.

Au point 2. C ++ permet de surcharger "new" avec plusieurs arguments. Le deuxième code ci-dessous montre un tel cas pour l'allocation d'objets uniques. Cela devrait donner une idée et peut-être être utile pour la situation que vous avez. L'opérateur new [] peut également être modifié de manière appropriée.

// Code pour le point 1.

#include <iostream>

struct A
{
    // int/double/char/etc not inited with 0
    // with or without this constructor
    // If present, the class is not POD, else it is.
    A() { }

    int i;
    double d;
    char c[20];
};

int main()
{
    A* a = new A;
    std::cout << a->i << ' ' << a->d << '\n';
    for(int i = 0; i < 20; ++i)
        std::cout << (int) a->c[i] << '\n';
}

Le compilateur Intel 11.1 affiche cette sortie (qui est bien sûr de la mémoire non initialisée pointée par "a").

993001483 6.50751e+029
105
108
... // skipped
97
108

// Code pour le point 2.

#include <cstddef>
#include <iostream>
#include <new>

// Just to use two different classes.
class arena { };
class policy { };

struct A
{
    void* operator new(std::size_t, arena& arena_obj, policy& policy_obj)
    {
        std::cout << "special operator new\n";
        return (void*)0x1234; //Just to test
    }
};

void* operator new(std::size_t, arena& arena_obj, policy& policy_obj)
{
    std::cout << "special operator new (global)\n";
    return (void*)0x5678; //Just to test
}

int main ()
{
    arena arena_obj;
    policy policy_obj;
    A* ptr = new(arena_obj, policy_obj) A;
    int* iptr = new(arena_obj, policy_obj) int;
    std::cout << ptr << "\n";
    std::cout << iptr << "\n";
}

la source
Merci pour les corrections. Il semble que C ++ ne présente pas de complications supplémentaires par rapport à C, à l'exception des tableaux non POD tels que ceux std::complexqui sont explicitement initialisés.
Jed Brown
1
@JedBrown: Raison numéro 6 pour éviter d'utiliser std::complex?
Jack Poulson
1

Nous avons l'infrastructure logicielle pour paralléliser l'assemblage sur chaque cellule sur plusieurs cœurs en utilisant les blocs de construction de thread (en gros, vous avez une tâche par cellule et devez planifier ces tâches sur les processeurs disponibles - ce n'est pas comme ça que c'est mais c'est l'idée générale). Le problème est que pour l'intégration locale, vous avez besoin d'un certain nombre d'objets temporaires (scratch) et vous devez en fournir au moins autant qu'il y a de tâches pouvant s'exécuter en parallèle. Nous constatons une mauvaise accélération, probablement parce que lorsqu'une tâche est placée sur un processeur, elle récupère l'un des objets de travail qui se trouvent généralement dans le cache d'un autre cœur. Nous avions deux questions:

(i) Est-ce vraiment la raison? Lorsque nous exécutons le programme sous cachegrind, je vois que j'utilise essentiellement le même nombre d'instructions que lors de l'exécution du programme sur un seul thread, mais la durée d'exécution totale accumulée sur tous les threads est beaucoup plus grande que celle à un seul thread. Est-ce vraiment parce que je blâme continuellement le cache?

(ii) Comment puis-je savoir où je suis, où se trouvent chacun des objets de travail et quel objet de travail je devrais prendre pour accéder à celui qui est chaud dans le cache de mon noyau actuel?

En fin de compte, nous n'avons trouvé de réponse à aucune de ces solutions et après quelques travaux, nous avons décidé que nous manquions d'outils pour enquêter et résoudre ces problèmes. Je sais comment au moins en principe résoudre le problème (ii) (à savoir, en utilisant des objets thread-local, en supposant que les threads restent épinglés aux cœurs du processeur - une autre conjecture qui n'est pas triviale à tester), mais je n'ai pas d'outils pour tester le problème (je).

Donc, de notre point de vue, traiter avec NUMA est toujours une question non résolue.

Wolfgang Bangerth
la source
Vous devez lier vos threads aux sockets afin de ne pas vous demander si les processeurs sont épinglés. Linux aime déplacer des choses.
Bill Barth
En outre, l'échantillonnage de getcpu () ou sched_getcpu () (selon votre libc et votre noyau et ainsi de suite) devrait vous permettre de déterminer où les threads s'exécutent sous Linux.
Bill Barth
Oui, et je pense que les blocs de construction de threads que nous utilisons pour planifier le travail sur les threads épinglent les threads aux processeurs. C'est pourquoi nous avons essayé de travailler avec le stockage thread-local. Mais il m'est encore difficile de trouver une solution à mon problème (i).
Wolfgang Bangerth
1

Au-delà de hwloc, il existe quelques outils qui peuvent générer des rapports sur l'environnement de mémoire d'un cluster HPC et qui peuvent être utilisés pour définir une variété de configurations NUMA.

Je recommanderais LIKWID comme un tel outil car il évite une approche basée sur le code vous permettant par exemple d'épingler un processus à un noyau. Cette approche de l'outillage pour traiter la configuration de la mémoire spécifique à la machine contribuera à garantir la portabilité de votre code entre les clusters.

Vous pouvez trouver une brève présentation de ISC'13 " LIKWID - Lightweight Performance Tools " et les auteurs ont publié un article sur Arxiv " Meilleures pratiques pour l'ingénierie de performance assistée par HPM sur un processeur multicœur moderne ". Cet article décrit une approche pour interpréter les données des compteurs matériels afin de développer un code performant spécifique à l'architecture et à la topologie de la mémoire de votre machine.

eoinbrazil
la source
LIKWID est utile, mais la question était plutôt de savoir comment écrire des bibliothèques numériques / sensibles à la mémoire qui peuvent obtenir de manière fiable et auto-auditer la localité attendue dans une gamme variée d'environnements d'exécution, de schémas de thread, de gestion des ressources MPI et de paramétrage d'affinité, à utiliser avec autres bibliothèques, etc.
Jed Brown