Algoritmi paralleli (GPU) per automi cellulari asincroni


12

Ho una collezione di modelli computazionali che potrebbero essere descritti come automi cellulari asincroni. Questi modelli assomigliano al modello Ising, ma sono leggermente più complicati. Sembra che tali modelli trarrebbero beneficio dall'esecuzione su una GPU anziché su una CPU. Sfortunatamente non è abbastanza semplice parallelizzare un modello del genere, e non mi è affatto chiaro come procedere. Sono consapevole che esiste della letteratura sull'argomento, ma sembra che tutto sia rivolto a informatici informatici interessati ai dettagli della complessità algoritmica, piuttosto che a qualcuno come me che vuole solo una descrizione di qualcosa che posso implementare, e di conseguenza lo trovo piuttosto impenetrabile.

Per chiarezza, non sto cercando un algoritmo ottimale tanto quanto qualcosa che posso implementare rapidamente in CUDA che probabilmente darà una notevole velocità sulla mia implementazione della CPU. Il tempo del programmatore è molto più un fattore limitante del tempo del computer in questo progetto.

Dovrei anche chiarire che un automa cellulare asincrono è una cosa piuttosto diversa da uno sincrono e che le tecniche per parallelizzare le CA sincrone (come la vita di Conway) non possono essere facilmente adattate a questo problema. La differenza è che una CA sincrona aggiorna ogni cella contemporaneamente in ogni fase, mentre una asincrona aggiorna una regione locale scelta casualmente in ogni fase come indicato di seguito.

I modelli che vorrei parallelizzare sono implementati su un reticolo (di solito uno esagonale) costituito da ~ 100000 celle (anche se mi piacerebbe usarne di più), e l'algoritmo non parallelizzato per eseguirle si presenta così:

  1. Scegli una coppia di celle vicine a caso

  2. Calcola una funzione "energetica" sulla base di un quartiere locale che circonda queste celluleΔE

  3. Con una probabilità che dipende da (con β un parametro), scambiare gli stati delle due celle o non fare nulla.eβΔEβ

  4. Ripeti i passaggi precedenti indefinitamente.

Ci sono anche alcune complicazioni a che fare con le condizioni al contorno, ma immagino che queste non costituiranno molte difficoltà per la parallelizzazione.

