상세 컨텐츠

본문 제목

ALEngine: SIMD를 이용한 최적화 - (3)

Computer Graphics/ALEngine

by Banjosh 2025. 3. 26. 19:57

본문

SIMD 최적화를 적용한 이유

ALEngine에서는 일반적으로 자주 쓰이는 glm이라는 수학 라이브러리 대신 우리가 직접 만든 alglm이라는 라이브러리를 사용한다. 이전 과제들에서도 직접 만든 수학 라이브러리를 사용하긴 했지만, 이번 프로젝트처럼 큰 규모가 아니었기에 별다른 최적화 없이 사용했고, 이번 프로젝트에서는 연산이 많이 일어나기 때문에 수학 라이브러리의 최적화도 중요해졌다.
따라서 우리는 alglm 수학 라이브러리에 SIMD 최적화를 적용하기로 했다.


SIMD란?

SIMD는 Single Instruction, Multiple Data의 약자로, 말 그대로 하나의 명령어로 여러 개의 데이터 연산을 처리하는 방식이다.
일반적인 CPU의 레지스터들은 32bit 혹은 64bit 크기로, 하나의 명령어로 하나의 데이터만 처리할 수 있게 되어 있다.
이와 달리 SIMD에 사용되는 특수한 레지스터는 128bit에서 512bit까지 존재하며, 하나의 레지스터에 여러 개의 데이터를 담아 한 번의 명령어로 처리할 수 있다.
예를 들어, 512bit 레지스터는 크기가 64byte이기 때문에 4byte 크기의 float이 16개 들어갈 수 있고, 이를 통해 float 16개의 연산을 한 번의 명령어로 처리할 수 있다.


SIMD가 효율적인 이유

수학 라이브러리에서 구현한 구조체인 vector와 matrix는 연속적인 공간에 float을 저장하여 연산을 하는 구조체이다.
이때 float들이 연속적인 메모리 공간에 저장된다는 점을 이용하면 SIMD를 통해 명령어 실행 횟수를 줄여 최적화를 할 수 있게 된다.


SIMD를 적용하기 위해 알아야 하는 점

1. CPU마다 다른 SIMD 명령어 집합

CPU마다 지원하는 SIMD 명령어 집합이 다르고, 그에 따라 사용할 수 있는 SIMD 레지스터의 크기도 달라진다.
대표적인 SIMD 명령어 집합에는 SSE(128bit), AVX(256bit), AVX-512(512bit) 등이 있고, 각 명령어 집합에 맞는 레지스터 크기가 있다. 예를 들어 SSE는 128bit 레지스터인 XMM 레지스터를 사용하고, AVX는 256bit YMM, AVX-512는 512bit ZMM 레지스터를 사용한다. 512bit 레지스터를 사용하는 AVX-512 명령어는 16개의 float을 한 번에 연산할 수 있다.

2. 데이터 정렬

SIMD 명령어는 특정 크기의 레지스터에 맞게 데이터를 한 번에 읽고 처리하는 구조이다. 따라서 연산할 데이터들을 해당 레지스터 크기에 맞게 바이트 정렬을 시켜놔야한다. 만약 SIMD 레지스터의 크기에 맞게 정렬되지 않은 데이터를 쓰는 순간 예외가 발생할 수 있고, 예외가 발생하지 않더라도 정렬되지 않은 데이터는 CPU가 한 번에 읽어올 수 없기 때문에 최적화의 의미가 퇴색될 수 있다.

우리는 vec2, vec3, vec4, mat3, mat4, quat 구조체들을 16바이트 정렬하여 사용했다.

3. SIMD 명령어 사용

AVX2 명령어를 통해 구현한 vec4의 +, -, * 연산자 구현은 다음과 같다:

vec4 vec4::operator+(const vec4 &rhs) const
{
	__m128 a = _mm_load_ps(reinterpret_cast<const float *>(this));
	__m128 b = _mm_load_ps(reinterpret_cast<const float *>(&rhs));
	__m128 r = _mm_add_ps(a, b);
	vec4 result;
	_mm_store_ps(reinterpret_cast<float *>(&result), r);
	return result;
}

vec4 vec4::operator-(const vec4 &rhs) const
{
	__m128 a = _mm_load_ps(reinterpret_cast<const float *>(this));
	__m128 b = _mm_load_ps(reinterpret_cast<const float *>(&rhs));
	__m128 r = _mm_sub_ps(a, b);
	vec4 result;
	_mm_store_ps(reinterpret_cast<float *>(&result), r);
	return result;
}

