Streaming Through Caches
벡터화가 효율적으로 수행되기 위해서는 과도한 오버헤드 없이 벡터 명령문을 통해 데이터를 주고 받을 수 있어야 한다. 데이터 이동의 효율성은 데이터 레이아웃, 정렬, 프리페칭 및 효율적인 저장 작업에 달려 있으며 컴파일러에 의해 자동적으로 생성된 비효율성을 찾아내기 위해서는 개발자가 코드를 직접 분석해야 한다.
Why data layout affects vectorization performance?
벡터의 병렬 처리는 데이터 요소의 여러 페어(FMA 사용시 트리플렛(triplet))에 대해 똑같은 연산을 동시에 수행하면서 발생한다. AVX-512 벡터 레지스터(ZMM 레지스터)의 활용 개념을 설명해주며, 이는 레지스터의 정확한 폭(512비트)만 제외한다면 AVX-512가 아닌 벡터 레지스터의 활용에도 적용될 수 있다. AVX-512는 벡터 레지스터의 폭이 넓고 전체 메모리 대역폭이 커서 보다 고성능의 벡터 수준 병렬 처리를 수행할 수 있을 뿐 아니라, 애플리케이션이 올바른 방법으로 프로그래밍되어있다면 코드의 수정 없이도 인텔 MKL(Math Kernel Library)이나 컴파일러를 통해 사용할 수 있다.
위 그림은 각각 16개의 단정밀도(single-precision) 부동소수점 값을 가진 두 개의 레지스터에서 벡터 뎃셈이 어떻게 수행되는지를 보여주고 있다. 이 계산 작업을 끝내려면 예를 들어, 메인 메모리로부터 두 개의 입력 레지스터에 데이터를 로드한 후 뎃셈 연산을 수행하는 일련의 과정이 필요한데, 최적의 성능으로 수행하기 위해서는 다음과 같은 몇 가지 이슈를 이해하고 있어야 한다.
- 메모리 내 데이터의 레이아웃, 정렬 및 패킹: 16개의 값이 입력 레지스터에 수집되어야 한다. 정렬된 메모리 위치에서 간단한 벡터 로드를 수행하면 최적의 성능을 얻을 수 있지만, 그럴 수 없는 경우는 우선 데이터를 순서대로 배열하여 메모리 상에 정렬시킬 수 있는지를 생각해 보아야 한다. 최적의 상태로 패킹 및 정렬되지 않은 데이터를 이용하여 벡터 연산을 수행하면, 벡터 연산에 사용하기 위해 레지스터에 데이터를 수집하고 구성하는 데 더 많은 명령어의 수행과 캐시(/메모리)에 대한 접근이 필요하게 되어 성능에 좋지 않은 영향을 미칠 수 있다. 명령어들은 가급적 최적의 순설로 사용되어야 하며, 그 순서의 제어를 컴파일러에 위임할 것인지 내장함수를 사용해 코드상에서 직접 수행할 것인지는 프로그래머가 선택할 수 있다.
- 데이터의 접근 빈도가 증가하거나 추가적인 명령어를 사용하면 응용 코드의 성능이 저하된다. gather나 scatter와 같이 데이터 접근을 필요로 하는 명령을 추가적으로 실행하는 경우가 이에 적합한 예이며, 데이터 레이아웃을 변경해 그 사용을 피해갈 수 있다면 그렇게 하는 것이 바람직하다. 컴파일러는 변수의 정렬을 명시하고 강제하는 데 사용할 수 있는 지시문과 명령 옵션을 제공한다.
- 데이터 로컬리티
- 메모리 대신 캐시에서 데이터를 가져온다. 데이터는 결국 메모리에서 읽어 오는 것이지만, 벡터로드에 필요한 데이터를 메모리에서 가져와 프로세서에 가장 가까운 곳(L1 캐시라고 알려진 1단계 저장 장소)에 저장한 상태에서 벡터 로드 명령을 호출하면 그 로딩 속도를 훨씬 빠르게 할 수 있다. 실제로 나이츠랜딩에서 데이터는 메모리(MCDRAM 또는 DDR)에서 L2 캐시를 통해 L1캐시로 이동하는데, 이는 ‘메모리에서 L2 캐시로의 프리페치’, ‘L2 캐시에서 L1 캐시로의 프리페치’, ‘로드 명령어를 사용한 L1캐시에서의 프리페치’의 순차적인 실행에 의해 최적의 상태로 수행될 수 있다. 데이터의 프리페치는 프로그래머가 수동으로 실행할 수 있고 컴파일러에 위임해 자동으로 실행할 수도 있다. L1 프리페치 명령어를 사용하면 메모리에서 L1 캐시로 데이터를 직접 이동시킬 수도 있지만 한 번에 동시 수행 가능한 연산의 수는 L2 프리페치와 비교해 매우 적다. 컴파일러에서 자동으로 프리페치를 수행할 수 있으며, 이를 돕기 위해 컴파일러에 힌드틀 제공할 수 있는 지시문들이 있다. mm_prefetch 인트린직(intrinsics, 내장함수)을 이용해 수동으로 프리페치를 수행할 수도 있다.
- 데이터의 재사용: 두 번 이상 접근해 재사용할 데이터를 캐시에 불러온 경우, 해당 데이터에 접근하기 위해서는 프로그램 내에서 최대한 가까운 순서로 실행되어야 하며, 이것이 바로 데이터 참조를 위한 로컬리티를 임시적으로 증가시킬 수 있는 방법이다. 데이터를 신속하게 재사용할 경우 해당 데이터가 캐시에서 제거되어 메모리로부터 다시 페치되는 상황을 피할 수 있기 때문에, 데이터 재사용률을 높이기 위해 소스 코드를 재정렬하는 것이 성능 최적화에 도움이 될 때가 종종 있다. 인텔 MKL은 라이브러리 루틴에서 캐시 블로킹을 사용하며, 데이터 재사용률을 높여 프로그램의 성능을 개선하는 데 큰 도움을 준다. 라이브러리를 사용하지 않고 명시적으로 코드를 작성한 경우에는 프로그래머가 직접 블로킹에 대해 고민해야 한다. 프로세서에서 보다 효율적인 캐시 블로킹을 수행할 수 있도록 지난 수십 년 동안 많은 연구를 해왔고, 그 결과를 기존의 프로세서와 동일하게 나이츠랜딩에도 적용할 수 있다는 사실은 좋은 소식이 아닐 수 없다. 캐시의 효율적인 활용은 분명 어려운 일이지만, 코드 실행에 필요한 전력 소비를 줄이고 실행 성능을 높이기 위해 캐시가 존재한다는 사실에 주목할 필요가 있다. 캐시를 최대한 효율적으로 활용하기 위해 프로그래밍 방식을 바꾸는 일은 개발자 입장에서 수고로울 수 있지만, 캐시가 없을 때에 비해 더 큰 성능상의 이점과 전력 효율성을 확보하기 위해 개발자가 감수해야 할 몫이다.
- 스트리밍 스토어 명령: 프로그램에서 다시 사용하지 않을 데이터를 연속된 메모리 공간에 저장한다면, 스트리밍 스토어 명령을 활용하는 것이 중요할 수 있다. 스트리밍 스토어 명령을 이용하면 데이터를 캐시가 아닌 메모리에 직접 쓸 수 있으며, 이를 통해 이미 캐시에 저장된 데이터의 재사용을 보장할 수 있다.