web-dev-qa-db-pt.com

Por que adições elementares são muito mais rápidas em loops separados do que em um loop combinado?

Suponha que a1, b1, c1 e d1 apontem para memória heap e meu código numérico tenha o loop principal a seguir.

const int n = 100000;

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
    c1[j] += d1[j];
}

Este loop é executado 10.000 vezes através de outro loop for externo. Para acelerar, mudei o código para:

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
}

for (int j = 0; j < n; j++) {
    c1[j] += d1[j];
}

Compilado no MS Visual C++ 10.0 com otimização total e SSE2 habilitado para 32 bits em um Intel Core 2 Duo (x64), o primeiro exemplo leva 5,5 segundos e o exemplo de ciclo duplo leva apenas 1,9 segundos. Minha pergunta é: (Por favor, consulte a minha pergunta reformulada na parte inferior)

PS: Não tenho certeza se isso ajuda:

A desmontagem do primeiro loop basicamente se parece com isso (este bloco é repetido cerca de cinco vezes no programa completo):

movsd       xmm0,mmword ptr [edx+18h]
addsd       xmm0,mmword ptr [ecx+20h]
movsd       mmword ptr [ecx+20h],xmm0
movsd       xmm0,mmword ptr [esi+10h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [edx+20h]
addsd       xmm0,mmword ptr [ecx+28h]
movsd       mmword ptr [ecx+28h],xmm0
movsd       xmm0,mmword ptr [esi+18h]
addsd       xmm0,mmword ptr [eax+38h]

Cada loop do exemplo de loop duplo produz este código (o bloco a seguir é repetido cerca de três vezes):

addsd       xmm0,mmword ptr [eax+28h]
movsd       mmword ptr [eax+28h],xmm0
movsd       xmm0,mmword ptr [ecx+20h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [ecx+28h]
addsd       xmm0,mmword ptr [eax+38h]
movsd       mmword ptr [eax+38h],xmm0
movsd       xmm0,mmword ptr [ecx+30h]
addsd       xmm0,mmword ptr [eax+40h]
movsd       mmword ptr [eax+40h],xmm0

A questão acabou por não ser relevante, pois o comportamento depende severamente dos tamanhos dos arrays (n) e do cache da CPU. Então, se houver mais interesse, eu reformulo a pergunta:

Você poderia fornecer uma visão sólida dos detalhes que levam aos diferentes comportamentos de cache, conforme ilustrado pelas cinco regiões no gráfico a seguir?

Também pode ser interessante apontar as diferenças entre as arquiteturas de CPU/cache, fornecendo um gráfico semelhante para essas CPUs.

PPS: Aqui está o código completo. Ele usa TBBTick_Count para maior tempo de resolução, que pode ser desabilitado por não definir a macro TBB_TIMING:

#include <iostream>
#include <iomanip>
#include <cmath>
#include <string>

//#define TBB_TIMING

#ifdef TBB_TIMING   
#include <tbb/tick_count.h>
using tbb::tick_count;
#else
#include <time.h>
#endif

using namespace std;

//#define preallocate_memory new_cont

enum { new_cont, new_sep };

double *a1, *b1, *c1, *d1;


void allo(int cont, int n)
{
    switch(cont) {
      case new_cont:
        a1 = new double[n*4];
        b1 = a1 + n;
        c1 = b1 + n;
        d1 = c1 + n;
        break;
      case new_sep:
        a1 = new double[n];
        b1 = new double[n];
        c1 = new double[n];
        d1 = new double[n];
        break;
    }

    for (int i = 0; i < n; i++) {
        a1[i] = 1.0;
        d1[i] = 1.0;
        c1[i] = 1.0;
        b1[i] = 1.0;
    }
}

void ff(int cont)
{
    switch(cont){
      case new_sep:
        delete[] b1;
        delete[] c1;
        delete[] d1;
      case new_cont:
        delete[] a1;
    }
}

double plain(int n, int m, int cont, int loops)
{
#ifndef preallocate_memory
    allo(cont,n);
#endif

#ifdef TBB_TIMING   
    tick_count t0 = tick_count::now();
#else
    clock_t start = clock();
#endif

    if (loops == 1) {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++){
                a1[j] += b1[j];
                c1[j] += d1[j];
            }
        }
    } else {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                a1[j] += b1[j];
            }
            for (int j = 0; j < n; j++) {
                c1[j] += d1[j];
            }
        }
    }
    double ret;

#ifdef TBB_TIMING   
    tick_count t1 = tick_count::now();
    ret = 2.0*double(n)*double(m)/(t1-t0).seconds();
#else
    clock_t end = clock();
    ret = 2.0*double(n)*double(m)/(double)(end - start) *double(CLOCKS_PER_SEC);
#endif

#ifndef preallocate_memory
    ff(cont);
#endif

    return ret;
}


