Algoritmos paralelos (GPU) para autômatos celulares assíncronos

12

Eu tenho uma coleção de modelos computacionais que podem ser descritos como autômatos celulares assíncronos. Esses modelos se assemelham ao modelo de Ising, mas são um pouco mais complicados. Parece que esses modelos se beneficiariam de serem executados em uma GPU, e não em uma CPU. Infelizmente, não é muito simples paralelizar esse modelo, e não está claro para mim como fazê-lo. Estou ciente de que há literatura sobre o assunto, mas tudo parece ser direcionado a cientistas da computação que estão interessados ​​nos detalhes da complexidade algorítmica, em vez de alguém como eu, que quer apenas uma descrição de algo que eu possa implementar, e consequentemente, acho bastante inpenetrável.

Para maior clareza, não estou procurando um algoritmo ideal, mas algo que eu possa implementar rapidamente no CUDA que provavelmente dará uma aceleração significativa na implementação da minha CPU. O tempo do programador é muito mais um fator limitante do que o tempo do computador neste projeto.

Devo também esclarecer que um autômato celular assíncrono é algo bem diferente de um síncrono, e as técnicas para paralelizar CAs síncronas (como a vida de Conway) não podem ser facilmente adaptadas a esse problema. A diferença é que uma CA síncrona atualiza todas as células simultaneamente a cada etapa, enquanto uma assíncrona atualiza uma região local escolhida aleatoriamente a cada etapa, conforme descrito abaixo.

Os modelos que eu desejo paralelizar são implementados em uma rede (geralmente hexagonal) que consiste em ~ 100000 células (embora eu gostaria de usar mais), e o algoritmo não paralelo para executá-las se parece com o seguinte:

  1. Escolha um par de células vizinho aleatoriamente

  2. Calcular uma função "energia" base em uma vizinhança local ao redor dessas célulasΔE

  3. Com uma probabilidade que depende de (com a parâmetro), troque os estados das duas células ou não faça nada. βeβΔEβ

  4. Repita as etapas acima indefinidamente.

Também existem algumas complicações relacionadas às condições de contorno, mas imagino que elas não representem muita dificuldade para a paralelização.

Vale ressaltar que estou interessado na dinâmica transitória desses sistemas, e não apenas no estado de equilíbrio, por isso preciso de algo que tenha uma dinâmica equivalente à acima, em vez de apenas algo que se aproxime da mesma distribuição de equilíbrio. (Portanto, variações do algoritmo do tabuleiro de damas não são o que estou procurando.)

A principal dificuldade em paralelizar o algoritmo acima é a colisão. Como todos os cálculos dependem apenas de uma região local da rede, é possível que muitos sites da rede sejam atualizados em paralelo, desde que suas vizinhanças não estejam sobrepostas. A questão é como evitar essas sobreposições. Posso pensar em várias maneiras, mas não sei qual é a melhor para implementar. Estes são os seguintes:

  • Use a CPU para gerar uma lista de sites de grade aleatórios e verificar colisões. Quando o número de sites da grade for igual ao número de processadores GPU, ou se uma colisão for detectada, envie cada conjunto de coordenadas para uma unidade GPU para atualizar o site da grade correspondente. Isso seria fácil de implementar, mas provavelmente não daria muita velocidade, pois verificar colisões na CPU provavelmente não seria muito mais barato do que fazer toda a atualização na CPU.

  • Divida a estrutura em regiões (uma por unidade de GPU) e tenha uma unidade de GPU responsável por selecionar e atualizar aleatoriamente as células da grade em sua região. Mas há muitos problemas com essa ideia que não sei resolver, sendo o mais óbvio o que exatamente deveria acontecer quando uma unidade escolhe um bairro que se sobrepõe à extremidade de sua região.

  • Aproxime o sistema da seguinte maneira: deixe o tempo prosseguir em etapas discretas. Divida a estrutura em um diferenteconjunto de regiões em todas as etapas, de acordo com algum esquema predefinido, e cada unidade da GPU seleciona e atualiza aleatoriamente um par de células da grade cuja vizinhança não se sobrepõe ao limite da região. Como os limites mudam a cada passo, essa restrição pode não afetar muito a dinâmica, desde que as regiões sejam relativamente grandes. Parece fácil de implementar e provavelmente rápido, mas não sei até que ponto isso aproximará a dinâmica ou qual é o melhor esquema para escolher os limites da região a cada etapa do tempo. Encontrei algumas referências a "autômatos celulares síncronos em bloco", que podem ou não ser iguais a essa idéia. (Não sei porque parece que todas as descrições do método estão em russo ou em fontes às quais não tenho acesso.)

Minhas perguntas específicas são as seguintes:

  • Algum dos algoritmos acima é uma maneira sensata de abordar a paralelização por GPU de um modelo de CA assíncrono?

  • Existe uma maneira melhor?

  • Existe código de biblioteca existente para esse tipo de problema?

  • Onde posso encontrar uma descrição clara em inglês do método "síncrono em bloco"?

Progresso

