[Vulkan으로 GPGPU 시작하기] #5: 메모리 관리와 버퍼/이미지 객체 기초

지난 글(#4)에서는 큐(Queue)와 커맨드 버퍼(Command Buffer) 개념을 정리하며, Vulkan이 GPU에 명령을 전달하는 독특한 방식을 살펴봤습니다. 이제 GPU에게 할 일을 시킬 수 있는 준비는 되었지만, 정작 우리가 처리할 데이터(배열, 이미지 등)를 GPU 메모리에 올려놓는 과정은 아직 다루지 않았습니다. 이번 글에서는 Vulkan 메모리 관리의 기초인 메모리 할당, 버퍼(Buffer), 이미지(Image) 객체를 다뤄보겠습니다.

CUDA를 사용해보신 분이라면 cudaMalloc과 cudaMemcpy 정도로 GPU 메모리 관리가 비교적 단순했다는 기억이 있을 겁니다. 반면 Vulkan에서는 메모리 할당이 좀 더 “수작업”에 가깝고, 어떤 메모리 타입을 쓸지 직접 결정하고, 버퍼나 이미지를 만들어서 이 메모리에 바인딩해야 합니다. 이 다소 번거로운 과정은 다양한 하드웨어 상황에 유연하게 대응하기 위한 Vulkan의 철학을 반영합니다.

GPU 메모리 할당의 기본 아이디어

Vulkan에서 GPU 메모리를 다루려면 다음 흐름을 이해해야 합니다.

  1. 메모리 타입 확인: GPU가 지원하는 메모리 타입(예: 장치 로컬(Device Local) 메모리, 호스트 가시(Host Visible) 메모리)을 조사
  2. 버퍼/이미지 객체 생성: GPU에서 사용할 데이터를 표현하는 객체(버퍼나 이미지)를 생성
  3. 메모리 할당과 바인딩: 필요한 메모리를 할당하고, 해당 메모리를 버퍼나 이미지 객체에 바인딩
  4. 호스트 메모리에 맵(Map): 필요하다면 호스트 메모리에 매핑하여 데이터를 CPU에서 GPU 메모리로 복사하거나, GPU 계산 결과를 읽어옴

CUDA에서는 cudaMalloc(&ptr, size) 한 번으로 GPU 메모리 확보가 가능하고, cudaMemcpy()로 호스트-디바이스간 데이터 전송이 명확했습니다. Vulkan은 더 낮은 레벨에서 동작하므로, “이 버퍼를 어떤 메모리 유형에 할당할지”를 개발자가 직접 결정해야 합니다. 이렇게 하면 다양한 GPU 아키텍처와 메모리 구성에 맞춰 최적화할 수 있습니다.

메모리 타입 파악하기

GPU 메모리를 할당하기 전에, 물리 디바이스가 어떤 메모리 타입을 지원하는지 파악해야 합니다. vkGetPhysicalDeviceMemoryProperties() 함수를 이용하면, 메모리 힙(Heap)과 타입(Type)을 확인할 수 있습니다.

VkPhysicalDeviceMemoryProperties memProperties;
vkGetPhysicalDeviceMemoryProperties(chosenDevice, &memProperties);

for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) {
    // memProperties.memoryTypes[i].propertyFlags 를 확인해
    // VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT 등
    // 어떤 특성을 가진 메모리 타입인지 알 수 있음
}

GPGPU 연산을 위해 자주 사용할 시나리오는 다음과 같습니다.

  • Host Visible 메모리: CPU에서 직접 메모리에 접근 가능. 데이터 업로드/다운로드에 편리하지만, 대개 속도가 더 느림.
  • Device Local 메모리: GPU 전용으로 빠른 접근 가능. 대용량 데이터 처리나 자주 쓰이는 버퍼를 여기에 두면 성능에 유리.

실제 애플리케이션에서는 Host Visible 메모리에 데이터를 올려 GPU로 복사한 뒤, Device Local 메모리에 옮기는 전략을 취하기도 합니다.

버퍼(Buffer) 객체 생성

버퍼는 GPU 메모리 상에서 연속적인 바이트 배열을 의미합니다. 벡터 데이터, 행렬, 혹은 기타 임의의 바이너리 데이터를 저장할 수 있습니다. 우선 버퍼 객체를 생성하고, 이후에 메모리를 할당하고 바인딩하는 순서로 진행합니다.

VkBufferCreateInfo bufferInfo = {};
bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferInfo.size = sizeof(float) * 1024; // 예: float 1024개 크기
bufferInfo.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; // Compute 셰이더에서 사용할 스토리지 버퍼
bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

VkBuffer buffer;
if (vkCreateBuffer(device, &bufferInfo, NULL, &buffer) != VK_SUCCESS) {
    fprintf(stderr, "Failed to create buffer!\n");
    return 1;
}

위 예제에서는 스토리지 버퍼(Storage Buffer)로 사용할 버퍼를 하나 만들었습니다. 이 버퍼는 나중에 Compute 셰이더에서 입출력 데이터로 활용할 수 있습니다.