void main()
{   
    freopen("C:\\test.csv", "w", stdout);

    char *s = " ";

    string na[2] ={"new_cont", "new_sep"};

    cout << "n";

    for (int j = 0; j < 2; j++)
        for (int i = 1; i <= 2; i++)
#ifdef preallocate_memory
            cout << s << i << "_loops_" << na[preallocate_memory];
#else
            cout << s << i << "_loops_" << na[j];
#endif

    cout << endl;

    long long nmax = 1000000;

#ifdef preallocate_memory
    allo(preallocate_memory, nmax);
#endif

    for (long long n = 1L; n < nmax; n = max(n+1, long long(n*1.2)))
    {
        const long long m = 10000000/n;
        cout << n;

        for (int j = 0; j < 2; j++)
            for (int i = 1; i <= 2; i++)
                cout << s << plain(n, m, j, i);
        cout << endl;
    }
}

(Mostra FLOP/s para valores diferentes de n.)

enter image description here

2117
Johannes Gerer

Após uma análise mais aprofundada disso, acredito que isso seja (pelo menos parcialmente) causado pelo alinhamento de dados dos quatro indicadores. Isso causará algum nível de conflitos de banco/caminho de cache.

Se eu adivinhei corretamente como você está alocando seus arrays, elesprovavelmente estarão alinhados à linha da página.

Isso significa que todos os seus acessos em cada loop cairão no mesmo modo de cache. No entanto, os processadores Intel tiveram uma associação de cache L1 de 8 vias por um tempo. Mas, na realidade, o desempenho não é completamente uniforme. Acessar quatro vias ainda é mais lento do que dizer de duas maneiras.

EDIT: de fato, parece que você está alocando todos os arrays separadamente. Normalmente, quando grandes alocações são solicitadas, o alocador solicitará novas páginas do sistema operacional. Portanto, há uma grande chance de que grandes alocações apareçam no mesmo deslocamento de um limite de página.

Aqui está o código de teste:

int main(){
    const int n = 100000;

#ifdef ALLOCATE_SEPERATE
    double *a1 = (double*)malloc(n * sizeof(double));
    double *b1 = (double*)malloc(n * sizeof(double));
    double *c1 = (double*)malloc(n * sizeof(double));
    double *d1 = (double*)malloc(n * sizeof(double));
#else
    double *a1 = (double*)malloc(n * sizeof(double) * 4);
    double *b1 = a1 + n;
    double *c1 = b1 + n;
    double *d1 = c1 + n;
#endif

    //  Zero the data to prevent any chance of denormals.
    memset(a1,0,n * sizeof(double));
    memset(b1,0,n * sizeof(double));
    memset(c1,0,n * sizeof(double));
    memset(d1,0,n * sizeof(double));

    //  Print the addresses
    cout << a1 << endl;
    cout << b1 << endl;
    cout << c1 << endl;
    cout << d1 << endl;

    clock_t start = clock();

    int c = 0;
    while (c++ < 10000){

#if ONE_LOOP
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
            c1[j] += d1[j];
        }
#else
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
        }
        for(int j=0;j<n;j++){
            c1[j] += d1[j];
        }
#endif

    }

    clock_t end = clock();
    cout << "seconds = " << (double)(end - start) / CLOCKS_PER_SEC << endl;

    system("pause");
    return 0;
}

Resultados comparativos:

EDIT: Resultados em uma máquina de arquitetura real Core 2:

2 x Intel Xeon X5482 Harpertown a 3,2 GHz:

#define ALLOCATE_SEPERATE
#define ONE_LOOP
00600020
006D0020
007A0020
00870020
seconds = 6.206

#define ALLOCATE_SEPERATE
//#define ONE_LOOP
005E0020
006B0020
00780020
00850020
seconds = 2.116

//#define ALLOCATE_SEPERATE
#define ONE_LOOP
00570020
00633520
006F6A20
007B9F20
seconds = 1.894

//#define ALLOCATE_SEPERATE
//#define ONE_LOOP
008C0020
00983520
00A46A20
00B09F20
seconds = 1.993

Observações:

  • 6,206 segundos com um loop e 2.116 segundos com dois loops. Isso reproduz exatamente os resultados do OP.

  • Nos dois primeiros testes, os arrays são alocados separadamente. Você notará que todos eles têm o mesmo alinhamento em relação à página.

  • Nos dois testes, os arrays são empacotados juntos para quebrar esse alinhamento. Aqui você notará que ambos os loops são mais rápidos. Além disso, o segundo loop (duplo) é agora o mais lento, como você esperaria normalmente.

Como o @Stephen Cannon aponta nos comentários, é muito provável que esse alinhamento causefalse aliasingnas unidades load/store ou no cache. Eu pesquisei por isso e descobri que a Intel realmente tem um contador de hardware paraaliasing de endereço parcialstalls:

http://software.intel.com/sites/products/documentation/doclib/stdxe/2013/~amplifierxe/pmw_dp/events/partial_address_alias.html


5 Regiões - Explicações

Região 1:

Essa é fácil. O conjunto de dados é tão pequeno que o desempenho é dominado por sobrecarga, como looping e ramificação.

