2048x2048과 2047x2047의 배열 곱셈에서 성능이 크게 저하되는 이유는 무엇입니까?


127

MATLAB이 왜 행렬 곱셈에서 그렇게 빠른가?에서 언급 한 것처럼 행렬 곱셈 벤치마킹을하고 있습니다 .

이제 두 개의 2048x2048 행렬을 곱할 때 또 다른 문제가 있습니다 .C #과 다른 것 사이에는 큰 차이가 있습니다. 2047x2047 행렬 만 곱하려고하면 정상적인 것 같습니다. 비교를 위해 다른 것들도 추가했습니다.

1024x1024-10 초

1027x1027-10 초

2047x2047-90 초

2048x2048-300 초

2049x2049-91 초 (최신 정보)

2500x2500-166 초

2k x 2k의 경우 3 분 반 차이입니다.

2 차원 배열 사용

//Array init like this
int rozmer = 2048;
float[,] matice = new float[rozmer, rozmer];

//Main multiply code
for(int j = 0; j < rozmer; j++)
{
   for (int k = 0; k < rozmer; k++)
   {
     float temp = 0;
     for (int m = 0; m < rozmer; m++)
     {
       temp = temp + matice1[j,m] * matice2[m,k];
     }
     matice3[j, k] = temp;
   }
 }

23
이것은 고급 레벨 C 프로그래밍 또는 OS 디자인 클래스를위한 훌륭한 시험 문제입니다. ;-)
Dana the Sane

32 및 64 비트뿐만 아니라 다차원 [,] 및 들쭉날쭉 한 [] [] 배열을 모두 테스트 해 보셨습니까? 나는 몇 번만 테스트했지만 들쭉날쭉 한 결과와 더 일치하는 것처럼 보였지만 들쭉날쭉 한 64 비트는 높았습니다.이 상황에 적용되는 지트에 휴리스틱이 있는지 또는 이전에 제안 된 캐시와 관련이 있는지 모르겠습니다. GPGPU 솔루션을 원하는 경우 research.microsoft.com/en-us/projects/accelerator 가 있으며 다른 게시물의 시간과 경쟁해야합니다.
Kris

다소 순진한 질문이지만 두 개의 제곱 행렬을 곱하는 데 몇 개의 op (더하기 / 곱하기)가 포함됩니까?
Nick T

답변:


61

이것은 아마도 L2 캐시의 충돌과 관련이 있습니다.

matice1의 캐시 누락은 순차적으로 액세스되므로 문제가되지 않습니다. 그러나 matice2의 경우 전체 열이 L2에 맞는 경우 (예 : matice2 [0, 0], matice2 [1, 0], matice2 [2, 0] ... 등에 액세스 할 때 아무런 문제가 없음) matice2로 캐시가 누락되었습니다.

변수의 바이트 주소가 X 인 경우 캐시가 작동하는 방식을 자세히 살펴 보려면 캐시 라인보다 (X >> 6) & (L-1)이됩니다. 여기서 L은 캐시의 총 캐시 라인 수입니다. L은 항상 2의 거듭 제곱입니다. 6은 2 ^ 6 == 64 바이트가 표준 캐시 크기입니다.

이것이 무엇을 의미합니까? 주소 X와 주소 Y를 가지고 있고 (X >> 6)-(Y >> 6)을 L로 나눌 수 있으면 (즉, 2의 큰 거듭 제곱) 동일한 캐시 라인에 저장됩니다.

이제 문제로 돌아가려면 2048과 2049의 차이점은 무엇입니까?

2048이 당신의 크기 일 때 :

& matice2 [x, k] 및 & matice2 [y, k]를 취하면 차이 (& matice2 [x, k] >> 6)-(& matice2 [y, k] >> 6)는 2048 * 4 (크기)로 나눌 수 있습니다. 플로트). 따라서 2의 큰 힘.

따라서 L2의 크기에 따라 많은 캐시 라인 충돌이 발생하고 L2의 작은 부분 만 사용하여 열을 저장하므로 실제로 전체 열을 캐시에 저장할 수 없으므로 성능이 저하됩니다. .

크기가 2049 인 경우 차이는 2049 * 4이며 2의 거듭 제곱이 아니므로 충돌이 줄어들고 열이 캐시에 안전하게 맞습니다.

이제이 이론을 테스트하기 위해 할 수있는 몇 가지가 있습니다.

이 matice2 [razmor, 4096]와 같이 배열 matice2 배열을 할당하고 razmor = 1024, 1025 또는 모든 크기로 실행하면 이전에 비해 성능이 매우 저하됩니다. 모든 열이 서로 충돌하도록 강제로 정렬하기 때문입니다.

그런 다음 matice2 [razmor, 4097]를 시도하고 어떤 크기로든 실행하면 훨씬 더 나은 성능을 볼 수 있습니다.


마지막 두 단락에서 실수를 했습니까? 두 시도는 모두 동일합니다. :)
Xeo

캐시 연관성 도 중요한 역할을합니다.
벤 잭슨

20

