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.