정확히 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.