Best Practices für die Zuordnung / Initialisierung von portablem Multicore- / NUMA-Speicher

17

Wenn in Umgebungen mit gemeinsam genutztem Speicher (z. B. Threading über OpenMP, Pthreads oder TBB) Berechnungen mit begrenzter Speicherbandbreite durchgeführt werden, besteht ein Dilemma dahingehend, wie sichergestellt werden kann, dass der Speicher korrekt auf den physischen Speicher verteilt wird, sodass jeder Thread hauptsächlich auf einen Speicher zugreift "lokaler" Speicherbus. Obwohl die Schnittstellen nicht portabel sind, haben die meisten Betriebssysteme Möglichkeiten, die Thread-Affinität festzulegen (z. B. pthread_setaffinity_np()auf vielen POSIX-Systemen, sched_setaffinity()unter Linux, SetThreadAffinityMask()unter Windows). Es gibt auch Bibliotheken wie hwloc zum Bestimmen der Speicherhierarchie, aber leider bieten die meisten Betriebssysteme noch keine Möglichkeiten zum Festlegen von NUMA-Speicherrichtlinien. Linux ist mit libnuma eine bemerkenswerte AusnahmeErmöglichen, dass die Anwendung die Speicherrichtlinie und die Seitenmigration mit der Seitengranularität manipuliert (seit 2004 in der Hauptzeile, daher weit verbreitet). Andere Betriebssysteme erwarten, dass Benutzer eine implizite "First Touch" -Richtlinie einhalten.

Das Arbeiten mit einer "First Touch" -Richtlinie bedeutet, dass der Aufrufer Threads mit der Affinität erstellen und verteilen soll, die er später beim ersten Schreiben in den neu zugewiesenen Speicher verwenden möchte. (Nur sehr wenige Systeme sind so konfiguriert, dass sie malloc()tatsächlich Seiten finden. Es wird lediglich versprochen, sie zu finden, wenn sie tatsächlich fehlerhaft sind, möglicherweise durch verschiedene Threads.) Dies impliziert, dass die Zuweisung mit calloc()oder die sofortige Initialisierung des Speichers nach der Zuweisung mit memset()schädlich ist, da dies zu Fehlern führen kann Der gesamte Speicher auf dem Speicherbus des Kerns, auf dem der Zuordnungsthread ausgeführt wird, führt zu einer Speicherbandbreite im ungünstigsten Fall, wenn auf den Speicher von mehreren Threads aus zugegriffen wird. Gleiches gilt für den C ++ - newOperator, der darauf besteht, viele neue Zuordnungen zu initialisieren (zstd::complex). Einige Beobachtungen zu dieser Umgebung:

  • Die Zuweisung kann als "Thread-Kollektiv" erfolgen, aber die Zuweisung wird nun in das Threading-Modell gemischt, was für Bibliotheken unerwünscht ist, die mit Clients unter Verwendung verschiedener Threading-Modelle interagieren müssen (möglicherweise mit jeweils eigenen Thread-Pools).
  • RAII wird als wichtiger Bestandteil von idiomatischem C ++ angesehen, scheint jedoch die Speicherleistung in einer NUMA-Umgebung aktiv zu beeinträchtigen. Die Platzierung newkann mit dem über malloc()oder von zugewiesenen Speicher verwendet werden libnuma, dies ändert jedoch den Zuweisungsprozess (was ich für notwendig halte).
  • BEARBEITEN: Meine frühere Aussage zum Operator newwar falsch, er kann mehrere Argumente unterstützen, siehe Chetans Antwort. Ich glaube, es gibt immer noch Bedenken, Bibliotheken oder STL-Container dazu zu bringen, eine bestimmte Affinität zu verwenden. Es können mehrere Felder gepackt sein, und es kann unpraktisch sein, sicherzustellen, dass z. B. eine std::vectorNeuzuweisung mit dem richtigen aktiven Kontextmanager erfolgt.
  • Jeder Thread kann seinen eigenen privaten Speicher zuordnen und fehlerhaft behandeln, aber dann ist die Indizierung in benachbarte Regionen komplizierter. (Betrachten Sie ein dünn besetztes Matrixvektorprodukt mit einer Zeilenpartition der Matrix und der Vektoren. Die Indizierung des nicht besetzten Teils von x erfordert eine kompliziertere Datenstruktur, wenn x im virtuellen Speicher nicht zusammenhängend ist.)yAxxx

Werden Lösungen für die NUMA-Zuweisung / -Initialisierung als idiomatisch betrachtet? Habe ich andere kritische Fallstricke ausgelassen?

(Ich meine nicht, dass meine C ++ - Beispiele eine Betonung dieser Sprache implizieren, jedoch codiert die C ++ - Sprache einige Entscheidungen über die Speicherverwaltung, die eine Sprache wie C nicht enthält. Daher besteht tendenziell ein größerer Widerstand, wenn vorgeschlagen wird, dass C ++ - Programmierer diese ausführen Dinge anders.)

