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 esse foi um dos principais motivos pelos quais muitas arquiteturas e desenvolvedores de máquinas acabaram construindo e projetando sistemas com vários núcleos, com a capacidade de executar aplicativos multithread e programação paralela.
Analisando esse tipo de abordagem sem envolver como o Hardware, o SO e o Compilador (s) trabalham juntos para fazer alocações de heap que envolvem o trabalho com RAM, Cache, Arquivos de Página, etc .; a matemática que está na base desses algoritmos nos mostra qual desses dois é a melhor solução.
Podemos usar uma analogia de um Boss
ser Summation
que representará um For Loop
que deve viajar entre trabalhadores A
e B
.
Podemos ver facilmente que o Caso 2 é pelo menos metade da velocidade, se não um pouco mais que o Caso 1, devido à diferença na distância necessária para viajar e ao tempo gasto entre os trabalhadores. Essa matemática está alinhada quase virtualmente e perfeitamente com o BenchMark Times e com o número 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 pergunta alterada em relação ao comportamento dos caches, juntamente com muitas das outras excelentes respostas e comentários úteis; Gostaria de tentar fazer algo diferente aqui, adotando uma abordagem diferente sobre essa situação e esse problema.
A abordagem
Considerando os dois loops e toda a discussão sobre cache e arquivamento de páginas, gostaria de adotar outra abordagem para analisar isso de uma perspectiva diferente. Um que não envolva os arquivos de cache e de página nem as execuções para alocar memória, de fato, essa abordagem nem sequer diz respeito ao hardware ou software real.
A perspectiva
Depois de analisar o código por um tempo, ficou bastante claro qual é o problema e o que está gerando. Vamos dividir isso em um problema algorítmico e analisá-lo da perspectiva de usar notações matemáticas e aplicar uma analogia aos problemas matemáticos e aos algoritmos.
O que sabemos
Sabemos que esse loop será executado 100.000 vezes. Sabemos também que a1
, b1
, c1
ed1
são ponteiros em uma arquitetura de 64 bits. No C ++ em uma máquina de 32 bits, todos os ponteiros têm 4 bytes e em uma máquina de 64 bits, têm 8 bytes de tamanho, pois os ponteiros têm um comprimento fixo.
Sabemos que temos 32 bytes para alocar nos dois casos. A única diferença é que estamos alocando 32 bytes ou 2 conjuntos de 2-8 bytes em cada iteração, em que no segundo caso estamos alocando 16 bytes para cada iteração para os dois loops independentes.
Ambos os loops ainda são iguais a 32 bytes no total de alocações. Com essas informações, vamos agora em frente e mostrar a matemática geral, algoritmos e analogia desses conceitos.
Sabemos o número de vezes que o mesmo conjunto ou grupo de operações que 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 geral 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, as referências já foram incluídas na pergunta original e também em algumas respostas e comentários; e podemos ver uma diferença significativa entre os dois, e esse é todo o raciocínio desta proposta para esse problema.
Vamos investigar
Já é aparente que muitos já fizeram isso examinando as alocações de heap, testes de benchmark, analisando RAM, cache e arquivos de página. Analisando pontos de dados específicos e índices de iteração específicos também foram incluídos e as várias conversas sobre esse problema específico fazem com que muitas pessoas comecem a questionar outras coisas relacionadas a ele. Como começamos a analisar esse problema usando algoritmos matemáticos e aplicando uma analogia a ele? Começamos fazendo algumas afirmações! Então construímos nosso algoritmo a partir daí.
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, pois estamos apenas interessados em o próprio algoritmo.
- Nos dois casos, temos 4 funções para trabalhar e 2 chamadas de função, com 2 operações sendo executadas em cada chamada de função. Vamos configurá-los como funções e chamadas a funções como o seguinte:
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 somatórios, 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, F2()
existe apenas Sum
de Case1
onde F1()
está contido em Sum
de Case1
e em ambos Sum1
e Sum2
de Case2
. Isso ficará evidente mais tarde, quando começarmos a concluir que há uma otimização que está acontecendo dentro do segundo algoritmo.
As iterações no primeiro caso Sum
chamam f(a)
que serão adicionadas a si mesmas f(b)
e, em seguida, chamam f(c)
que farão o mesmo, mas serão adicionadas f(d)
a si mesmas para cada 100000
iteração. No segundo caso, temos Sum1
e Sum2
que ambos agem da mesma forma como se fossem a mesma função sendo chamada duas vezes seguidas.
Nesse caso, podemos tratar Sum1
e Sum2
simplesmente como antigos, Sum
onde Sum
, neste caso, se parece com isso: Sum n=1 : [1,100000] { f(a) = f(a) + f(b); }
e agora isso parece uma otimização, onde podemos apenas considerar que é a mesma função.
Resumo com Analogia
Com o que vimos no segundo caso, quase parece que há otimização, já que ambos os 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)
, e f(d)
. Nos dois casos e na comparação entre os dois, é a diferença na distância que o somatório deve percorrer em cada caso que fornece a diferença no tempo de execução.
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
e B
e que seus empregos são a carne C
e D
, respectivamente, e para pegar algum pacote a partir deles e devolvê-lo. Nessa analogia, os loops for ou as iterações de somatória e as verificações de condição em si não representam realmente o Boss
. O que realmente representa Boss
não é diretamente dos algoritmos matemáticos reais, 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 um escopo, enquanto o segundo algoritmo possui dois escopos consecutivos.
No primeiro caso de cada recibo de chamada, ele Boss
acessa A
e fornece o pedido e A
sai para buscar o B's
pacote, depois Boss
acessa C
e fornece os pedidos para fazer o mesmo e receber o pacote D
em cada iteração.
No segundo caso, ele Boss
trabalha diretamente A
para buscar o B's
pacote até que todos os pacotes sejam recebidos. Em seguida, ele Boss
trabalha C
para fazer o mesmo para obter todos os D's
pacotes.
Como estamos trabalhando com um ponteiro de 8 bytes e lidando com a alocação de heap, vamos considerar o seguinte problema. Digamos que Boss
seja a 100 pés A
e a A
500 pés C
. Não precisamos nos preocupar com o quão longe isso Boss
está inicialmente C
devido à ordem das execuções. Nos dois casos, o Boss
primeiro viaja do A
primeiro para o depois B
. Essa analogia não quer dizer que essa distância seja exata; é apenas um cenário de caso de teste útil 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 os locais de endereço podem não variar muito ou podem variar significativamente, dependendo da natureza dos tipos de dados e dos tamanhos da matriz.
Os casos de teste:
Primeiro Caso: Na primeira iteração, o usuárioBoss
deve inicialmente percorrer 100 pés para dar o escorregão do pedidoA
eA
disparar e faz o que quer, mas depoisBoss
precisa percorrer 500 pésC
para dar o escorregão do pedido. Em seguida, na próxima iteração e em todas as outras iterações após a etapa,Boss
é necessário ir e voltar 500 pés entre as duas.
Segundo caso: ABoss
tem que viajar 100 pés na primeira iteração paraA
, mas depois disso, ele já está lá e apenas espera paraA
voltar até que todos os deslizamentos são preenchidos. Em seguida, eleBoss
precisa percorrer 500 pés na primeira iteração paraC
porqueC
fica a 500 pésA
. Como issoBoss( Summation, For Loop )
está sendo chamado logo após o trabalho comA
ele, ele apenas espera lá, como fezA
até que todos osC's
pedidos sejam concluídos.
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 que 10 milhões. Agora, isso não é exato, porque não sabemos a diferença real na distância entre qual endereço da RAM ou de qual cache ou arquivo de página cada chamada em cada iteração será devida a muitas outras variáveis invisíveis. Esta é apenas uma avaliação da situação a ser observada e analisada do pior cenário possível.
A partir desses números, quase pareceria que o Algoritmo Um deveria ser 99%
mais lento que o Algoritmo Dois; no entanto, esta é apenas a Boss's
parte ou a responsabilidade dos algoritmos e não conta para os trabalhadores reais A
, B
, C
, e D
e o que tem que fazer em cada iteração do loop. Portanto, o trabalho do chefe é responsável por apenas 15 a 40% do total do trabalho que está sendo realizado. A maior parte do trabalho realizado através dos trabalhadores tem um impacto um pouco maior no sentido de manter a proporção das diferenças da taxa de velocidade em cerca de 50-70%
A observação: - As diferenças entre os dois algoritmos
Nesta situação, é a estrutura do processo do trabalho que está sendo realizado. Isso mostra que o Caso 2 é mais eficiente, tanto pela otimização parcial de uma declaração de função e definição semelhantes, onde são apenas as variáveis que diferem por nome quanto pela distância percorrida.
Também vemos que a distância total percorrida no Caso 1 é muito maior do que no Caso 2 e podemos considerar essa distância percorrida como nosso Fator de Tempo entre os dois algoritmos. O caso 1 tem muito mais trabalho a fazer do que o caso 2 .
Isso é observável a partir das evidências das ASM
instruções mostradas nos dois casos. Junto com o que já foi declarado sobre esses casos, isso não explica o fato de que, no Caso 1, o chefe terá que esperar pelos dois A
e C
voltar antes que possa voltar a A
cada iteração. Também não leva em conta o fato de que, se A
ou B
estiver demorando muito tempo, os dois Boss
trabalhadores ficarão ociosos aguardando a execução.
No caso 2, o único que está ocioso é o Boss
até que o trabalhador volte. Portanto, mesmo isso afeta o algoritmo.
Pergunta (s) alterada (s) do PO
EDIT: A questão acabou por não ter relevância, pois o comportamento depende muito dos tamanhos das matrizes (n) e do cache da CPU. Portanto, se houver mais interesse, refiz 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 arquiteturas de CPU / cache, fornecendo um gráfico semelhante para essas CPUs.
Sobre estas perguntas
Como demonstrei sem dúvida, há um problema subjacente antes mesmo de o hardware e o software serem envolvidos.
Agora, quanto ao gerenciamento de memória e cache, juntamente com os arquivos de paginação, etc., que trabalham juntos em um conjunto integrado de sistemas entre os seguintes:
The Architecture
{Hardware, Firmware, alguns Drivers Incorporados, Kernels e Conjuntos de Instruções ASM}.
The OS
{Sistemas de gerenciamento de arquivos e memória, drivers e registro}.
The Compiler
{Unidades de tradução e otimizações do código fonte}.
- E até o
Source Code
próprio com seu conjunto de algoritmos distintos.
Já podemos ver que há um gargalo que está acontecendo dentro do primeiro algoritmo antes mesmo de aplicá-la a qualquer máquina com qualquer arbitrária Architecture
, OS
e, Programmable Language
em comparação com o segundo algoritmo. Já existia um problema antes de envolver as intrínsecas de um computador moderno.
Os resultados finais
Contudo; não quer dizer que essas novas questões não sejam importantes porque são elas mesmas e, afinal, desempenham um papel. Eles impactam os procedimentos e o desempenho geral, e isso é evidente nos vários gráficos e avaliações de muitos que deram suas respostas e / ou comentários.
Se você prestasse atenção à analogia dos Boss
e dos dois trabalhadores A
e 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 sem o envolvimento do hardware e software do computador Case 2
é aproximadamente 60%
mais rápido que Case 1
.
Quando você olha para os gráficos e tabelas depois que esses algoritmos foram aplicados a algum código-fonte, compilado, otimizado e executado através do sistema operacional para executar suas operações em uma determinada peça de hardware, você pode ver um pouco mais de degradação entre as diferenças nesses algoritmos.
Se o Data
aparelho for razoavelmente pequeno, pode não parecer uma diferença tão ruim a princípio. No entanto, como Case 1
é mais 60 - 70%
lento do Case 2
que podemos observar o crescimento dessa função em termos das diferenças nas execuções de tempo:
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)
Essa aproximação é a diferença média entre esses dois loops, tanto algoritmicamente quanto nas operações da máquina, envolvendo otimizações de software e instruções da máquina.
Quando o conjunto de dados cresce linearmente, o mesmo ocorre com a diferença de tempo entre os dois. O algoritmo 1 tem mais buscas do que o algoritmo 2, o que é evidente quando ele Boss
precisa percorrer a distância máxima entre A
e C
para cada iteração após a primeira iteração, enquanto o algoritmo 2 Boss
precisa percorrer A
uma vez e depois de terminar, A
ele precisa percorrer uma distância máxima apenas uma vez quando passar de A
para C
.
Tentar Boss
concentrar-se em fazer duas coisas semelhantes ao mesmo tempo e manipulá-las para frente e para trás, em vez de focar em tarefas consecutivas semelhantes, o deixará bastante irritado no final do dia, já que ele teve que viajar e trabalhar duas vezes mais. Portanto, não perca o escopo da situação deixando seu chefe entrar em um gargalo interpolado porque a esposa e os filhos do chefe não o apreciariam.
Alteração: Princípios de Design de Engenharia de Software
- A diferença entre Local Stack
e Heap Allocated
cálculos na iterativa para loops e a diferença entre seus usos, eficiências e eficácia -
O algoritmo matemático que propus acima se aplica principalmente a loops que executam operações em dados alocados no heap.
- Operações consecutivas de pilha:
- Se os loops estiverem executando operações nos dados localmente em um único bloco de código ou escopo que esteja dentro do quadro da pilha, ele ainda será aplicado, mas os locais da memória estarão muito mais próximos onde normalmente são seqüenciais e a diferença na distância percorrida ou no tempo de execução é quase insignificante. Como não há alocações sendo feitas dentro do heap, a memória não é dispersa e a memória não está sendo buscada através do ram. A memória é tipicamente seqüencial e relativa ao quadro da pilha e ao ponteiro da pilha.
- Quando operações consecutivas estão sendo realizadas na pilha, um processador moderno armazenará em cache valores e endereços repetitivos, mantendo esses valores nos registros de cache local. O tempo das operações ou instruções aqui é da ordem de nanossegundos.
- Operações Alocadas de Pilha Consecutiva:
- Quando você começa a aplicar alocações de heap e o processador precisa buscar os endereços de memória em chamadas consecutivas, dependendo da arquitetura da CPU, do controlador de barramento e dos módulos Ram, o tempo de operação ou execução pode ser da ordem de micro a milissegundos. Em comparação com as operações de pilha em cache, elas são bastante lentas.
- A CPU precisará buscar o endereço de memória do Ram e, normalmente, qualquer coisa no barramento do sistema é lenta em comparação com os caminhos de dados internos ou com os barramentos de dados da própria CPU.
Portanto, quando você estiver trabalhando com dados que precisam estar no heap e percorrê-los em loops, é mais eficiente manter cada conjunto de dados e seus algoritmos correspondentes em seu próprio loop único. Você obterá otimizações melhores em comparação à tentativa de fatorar loops consecutivos colocando várias operações de conjuntos de dados diferentes que estão no heap em um único loop.
Não há problema em fazer isso com os dados que estão na pilha, pois eles são frequentemente armazenados em cache, mas não para os dados que precisam ter seu endereço de memória consultado a cada iteração.
É aqui que entra em cena a engenharia de software e o design da arquitetura de software. É a capacidade de saber como organizar seus dados, saber quando armazená-los em cache, saber quando alocá-los na pilha, saber como projetar e implementar seus algoritmos e saber quando e onde chamá-los.
Você pode ter o mesmo algoritmo que pertence ao mesmo conjunto de dados, mas pode desejar um design de implementação para sua variante de pilha e outro para sua variante alocada ao heap apenas por causa do problema acima, visto pela O(n)
complexidade do algoritmo ao trabalhar com a pilha.
Pelo que notei ao longo dos anos, muitas pessoas não levam esse fato em consideração. Eles tenderão a projetar um algoritmo que funcione em um conjunto de dados específico e o usarão independentemente do conjunto de dados armazenado em cache localmente na pilha ou se foi alocado no heap.
Se você deseja uma otimização verdadeira, sim, pode parecer uma duplicação de código, mas, para generalizar, seria mais eficiente ter duas variantes do mesmo algoritmo. Um para operações de pilha e outro para operações de heap que são executadas em loops iterativos!
Aqui está um pseudo exemplo: duas estruturas simples, um algoritmo.
struct A {
int data;
A() : data{0}{}
A(int a) : data{a}{}
};
struct B {
int data;
B() : data{0}{}
A(int b) : data{b}{}
}
template<typename T>
void Foo( T& t ) {
// do something with t
}
// some looping operation: first stack then heap.
// stack data:
A dataSetA[10] = {};
B dataSetB[10] = {};
// For stack operations this is okay and efficient
for (int i = 0; i < 10; i++ ) {
Foo(dataSetA[i]);
Foo(dataSetB[i]);
}
// If the above two were on the heap then performing
// the same algorithm to both within the same loop
// will create that bottleneck
A* dataSetA = new [] A();
B* dataSetB = new [] B();
for ( int i = 0; i < 10; i++ ) {
Foo(dataSetA[i]); // dataSetA is on the heap here
Foo(dataSetB[i]); // dataSetB is on the heap here
} // this will be inefficient.
// To improve the efficiency above, put them into separate loops...
for (int i = 0; i < 10; i++ ) {
Foo(dataSetA[i]);
}
for (int i = 0; i < 10; i++ ) {
Foo(dataSetB[i]);
}
// This will be much more efficient than above.
// The code isn't perfect syntax, it's only psuedo code
// to illustrate a point.
Isso é o que eu estava me referindo por ter implementações separadas para variantes de pilha versus variantes de heap. Os algoritmos em si não importam muito, são as estruturas de loop que você as utilizará.