Região 2:

Aqui, conforme os tamanhos dos dados aumentam, a quantidade de sobrecarga relativa diminui e o desempenho "satura". Aqui, dois loops são mais lentos porque têm o dobro de voltas e ramificações.

Não sei exatamente o que está acontecendo aqui ... O alinhamento ainda pode ter algum efeito, pois Agner Fog menciona conflitos de bancos de cache . (Esse link é sobre Sandy Bridge, mas a ideia ainda deve ser aplicável ao Core 2).

Região 3:

Neste ponto, os dados não se encaixam mais no cache L1. Portanto, o desempenho é limitado pela largura de banda do cache L1 <-> L2.

Região 4:

A queda de desempenho no circuito único é o que estamos observando. E como mencionado, isso se deve ao alinhamento que (mais provavelmente) faz com quealiasing falsopare nas unidades de carga/armazenamento do processador.

No entanto, para que o aliasing falso ocorra, deve haver um passo grande o suficiente entre os conjuntos de dados. É por isso que você não vê isso na região 3.

região 5:

Nesse ponto, nada se encaixa no cache. Então você está limitado pela largura de banda da memória.


2 x Intel X5482 Harpertown @ 3.2 GHzIntel Core i7 870 @ 2.8 GHzIntel Core i7 2600K @ 4.4 GHz

1614
Mysticial

OK, a resposta certa definitivamente tem que fazer alguma coisa com o cache da CPU. Mas, para usar o argumento de cache pode ser bastante difícil, especialmente sem dados.

Há muitas respostas, que levaram a muita discussão, mas vamos encarar: problemas de cache podem ser muito complexos e não são unidimensionais. Eles dependem muito do tamanho dos dados, então minha pergunta era injusta: acabou se tornando um ponto muito interessante no gráfico do cache.

A resposta de Mysticial convenceu muitas pessoas (inclusive eu), provavelmente porque era a única que parecia confiar em fatos, mas era apenas um "ponto de dados" da verdade.

É por isso que combinei seu teste (usando uma alocação contínua versus alocação separada) e o conselho do @James 'Answer.

Os gráficos abaixo mostram que a maioria das respostas e, especialmente, a maioria dos comentários para a pergunta e respostas podem ser consideradas completamente erradas ou verdadeiras, dependendo do cenário exato e dos parâmetros usados.

Note que minha pergunta inicial foi em n = 100.000 . Este ponto (por acidente) exibe um comportamento especial:

  1. Possui a maior discrepância entre a versão de um e dois loop (quase um fator de três)

  2. É o único ponto, onde um loop (ou seja, com alocação contínua) bate a versão de dois ciclos. (Isso tornou a resposta do Mysticial possível, em tudo.)

O resultado usando dados inicializados:

Enter image description here

O resultado usando dados não inicializados (isso é o que Mysticial testou):

Enter image description here

E isso é difícil de explicar: dados inicializados, que são alocados uma vez e reutilizados para cada caso de teste seguinte de tamanho de vetor diferente:

Enter image description here

Proposta

Todas as questões relacionadas ao desempenho de baixo nível no Stack Overflow devem ser necessárias para fornecer informações MFLOPS para toda a gama de tamanhos de dados relevantes do cache! É um desperdício de tempo de todo mundo pensar em respostas e especialmente discuti-las com outras pessoas sem essa informação.

210
Johannes Gerer

O segundo loop envolve muito menos atividade de cache, portanto, é mais fácil para o processador acompanhar as demandas de memória.

73
Puppy

Imagine que você está trabalhando em uma máquina em que n era o valor correto para que fosse possível manter dois dos seus arrays na memória ao mesmo tempo, mas a memória total disponível, via cache de disco, ainda era suficiente para armazenar todos os quatro.

Assumindo uma política simples de cache LIFO, este código:

for(int j=0;j<n;j++){
    a[j] += b[j];
}
for(int j=0;j<n;j++){
    c[j] += d[j];
}

primeiro faria com que a e b fossem carregados em RAM e fossem trabalhados inteiramente na RAM. Quando o segundo loop iniciar, c e d seriam então carregados do disco para RAM e operados.

o outro loop

for(int j=0;j<n;j++){
    a[j] += b[j];
    c[j] += d[j];
}

irá mostrar dois arrays e paginar nos outros dois todas as vezes ao redor do loop . Isso obviamente seria muito mais lento.

Você provavelmente não está vendo o cache de disco em seus testes, mas provavelmente está vendo os efeitos colaterais de alguma outra forma de armazenamento em cache.


Parece haver um pouco de confusão/incompreensão aqui, então tentarei elaborar um pouco usando um exemplo.

Diga n = 2 e estamos trabalhando com bytes. No meu cenário, portanto, temos apenas 4 bytes de RAM eo resto de nossa memória é significativamente mais lento (digamos 100 vezes mais acesso).