Vale la pena ricordare che sono interessato alle dinamiche transitorie di questi sistemi piuttosto che solo allo stato di equilibrio, quindi ho bisogno di qualcosa che abbia dinamiche equivalenti a quelle sopra, piuttosto che qualcosa che si avvicini alla stessa distribuzione di equilibrio. (Quindi le variazioni dell'algoritmo chequerboard non sono ciò che sto cercando.)

La principale difficoltà nel parallelizzare l'algoritmo sopra è le collisioni. Poiché tutti i calcoli dipendono solo da una regione locale del reticolo, è possibile che molti siti reticolari vengano aggiornati in parallelo, purché i loro quartieri non si sovrappongano. La domanda è come evitare tali sovrapposizioni. Posso pensare a diversi modi, ma non so quale sia il migliore da implementare. Questi sono i seguenti:

  • Utilizzare la CPU per generare un elenco di siti di griglia casuali e verificare la presenza di collisioni. Quando il numero di siti della griglia è uguale al numero di processori GPU o se viene rilevata una collisione, inviare ciascun set di coordinate a un'unità GPU per aggiornare il sito della griglia corrispondente. Questo sarebbe facile da implementare ma probabilmente non darebbe molta velocità, dal momento che il controllo delle collisioni sulla CPU probabilmente non sarebbe molto più economico rispetto all'intero aggiornamento sulla CPU.

  • Dividi il reticolo in regioni (una per unità GPU) e disponi di un'unità GPU responsabile della selezione casuale e dell'aggiornamento delle celle della griglia all'interno della sua regione. Ma ci sono molti problemi con questa idea che non so come risolvere, il più ovvio è esattamente cosa dovrebbe accadere quando un'unità sceglie un quartiere che si sovrappone al limite della sua regione.

  • Approssimare il sistema come segue: lasciare che il tempo proceda in passaggi discreti. Dividi il reticolo in un altroinsieme di regioni su ogni passaggio temporale secondo uno schema predefinito e ogni unità GPU seleziona e aggiorna casualmente una coppia di celle della griglia il cui vicinato non si sovrappone al confine della regione. Poiché i limiti cambiano ogni volta che questo passaggio può non influire troppo sulle dinamiche, purché le regioni siano relativamente grandi. Questo sembra facile da implementare e probabilmente sarà veloce, ma non so quanto si avvicinerà alla dinamica, o qual è lo schema migliore per scegliere i confini della regione in ogni fase temporale. Ho trovato alcuni riferimenti a "automi cellulari sincroni a blocchi", che possono essere o meno gli stessi di questa idea. (Non lo so perché sembra che tutte le descrizioni del metodo siano in russo o in fonti alle quali non ho accesso.)

Le mie domande specifiche sono le seguenti:

  • Qualcuno dei suddetti algoritmi è un modo sensato per avvicinarsi alla parallelizzazione GPU di un modello CA asincrono?

  • C'è un modo migliore?

  • Esiste un codice libreria esistente per questo tipo di problema?

  • Dove posso trovare una chiara descrizione in lingua inglese del metodo "block-sincrono"?

Progresso

Credo di aver escogitato un modo per parallelizzare una CA asincrona che potrebbe essere adatta. L'algoritmo descritto di seguito è per una normale CA asincrona che aggiorna solo una cella alla volta, anziché una coppia di celle vicine come la mia. Ci sono alcuni problemi con la generalizzazione nel mio caso specifico, ma penso di avere un'idea di come risolverli. Tuttavia, non sono sicuro di quanto possa essere utile in termini di velocità, per i motivi discussi di seguito.

L'idea è di sostituire la CA asincrona (d'ora in poi ACA) con una CA sincrona stocastica (SCA) che si comporta in modo equivalente. Per fare ciò, immaginiamo innanzitutto che l'ACA sia un processo di Poisson. Cioè, il tempo procede continuamente e ogni cella è una probabilità costante per unità di tempo di eseguire la sua funzione di aggiornamento, indipendentemente dalle altre celle.

Xijtijtij(0)Exp(λ)λ è un parametro il cui valore può essere scelto arbitrariamente.)

Ad ogni fase temporale logica, le celle dell'SCA vengono aggiornate come segue:

  • k,li,jtkl<tij

  • XijXklΔtExp(λ)tijtij+Δt

Credo che ciò garantisca che le celle verranno aggiornate in un ordine che può essere "decodificato" per corrispondere all'ACA originale, evitando collisioni e consentendo l'aggiornamento di alcune celle in parallelo. Tuttavia, a causa del primo punto elenco sopra, ciò significa che la maggior parte dei processori GPU sarà in gran parte inattiva su ogni fase del SCA, che è tutt'altro che ideale.

Devo riflettere ancora sul fatto che le prestazioni di questo algoritmo possano essere migliorate e su come estenderlo per affrontare il caso in cui più celle vengono aggiornate contemporaneamente nell'ACA. Tuttavia, sembra promettente, quindi ho pensato di descriverlo qui nel caso in cui qualcuno (a) sia a conoscenza di qualcosa di simile in letteratura, o (b) possa offrire informazioni su questi problemi rimanenti.


Forse puoi formulare il tuo problema in un approccio basato su stencil. Esistono molti software per problemi basati sullo stencil. Puoi dare un'occhiata a: libgeodecomp.org/gallery.html , Conway's Game of Life. Questo potrebbe avere alcune somiglianze.
vanCompute il

@vanCompute che sembra uno strumento fantastico, ma dalla mia indagine iniziale (piuttosto superficiale), sembra che il paradigma del codice dello stencil sia intrinsecamente sincrono, quindi probabilmente non è adatto a quello che sto cercando di fare. Lo esaminerò ulteriormente, tuttavia.
Nathaniel,