vec4 vec4::operator*(const vec4 &rhs) const
{
	__m128 a = _mm_load_ps(reinterpret_cast<const float *>(this));
	__m128 b = _mm_load_ps(reinterpret_cast<const float *>(&rhs));
	__m128 r = _mm_mul_ps(a, b);
	vec4 result;
	_mm_store_ps(reinterpret_cast<float *>(&result), r);
	return result;
}

+, -, * 연산은 다음과 같은 형식을 따른다:

  1. 128bit의 레지스터 a, b에 float 4개를 load
  2. 128bit의 레지스터 r에 +, -, * 연산 결과를 저장
  3. 원하는 메모리에 연산 결과 store

물론 모든 SIMD 명령어가 이 패턴을 따르지는 않지만, 기본적으로 데이터를 레지스터에 load하고 연산 후 store하는 흐름을 이해해두면 나중에 다른 명령어들도 쉽게 따라갈 수 있다.


SIMD 라이브러리 적용 후 발생한 문제점

glm을 SIMD를 적용한 수학 라이브러리로 교체하면서 두 가지 문제가 발생했다.

1. 메모리 정렬을 중복하여 사용

struct Light
{
	alignas(16) glm::vec3 position;	 // 광원의 위치 (점광원, 스포트라이트)
	alignas(16) glm::vec3 direction; // 광원의 방향 (스포트라이트, 방향성 광원)
	alignas(16) glm::vec3 color;	 // 광원의 색상
	alignas(4) float intensity;		 // 광원의 강도
	alignas(4) float innerCutoff;	 // 스포트라이트 내부 각도 (cosine 값)
	alignas(4) float outerCutoff;	 // 스포트라이트 외부 각도 (cosine 값)
	alignas(4) uint32_t type;		 // 광원 타입 (0: 점광원, 1: 스포트라이트, 2: 방향성 광원)
	alignas(4) uint32_t onShadowMap;
	alignas(4) uint32_t padding;
	alignas(8) glm::vec2 padding2;
};

다음과 같이 glm::vec2를 8byte 정렬하여 사용하고 있었는데, 이를 alglm::vec2로 교체했더니 오류가 발생했다.
알고 보니 alglm::vec2는 SIMD를 위해 16byte 정렬을 한 구조체인데, 다시 8byte 정렬을 한 순간 alglm::vec2 구조체는 실제로 8byte 정렬이 되는 것이었다.

Uniform 변수들은 shader에서 읽을 때 바이트 정렬에 굉장히 예민하기 때문에, 위와 같은 정렬 실수에 의해 shader에서 특정 변수에 쓰레기 값이 들어가거나 바이트가 밀려 뒤에 연속으로 존재하는 값들이 잘못 들어갈 수 있다.
실제로 우리도 해당 오류로 인해 shader의 유니폼 변수에 쓰레기 값이 들어가는 문제로 고생을 했다.


2. 메모리풀에서 정렬되지 않은 메모리를 할당해 발생한 오류

이번에 바이트 정렬을 해보며 _aligned_malloc()이란 것을 처음 알게 되었다.
중복 바이트 정렬 오류를 수정하고도 엔진에서 계속 오류가 발생하길래 원인을 찾아봤더니, 메모리 풀에서 바이트 정렬된 구조체(vec4 등)를 사용할 때 메모리 할당을 정렬되지 않은 채로 해줘서 발생한 것이었다.
따라서 메모리풀에서 사용하는 malloc과 free를 _aligned_malloc()과 _aligned_free()로 바꿔, 바이트 정렬된 메모리를 할당해주는 방법을 적용하였다. (할당된 메모리를 나눠줄 때도 16byte 단위로 나눠서 할당)


아쉬운 점

어찌저찌 SIMD를 적용한 수학 라이브러리를 엔진에 이식하긴 했지만, 아쉬운 점도 있었다.

1. CPU 별로 SIMD 명령어 분기를 구현하지 못한 점

위에서 말했듯이 SIMD 명령어는 CPU에 따라 호환되는 명령어 집합이 다른데, 이를 고려한 분기를 구현하지 못한 게 아쉽다.
다양한 명령어 집합을 통한 SIMD 연산을 구현해놓고, CPU에 따라 호환되는 파일을 include해주는 식으로 갔으면 더 좋았을 것 같다.

2. 시간이 없어 직접 명령어를 하나하나 공부하며 구현하지 못한 점

SIMD 공부까지는 했는데, 팀원들과 목표한 기간이 얼마 남지 않아 GPT의 도움을 많이 받아 구현을 한 게 좀 아쉽다.
좀 더 깊이 있게 SIMD 명령어를 분석하면서 직접 구현해보고 싶었는데, 시간이 이를 용서해주지 않았다.
다음에 SIMD를 또 구현할 일이 있다면, 그때는 좀 더 SIMD 구현을 진득하게 해보고 싶다.

 

 

관련글 더보기