Jed Brown
quelle

Antworten:

7

Eine von mir bevorzugte Lösung für dieses Problem besteht darin, Threads und (MPI-) Aufgaben effektiv auf Speichercontrollerebene zu disaggregieren. Entfernen Sie also die NUMA-Aspekte aus Ihrem Code, indem Sie für jeden Task einen Task pro CPU-Sockel oder Speichercontroller und anschließend Threads erstellen. Wenn Sie dies auf diese Weise tun, sollten Sie in der Lage sein, den gesamten Speicher sicher über die erste Berührung oder eine der verfügbaren APIs an diesen Socket / Controller zu binden, unabhängig davon, welcher Thread tatsächlich die Zuweisung oder Initialisierung vornimmt. Das Weiterleiten von Nachrichten zwischen Sockets ist in der Regel, zumindest in MPI, recht gut optimiert. Sie können immer mehr MPI-Aufgaben als diese haben, aber aufgrund der Probleme, die Sie ansprechen, empfehle ich selten, dass die Leute weniger haben.

Bill Barth
quelle
1
Dies ist eine praktische Lösung, aber obwohl wir schnell mehr Kerne erhalten, stagniert die Anzahl der Kerne pro NUMA-Knoten bei ungefähr 4. Werden wir also auf dem hypothetischen 1000-Kern-Knoten 250 MPI-Prozesse ausführen? (Das wäre toll, aber ich bin skeptisch.)
Jed Brown
Ich stimme nicht zu, dass die Anzahl der Kerne pro NUMA stagniert. Sandy Bridge E5 hat 8. Magny Cours hatte 12. Ich habe einen Westmere-EX-Knoten mit 10. Interlagos (ORNL Titan) hat 20. Knights Corner wird mehr als 50 haben. Ich würde raten, dass die Kerne pro NUMA behalten Schritt mit Moores Gesetz, mehr oder weniger.
Bill Barth
Magny Cours und Interlagos haben zwei Matrizen in verschiedenen NUMA-Regionen, also 6 und 8 Kerne pro NUMA-Region. Zurück zu 2006, wo zwei Sockel von Quad-Core-Clovertown dieselbe Schnittstelle (Blackford-Chipsatz) für den Speicher verwenden und die Anzahl der Kerne pro NUMA-Region für mich nicht so schnell wächst. Blue Gene / Q erweitert diese flache Ansicht des Speichers ein bisschen weiter und Knight's Corner wird vielleicht einen weiteren Schritt unternehmen (obwohl es sich um ein anderes Gerät handelt, sollten wir uns also stattdessen mit GPUs vergleichen, bei denen wir 15 (Fermi) oder jetzt 8 (Fermi) haben. Kepler) SMs, die einen flachen Speicher anzeigen).
Jed Brown
Guter Anruf auf den AMD-Chips. Ich hatte vergessen. Dennoch denke ich, dass Sie für eine Weile ein kontinuierliches Wachstum in diesem Bereich sehen werden.
Bill Barth
6

Diese Antwort ist eine Antwort auf zwei C ++ - bezogene Missverständnisse in der Frage.

  1. "Gleiches gilt für den Operator C ++ new, der darauf besteht, neue Zuordnungen (einschließlich PODs) zu initialisieren."
  2. "C ++ - Operator new akzeptiert nur einen Parameter"

Es ist keine direkte Antwort auf von Ihnen erwähnte Multi-Core-Probleme. Nur auf Kommentare reagieren, die C ++ - Programmierer als C ++ - Fanatiker klassifizieren, damit die Reputation erhalten bleibt;).

Zu Punkt 1. C ++ "new" oder Stack Allocation bestehen nicht darauf, neue Objekte zu initialisieren, egal ob PODs oder nicht. Der vom Benutzer definierte Standardkonstruktor der Klasse trägt diese Verantwortung. Der erste Code unten zeigt Junk-Print, ob die Klasse POD ist oder nicht.

Zu Punkt 2. C ++ erlaubt das Überladen von "new" mit mehreren Argumenten. Der zweite Code unten zeigt einen solchen Fall für die Zuordnung einzelner Objekte. Es sollte eine Idee geben und möglicherweise für die Situation nützlich sein, die Sie haben. operator new [] kann ebenfalls entsprechend geändert werden.

// Code für Punkt 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';
}

Intels 11.1-Compiler zeigt diese Ausgabe (was natürlich nicht initialisierter Speicher ist, auf den "a" zeigt).

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

// Code für Punkt 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";
}

