512x512 매트릭스를 513x513 매트릭스를 바꾸는 것보다 훨씬 느린 이유는 무엇입니까?


218

크기가 다른 정사각 행렬에 대한 실험을 수행 한 후 패턴이 나타났습니다. 항상, 크기의 행렬을 전치하는 2^n것이 size의 크기를 전치하는 것보다 느립니다2^n+1 . 작은 값의 n경우 차이가 크지 않습니다.

그러나 512 이상의 값에서 큰 차이가 발생합니다. (적어도 저에게는)

면책 조항 : 함수가 요소의 이중 스왑으로 인해 실제로 행렬을 전치하지는 않지만 차이는 없습니다.

코드를 따릅니다.

#define SAMPLES 1000
#define MATSIZE 512

#include <time.h>
#include <iostream>
int mat[MATSIZE][MATSIZE];

void transpose()
{
   for ( int i = 0 ; i < MATSIZE ; i++ )
   for ( int j = 0 ; j < MATSIZE ; j++ )
   {
       int aux = mat[i][j];
       mat[i][j] = mat[j][i];
       mat[j][i] = aux;
   }
}

int main()
{
   //initialize matrix
   for ( int i = 0 ; i < MATSIZE ; i++ )
   for ( int j = 0 ; j < MATSIZE ; j++ )
       mat[i][j] = i+j;

   int t = clock();
   for ( int i = 0 ; i < SAMPLES ; i++ )
       transpose();
   int elapsed = clock() - t;

   std::cout << "Average for a matrix of " << MATSIZE << ": " << elapsed / SAMPLES;
}

MATSIZE크기를 바꾸면 크기를 바꿀 수 있습니다. 나는 ideone에 두 가지 버전을 게시했습니다.

내 환경 (MSVS 2010, 전체 최적화)에서 차이점은 비슷합니다.

  • 크기 512- 평균 2.19ms
  • 크기 513- 평균 0.57ms

왜 이런 일이 발생합니까?


9
귀하의 코드는 캐시가 비우호적 인 것처럼 보입니다.
코드 InChaos

7
이 질문과 거의 같은 문제입니다 : stackoverflow.com/questions/7905760/…
Mysticial

@CodesInChaos를 피 할까? (또는 다른 사람)
corazza September

@Bane 허용 된 답변을 읽는 것은 어떻습니까?
코드 InChaos

4
@nzomkxia 최적화없이 아무것도 측정하는 것은 무의미합니다. 최적화를 비활성화하면 생성 된 코드가 다른 병목 현상을 숨길 수있는 불필요한 쓰레기로 흩어집니다. (예 : 메모리)
Mysticial

답변:


197

설명은 Agner Fog의 C ++에서 소프트웨어 최적화의하고 캐시에 데이터를 액세스하고 저장하는 방법을 줄입니다.

용어 및 자세한 정보는 는 캐싱에 Wiki 항목을 하십시오. 여기에서 좁힐 것입니다.

캐시는 세트 로 구성 되며 라인으로 . 한 번에 하나의 세트 만 사용되며 그 중 포함 된 라인 중 하나를 사용할 수 있습니다. 한 줄에 여러 번 미러링 할 수있는 메모리는 우리에게 캐시 크기를 제공합니다.

특정 메모리 주소의 경우 수식을 사용하여 미러링해야하는 세트를 계산할 수 있습니다.

set = ( address / lineSize ) % numberOfsets

이러한 종류의 공식은 각 메모리 주소를 읽을 가능성이 높기 때문에 세트 전체에 균일하게 분포하는 것이 이상적입니다. 이상적입니다 이상적으로 했음 ).

중복이 발생할 수 있음이 분명합니다. 캐시가 누락 된 경우 캐시에서 메모리를 읽고 이전 값을 바꿉니다. 각 세트에는 여러 줄이 있으며, 그 중에서 가장 최근에 사용한 줄은 새로 읽은 메모리로 덮어 씁니다.

Agner의 예제를 다소 따르려고 노력할 것입니다.

