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; }
   }
}


7
Você pode adicionar alguns resultados de benchmark?
naught101


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


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


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.


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.


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 .


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.


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.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.