주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

주의 : 완전히 이해하고 작성한 글이 아니므로 잘못된 내용이 포함되어 있을 수 있습니다.

주의 : 이상하면 참고자료를 확인하세요.

정보 : 본문의 소스 코드는 Vulkan C++ exmaples and demos 를 기반으로 하고 있습니다. 



이 문서는 optimal tiling 에 대해서 정리하기 위한 목적으로 작성되었습니다.


Resource View


데이터라는 것은 본질적으로 일련의 메모리입니다. 그런데 그 데이터를 다루기 위해서는 데이터가 메모리에 어떤 식으로 써져 있는지 알 필요가 있습니다. Vulkan 은 리소스가 포함하고 있는 데이터에 대한 뷰( view )를 제공합니다. 그 뷰는 해당 데이터의 메모리가 어떤 포맷을 형성( formatting )하고 어떤 차원성( dimensionality )를 가지고 있는지를 기술합니다. 이것은 메모리를 보는 일종의 관점입니다.




명세에서는 버퍼와 이미지에 대해서 다음과 같이 기술하고 있습니다.


Buffers are essentially unformatted arrays of bytes whereas images contain format information, can be multidimensional and may have associated metadata.


여기에서 버퍼에 대해 설명할 때, "essentially unformmated" 라고 이야기하고 있습니다. 이게 참 헷갈리는데요, VkBufferViewCreateInfo 구조체는 내부에 format 이라는 필드를 포함하고 있습니다. 명세의 valid usage 항목에 보면, "format must be a valid VkFormat value" 라고 이야기하고 있기도 합니다.


그러므로 저 문장은 무시해 주시는 것이 정신 건강에 좋습니다. 굳이 해석을 해야 한다면 "필수적으로 포맷팅되어 있지는 않다" 라고 생각하시는 것이 좋습니다. 왜냐하면 컴퓨트 셰이더같은 곳에는 버퍼가 이미지 형태로 들어 갈 수도 있기 때문입니다.


Formats


일단 버퍼나 이미지를 위해서 어떤 포맷을 지원하느냐를 아는 것이 중요할 것입니다. 이를 위해서 VkGetPhysicalDeviceFormatProperties() 와 VkGetPhysicalDeviceImageFormatProperties() 라는 함수가 존재합니다.


Physical Device Format Properties


먼저 VkGetPhysicalDeviceFormatProperties() 에 대해서 살펴 보겠습니다. 이 메서드는 특정 포맷( VkFormat )에 대한 속성을 제공합니다.



VkFormatProperties 는 다음과 같이 정의되어 있죠.



여기에 linearTilingFeatures, optimalTilingFeatures, bufferFeatures 라는 세 개의 필드가 존재합니다. 이것들은 전부 VkFormatFeatureFlagBits 의 조합입니다. 이 포맷의 리소스가 linearTiling 일 때, optimalTiling 일 때, buffer 일 때, 어떤 용도로 사용될 수 있느냐를 지정하게 됩니다.



엄청나게 많은 플래그 비트가 존재합니다. 그 조합을 보고 용처를 정할 수 있는거죠. 예를 들어 CapsViewer 의 "Formats" 탭에는 여러 가지 포맷들이 존재하는데, 각 포맷이 어떤 플래그 비트들로 구성되어 있는지 알 수 있습니다.



각각의 의미에 대해서는 나중에 천천히 알아 보기로 하고, 일단은 linear-tiling 과 optimal-tiling 이라는 개념에 대해서 살펴 보도록 하겠습니다. 아마도 대부분에게는 익숙하지 않은 개념일 거라 생각합니다. 


Optimal Tiling


Linear-Tiling 이라는 것은 데이터가 좌상단이 Top-Left 이고 우하단이 Bottom-Right 인 형태로 되어 있는 것을 의미합니다. 위에서 view 를 설명할 때 Row-Major 라는 것을 이야기했습니다. 일반적으로 우리가 생각하는 데이터의 구성 순서죠. 말 그대로 선형적 순서로 데이터에 접근합니다.


