Best practice per l'allocazione / inizializzazione della memoria multicore / NUMA portatile


17

Quando vengono eseguiti calcoli limitati della larghezza di banda della memoria in ambienti di memoria condivisa (ad esempio threading tramite OpenMP, Pthreads o TBB), esiste un dilemma su come garantire che la memoria sia distribuita correttamente nella memoria fisica , in modo tale che ciascun thread acceda principalmente alla memoria su un bus di memoria "locale". Sebbene le interfacce non siano portatili, la maggior parte dei sistemi operativi ha modi per impostare l'affinità dei thread (ad esempio pthread_setaffinity_np()su molti sistemi POSIX, sched_setaffinity()su Linux, SetThreadAffinityMask()su Windows). Esistono anche librerie come hwloc per determinare la gerarchia di memoria, ma sfortunatamente la maggior parte dei sistemi operativi non fornisce ancora modi per impostare criteri di memoria NUMA. Linux è un'eccezione notevole, con libnumaconsentendo all'applicazione di manipolare i criteri di memoria e la migrazione delle pagine con granularità delle pagine (in linea di massima dal 2004, quindi ampiamente disponibili). Altri sistemi operativi prevedono che gli utenti osservino una politica implicita di "primo tocco".

Lavorare con una politica di "primo tocco" significa che il chiamante dovrebbe creare e distribuire thread con qualsiasi affinità che intendono utilizzare in seguito quando scrivono per la prima volta nella memoria appena allocata. (Pochissimi sistemi sono configurati in modo tale da malloc()trovare effettivamente le pagine, promette solo di trovarle quando sono effettivamente guaste, forse da thread diversi.) Ciò implica che l'allocazione che utilizza calloc()o che inizializza immediatamente la memoria dopo l'utilizzo di allocazione memset()è dannosa poiché tenderà a guastarsi tutta la memoria sul bus di memoria del core che esegue il thread di allocazione, portando alla larghezza di banda di memoria nel caso peggiore quando si accede alla memoria da più thread. Lo stesso vale per l' newoperatore C ++ che insiste sull'inizializzazione di molte nuove allocazioni (ad esstd::complex). Alcune osservazioni su questo ambiente:

  • L'allocazione può essere resa "thread collettiva", ma ora l'allocazione viene mescolata nel modello di threading, il che è indesiderabile per le librerie che potrebbero dover interagire con i client utilizzando diversi modelli di threading (forse ognuno con i propri pool di thread).
  • RAII è considerata una parte importante del C ++ idiomatico, ma sembra essere attivamente dannosa per le prestazioni della memoria in un ambiente NUMA. Il posizionamento newpuò essere utilizzato con memoria allocata tramite malloc()o da routine libnuma, ma ciò modifica il processo di allocazione (che credo sia necessario).
  • EDIT: La mia precedente dichiarazione sull'operatore newnon era corretta, può supportare più argomenti, vedi la risposta di Chetan. Credo che ci sia ancora la preoccupazione di far sì che le librerie o i contenitori STL utilizzino l'affinità specificata. Più campi possono essere impacchettati e può essere scomodo garantire, ad esempio, una std::vectorriallocazione con il gestore del contesto corretto attivo.
  • Ogni thread può allocare e criticare la propria memoria privata, ma l'indicizzazione nelle regioni vicine è più complicata. (Considera un prodotto vettoriale a matrice sparsa con una partizione di riga della matrice e dei vettori; l'indicizzazione della parte non posseduta di richiede una struttura di dati più complicata quando non è contiguo nella memoria virtuale.)yUNXXX

Qualche soluzione all'allocazione / inizializzazione NUMA è considerata idiomatica? Ho lasciato fuori altri aspetti critici?

