2022. 6. 15. 09:13ㆍVulkan
개요
지난 시간에는 벌칸 API 함수를 불러와 쓸 수 있는 그래픽 카드를 찾고, 추상화 객체를 만들고, 창 시스템에 연결하여 삼각형을 그려 전달해 보았습니다. 글의 끄트머리에 잠시 동기화가 필요하다며 이미지를 받아온 다음에야 세마포어로 신호를 주고 그리기를 시작해야 한다고 했었는데요, 이번 글에서는 명시적으로 사용할 수 있는 동기화의 유형에 대하여 간단히 알아보고, 활용해 보겠습니다.
이 글은 사양의 7장을 기반으로 작성됩니다. 밑바닥부터 알지 않다면 쉬운 개념은 아닌 모양이니, 최대한 겉부분만 보고 나머지는 간단한 활용을 통해 알아봅니다. 따라서 이론을 알고자 한다면 해당 링크를 이용해 주세요.
목차
1. 동기화의 필요성
1.1. 수행 종속성
1.2. 메모리 종속성
2. 동기화 객체
4.1. 제출 순서
4.2. 신호 동작 순서
본문
1. 동기화의 필요성
더블/트리플 버퍼링을 하는 경우 그릴 수 있는 이미지를 받아온 다음에야 그 위에 그림을 그리게 할 수 있다는 말은 어렵지 않게 받아들일 수 있습니다. 또 서브패스 의존성에서는 COLOR ATTACHMENT WRITE 동작, 즉 조각 셰이더를 하기 전에 다른 COLOR ATTACHMENT WRITE 동작을(스왑체인에서 받아오기를) 기다렸습니다. 시킨 명령들이 비동기적으로 수행되기 때문에 동기화가 필요한 건데, 여기서는 일반적인 얘기를 조금만 해 봅시다.
1.1. 수행 종속성
수행 종속성은 논리적으로 동작 B 전에 동작 A가 일어나야 하는 것을 말합니다. 예를 들면 그림판에 원을 그리고 그 안에 페인트통을 사용하는 것과 페인트통을 사용하고 원을 그리는 것의 결과는 다릅니다.
![]() |
![]() |
A가 B 전에 일어나야 하는 동작이며 A와 B 동작은 각각 명령 C와 D의 부분집합이라고 합시다. A와 B에 대한 동기화 명령인 S를 만들었다고 할 때, 벌칸에서는 C, S, D 순서대로 제출할 경우 A 다음 B가 일어나도록 보장해 줍니다.
1.2. 메모리 종속성
동작 집합 A가 가용성 관련 동작 전에, 가용성 동작은 가시성 관련 동작 전에, 가시성 동작은 B 전에 있어야 하는 경우를 메모리 종속성이라고 합니다.
- 가용성(availability) 동작: 어떤 접근(들)에 의해 작성된 메모리를 사용할 수 있게 합니다. 내용이 바뀌거나 해제되기 전까지 유지됩니다.
- 메모리 영역(domain) 동작: 원본 메모리 영역에서 사용 가능한 쓰기 명령을 목적지 메모리 영역에서 사용할 수 있게 합니다.
- 가시성(visibility) 동작: 가용 메모리 영역을 주어진 메모리 접근 동작에서 볼 수 있게 합니다.
(위의 기호 이어서) C에서 하는 메모리 접근 c, D에서 하는 메모리 접근 c, S가 커버하는 메모리 범위 a, b에 대하여 C, S, D를 순서대로 제출하면 a에 대한 작성이 먼저 가용이 되며, a를 포함하여 가능한 메모리 쓰기는 b에서 보이게 됩니다.
메모리 종속성의 경우 상대적으로 내부적인 면이 있습니다. 솔직히 저도 의미는 대충 알 것도 같지만 언제 쓰는지 잘 와 닿지 않습니다. 이것은 실제로 활용해 보면서 파악하실 수 있다면 좋겠네요.
2. 동기화 객체
명령 간 순서가 암시적으로 보장되는 부분도 있기는 하지만, 벌칸에서는 다음 5종류를 명시적 동기화를 위한 객체로 지원합니다. 일단은 펜스와 세마포어만 다루어 보겠습니다. 파이프라인 장벽은 이름에서 보이는 것과 같이, 파이프라인의 실행과 종속성이 있을 동작 (ex: 렌더 타겟의 스크린샷을 찍는 것)에서 사용할 수 있습니다.
- 펜스: CPU가 GPU의 특정 작업이 끝날 때까지 기다릴 수 있습니다.
- 세마포어: GPU의 어떤 작업이 다른 작업을 기다리게 할 수 있습니다.
- 이벤트: CPU 혹은 명령 버퍼 내에서 신호를 주는 것을 기다릴 수 있습니다.
- 파이프라인 장벽: 명령 버퍼의 한 지점에 삽입되어 그 전후의 메모리 종속성을 보장합니다.
- 렌더 패스: 렌더 패스에서는 자체적으로 위의 요소들을 활용하여 순서를 정의할 수 있습니다.
2.1. 펜스
펜스는 지나갈 수 있는 상태(signaled)와 아닌 상태(unsignaled)로 나뉩니다. 명령을 제출할 때 신호를 줄 펜스를 지정할 수 있습니다. (앞의 vkAcquireNextImageKHR 함수를 떠올려 보세요. 세마포어, 펜스 중 하나 이상을 주어야 했습니다)
- VkFence라는 포인터를 핸들로 가집니다.
- 이걸 기다릴 지점은 바로 vkWaitForFences 함수를 부른 자리가 됩니다. 펜스의 배열을 입력받고, 그 중 하나를 기다릴지 모두를 기다릴지 선택할 수 있습니다. 타임아웃(ns단위)도 정할 수 있습니다.
- 펜스를 지나간다고 signaled가 풀리지 않습니다. vkResetFences 함수로 풀 수 있습니다. 그 역을 수행하는 함수는 없습니다.
- vkGetFenceStatus 함수로 상태를 확인할 수 있습니다.
- 초기 상태는 기본적으로 unsignaled지만 signaled로 만들도록 할 수도 있습니다.
활용은 아래에서 잠깐 해 보도록 합시다.
2.2. 세마포어
세마포어는 앞에서 잠깐 사용해 보았었죠. 스왑체인이 쓸 수 있는 이미지를 알려주고 실제 이미지가 준비되기까지 그것을 기다리라고 했습니다. submit info에 세마포어 배열과 그에 대응하는 대기 작업 배열(조합 참고)을 주었었죠. 그 다음 동작인 '표시'는 vkQueueWaitIdle(graphicsQueue);를 통해서 기다렸었는데, 이 역시 세마포어로 대체할 수 있습니다. 세마포어에서 신호를 받아 한 번 지나가게 되면 세마포어의 신호는 다시 내려갑니다.
운영체제를 배우신 분이라면 이진 세마포어와 더 많은 카운트를 가지는 세마포어가 있는 걸로 알고 계실 텐데, 벌칸의 경우 기본적으로 이진 세마포어를 지원하며 1.2 이상 버전에서는 타임라인 세마포어라는, 64비트 정수 카운터를 지원합니다. 이것의 용도 등은 여기서 다루지 않겠습니다.
- vkSemaphore라는 포인터를 핸들로 가집니다.
- 큐 명령 간의 동기화를 맡는 관계로, 주로 info 계열 구조체에 신호를 줄 세마포어와 대기 세마포어를 명시합니다.
- 타임라인 세마포어의 경우 펜스 마냥 대기나 카운터 값 확인, 시그널 주기 등이 가능합니다.
역시 아래에서 잠깐 해 보도록 합시다.
3. 펜스와 세마포어 활용해 보기
(이 부분은 이전 글과 이어집니다.)
이전 글에서는 명령 버퍼를 여러 개 만들어 놓고 한 개만 썼던 걸 기억하실 겁니다. 명령 버퍼를 여러 개 사용하면 다음 것을 그리는 동안 다른 명령을 다른 버퍼에 써 둘 수 있습니다. graphicsQueue가 끝나기 전까지 CPU 측에서 기다려야 했던 것과을 무시하고요. 그것을 위한 구조를 구상해 봅시다. (여기의 내용과 같습니다)
스왑체인은 쓸 수 있는 이미지 번호를 알려주고 우리는 거기를 참조하는 프레임버퍼를 바인드하도록 했습니다. 그걸 그리는 동안에 다른 명령을 주고 또 시작하는 게 속도상 가능하다면, 이러한 점을 고려해야 합니다.
- pending 상태(큐에 올렸지만 아직 실행은 하지 않은 상태)의 명령 버퍼는 리셋할 수 없습니다. 따라서 개별 버퍼를 리셋할 수 있도록 구성하고 여러 개의 버퍼를 굴려야 합니다. 몇 번 버퍼를 굴리고 있는지는 역시 저장해야겠죠.
- 이미 진행 중인 명령에 대하여 같은 세마포어를 쓸 수 없습니다. 세마포어 S에 대하여 신호를 주는 A1, A2 명령과 그 신호를 받아야 진행할 수 있는 B1, B2 명령이 있는데 A1 - A2 - B1 - B2 순으로 실행된다면 B2가 막히겠죠?
- 모든 버퍼를 쓰게 되는 게 속도상 가능하다면, 모든 버퍼를 쓰고 있는 경우에는 멈춰야 합니다.
- 스왑체인의 이미지 수보다 많은 수의 명령 버퍼를 쓴다면 아무리 수행이 빨라도 가용 이미지를 얻어오는 한계에 막히기 때문에 총 처리율이 더 이상 증가하지 않습니다. 특별히 손해랄 건 없지만 아무래도 의미 없는 메모리 차지가 늘어나겠죠.
그러니, 우리에게 필요한 동기화 객체는 이렇습니다.
- 명령 버퍼 수만큼의 펜스: 참고로 여러 개의 펜스를 동시에 기다리는 건 가능하지만 거기서 가장 먼저 열린 펜스가 몇 번인지 바로 알려주는 함수는 없습니다. (필요하다면 개별 체크해야 합니다.) 그냥 번호 순으로 검사하는 게 낫다고 봅니다. 펜스에 신호를 주는 것은 vkQueueSubmit에서 pending이 종료되었을 때 주는 걸로 하면 되겠습니다.
- 명령 버퍼 수만큼의 세마포어 2세트: 이미지를 받아올 때까지 기다리는 세마포어와 그리기가 끝날 때까지 기다리는 세마포어를 세팅합니다. 이걸로, vkQueueWaitIdle 함수는 루프 중에는 안 부르게 합니다. (CPU가 기다려 버리면 버퍼를 여럿 쓸 이유가 없어지니)
이를 설명하는 것을 미루기 위해 0번 버퍼만 썼던 겁니다.
코드로 작성해 봅시다.
먼저 명령 버퍼의 개별 리셋이 가능하도록 해 줍시다.
// VkPlayer::createCommandPool
VkCommandPoolCreateInfo info{};
info.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
일단 멤버를 추가하여 사용할 버퍼의 번호를 지정하고, fixedDraw()에서 이를 적용합니다.
// VkPlayer.h
static int commandBufferNumber;
// VkPlayer::fixedDraw
...
vkResetCommandBuffer(commandBuffers[commandBufferNumber], 0);
...
if (vkBeginCommandBuffer(commandBuffers[commandBufferNumber], &buffbegin) != VK_SUCCESS) {
...
vkCmdBeginRenderPass(commandBuffers[commandBufferNumber], &rpbegin, VK_SUBPASS_CONTENTS_INLINE);
vkCmdBindPipeline(commandBuffers[commandBufferNumber], VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline0);
vkCmdBindVertexBuffers(commandBuffers[0], 0, 1, &vb, offsets);
...
vkCmdDraw(commandBuffers[commandBufferNumber], 3, 1, 0, 0);
vkCmdEndRenderPass(commandBuffers[commandBufferNumber]);
if (vkEndCommandBuffer(commandBuffers[commandBufferNumber]) != VK_SUCCESS) {
...
submitInfo.pCommandBuffers = &commandBuffers[commandBufferNumber];
...
//(함수 마지막 부분)
commandBufferNumber = (commandBufferNumber + 1) % COMMANDBUFFER_COUNT;
COMMANDBUFFER_COUNT가 2의 거듭제곱이라면 % 연산이 빨라지겠죠?
여기까지 하면 다른 명령 버퍼를 쓰게 되긴 하지만, 겉보기에 딱히 달라지는 점은 아직 없습니다. 명령 버퍼를 번갈아 쓰게 됐지만 여전히 큐가 비워질 때까지 기다리고 있으니까요. 또 스왑체인 이미지 수보다 버퍼 수가 많은 모양입니다. 그래서 세마포어를 아직 하나만 쓰지만 문제도 발생할 일이 없습니다. 이런 꼴인 겁니다.

큐를 기다릴 거 없이 스왑체인 이미지만 기다리면 되도록 세마포어를 여러 개 만들게 멤버를 확장합니다.
// VkPlayer.h
static VkSemaphore fixedSp[], presentSp[];
// VkPlayer.cpp
VkSemaphore VkPlayer::fixedSp[VkPlayer::COMMANDBUFFER_COUNT] = {};
VkSemaphore VkPlayer::presentSp[VkPlayer::COMMANDBUFFER_COUNT] = {};
생성/해제 함수도 맞게 수정합니다.
bool VkPlayer::createSemaphore() {
VkSemaphoreCreateInfo info{};
info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
for (int i = 0; i < COMMANDBUFFER_COUNT; i++) {
if (vkCreateSemaphore(device, &info, nullptr, &fixedSp[i]) != VK_SUCCESS) {
fprintf(stderr, "Failed to create semaphore\n");
return false;
}
if (vkCreateSemaphore(device, &info, nullptr, &presentSp[i]) != VK_SUCCESS) {
fprintf(stderr, "Failed to create semaphore\n");
return false;
}
}
return true;
}
void VkPlayer::destroySemaphore() {
for (int i = 0; i < COMMANDBUFFER_COUNT; i++) {
vkDestroySemaphore(device, fixedSp[i], nullptr);
vkDestroySemaphore(device, presentSp[i], nullptr);
}
}
fixedSp가 있던 부분을 배열의 원소로 수정합니다.
// VkPlayer::fixedDraw
...
if (vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, fixedSp[commandBufferNumber], nullptr, &imgIndex) != VK_SUCCESS) {
...
submitInfo.pWaitSemaphores = &fixedSp[commandBufferNumber];
sumbitInfo가 끝나면 새로 만든 presentSp에 신호를 주도록, 그리고 present에서 이를 받도록 시킵니다.
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &presentSp[commandBufferNumber];
...
presentInfo.waitSemaphoreCount = 1;
presentInfo.pWaitSemaphores = &presentSp[commandBufferNumber];
vkQueueWaitIdle 함수 호출 부분 2개는 지웁니다.
컴파일 후 실행해 볼까요? 그려는 집니다만, 확인 계층을 켜 놨다면 다음과 같은 한 종류의 오류 메시지를 보여줄 겁니다.
이는 아직 펜스를 안 쳤기 때문입니다. 아까 pending 상태의 버퍼는 리셋하면 안 된다고 했었죠? vkQueueSubmit에 fence를 전달하면 이게 끝났다고 알려줄 수 있습니다. 어서 만들어 줍니다.
// VkPlayer.h
static VkFence bufferFence[];
// VkPlayer.cpp
VkFence VkPlayer::bufferFence[VkPlayer::COMMANDBUFFER_COUNT] = {};
생성, 해제 함수는 세마포어랑 매우 비슷합니다. 여기서는 처음에 지나갈 수 있도록 생성할 때 플래그를 켜 둡니다.
// VkPlayer::createSemaphore
VkFenceCreateInfo fenceInfo{};
fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT;
for(int i=0; i<COMMANDBUFFER_COUNT; i++){
...
if (vkCreateFence(device, &fenceInfo, nullptr, &bufferFence[i]) != VK_SUCCESS) {
fprintf(stderr, "Failed to create fence\n");
return false;
}
}
...
void VkPlayer::destroySemaphore() {
for (int i = 0; i < COMMANDBUFFER_COUNT; i++) {
...
vkDestroyFence(device, bufferFence[i], nullptr);
}
}
그 다음 펜스를 검사하게 합니다. 어차피 우리는 펜스 한 개만 통과할 거기 때문에 스왑체인의 이미지 얻는 것보다 먼저 불러도 되고 나중에 불러도 됩니다. 저는 vkResetCommandBuffer 직전에 호출하게 했습니다.
// VkPlayer::fixedDraw
vkWaitForFences(device, 1, &bufferFence[commandBufferNumber], VK_FALSE, UINT64_MAX);
vkResetFences(device, 1, &bufferFence[commandBufferNumber]);
vkResetCommandBuffer(commandBuffers[commandBufferNumber], 0);
그리기 명령을 제출하는 vkQueueSubmit에 버퍼에서 올린 명령 수행이 끝나면 펜스에 신호를 주도록 시킵니다.
if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, bufferFence[commandBufferNumber]) != VK_SUCCESS) {
컴파일하고 실행해 봅시다. 빨라졌나요?
실행 결과 1000fps 이상 상승한 4400 정도를 보입니다. (CPU 사용량도 올랐습니다.) 확인 계층을 끄면 거기서 1000~2000fps 더 올라갑니다. 이는 버퍼 수가 1인 경우에도 마찬가지였고 스왑 체인 이미지 수를 넘어서기까지는 당연히 버퍼 수가 많을수록 빨랐습니다. 진동이 작지 않아서 측정은 기존의 코드처럼 16프레임마다 idt를 출력하는 게 아니라 1024프레임마다 frame/glfwGetTime()을 출력했습니다.
아마 닫을 때 오류를 보셨을 겁니다. 대충 세마포어와 명령 버퍼를 해제할 때에 대한 내용인데, 쓰고 있는데 왜 끝내냐는 말입니다. 이를 해결하는 방법은 간단하게, 장치가 하던 일을 마치기를 기다리면 됩니다. 루프가 끝나면 다음처럼 하던 일을 기다려 줍시다.
void VkPlayer::start() {
if (init()) {
mainLoop();
vkDeviceWaitIdle(device);
}
finalize();
}
이제 정상적으로 종료할 수 있습니다. 여기까지의 코드는 이렇습니다.
4. 동기화를 신경 쓰지 않아도 될 부분?
사양의 7장 2절을 보면, 동기화를 굳이 명시하지 않아도 순서가 지켜질 수 있는 요소가 몇 가지 있습니다.
4.1. 제출 순서
기록되고 큐에 제출된 순서는 실행 순서와 같습니다(그래서 '큐'겠죠...). 다만 기본적으로 실행이나 메모리 종속성을 보장하지는 않습니다. 즉, 이런 느낌의 실행이 될 수 있습니다.
순서의 기준을 조금 더 자세히 알아봅시다.
- vkQueueSubmit 계열을 실행한 순서
- vkQueueSubmit에 전달된 pSubmits의 배열 내의 submitInfo 순서
- VkSubmitInfo의 pCommandBuffers 배열 내의 순서
- 버퍼에 vkCmd 계열로 기록한 순서
- 렌더패스 시작 전/종료 후의 명령과 시작 명령, 끝 명령끼리의 순서 (: 이 사이를 렌더 패스 인스턴스라고 하는 듯)
- 렌더 패스 내의 명령 중 같은 서브패스의 명령끼리의 순서 (이 2개는 제가 무슨 말인지 이해를 못했습니다. 나중에 알면 수정하겠습니다.)
- 바인드와 같은 상태 변환 명령은 장치에서 동작을 하는 게 아니라 명령 버퍼의 이후 상태에 영향을 줍니다. 이후의 동작 명령에서 이 상태를 가지고 장치에서 상태를 변경합니다.
꽤 직관적이죠? vkCmd 하나 이상이 모인 명령 버퍼, 명령 버퍼 하나 이상이 모인 VkSubmitInfo, VkSubmitInfo 하나 이상이 모인 큐 제출까지 그대로 순서가 들어갑니다.
4.2. 신호 동작 순서
신호 동작 순서는 세마포어와 펜스에 신호를 주는 순서를 말합니다.
- vkQueueSubmit을 통해 전달된 순서
- vkQueueSubmit에 전달된 VkSubmitInfo 배열 내 순서
- vkQueueSubmit 계열과 vkQueueBindSparse에 전달된 펜스 신호는 그 안의 명령의 세마포어 신호가 모두 주어진 후에 주어집니다.
- 같은 VkSubmitInfo 계열과 VkBindSparseInfo 안에 들어간 명령 간에는 순서가 정해지지 않습니다.
- vkSignalSemaphore 명령은 큐에 올라가는 게 아니고 그 함수 자체에서 바로 신호를 주고 리턴합니다.
지금 명령 버퍼 하나를 가지고 한 프레임을 그리고 있습니다만 이미지를 받아오기와 조각 셰이더 출력, 조각 셰이더 출력과 화면에 표시하는 것 외의 동기화를 명시하지 않은 이유를 이제는 알 수 있습니다.파이프라인과 정점 버퍼를 바인드하는 작업은 동작 없이 버퍼 단위로 쓰이므로 Draw는 그걸 기다릴 필요가 없어요. 렌더패스 시작과 끝 명령은 동작 명령에 해당하는데, "이 2개"와 "그 사이의 명령"간에도 별도의 조치가 필요 없는 것 같습니다. 이건 나중에 알게 되면 추가하겠습니다. 서브 패스 내 렌더링과 종료에 대해서는 장벽 등의 동기화가 필요 없습니다.
한 가지 더 추가하자면.. 투명도 때문에 깊이 순서로 정렬한 그리기 명령 간에도 별도의 동기화 처리가 필요 없다고 합니다. 여기를 참고하세요.
요약
벌칸의 각종 명령은 시키고 나서 바로 리턴되며, 비동기적으로 실행됩니다. 벌칸은 종속성(전후 관계)을 보장하는 객체를 몇 가지 제공하는데, 그 중에 세마포어와 펜스의 간단한 특성을 알아보았습니다.
실습으로 세마포어를 이용하여 한 번의 그리고 표시하는 명령 안에서 종속성을 보장했고, 하나의 명령 버퍼를 큐에 올리고 난 후 그것을 수행하는 동안 다른 명령 버퍼를 작성하면서 아직 쓰고 있는 명령을 리셋하지 않도록 펜스를 이용하여 보장했습니다.
과제
이 프로그램에서 세마포어를 2세트에서 한 세트로 줄인다면, 즉 스왑체인 이미지 받아오기 - 그리기 명령 - 표시 명령에 대하여 세마포어 한 개만을 가지고 동기화를 시도한다면 어떤 잠재적인 문제가 있을 수 있을지 생각해 봅시다. 크게 복잡하지 않은 이유일 겁니다. 단, 아마 여러분의 장비(CPU, GPU)가 아주 오래 되거나 다른 프로그램도 많이 돌리고 있어 바쁜 게 아닌 이상 실험으로 그 문제가 눈에 보이기 기다리는 것은 꽤 어려울 겁니다.
'Vulkan' 카테고리의 다른 글
Vulkan - 5. 인덱스 버퍼와 스테이징 버퍼 (0) | 2022.06.21 |
---|---|
Vulkan - 4. 공유(uniform) 변수 (0) | 2022.06.16 |
Vulkan - 2. 안녕 삼각형 (0) | 2022.06.08 |
Vulkan - 1. 벌칸의 특징, 그리고 시작을 위해 구성하기(Windows) (0) | 2022.06.08 |
Vulkan (0) | 2022.06.08 |