Acredito que criei uma maneira de paralelizar uma autoridade de certificação assíncrona que possa ser adequada. O algoritmo descrito abaixo é para uma CA assíncrona normal que atualiza apenas uma célula por vez, em vez de um par de células vizinho como o meu. Existem alguns problemas com a generalização no meu caso específico, mas acho que tenho uma idéia de como resolvê-los. No entanto, não tenho certeza de quanto benefício de velocidade ele oferecerá, pelos motivos discutidos abaixo.

A idéia é substituir a CA assíncrona (doravante ACA) por uma CA síncrona estocástica (SCA) que se comporte de maneira equivalente. Para fazer isso, primeiro imaginamos que o ACA é um processo de Poisson. Ou seja, o tempo continua continuamente e cada célula tem uma probabilidade constante por unidade de tempo de executar sua função de atualização, independentemente das outras células.

Construímos um SCA cujas células armazenam duas coisas: o estado da célula (ou seja, os dados que seriam armazenados em cada célula na implementação seqüencial) e um número de ponto flutuante representando o (contínuo ) hora em que será atualizada na próxima. Esse tempo contínuo não corresponde às etapas de atualização do SCA. Vou me referir a este último como "tempo lógico". Os valores de tempo são inicializados aleatoriamente de acordo com uma distribuição exponencial: . (Onde é um parâmetro cujo valor pode ser escolhido arbitrariamente.)Xijtijtij(0)Exp(λ)λ

Em cada etapa do tempo lógico, as células do SCA são atualizadas da seguinte maneira:

  • Se, para qualquer na vizinhança de , o tempo , não faça nada.k,li,jtkl<tij

  • XijXklΔtExp(λ)tijtij+Δt

Acredito que isso garanta que as células sejam atualizadas em uma ordem que possa ser "decodificada" para corresponder à ACA original, evitando colisões e permitindo que algumas células sejam atualizadas em paralelo. No entanto, devido ao primeiro ponto acima, isso significa que a maioria dos processadores GPU ficará ociosa a cada etapa do SCA, o que é menos do que o ideal.

Eu preciso pensar um pouco mais sobre se o desempenho desse algoritmo pode ser aprimorado e como estender esse algoritmo para lidar com o caso em que várias células são atualizadas simultaneamente no ACA. No entanto, parece promissor, então pensei em descrevê-lo aqui, caso alguém (a) saiba de algo semelhante na literatura ou (b) possa oferecer qualquer insight sobre esses problemas restantes.

Nathaniel
fonte
Talvez você possa formular seu problema em uma abordagem baseada em estêncil. Existe muito software para problemas baseados em estêncil. Você pode dar uma olhada em: libgeodecomp.org/gallery.html , Jogo da vida de Conway. Isso pode ter algumas semelhanças.
vanCompute
@vanCompute que parece uma ferramenta fantástica, mas da minha investigação inicial (bastante superficial), parece que o paradigma do código do estêncil é inerentemente síncrono, portanto, provavelmente não é adequado para o que estou tentando fazer. No entanto, analisarei mais a fundo.
18713 Nathaniel
Você pode fornecer mais alguns detalhes sobre como paralelizar isso usando o SIMT? Você usaria uma linha por par? Ou o trabalho envolvido na atualização de um único par pode ser distribuído por 32 ou mais threads?
Pedro
@Pedro, o trabalho envolvido na atualização de um único par é bastante pequeno (basicamente somando pela vizinhança, mais uma iteração de um gerador de números aleatórios e um exp()), então eu não pensaria que faz muito sentido espalhá-lo por vários segmentos. Eu acho que é melhor (e mais fácil para mim) tentar atualizar vários pares em paralelo, com um par por thread.
Nathaniel
Ok, e como você define uma sobreposição para emparelhar atualizações? Se os pares se sobrepõem, ou se seus bairros se sobrepõem?
Pedro

Respostas:

4

Eu usaria a primeira opção e usaria uma execução CA síncrona antes (usando a GPU), para detectar colisões, executar uma etapa de uma CA hexagonal cuja regra é o valor da célula central = Sum (vizinhos), esta CA deve ter sete estados devem ser iniciados com célula selecionada aleatoriamente e seu status verificado antes de executar a regra de atualização para cada gpu.

Amostra 1. O valor de uma célula vizinha é compartilhado

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

uma etapa de uma CA cuja regra é célula central hexagonal = Sum (vizinhos)

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

Amostra 2. O valor de uma célula a ser atualizada é levado em consideração como vizinho do outro

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

Após a iteração

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

Amostra 3. Não há relacionamento

  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

Após a iteração

  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
