[Vulkan으로 GPGPU 시작하기] #4: 큐와 커맨드 버퍼로 명령 관리하기

지난 글(#3)에서는 물리 디바이스를 골라서 로지컬 디바이스를 만들고, 큐를 확보하는 단계까지 진행했습니다. 이제 GPU에 작업을 시키기 위해서는 명령(Commands)들을 모아둘 그릇이 필요한데, Vulkan에서는 이를 "커맨드 버퍼(Command Buffer)"라고 부릅니다. 이번 글에서는 커맨드 버퍼를 다루는 법과 큐에 이 버퍼를 제출(Submit)하는 과정을 살펴보겠습니다. 또한 CUDA의 스트림(Stream) 개념과 비교하여 Vulkan 방식이 어떤 점에서 다른지 이해해봅시다.

왜 커맨드 버퍼인가?

간단히 말해, 커맨드 버퍼는 GPU에 내릴 명령을 모아둔 리스트입니다. 이 리스트를 나중에 큐(Queue)에 제출하면, GPU가 순서대로 실행하게 됩니다. 이런 구조는 단순히 함수 호출로 GPU를 동작시키는 것보다 더 수고스러워 보일 수 있습니다. 하지만 대규모 명령을 일괄 처리하거나, 병렬로 커맨드 버퍼를 준비한 뒤 제출하는 등의 유연한 패턴을 구현하기에 유리합니다.

 

CUDA에서도 스트림(Streams)을 통해 비동기 명령 실행을 지원하지만, CUDA 스트림은 호출 시점에 바로 명령이 들어가는 단순한 큐에 가깝습니다. 반면 Vulkan은 커맨드 버퍼라는 중간 단계를 추가로 둬서, 명령 준비와 제출을 명확히 분리하고, 필요할 때 커맨드 버퍼를 재사용하는 등의 최적화도 가능합니다.

커맨드 풀(Command Pool)과 커맨드 버퍼 할당

커맨드 버퍼를 만들기 전에, 먼저 커맨드 풀(Command Pool)을 생성해야 합니다. 커맨드 풀은 커맨드 버퍼 할당에 사용되는 메모리 풀처럼 생각할 수 있으며, 특정 큐 패밀리와 연관됩니다.

// 로지컬 디바이스(device)와 computeQueueFamilyIndex를 이미 확보했다고 가정 (이전 글 참조)

VkCommandPoolCreateInfo poolInfo = {};
poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
poolInfo.queueFamilyIndex = computeQueueFamilyIndex;

VkCommandPool commandPool;
if (vkCreateCommandPool(device, &poolInfo, NULL, &commandPool) != VK_SUCCESS) {
    fprintf(stderr, "Failed to create command pool!\n");
    return 1;
}

// 이제 commandPool을 통해 커맨드 버퍼를 할당할 수 있음

커맨드 풀을 만들었다면, 이 풀로부터 커맨드 버퍼를 할당합니다.

VkCommandBufferAllocateInfo allocInfo = {};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool;
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; // 기본적인 1차 커맨드 버퍼
allocInfo.commandBufferCount = 1;

VkCommandBuffer commandBuffer;
if (vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer) != VK_SUCCESS) {
    fprintf(stderr, "Failed to allocate command buffer!\n");
    return 1;
}

이제 commandBuffer라는 핸들이 생겼습니다. 여기에 GPU 작업 명령을 기록할 수 있습니다.

커맨드 버퍼 레코딩(Recording)과 제출(Submit)

커맨드 버퍼에 명령을 기록하려면 먼저 vkBeginCommandBuffer()를 호출해 레코딩 상태에 들어갑니다. 그 후 원하는 명령(예: Compute 파이프라인 바인딩, Dispatch 명령 등)을 추가하고 vkEndCommandBuffer()로 마무리합니다.

VkCommandBufferBeginInfo beginInfo = {};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;

if (vkBeginCommandBuffer(commandBuffer, &beginInfo) != VK_SUCCESS) {
    fprintf(stderr, "Failed to begin command buffer!\n");
    return 1;
}

// 여기서 commandBuffer에 실제 GPU 명령들을 기록할 수 있음
// 예: Compute 파이프라인 바인딩, Dispatch 명령 등
// 아직은 단순히 테스트용이므로 명령 기록 없이 바로 종료

if (vkEndCommandBuffer(commandBuffer) != VK_SUCCESS) {
    fprintf(stderr, "Failed to end command buffer!\n");
    return 1;
}

