Algoritmos paralelos (GPU) para autómatas celulares asíncronos

12

Tengo una colección de modelos computacionales que podrían describirse como autómatas celulares asíncronos. Estos modelos se parecen al modelo de Ising, pero son un poco más complicados. Parece que tales modelos se beneficiarían de ejecutarse en una GPU en lugar de una CPU. Desafortunadamente, no es muy sencillo paralelizar un modelo así, y no tengo muy claro cómo hacerlo. Soy consciente de que hay literatura sobre el tema, pero todo parece estar dirigido a científicos informáticos expertos que están interesados ​​en los detalles de la complejidad algorítmica, en lugar de alguien como yo que solo quiere una descripción de algo que pueda implementar, y en consecuencia lo encuentro bastante inpenetrable.

Para mayor claridad, no estoy buscando un algoritmo óptimo, sino algo que pueda implementar rápidamente en CUDA que probablemente dé una aceleración significativa sobre la implementación de mi CPU. El tiempo del programador es mucho más un factor limitante que el tiempo de la computadora en este proyecto.

También debería aclarar que un autómata celular asíncrono es algo bastante diferente de uno sincrónico, y las técnicas para paralelizar CA sincrónicas (como la vida de Conway) no pueden adaptarse fácilmente a este problema. La diferencia es que una CA síncrona actualiza cada celda simultáneamente en cada paso de tiempo, mientras que una asíncrona actualiza una región local elegida al azar en cada paso de tiempo como se describe a continuación.

Los modelos que deseo paralelizar se implementan en una red (generalmente hexagonal) que consta de ~ 100000 celdas (aunque me gustaría usar más), y el algoritmo no paralelizado para ejecutarlos se ve así:

  1. Elige un par de celdas vecinas al azar

  2. Calcule una función de "energía" basada en un vecindario local que rodea estas célulasΔE

  3. Con una probabilidad que depende de (con β un parámetro), intercambie los estados de las dos celdas o no haga nada.eβΔEβ

  4. Repita los pasos anteriores indefinidamente.

También hay algunas complicaciones que ver con las condiciones de contorno, pero imagino que no supondrán mucha dificultad para la paralelización.

Vale la pena mencionar que estoy interesado en la dinámica transitoria de estos sistemas en lugar de solo el estado de equilibrio, por lo que necesito algo que tenga una dinámica equivalente a la anterior, en lugar de algo que se acerque a la misma distribución de equilibrio. (Entonces, las variaciones del algoritmo de tablero de ajedrez no son lo que estoy buscando).

La principal dificultad para paralelizar el algoritmo anterior son las colisiones. Debido a que todos los cálculos dependen solo de una región local de la red, es posible que muchos sitios de red se actualicen en paralelo, siempre que sus vecindarios no se superpongan. La pregunta es cómo evitar tales superposiciones. Se me ocurren varias formas, pero no sé cuál es la mejor para implementarla. Estos son los siguientes:

  • Use la CPU para generar una lista de sitios de cuadrícula aleatorios y verificar las colisiones. Cuando el número de sitios de cuadrícula es igual al número de procesadores de GPU, o si se detecta una colisión, envíe cada conjunto de coordenadas a una unidad de GPU para actualizar el sitio de cuadrícula correspondiente. Esto sería fácil de implementar, pero probablemente no aceleraría mucho, ya que verificar las colisiones en la CPU probablemente no sería mucho más barato que hacer toda la actualización en la CPU.

  • Divida la red en regiones (una por unidad de GPU) y haga que una unidad de GPU se encargue de seleccionar y actualizar aleatoriamente las celdas de la cuadrícula dentro de su región. Pero hay muchos problemas con esta idea que no sé cómo resolver, el más obvio es qué debería suceder exactamente cuando una unidad elige un vecindario que se superpone al borde de su región.

  • Aproxime el sistema de la siguiente manera: deje que el tiempo avance en pasos discretos. Divide el enrejado en otroconjunto de regiones en cada paso de tiempo de acuerdo con un esquema predefinido, y haga que cada unidad de GPU seleccione y actualice aleatoriamente un par de celdas de cuadrícula cuyo vecindario no se superponga con el límite de la región. Como los límites cambian cada vez que pasa, esta restricción podría no afectar demasiado la dinámica, siempre que las regiones sean relativamente grandes. Esto parece fácil de implementar y es probable que sea rápido, pero no sé qué tan bien se aproximará a la dinámica, o cuál es el mejor esquema para elegir los límites de la región en cada paso de tiempo. Encontré algunas referencias a "autómatas celulares síncronos en bloque", que pueden o no ser lo mismo que esta idea. (No lo sé porque parece que todas las descripciones del método están en ruso o en fuentes a las que no tengo acceso).

Mis preguntas específicas son las siguientes:

  • ¿Alguno de los algoritmos anteriores es una forma sensata de abordar la paralelización de GPU de un modelo de CA asíncrono?

  • ¿Hay una mejor manera?

  • ¿Existe un código de biblioteca existente para este tipo de problema?

  • ¿Dónde puedo encontrar una descripción clara en inglés del método "bloque sincrónico"?