(Non voglio dire per la mia C esempi ++ implicare l'accento su quel linguaggio, ma il C ++ linguaggio di codifica per alcune decisioni sulla gestione della memoria che un linguaggio come C non, quindi non vi tende ad essere più resistenza quando suggerendo che i programmatori C ++ fanno quelli le cose diversamente.)

Risposte:


7

Una soluzione a questo problema che tendo a preferire è disaggregare i thread e le attività (MPI) a livello di controller di memoria. Vale a dire, rimuovere gli aspetti NUMA dal codice disponendo di un'attività per socket CPU o controller di memoria e quindi thread sotto ogni attività. Se lo fai in questo modo, dovresti essere in grado di associare tutta la memoria a quel socket / controller in modo sicuro tramite il primo tocco o una delle API disponibili, indipendentemente dal thread che svolge effettivamente il lavoro di allocazione o inizializzazione. Il passaggio di messaggi tra socket è in genere abbastanza ben ottimizzato, almeno in MPI. Puoi sempre avere più compiti MPI di questo, ma a causa dei problemi che sollevi, raramente consiglio alle persone di avere meno.


1
Questa è una soluzione pratica, ma anche se stiamo rapidamente ottenendo più core, il numero di core per nodo NUMA è abbastanza stagnante intorno a 4. Quindi sull'ipotetico nodo 1000 core, eseguiremo 250 processi MPI? (Sarebbe fantastico, ma sono scettico.)
Jed Brown,

Non sono d'accordo sul fatto che il numero di core per NUMA sia stagnante. Sandy Bridge E5 ha 8. Magny Cours aveva 12. Ho un nodo Westmere-EX con 10. Interlagos (ORNL Titan) ne ha 20. Knights Corner ne avrà più di 50. Immagino che i core per NUMA stiano mantenendo al passo con la Legge di Moore, più o meno.
Bill Barth,

Magny Cours e Interlagos hanno due matrici in diverse regioni NUMA, quindi 6 e 8 core per regione NUMA. Torna al 2006, dove due socket di Clovertown quad-core condividono la stessa interfaccia (chipset Blackford) con la memoria e non mi sembra che il numero di core per regione NUMA stia crescendo così rapidamente. Blue Gene / Q estende ulteriormente questa visione piatta della memoria e forse Knight's Corner farà un altro passo (anche se si tratta di un dispositivo diverso, quindi forse dovremmo invece confrontare le GPU, dove ne abbiamo 15 (Fermi) o ora 8 ( Keplero) SM che visualizzano memoria piatta).
Jed Brown,

Buona chiamata sui chip AMD. Ho dimenticato. Tuttavia, penso che vedrai una crescita continua in quest'area per un po '.
Bill Barth,

6

Questa risposta è in risposta a due idee sbagliate relative al C ++ nella domanda.

  1. "Lo stesso vale per il nuovo operatore C ++ che insiste sull'inizializzazione di nuove allocazioni (compresi i POD)"
  2. "L'operatore C ++ nuovo accetta solo un parametro"

Non è una risposta diretta per i problemi multi-core che menzioni. Basta rispondere ai commenti che classificano i programmatori C ++ come fanatici del C ++ in modo da mantenere la reputazione;).

Al punto 1. C ++ "new" o allocazione dello stack non insistono sull'inizializzazione di nuovi oggetti, siano essi POD o meno. Il costruttore predefinito della classe, come definito dall'utente, ha questa responsabilità. Il primo codice seguente mostra la posta indesiderata stampata indipendentemente dal fatto che la classe sia POD.

Al punto 2. C ++ consente di sovraccaricare "nuovo" con più argomenti. Il secondo codice seguente mostra un caso del genere per l'allocazione di singoli oggetti. Dovrebbe dare un'idea e forse essere utile per la situazione che hai. l'operatore new [] può anche essere opportunamente modificato.

// Codice per il punto 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';
}

Il compilatore 11.1 di Intel mostra questo output (che è ovviamente memoria non inizializzata puntata da "a").

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

// Codice per il punto 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";
}

