Por que a ordem dos loops afeta o desempenho ao iterar em uma matriz 2D?

360

Abaixo estão dois programas que são quase idênticos, exceto que eu mudei as variáveis ie j. Ambos correm em diferentes quantidades de tempo. Alguém poderia explicar por que isso acontece?

Versão 1

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

main () {
  int i,j;
  static int x[4000][4000];
  for (i = 0; i < 4000; i++) {
    for (j = 0; j < 4000; j++) {
      x[j][i] = i + j; }
  }
}

Versão 2

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

main () {
  int i,j;
  static int x[4000][4000];
  for (j = 0; j < 4000; j++) {
     for (i = 0; i < 4000; i++) {
       x[j][i] = i + j; }
   }
}
Marca
fonte
26
en.wikipedia.org/wiki/…
Brendan Long
7
Você pode adicionar alguns resultados de benchmark?
naught101
3
Veja também: stackoverflow.com/questions/9888154/…
Thomas Padron-McCarthy
14
@ naught101 Os benchmarks mostrarão uma diferença de desempenho entre 3 e 10 vezes. Isso é básico C / C ++, eu estou completamente perplexo de como isso tem tantos votos ...
TC1
12
@ TC1: Não acho tão básico assim; talvez intermediário. Mas não deve surpreender que o material "básico" tenda a ser útil para mais pessoas, daí os muitos votos positivos. Além disso, essa é uma pergunta difícil de pesquisar no Google, mesmo que seja "básica".
Larsh

Respostas:

595

Como já foi dito, a questão é a loja para o local de memória na matriz: x[i][j]. Aqui está um pouco do porquê:

Você tem uma matriz bidimensional, mas a memória do computador é inerentemente unidimensional. Então, enquanto você imagina sua matriz assim:

0,0 | 0,1 | 0,2 | 0,3
----+-----+-----+----
1,0 | 1,1 | 1,2 | 1,3
----+-----+-----+----
2,0 | 2,1 | 2,2 | 2,3

O seu computador armazena-o na memória como uma única linha:

0,0 | 0,1 | 0,2 | 0,3 | 1,0 | 1,1 | 1,2 | 1,3 | 2,0 | 2,1 | 2,2 | 2,3

No segundo exemplo, você acessa a matriz fazendo um loop sobre o segundo número primeiro, ou seja:

x[0][0] 
        x[0][1]
                x[0][2]
                        x[0][3]
                                x[1][0] etc...

Significando que você está acertando todos eles em ordem. Agora olhe para a 1ª versão. Voce esta fazendo:

x[0][0]
                                x[1][0]
                                                                x[2][0]
        x[0][1]
                                        x[1][1] etc...

Devido à maneira como C organizou a matriz 2-d na memória, você está pedindo que ela salte por todo o lado. Mas agora para o kicker: Por que isso importa? Todos os acessos à memória são iguais, certo?

Não: por causa dos caches. Os dados da sua memória são trazidos para a CPU em pequenos pedaços (chamados de 'linhas de cache'), normalmente de 64 bytes. Se você tem números inteiros de 4 bytes, significa que você está obtendo 16 números inteiros consecutivos em um pequeno pacote. Na verdade, é bastante lento buscar esses pedaços de memória; sua CPU pode fazer muito trabalho no tempo necessário para carregar uma única linha de cache.

Agora, olhe novamente para a ordem dos acessos: O segundo exemplo é (1) pegar um pedaço de 16 polegadas, (2) modificar todos eles, (3) repetir 4000 * 4000/16 vezes. Isso é agradável e rápido, e a CPU sempre tem algo para trabalhar.

O primeiro exemplo é (1) pegue um pedaço de 16 polegadas, (2) modifique apenas um deles, (3) repita 4000 * 4000 vezes. Isso exigirá 16 vezes o número de "buscas" da memória. Na verdade, sua CPU terá que gastar um tempo esperando que a memória apareça e, enquanto estiver sentado, você estará perdendo um tempo valioso.

Nota importante:

Agora que você tem a resposta, eis uma observação interessante: não há razão inerente para que seu segundo exemplo seja o mais rápido. Por exemplo, no Fortran, o primeiro exemplo seria rápido e o segundo lento. Isso ocorre porque, em vez de expandir as coisas em "linhas" conceituais, como C faz, o Fortran se expande em "colunas", ou seja:

0,0 | 1,0 | 2,0 | 0,1 | 1,1 | 2,1 | 0,2 | 1,2 | 2,2 | 0,3 | 1,3 | 2,3

O layout de C é chamado de 'linha principal' e o de Fortran é chamado de 'coluna principal'. Como você pode ver, é muito importante saber se a sua linguagem de programação é de linhas principais ou de colunas! Aqui está um link para mais informações: http://en.wikipedia.org/wiki/Row-major_order