캐싱 효과 일 것입니다. 2의 거듭 제곱 인 행렬 크기와 2의 거듭 제곱 인 캐시 크기를 사용하면 L1 캐시의 작은 부분 만 사용하여 결과를 크게 저하시킬 수 있습니다. 순진한 행렬 곱셈은 일반적으로 데이터를 캐시로 가져와야 할 필요성에 의해 제한됩니다. 타일링 (또는 캐시 불명확 한 알고리즘)을 사용하는 최적화 된 알고리즘은 L1 캐시를보다 잘 사용하는 데 중점을 둡니다.

다른 쌍 (2 ^ n-1,2 ^ n)의 시간을 정하면 비슷한 효과가 나타납니다.

matice2 [m, k]에 액세스하는 내부 루프에서 좀 더 자세히 설명하기 위해 matice2 [m, k]와 matice2 [m + 1, k]가 2048 * sizeof (float)만큼 서로 상쇄 될 수 있습니다. 따라서 L1 캐시에서 동일한 인덱스에 매핑됩니다. N-way 연관 캐시를 사용하면 일반적으로 이들 모두에 대해 1-8 개의 캐시 위치가 있습니다. 따라서 거의 모든 액세스가 L1 캐시 제거를 트리거하고 느린 캐시 또는 주 메모리에서 데이터를 가져옵니다.


+1. 가능성이 높습니다. 캐시 연관성에주의해야합니다.
Macke

16

이것은 CPU 캐시 크기와 관련이있을 수 있습니다. 행렬 행렬의 2 행이 맞지 않으면 RAM에서 요소를 교체하는 시간이 느려집니다. 여분의 4095 요소는 열이 맞지 않도록하기에 충분할 수 있습니다.

귀하의 경우 2047 2d 매트릭스의 2 행은 16KB 메모리 (32 비트 유형으로 가정)에 속합니다. 예를 들어 LKB 캐시 (버스의 CPU에 가장 가까운)가 64KB 인 경우 한 번에 최소 4 개의 행 (2047 * 32)을 캐시에 넣을 수 있습니다. 더 긴 행으로 16KB 이상으로 행 쌍을 푸시하는 패딩이 필요한 경우 상황이 복잡해지기 시작합니다. 또한 캐시를 '누락'할 때마다 다른 캐시 또는 주 메모리에서 데이터를 교환하면 작업이 지연됩니다.

내 생각에 다른 크기의 행렬로 보는 런타임의 차이는 운영 체제가 사용 가능한 캐시를 얼마나 효과적으로 사용할 수 있는지에 영향을받습니다 (일부 조합은 문제가 있습니다). 물론 이것은 모두 내 단순화입니다.


2
그러나 그가
16.7MB

2049x2049-91 초로 결과를 업데이트했습니다. "캐시 문제"인 경우 여전히 300+이어야합니까?
늑대

@Marino 답변을 고려하여 업데이트되었습니다.
Dana the Sane

1
나는 이러한 설명 중 어느 것도 문제를 유발하는 다양하고 드문 크기에 관한 새로운 세부 사항을 적절하게 다루지 못한다고 생각합니다.
Ken Rockot

2
나는이 설명이 정확하다고 생각하지 않습니다. 문제는 크기가 2 일 때 캐시 라인 충돌로 인해 캐시 용량을 완전히 활용하지 않는 데 있습니다. 또한 운영 체제는 캐시 할 대상과 제거 할 대상을 결정하는 OS가 아니기 때문에 캐시와는 전혀 관련이 없습니다. 하드웨어에서. OS는 데이터 정렬과 관련이 있지만이 경우 C #이 데이터를 할당하는 방법과 메모리에서 2D 배열을 나타내는 방법에 관한 것입니다 .OS는 관련이 없습니다.
zviadm


5

시간이 더 큰 크기로 떨어지고 있다고 가정 할 때, 특히 문제가있는 매트릭스 크기에 대해 2의 거듭 제곱으로 인해 캐시 충돌이 발생할 가능성이 더 적습니까? 캐싱 문제에 대한 전문가는 아니지만 캐시 관련 성능 문제에 대한 훌륭한 정보는 여기에 있습니다 .


캐시 연관성에 대한 링크의 섹션 5가 특히 적용되는 것 같습니다.
Dana the Sane

4

matice2어레이를 세로 로 액세스 하면 캐시에서 더 많이 스왑 및 스왑됩니다. 배열을 대각선으로 미러링하여 [k,m]대신에 사용하여 액세스 할 수 있으면 [m,k]코드가 훨씬 빠르게 실행됩니다.

나는 이것을 1024x1024 행렬에 대해 테스트했으며 약 두 배 빠릅니다. 2048x2048 매트릭스의 경우 약 10 배 빠릅니다.


이것은 2049가 2048보다 빠른 이유를 설명하지 않습니다.
Macke

@Macke : 메모리 캐싱의 한계를 초과하여 캐시 누락이 훨씬 많기 때문입니다.
Guffa

왜 공감해야합니까? 잘못되었다고 생각하지 않으면 답변을 개선 할 수 없습니다.
Guffa

설명이없는 또 다른 다운 보트 ... 내 대답에 가장 많이 찬성하는 답과 같이 "아마도", "추측"및 "해야한다"가 너무 적습니까?
구파