Progreso

Creo que se me ocurrió una forma de paralelizar una CA asincrónica que podría ser adecuada. El algoritmo que se describe a continuación es para una CA asíncrona normal que actualiza solo una celda a la vez, en lugar de un par de celdas vecinas como la mía. Hay algunos problemas para generalizarlo en mi caso específico, pero creo que tengo una idea de cómo resolverlos. Sin embargo, no estoy seguro de cuánto beneficio de velocidad dará, por las razones que se analizan a continuación.

La idea es reemplazar la CA asíncrona (en adelante ACA) con una CA síncrona estocástica (SCA) que se comporta de manera equivalente. Para hacer esto, primero imaginamos que el ACA es un proceso de Poisson. Es decir, el tiempo transcurre continuamente, y cada celda como una probabilidad constante por unidad de tiempo de realizar su función de actualización, independientemente de las otras celdas.

Xijtijtij(0)Exp(λ)λ es un parámetro cuyo valor puede elegirse arbitrariamente).

En cada paso de tiempo lógico, las celdas del SCA se actualizan de la siguiente manera:

  • k,li,jtkl<tij

  • XijXklΔtExp(λ)tijtij+Δt

Creo que esto garantiza que las celdas se actualizarán en un orden que puede "decodificarse" para que corresponda con el ACA original, evitando colisiones y permitiendo que algunas celdas se actualicen en paralelo. Sin embargo, debido al primer punto anterior, significa que la mayoría de los procesadores de GPU estarán inactivos en cada paso de tiempo del SCA, que es menos que ideal.

Necesito pensar un poco más si se puede mejorar el rendimiento de este algoritmo y cómo extender este algoritmo para tratar el caso en el que varias celdas se actualizan simultáneamente en el ACA. Sin embargo, parece prometedor, así que pensé en describirlo aquí en caso de que alguien (a) conozca algo similar en la literatura, o (b) pueda ofrecer alguna idea sobre estos temas restantes.

Nathaniel
fuente
Quizás pueda formular su problema con un enfoque basado en una plantilla. Existe mucho software para problemas basados ​​en plantillas. Puede echar un vistazo a: libgeodecomp.org/gallery.html , Conway's Game of Life. Esto podría tener algunas similitudes.
vanCompute
@vanCompute que parece una herramienta fantástica, pero desde mi investigación inicial (más bien superficial), parece que el paradigma del código de plantilla es inherentemente sincrónico, por lo que probablemente no se adapte bien a lo que estoy tratando de hacer. Sin embargo, lo investigaré más a fondo.
Nathaniel
¿Puede proporcionar algunos detalles más sobre cómo paralelizar esto usando SIMT? ¿Usarías un hilo por par? ¿O el trabajo relacionado con la actualización de un solo par puede extenderse en 32 o más hilos?
Pedro
@Pedro, el trabajo involucrado en la actualización de un solo par es bastante pequeño (básicamente sumando el vecindario, más una iteración de un generador de números aleatorios y uno exp()), por lo que no habría pensado que tiene mucho sentido distribuirlo en múltiples hilos. Creo que es mejor (y más fácil para mí) intentar actualizar varios pares en paralelo, con un par por hilo.
Nathaniel
Ok, ¿y cómo define una superposición para emparejar actualizaciones? Si las parejas se superponen, o si sus vecindarios se superponen?
Pedro

Respuestas:

4

Usaría la primera opción y usaría una ejecución de CA sincrónica antes (usando la GPU), para detectar colisiones, ejecutar un paso de una CA hexagonal cuya regla es el valor de la celda central = Suma (vecinos), esta CA debe tener se deben iniciar siete estados con una celda seleccionada al azar y verificar su estado antes de ejecutar la regla de actualización para cada gpu.

Muestra 1. El valor de una celda vecina se comparte

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 paso de una CA cuya regla es la celda central hexagonal = Suma (vecinos)

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

Muestra 2. El valor de una celda para actualizar se tiene en cuenta como vecino en la otra

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

Después de la iteración

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

Muestra 3. No hay relación.

  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

Después de la iteración

  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

jlopez1967
fuente
O(n)n
Creo que hay mucho que se puede paralelizar. El procesamiento de colisión se realiza por completo en la GPU es un paso en una CA síncrona como se muestra en el enlace publicado anteriormente. para la verificación se usaría una regla local si Sum (vecinos) = 8 NO colisión, Sum (vecinos)> 8 Colisión, se verificaría antes de ejecutar el cambio de la regla de actualización si no hay estados de celda de colisión, ya que los dos deben colocarse cerca los puntos a evaluar si no están cerca es que pertenecen a otras celdas.
jlopez1967
Entiendo eso, pero el problema es, ¿qué haces cuando detectas una colisión? Como expliqué anteriormente, su algoritmo de CA es solo el primer paso para detectar una colisión. El segundo paso es buscar celdas en la cuadrícula con un estado> = 2, y esto no es trivial.
Nathaniel
Por ejemplo, imagine que queremos detectar la celda de colisión (5.7), en autómatas celulares y suma ejecutada (vecinos de la celda (5.7)) y si el valor es 8 y si no hay colisión es mayor que 8, no hay colisión. debe estar en la función que evalúa cada celda para definir el siguiente estado de la celda en autómatas celulares asíncronos. La detección de colisión para cada celda es una regla local que involucra solo sus celdas vecinas
jlopez1967
Sí, pero la pregunta que debemos ser capaces de responder para paralelizar una CA asincrónica no es "hubo una colisión en la celda (5,7)" sino "hubo una colisión en algún lugar de la cuadrícula, y si fue así, dónde fue ¿eso?" Eso no se puede responder sin iterar sobre la cuadrícula.
Nathaniel
1

