Paralelamente, as leituras aleatórias parecem funcionar bem - por quê?

18

Considere o seguinte programa de computador muito simples:

for i = 1 to n:
    y[i] = x[p[i]]

Aqui e y são n matrizes -element de bytes, e p é um n matriz -element de palavras. Aqui n é grande, por exemplo, n = 2 31 (para que apenas uma fração desprezível dos dados caiba em qualquer tipo de memória cache).xynpnnn=231

Suponha que consiste em números aleatórios , distribuídos uniformemente entre 1 e n .p1n

Da perspectiva do hardware moderno, isso deve significar o seguinte:

  • ler é barato (leitura seqüencial)p[i]
  • ler é muito caro (leituras aleatórias; quase todas as leituras são falhas de cache; teremos que buscar cada byte individual da memória principal)x[p[i]]
  • escrever é barato (gravação seqüencial).y[i]

E é de fato o que estou observando. O programa é muito lento em comparação com um programa que faz apenas leituras e gravações sequenciais. Ótimo.

Agora vem a pergunta: quão bem esse programa é paralelo às modernas plataformas multinúcleo?


Minha hipótese era que esse programa não se compara bem. Afinal, o gargalo é a memória principal. Um único núcleo já está perdendo a maior parte do tempo apenas aguardando alguns dados da memória principal.

No entanto, não foi isso que observei quando comecei a experimentar alguns algoritmos em que o gargalo era esse tipo de operação!

Simplesmente substituí o loop for ingênuo por um loop for paralelo do OpenMP (em essência, ele apenas dividirá o intervalo em partes menores e executará essas partes em diferentes núcleos da CPU em paralelo).[1,n]

Em computadores de gama baixa, as acelerações eram de fato menores. Mas em plataformas de ponta, fiquei surpreso por estar recebendo excelentes acelerações quase lineares. Alguns exemplos concretos (os horários exatos podem ser um pouco diferentes, há muitas variações aleatórias; foram apenas experiências rápidas):

  • 2 x Xeon de 4 núcleos (no total 8 núcleos): fator 5-8 acelerações em comparação à versão single-threaded.

  • Xeon de 2 x 6 núcleos (no total 12 núcleos): acelerações de fator 8 a 14 em comparação com a versão single-threaded.

Agora isso foi totalmente inesperado. Questões:

  1. Precisamente por que esse tipo de programa é tão paralelo ? O que acontece no hardware? (Meu palpite atual é algo assim: as leituras aleatórias de threads diferentes são "canalizadas" e a taxa média de obter respostas a essas perguntas é muito maior do que no caso de um único thread.)

  2. x[p[i]]x[p[i+1]]

  3. Qual é o modelo teórico correto que poderíamos usar para analisar esse tipo de programa (e fazer previsões corretas do desempenho)?


Edit: Agora há alguns resultados de código-fonte e benchmark disponíveis aqui: https://github.com/suomela/parallel-random-read

n=232

  • Aproximadamente. 42 ns por iteração (leitura aleatória) com um único encadeamento
  • Aproximadamente. 5 ns por iteração (leitura aleatória) com 12 núcleos.
Jukka Suomela
fonte

Respostas:

9

pnpnpp

Agora, vamos levar em conta os problemas de memória. A aceleração super-linear que você realmente observou no nó baseado em Xeon de ponta é justificada a seguir.

nn/pp

n=231

n

Finalmente, além do QSM (Queuing Shared Memory) , não conheço outro modelo paralelo teórico que leve em consideração, no mesmo nível, a disputa pelo acesso à memória compartilhada (no seu caso, ao usar o OpenMP, a memória principal é compartilhada entre os núcleos e o cache também é sempre compartilhado também entre os núcleos). De qualquer forma, embora o modelo seja interessante, ele não obteve grande sucesso.

Massimo Cafaro
fonte
1
Também pode ajudar a considerar isso, pois cada núcleo fornece uma quantidade mais ou menos fixa de paralelismo no nível de memória, por exemplo, 10 x [] cargas em processo em um determinado momento. Com uma chance de 0,5% de acerto no L3 compartilhado, um único encadeamento teria uma chance de 0,995 ** 10 (95 +%) de exigir que todas essas cargas aguardassem uma resposta da memória principal. Com 6 núcleos fornecendo um total de 60 x [] leituras pendentes, há quase 26% de chance de que pelo menos uma leitura seja atingida em L3. Além disso, quanto mais MLP, mais o controlador de memória pode agendar acessos para aumentar a largura de banda real.
Paul A. Clayton
5

Decidi experimentar __builtin_prefetch (). Estou postando aqui como resposta, caso outros desejem testá-lo em suas máquinas. Os resultados estão próximos do que Jukka descreve: Cerca de uma diminuição de 20% no tempo de execução ao pré-buscar 20 elementos à frente versus pré-buscar 0 elementos à frente.

Resultados:

prefetch =   0, time = 1.58000
prefetch =   1, time = 1.47000
prefetch =   2, time = 1.39000
prefetch =   3, time = 1.34000
prefetch =   4, time = 1.31000
prefetch =   5, time = 1.30000
prefetch =   6, time = 1.27000
prefetch =   7, time = 1.28000
prefetch =   8, time = 1.26000
prefetch =   9, time = 1.27000
prefetch =  10, time = 1.27000
prefetch =  11, time = 1.27000
prefetch =  12, time = 1.30000
prefetch =  13, time = 1.29000
prefetch =  14, time = 1.30000
prefetch =  15, time = 1.28000
prefetch =  16, time = 1.24000
prefetch =  17, time = 1.28000
prefetch =  18, time = 1.29000
prefetch =  19, time = 1.25000
prefetch =  20, time = 1.24000
prefetch =  19, time = 1.26000
prefetch =  18, time = 1.27000
prefetch =  17, time = 1.26000
prefetch =  16, time = 1.27000
prefetch =  15, time = 1.28000
prefetch =  14, time = 1.29000
prefetch =  13, time = 1.26000
prefetch =  12, time = 1.28000
prefetch =  11, time = 1.30000
prefetch =  10, time = 1.31000
prefetch =   9, time = 1.27000
prefetch =   8, time = 1.32000
prefetch =   7, time = 1.31000
prefetch =   6, time = 1.30000
prefetch =   5, time = 1.27000
prefetch =   4, time = 1.33000
prefetch =   3, time = 1.38000
prefetch =   2, time = 1.41000
prefetch =   1, time = 1.41000
prefetch =   0, time = 1.59000

Código:

#include <stdlib.h>
#include <time.h>
#include <stdio.h>

void cracker(int *y, int *x, int *p, int n, int pf) {
    int i;
    int saved = pf;  /* let compiler optimize address computations */

    for (i = 0; i < n; i++) {
        __builtin_prefetch(&x[p[i+saved]]);
        y[i] += x[p[i]];
    }
}

int main(void) {
    int n = 50000000;
    int *x, *y, *p, i, pf, k;
    clock_t start, stop;
    double elapsed;

    /* set up arrays */
    x = malloc(sizeof(int)*n);
    y = malloc(sizeof(int)*n);
    p = malloc(sizeof(int)*n);
    for (i = 0; i < n; i++)
        p[i] = rand()%n;

    /* warm-up exercise */
    cracker(y, x, p, n, pf);

    k = 20;
    for (pf = 0; pf < k; pf++) {
        start = clock();
        cracker(y, x, p, n, pf);
        stop = clock();
        elapsed = ((double)(stop-start))/CLOCKS_PER_SEC;
        printf("prefetch = %3d, time = %.5lf\n", pf, elapsed);
    }
    for (pf = k; pf >= 0; pf--) {
        start = clock();
        cracker(y, x, p, n, pf);
        stop = clock();
        elapsed = ((double)(stop-start))/CLOCKS_PER_SEC;
        printf("prefetch = %3d, time = %.5lf\n", pf, elapsed);
    }

    return 0;
}
Pat Morin
fonte
4
  1. O acesso DDR3 é realmente canalizado. Os http://www.eng.utah.edu/~cs7810/pres/dram-cs7810-protocolx2.pdf, slides 20 e 24 mostram o que acontece no barramento de memória durante operações de leitura em pipeline.

  2. (parcialmente errado, veja abaixo) Vários encadeamentos não serão necessários se a arquitetura da CPU suportar a pré-busca de cache. O x86 e o ​​ARM modernos, assim como muitas outras arquiteturas, têm uma instrução de pré-busca explícita. Além disso, muitos tentam detectar padrões nos acessos à memória e fazem a pré-busca automaticamente. O suporte ao software é específico do compilador, por exemplo, o GCC e o Clang têm __builtin_prefech () intrínseco à pré-busca explícita.

O hyperthreading no estilo Intel parece funcionar muito bem em programas que passam a maior parte do tempo aguardando falhas no cache. Na minha experiência, na carga de trabalho intensiva em computação, a aceleração vai muito pouco acima do número de núcleos físicos.

EDIT: Eu estava errado no ponto 2. Parece que, enquanto a pré-busca pode otimizar o acesso à memória para um único núcleo, a largura de banda combinada da memória de vários núcleos é maior que a largura de banda do único núcleo. Quanto maior, depende da CPU.

O pré-buscador de hardware e outras otimizações juntas tornam o benchmarking muito complicado. É possível construir casos em que a pré-busca explícita tenha um efeito muito visível ou inexistente no desempenho, sendo este benchmark um dos últimos.

Juhani Simola
fonte
__builtin_prefech parece muito promissor. Infelizmente, em meus experimentos rápidos, isso não pareceu ajudar muito no desempenho de thread único (<10%). Quantas melhorias de velocidade devo esperar nesse tipo de aplicativo?
Jukka Suomela
Eu esperava mais. Como sei que a pré-busca tem efeito significativo no DSP e nos jogos, tive que me experimentar. Acabou no buraco do coelho vai mais profundo ...
Juhani Simola
Minha primeira tentativa foi criar uma ordem aleatória fixa armazenada em uma matriz e, em seguida, iterar nessa ordem com e sem pré-busca ( gist.github.com/osimola/7917602 ). Isso trouxe uma diferença de cerca de 2% em um Core i5. Parece que a pré-busca não funciona, ou o preditor de hardware entende a indireção.
Juhani Simola
1
Portanto, testando isso, a segunda tentativa ( gist.github.com/osimola/7917568 ) acessa a memória em sequência gerada por uma semente aleatória fixa. Dessa vez, a versão de pré-busca era aproximadamente duas vezes mais rápida que a não-busca e 3 vezes mais rápida que a pré-busca 1 passo à frente. Observe que a versão de pré-busca faz mais cálculos por acesso à memória do que a versão sem pré-busca.
Juhani Simola
Isso parece depender da máquina. Tentei o código de Pat Morin abaixo (não posso comentar sobre esse post porque não tenho reputação) e meu resultado está dentro de 1,3% para diferentes valores de pré-busca.
Juhani Simola