Puoi fornire qualche dettaglio in più su come parallelizzeresti usando SIMT? Utilizzeresti un thread per coppia? O il lavoro relativo all'aggiornamento di una singola coppia può essere suddiviso su 32 o più thread?
Pedro,

@Pedro il lavoro coinvolto nell'aggiornamento di una singola coppia è piuttosto piccolo (sostanzialmente sommando il vicinato, più un'iterazione di un generatore di numeri casuali e uno exp()), quindi non avrei pensato che avesse molto senso diffonderlo su più thread. Penso che sia meglio (e più facile per me) provare ad aggiornare più coppie in parallelo, con una coppia per thread.
Nathaniel,

Ok, e come si definisce una sovrapposizione per accoppiare gli aggiornamenti? Se le coppie stesse si sovrappongono o se i loro quartieri si sovrappongono?
Pedro,

Risposte:


4

Vorrei utilizzare la prima opzione e utilizzare una corsa AC sincrona prima (utilizzando la GPU), per rilevare le collisioni, eseguire un passaggio di una CA esagonale la cui regola è il valore della cella centrale = Somma (vicini), questa CA deve avere sette stati dovrebbero essere iniziati con una cella selezionata casualmente e il loro stato verificato prima di eseguire la regola di aggiornamento per ogni GPU.

Esempio 1. Il valore di una cella vicina è condiviso

0 0 0 0 0 0 0

  0 0 1 0 0 0

0 0 0 0 0 0 0

  0 0 0 1 0 0

0 0 0 0 0 0 0

un passaggio di una CA la cui regola è cella centrale esagonale = Somma (vicini)

0 0 1 1 0 0 0

  0 1 1 1 0 0

0 0 1 2 1 0 0

  0 0 1 1 1 0

0 0 0 1 1 0 0

Esempio 2. Il valore di una cella da aggiornare viene preso in considerazione come vicino dall'altra

0 0 0 0 0 0 0

  0 0 1 0 0 0

0 0 0 1 0 0 0

  0 0 0 0 0 0

0 0 0 0 0 0 0

Dopo l'iterazione

0 0 1 1 0 0 0

  0 1 2 2 0 0

0 0 2 2 1 0 0

  0 0 1 1 0 0

0 0 0 0 0 0 0

Esempio 3. Non c'è relazione

  0 0 0 0 0 0

0 0 1 0 0 0 0

  0 0 0 0 0 0

0 0 0 0 0 0 0

  0 0 0 1 0 0

0 0 0 0 0 0 0

Dopo l'iterazione

  0 1 1 0 0 0

0 1 1 1 0 0 0

  0 1 1 0 0 0

0 0 0 1 1 0 0

  0 0 1 1 1 0

0 0 0 1 1 0 0


O(n)n

Penso che ci sia molto che possa essere parallelizzato. L'elaborazione della collisione viene eseguita interamente sulla GPU è un passaggio in un AC sincrono, come mostrato nel link pubblicato sopra. per la verifica utilizzerebbe una regola locale se Somma (vicini) = 8 NO collisione, Somma (vicini)> 8 Collisione, verrebbe verificata prima di eseguire la modifica della regola di aggiornamento se non vi sono stati di cella di collisione, poiché i due dovrebbero essere posizionati vicino i punti da valutare se non sono vicini appartengono ad altre celle.
jlopez1967,

Lo capisco, ma il problema è che cosa fai quando rilevi una collisione? Come ho spiegato sopra, il tuo algoritmo CA è solo il primo passo per rilevare una collisione. Il secondo passo è cercare nella griglia le celle con uno stato> = 2, e questo non è banale.
Nathaniel,

ad esempio, immaginiamo di voler rilevare la cella di collisione (5.7), sugli automi cellulari e la somma eseguita (vicini di cella (5,7)) e se il valore è 8 e se non vi è alcuna collisione è maggiore di 8 nessuna collisione questa dovrebbe essere nella funzione che valuta ogni cella per definire lo stato successivo della cella in automi cellulari asincroni. Il rilevamento della collisione per ogni cellula è una regola locale che coinvolge solo le sue cellule vicine
jlopez1967,

