컴퓨트 셰이더는 GPU에서 실행되는 셰이더 프로그램의 한 종류로, 그래픽스 렌더링과는 별도로 일반적인 계산 작업을 수행하기 위해 사용된다. 이는 GPU의 병렬 처리 능력을 활용하여 대규모 데이터 처리, 물리 시뮬레이션, 이미지 처리 등을 효율적으로 수행할 수 있도록 한다.
컴퓨트 셰이더의 개념
컴퓨트 셰이더는 그래픽스 파이프라인의 다른 셰이더와 달리 화면을 그리기 위한 입력과 출력을 필요로 하지 않는다. 대신, 보다 일반적인 계산 작업을 수행하기 위해 설계되었다. 컴퓨트 셰이더는 매우 많은 양의 데이터를 동시에 처리하는 데 최적화되어 있으므로, 복잡한 계산이나 데이터를 병렬로 처리하는 작업에 적합한다.
컴퓨트 셰이더는 일반적으로 다음과 같은 작업에 사용된다:
- 물리 시뮬레이션 (유체 동역학, 입자 시스템 등)
- 이미지 처리 (필터링, 변환 등)
- 데이터 분석 및 처리 (예: 빅 데이터 분석)
- 머신 러닝
구조
컴퓨트 셰이더는 다수의 워크 그룹(work group) 으로 나누어 지며, 각 워크 그룹은 다시 워크 아이템(work item) 으로 구성된다. 이를 통해 대규모 병렬 처리가 가능한다.
워크 아이템의 수를 \mathbf{num_{threads}} 라고 할 때, 컴퓨트 셰이더의 실행 단위는 다음과 같이 정의된다:
여기서 num_threads_per_group 은 한 워크 그룹 내에서 실행되는 워크 아이템의 수를, num_groups 는 전체 워크 그룹의 수를 나타낸다.
예제 코드
컴퓨트 셰이더 코드의 예제를 통해 구체적으로 살펴보겠다. 일례로, 두 벡터를 더하는 간단한 컴퓨트 셰이더 코드를 작성해 보자:
#version 430
layout (local_size_x = 1024) in;
layout (std430, binding = 0) buffer Input1 {
float in1[];
};
layout (std430, binding = 1) buffer Input2 {
float in2[];
};
layout (std430, binding = 2) buffer Output {
float out[];
};
void main() {
uint index = gl_GlobalInvocationID.x;
out[index] = in1[index] + in2[index];
}
위 코드는 다음과 같이 구성된다:
- #version 430: GLSL 버전을 지정한다.
- layout (local_size_x = 1024) in;: 워크 그룹당 1024개의 워크 아이템을 사용할 것을 지정한다.
- layout (std430, binding = 0): 각각의 입력(bufer) 변수를 정의한다.
- gl_GlobalInvocationID.x: 각 워크 아이템의 전역 인덱스를 가져온다.
메모리 관리
컴퓨트 셰이더에서는 GPU 메모리를 효율적으로 사용하는 것이 중요하다. 다음은 주요 메모리 유형들이다:
- 공유 메모리(shared memory): 워크 그룹 내에서 공유되는 메모리이다. 이를 통해 워크 그룹 내의 워크 아이템들이 데이터를 공유하고 협력할 수 있다.
- 글로벌 메모리(global memory): 모든 워크 아이템이 접근 가능한 메모리이다. 주로 입력과 출력 데이터를 저장한다.
- 로컬 메모리(local memory): 각 워크 아이템에 의해 사용되는 개별적인 메모리이다.
- 상수 메모리(constant memory): 읽기 전용 메모리로, 모든 워크 아이템이 공유한다.
동기화
컴퓨트 셰이더에서 여러 워크 아이템들이 협력할 수 있도록 동기화 기능이 필요하다. 주요 동기화 함수로는 barrier() 함수가 있으며, 이는 워크 아이템들이 공통의 접근 지점에 도달할 때까지 실행을 중지시킨다.
memoryBarrier();
barrier();
이러한 동기화 기능을 통해 데이터 일관성을 보장하고 경쟁 상태(race condition)를 방지할 수 있다.
효율적인 컴퓨트 셰이더 작성 방법
효율적인 컴퓨트 셰이더를 작성하기 위해서는 다음과 같은 요소들을 고려해야 한다:
-
데이터 레이아웃 최적화: 데이터가 GPU 메모리에 저장되는 형식이 성능에 중요한 영향을 미친다. 효율적인 메모리 접근 패턴을 유지하고 데이터 로컬리티(locality)를 최대화하는 것이 중요하다.
-
워크 그룹 크기 최적화: 워크 그룹의 크기를 조정하여 GPU의 효율적인 리소스 사용을 보장해야 한다. 일반적으로 워크 그룹 크기는 GPU의 처리 장치에 따라 조정될 수 있다.
-
메모리 동기화 최소화: 불필요한 동기화는 성능 저하를 초래할 수 있다. 반드시 필요한 경우에만 동기화 함수를 사용해야 한다.
-
비동기 실행: 가능한 한 많은 작업을 비동기로 실행하여 GPU와 CPU 리소스를 최대한 활용해야 한다.
-
반환 값 최소화: 셰이더 함수가 반환하는 값을 최소화하고, 대신 출력 버퍼를 사용하여 결과를 저장하도록 한다.
-
쉐이더 코드 최적화: 중복 계산을 최소화하고 수학적 최적화를 통해 셰이더 코드의 효율성을 높여야 한다.
활용 예
컴퓨트 셰이더는 다양한 분야에서 사용될 수 있다. 다음은 몇 가지 대표적인 예이다:
- 물리 시뮬레이션: 유체 동역학, 연기 및 서킷 시뮬레이션 등이 GPU의 병렬 처리 능력을 통해 실시간으로 수행될 수 있다.
- 이미지 처리: 필터링, 테두리 감지, 블러링 등 다양한 이미지 처리 작업이 빠르게 처리될 수 있다.
- 머신 러닝: GPU를 이용한 대규모 데이터 분석 및 학습 작업을 수행할 수 있다.
예제 프로젝트
간단한 예제 프로젝트를 통해 실습을 진행해보겠다. 예제 프로젝트는 C++과 OpenGL을 사용하여 간단한 컴퓨트 셰이더를 작성하고 실행하는 과정을 포함한다.
예제 프로젝트 설정
- 준비물:
- C++ 컴파일러 (예: GCC, Clang, MSVC)
- OpenGL 및 GLEW, GLFW 라이브러리
- GLSL 컴퓨트 셰이더 코드
#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <iostream>
// 셰이더 소스 코드
const char* computeShaderSource = R"(
#version 430
layout (local_size_x = 1024) in;
layout (std430, binding = 0) buffer Input1 {
float in1[];
};
layout (std430, binding = 1) buffer Input2 {
float in2[];
};
layout (std430, binding = 2) buffer Output {
float out[];
};
void main() {
uint index = gl_GlobalInvocationID.x;
out[index] = in1[index] + in2[index];
}
)";
void checkCompileErrors(GLuint shader, std::string type) {
GLint success;
GLchar infoLog[1024];
if (type != "PROGRAM") {
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
if (!success) {
glGetShaderInfoLog(shader, 1024, nullptr, infoLog);
std::cerr << "ERROR::SHADER_COMPILATION_ERROR of type: " << type << "\n" << infoLog << "\n";
}
} else {
glGetProgramiv(shader, GL_LINK_STATUS, &success);
if (!success) {
glGetProgramInfoLog(shader, 1024, nullptr, infoLog);
std::cerr << "ERROR::PROGRAM_LINKING_ERROR of type: " << type << "\n" << infoLog << "\n";
}
}
}
int main() {
// OpenGL 초기화
if (!glfwInit()) {
std::cerr << "Failed to initialize GLFW" << std::endl;
return -1;
}
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
GLFWwindow* window = glfwCreateWindow(800, 600, "Compute Shader Example", NULL, NULL);
if (!window) {
std::cerr << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
glewExperimental = GL_TRUE;
if (glewInit() != GLEW_OK) {
std::cerr << "Failed to initialize GLEW" << std::endl;
return -1;
}
// 컴퓨트 셰이더 컴파일 및 프로그램 생성
GLuint computeShader = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(computeShader, 1, &computeShaderSource, nullptr);
glCompileShader(computeShader);
checkCompileErrors(computeShader, "COMPUTE_SHADER");
GLuint shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, computeShader);
glLinkProgram(shaderProgram);
checkCompileErrors(shaderProgram, "PROGRAM");
// 데이터 준비
const int dataSize = 1024;
float data1[dataSize];
float data2[dataSize];
for (int i = 0; i < dataSize; ++i) {
data1[i] = static_cast<float>(i);
data2[i] = static_cast<float>(i) * 2.0f;
}
// 버퍼 생성 및 데이터 업로드
GLuint buffer1, buffer2, outBuffer;
glGenBuffers(1, &buffer1);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer1);
glBufferData(GL_SHADER_STORAGE_BUFFER, sizeof(data1), data1, GL_STATIC_DRAW);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, buffer1);
glGenBuffers(1, &buffer2);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer2);
glBufferData(GL_SHADER_STORAGE_BUFFER, sizeof(data2), data2, GL_STATIC_DRAW);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, buffer2);
glGenBuffers(1, &outBuffer);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, outBuffer);
glBufferData(GL_SHADER_STORAGE_BUFFER, sizeof(data1), nullptr, GL_STATIC_COPY);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, outBuffer);
// 컴퓨트 셰이더 실행
glUseProgram(shaderProgram);
glDispatchCompute(dataSize / 1024, 1, 1);
glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT);
// 결과 가져오기
float results[dataSize];
glBindBuffer(GL_SHADER_STORAGE_BUFFER, outBuffer);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(results), results);
// 결과 출력
for (int i = 0; i < dataSize; ++i) {
std::cout << results[i] << std::endl;
}
// 자원 해제
glDeleteBuffers(1, &buffer1);
glDeleteBuffers(1, &buffer2);
glDeleteBuffers(1, &outBuffer);
glDeleteProgram(shaderProgram);
glDeleteShader(computeShader);
glfwDestroyWindow(window);
glfwTerminate();
return 0;
}
위 코드는 간단한 C++ 프로젝트로 두 벡터의 합을 계산하는 컴퓨트 셰이더를 실행한다. 핵심 부분은 다음과 같다:
- OpenGL 초기화: GLFW와 GLEW를 사용하여 OpenGL 컨텍스트를 초기화한다.
- 셰이더 컴파일과 링크: 컴퓨트 셰이더를 컴파일하고 프로그램을 생성한다.
- 버퍼 생성 및 데이터 업로드: 입력 데이터를 GPU 메모리에 업로드한다.
- 컴퓨트 셰이더 실행 및 결과 확인: 셰이더를 실행하고 결과를 확인한다.