각 세트에 4 개의 행이 있고 각각 64 바이트를 보유한다고 가정하십시오. 우리는 먼저 주소를 읽으려고 0x2710세트에 간다 28. 그리고 우리는 또한 주소를 읽으려고 0x2F00, 0x3700, 0x3F000x4700. 이들은 모두 같은 세트에 속합니다. 를 읽기 전에 0x4700세트의 모든 라인이 채워 졌을 것입니다. 메모리를 읽으면 세트의 기존 줄, 처음에 유지했던 줄을 제거합니다 0x2710. 문제는 (이 예에서는) 0x800별개의 주소를 읽는다는 사실에 있습니다 . 이것이 중요한 보폭입니다 (이 예에서는 다시 한 번).

중요한 보폭도 계산할 수 있습니다.

criticalStride = numberOfSets * lineSize

간격이 criticalStride같거나 여러 변수 가 같은 캐시 라인에 대해 경쟁합니다.

이것이 이론 부분입니다. 다음으로 설명 (Agner, 실수를 피하기 위해 자세히 따르고 있습니다) :

8kb 캐시, 세트당 4 줄 * 64 바이트의 행 크기를 갖는 64x64의 행렬 (캐시에 따라 효과가 다름을 기억하십시오)을 가정하십시오. 각 라인은 매트릭스 (64 비트 int) 에서 8 개의 요소를 보유 할 수 있습니다 .

중요한 보폭은 2048 바이트이며, 이는 메모리에서 연속적인 매트릭스의 4 행에 해당합니다.

행 28을 처리한다고 가정합니다.이 행의 요소를 가져 와서 열 28의 요소와 바꾸려고합니다. 행의 처음 8 개 요소는 캐시 행을 구성하지만 8 개의 다른 행으로 이동합니다. 임계 보폭은 4 행 떨어져 있습니다 (열에 4 개의 연속 요소).

열에서 요소 16에 도달하면 (세트당 4 개의 캐시 라인 및 4 개의 행 간격 = 문제) ex-0 요소가 캐시에서 제거됩니다. 열 끝에 도달하면 이전의 모든 캐시 라인이 손실되고 다음 요소에 액세스 할 때 다시로드해야합니다 (전체 라인을 덮어 씁니다).

중요한 보폭의 배수가 아닌 크기를 가지면 더 이상 수직에서 중요한 보폭 요소를 다루지 않으므로 캐시 재로드 횟수가 크게 줄어들 기 때문에 재난에 대한 완벽한 시나리오 를 망칠 수 있습니다.

또 다른 면책 조항 -나는 방금 설명에 머리를 썼고 그것을 이해하기를 희망하지만 실수 할 수 있습니다. 어쨌든, 나는 Mysticial 의 응답 (또는 확인)을 기다리고 있습니다. :)


아 그리고 다음에. 라운지를 통해 나를 직접 핑 . SO에서 모든 이름의 인스턴스를 찾지 못했습니다. :) 나는 정기적 인 이메일 알림을 통해서만 이것을 보았다.
Mysticial

내 친구의 @Mysticial @Luchian 고르 하나는 그의 하더군요 Intel core i3의 PC가 실행하는 Ubuntu 11.04 i386거의 동일한 성능을 보여 GCC 4.6 그래서 내 컴퓨터에 대해 동일 수다 좋은 Intel Core 2 Duo와 Mingw gcc4.4 에서 실행중인, windows 7(32)때 큰 차이를 보이지 않습니다 .IT 좀 오래된 PC와이 세그먼트를 컴파일 intel centrinoGCC 4.6 에 실행중인, ubuntu 12.04 i386.
Hongxu Chen

또한 주소가 4096의 배수로 다른 메모리 액세스는 Intel SnB 제품군 CPU에 대해 잘못된 종속성을 갖습니다. (즉, 페이지 내에서 동일한 오프셋). 이는 일부 작업이 상점 일 때 처리량을 줄일 수 있습니다. 짐과 상점의 혼합.
Peter Cordes

which goes in set 24대신 " 세트 28 " 을 의미 했 습니까? 그리고 당신은 32 세트를 가정합니까?
Ruslan

당신이 올바른지, 그것은 28입니다 :) 나는 또한 9.2 캐시 조직으로 이동할 수 있습니다 원래의 설명, 링크 된 종이를 두 번 확인
Luchian 고르

78

Luchian 은이 동작이 발생 하는 이유에 대한 설명을 제공 하지만이 문제에 대한 하나의 가능한 솔루션을 표시하는 동시에 캐시 무의식 알고리즘에 대한 정보를 제공하는 것이 좋습니다.