Assumindo uma política de cache bastante burra de se o byte não estiver no cache, coloque-o lá e obtenha o byte a seguir também enquanto estivermos nele você obterá um cenário como este:

  • Com

    for(int j=0;j<n;j++){
     a[j] += b[j];
    }
    for(int j=0;j<n;j++){
     c[j] += d[j];
    }
    
  • cache a[0] e a[1] depois b[0] e b[1] e set a[0] = a[0] + b[0] na cache - existem agora quatro bytes na cache, a[0], a[1] e b[0], b[1]. Custo = 100 + 100.

  • defina a[1] = a[1] + b[1] no cache. Custo = 1 + 1.
  • Repita para c e d.
  • Custo total = (100 + 100 + 1 + 1) * 2 = 404

  • Com

    for(int j=0;j<n;j++){
     a[j] += b[j];
     c[j] += d[j];
    }
    
  • cache a[0] e a[1] depois b[0] e b[1] e set a[0] = a[0] + b[0] na cache - existem agora quatro bytes na cache, a[0], a[1] e b[0], b[1]. Custo = 100 + 100.

  • ejete a[0], a[1], b[0], b[1] do cache e do cache c[0] e c[1] depois d[0] e d[1] e defina c[0] = c[0] + d[0] no cache. Custo = 100 + 100.
  • Eu suspeito que você está começando a ver para onde estou indo.
  • Custo total = (100 + 100 + 100 + 100) * 2 = 800

Este é um cenário clássico de thrash de cache.

44
OldCurmudgeon

Não é por causa de um código diferente, mas por causa do cache: RAM é mais lento que a CPU registra e uma memória cache está dentro da CPU para evitar gravar RAM toda vez que uma variável está mudando. Mas o cache não é grande como o RAM é, portanto, mapeia apenas uma fração dele.

O primeiro código modifica os endereços de memória distantes, alternando-os em cada loop, exigindo assim a invalidação contínua do cache.

O segundo código não alterna: apenas flui nos endereços adjacentes duas vezes. Isso faz com que todo o trabalho seja concluído no cache, invalidando-o somente depois que o segundo loop for iniciado.

30
Emilio Garavaglia

Não consigo replicar os resultados discutidos aqui.

Eu não sei se código de benchmark fraco é o culpado, ou o que, mas os dois métodos estão dentro de 10% um do outro na minha máquina usando o código a seguir, e um loop é geralmente apenas um pouco mais rápido que dois - como você Espero.

Tamanhos de array variaram de 2 ^ 16 a 2 ^ 24, usando oito loops. Tive o cuidado de inicializar os arrays de origem para que a atribuição += não estivesse pedindo ao FPU para adicionar lixo de memória interpretado como um duplo.

Eu brinquei com vários esquemas, como colocar a atribuição de b[j], d[j] para InitToZero[j] dentro dos loops, e também com += b[j] = 1 e += d[j] = 1, e obtive resultados razoavelmente consistentes.

Como era de se esperar, inicializar b e d dentro do loop usando InitToZero[j] deu uma vantagem à abordagem combinada, já que elas eram feitas back-to-back antes das atribuições para a e c, mas ainda dentro de 10%. Vai saber.

Hardware é Dell XPS 8500 com geração 3 Core i7 @ 3,4 GHz e 8 GB de memória. Para 2 ^ 16 a 2 ^ 24, usando oito voltas, o tempo cumulativo foi de 44,987 e 40,965, respectivamente. Visual C++ 2010, totalmente otimizado.

PS: Eu mudei os loops para contar até zero, e o método combinado foi ligeiramente mais rápido. Coçando minha cabeça. Observe o novo dimensionamento de array e contagens de loop.

// MemBufferMystery.cpp : Defines the entry point for the console application.
//
#include "stdafx.h"
#include <iostream>
#include <cmath>
#include <string>
#include <time.h>

#define  dbl    double
#define  MAX_ARRAY_SZ    262145    //16777216    // AKA (2^24)
#define  STEP_SZ           1024    //   65536    // AKA (2^16)