커맨드 버퍼가 준비되면 이제 큐에 제출할 수 있습니다.

VkSubmitInfo submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;

// computeQueue는 이전 글에서 vkGetDeviceQueue로 받아온 큐
if (vkQueueSubmit(computeQueue, 1, &submitInfo, VK_NULL_HANDLE) != VK_SUCCESS) {
    fprintf(stderr, "Failed to submit to queue!\n");
    return 1;
}

// 큐에 제출한 명령이 모두 끝날 때까지 대기
vkQueueWaitIdle(computeQueue);

이 과정을 거치면 GPU가 commandBuffer에 기록된 명령을 실행하게 됩니다(지금은 명령이 없으니 아무 일도 안 하겠지만).

CUDA 스트림과의 비교

CUDA 스트림은 함수 호출 시점에 GPU 명령이 바로 스트림에 들어가고, GPU가 순서대로 처리하는 방식입니다. Vulkan은 커맨드 버퍼라는 별도의 목록을 먼저 만든 뒤, 이를 큐에 제출하기 때문에 두 단계로 나뉘어 있습니다.

  • CUDA 스트림: 명령 발행(커널 호출, memcpy) 시 즉시 스트림에 등록
  • Vulkan 커맨드 버퍼 & 큐: 명령을 녹화(Record)하는 단계와 제출(Submit)하는 단계를 분리

이런 구조 덕분에 Vulkan은 명령 준비와 실행을 더 유연하게 다룰 수 있습니다. 예를 들어, 여러 스레드에서 각각 커맨드 버퍼를 따로 준비한 뒤, 메인 스레드에서 이들을 한 번에 큐에 제출하는 식으로 확장 가능합니다. 반면 CUDA는 NVIDIA GPU에 특화되어 더 단순한 모델을 제공하지만, Vulkan은 다양한 하드웨어를 포괄하는 상위 호환적 구조를 지향하기 때문에 이런 식의 저수준 제어가 필요한 것입니다.

간단한 예제 코드 종합하기

아래는 지금까지 진행한 과정을 요약한 간단한 코드 샘플입니다. 이 코드는 실제 Compute 파이프라인이나 연산 명령 없이, 커맨드 풀/버퍼를 만들고 큐에 제출하는 흐름을 보여줍니다. (인스턴스, 디바이스, 큐 생성은 이전 글 참조)

// Assume instance, device, computeQueueFamilyIndex, computeQueue 확보 완료 상태

// 커맨드 풀 생성
VkCommandPoolCreateInfo poolInfo = {};
poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
poolInfo.queueFamilyIndex = computeQueueFamilyIndex;

VkCommandPool commandPool;
vkCreateCommandPool(device, &poolInfo, NULL, &commandPool);

// 커맨드 버퍼 할당
VkCommandBufferAllocateInfo allocInfo = {};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool;
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
allocInfo.commandBufferCount = 1;

VkCommandBuffer commandBuffer;
vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);

// 명령 레코딩 시작/종료
VkCommandBufferBeginInfo beginInfo = {};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
vkBeginCommandBuffer(commandBuffer, &beginInfo);
// 여기서 실제 명령 기록 가능(지금은 없음)
vkEndCommandBuffer(commandBuffer);

// 큐에 제출
VkSubmitInfo submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;

vkQueueSubmit(computeQueue, 1, &submitInfo, VK_NULL_HANDLE);
vkQueueWaitIdle(computeQueue);

printf("Command buffer submitted and queue waited idle.\n");

// 리소스 정리 시 commandPool, device, instance 해제 필요

이 코드는 명령을 기록하지는 않았지만, 커맨드 버퍼를 할당하고 제출하는 전체 흐름을 보여줍니다. 이후 Compute 파이프라인 설정, 실제 연산 명령(Dispatch) 추가 등으로 확장할 수 있습니다.

다음 글 예고

다음 글(#5)에서는 Vulkan에서 메모리를 관리하고, 버퍼(Buffer)와 이미지(Image) 객체를 다루는 기초를 다룰 예정입니다. GPGPU 연산을 위해서는 연산 대상 데이터를 GPU 메모리에 올려놓고 결과를 다시 가져와야 합니다. CUDA에서 cudaMalloc와 cudaMemcpy를 사용했던 것과 달리, Vulkan에서는 메모리 할당과 바인딩, 버퍼 생성, 매핑 등의 과정을 조금 더 세밀하게 처리해야 합니다.

유용한 링크 & 리소스

반응형