quelle
Danke für die Korrekturen. Es scheint , dass C ++ nicht vorhanden zusätzliche Komplikationen in Bezug auf C, mit Ausnahme von nicht-POD - Arrays wie std::complexdie sind explizit initialisiert.
Jed Brown
1
@JedBrown: Grund Nummer 6 zu vermeiden std::complex?
Jack Poulson
1

In deal.II haben wir die Software-Infrastruktur, um die Assemblierung jeder Zelle auf mehrere Kerne mithilfe der Threading-Bausteine ​​zu parallelisieren (im Wesentlichen haben Sie eine Aufgabe pro Zelle und müssen diese Aufgaben auf verfügbaren Prozessoren planen - so ist es nicht implementiert, aber es ist die allgemeine Idee). Das Problem ist, dass Sie für die lokale Integration eine Reihe von temporären Objekten (Scratch-Objekten) benötigen und mindestens so viele bereitstellen müssen, wie Tasks parallel ausgeführt werden können. Wir sehen eine schlechte Beschleunigung, wahrscheinlich, weil eine Aufgabe, wenn sie auf einen Prozessor gelegt wird, eines der Arbeitsobjekte erfasst, die sich normalerweise im Cache eines anderen Kerns befinden. Wir hatten zwei Fragen:

(i) Ist das wirklich der Grund? Wenn wir das Programm unter Cachegrind ausführen, sehe ich, dass ich im Grunde die gleiche Anzahl von Anweisungen verwende wie beim Ausführen des Programms auf einem einzelnen Thread, aber die Gesamtlaufzeit, die über alle Threads angesammelt ist, ist viel größer als die auf einem einzelnen Thread. Liegt es wirklich daran, dass ich ständig Fehler im Cache habe?

(ii) Wie kann ich herausfinden, wo ich bin, wo sich die einzelnen Arbeitsobjekte befinden und welches Arbeitsobjekt ich benötigen würde, um auf das Objekt zuzugreifen, das sich im Cache meines aktuellen Kerns befindet?

Letztendlich haben wir keine Antwort auf eine dieser Lösungen gefunden und nach einigen Arbeiten festgestellt, dass uns die Werkzeuge fehlen, um diese Probleme zu untersuchen und zu lösen. Ich weiß, wie man das Problem (ii) zumindest im Prinzip löst (und zwar unter der Annahme, dass Threads an Prozessorkerne gebunden bleiben - eine andere Vermutung, die nicht trivial zu testen ist), aber ich habe keine Tools zum Testen des Problems (ich).

Aus unserer Sicht ist der Umgang mit NUMA immer noch eine ungelöste Frage.

Wolfgang Bangerth
quelle
Sie sollten Ihre Threads an Sockets binden, damit Sie sich nicht wundern müssen, ob Prozessoren angeheftet sind. Linux liebt es, Dinge zu bewegen.
Bill Barth
Außerdem sollten Sie mit getcpu () oder sched_getcpu () (abhängig von Ihrer libc und Ihrem Kernel und so weiter) bestimmen können, wo Threads unter Linux ausgeführt werden.
Bill Barth
Ja, und ich denke, die Threading-Bausteine, mit denen wir die Arbeit an Threads planen, verbinden Threads mit Prozessoren. Aus diesem Grund haben wir versucht, mit thread-lokalem Speicher zu arbeiten. Trotzdem fällt es mir schwer, eine Lösung für mein Problem (i) zu finden.
Wolfgang Bangerth
1

Über hwloc hinaus gibt es einige Tools, die Berichte zur Speicherumgebung eines HPC-Clusters erstellen und zum Festlegen verschiedener NUMA-Konfigurationen verwendet werden können.

Ich würde LIKWID als ein solches Tool empfehlen, da es einen Code-basierten Ansatz vermeidet, mit dem Sie beispielsweise einen Prozess an einen Kern anheften können. Mit diesem Tool-Ansatz zur Adressierung der rechnerspezifischen Speicherkonfiguration können Sie die Portabilität Ihres Codes über Cluster hinweg sicherstellen.

Sie finden eine kurze Präsentation aus ISC'13 " LIKWID - Lightweight Performance Tools ", und die Autoren haben einen Artikel über Arxiv " Best Practices für HPM-unterstütztes Performance Engineering auf modernen Multicore-Prozessoren " veröffentlicht. In diesem Dokument wird ein Ansatz zur Interpretation der Daten von Hardwarezählern beschrieben, um einen performanten Code zu entwickeln, der für die Architektur und die Speichertopologie Ihres Computers spezifisch ist.

Eoinbrazil
quelle
LIKWID ist nützlich, aber die Frage war eher, wie numerische / speichersensitive Bibliotheken geschrieben werden können, mit denen die erwartete Lokalität in einer Vielzahl von Ausführungsumgebungen, Threading-Schemata, MPI-Ressourcenverwaltung und Affinitätseinstellung zuverlässig ermittelt und selbst überprüft werden kann andere Bibliotheken usw.
Jed Brown