Najważniejsze wskazówki dotyczące przenośnej wielordzeniowej / alokacji / inicjalizacji pamięci NUMA

17

Gdy obliczenia o ograniczonej przepustowości pamięci są wykonywane w środowiskach pamięci współużytkowanej (np. Wątkowych przez OpenMP, Pthreads lub TBB), pojawia się dylemat, jak zapewnić, aby pamięć była prawidłowo rozdzielona na pamięć fizyczną , tak aby każdy wątek w większości uzyskiwał dostęp do pamięci na „lokalna” magistrala pamięci. Chociaż interfejsy nie są przenośne, większość systemów operacyjnych ma sposoby ustawiania powinowactwa wątków (np. W pthread_setaffinity_np()wielu systemach POSIX, sched_setaffinity()Linux, SetThreadAffinityMask()Windows). Istnieją również biblioteki takie jak hwloc do określania hierarchii pamięci, ale niestety większość systemów operacyjnych nie zapewnia jeszcze sposobów ustawiania zasad pamięci NUMA. Linux jest godnym uwagi wyjątkiem, z libnumaumożliwianie aplikacji do manipulowania polityką pamięci i migracją stron przy ich szczegółowości (w głównej wersji od 2004 roku, a więc szeroko dostępne). Inne systemy operacyjne oczekują, że użytkownicy będą przestrzegać domyślnej zasady „pierwszego dotknięcia”.

Praca z zasadą „pierwszego dotknięcia” oznacza, że ​​osoba dzwoniąca powinna tworzyć i rozpowszechniać wątki z dowolnym powinowactwem, którego zamierzają użyć później, kiedy po raz pierwszy pisze do świeżo przydzielonej pamięci. (Bardzo niewiele systemów jest skonfigurowanych w taki sposób, że malloc()faktycznie wyszukuje strony, po prostu obiecuje je znaleźć, gdy zostaną faktycznie uszkodzone, być może przez różne wątki.) Oznacza to, że przydział calloc()lub natychmiastowe zainicjowanie pamięci po użyciu przydziału memset()jest szkodliwe, ponieważ może powodować błędy cała pamięć na szynę pamięci rdzenia, na którym działa wątek alokujący, co prowadzi do najmniejszego pasma przepustowości pamięci, gdy pamięć jest dostępna z wielu wątków. To samo dotyczy newoperatora C ++ , który nalega na zainicjowanie wielu nowych alokacji (npstd::complex). Kilka uwag na temat tego środowiska:

  • Alokacja może być „kolektywna dla wątków”, ale teraz alokacja staje się mieszana w modelu wątków, co jest niepożądane w przypadku bibliotek, które mogą być zmuszone do interakcji z klientami używającymi różnych modeli wątków (być może każda z własnymi pulami wątków).
  • RAII jest uważane za ważną część idiomatycznego C ++, ale wydaje się być aktywnie szkodliwe dla wydajności pamięci w środowisku NUMA. Umieszczania newmożna używać z pamięcią przydzieloną przez malloc()lub z procedur libnuma, ale zmienia to proces przydzielania (który moim zdaniem jest konieczny).
  • EDYCJA: Moje wcześniejsze stwierdzenie o operatorze newbyło niepoprawne, może obsługiwać wiele argumentów, patrz odpowiedź Chetana. Uważam, że nadal istnieje obawa, aby biblioteki lub kontenery STL używały określonego powinowactwa. Wiele pól może być spakowanych i może być niewygodne upewnienie się, że np. std::vectorRealokacja następuje przy aktywnym poprawnym menedżerze kontekstu.
  • Każdy wątek może alokować i uszkadzać własną pamięć prywatną, ale indeksowanie do sąsiednich regionów jest bardziej skomplikowane. (Rozważ rzadki iloczyn macierzowo-wektorowy z rzędem partycji macierzy i wektorów; indeksowanie nie posiadanej części x wymaga bardziej skomplikowanej struktury danych, gdy x nie jest ciągły w pamięci wirtualnej.)yZAxxx

Czy jakieś rozwiązania dotyczące alokacji / inicjalizacji NUMA są uważane za idiomatyczne? Czy pominąłem inne krytyczne błędy?

(Nie mam na myśli mojego C ++ przykłady sugerować nacisk na ten język, jednak C ++ język koduje pewne decyzje o zarządzaniu pamięcią, że język jak C nie, więc nie wydaje się być większy opór, kiedy sugeruje, że programistów C ++ zrobić ci rzeczy inaczej.)