Sì, ma la domanda a cui dobbiamo essere in grado di rispondere per parallelizzare una CA asincrona non è "c'era una collisione nella cella (5,7)" ma "c'era una collisione da qualche parte sulla griglia, e se sì dov'era vero?" Non è possibile rispondere senza iterare sulla griglia.
Nathaniel,

1

Seguendo le risposte alle mie domande nei commenti sopra, ti suggerirei di provare un approccio basato sul blocco in cui ogni thread tenta di bloccare il vicinato che aggiornerà prima di calcolare l'aggiornamento effettivo.

Puoi farlo usando le operazioni atomiche previste in CUDA e una matrice intcontenente i blocchi per ogni cella, ad es lock. Ogni thread quindi procede come segue:

ci, cj = choose a pair at random.

int locked = 0;

/* Try to lock the cell ci. */
if ( atomicCAS( &lock[ci] , 0 , 1 ) == 0 ) {

    /* Try to lock the cell cj. */
    if ( atomicCAS( &lock[cj] , 0 , 1 ) == 0 ) {

        /* Now try to lock all the neigbourhood cells. */
        for ( cn = indices of all neighbours )
            if ( atomicCAS( &lock[cn] , 0 , 1 ) != 0 )
                break;

        /* If we hit a break above, we have to unroll all the locks. */
        if ( cn < number of neighbours ) {
            lock[ci] = 0;
            lock[cj] = 0;
            for ( int i = 0 ; i < cn ; i++ )
                lock[i] = 0;
            }

        /* Otherwise, we've successfully locked-down the neighbourhood. */
        else
            locked = 1;

        }

    /* Otherwise, back off. */
    else
        lock[ci] = 0;
    }

/* If we got everything locked-down... */
if ( locked ) {

    do whatever needs to be done...

    /* Release all the locks. */
    lock[ci] = 0;
    lock[cj] = 0;
    for ( int i = 0 ; i < cn ; i++ )
        lock[i] = 0;

    }

Si noti che questo approccio non è probabilmente il più ottimale, ma potrebbe fornire un punto di partenza interessante. Se ci sono molte collisioni tra i thread, cioè uno o più per 32 thread (come in una collisione per ordito), allora ci sarà un bel po 'di diversione del ramo. Inoltre, le operazioni atomiche possono essere un po 'lente, ma dal momento che stai solo facendo operazioni di confronto e scambio, dovrebbe ridimensionarsi bene.

Il sovraccarico di blocco può sembrare intimidatorio, ma in realtà sono solo alcuni incarichi e rami, non molto di più.

Nota anche che sono veloce e sciolto con notazione negli anelli di isopra i vicini.

Addendum: ero abbastanza sprezzante da presumere che si potesse semplicemente arretrare quando le coppie si scontrano. In caso contrario, puoi avvolgere tutto a partire dalla seconda riga in while-loop e aggiungere breaka alla fine della ifdichiarazione finale .

Tutti i thread dovranno quindi attendere fino al termine dell'ultimo, ma se le collisioni sono rare, dovresti riuscire a cavartela.

Addendum 2: Do non essere tentato di aggiungere le chiamate a __syncthreads()qualsiasi parte di questo codice, in particolare è la versione looping descritta nell'addendum precedente! L'asincronicità è essenziale per evitare ripetute collisioni in quest'ultimo caso.


Grazie, sembra piuttosto buono. Probabilmente migliore dell'idea complicata che stavo prendendo in considerazione, e molto più facile da implementare. Posso rendere rare le collisioni usando una griglia abbastanza grande, che probabilmente va bene. Se il metodo di back-off risulta essere significativamente più veloce, posso usarlo per indagare in modo informale sui parametri e passare al metodo di attesa di tutti per completare quando devo generare risultati ufficiali. Ci proverò presto.
Nathaniel,

1

