DuckingRacoon
+ SIMD란? 일반 연산과 SIMD의 연산 성능 실험 본문
🔎 SIMD란?
SIMD는 Single Instruction, Multiple Data의 약자로 하나의 명령어로 여러 개의 값은 동시에 계산하는 방식이다.
- 병렬 컴퓨팅의 한 종류
한 번에 여러 연산을 해야하는 벡터 연산, 행렬 연산에 유용하다.
🔨 SSE: SIMD 확장
SSE (Streaming SIMD Extensions)라 불리는 명령어 셋은 SIMD를 확장한 것으로 한 번에 여러 데이터를 처리할 수 있다.
128비트 너비의 레지스터를 사용할 수 있으며, 하나의 명령으로 128 비트만큼의 연산을 수행할 수 있다. - 벡터 연산을 지원한다.
- 4개의 32 비트 정수/float
- 2개의 64 비트 정수/float
이후 SSE2, SSE3, SSE4등 발전 되어 256비트/512비트의 레지스터도 추가되었다.
이후 예시에서 SSE를 사용하기위해 <immintrin.h>
헤더를 include하였다.
SISD 연산과 SIMD 연산 성능 차이
SIMD와 SISD의 어셈블리 Instruction
다음 블로그를 참고하였다: SIMD에 대한 집중탐구!
직접 덧셈의 어셈블리 코드를 비교해보았다.
- 🙋 어셈블리 코드 확인하는 방법:
다음 링크를 참고하면 된다: 비주얼 스튜디오 어셈블리코드 확인하기 (DisAssembly) - 전체 코드 파일
SISD 덧셈 함수
void add(int* a, int* b, int* 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]; }
SIMD 덧셈 함수
- void addSIMD(int* a, int* b, int* c)
{
__m128i mA = _mm_load_si128((__m128i*)a);
__m128i mB = _mm_load_si128((__m128i*)b);
__m128i mC = _mm_add_epi32(mA, mB);
_mm_store_si128((__m128i*)c, mC);
}
SISD의 Insturction들 | SIMD의 Insturction들 |
같은 결과 값을 내는 연산에서 SIMD연산이 SISD보다 적은 Instruction을 쓰는 걸 볼 수 있다.
성능 비교 - 시간
덧셈과 같은 단순한 연산에선 성능 차이가 크게 없지만, 행렬 곱셈 등 복잡한 연산에선 2~4배의 차이를 보인다.
연산이 복잡할수록, 연산 횟수가 많을 수록 성능에 큰 차이를 보인다.
덧셈
4개의 elements를 가진 int 배열을 1000000번 연산했을 때의 차이이다.
// ...(생략) 위의 add 함수들과 동일
int main()
{
// ... (생략)
int a[] = { 1, 2, 3, 4 };
int b[] = { 5, 6, 7, 8 };
int c[4];
auto start = chrono::high_resolution_clock::now();
for (int i = 0; i < 1000000; i++)
{
add(a, b, c);
}
auto end = chrono::high_resolution_clock::now();
auto addTime = chrono::duration_cast<chrono::nanoseconds>(end - start).count();
cout << "add: " << addTime << "ns" << endl;
start = chrono::high_resolution_clock::now();
for (int i = 0; i < 1000000; i++)
{
addSIMD(a, b, c);
}
end = chrono::high_resolution_clock::now();
auto addSIMDTime = chrono::duration_cast<chrono::nanoseconds>(end - start).count();
cout << "addSIMD: " << addSIMDTime << "ns" << endl;
// ... (생략) 결과 비교
}
큰 차이를 보이진 않지만, 성능이 향상됨을 볼 수 있다.
행렬 곱셈
이번엔 좀 더 복잡한 연산을 해보자.
1000 * 1000 크기의 행렬 곱셈을 수행해보았다.
// ...
const int N = 1000;
// 일반 행렬 곱셈
void matrixMulti(float* A, float* B, float* C)
{
for (int i = 0; i < N; i++)
{
for (int j = 0; j < N; j++)
{
C[i * N + j] = 0;
for (int k = 0; k < N; k++)
{
C[i * N + j] += A[i * N + k] * B[k * N + j];
}
}
}
}
// 128비트 레지스터를 사용하여 SIMD 행렬 곱셈
void matrixMultiplySIMD128(float* A, float* B, float* C)
{
for (int i = 0; i < N; ++i)
{
for (int j = 0; j < N; ++j)
{
__m128 sum = _mm_setzero_ps();
for (int k = 0; k < N; k += 4)
{
__m128 a = _mm_loadu_ps(&A[i * N + k]);
__m128 b = _mm_loadu_ps(&B[k * N + j]);
sum = _mm_add_ps(sum, _mm_mul_ps(a, b));
}
float result[4];
_mm_storeu_ps(result, sum);
C[i * N + j] = result[0] + result[1] + result[2] + result[3];
}
}
}
// 256비트 레지스터를 사용하여 SIMD 행렬 곱셈
void matrixMultiplySIMD256(float* A, float* B, float* C)
{
for (int i = 0; i < N; ++i)
{
for (int j = 0; j < N; ++j)
{
__m256 sum = _mm256_setzero_ps();
for (int k = 0; k < N; k += 8)
{
__m256 a = _mm256_loadu_ps(&A[i * N + k]);
__m256 b = _mm256_loadu_ps(&B[k * N + j]);
sum = _mm256_add_ps(sum, _mm256_mul_ps(a, b));
}
float result[8];
_mm256_storeu_ps(result, sum);
C[i * N + j] = result[0] + result[1] + result[2] + result[3] + result[4] + result[5] + result[6] + result[7];
}
}
}
int main()
{
// ...(생략) 행렬 초기화
// 일반 행렬 곱셈
auto start = chrono::high_resolution_clock::now();
matrixMulti(A, B, C);
auto end = chrono::high_resolution_clock::now();
auto regularTime = chrono::duration_cast<chrono::milliseconds>(end - start).count();
cout << "Regular Matrix Multiply: " << regularTime << "ms" << endl;
// SIMD 행렬 곱셈 128
start = chrono::high_resolution_clock::now();
matrixMultiplySIMD128(A, B, C);
end = chrono::high_resolution_clock::now();
auto simdTime = chrono::duration_cast<chrono::milliseconds>(end - start).count();
cout << "SIMD Matrix Multiply: " << simdTime << "ms" << endl;
// SIMD 행렬 곱셈 256
start = chrono::high_resolution_clock::now();
matrixMultiplySIMD256(A, B, C);
end = chrono::high_resolution_clock::now();
auto simdTime256 = chrono::duration_cast<chrono::milliseconds>(end - start).count();
cout << "SIMD Matrix Multiply 256: " << simdTime256 << "ms" << endl;
// ... (생략) 결과 출력 및 비교
}
행렬 곱셈에서는 성능 차이가 크게 나는 것을 볼 수 있고,
레지스터의 크기가 커질 수록, 성능이 빨라짐을 볼 수 있다.
위의 예시처럼 1000 * 1000와 같은 큰 행렬을 계산하기 위해선 __m256
, __m512
등 더 큰 레지스터를 사용하는 것이 좋겠지만,
만일 벡터 연산과 같이 2~4 요소를 연산한다면 __m128
를 사용하는 것이 기능/메모리 차원에서 더 좋을 것이다.
기타 출처:
DirectX 11을 이용한 3D 게임 프로그래밍 입문, 프랭크 D. 루나
'공부 > DirectX11을 이용한 3D 게임 프로그래밍 입문' 카테고리의 다른 글
[DX11 3D 게프입] CH1-1. 벡터와 XNA math 라이브러리 (1) | 2024.02.07 |
---|---|
[DX11 3D 게프입] 0. DirectX 11의 개요 및 설치 (0) | 2024.02.07 |