정확히 8192 개의 요소를 반복 할 때 왜 프로그램이 느려 집니까?


755

문제의 프로그램에서 발췌 한 내용은 다음과 같습니다. 행렬 img[][]의 크기는 SIZE × SIZE이며 다음 위치에서 초기화됩니다.

img[j][i] = 2 * j + i

그런 다음 행렬을 만들고 res[][]여기의 각 필드는 img 행렬에서 주변의 9 개 필드의 평균이되도록합니다. 간단하게하기 위해 테두리는 0으로 유지됩니다.

for(i=1;i<SIZE-1;i++) 
    for(j=1;j<SIZE-1;j++) {
        res[j][i]=0;
        for(k=-1;k<2;k++) 
            for(l=-1;l<2;l++) 
                res[j][i] += img[j+l][i+k];
        res[j][i] /= 9;
}

그게 전부 프로그램에 있습니다. 완전성을 위해 여기에 앞으로 오는 것이 있습니다. 코드가 나오지 않습니다. 보시다시피, 그것은 단지 초기화 일뿐입니다.

#define SIZE 8192
float img[SIZE][SIZE]; // input image
float res[SIZE][SIZE]; //result of mean filter
int i,j,k,l;
for(i=0;i<SIZE;i++) 
    for(j=0;j<SIZE;j++) 
        img[j][i] = (2*j+i)%8196;

기본적으로이 프로그램은 SIZE가 2048의 배수 일 때 느립니다 (예 : 실행 시간).

SIZE = 8191: 3.44 secs
SIZE = 8192: 7.20 secs
SIZE = 8193: 3.18 secs

컴파일러는 GCC입니다. 내가 아는 것에서 이것은 메모리 관리 때문이지만 실제로 그 주제에 대해 너무 많이 알지 못하기 때문에 여기에 묻습니다.

또한이 문제를 해결하는 방법은 좋지만 누군가가 이러한 실행 시간을 설명 할 수 있다면 이미 충분히 기쁠 것입니다.

나는 malloc / free에 대해 이미 알고 있지만 문제는 사용 된 메모리 양이 아니며 실행 시간 일 뿐이므로 어떻게 도움이되는지 모르겠습니다.


67
@bokan은 크기가 캐시의 중요한 보폭의 배수 일 때 발생합니다.
— Luchian Grigore

5
@Mysticial, 그것은 중요하지 않습니다, 그것은 똑같은 정확한 문제를 드러냅니다. 코드는 다를 수 있지만 기본적으로 두 질문 모두 같은 시간에 대해 묻습니다 (제목은 분명히 비슷합니다).
— Griwes September

33
고성능을 원한다면 2 차원 배열을 사용하여 이미지를 처리해서는 안됩니다. 모든 픽셀이 가공되지 않은 것을 고려하여 1 차원 배열처럼 처리하십시오. 이 패스를 두 번에 수행하십시오. 먼저 3 픽셀의 슬라이딩 합계를 사용하여 주변 픽셀의 값을 추가합니다. slideSum + = src [i + 1] -src [i-1]; dest [i] = 슬라이드 섬;. 그런 다음 수직으로 동일하게 나누고 동시에 나누십시오 : dest [i] = (src [i-width] + src [i] + src [i + width]) / 9. www-personal.engin.umd.umich.edu/~jwvm/ece581/18_RankedF.pdf
— bokan September

8
실제로 두 가지 일이 있습니다. 그것은 단지 슈퍼 정렬이 아닙니다.
— Mysticial

7
(답변에 약간의 작은 선택이 있습니다. 첫 번째 코드 세그먼트의 경우 모든 for 루프에 중괄호가 있으면 좋을 것입니다.)
— Trevor Boyd Smith

답변:


954

차이점은 다음 관련 질문과 동일한 수퍼 얼라인먼트 문제로 인해 발생합니다.

그러나 코드에 다른 문제가 있기 때문입니다.

원래 루프에서 시작 :

for(i=1;i<SIZE-1;i++) 
    for(j=1;j<SIZE-1;j++) {
        res[j][i]=0;
        for(k=-1;k<2;k++) 
            for(l=-1;l<2;l++) 
                res[j][i] += img[j+l][i+k];
        res[j][i] /= 9;
}

먼저 두 개의 내부 루프가 사소한 것입니다. 다음과 같이 풀 수 있습니다.

for(i=1;i<SIZE-1;i++) {
    for(j=1;j<SIZE-1;j++) {
        res[j][i]=0;
        res[j][i] += img[j-1][i-1];
        res[j][i] += img[j  ][i-1];
        res[j][i] += img[j+1][i-1];
        res[j][i] += img[j-1][i  ];
        res[j][i] += img[j  ][i  ];
        res[j][i] += img[j+1][i  ];
        res[j][i] += img[j-1][i+1];
        res[j][i] += img[j  ][i+1];
        res[j][i] += img[j+1][i+1];
        res[j][i] /= 9;
    }
}