Sono lo sviluppatore principale di LibGeoDecomp. Anche se concordo con vanCompute sul fatto che potresti emulare il tuo ACA con una CA, hai ragione sul fatto che ciò non sarebbe molto efficiente, poiché solo poche celle in un determinato passaggio devono essere aggiornate. Questa è davvero un'applicazione molto interessante - e divertente con cui armeggiare!

Ti suggerirei di combinare le soluzioni proposte da jlopez1967 e Pedro: l'algoritmo di Pedro cattura bene il parallelismo, ma quei blocchi atomici sono terribilmente lenti. La soluzione di jlopez1967 è elegante quando si tratta di rilevare le collisioni, ma controllando tutte le ncelle, quando solo un sottoinsieme più piccolo (da ora in poi suppongo che ci sia qualche parametro kche denota il numero di celle da aggiornare contemporaneamente) sono attivi, è chiaramente proibitivo.

__global__ void markPoints(Cell *grid, int gridWidth, int *posX, int *posY)
{
    int id = blockIdx.x * blockDim.x + threadIdx.x;
    int x, y;
    generateRandomCoord(&x, &y);
    posX[id] = x;
    posY[id] = y;
    grid[y * gridWidth + x].flag = 1;
}

__global__ void checkPoints(Cell *grid, int gridWidth, int *posX, int *posY, bool *active)
{
    int id = blockIdx.x * blockDim.x + threadIdx.x;
    int x = posX[id];
    int y = posY[id];
    int markedNeighbors = 
        grid[(y - 1) * gridWidth + x + 0].flag +
        grid[(y - 1) * gridWidth + x + 1].flag +
        grid[(y + 0) * gridWidth + x - 1].flag +
        grid[(y + 0) * gridWidth + x + 1].flag +
        grid[(y + 1) * gridWidth + x + 0].flag +
        grid[(y + 1) * gridWidth + x + 1].flag;
    active[id] = (markedNeighbors > 0);
}


__global__ void update(Cell *grid, int gridWidth, int *posX, int *posY, bool *active)
{
    int id = blockIdx.x * blockDim.x + threadIdx.x;
    int x = posX[id];
    int y = posY[id];
    grid[y * gridWidth + x].flag = 0;
    if (active[id]) {
        // do your fancy stuff here
    }
}

int main() 
{
  // alloc grid here, update up to k cells simultaneously
  int n = 1024 * 1024;
  int k = 1234;
  for (;;) {
      markPoints<<<gridDim,blockDim>>>(grid, gridWidth, posX, posY);
      checkPoints<<<gridDim,blockDim>>>(grid, gridWidth, posX, posY, active);
      update<<<gridDim,blockDim>>>(grid, gridWidth, posX, posY, active);
  }
}

In assenza di una buona sincronizzazione globale sulla GPU, è necessario richiamare più kernel per le diverse fasi. Su Kepler di Nvidia puoi spostare anche il loop principale sulla GPU, ma non mi aspetto che guadagni molto.

Gli algoritmi raggiungono un grado (configurabile) di parallelismo. Immagino che la domanda interessante sia se le collisioni influenzeranno la tua distribuzione casuale quando aumenti k.


0

Ti suggerisco di vedere questo link http://www.wolfram.com/training/courses/hpc021.html circa 14:15 minuti nel video naturalmente, formazione in matematica in cui eseguono un'implementazione di automi cellulari usando CUDA , da lì e puoi modificarlo.


Purtroppo questa è una CA sincrona, che è un tipo di bestia piuttosto diverso da quelli asincroni con cui ho a che fare. In una CA sincrona, ogni cella viene aggiornata contemporaneamente e questo è facile da parallelizzare su una GPU, ma in una CA asincrona una singola cella scelta casualmente viene aggiornata ogni volta (in realtà nel mio caso sono due celle vicine), e questo rende la parallelizzazione è molto più difficile. I problemi delineati nella mia domanda sono specifici per la necessità di una funzione di aggiornamento asincrono.
Nathaniel,
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.