int _tmain(int argc, _TCHAR* argv[]) {
    long i, j, ArraySz = 0,  LoopKnt = 1024;
    time_t start, Cumulative_Combined = 0, Cumulative_Separate = 0;
    dbl *a = NULL, *b = NULL, *c = NULL, *d = NULL, *InitToOnes = NULL;

    a = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    b = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    c = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    d = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    InitToOnes = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    // Initialize array to 1.0 second.
    for(j = 0; j< MAX_ARRAY_SZ; j++) {
        InitToOnes[j] = 1.0;
    }

    // Increase size of arrays and time
    for(ArraySz = STEP_SZ; ArraySz<MAX_ARRAY_SZ; ArraySz += STEP_SZ) {
        a = (dbl *)realloc(a, ArraySz * sizeof(dbl));
        b = (dbl *)realloc(b, ArraySz * sizeof(dbl));
        c = (dbl *)realloc(c, ArraySz * sizeof(dbl));
        d = (dbl *)realloc(d, ArraySz * sizeof(dbl));
        // Outside the timing loop, initialize
        // b and d arrays to 1.0 sec for consistent += performance.
        memcpy((void *)b, (void *)InitToOnes, ArraySz * sizeof(dbl));
        memcpy((void *)d, (void *)InitToOnes, ArraySz * sizeof(dbl));

        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
                c[j] += d[j];
            }
        }
        Cumulative_Combined += (clock()-start);
        printf("\n %6i miliseconds for combined array sizes %i and %i loops",
                (int)(clock()-start), ArraySz, LoopKnt);
        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
            }
            for(j = ArraySz; j; j--) {
                c[j] += d[j];
            }
        }
        Cumulative_Separate += (clock()-start);
        printf("\n %6i miliseconds for separate array sizes %i and %i loops \n",
                (int)(clock()-start), ArraySz, LoopKnt);
    }
    printf("\n Cumulative combined array processing took %10.3f seconds",
            (dbl)(Cumulative_Combined/(dbl)CLOCKS_PER_SEC));
    printf("\n Cumulative seperate array processing took %10.3f seconds",
        (dbl)(Cumulative_Separate/(dbl)CLOCKS_PER_SEC));
    getchar();

    free(a); free(b); free(c); free(d); free(InitToOnes);
    return 0;
}

Não sei por que foi decidido que o MFLOPS era uma métrica relevante. Eu pensava que a ideia era focar nos acessos à memória, então tentei minimizar a quantidade de tempo de computação de ponto flutuante. Eu deixei no +=, mas não tenho certeza do porquê.

Uma atribuição direta sem computação seria um teste mais limpo do tempo de acesso à memória e criaria um teste uniforme, independentemente da contagem do loop. Talvez eu tenha perdido alguma coisa na conversa, mas vale a pena pensar duas vezes. Se o sinal de mais é deixado fora da atribuição, o tempo acumulado é quase idêntico em 31 segundos cada.

19
user1899861

É porque a CPU não tem tantos erros de cache (onde tem que esperar que os dados da matriz venham dos chips RAM). Seria interessante para você ajustar o tamanho das matrizes continuamente para que você exceda os tamanhos do cache de nível 1 (L1) e, em seguida, o cache de nível 2 (L2) , da sua CPU e traçar o tempo gasto para o seu código para executar contra os tamanhos das matrizes. O gráfico não deve ser uma linha reta como você esperaria.

16
James

O primeiro loop alterna a escrita em cada variável. O segundo e o terceiro apenas fazem pequenos saltos de tamanho de elemento.

Tente escrever duas linhas paralelas de 20 cruzes com uma caneta e papel separados por 20 cm. Tente terminar uma vez e depois a outra linha e tente outra vez escrevendo uma cruz em cada linha alternadamente.

13
Guillaume Kiz

A questão original

Por que um loop é muito mais lento que dois loops?


Conclusão:

Caso 1 é um problema clássico de interpolação que é ineficiente. Eu também acho que essa foi uma das principais razões pelas quais muitas arquiteturas de máquinas e desenvolvedores acabaram construindo e projetando sistemas multi-core com a habilidade de fazer aplicações multi-threaded assim como programação paralela.

Olhando para isto deste tipo de abordagem sem envolver como o Hardware, SO e Compilador (s) trabalham juntos para fazer alocações de pilha que envolvem trabalhar com RAM, Cache, Arquivos de Página, etc .; as matemáticas que estão na base desses algoritmos nos mostram qual dessas duas é a melhor solução. Podemos usar uma analogia onde um Boss ou Summation que irá representar um For Loop que tem que viajar entre os trabalhadores A & B, podemos facilmente ver que Caso 2 é pelo menos 1/2 tão rápido se não um pouco mais que Caso 1 devido à diferença na distância que é necessário para viajar e o tempo gasto entre os trabalhadores. Esta matemática se alinha quase virtualmente e perfeitamente tanto com o Bench Mark Times quanto com a quantidade de diferenças nas instruções de montagem.

Agora vou começar a explicar como tudo isso funciona abaixo.


Avaliando o problema

O código do OP:

const int n=100000;

for(int j=0;j<n;j++){
    a1[j] += b1[j];
    c1[j] += d1[j];
}

E

for(int j=0;j<n;j++){
    a1[j] += b1[j];
}
for(int j=0;j<n;j++){
    c1[j] += d1[j];
}

A consideração

Considerando a pergunta original do OP sobre as 2 variantes dos loops for e sua questão emendada em relação ao comportamento dos caches, juntamente com muitas outras excelentes respostas e comentários úteis; Eu gostaria de tentar fazer algo diferente aqui, tomando uma abordagem diferente sobre esta situação e problema.


A abordagem

Considerando os dois loops e toda a discussão sobre cache e preenchimento de páginas, eu gostaria de ter uma outra abordagem de olhar para isso de uma perspectiva diferente. Um que não envolva o cache e os arquivos de paginação nem as execuções para alocar memória, na verdade, essa abordagem nem sequer diz respeito ao hardware ou software em si.