그래서 우리가 관심있는 두 개의 외부 루프를 남깁니다.

이제 우리는이 질문에서 문제가 동일하다는 것을 알 수 있습니다. 왜 2D 배열을 반복 할 때 루프 순서가 성능에 영향을 줍니까?

행 방향이 아닌 열 방향으로 행렬을 반복합니다.


이 문제를 해결하려면 두 루프를 서로 바꿔야합니다.

for(j=1;j<SIZE-1;j++) {
    for(i=1;i<SIZE-1;i++) {
        res[j][i]=0;
        res[j][i] += img[j-1][i-1];
        res[j][i] += img[j  ][i-1];
        res[j][i] += img[j+1][i-1];
        res[j][i] += img[j-1][i  ];
        res[j][i] += img[j  ][i  ];
        res[j][i] += img[j+1][i  ];
        res[j][i] += img[j-1][i+1];
        res[j][i] += img[j  ][i+1];
        res[j][i] += img[j+1][i+1];
        res[j][i] /= 9;
    }
}

이렇게하면 모든 비 순차 액세스가 완전히 제거되므로 더 이상 2의 거듭 제곱에서 임의의 속도 저하가 발생하지 않습니다.


코어 i7 920 @ 3.5GHz

원본 코드 :

8191: 1.499 seconds
8192: 2.122 seconds
8193: 1.582 seconds

교환 된 외부 루프 :

8191: 0.376 seconds
8192: 0.357 seconds
8193: 0.351 seconds

217
또한 내부 루프를 풀면 성능에 영향을 미치지 않습니다. 컴파일러가 자동으로 수행합니다. 외부 루프의 문제를 쉽게 발견 할 수 있도록 제거하기위한 목적으로 만 풀었습니다.
— Mysticial

29
또한 각 행의 합계를 캐싱하여이 코드의 속도를 3 배 더 높일 수 있습니다. 그러나 그와 다른 최적화는 원래 질문의 범위를 벗어납니다.
— Eric Postpischil

34
@ClickUpvote 이것은 실제로 하드웨어 (캐싱) 문제입니다. 언어와 관련이 없습니다. 네이티브 코드로 컴파일하거나 JIT하는 다른 언어로 시도한 경우 동일한 효과가 나타납니다.
— Mysticial

19
@ClickUpvote : 당신은 오히려 오도 된 것 같습니다. 그 "두 번째 루프"는 손으로 내부 루프를 풀어주는 신비로운 것입니다. 이것은 컴파일러가 어쨌든 거의 확실하게 할 일이며 Mystical은 외부 루프 문제를보다 명확하게하기 위해 수행했습니다. 결코 자신을 괴롭히는 일이 아닙니다.
— 릴리 발라드

154
이 글은 SO에 대한 좋은 답변의 완벽한 예입니다. 비슷한 질문을 참조하고, 어떻게 접근했는지 단계별로 설명하고, 문제를 설명하고, 문제를 해결하는 방법을 설명하고, 형식을 지정하고, 코드를 실행하는 예제도 있습니다. 당신의 기계에. 당신의 기여에 감사드립니다.
— MattSayar

57

다음 테스트는 기본 Qt Creator 설치에서 사용되는 Visual C ++ 컴파일러로 수행되었습니다 (최적화 플래그가없는 것 같습니다). GCC를 사용할 때 Mystical 버전과 "최적화 된"코드 사이에는 큰 차이가 없습니다. 결론은 컴파일러 최적화가 마이크로 최적화를 인간보다 더 잘 처리한다는 것입니다 (마침내). 나머지 답변은 참고 용으로 남겨 두십시오.


이 방법으로 이미지를 처리하는 것은 비효율적입니다. 단일 차원 배열을 사용하는 것이 좋습니다. 모든 픽셀을 처리하는 것은 하나의 루프에서 수행됩니다. 다음을 사용하여 포인트에 무작위로 액세스 할 수 있습니다.

pointer + (x + y*width)*(sizeOfOnePixel)

이 특별한 경우에는 세 개의 픽셀 그룹을 각각 세 번 사용하기 때문에 가로로 세 개의 픽셀 그룹을 계산하고 캐시하는 것이 좋습니다.

몇 가지 테스트를 수행했으며 공유 할 가치가 있다고 생각합니다. 각 결과는 평균 5 번의 테스트입니다.

user1615209의 원본 코드 :

8193: 4392 ms
8192: 9570 ms

신비로운 버전 :

8193: 2393 ms
8192: 2190 ms