Jed Brown
źródło

Odpowiedzi:

7

Jednym z rozwiązań tego problemu, który wolę, jest dezagregacja wątków i zadań (MPI) na poziomie kontrolera pamięci. Tj. Usuń aspekty NUMA z kodu, mając jedno zadanie na gniazdo procesora lub kontroler pamięci, a następnie wątki w ramach każdego zadania. Jeśli zrobisz to w ten sposób, powinieneś być w stanie bezpiecznie powiązać całą pamięć z tym gniazdem / kontrolerem za pomocą pierwszego dotknięcia lub jednego z dostępnych interfejsów API, bez względu na to, który wątek faktycznie wykonuje alokację lub inicjalizację. Przekazywanie wiadomości między gniazdami jest zwykle dość dobrze zoptymalizowane, przynajmniej w MPI. Zawsze możesz mieć więcej zadań MPI niż to, ale z powodu podnoszonych problemów rzadko zalecam, aby ludzie mieli mniej.

Bill Barth
źródło
1
Jest to praktyczne rozwiązanie, ale chociaż szybko otrzymujemy więcej rdzeni, liczba rdzeni na węzeł NUMA jest dość stagnacyjna na poziomie około 4. Czy w hipotetycznym węźle 1000 rdzeni będziemy przeprowadzać procesy 250 MPI? (Byłoby wspaniale, ale jestem sceptyczny.)
Jed Brown,
Nie zgadzam się, że liczba rdzeni na NUMA jest w stagnacji. Sandy Bridge E5 ma 8. Magny Cours miał 12. Mam węzeł Westmere-EX z 10. Interlagos (ORNL Titan) ma 20. Knights Corner będzie miał ponad 50. Sądzę, że rdzenie na NUMA zachowują mniej więcej zgodnie z prawem Moore'a.
Bill Barth
Magny Cours i Interlagos mają dwie matryce w różnych regionach NUMA, a zatem 6 i 8 rdzeni na region NUMA. Wróćmy do roku 2006, w którym dwa gniazda czterordzeniowego Clovertown współdzieliłyby ten sam interfejs (chipset Blackforda) z pamięcią i nie wydaje mi się, żeby liczba rdzeni na region NUMA rosła tak szybko. Blue Gene / Q rozszerza to płaskie spojrzenie na pamięć i być może Knight's Corner zrobi kolejny krok (choć jest to inne urządzenie, więc może powinniśmy porównać do GPU, gdzie mamy 15 (Fermi) lub teraz 8 ( Kepler) SM przeglądające płaską pamięć).
Jed Brown
Dobre połączenie z układami AMD. Zapomniałem. Mimo to myślę, że przez jakiś czas będziecie obserwować dalszy rozwój w tej dziedzinie.
Bill Barth
6

Ta odpowiedź jest odpowiedzią na dwa nieporozumienia związane z C ++ w pytaniu.

  1. „To samo dotyczy nowego operatora C ++, który nalega na zainicjowanie nowych przydziałów (w tym POD)”
  2. „Nowy operator C ++ przyjmuje tylko jeden parametr”

Nie jest to bezpośrednia odpowiedź na wspomniane problemy dotyczące wielu rdzeni. Wystarczy odpowiedzieć na komentarze, które klasyfikują programistów C ++ jako fanatyków C ++, aby utrzymać reputację;).

Do punktu 1. C ++ „nowy” lub przydział stosu nie nalega na inicjowanie nowych obiektów, niezależnie od tego, czy POD. Domyślny konstruktor klasy, zdefiniowany przez użytkownika, ponosi tę odpowiedzialność. Pierwszy kod poniżej pokazuje śmieci wydrukowane, czy klasa jest POD, czy nie.

Do punktu 2. C ++ pozwala na przeciążanie „nowego” wieloma argumentami. Drugi kod poniżej pokazuje taki przypadek przydzielania pojedynczych obiektów. Powinien dać pomysł i być może przydatny w obecnej sytuacji. operator new [] można również odpowiednio zmodyfikować.

// Kod dla punktu 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';
}

Kompilator Intela 11.1 pokazuje to wyjście (którym jest oczywiście niezainicjowana pamięć wskazywana przez „a”).

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

// Kod dla punktu 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";
}

źródło
Dzięki za poprawki. Wydaje się, że C ++ nie stwarza dodatkowych komplikacji w stosunku do C, z wyjątkiem tablic innych niż POD, takich jak te, std::complexktóre jawnie inicjowane.
Jed Brown
1
@JedBrown: Powód numer 6, aby uniknąć używania std::complex?
Jack Poulson
1