fonte
O(n)n
Eu acho que há muito que pode ser paralelo. O processamento de colisão é efetuado inteiramente na GPU; é uma etapa de uma AC síncrona, como mostrado no link postado acima. para verificação, usaria uma regra local se Sum (vizinhos) = 8 NO colisão, Sum (vizinhos)> 8 colisão, seria verificado antes de executar a alteração da regra de atualização se não houver estados de célula de colisão, pois os dois devem ser colocados perto de os pontos a serem avaliados, se não estiverem próximos, pertencem a outras células.
Jlopez1967
Entendo isso, mas o problema é: o que você faz quando detecta uma colisão? Como expliquei acima, seu algoritmo de CA é apenas o primeiro passo para detectar uma colisão. O segundo passo é procurar células na grade com um estado> = 2, e isso não é trivial.
19413 Nathaniel
Por exemplo, imagine que queremos detectar a célula de colisão (5.7), nos autômatos celulares e na soma executada (vizinhos da célula (5,7)) e se o valor for 8 e se não houver colisão for maior que 8, não haverá colisão. deve estar na função que avalia cada célula para definir o próximo estado da célula nos autômatos celulares assíncronos. A detecção de colisão para cada célula é uma regra local que envolve apenas as suas células vizinhas
jlopez1967
Sim, mas a pergunta que precisamos responder para paralelizar uma CA assíncrona não é "houve uma colisão na célula (5,7)", mas "houve uma colisão em algum lugar da grade e, em caso afirmativo, onde estava isto?" Isso não pode ser respondido sem percorrer a grade.
19413 Nathaniel
1

Seguindo suas respostas às minhas perguntas nos comentários acima, sugiro que você tente uma abordagem baseada em bloqueio na qual cada thread tente bloquear o bairro que será atualizado antes de calcular a atualização real.

Você pode fazer isso usando as operações atômicas previstas no CUDA e uma matriz intcontendo os bloqueios para cada célula, por exemplo lock. Cada thread faz o seguinte:

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;

    }

Observe que essa abordagem provavelmente não é a mais ideal, mas poderia fornecer um ponto de partida interessante. Se houver muitas colisões entre threads, ou seja, uma ou mais por 32 threads (como em uma colisão por warp), haverá um pouco de desvio de ramificação. Além disso, as operações atômicas podem ser um pouco lentas, mas como você está executando apenas operações de comparação e troca, ela deve ser dimensionada ok.

A sobrecarga de bloqueio pode parecer intimidadora, mas na verdade são apenas algumas atribuições e ramificações, não muito mais.

Note também que estou sendo rápido e solto com notação nos loops de isobre os vizinhos.

Adendo: Eu era suficientemente corajoso para assumir que você poderia simplesmente recuar quando os pares colidirem. Se esse não for o caso, você pode agrupar tudo a partir da segunda linha em um whileloop e adicionar a breakno final da ifdeclaração final .

Todos os encadeamentos terão que esperar até que o último seja concluído, mas se as colisões forem raras, você poderá se safar.

Adendo 2: Do não ser tentado a acrescentar chamadas para __syncthreads()qualquer lugar este código, especialmente, a versão looping descrito na adenda anterior! A assincronicidade é essencial para evitar colisões repetidas no último caso.

Pedro
fonte
Obrigado, isso parece muito bom. Provavelmente melhor do que a ideia complicada que eu estava considerando e muito mais fácil de implementar. Posso tornar as colisões raras usando uma grade grande o suficiente, o que provavelmente é bom. Se o método just-back-off for significativamente mais rápido, eu posso usá-lo para investigar informalmente os parâmetros e mudar para o método de esperar que todos os outros completem quando precisar gerar resultados oficiais. Em breve, tentarei fazer isso.
22313 Nathaniel
1

Sou o principal desenvolvedor do LibGeoDecomp. Embora eu concorde com o vanCompute de que você pode emular seu ACA com uma CA, você está certo de que isso não seria muito eficiente, pois apenas poucas células em uma determinada etapa devem ser atualizadas. Esta é realmente uma aplicação muito interessante - e divertida de mexer!

Eu sugiro que você combine as soluções propostas por jlopez1967 e Pedro: o algoritmo de Pedro captura bem o paralelismo, mas esses bloqueios atômicos são terrivelmente lentos. A solução de jlopez1967 é elegante quando se trata de detectar colisões, mas verificar todas as ncélulas, quando apenas um subconjunto menor (assumirei a partir de agora que existe algum parâmetro kque indica o número de células a serem atualizadas simultaneamente), é claramente 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);
  }
}

Na ausência de boa sincronização global na GPU, você precisa chamar vários kernels para as diferentes fases. No Kepler da Nvidia, você pode mover até o loop principal para a GPU, mas não espero que isso ganhe muito.

Os algoritmos atingem um grau (configurável) de paralelismo. Acho que a questão interessante é se as colisões afetarão sua distribuição aleatória quando você aumentar k.

gentryx
fonte
0

Sugiro que você veja este link http://www.wolfram.com/training/courses/hpc021.html aproximadamente 14:15 minutos no vídeo, é claro, treinamento mathematica onde eles implementam um autômato celular usando CUDA , a partir daí e você pode modificá-lo.

Juan Carlos Lopez
fonte
Infelizmente, essa é uma CA síncrona, que é um tipo de animal bastante diferente dos assíncronos com os quais estou lidando. Em uma CA síncrona, todas as células são atualizadas simultaneamente, e é fácil paralelizar em uma GPU, mas em uma CA assíncrona, uma única célula escolhida aleatoriamente é atualizada a cada etapa (na verdade, no meu caso, são duas células vizinhas), e isso faz com que a paralelização muito mais difícil. Os problemas descritos na minha pergunta são específicos para a necessidade de uma função de atualização assíncrona.
Nathaniel