1D 배열을 사용한 두 번의 패스 : 첫 번째 패스는 가로 합, 두 번째는 세로 합 및 평균 세 개의 포인터를 가진 두 개의 패스 주소 지정과 다음과 같이 증가합니다.

imgPointer1 = &avg1[0][0];
imgPointer2 = &avg1[0][SIZE];
imgPointer3 = &avg1[0][SIZE+SIZE];

for(i=SIZE;i<totalSize-SIZE;i++){
    resPointer[i]=(*(imgPointer1++)+*(imgPointer2++)+*(imgPointer3++))/9;
}

8193: 938 ms
8192: 974 ms

1D 배열을 사용하고 다음과 같이 주소를 지정하는 두 번의 통과 :

for(i=SIZE;i<totalSize-SIZE;i++){
    resPointer[i]=(hsumPointer[i-SIZE]+hsumPointer[i]+hsumPointer[i+SIZE])/9;
}

8193: 932 ms
8192: 925 ms

한 번의 패스 캐싱 가로 행은 한 행 앞당겨서 캐시에 유지됩니다.

// Horizontal sums for the first two lines
for(i=1;i<SIZE*2;i++){
    hsumPointer[i]=imgPointer[i-1]+imgPointer[i]+imgPointer[i+1];
}
// Rest of the computation
for(;i<totalSize;i++){
    // Compute horizontal sum for next line
    hsumPointer[i]=imgPointer[i-1]+imgPointer[i]+imgPointer[i+1];
    // Final result
    resPointer[i-SIZE]=(hsumPointer[i-SIZE-SIZE]+hsumPointer[i-SIZE]+hsumPointer[i])/9;
}

8193: 599 ms
8192: 652 ms

결론:

  • 여러 개의 포인터를 사용하는 것의 이점이없고 증분 만합니다 (더 빠를 것이라고 생각했습니다)
  • 수평 합계를 캐싱하는 것이 여러 번 계산하는 것보다 낫습니다.
  • 2 패스는 3 배 빠르지 않고 2 회만 진행됩니다.
  • 단일 패스와 중간 결과 캐싱을 모두 사용하여 3.6 배 더 빠르게 달성 할 수 있습니다.

훨씬 더 잘 할 수 있다고 확신합니다.

참고 Mystical의 탁월한 답변에 설명 된 캐시 문제보다는 일반적인 성능 문제를 해결하기 위해이 답변을 작성했습니다. 처음에는 단지 의사 코드였습니다. 주석에서 테스트를 수행하라는 요청을 받았습니다 ... 여기 테스트가 포함 된 완전히 리팩토링 된 버전이 있습니다.


9
"적어도 3 배는 더 빠르다고 생각합니다."-일부 통계 나 인용으로 해당 주장을 뒷받침해야합니까?
— Adam Rosenfield

8
@AdamRosenfield "나는 생각한다"= 가정! = "그것은이다"= 주장. 나는 이것에 대한 메트릭이 없으며 테스트를보고 싶습니다. 그러나 광산은 픽셀 당 7 증분, 2 서브, 2 추가 및 1 div가 필요합니다. CPU에 레지스터보다 적은 로컬 var를 사용하는 각 루프. 다른 하나는 컴파일러 최적화에 따라 주소를 지정하기 위해 7 증분, 6 감소, 1 div 및 10 ~ 20 mul이 필요합니다. 또한 루프의 각 명령어는 이전 명령어의 결과를 필요로하므로 Pentium의 수퍼 스칼라 아키텍처의 이점을 무시합니다. 따라서 더 빨라야합니다.
— bokan

3
원래 질문에 대한 답은 메모리 및 캐시 효과에 관한 것입니다. OP의 코드가 너무 느린 이유는 메모리 액세스 패턴이 행이 아니라 열로 이동하기 때문입니다. 이는 캐시의 참조 위치가 매우 열악합니다. 연속 행이 직접 매핑 된 캐시 또는 낮은 연관성이있는 캐시에서 동일한 캐시 라인을 사용하므로 캐시 미스율이 훨씬 높아지기 때문에 8192 에서 특히 나쁩니다. 루프를 교환하면 캐시 위치가 크게 증가하여 성능이 크게 향상됩니다.
— Adam Rosenfield

1
잘하셨습니다, 그것들은 인상적인 숫자입니다. 아시다시피, 메모리 성능에 관한 것입니다. 증분과 함께 여러 포인터를 사용하면 아무런 이점이 없습니다.
— Adam Rosenfield

2
@AdamRosenfield 오늘 아침에 테스트를 재현 할 수 없어서 걱정이되었습니다. 성능 향상은 Visual C ++ 컴파일러에서만 발생하는 것으로 보입니다. gcc를 사용하면 약간의 차이가 있습니다.
— bokan
당사 사이트를 사용함과 동시에 당사의 쿠키 정책과 개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.