Grazie per le correzioni. Sembra che C ++ non presenti ulteriori complicazioni rispetto a C, ad eccezione di matrici non POD come quelle std::complexche sono esplicitamente inizializzate.
Jed Brown,

1
@JedBrown: motivo numero 6 per evitare l'uso std::complex?
Jack Poulson,

1

In deal.II abbiamo l'infrastruttura software per parallelizzare l'assemblaggio su ogni cella su più core usando i Threading Building Blocks (in sostanza, hai un'attività per cella e devi pianificare queste attività sui processori disponibili - non è così che è implementato ma è l'idea generale). Il problema è che per l'integrazione locale hai bisogno di un numero di oggetti temporanei (scratch) e devi fornire almeno quante sono le attività che possono essere eseguite in parallelo. Vediamo una scarsa velocità, presumibilmente perché quando un'attività viene inserita in un processore prende uno degli oggetti scratch che si troveranno in genere nella cache di altri core. Abbiamo avuto due domande:

(i) È davvero questo il motivo? Quando eseguiamo il programma in cachegrind vedo che sto usando praticamente lo stesso numero di istruzioni di quando eseguo il programma su un singolo thread, ma il tempo di esecuzione totale accumulato su tutti i thread è molto più grande di quello a thread singolo. È davvero perché continuo a criticare la cache?

(ii) Come posso sapere dove sono, dove si trovano ciascuno degli oggetti scratch e quale oggetto scratch dovrei prendere per accedere a quello che è caldo nella cache del mio core attuale?

Alla fine, non abbiamo trovato le risposte a nessuna di queste soluzioni e dopo un paio di lavori abbiamo deciso che mancavano gli strumenti per indagare e risolvere questi problemi. So almeno in linea di principio risolvere il problema (ii) (vale a dire, utilizzare oggetti thread-local, supponendo che i thread rimangano bloccati sui core del processore - un'altra congettura che non è banale da testare), ma non ho strumenti per testare il problema (io).

Quindi, dal nostro punto di vista, trattare con NUMA è ancora una domanda irrisolta.


Dovresti legare i tuoi thread ai socket in modo da non chiederti se i processori sono bloccati. A Linux piace spostare le cose.
Bill Barth,

Inoltre, il campionamento di getcpu () o sched_getcpu () (a seconda di libc, kernel e quant'altro) dovrebbe consentire di determinare dove sono in esecuzione i thread su Linux.
Bill Barth,

Sì, e penso che i Threading Building Blocks che usiamo per programmare il lavoro sui thread dei pin dei thread ai processori. Questo è il motivo per cui abbiamo provato a lavorare con l'archiviazione locale thread. Ma è ancora difficile per me trovare una soluzione al mio problema (i).
Wolfgang Bangerth,

1

Oltre a hwloc ci sono alcuni strumenti che possono riportare sull'ambiente di memoria di un cluster HPC e che possono essere usati per impostare una varietà di configurazioni NUMA.

Consiglierei LIKWID come uno di questi strumenti in quanto evita un approccio basato sul codice che consente ad esempio di bloccare un processo su un core. Questo approccio di strumenti per indirizzare la configurazione della memoria specifica della macchina contribuirà a garantire la portabilità del codice tra i cluster.

È possibile trovare una breve presentazione delineata da ISC'13 " LIKWID - Lightweight Performance Tools " e gli autori hanno pubblicato un articolo su Arxiv " Best practice per la progettazione prestazionale assistita da HPM su moderni processori multicore ". Questo documento descrive un approccio all'interpretazione dei dati dai contatori hardware per sviluppare codice performante specifico per l'architettura della macchina e la topologia della memoria.


LIKWID è utile, ma la domanda era più su come scrivere librerie numeriche / sensibili alla memoria che possono ottenere in modo affidabile e autocontrollare la località prevista in una vasta gamma di ambienti di esecuzione, schemi di threading, gestione delle risorse MPI e impostazione di affinità, utilizzare con altre biblioteche, ecc.
Jed Brown il
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.