SIMD 병렬 프로그래밍
SIMD는 Single Instruction Multiple Data의 줄임말로, 하나의 명령어로 여러개의 데이터를 한번에 처리하는 병렬화하는 프로그래밍 방법을 소개합니다. SIMD는 동영상 렌더링 처리와 같이 대용량데이터처리에 효과적입니다.
SIMD는 Single Instruction Multiple Data의 약자로, 하나의 명령어로 여러 개의 데이터를 동시에 처리하는 기법입니다. 일반적인 프로그램은 대부분 SISD(Single Instruction Single Data) 방식으로 구현되며, 이는 기본적인 폰 노이만 컴퓨터가 사용하는 방식입니다. 하지만 동영상 인코딩, 그래픽 렌더링 등의 작업에서 SIMD를 적용하면 성능적으로 큰 이점을 얻을 수 있습니다.
CPU-Z와 같은 프로그램을 통해 CPU가 지원하는 명령어 셋을 확인할 수 있는데, Intel의 경우 MMX(SSE2), AMD의 경우 3D Now! 같은 기술들이 SIMD를 의미합니다.
SISD VS SIMD
위의 예는 우리가 일반적으로 사용하는 SISD 방식과 이제부터 살펴볼 SIMD 연산 간의 차이를 나타냅니다. Vector4 + Vector4 연산을 간단하게 SISD와 SIMD로 표현하면 다음과 같습니다.
// SISD
var a = [1, 2, 3, 4];
var b = [5, 6, 7, 8];
var c = [];
c[0] = a[0] + b[0];
c[1] = a[1] + b[1];
c[2] = a[2] + b[2];
c[3] = a[3] + b[3];
c; // Array[6, 8, 10, 12]
// SIMD
var a = SIMD.Float32x4(1, 2, 3, 4);
var b = SIMD.Float32x4(5, 6, 7, 8);
var c = SIMD.Float32x4.add(a,b); // Float32x4[6, 8, 10, 12]
게임을 만들기 위해서는 벡터 및 행렬 연산을 피할 수 없습니다. 그렇기 때문에 SIMD를 활용해 코드를 작성하는 것이 유리합니다. 하지만 이는 쉽지 않은 작업일 것입니다. 다행히도, DirectX와 OpenGL과 같은 대표적인 그래픽 라이브러리는 SIMD를 지원합니다.
- DirectX 11부터 제공되는 XNAMath 라이브러리는 SSE2(SIMD) 명령어 셋을 이용해 구현되었습니다.
- 예를 들어, XMLoadFloat3, XMVector3Transform 등의 함수가 이에 해당합니다.
이를 통해 개발자는 벡터 및 행렬 연산을 보다 효율적으로 수행할 수 있으며, 성능을 최적화할 수 있습니다. 이러한 라이브러리를 활용하면 직접 SIMD 코드를 작성하는 것보다 더 쉽게 성능 향상을 기대할 수 있습니다.
어떻게 구현하나요?
SIMD는 직접 어셈블리로 구현하거나 Intrinsic Function을 이용하여 구현할 수 있습니다. Intrinsic Function은 인라인 함수로, 함수의 형태를 가지고 있지만 어셈블리 명령어와 1:1로 매칭되어 보다 쉽게 SSE를 이용할 수 있게 해주는 내장 함수입니다. 어셈블리어로 코드를 작성하는 것은 많은 사람들에게 쉽지 않은 일이기 때문에, 여기서는 Intrinsic Function을 이용해 코드를 작성하는 방법을 다루겠습니다.
그러나 Intrinsic Function을 사용하면 직접 어셈블리어로 코드를 작성하는 것보다 불필요한 명령어들이 조금 더 생성될 수 있다는 점을 유의해야 합니다. 이를 통해 SIMD의 이점을 살리면서도 보다 쉽게 고성능 코드를 작성할 수 있습니다.
프로그래머가 코드를 SIMD로 쉽게 변환할 수 있도록 작성했다면, 최신 컴파일러는 설정에 따라 자동으로 SIMD 명령어를 포함한 코드로 변환해줍니다.
또한 SIMD를 제대로 활용하려면 정렬된 메모리(aligned memory)를 사용해야 합니다. 정렬된 메모리가 무엇인지 다음 코드 예제를 통해 주석으로 설명하겠습니다.
short a, b, c, d; // (1) Not aligned
short a[4]; // (2) Not Aligned
__declspec(align(32)) short a[4]; // (3) Aligned
(1)의 경우에는 어느 정도 이해가 간다고 생각합니다. 그러나 (2)는 왜 정렬되지 않았다고, (3)은 왜 정렬되었다고 표현하는 걸까요? 다음 그림을 봅시다.
그림에서 보시는 바와 같이 “Align, 메모리 정렬”의 정의는 메모리 시작 지점을 지정된 숫자의 배수로 맞추고, 내부 원소 각각의 크기를 지정된 크기로 맞추는 것을 의미합니다.
Intrinsic Function
- 위의 제목을 클릭하시면 Intel에서 제공하는 Intrinsic Function에 관한 document로 연결됩니다.
Intrinsic 함수의 명명법은 위의 그림과 같습니다.
저는 위의 함수 목록을 참고하여 두 배열에서 각 항목의 최댓값을 구하는 코드를 SISD, SIMD로 작성하려고 합니다.
const int n = 1000000000;
__m128i a, b, r;
__declspec(align(16)) short v1[8] = { 1, 2, 3, 4, 5, 6, 7, 8 };
__declspec(align(16)) short v2[8] = { 8, 1, 7, 2, 6, 3, 5, 4 };
__declspec(align(16)) short result[8];
// SISD
for (int i = 0; i < n; ++i) {
result[0] = v1[0] > v2[0] ? v1[0] : v2[0];
result[1] = v1[1] > v2[1] ? v1[1] : v2[1];
result[2] = v1[2] > v2[2] ? v1[2] : v2[2];
result[3] = v1[3] > v2[3] ? v1[3] : v2[3];
result[4] = v1[4] > v2[4] ? v1[4] : v2[4];
result[5] = v1[5] > v2[5] ? v1[5] : v2[5];
result[6] = v1[6] > v2[6] ? v1[6] : v2[6];
result[7] = v1[7] > v2[7] ? v1[7] : v2[7];
}
// SIMD
for (int i = 0; i < n; ++i) {
a = _mm_loadu_si128((__m128i *)v1);
b = _mm_loadu_si128((__m128i *)v2);
r = _mm_max_epi16(a, b);
_mm_storeu_si128((__m128i *)result, r);
}
결과물을 보면 더 놀랍습니다.
정리
이해하기 어렵고 공부해야 할 내용이 많은 주제이기 때문에, 제대로 설명이 됐는지 모르겠네요. 예제도 다소 빈약했지만, 이 글은 SIMD 기초의 일부만 다룬 것이며, 훨씬 더 깊이 있는 내용을 공부해야 하는 분야입니다.
사실, 라이브러리나 프레임워크를 개발하는 분이 아니라면 이렇게까지 최적화할 필요가 있을까 싶기도 합니다. 그러나 앞으로 SIMD는 더 많은 곳에서 활용될 것이라고 생각합니다. 또한, 최적화 할 부분을 잘 판단하여 적용한다면 어렵지만 정말로 큰 성과를 얻을 수 있을 것입니다.
Reference
SIMD 병렬 프로그래밍
SIMD 병렬 프로그래밍이 무엇인지 알아봅시다.