Robert Martin
fonte
14
Esta é uma resposta bastante completa; foi o que me ensinaram ao lidar com falhas de cache e gerenciamento de memória.
30512 Makoto
7
Você tem as versões "primeira" e "segunda" no caminho errado; o primeiro exemplo varia o primeiro índice no loop interno e será o exemplo de execução mais lenta.
caf
Ótima resposta. Se Mark quiser ler mais sobre esse detalhe, eu recomendaria um livro como o Write Great Code.
Wkl
8
Pontos de bônus por apontar que C alterou a ordem das linhas de Fortran. Para a computação científica, o tamanho do cache L2 é tudo, porque se todas as suas matrizes se ajustarem ao L2, o cálculo poderá ser concluído sem a necessidade de ir para a memória principal.
precisa saber é o seguinte
4
@ Birryree: O que todo programador deve saber sobre memória, disponível gratuitamente, também é uma boa leitura.
caf
68

Nada a ver com montagem. Isso ocorre devido a falhas de cache .

As matrizes multidimensionais C são armazenadas com a última dimensão como a mais rápida. Portanto, a primeira versão perderá o cache em todas as iterações, enquanto a segunda versão não. Portanto, a segunda versão deve ser substancialmente mais rápida.

Veja também: http://en.wikipedia.org/wiki/Loop_interchange .

Oliver Charlesworth
fonte
23

A versão 2 será executada muito mais rapidamente porque usa o cache do computador melhor que a versão 1. Se você pensar bem, as matrizes são apenas áreas contíguas da memória. Quando você solicita um elemento em uma matriz, seu sistema operacional provavelmente trará uma página de memória para o cache que contém esse elemento. No entanto, como os próximos elementos também estão nessa página (por serem contíguos), o próximo acesso já estará em cache! É isso que a versão 2 está fazendo para acelerar sua velocidade.

A versão 1, por outro lado, está acessando elementos em colunas, e não em linhas. Esse tipo de acesso não é contíguo no nível da memória; portanto, o programa não pode aproveitar tanto o cache do SO.

Oleksi
fonte
Com esses tamanhos de matriz, provavelmente o gerenciador de cache na CPU e não no SO é responsável aqui.
krlmlr
12

O motivo é o acesso a dados em cache local. No segundo programa, você está digitalizando linearmente a memória, beneficiando do armazenamento em cache e da pré-busca. O padrão de uso de memória do seu primeiro programa é muito mais espalhado e, portanto, apresenta um comportamento de cache pior.

Codificador de comprimento variável
fonte
11

Além das outras excelentes respostas sobre os acertos do cache, também há uma possível diferença de otimização. Seu segundo loop provavelmente será otimizado pelo compilador em algo equivalente a:

  for (j=0; j<4000; j++) {
    int *p = x[j];
    for (i=0; i<4000; i++) {
      *p++ = i+j;
    }
  }

Isso é menos provável para o primeiro loop, porque seria necessário incrementar o ponteiro "p" com 4000 a cada vez.

EDIT: p++ e até *p++ = ..pode ser compilado em uma única instrução de CPU na maioria das CPUs. *p = ..; p += 4000não pode, portanto, há menos benefícios em otimizá-lo. Também é mais difícil, porque o compilador precisa conhecer e usar o tamanho da matriz interna. E não ocorre com frequência no loop interno no código normal (ocorre apenas para matrizes multidimensionais, em que o último índice é mantido constante no loop e o penúltimo no último é escalado), portanto a otimização é menos prioritária .

peixeinear
fonte
Eu não entendo o que 'porque precisaria pular o ponteiro "p" com 4000 cada vez "significa.
Veedrac #
@Veedrac O ponteiro teria de ser incrementado com 4000 dentro do ciclo interior: p += 4000isop++
fishinear
Por que o compilador considerou isso um problema? ijá é incrementado por um valor não unitário, dado que é um incremento de ponteiro.
Veedrac
Eu adicionei mais explicações
fishinear perto de
Tente digitar int *f(int *p) { *p++ = 10; return p; } int *g(int *p) { *p = 10; p += 4000; return p; }em gcc.godbolt.org . Os dois parecem compilar basicamente o mesmo.
Veedrac
7

Esta linha o culpado:

x[j][i]=i+j;

A segunda versão usa memória contínua, portanto, será substancialmente mais rápida.

Eu tentei com

x[50000][50000];

e o tempo de execução é 13s para a versão1 versus 0,6s para a versão2.

Nicolas Modrzyk
fonte
4

Eu tento dar uma resposta genérica.

Porque i[y][x]é uma abreviação para *(i + y*array_width + x)C (experimente o elegante int P[3]; 0[P] = 0xBEEF;).

À medida que você repete y, você repete sobre pedaços de tamanho array_width * sizeof(array_element). Se você tiver isso em seu loop interno, terá array_width * array_heightiterações sobre esses blocos.

Ao inverter a ordem, você terá apenas array_heightiterações de partes e entre qualquer iteração de partes, você terá array_widthapenas iterações sizeof(array_element).

Enquanto em CPUs x86 realmente antigas isso não importava muito, hoje em dia o x86 faz muita pré-busca e armazenamento em cache de dados. Você provavelmente produz muitas falhas de cache na sua ordem de iteração mais lenta.

Sebastian Mach
fonte