4

캐시 앨리어싱

또는 캐시 탈곡 , 나는 용어를 화폐로 주조 할 수 있습니다.

캐시는 하위 비트로 인덱싱하고 상위 비트로 태그를 지정하여 작동합니다.

캐시에 4 워드가 있고 행렬이 4 x 4임을 이미징합니다. 열에 액세스하고 행의 길이가 2의 거듭 제곱이면 메모리의 각 열 요소가 동일한 캐시 요소에 매핑됩니다.

2의 거듭 제곱은 실제로이 문제에 최적입니다. 각각의 새 열 요소는 행별로 액세스하는 것처럼 다음 캐시 슬롯에 정확하게 매핑됩니다.

실제로, 태그는 연속적으로 증가하는 여러 개의 주소를 다루며,이 주소는 여러 인접 요소를 연속적으로 캐시합니다. 각각의 새 행이 매핑되는 버킷을 오프셋하면 열을 통과해도 이전 항목이 바뀌지 않습니다. 다음 열이 순회되면 전체 캐시가 다른 행으로 채워지고 캐시에 맞는 각 행 섹션이 여러 열에 도달합니다.

캐시는 DRAM보다 훨씬 빠르기 때문에 (주로 온칩이기 때문에) 적중률이 가장 중요합니다.


2

캐시 크기 제한에 도달했거나 타이밍에서 반복성에 문제가있는 것 같습니다.

문제가 무엇이든간에 C #에서 행렬 곱셈을 직접 작성하지 말고 BLAS의 최적화 된 버전을 사용해야합니다. 현대식 기계에서는 그 크기의 행렬을 1 초 안에 곱해야합니다.


1
BLAS를 알고 있지만 작업을 최대한 빨리 수행하는 것이 아니라 다양한 언어로 작성하고 테스트하는 것이 었습니다. 이것은 나에게 매우 이상한 문제이며, 결과가 왜 그런지 궁금합니다.
늑대

3
@ 늑대 나는 1 초가 걸리는 것이 90 초 또는 300 초가 걸리는지 흥분하기가 어렵다는 것을 알았습니다.
David Heffernan

4
어떻게 작동하는지 배우는 가장 좋은 방법은 직접 작성하고 구현을 개선 할 수있는 방법을 보는 것입니다. 이것이 늑대가하는 일입니다.
Callum Rogers

@Callum Rogers는 동의했다. 이것이 파일 복사 작업에서 버퍼 크기의 중요성을 알게 된 방법입니다.
켈리 S. 프랑스

1

캐시 계층을 효과적으로 활용하는 것이 매우 중요합니다. 다차원 배열에 데이터가 잘 정리되어 있는지 확인해야합니다.이 배열은 타일링 하여 수행 할 수 있습니다 . 이렇게하려면 인덱싱 메커니즘과 함께 2D 배열을 1D 배열로 저장해야합니다. 기존 방법의 문제점은 동일한 행에있는 두 개의 인접한 배열 요소가 메모리에서 서로 옆에 있지만 동일한 열의 두 개의 인접한 요소는 메모리의 W 요소 로 구분되며 여기서 W 는 열 수입니다. . 타일링은 10 배의 성능 차이를 만들 수 있습니다.


흠-2D (float [,] matice = new float [rozmer, rozmer];)로 선언 된 배열은 RAM에서 1 차원 배열로만 할당되고 행 / 스트라이드 계산은 후드에서 수행됩니다. 그렇다면 왜 그것을 1D로 선언하고 수동 행 / 스트라이드 계산을 수행하는 것이 더 빠를까요? sol'n은 큰 배열을 작은 배열의 배열로 할당한다는 것을 의미합니까?
Eric M

1
라이브러리 또는 사용중인 도구가 타일링을 수행하는 경우 필요하지 않습니다. 그러나 C / C ++에서 전통적인 2D 어레이를 사용한다면 타일링은 성능을 향상시킵니다.
Arlen

0

나는 그것이 " Sequential Flooding " . 이것은 캐시 크기보다 약간 큰 객체 목록을 반복하려고하므로 목록 (배열)에 대한 모든 단일 요청을 램에서 수행해야하며 단일 캐시를 얻지 못합니다. 히트.

귀하의 경우 배열 2048 인덱스를 2048 번 반복하지만 배열 구조의 오버 헤드로 인해 2047에 대한 공간 만 있으므로 배열 pos에 액세스 할 때 마다이 배열 pos를 가져와야합니다. 램에서. 그런 다음 캐시에 저장되지만 다시 사용하기 직전에 덤프됩니다. 따라서 캐시는 본질적으로 쓸모가 없으므로 실행 시간이 훨씬 길어집니다.


1
잘못되었습니다. 2049는 2048보다 빠르므로 귀하의 주장을 반박합니다.
Macke

@Macke : 가능합니다. 그러나 프로세서에 사용 된 캐시 정책이 여전히 이러한 결정을 내릴 가능성이 약간 있습니다. 그럴 가능성은 없지만 생각할 수있는 것은 아닙니다.
Automatico
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.