하지만 GPU 입장에서는 이런 데이터 구조가 성능저하를 발생시킬 수 있습니다. 캐시 미스를 발생시킬 수 있기 때문이죠. 


GPU 는 필터링이라는 것을 수행하는데, 이를 위해서는 row 를 건너 뛰고 접근해야 하는 경우가 있습니다. 텍셀당 4 바이트인 1024 x 1024 크기의 텍스쳐가 있고, 2 x 2 크기의 커널로 필터링을 한다고 가정해 보죠. ( 0, 0 ) 에서 ( 0, 1 ) 에 접근할 때는 같은 캐시라인에 존재할 확률이 높습니다. 하지만 ( 1, 0 ) 과 ( 1, 1 ) 에 접근하기 위해서는 4 K 의 메모리 주소를 건너 뛰어야 합니다. 일반적으로 캐시미스가 발생할 확률이 높아집니다. 왜냐하면 보통 GPU 의 캐시라인은 CPU 의 캐시라인에 비해 매우 작기 때문입니다. 구글링을 해 보니 Nvidia 의 Fermi 와 Kelper 에서는 L2 캐시라인이 32 byte 라고 하더군요. 이 경우에 캐시라인은 다음과 같이 채워지겠죠( 한 칸에 4 Byte 라고 가정한 겁니다 ).



이 때 텍셀이 linear tiling 으로 채워져 있다면 캐시미스가 발생합니다. 




하지만 2 x 2 블록으로 텍셀이 채워져 있다면 어떻게 될까요?



필터링시에 캐시미스가 줄어들게 될 것입니다. 같은 캐시라인 안에 텍셀들이 들어 있을 확률이 높아지기 때문입니다. 이런 식으로 하드웨어를 위해 최적화된 형태로 메모리를 구성하는 것이 바로 optimal tiling 입니다. 


우리가 VkImageCreateInfo::tiling 에 VK_IMAGE_TILING_LINEAR 를 지정하면 하드웨어는 메모리를 linear 라 간주하고 읽어들일 것이고, VK_IMAGE_TILING_OPTIMAL 을 지정하면 optimal 이라 간주하고 읽어들일 것입니다.


그런데 한 가지 문제가 있습니다. Optimal filing 을 위한 포맷은 불투명하고 vendor-specific 하다는 것입니다. 그렇기 때문에 CPU 측에 있는 linear format 을 GPU 측을 위한 optimal format 으로 변경하는 작업을 해야 합니다. 바로 그것을 스테이징( staging )이라고 합니다.


Staging


D3D 에서 사용하던 스테이징이라는 개념에 친숙한 분들도 있을 겁니다. 일반적으로 GPU 메모리( ex : render-target )를 CPU 가 접근할 수 있는 메모리로 "read-back" 하는데 쓰였었죠. CopyResource() 나 CopySubResourceRegion() 같은 메서드를 호출해서 이런 일을 수행했었습니다.


이 스테이징이라는 개념은 이종의 메모리 사이에서 복사를 가능하게 하는데 사용됩니다. GPU 메모리를 CPU 메모리로, 혹은 CPU 메모리를 GPU 메모리로 옮겨주기 위해서 사용됩니다. 이 때 호스트나 디바이스에 걸맞는 포맷으로 컨버팅하는 과정이 포함됩니다. 그런데 일반적으로 스테이징을 수행할 때는 이미지가 아니라 버퍼를 사용합니다. [ 3 ] 에 의하면 NVidia 하드웨어에서는 버퍼를 사용하라고 권장하고 있더군요.


On devices with dedicated device memory, it is most likely that all resources that are used for many frames are filled through staging buffers. When updating image data we recommend the use of staging buffers, rather than staging images for our hardware. For a small data buffer, updates via the CommandBuffer provide an alternative approach by inlining the data directly.