Siguiendo sus respuestas a mis preguntas en los comentarios anteriores, le sugiero que pruebe un enfoque basado en el bloqueo en el que cada hilo intenta bloquear el vecindario que se actualizará antes de calcular la actualización real.

Puede hacer esto usando las operaciones atómicas provistas en CUDA, y una matriz que intcontiene los bloqueos para cada celda, por ejemplo lock. Cada hilo hace lo siguiente:

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;

    }

Tenga en cuenta que este enfoque probablemente no sea el más óptimo, pero podría proporcionar un punto de partida interesante. Si hay muchas colisiones entre hilos, es decir, uno o más por 32 hilos (como en una colisión por urdimbre), habrá un poco de desvío de ramas. Además, las operaciones atómicas pueden ser un poco lentas, pero dado que solo está haciendo operaciones de comparar e intercambiar, debería escalar correctamente.

La sobrecarga de bloqueo puede parecer intimidante, pero en realidad son solo algunas tareas y ramas, no mucho más.

Tenga en cuenta también que estoy siendo rápido y suelto con notación en los lazos de ilos vecinos.

Anexo: Fui lo suficientemente arrogante como para suponer que simplemente podría retroceder cuando los pares colisionan. Si este no es el caso, puede envolver todo a partir de la segunda línea en un whilebucle y agregar un breakal final de la ifdeclaración final .

Todos los hilos tendrán que esperar hasta que se termine el último, pero si las colisiones son raras, debería poder salirse con la suya.

Anexo 2: ¡ No tenga la tentación de agregar llamadas a __syncthreads()ninguna parte de este código, especialmente a la versión en bucle descrita en el anexo anterior! La asincronía es esencial para evitar colisiones repetidas en este último caso.

Pedro
fuente
Gracias, esto se ve bastante bien. Probablemente mejor que la idea complicada que estaba considerando, y mucho más fácil de implementar. Puedo hacer que las colisiones sean raras usando una cuadrícula lo suficientemente grande, lo que probablemente esté bien. Si el método de retroceso resulta ser significativamente más rápido, puedo usarlo para investigar informalmente los parámetros y cambiar al método de esperar a que todos los demás completen cuando necesito generar resultados oficiales. Voy a intentar esto pronto.
Nathaniel
1

Soy el desarrollador principal de LibGeoDecomp. Si bien estoy de acuerdo con vanCompute en que podría emular su ACA con una CA, tiene razón en que esto no sería muy eficiente, ya que solo algunas celdas en un paso dado deben actualizarse. De hecho, esta es una aplicación muy interesante, ¡y divertida de jugar!

Le sugiero que combine las soluciones propuestas por jlopez1967 y Pedro: el algoritmo de Pedro captura bien el paralelismo, pero esos bloqueos atómicos son muy lentos. La solución de jlopez1967 es elegante cuando se trata de detectar colisiones, pero verificando todas las nceldas, cuando solo un subconjunto más pequeño (de ahora en adelante supondré que hay algún parámetro kque denota el número de celdas que se actualizarán simultáneamente) está activo, Es claramente prohibitivo.

__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);
  }
}

En ausencia de una buena sincronización global en la GPU, debe invocar múltiples núcleos para las diferentes fases. En Kepler de Nvidia puedes mover incluso el bucle principal a la GPU, pero no espero que gane mucho.

Los algoritmos logran un grado de paralelismo (configurable). Supongo que la pregunta interesante es si las colisiones afectarán su distribución aleatoria cuando aumente k.

gentryx
fuente
0

Le sugiero que vea este enlace http://www.wolfram.com/training/courses/hpc021.html aproximadamente 14:15 minutos en el video, por supuesto, capacitación matemática donde implementan un autómata celular usando CUDA , desde allí y puedes modificarlo.

Juan carlos lopez
fuente
Desafortunadamente, es una CA síncrona, que es un tipo de bestia bastante diferente de las asincrónicas con las que estoy tratando. En una CA síncrona, cada celda se actualiza simultáneamente, y esto es fácil de paralelizar en una GPU, pero en una CA asíncrona, una celda elegida aleatoriamente se actualiza cada paso (en mi caso son dos celdas vecinas), y esto hace que la paralelización mucho más difícil. Los problemas descritos en mi pregunta son específicos para necesitar una función de actualización asincrónica.
Nathaniel