메모리 요구사항 확인 및 메모리 바인딩

버퍼를 만들면 GPU는 이 버퍼를 어떤 메모리에 넣을지 알고 싶어합니다. vkGetBufferMemoryRequirements()를 통해 버퍼에 필요한 메모리 크기, 정렬(Alignment) 정보를 얻을 수 있습니다.

VkMemoryRequirements memReq;
vkGetBufferMemoryRequirements(device, buffer, &memReq);

// memReq.size, memReq.alignment, memReq.memoryTypeBits 를 활용해
// 적절한 메모리 타입을 선택

memReq.memoryTypeBits를 사용해 어떤 메모리 타입에 할당할 수 있는지 결정한 뒤, 우리가 원하는 속성과 호환되는 메모리 타입 인덱스를 찾아야 합니다.

uint32_t memoryTypeIndex = findMemoryType(memReq.memoryTypeBits, 
    VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, 
    memProperties);
// findMemoryType 함수는 memoryTypeBits와 propertyFlags를 사용해 적합한 메모리 타입 인덱스를 찾아주는 유틸 함수라고 가정

메모리 타입 인덱스를 찾았다면, 실제 메모리를 할당합니다.

VkMemoryAllocateInfo allocInfo = {};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = memReq.size;
allocInfo.memoryTypeIndex = memoryTypeIndex;

VkDeviceMemory bufferMemory;
if (vkAllocateMemory(device, &allocInfo, NULL, &bufferMemory) != VK_SUCCESS) {
    fprintf(stderr, "Failed to allocate buffer memory!\n");
    return 1;
}

// 버퍼와 메모리를 바인딩
vkBindBufferMemory(device, buffer, bufferMemory, 0);

이제 buffer와 bufferMemory가 연결되었습니다.

CUDA의 cudaMalloc() 한 번이면 해결되던 일이 이렇게 길어진 이유는 Vulkan이 다양한 하드웨어 상황을 지원하고, 최적의 메모리 할당 전략을 선택하도록 개발자에게 책임을 넘기기 때문입니다.

호스트 메모리에 매핑(Map)하기

Host Visible 메모리에 할당했다면, CPU에서 이 메모리를 직접 접근해 데이터를 쓸 수 있습니다. 이를 위해 vkMapMemory()를 호출합니다.

void* data;
vkMapMemory(device, bufferMemory, 0, memReq.size, 0, &data);
// data 포인터를 통해 CPU에서 GPU 버퍼에 쓸 수 있음

// 예: float 배열 초기화
float* floatData = (float*)data;
for (int i = 0; i < 1024; i++) {
    floatData[i] = (float)i;
}

vkUnmapMemory(device, bufferMemory);

이제 GPU 메모리 상의 버퍼에 우리가 원하는 데이터를 올렸습니다. CUDA의 cudaMemcpy()와 유사한 개념이지만, Vulkan에서는 메모리 매핑과 언매핑, 그리고 올바른 메모리 타입 선택이 개발자 몫입니다.

이미지(Image) 객체 (간략히)

이미지는 2D 텍스처, 렌더 타겟, 혹은 3D 볼륨 데이터 등 픽셀 기반 데이터를 GPU에서 다루는 객체입니다. 버퍼와 비슷한 방식으로 vkCreateImage()로 이미지 객체를 만들고, vkGetImageMemoryRequirements()로 필요한 메모리를 확인한 뒤 할당, 바인딩해야 합니다. 여기서는 GPGPU 기초에서 주로 1D 벡터나 행렬 처리를 예로 들고 있으므로, 이미지 관련 내용은 앞으로 렌더링과 연계하거나 2D 데이터 처리 시 자세히 다뤄보겠습니다.

정리

  • 메모리 타입 파악: GPU 메모리 특성에 따라 Host Visible, Device Local 등 다양한 타입 선택
  • 버퍼/이미지 생성: GPU에서 사용할 데이터 구조(버퍼, 이미지) 정의
  • 메모리 할당 및 바인딩: 필요한 메모리를 할당하고 객체와 연결
  • 호스트 메모리 매핑: CPU에서 직접 데이터 쓰기/읽기 가능

CUDA와 비교하면 상당히 번거롭지만, 이 과정을 이해하면 Vulkan이 얼마나 유연한지 실감할 수 있습니다. 다양한 메모리 타입을 직접 다루는 능력은 성능 최적화에 큰 도움이 됩니다.

다음 글 예고

다음 글(#6)에서는 이제 메모리와 버퍼를 준비했으니, 실제 Compute 셰이더를 작성하고 파이프라인을 구성하는 과정을 다룰 예정입니다. Vulkan에서 셰이더를 SPIR-V로 컴파일하고, 디스크립터(Descriptor)를 사용해 버퍼를 셰이더에 연결하는 방법을 소개할 것입니다. CUDA 커널 실행 방식과 비교하면서, Vulkan 컴퓨트 파이프라인이 어떻게 구성되는지 함께 살펴보겠습니다.

유용한 링크 & 리소스

 

반응형