백문이 불여일견이라고, 파일에서 로드한 linear tiling 이미지를 optimal tiling 이미지로 만드는 과정에 대해서 살펴 보도록 하겠습니다. "texture.cpp" 샘플의 loadTexture() 메서드의 구현을 살펴 볼 것입니다. 이 과정을 짧게 정리하자면, VkImage 를 DEVICE_LOCAL 로 생성하고 VkBuffer 를 HOST_VISIBLE | HOST_COHERENT 로 만들어서 스테이징하는 것입니다.


먼저 이미지를 파일로부터 로드합니다. 여기에서 texture 는 샘플에서 만든 래퍼 클래스입니다. VkImage 를 캡슐화( encapsulation )하고 있습니다.



다음으로는 스테이징 버퍼를 생성하게 됩니다. 오브젝트 생성, 메모리 할당, 메모리 바인딩의 순서를 거치게 됩니다. 그리고 파일에서 읽어들인 데이터를 vkMapMemory() 와 vkUnmapMemory() 를 통해 스테이징 버퍼에 복사합니다.



이제 스테이징 버퍼를 채웠으니, 이것을 어떻게 복사할 것인지 영역을 지정할 필요가 있습니다.



다음으로 우리가 최종적으로 optimal tiling 으로 채울 이미지를 만듭니다. 역시나 오브젝트 생성, 메모리 할당, 메모리 바인딩의 순서를 거칩니다. 스테이징 버퍼 생성과 다른 점이 있다면, 스테이징을 통해 내용을 채울 것이기 때문에 vkMapMemory() 와 vkUnmapMemory() 를 사용하지 않는다는 것입니다.



여기에서 VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT 를 지정하는 것을 보실 수 있습니다. VK_IMAGE_TILING_OPTIMAL 을 지정했기 때문에 반드시 지원되는 usage 를 사용해야 합니다. 실제로 CapsViewer 를 열어 보면 이 usage 들을 사용할 수 있도록 지정되어 있는 것을 확인할 수 있습니다.



여기에서 예리하신 분들은 뭔가 이상하다는 것을 발견하셨을 겁니다. CapsViewer 에는 SAMPLED_IMAGE_BIT 라고 되어 있는데 VK_IMAGE_USAGE_SAMPLED_BIT 와 이름이 다릅니다. 그렇습니다. 이것은 VkImageUsageFlagBits 로 이미지만을 위한 usage 입니다. VkFormatFeatureFlagBits 는 모든 리소스를 위한 usage 죠. 



버퍼도 따로 VkBufferUsageFlagBits 라는 버퍼만을 위한 usage 를 가지고 있습니다.



스테이징 버퍼와 이미지를 생성했으니, 실제로 스테이징을 수행할 차례입니다.



프라이머리 커맨드 버퍼를 만들어서 vkCmdCopyBufferToImage() 커맨드를 날리는 것을 보실 수 있습니다. 이를 통해서 실제 스테이징이 수행되는거죠.


정리


GPU 는 자신만의 최적화된 형태의 메모리 구조를 가지고 있습니다. 그런데 그 형태는 불투명하기 때문에 우리가 CPU 를 통해 그런 메모리 구조를 만들 수 없습니다. 하지만 스테이징 버퍼를 사용해 GPU 메모리에 데이터를 복사하는 과정에서 optimal format 으로의 컨버팅이 가능해 집니다.


이 문서에서는 코드에 대해서 자세히 설명하지 않았습니다. Optimal tiling 의 개념을 정리하는데 그 목적이 있기 때문입니다. 스테이징 버퍼에 대한 더 많은 정보를 원하신다면 cpp0x 님이 번역한 [ Staging buffer ] 를 참고하시기 바랍니다.



참고 자료


[ 1 ] Vulkan 1.1 Specification, Khronos.


[ 2 ] CUDA Memory and Cache Architecture, The Supercomputing Blog.


[ 3 ] Vulkan Memory Management, NVidia.

+ Recent posts