알고리즘은 기본적으로 다음을 수행합니다.

for (int i = 0; i < N; i++) 
   for (int j = 0; j < N; j++) 
        A[j][i] = A[i][j];

이것은 현대 CPU에 끔찍한 일입니다. 한 가지 해결책은 캐시 시스템에 대한 세부 사항을 알고 이러한 문제점을 피하기 위해 알고리즘을 조정하는 것입니다. 당신이 그 세부 사항을 알고있는 한 잘 작동합니다.

그보다 더 잘할 수 있습니까? 예, 우리는 할 수 있습니다 :이 문제에 대한 일반적인 접근 방식 은 이름에서 알 수 있듯이 특정 캐시 크기에 의존 하지 않는 캐시 인식 알고리즘 입니다 [1]

해결책은 다음과 같습니다.

void recursiveTranspose(int i0, int i1, int j0, int j1) {
    int di = i1 - i0, dj = j1 - j0;
    const int LEAFSIZE = 32; // well ok caching still affects this one here
    if (di >= dj && di > LEAFSIZE) {
        int im = (i0 + i1) / 2;
        recursiveTranspose(i0, im, j0, j1);
        recursiveTranspose(im, i1, j0, j1);
    } else if (dj > LEAFSIZE) {
        int jm = (j0 + j1) / 2;
        recursiveTranspose(i0, i1, j0, jm);
        recursiveTranspose(i0, i1, jm, j1);
    } else {
    for (int i = i0; i < i1; i++ )
        for (int j = j0; j < j1; j++ )
            mat[j][i] = mat[i][j];
    }
}

약간 더 복잡하지만 짧은 테스트를 통해 VS2010 x64 릴리스가있는 고대 e8400에서 매우 흥미로운 것을 보여줍니다. MATSIZE 8192

int main() {
    LARGE_INTEGER start, end, freq;
    QueryPerformanceFrequency(&freq);
    QueryPerformanceCounter(&start);
    recursiveTranspose(0, MATSIZE, 0, MATSIZE);
    QueryPerformanceCounter(&end);
    printf("recursive: %.2fms\n", (end.QuadPart - start.QuadPart) / (double(freq.QuadPart) / 1000));

    QueryPerformanceCounter(&start);
    transpose();
    QueryPerformanceCounter(&end);
    printf("iterative: %.2fms\n", (end.QuadPart - start.QuadPart) / (double(freq.QuadPart) / 1000));
    return 0;
}

results: 
recursive: 480.58ms
iterative: 3678.46ms

편집 : 크기의 영향에 관하여 : 어느 정도 눈에 띄게 보이지만 반복 발음을 1로 재귀 대신 리프 노드로 사용하기 때문에 (재귀 알고리즘의 일반적인 최적화) 훨씬 덜 두드러집니다. LEAFSIZE = 1로 설정하면, 캐시는 나에게 영향을 미치지 않습니다 8193: 1214.06; 8192: 1171.62ms, 8191: 1351.07ms. 이 "벤치 마크"는 우리가 완전히 정확한 값을 원한다면 너무 편한 것이 아닙니다.])

[1] 이것에 대한 출처 : 글쎄, 만약 당신이 Leiserson과 공동으로 일한 누군가로부터 강의를받을 수 없다면 .. 나는 그들의 논문이 좋은 출발점이라고 가정합니다. 이러한 알고리즘은 여전히 ​​거의 설명되지 않습니다. CLR에는 이에 대한 단일 각주가 있습니다. 여전히 사람들을 놀라게하는 좋은 방법입니다.


편집 (참고 : 나는이 답변을 게시 한 사람이 아니며 단지 이것을 추가하고 싶었습니다) :
위 코드의 완전한 C ++ 버전은 다음과 같습니다.