A perspectiva

Depois de observar o código por algum tempo, ficou claro qual é o problema e o que está gerando. Vamos dividir isso em um problema algorítmico e examiná-lo a partir da perspectiva de usar notações matemáticas, em seguida, aplicar uma analogia aos problemas de matemática, bem como aos algoritmos.


O que sabemos

Sabemos que o loop dele será executado 100.000 vezes. Também sabemos que a1, b1, c1 e d1 são ponteiros em uma arquitetura de 64 bits. Em C++ em uma máquina de 32 bits, todos os ponteiros são de 4 bytes e, em uma máquina de 64 bits, eles têm 8 bytes de tamanho, pois os ponteiros são de comprimento fixo. Sabemos que temos 32 bytes nos quais alocar para ambos os casos. A única diferença é que estamos alocando 32 bytes ou 2 conjuntos de 2-8bytes em cada iteração, onde no segundo caso estamos alocando 16 bytes para cada iteração para ambos os loops independentes. Portanto, ambos os loops ainda são iguais a 32 bytes no total de alocações. Com esta informação, vamos em frente e mostrar a matemática geral, algoritmo e analogia do mesmo. Sabemos a quantidade de vezes que o mesmo conjunto ou grupo de operações terá que ser executado nos dois casos. Nós sabemos a quantidade de memória que precisa ser alocada nos dois casos. Podemos avaliar que a carga de trabalho global das alocações entre os dois casos será aproximadamente a mesma.


O que não sabemos

Não sabemos quanto tempo levará para cada caso, a menos que definamos um contador e executemos um teste de benchmark. No entanto, os pontos de referência já foram incluídos a partir da pergunta original e de algumas das respostas e comentários, bem como podemos ver uma diferença significativa entre os dois e este é todo o raciocínio desta questão para este problema e para a resposta do mesmo começar com.


Vamos investigar

Já é evidente que muitos já fizeram isso observando as alocações de heap, testes de benchmark, olhando para RAM, Cache e Arquivos de Páginas. Olhando para pontos de dados específicos e índices de iteração específicos também foi incluído e as várias conversas sobre este problema específico tem muitas pessoas começando a questionar outras coisas relacionadas sobre isso. Então, como começamos a olhar para este problema usando algoritmos matemáticos e aplicando uma analogia a ele? Nós começamos fazendo algumas afirmações! Então nós construímos nosso algoritmo de lá.


Nossas afirmações:

  • Vamos deixar nosso loop e suas iterações serem uma soma que começa em 1 e termina em 100000 em vez de começar com 0 como nos loops, pois não precisamos nos preocupar com o esquema de indexação 0 do endereçamento de memória, uma vez que estamos apenas interessados ​​em o próprio algoritmo.
  • Em ambos os casos temos 4 funções para trabalhar e 2 chamadas de função com 2 operações sendo feitas em cada chamada de função. Por isso, as configuraremos como funções e chamadas de função como F1(), F2(), f(a), f(b), f(c) e f(d).

Os Algoritmos:

1º caso: - Apenas um somatório, mas duas chamadas de função independentes.

Sum n=1 : [1,100000] = F1(), F2();
                       F1() = { f(a) = f(a) + f(b); }
                       F2() = { f(c) = f(c) + f(d);  }

2º caso: - Dois summations mas cada um tem sua própria chamada de função.

Sum1 n=1 : [1,100000] = F1();
                        F1() = { f(a) = f(a) + f(b); }

Sum2 n=1 : [1,100000] = F1();
                        F1() = { f(c) = f(c) + f(d); }

Se você notou que F2() existe somente em Sum, onde Sum1 e Sum2 contêm apenas F1(). Isso também ficará evidente mais tarde, quando começarmos a concluir que há uma espécie de otimização acontecendo a partir do segundo algoritmo.

As iterações através do primeiro caso Sum chama f(a) que irá adicionar a sua auto f(b) e chama f(c) que fará o mesmo mas adicionará f(d) a si mesmo para cada 100000 iterations. No segundo caso, temos Sum1 e Sum2 E ambos agem da mesma forma como se fossem a mesma função sendo chamada duas vezes seguidas. Neste caso, podemos tratar Sum1 e Sum2 como simplesmente antigo Sum, onde Sum, neste caso, se parece com isto: Sum n=1 : [1,100000] { f(a) = f(a) + f(b); } e, agora, isso parece uma otimização na qual podemos considerá-la a mesma função.


Resumo com Analogia

Com o que vimos no segundo caso, quase parece haver otimização, já que os dois loops têm a mesma assinatura exata, mas esse não é o problema real. A questão não é o trabalho que está sendo feito por f(a), f(b), f(c) & f(d) em ambos os casos e a comparação entre os dois é a diferença na distância que o Summation tem que percorrer em ambos os casos que lhe dá a diferença na execução do tempo .

