DuckingRacoon

+ SIMD란? 일반 연산과 SIMD의 연산 성능 실험 본문

공부/DirectX11을 이용한 3D 게임 프로그래밍 입문

+ SIMD란? 일반 연산과 SIMD의 연산 성능 실험

따킹라쿤 2024. 2. 9. 19:09

🔎 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에 대한 집중탐구!

직접 덧셈의 어셈블리 코드를 비교해보았다.

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. 루나

SIMD - 위키백과, 우리 모두의 백과사전 (wikipedia.org)

스트리밍 SIMD 확장 - 위키백과, 우리 모두의 백과사전 (wikipedia.org)