루프 순서가 2D 배열을 반복 할 때 성능에 영향을 미치는 이유는 무엇입니까?


360

아래는 제가 전환하는 것을 제외하고는 거의 동일한 두 개의 프로그램입니다 ij주변 변수. 둘 다 다른 시간에 실행됩니다. 왜 이런 일이 발생했는지 설명해 주시겠습니까?

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

버전 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
벤치 마크 결과를 추가 할 수 있습니까?
naught101


14
@ naught101 벤치 마크는 3 ~ 10 배의 성능 차이를 보여줍니다. 이것은 기본적인 C / C ++이며, 이것이 얼마나 많은 표를 얻었는지에 대해 완전히 혼란
스러워합니다

12
@ TC1 : 기본이라고 생각하지 않습니다. 아마도 중간 일 수도 있습니다. 그러나 "기본"항목이 더 많은 사람들에게 유용한 경향이 있다는 점은 놀라운 일이 아닙니다. 또한 이것은 "기본"인 경우에도 Google에 어려운 질문입니다.
LarsH

답변:


595

다른 사람들이 말했듯이 문제는 배열의 메모리 위치에 대한 저장소 x[i][j]입니다. 이유는 다음과 같습니다.

2 차원 배열이 있지만 컴퓨터의 메모리는 본질적으로 1 차원입니다. 따라서 배열을 다음과 같이 상상하십시오.

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

컴퓨터는이를 한 줄로 메모리에 저장합니다.

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

두 번째 예에서는 두 번째 숫자를 먼저 반복하여 배열에 액세스합니다.

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

당신이 순서대로 그들을 때리는 것을 의미합니다. 이제 첫 번째 버전을보십시오. 당신은하고 있습니다 :

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

C가 메모리에 2 차원 배열을 배치하는 방식으로 인해 모든 곳을 뛰어 넘도록 요구하고 있습니다. 그러나 이제 키커에게 : 왜 이것이 문제가 되는가? 모든 메모리 액세스가 동일합니까?

아니요 : 캐시 때문입니다. 메모리의 데이터는 일반적으로 64 바이트 인 작은 청크 ( '캐시 라인'이라고 함)로 CPU로 가져옵니다. 4 바이트 정수가 있다면 깔끔한 작은 번들로 16 개의 연속 정수를 얻는다는 의미입니다. 실제로 이러한 메모리 덩어리를 가져 오는 것은 상당히 느립니다. CPU는 단일 캐시 라인을로드하는 데 걸리는 시간에 많은 작업을 수행 할 수 있습니다.

이제 액세스 순서를 다시 살펴보십시오. 두 번째 예는 (1) 16 개 덩어리 청크를 잡고 (2) 모두 수정하고, (3) 4000 * 4000 / 16 회 반복합니다. 훌륭하고 빠르며 CPU는 항상 작동해야합니다.

첫 번째 예는 (1) 16 개의 덩어리 청크를 잡고, (2) 그중 하나만 수정하고, (3) 4000 * 4000 번 반복합니다. 메모리에서 "페치"횟수의 16 배가 필요합니다. 실제로 CPU는 해당 메모리가 표시되기를 기다리는 데 시간을 소비해야하며, 앉아있는 동안 귀중한 시간을 낭비하고 있습니다.

중요 사항:

이제 답을 얻었으니 여기에 흥미로운 메모가 있습니다. 두 번째 예제가 빠른 이유는 없습니다. 예를 들어, 포트란에서 첫 번째 예는 빠르고 두 번째 예는 느립니다. C와 같은 개념적인 "행"으로 확장하는 대신, 포트란은 "열"로 확장되기 때문입니다.

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

C의 레이아웃을 '행 전공'이라고하고 포트란을 '열 전공'이라고합니다. 보다시피 프로그래밍 언어가 행 전공인지 열 전공인지 아는 것이 매우 중요합니다! 자세한 내용은 다음 링크를 참조하십시오 : http://en.wikipedia.org/wiki/Row-major_order


14
이것은 매우 철저한 답변입니다. 캐시 미스 및 메모리 관리를 다룰 때 제가 배운 것입니다.
Makoto

7
잘못된 방식으로 "첫 번째"및 "두 번째"버전이 있습니다. 첫 번째 예제는 내부 루프 의 첫 번째 인덱스를 변경하며 더 느린 실행 예제입니다.
caf

좋은 대답입니다. Mark가 그러한 핵심에 대해 더 많이 읽고 싶다면 Write Great Code와 같은 책을 추천합니다.
wkl

8
C가 Fortran에서 행 순서를 변경했음을 나타내는 보너스 포인트. 과학 컴퓨팅의 경우 L2 캐시 크기는 모든 배열이 L2에 적합하면 주 메모리로 이동하지 않고 계산을 완료 할 수 있기 때문에 모든 것입니다.
Michael Shopsin