Pense no For Loops como sendo o Summations que faz as iterações como sendo um Boss que está dando ordens para duas pessoas A & B e que seus trabalhos são para meat C & D respectivamente e para pegar algum pacote deles e retorná-lo. Na analogia aqui, as iterações de loop for ou somatórios de somatórios e de condições não representam o Boss. O que realmente representa o Boss aqui não é dos algoritmos matemáticos reais diretamente, mas do conceito real de Scope e Code Block dentro de uma rotina ou sub-rotina, método, função, unidade de tradução, etc. O primeiro algoritmo tem 1 escopo onde o segundo O algoritmo tem dois escopos consecutivos.

No primeiro caso em cada escala de chamada, o Boss vai para A e dá a ordem e A sai para buscar o pacote B's e o Boss vai para C e dá as ordens para fazer o mesmo e receber o pacote de D em cada iteração.

No segundo caso, o Boss trabalha diretamente com A para ir buscar o pacote B's até que todos os pacotes sejam recebidos. Então o Boss trabalha com C para fazer o mesmo para obter todos os pacotes D's.

Como estamos trabalhando com um ponteiro de 8 bytes e lidando com a alocação de heap, vamos considerar esse problema aqui. Digamos que o Boss esteja a 100 pés de A e que A esteja a 150 metros de C. Nós não precisamos nos preocupar com o quão longe o Boss é inicialmente de C por causa da ordem das execuções. Em ambos os casos, o Boss inicialmente viaja de A primeiro para B. Essa analogia não quer dizer que essa distância é exata; é apenas um cenário de caso de teste de uso para mostrar o funcionamento dos algoritmos. Em muitos casos, ao fazer alocações de heap e trabalhar com os arquivos de cache e de página, essas distâncias entre locais de endereço podem não variar muito em diferenças ou podem variar muito dependendo da natureza dos tipos de dados e dos tamanhos das matrizes.


Os casos de teste:

Primeiro caso: Na primeira iteração o Boss tem que ir inicialmente de 100 pés para dar o recibo de despacho para A e A apaga e faz o seu trabalho, mas então o Boss tem que viajar 500 pés para C para lhe dar o seu pedido. Em seguida, na próxima iteração e em todas as outras iterações após o Boss, é necessário percorrer 500 pés entre os dois.

Segundo caso: The Boss tem que percorrer 100 pés na primeira iteração até A, mas depois disso ele já está lá e apenas espera que A retorne até que todos os slips sejam preenchidos. Então o Boss tem que percorrer 500 pés na primeira iteração para C porque C está a 150 metros de A pois esta Boss( Summation, For Loop ) está sendo chamada logo após trabalhar com A e então apenas espera como ele fez com A até que todos os slings de ordem C's sejam feitos.


A diferença nas distâncias percorridas