W deal.II mamy infrastrukturę oprogramowania do równoległego montażu na każdej komórce na wielu rdzeniach za pomocą bloków wątków (w zasadzie masz jedno zadanie na komórkę i musisz zaplanować te zadania na dostępnych procesorach - nie w ten sposób wdrożone, ale jest to ogólny pomysł). Problem polega na tym, że do lokalnej integracji potrzebujesz wielu tymczasowych (scratch) obiektów i musisz podać co najmniej tyle, ile jest zadań, które można uruchomić równolegle. Widzimy słabe przyspieszenie, prawdopodobnie dlatego, że gdy zadanie zostanie umieszczone na procesorze, chwyta on jeden ze zdrapanych obiektów, który zwykle będzie w pamięci podręcznej innego rdzenia. Mieliśmy dwa pytania:

(i) Czy to naprawdę powód? Kiedy uruchamiamy program w cachegrind, widzę, że używam zasadniczo takiej samej liczby instrukcji, jak podczas uruchamiania programu w jednym wątku, ale całkowity czas działania zgromadzony dla wszystkich wątków jest znacznie większy niż w jednym wątku. Czy to naprawdę dlatego, że ciągle winy pamięci podręcznej?

(ii) Jak mogę dowiedzieć się, gdzie jestem, gdzie znajdują się wszystkie obiekty scratch i które obiekty scratch muszę wziąć, aby uzyskać dostęp do tego, który jest gorący w pamięci podręcznej mojego rdzenia?

Ostatecznie nie znaleźliśmy odpowiedzi na żadne z tych rozwiązań i po kilku pracach zdecydowaliśmy, że brakuje nam narzędzi do zbadania i rozwiązania tych problemów. Wiem, jak przynajmniej w zasadzie rozwiązać problem (ii) (a mianowicie, używając obiektów lokalnych wątków, zakładając, że wątki pozostają przypięte do rdzeni procesora - kolejna hipoteza, która nie jest trywialna do przetestowania), ale nie mam narzędzi do testowania problemu (ja).

Z naszego punktu widzenia radzenie sobie z NUMA jest wciąż nierozwiązanym pytaniem.

Wolfgang Bangerth
źródło
Powiąż swoje wątki z gniazdami, abyś nie musiał się zastanawiać, czy procesory są przypięte. Linux lubi przenosić różne rzeczy.
Bill Barth
Ponadto próbkowanie getcpu () lub schedule_getcpu () (w zależności od biblioteki libc i jądra i innych elementów) powinno umożliwić określenie, gdzie wątki działają w systemie Linux.
Bill Barth
Tak, i myślę, że bloki wątków, których używamy do planowania pracy nad wątkami, łączą wątki z procesorami. Dlatego próbowaliśmy pracować z pamięcią lokalną wątku. Ale wciąż trudno mi znaleźć rozwiązanie mojego problemu (i).
Wolfgang Bangerth
1

Oprócz hwloc istnieje kilka narzędzi, które mogą raportować o środowisku pamięci klastra HPC i które można wykorzystać do ustawienia różnych konfiguracji NUMA.

Poleciłbym LIKWID jako jedno z takich narzędzi, ponieważ unika ono podejścia opartego na kodzie, pozwalającego na przykład przypiąć proces do rdzenia. Takie podejście do oprzyrządowania w celu rozwiązania konfiguracji pamięci specyficznej dla maszyny pomoże zapewnić przenośność kodu między klastrami.

Można znaleźć krótką prezentację opisującą go z ISC'13 „ LIKWID - lekkie narzędzia wydajności ”, a autorzy opublikowali artykuł na temat Arxiv „ Najlepsze praktyki inżynierii wydajności wspomaganej przez HPM na temat współczesnego procesora wielordzeniowego ”. W tym artykule opisano podejście do interpretacji danych z liczników sprzętowych w celu opracowania wydajnego kodu specyficznego dla architektury komputera i topologii pamięci.

eoinbrazil
źródło
LIKWID jest użyteczny, ale pytaniem było więcej o tym, jak napisać biblioteki numeryczne / wrażliwe na pamięć, które mogą niezawodnie uzyskać i samokontrolować oczekiwaną lokalizację w różnych środowiskach wykonawczych, schematach wątków, zarządzaniu zasobami MPI i ustawianiu powinowactwa, używać z inne biblioteki itp.
Jed Brown