4
@birryree : 모든 프로그래머가 메모리에 대해 알아야 할 자유 도 또한 잘 읽습니다.
caf

68

어셈블리와 관련이 없습니다. 이것은 캐시 누락 으로 인한 것 입니다.

C 다차원 배열은 마지막 차원을 가장 빠르게 저장합니다. 따라서 첫 번째 버전은 모든 반복에서 캐시를 놓치지 만 두 번째 버전은 그렇지 않습니다. 따라서 두 번째 버전은 훨씬 빠릅니다.

http://en.wikipedia.org/wiki/Loop_interchange 도 참조하십시오 .


23

버전 2는 버전 1보다 컴퓨터의 캐시를 더 잘 사용하기 때문에 훨씬 더 빠르게 실행됩니다. 생각하면 배열은 메모리의 연속적인 영역 일뿐입니다. 배열의 요소를 요청하면 OS에서 해당 요소가 포함 된 메모리 페이지를 캐시로 가져옵니다. 그러나 다음 몇 개의 요소도 연속되어 있기 때문에 해당 페이지에 있으므로 다음 액세스는 이미 캐시에 있습니다! 이것이 버전 2가 속도를 높이기 위해하는 일입니다.

반면, 버전 1은 행이 아닌 열 방식으로 요소에 액세스합니다. 이러한 종류의 액세스는 메모리 수준에서 연속적이지 않으므로 프로그램은 OS 캐싱을 많이 이용할 수 없습니다.


이러한 배열 크기의 경우 OS가 아닌 CPU의 캐시 관리자가 여기에 있습니다.
krlmlr

12

그 이유는 캐시 로컬 데이터 액세스입니다. 두 번째 프로그램에서는 캐싱 및 프리 페치의 이점이있는 메모리를 통해 선형으로 스캔합니다. 첫 번째 프로그램의 메모리 사용 패턴이 훨씬 넓어 지므로 캐시 동작이 악화됩니다.


11

캐시 적중률에 대한 다른 훌륭한 답변 외에도 가능한 최적화 차이가 있습니다. 두 번째 루프는 컴파일러에 의해 다음과 같은 것으로 최적화 될 수 있습니다.

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

매번 4000으로 포인터 "p"를 증가시켜야하기 때문에 이것은 첫 번째 루프에서는 가능성이 적습니다.

편집 : p++ 심지어 *p++ = ..대부분의 CPU에서 단일 CPU 명령으로 컴파일 할 수도 있습니다. *p = ..; p += 4000최적화 할 수없는 이점이 없습니다. 컴파일러는 내부 배열의 크기를 알고 사용해야하기 때문에 더 어렵습니다. 그리고 보통 코드의 내부 루프에서 종종 발생하지 않습니다 (마지막 인덱스가 루프에서 일정하게 유지되고 두 번째에서 마지막 인덱스가 단계 화되는 다차원 배열의 경우에만 발생). 최적화가 우선 순위가 아닙니다. .


'매번 4000으로 포인터 "p"를 점프해야하기 때문에 무엇을 의미하지 않습니다.
Veedrac

@Veedrac 포인터는 내부 루프 내부에서 4000으로 증가해야합니다. p += 4000isop++
fishinear

컴파일러가 왜 문제를 발견했을까요? i포인터가 증가하면 이미 비 단위 값으로 증가합니다.
Veedrac

추가 설명을 추가했습니다
fishinear

입력 시도 int *f(int *p) { *p++ = 10; return p; } int *g(int *p) { *p = 10; p += 4000; return p; }gcc.godbolt.org . 둘은 기본적으로 동일한 것으로 컴파일됩니다.
Veedrac

7

이 선의 범인 :

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

두 번째 버전은 연속 메모리를 사용하므로 훨씬 빠릅니다.

나는 함께 노력했다

x[50000][50000];

실행 시간은 version1의 경우 13 초이며 version2의 경우 0.6 초입니다.


4

나는 일반적인 대답을하려고합니다.

C에서 i[y][x]속기 때문에 *(i + y*array_width + x)(고상한 시도 int P[3]; 0[P] = 0xBEEF;).

반복 y할 때 크기의 덩어리를 반복합니다 array_width * sizeof(array_element). 내부 루프 array_width * array_height에 해당 항목 이 있으면 해당 청크에 대해 반복 됩니다 .

순서를 뒤집 으면 array_height청크 반복 만 발생하고 청크 반복 사이에는 array_width반복 만 수행됩니다 sizeof(array_element).

실제로 오래된 x86-CPU에서는 그다지 중요하지 않았지만 오늘날의 x86은 많은 프리 페치 및 데이터 캐싱을 수행합니다. 느린 반복 순서로 많은 캐시 미스가 발생할 수 있습니다.

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.