2022. 6. 29. 09:40ㆍVulkan
개요
그래픽스 프로그램에서는 그래픽 카드 상 메모리 할당 수에 한계가 있습니다. 한계에 부딪치지 않더라도 한 번의 할당으로 여러 객체를 위하여 메모리를 사용하는 것이 성능상 유리하다고 그래픽 카드 제조사로부터 알려져 있다고 합니다. 이것을 관리하는 구조를 직접 만드시려면 만들 수도 있겠지만 그런 부분일수록 실수하기도 쉬운 주제에 실수했다는 사실 자체를 깨닫는 것도 어렵습니다. 성능 검증도 쉽지 않을 거고요.
때문에 메모리 관리를 효율적으로 하게 해 주는 오픈 소스 라이브러리인, GPUOpen의 Vulkan Memory Allocator를 사용하는 것을 권장합니다. 이것은 단일 헤더(현 버전 기준, 약 2만 라인)로 구성되어 있으며 MIT 라이센스 아래에서 사용할 수 있습니다. 이 라이브러리는 공식 문서가 잘 되어 있습니다. 웬만해서는 벌칸의 기본적인 요소에 대한 충분한 이해를 한 이후에 해당 문서로 공부하는 것이 권장됩니다. 급하다고요? 여기를 중심으로 궁금한 것만 찾아보세요. 게임의 규모가 커질 경우를 고려한다면 여기도 읽어 보시는 게 좋을 겁니다.
기본 원리는 크게 복잡하지는 않은데, 한 256메가 정도(설정 가능) 블록을 할당한 뒤 가능하면 그 블록 안의 메모리를 사용하고, 부족하면 /2, /4, /8로 나눠담고, 그것도 불가능하면 새 블록을 할당하는 식으로 해서 메모리를 확보한 뒤 객체에 연결하는 겁니다.
이 글에서는 기존 프로그램을 당장 확장하지는 않고, 우리의 유스 케이스에서 자주 사용될 만한 부분에 대하여 저 문서에 대한 정리를 간단히 담습니다.
목차
1. 초기 세팅
2. 버퍼나 이미지 할당하기
3. 메모리 맵
본문
1. 초기 세팅
메모리를 관리하거나, 별도의 스레드 혹은 코루틴을 쓰거나, 어딘가에 끼워지는 형식의 라이브러리 등은 링킹뿐 아니라 런타임 초기화를 요구합니다. 1.0보다 이후의 버전을 쓴다면 초기화 시에 이를 명시해야 합니다. VMA는 VmaAllocator 객체를 초기화하여 시작합니다.
헤더를 포함해야겠죠. 헤더 하나로 끝인 라이브러리이기 때문에 STB때와 마찬가지로 정의부를 포함하는 전처리를 넣어야 합니다.
// VkPlayer.cpp
#define VMA_IMPLEMENTATION
#include "externals/vk_mem_alloc.h"
VMA는 VmaAllocator 객체를 초기화하면 그것을 중심으로 시작할 수 있습니다. 멤버로 만들어 줍니다.
// VkPlayer.h
// 전역 범위
struct VmaAllocator_T;
// 클래스 범위
static VmaAllocator_T* allocator;
static bool initAllocator();
static void destroyAllocator();
이전에 말씀드린 바와 같이 헤더 파일에 VMA_IMPLEMENTATION과 같은 함수 정의부 포함 지시문을 넣는 것은 사실상 링커 오류를 발생시키고 싶다는 말과 다름 없습니다. 방법은 2가진데 위와 같이 forward 선언을 하거나, 여긴 헤더만 포함하고 cpp 파일에는 #include "VkPlayer.h"보다 위에서 두 줄(#define으로 시작하는 것)을 쓰면 됩니다. (순서 중요합니다. 그 이유를 모르면 곤란합니다. 모른다면 이것 때문이 아니더라도 #include문의 의미와 링킹에 대하여 꼭 공부하셔야 합니다.)
아마 세팅에 따라 헤더 파일에 #include <vulkan/vulkan.h> 부분에서 오류가 날 수도 있습니다. (프로젝트 전용 세팅이 아니라 저와 같이 범용 세팅인 경우) 이 경우 꺽쇠괄호를 큰따옴표로 바꿔 주세요.
VMA는 가상 장치 위에서 돌아갑니다. 생성과 해제는 가상 장치 직후/직전에 하는 게 좋겠죠. 생성과 해제는 벌칸에서 지금까지 한 것과 비슷한 패턴입니다.
bool VkPlayer::initAllocator() {
VmaAllocatorCreateInfo info{};
info.instance = instance;
info.device = device;
info.physicalDevice = physicalDevice.card;
info.vulkanApiVersion = VK_API_VERSION_1_0;
if (vmaCreateAllocator(&info, &allocator) != VK_SUCCESS) {
fprintf(stderr, "Failed to create VMA allocator\n");
return false;
}
return true;
}
void VkPlayer::destroyAllocator() {
vmaDestroyAllocator(allocator);
}
할당과 해제를 이 객체를 중심으로 하면 장치가 실행함에 있어 더 효율적으로 할 수 있을 겁니다.
더 다양한 옵션은 우리의 사용 목적에 있어 크게 필요하지 않습니다. 관심이 있다면 문서를 참고하세요.
2. 버퍼나 이미지 할당하기
문서에 첫 부분에 보면 짧은 예제가 하나 있죠. allocator를 통해 할당을 하라고 시키라고 시키고 있습니다. 이걸 우리 이미지 할당에 적용해서 프로그램이 잘 돌아가나 (알아서) 확인해 봅시다. 현재 프로그램을 기준으로는, 스테이징이 필요 없는 깊이/스텐실 첨부물을 가지고 하는 게 쉬울 것 같네요.
순서는 기본적으로 VmaAllocationCreateInfo를 채우고(대부분의 경우 AUTO 옵션이 권장됨) vmaCreateBuffer, vmaCreateImage 같은 함수들을 이용합니다. 사용 방법은 아래와 같습니다.
VmaAllocationCreateInfo vmaInfo{};
vmaInfo.usage = VMA_MEMORY_USAGE_AUTO;
// VmaAllocation allocation;
vmaCreateImage(allocator, &imgInfo, &vmaInfo, &dsImage, &allocation, nullptr);
// dsImage: 이미지 객체 핸들을 받을 곳
원래 이미지 객체를 만들어 메모리 요구사항을 조사하고 나서 그에 충분한 VkDeviceMemory를 할당 후 바인드했는데, 그 과정이 VMA에서는 vmaCreateImage에 함축됩니다. 그에 대한 핸들은 5번째 매개변수를 통해서 전달받습니다. 즉 메모리 핸들과 오프셋을 포함한 이것저것이 저기에 함축되는 셈인데, 할당 상태가 궁금한 경우에는 6번째(마지막) 매개변수에 VmaAllocationInfo 구조체의 주소를 전달하면 알려줍니다. 문서를 확인해 보세요.
해제는 이 allocation 포인터로 합니다.
vmaDestroyImage(allocator, dsImage, allocation);
3. 메모리 맵
usage로 AUTO 옵션을 이용하여 메모리를 할당한 경우에는 원래 용도에 따라 GPU에서만 읽을 수 있는 영역이나 둘 다 읽을 수 있는 영역 등에서 최적의 경우를 찾아 배치합니다. 메모리 매핑이 가능함을 보장하기 위해서는 flags에 추가로 VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT, VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT 중 하나는 포함되어야 합니다. 둘의 차이는 이름에서부터 알 수 있겠지만, 어차피 대체로 memcpy가 권장됩니다. 메모리 맵은 이렇게 쓸 수 있습니다.
void* mappedData;
vmaMapMemory(allocator, allocation, &mappedData);
memcpy(mappedData, &sendingData, sizeof(sendingData));
vmaUnmapMemory(allocator, allocation);
vkMapMemory에서 VkDeviceMemory 자리가 VmaAllocation으로 바뀐 것밖에 없죠. 이 함수는 상기한 블록을 통째로 매핑하기 때문에 확인 계층을 켰다면 접근 위반을 우려하는 경고가 뜰 겁니다만 위에서처럼 곧이곧대로 객체를 사용하면서 사이즈가 초과되지만 않는다면 상관 없습니다.
HOST_COHERENT_BIT가 없으면 명시적 캐시 플러시가 필요하다면서. VMA에서 HOST_VISIBLE로 할당했을 데이터는 저 비트 역시 보장되는 건가? | ||
문서를 그대로 읽어 볼까? vmaFlushAllocation(allocator, allocation, 0, VK_SIZE_WHOLE) 다음 vmaInvalidateAllocation(allocator, allocation, 0, VK_SIZE_WHOLE)를 호출하면 돼. 개별 함수 문서를 보면 COHERENT 메모리에서는 함수 호출 자체가 무시된다네. |
한편 공유 버퍼 같이 매핑을 유지하고자 할 때는 flags에 VMA_ALLOCATION_CREATE_MAPPED_BIT도 포함되어야 합니다. 이 경우 할당 시 바로 매핑이 되며, 주소는 create 함수의 6번째 매개변수인 구조체의 pMappedData에서 찾아오면 됩니다. 역시 쓰기 전에 플러시는 하는 게 좋겠죠. 위 함수들의 배열 버전도 있으니 필요하면 써 보는 것도 좋겠네요.
아시다시피 직접적인 작업에서는 버퍼나 이미지 객체를 쓰지, VkDeviceMemory 객체를 쓸 일은 없습니다. 그러니 여기서 명시된 작업 외에는 그냥 하던 대로 하면 됩니다.
요약
이번 글에서는 특별한 내용 없이 그저 문서의 내용을 정리한 수준이었습니다. 기본 안내 문서랑 개별 함수 문서가 다 잘 돼 있어서 사실 뭐라 많이 적기보다는 가서 확인하는 게 더 좋겠네요. 먼저 vmaCreateAllocator로 초기화한 뒤, 문서의 예제와 같이 원래 벌칸의 함수와 비슷한 형식으로 할당해 주면 됩니다.
여기에서 자주 사용되는 형식에 대한 권장 사항을 확인해 보세요.
다음 글은 동적 공유 버퍼, 그 다음 글부터는 설계 및 모듈화가 있을 예정입니다. 오늘(2022/6/30)이 사실 각 객체의 캡슐화가 완료됐어야 하는 날입니다만 아무래도 이해를 조금 더 확실히 해야 했던 것도 있고 약간 바쁜 날들도 있어 늦었습니다. [C++ 강의를 올리려 했던 때도 비슷한 이유였습니다만 아무튼 이번에는 여기까지라도 한 게 다행이라고 해야 할까요?] 7월부터는 바쁩니다. 본업이 있더라도 이건 개인적으로 꼭 하고 싶은 바이기도 해서 계속 진행은 할 텐데, 정확히 어떻게 시간이 날지는 잘 모르겠네요.
'Vulkan' 카테고리의 다른 글
Vulkan - 12. 동적으로 여러 텍스처를 사용하기 (0) | 2022.07.17 |
---|---|
Vulkan - 11. 동적 공유(uniform) 버퍼 (0) | 2022.07.02 |
Vulkan - 9. 파이프라인 동적 상태 (0) | 2022.06.28 |
Vulkan - 8. 여러 서브패스에 걸친 렌더 패스 (0) | 2022.06.26 |
Vulkan - 7. 텍스처 (0) | 2022.06.23 |