template<class InIt, class OutIt>
void transpose(InIt const input, OutIt const output,
    size_t const rows, size_t const columns,
    size_t const r1 = 0, size_t const c1 = 0,
    size_t r2 = ~(size_t) 0, size_t c2 = ~(size_t) 0,
    size_t const leaf = 0x20)
{
    if (!~c2) { c2 = columns - c1; }
    if (!~r2) { r2 = rows - r1; }
    size_t const di = r2 - r1, dj = c2 - c1;
    if (di >= dj && di > leaf)
    {
        transpose(input, output, rows, columns, r1, c1, (r1 + r2) / 2, c2);
        transpose(input, output, rows, columns, (r1 + r2) / 2, c1, r2, c2);
    }
    else if (dj > leaf)
    {
        transpose(input, output, rows, columns, r1, c1, r2, (c1 + c2) / 2);
        transpose(input, output, rows, columns, r1, (c1 + c2) / 2, r2, c2);
    }
    else
    {
        for (ptrdiff_t i1 = (ptrdiff_t) r1, i2 = (ptrdiff_t) (i1 * columns);
            i1 < (ptrdiff_t) r2; ++i1, i2 += (ptrdiff_t) columns)
        {
            for (ptrdiff_t j1 = (ptrdiff_t) c1, j2 = (ptrdiff_t) (j1 * rows);
                j1 < (ptrdiff_t) c2; ++j1, j2 += (ptrdiff_t) rows)
            {
                output[j2 + i1] = input[i2 + j1];
            }
        }
    }
}

2
크기가 다른 행렬 사이의 시간을 재귀적이고 반복적이지 않은 시간으로 비교 한 경우이 방법이 적합합니다. 지정된 크기의 행렬에서 재귀 솔루션을 사용해보십시오.
Luchian Grigore

@Luchian 이미 동작을보고있는 이유 를 설명 했으므로이 문제에 대한 한 가지 해결책을 일반적으로 소개하는 것이 흥미 롭다고 생각했습니다.
Voo

더 빠른 알고리즘을 찾지 않고 더 큰 매트릭스를 처리하는 데 더 짧은 시간이 걸리는 이유에 대해 의문을 제기하고 있습니다.
Luchian Grigore

@Luchian 16383과 16384의 차이점은 여기에서 28. 27ms 또는 약 3.5 %입니다. 그리고 그것이 있다면 놀라게 될 것입니다.
Voo

3
작은 타일 ( 차원) recursiveTranspose에서 작업하여 캐시를 많이 채우지 않는다는 것을 설명하는 것이 흥미로울 수 있습니다 . LEAFSIZE x LEAFSIZE
Matthieu M.

60

Luchian Grigore의 답변에 대한 설명 으로 64x64 및 65x65 매트릭스의 두 경우에 대한 매트릭스 캐시의 존재는 다음과 같습니다 (숫자에 대한 자세한 내용은 위의 링크 참조).

아래 애니메이션의 색상은 다음을 의미합니다.

  • 하얀 – 캐시가 아닌
  • 연한 초록색 – 캐시에서
  • 밝은 녹색 – 캐시 적중
  • 주황색 – RAM에서 읽기만하면
  • 빨간 – 캐시 미스.

64x64 사례 :

64x64 매트릭스 용 캐시 존재 애니메이션

새 행에 대한 거의 모든 액세스로 인해 캐시 누락이 발생합니다. 이제 일반적인 경우 인 65x65 매트릭스를 찾는 방법은 다음과 같습니다.

65x65 매트릭스 용 캐시 존재 애니메이션

여기에서 초기 예열 후 대부분의 액세스가 캐시 적중임을 알 수 있습니다. CPU 캐시가 일반적으로 작동하는 방식입니다.


위의 애니메이션에 대한 프레임을 생성 한 코드는 여기에서 볼 수 있습니다 .


수직 스캐닝 캐시 적중이 첫 번째 경우에는 저장되지 않지만 두 번째 경우에는 왜 저장됩니까? 두 블록 모두에서 주어진 블록이 대부분의 블록에 대해 정확히 한 번만 액세스되는 것처럼 보입니다.
Josiah Yoder

@LuchianGrigore의 답변에서 열의 모든 줄이 동일한 세트에 속하기 때문입니다.
Josiah Yoder

예, 훌륭합니다. 나는 그들이 같은 속도에 있다는 것을 안다. 그러나 실제로는 그렇지 않습니까?
kelalaka

@kelalaka 예, 애니메이션 FPS는 동일합니다. 나는 둔화를 시뮬레이션하지 않았으며 여기서는 색상 만 중요합니다.
Ruslan

다른 캐시 세트를 나타내는 두 개의 정적 이미지를 갖는 것이 흥미로울 것입니다.
Josiah Yoder
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.