const n = 100000
distTraveledOfFirst = (100 + 500) + ((n-1)*(500 + 500); 
// Simplify
distTraveledOfFirst = 600 + (99999*100);
distTraveledOfFirst = 600 + 9999900;
distTraveledOfFirst =  10000500;
// Distance Traveled On First Algorithm = 10,000,500ft

distTraveledOfSecond = 100 + 500 = 600;
// Distance Traveled On Second Algorithm = 600ft;    

A comparação de valores arbitrários

Podemos ver facilmente que 600 é muito menos do que 10 milhões. Agora isso não é exato, porque não sabemos a diferença real na distância entre qual endereço de RAM ou de qual Cache ou Arquivo de Páginas cada chamada em cada iteração será devido a muitos outros invisíveis variáveis, mas isso é apenas uma avaliação da situação para estar ciente e tentando olhar para ele do pior cenário possível.

Então, por esses números, seria quase como se o Algoritmo Um fosse 99% mais lento que o Algoritmo Dois; no entanto, essa é apenas a parte The Boss's ou responsabilidade dos algoritmos e não considera os trabalhadores reais A, B, C e D e o que eles devem fazer em cada iteração do Loop. Assim, o trabalho dos chefes representa apenas cerca de 15 a 40% do total de trabalho realizado. Assim, a maior parte do trabalho que é feito através dos trabalhadores tem um impacto ligeiramente maior no sentido de manter a proporção das diferenças de taxa de velocidade para cerca de 50-70%


A observação: - As diferenças entre os dois algoritmos

Nessa situação, é a estrutura do processo do trabalho que está sendo executado e mostra que Caso 2 é mais eficiente tanto da otimização parcial de ter uma declaração e definição de função semelhante, onde apenas as variáveis que diferem pelo nome. E também vemos que a distância total percorrida em Caso 1 é muito mais distante do que em Caso 2 e podemos considerar essa distância percorrida em nosso Fator de Tempo entre os dois algoritmos. Caso 1 tem muito mais trabalho a fazer que Caso 2 faz. Isso também foi visto na evidência do ASM que foi mostrado entre os dois casos. Mesmo com o que já foi dito sobre esses casos, isso também não explica o fato de que em Caso 1 o chefe terá que esperar que ambos A & C voltem antes que ele possa voltar para A novamente a próxima iteração e também não leva em conta o fato de que se A ou B estiver demorando muito tempo, tanto o Boss quanto o (s) outro (s) trabalhador (es) também aguardarão em estado inativo. Em Caso 2 o único que está ocioso é o Boss até que o trabalhador retorne. Então, mesmo isso tem um impacto no algoritmo.



Os POs Pergunta (s) Alterada (s)

EDIT: A questão acabou por ser de pouca relevância, como o comportamento depende severamente dos tamanhos das matrizes (n) e do cache da CPU. Então, se houver mais interesse, eu reformulo a pergunta:

Você poderia fornecer uma visão sólida dos detalhes que levam aos diferentes comportamentos de cache, conforme ilustrado pelas cinco regiões no gráfico a seguir?

Também pode ser interessante apontar as diferenças entre as arquiteturas de CPU/cache, fornecendo um gráfico semelhante para essas CPUs.


Sobre estas questões

Como demonstrei sem dúvida, há um problema subjacente mesmo antes de o Hardware e o Software se envolverem. Agora, quanto ao gerenciamento de memória e cache, juntamente com arquivos de páginas, etc., que trabalham em conjunto em um conjunto integrado de sistemas entre: The Architecture {Hardware, Firmware, alguns drivers incorporados, Kernels e conjuntos de instruções ASM}, The OS {File and Memory Sistemas de Gestão, Drivers e Registro}, The Compiler {Unidades de Tradução e Otimizações do Código Fonte}, e até o próprio Source Code com seu (s) conjunto (s) de algoritmos distintos; nós já podemos ver que há um gargalo que está acontecendo dentro do primeiro algoritmo antes mesmo de aplicá-lo a qualquer máquina com qualquer Architecture, OS e Programmable Language arbitrárias em comparação com o segundo algoritmo. Então já existia um problema antes de envolver os intrínsecos de um computador moderno.


Os resultados finais

Contudo; isso não quer dizer que essas novas perguntas não tenham importância, porque elas mesmas são e desempenham um papel afinal. Eles afetam os procedimentos e o desempenho geral e isso é evidente com os vários gráficos e avaliações de muitos que deram suas respostas e/ou comentários. Se você prestar atenção à analogia do Boss e dos dois trabalhadores A & B que tiveram que ir e recuperar pacotes de C & D respectivamente e considerando as notações matemáticas dos dois algoritmos em questão, você pode ver que sem o envolvimento do o computador Case 2 é aproximadamente 60% mais rápido que Case 1 e quando você olha para os gráficos e gráficos depois que esses algoritmos são aplicados ao código fonte, compilados, otimizados e executados pelo sistema operacional para executar operações no hardware, você vê um pouco mais de degradação entre as diferenças nesses algoritmos.

Agora, se o conjunto "Data" for pequeno, pode não parecer tão ruim de uma diferença no início, mas como Case 1 é sobre 60 - 70% mais lento que Case 2, podemos observar o crescimento dessa função como sendo em termos das diferenças de tempo de execução :

DeltaTimeDifference approximately = Loop1(time) - Loop2(time)
//where 
Loop1(time) = Loop2(time) + (Loop2(time)*[0.6,0.7]) // approximately
// So when we substitute this back into the difference equation we end up with 
DeltaTimeDifference approximately = (Loop2(time) + (Loop2(time)*[0.6,0.7])) - Loop2(time)
// And finally we can simplify this to
DeltaTimeDifference approximately = [0.6,0.7]*(Loop2(time)

E essa aproximação é a diferença média entre esses dois loops, tanto algoritmicamente quanto operações de máquina envolvendo otimizações de software e instruções de máquina. Então, quando o conjunto de dados cresce linearmente, o mesmo acontece com a diferença de tempo entre os dois. Algoritmo 1 tem mais buscas do que o algoritmo 2 que é evidente quando o Boss teve que percorrer a distância máxima entre A & C para cada iteração após a primeira iteração enquanto Algorithm 2 o Boss teve que viajar para A uma vez e depois de ser feito com A ele teve que percorrer uma distância máxima apenas uma vez ao ir de A para C.

Portanto, tentar que o Boss se concentre em fazer duas coisas semelhantes ao mesmo tempo e fazer malabarismos com eles, em vez de se concentrar em tarefas consecutivas semelhantes, vai deixá-lo bastante irritado até o final do dia porque ele teve que viajar e trabalhar o dobro. . Portanto, não perca o escopo da situação deixando que seu chefe entre em um gargalo interpolado porque o cônjuge e os filhos do chefe não o apreciam.

5
Francis Cugler

Pode ser antigo C++ e otimizações. No meu computador eu obtive quase a mesma velocidade:

Um loop: 1,577 ms

Dois loops: 1.507 ms

Eu corro o Visual Studio 2015 em um processador E5-1620 de 3,5 GHz com 16 GB de RAM.

1
mathengineer