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

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

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



VkInstance 를 생성할 때 VkApplicationInfo 라는 것이 필요합니다. 거기에는 ApiVersion 이라는 필드가 있습니다.



명세에서의 설명을 보면 다음과 같습니다.


apiVersion must be the highest version of Vulkan that the application is designed to use, encoded as described in the API Version Numbers and Semantics section. The patch version number specified in apiVersion is ignored when creating an instance object. Only the major and minor version of the instance must match those requested in apiVersion.


- Vulkan specification 1.1.96


이 필드에는 VK_API_VERSION_1_0 이나 VK_API_VERSION_1_1 을 넣게 됩니다. 이것은 다음과 같이 정의되죠.



그리고 VK_MAKE_VERSION 은 다음과 같이 정의되어 있습니다.



여기에서 문제가 하나 발생합니다. 제가 지금 제 로컬 머신에 받아 놓은 LUNARG Vulkan SDK 의 버전은 1.1.85.0 입니다. 그리고 LUNAR XCHANGE 에 가 보면 여러 가지 버전들이 있는 것을 알 수 있죠.



여기에는 버전 번호가 하나 더 있어 헷갈립니다. VK_MAKE_VERSION 은 3 개 항목을 입력으로 받는데요, 여기에는 네 번째 버전이 하나 더 들어 가 있습니다.


대체 이 버전들의 의미는 무엇이고, 왜 패치 버전은 사용하지 않는 것일까요?


Api Version



Vulkan 명세에 의하면, Vulkan API 는 세 개의 버전 정보로 정의되며 32 비트 정수 안에 패킹( packing )됩니다; Major, Minor, Patch.



이것은 명세의 버전과 동일합니다. Vulkan 명세에서는 각 버전에서의 차이점을 다음과 같이 설명하고 있습니다.


  • Major 버전에서는 API 셋들이 변경됩니다. 보통 새로운 기능과 인터페이스가 추가되며, 기능이 변경되기도 합니다. 그리고 기능이 없어지거나 수정되기도 하죠. 즉 하위 버전과의 호환성이 유지되지 않을 수 있습니다. 아직 1.1 밖에 안 된 상황이라 미래에도 이런 일이 벌어질 지는 미지수네요.

  • Minor 버전에서는 새로운 기능이 추가되고, 일부 기능들은 변경되거나 없어질 수 있습니다. 하지만 없어진 기능들을 위한 인터페이스는 그대로 유지됩니다.

  • Patch 버전에서는 명세나 헤더가 조금만 변경됩니다. 보통 버그때문에 변경되는 것으로서 하위 호환성이 유지되어야만 합니다.


그러면 LUNARG SDK 의 버전은 무엇일까요? SDK 버전의 처음 세 개는 Vulkan Api 버전과 동일합니다. 단지 같은 버전에 대해서 다시 패치할 때 마지막 버전을 넣습니다.


$(Major).$(Minor).($Patch).($LUNARG_Patch)


뭐 사람이 하는 일이다보니 실수가 없을 수는 없겠죠. Api 버전은 명세일 뿐이고 실제 구현은 다르기 때문에 이런 버전을 추가한 것으로 보입니다.


Comparing patch versions


그런데 왜 ApiVersion 을 넣을 때는 patch 버전이 무시되는 것일까요?


안타깝게도 Vulkan SDK 에서 배포하고 있는 라이브러리들은 이름에 버전정보를 담고 있지 않습니다. 그리고 헤더에다가도 버전 정보를 담고 있지 않죠; 예를 들어 "vulkan-1-1-85.lib" 같은 라이브러리나 "VkCreateDevice_1_1_85()" 같은 함수는 없습니다. 아마도 Dll Hell 을 피하기 위해 항상 가장 높은 버전을 사용하도록 하고 있는 것으로 보입니다. 심지어는 라이브러리 이름조차 major 버전만을 사용합니다; "vulkan-1.lib".


그러므로 VK_MAKE_VERSION 에서는 패치 번호가 무시됩니다. 


제가 처음에 Api Version 을 입력할 때는 이걸 모르고 있었습니다. Api Version 에 patch 버전을 넣을 수 있길래 버전별로 그 결과를 비교할 수 있겠다 싶어서, 다음과 같이 만들었는데 쓸데 없는 짓이었군요.



만약 LUNARG SDK 의 1.1.85.0 버전에서 발생하던 버그가 1.1.92.1 버전에서 해결되었는지 알고자 한다면, 두 개의 SDK 를 모두 깔아 놓고 "VULKAN_SDK", "VK_SDK_PATH" 등의 환경변수를 변경해 가면서 테스트하는 수밖에 없습니다.


환경변수 설정을 batch 파일 등을 통해서 동적으로 제어할 수 있으면, 결과를 비교하는 더 나은 개발 환경을 만들 수는 있을 것 같습니다.

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

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

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

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


 

Memory 관련해서 정리하다가 의문을 하나 가지게 되었습니다.

 

DEVICE_LOCAL 을 지정하면 디바이스 램에 메모리가 할당됩니다. 하지만 allocator 를 사용해서 메모리를 할당하면 어떻게 될까요?

 

먼저 간단하게 malloc 을 사용하는 allocator 용 콜백 함수들을 만들었습니다.

 

 

그리고 나서 allocator 에 할당합니다.

 

 

그리고 1000 개의 이미지를 생성해서 메모리를 할당해 줬습니다. 이 때 앞에서 만든 allocator 를 사용했습니다.

 

 

여러분은 어떤 결과가 나올 거라 예상하시나요? 저는 호스트 램의 메모리가 올라갈 것이라 예상했습니다. 왜냐하면 malloc 은 최종적으로 VirtualAlloc 과 연결되어 있고 호스트 램을 위한 메모리를 할당할 것이라 생각했기 때문이죠. 또한 호스트 메모리 중에서 공유 메모리를 사용할 것이라 생각했습니다.

 

하지만 결과는 충격적( ? )이었습니다. texture 프로세스는 1.6 GB 의 디바이스 메모리를 할당합니다. 제가 캡쳐하는 시점에 다른 응용프로그램이 디바이스 메모리를 사용했는지 수치가 좀 높네요. 실제 수치는 크게 신경쓰지 않으셔도 됩니다. 어디에서 메모리가 늘어나고 있는지가 중요하죠.

 

 

 

리소스 모니터를 보면 좀 다른 결과이긴 하더군요.

 

 

이러한 결과를 볼 때 다음과 같은 가설을 세울 수 있습니다.

 

  • malloc 은 호출되는 문맥에 따라서 호스트 램에 메모리를 할당하기도 하고 디바이스 램에 메모리를 할당하기도 합니다. CUDA 버전 malloc 함수가 따로 있는 이유가 여기에 기반하는 것 같네요.
  • "커밋 크기"는 램의 종류에 상관없이 프로세스가 사용한 메모리의 총량을 보여줍니다.

 

이건 제가 의도한 환경이 아닙니다. 그래서 이미 할당된 메모리를 반환하는 allocator 를 만들어 보기로 했습니다. Reallocation  Free 는 별 의미가 없으므로 대충 넘겼습니다. 메모리가 어디에 할당되는지 확인하는 용도로 만든 것이므로 그냥 Allocation 만 구현했습니다.

 

그래서 처음에 2 GB 를 할당하고 요청이 올 때마다 메모리를 증가시켜 그 주소를 반환할 겁니다.

 

 

실행해 보니 역시나 충격적인 결과를 줍니다. CPU 와 GPU 의 메모리 usage 는 동일한데, 리소스 모니터의 "커밋" 만 다른 결과를 줍니다.

 

 

제가 할당한 2 GB 만 고스란히 늘어났습니다. 즉 DEVICE_LOCAL 을 지정한 메모리를 할당할 때 호스트 메모리를 반환하면, 그것이 무시되고 자체적으로 메모리를 할당한다고 추측해 볼 수 있습니다. Allocator 를 사용하는 의미가 없는거죠.

 

정리

 

DEVICE_LOCAL 을 위한 메모리에 대해서 이미 할당된 메모리를 관리하는 allocator 를 사용하면 메모리가 늘어납니다. 그러므로 HOST_VISIBLE 이나 HOST 전용 메모리( 아무 속성도 지정되지 않은 것 )에 대해서만 allocator 를 사용하시기 바랍니다.

 

이런 관리를 하는 건 매우 귀찮은 일이기 때문에, allocator 를 사용하고 싶으시다면 GPU Open 에서 제공하는 "Vulkan Memory Allocator" 를 사용하는 것을 추천합니다. 헤더 파일 하나만 include 해서 사용할 수 있는 편리한 라이브러리입니다.

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

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

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

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



독립적인 Video Card 를 장착한 일반적인 PC 에서의 메모리는 다음과 같이 구성되어 있습니다.



Windows 10 의 작업관리자의 성능탭을 보면 다음과 같이 그 정보가 나와 있습니다.



여기에서 공유 메모리는 호스트 램에 할당될 수 있는 메모리를 의미합니다. 워낙 디바이스 램의 크기가 작다보니 나온 방식이죠. 어쨌든 메모리 속성은 다음과 같이 매핑될 수 있습니다.



VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT 를 지정하면 GPU 전용 메모리에 할당되고, VK_PROEPRTY_HOST_VISIBLE_BIT 를 지정하면 공유 메모리에 할당됩니다. 아무 것도 지정하지 않는다면 CPU 전용 메모리가 되겠죠. 물론 거의 사용되지 않긴 합니다. HOST_VISIBLE 인 경우에는 항상 HOST_COHERENT 이거나 HOST_CACHED 여야만 합니다. 


일반적으로 이미지는 DEVICE_LOCAL 로 생성되고, 버퍼는 HOST_VISIBLE 로 생성되는 것 같습니다. 그리고 버퍼의 경우에는 HOST_VISIBLE 과 함께 HOST_COHERENT 를 사용하고 있는 것 같습니다. 아마도 버퍼에 대한 쓰기 연산이 많기 때문인 것으로 보입니다.


샘플의 "texture" 프로젝트에서는 텍스쳐를 생성해서 메모리를 바인딩합니다.



DEVICE_LOCAL 을 지정했으므로 이것은 GPU 전용 메모리에 할당될 것이라 예측할 수 있습니다. 실제로도 그렇죠.



그럼 이 텍스쳐를 공유 메모리에 할당하는 것도 가능할까요? 속성을 HOST_VISIBLE | HOST_COHERENT 로 변경해 봤습니다.



하지만 이렇게 하면 일치하는 메모리 타입을 찾는데 실패해서 크래시가 납니다( 위의 코드에서 getMemoryType() 호출 ). 


하지만 아직까지 메모리 타입과 힙에 대해서 제대로 이해하지 못했기 때문에 에러가 나도 뭘 봐야할지 갑갑하기만 할 것입니다. 그래서 이 부분에 대해서 좀 더 이해할 필요가 있습니다.


vkGetImageMemoryRequirements() 는 이미지를 위한 메모리 요구사항을 반환합니다.



Memory Management Basics ] 에서 언급했듯이, Vulkan 은 오브젝트 생성과 메모리 할당을 분리하고 있습니다. 그래서 두 번째 인자인 image 에는 이미지의 해상도나 포맷 정보가 포함되어 있죠. 그러면 vkGetImageMemoryRequirements() 는 이를 가져가서 세 번째 인자에다가 적절한 요구사항을 채워 줍니다.



  • size 는 이미지를 생성하는데 필요한 바이트 단위 메모리 크기입니다.
  • alignment 는 이미지를 생성하는 데 필요한 메모리가 몇 바이트 단위에서 정렬되어 있어야 하는지를 의미합니다.
  • memoryTypeBits 는 이미지를 위해 지원되는 메모리 타입들에 대한 비트마스크입니다. i 번째 비트는 VkPhysicalDeviceMemoryProperties 의 i 번째 메모리 타입을 의미합니다.


제가 vkGetImageMemoryRequirements() 를 호출했을 때 다음과 같은 결과를 획득했습니다.



이 이미지를 지원하는 메모리 타입은 1 번과 7 번이라는 의미가 됩니다( 1 번과 7 번 비트가 1 이기 때문입니다 ). 


CapsViewer 를 열어 보도록 하겠습니다. CapsViewer 의 Memory 탭의 내용은 메모리 타입과 힙에 대한 정보를 담고 있습니다. 


스크롤을 마지막까지 내리면 힙 정보가 있죠. 이는 VkPhysicalDeviceMemoryProperties 의 memoryHeaps 에 정의되어 있는 정보를 보여줍니다.



두 개의 힙이 존재하는 것을 보실 수 있습니다. 0 번 힙에는 HEAP_DEVICE_LOCAL 로 지정되어 있고, 1 번 힙에는 HEAP_DEVICE_LOCAL 이 지정되어 있지 않습니다. 이제 스크롤을 위로 올려 보면, 각 메모리 타입에 대한 정의를 볼 수 있습니다. 이는 VkPhysicalDeviceMemoryProperties 의 memoryTypes 에 정의되어 있는 정보를 보여줍니다.


예상하고 계시겠지만 HeapIndex 가 0 이라면 DEVICE_LOCAL 만 활성화될 수 있고, 1 이라면 DEVICE_LOCAL 은 절대 활성화될 수 없겠죠. 



아까 위에서 이미지를 위해서 1 번과 7 번 메모리 타입이 지원된다고 했었죠( 10000010 )? 1 번 메모리 타입은 순수하게 호스트를 위해서만 사용될 수 있습니다. 그러므로 HOST_VISIBLE | HOST_COHERENT 를 지정할 수가 없죠. 


7 번은 아래와 같이 DEVICE_LOCAL 로만 사용할 수 있는 메모리 타입입니다.



그러므로 이 이미지를 위한 메모리는 순수하게 GPU-Only 이거나 CPU-Only 여야 한다는 것을 알 수 있습니다.


어떤 리소스가 어떤 메모리 타입을 사용할 수 있느냐는 리소스를 생성할 때 사용한 정보에 의존합니다. 이 경우에는 VkImageCreateInfo 의 내용에 의존하겠죠. 아직까지 정확하게 어떤 항목들이 영향을 주는지는 잘 모르겠지만, 아마도 usage 가 가장 큰 영향을 주지 않을까 생각하고 있습니다.


정리


리소스의 메모리는 힙에 할당됩니다. 그 힙은 크게 HEAP_DEVICE_LOCAL 플래그를 포함하는 힙과 그렇지 않은 힙으로 나뉩니다. 어떤 힙을 사용하느냐에 따라서 메모리 타입이 결정이 되는데요, 특정 리소스는 그것이 사용할 수 있는 메모리 타입의 종류가 정해져 있습니다.


힙과 메모리 타입의 종류는 VkPhysicalDeviceMemoryProperties 에 저장되어 있으며, CapsViewer 에서 일목요연하게 확인하실 수 있습니다.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ 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.

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

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

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

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



지난 번에는 메모리 관리의 기본에 대해서 살펴 봤는데, 이 번에는 각 메모리 타입의 용례에 대해서 좀 깊게 살펴 보도록 할 계획입니다.


DEVICE_LOCAL


의심할 바 없는 가장 빠른 메모리를 의미합니다. 디바이스 내에서만 접근할 수 있습니다. 렌더타깃이나 매우 자주 접근해야 하는 리소스들을 위해서 사용됩니다.


[ 2 ] 에 의하면, DEVICE_LOCAL 할당에 실패했을 경우에는 HOST_VISIBLE | HOST_COHERENT 를 통해서 생성하라고 합니다. 이 때 HOST_CACHE 를 사용하지 말라고 하는군요. 그리고 물리 디바이스 메모리 단편화 때문에 할당이 실패하는 것을  막기 위해서는, 게임 내에서 해상도 변경과 같은 이유로 재할당될 때, 전체를 지우고 재할당하는 것을 추천한다고 합니다.


샘플에서 이를 사용하는 경우는 다음과 같았습니다( 아래에서 비트 연산자를 사용한 것은 Buffer Usage 를 표현하기 위함입니다 ).


  • Image.
  • VERTEX | STORAGE | TRANSFER_DST Buffer.
  • VERTEX | TRANSFER_DST Buffer.
  • INDEX | TRANSFER_DST Buffer.
  • INDIRECT | STORAGE | TRANSFER_DST Buffer.
  • STORAGE | TRANSFER_SRC | TRANSER_DST Buffer.


HOST_VISIBLE | HOST_COHERENT


호스트에서 접근할 수 있고, 그 접근이 메모리 일관성을 보장합니다. [ 2 ] 에 의하면 CPU 에서 GPU 로 데이터를 보낼 때 적절하다고 합니다.


샘플에서 이 조합을 사용하는 경우는 다음과 같았습니다( TRANSFER_SRC 버퍼는 일명 "Staging Buffer" 입니다 ).


  • TRANSFER_SRC Buffer.
  • UNIFORM Buffer.
  • STORAGE | TRANSFER_SRC Buffer.
  • VERTEX Buffer.
  • INDEX Buffer.
  • CONDITIONAL_RENDERING Buffer.


버퍼 중 대부분은 이런 조합을 사용합니다. 


예전에는 스키닝 결과에 기반해 파티클을 뿌리기 위해서 CPU 스키닝을 했는데, 이걸 잘 이용하면 이제는 GPU 스키닝의 결과를 받아 올 수 있을 것 같습니다.


HOST_VISIBLE | HOST_CACHED


[ 2 ] 에 의하면, GPU 에서 CPU 로 데이터를 보내는 경우에 이러한 조합을 사용한다고 합니다. 스크린샷을 찍거나 Hierarchical Z-Buffer occlusion 을 위한 피드백을 제공하는 경우에 좋다고 합니다.


샘플에서는 사용하는 경우가 없더군요.


HOST_VISIBLE


특이하게 HOST_VISIBLE 만 사용하는 경우가 있습니다. 명세에 의하면 HOST_CACHED 가 지정되지 않으면 기본적으로 HOST_COHERENT 하다고 이야기하고 있는데( however uncached memory is always host coherent ), 이건 HOST_VISIBLE | HOST_CACHED 와 동급으로 생각해야 하는 것인지 의문이 듭니다.


일단 샘플에서 이 플래그만 사용한 경우는 다음과 같습니다.


  • UINFORM Buffer.
  • TRANSFER_SRC Buffer.
  • VERTEX Buffer.
  • INDEX Buffer.



참고 자료


[ 1 ] Vulkan 1.1 Specification, Khronos.


[ 2 ] Vulkan Device Memory, gpuopen.

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

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

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

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



Vulkan 이나 D3D 12 로 오면서 가장 큰 변화는 저수준( low-level ) API 를 제공한다는 점에 있다고 할 수 있습니다. 앞서 살펴 봤던 커맨드 큐도 그렇고 메모리마저 응용프로그램이 관리할 수 있도록 하고 있습니다.


다음과 같은 이유들 때문에 커스텀 메모리 관리를 사용하게 됩니다[ 2 ]:


  • 메모리 할당은 종종 다소 무거운 운영체제의 동작을 포함합니다.
  • 이미 할당한 메모리를 재사용하는 것이 그것을 해제하고 새로운 메모리를 재할당하는 것보다 빠릅니다.
  • 연속된 청크의 메모리에 있는 오브젝트는 캐시 활용도를 높일 수 있습니다.
  • 하드웨어에 대해 잘 정렬된 데이터는 더 빠르게 처리될 수 있습니다.


오늘은 이러한 메모리 관리에 대해서 알아 보고자 합니다.


Device & Host


Vulkan 에서 "메모리 관리" 라는 개념에 친숙해지기 위해서는 디바이스와 호스트라는 관리 영역에 대해서 익숙해질 필요가 있습니다. 디바이스는 그래픽 카드를 의미하는 것이며 호스트는 디바이스를 이용하는 응용프로그램이 실행되는 시스템을 의미합니다.


일반적으로 디바이스와 호스트는 개별 메모리를 가지고 있습니다. SoC( System On Chip )을 사용하는 일부 머신을 제외하면 대부분의 데스크탑은 그래픽스 카드를 따로 가지고 있습니다. 물론 최신의 범용 프로세서들이 그 내부에 그래픽스 카드를 내장하고 있기는 하지만, 게이밍 그래픽스 하드웨어로서 그것을 사용하는 경우는 많지 않으므로 신경쓰지는 않겠습니다.


PS4 나 Xbox One 과 같은 게이밍 콘솔들도 SoC 에 GPU 를 내장하고 있습니다.


전통적인 PC 들은 고사양 GPU 와 같은 특정 작업을 위한 가속기들을 추가하는 옵션을 가진 일반화된 시스템으로서 설계되었습니다. 콘솔의 경우에는 적은 공간을 필요로 하고 게임을 위해서 목적성있게 만들어졌기 때문에, 많은 컴포넌트들을 통합하는 것이 유리합니다. 스마트폰, 게임 콘솔, PC 는 모두 SoC 혹은 System-on-Chip 이라 알려진 상당히 통합되어 있는 프로세서를 사용합니다. SoC 는 실제 CPU 코어와 CPU L1, L2 캐시, 그래픽 프로세서, 다양한 연결성( USB 포트, 하드 드라이브 ), 그리고 다른 기능블락들 및 메인 시스템 RAM 사이의 인터페이스 역할을 하는 메모리 컨트롤러 등을 포함합니다. 예전에는 이런 기능들이 보통 마더보드상에 다중의 칩으로 나뉘어 있었지만, 요새는 하나의 기능 블락으로 통합됩니다.


출처 : [ 3 ]


이런 SoC 같은 경우에는 모든 컴포넌트들이 RAM 을 공유하게 됩니다. 예를 들어 RAM 중에서 GPU 를 위해 사용되는 메모리를 할당/해제/전송하기 위해 사용하는 버스를 갈릭 버스( Garlic Bus )라 부르고, CPU 를 위해 사용되는 메모리를 할당/해제/전송하기 위해 사용하는 버스를 어니언 버스( Onion Bus )라고 부릅니다. 예전에 콘솔쪽 작업을 할 때 이것이 PS4 전용 용어인줄 알았는데, XBox 문서에서도 그렇게 구분하고 있더군요. 그런데 이 버스들이 사용할 수 있는 메모리의 총량을 미리 정할 수 있도록 하고 있습니다. 예를 들어 "갈릭 버스를 위해서 4 GB 를 할당한다"고 정할 수 있습니다. 그러면 소위 말하는 그래픽스 메모리는 4 GB 인 것입니다. 하나의 램을 사용하는데 굳이 이렇게 하는 이유는 모르겠습니다. 아마도 성능을 위해 메모리 지역성( locality )을 유지하려고 하는게 아닌가 싶습니다.


아래 그림을 보면 GPU 를 제외한 다른 장치들은 노스브릿지를 통해서 DRAM 과 통신합니다. 이때 사용하는 버스를 어니언 버스라고 부릅니다. 그리고 GPU 는 전용 메모리 컨트롤러를 통해서 DRAM 과 통신합니다. 이 때 사용하는 버스를 갈릭 버스라 부릅니다. 그런데 갈릭 버스의 대역폭이 어니언 버스의 대역폭보다 크다는 것을 알 수 있습니다. 이는 노스브릿지의 한계 때문이라고 합니다[ 4 ]. Xbox one 의 구조에 대해서 더 자세하게 알고자 한다면 [ 4 ] 를 참고하세요.


출처 : [ 4 ].


어쨌든 이러한 SoC 가 아닌 이상에는 디바이스 메모리와 호스트( 시스템 ) 메모리는 구분될 수밖에 없습니다.


Heap Memory


힙이라는 것은 디바이스나 호스트에 있는 RAM 에 할당된 메모리 블락을 의미합니다. 


이것을 관리하는 것은 OS 마다 다릅니다. 일단 윈도우즈에 국한해 설명드리자면 다음과 같이 메모리를 계층적으로 관리합니다( 아래 그림은 Win32 시절에 작성된 것이지만 Win64 에서도 유사합니다. ).


출처 : [ 9 ].


물리 메모리는 페이지 단위로 관리됩니다. 일반적으로 4 KB 를 사용하는 것으로 알고 있습니다. 예를 들어 4 KB 의 페이지를 사용하는 OS 에서 응용프로그램이 1 B 의 메모리를 할당하고 싶다고 하더라도 최소 4 KB 의 메모리가 할당된다는 것입니다. 1 B 를 위해서 4 KB 를 할당하면 낭비가 심해질 것입니다. 그러므로 페이지에서 다중의 오브젝트를 위한 메모리가 할당될 수 있습니다.


그런데 여기에는 몇 가지 문제가 있습니다.


  • OS 에는 여러 개의 프로그램이 있을 수 있는데, 그것들의 메모리 사용량을 합치면 물리 메모리의 양을 넘어설 수 있습니다. 이를 위해서는 메모리 상태를 관리하면서 필요한  메모리를 물리 메모리에 올리거나 불필요한 메모리를 물리 메모리에서 내려서 백업하는 등의 복잡한 작업이 필요합니다.
  • 페이지 내부에서 메모리 할당/해제하는 기능을 구현해야 합니다.


그래서 OS 들은 가상 메모리( virtual memory )라는 개념을 중심으로 응용프로그램의 메모리를 관리하고 위에서 언급했던 여러 가지 이슈들은 OS 에서 알아서 처리합니다. 그래서 어떤 메모리 할당함수든 최종적으로는 VirtualAlloc/VirtualFree 같은 함수들을 호출하게 됩니다. new/delete, malloc/free 도 모두 최종적으로는 앞의 함수들을 호출합니다.


그런데 VirtualAlloc/VirtualFree 도 메모리 상태( Reserved, Committed, Free )관리를 할 필요가 있습니다. 그래서 이것보다는 좀 더 쉽게 메모리 블락을 사용할 수 있는 레이어로서 힙이 만들어졌습니다. 일단 따로 OS 의 메모리 할당함수를 사용하지 않는다면 new/delete, malloc/free 류의 연산자/함수들은 이 힙과 연결됩니다. 이 힙은 최소한 4 KB 의 크기를 가집니다. 위에서 말한 물리 메모리 크기와 같죠. 이렇게 해야 페이지 단위로 물리 메모리에 올렸다가 다시 내렸다가 하는 것이 가능해집니다.


이야기가 좀 길어졌는데요, 더욱 자세한 내용에 대해 알고자 하신다면 [ 9 ], [ 10 ] 의 MSDN 문서를 참고하시기 바랍니다.


어쨌든, 이제 상상을 좀 해 보죠. 앞서 응용프로그램은 가상 메모리를 사용한다고 했습니다. 그러면 그 메모리들은 물리 메모리에 매핑되어 올라갔다 내려갔다 합니다. 실제 물리 메모리에서 그 주소가 어떻게 될지는 아무도 모르죠. 하지만 적어도 응용프로그램에서의 가상메모리는 4 KB 단위로 선형적입니다.


예를 들어 2 GB 의 물리메모리( RAM )를 가지고 있다고 가정하고, 이상적인 환경에서 응용프로그램이 그 물리메모리를 온전히 사용할 수 있다고 가정해 봅시다. 그리고 힙 하나의 크기를 64 KB 라고 가저합시다. 그러면 응용프로그램이 8 GB / 64 KB = 32768 개 만큼의 힙을 가지고 있게 됩니다. 물론 OS 마다 기본 힙 크기와 물리 메모리 크기는 다를 수 있으므로 단순한 예일 뿐입니다.


그러면 응용프로그램 입장에서는 다음과 같이 선형적으로 메모리를 관리할 수 있겠죠.



"그래서 뭐?" 라는 의문이 들 겁니다. 이렇게 선형적으로 관리하게 되면 할당된 가상 메모리 주소를 64 K 로 나누는 것만으로 힙의 인덱스( offset )을 알아낼 수 있다는 것입니다. 이것보다는 복잡하기는 하지만, 일반적인 페이지 기반 메모리 풀들은 이런 식으로 메모리 풀을 관리합니다. 여기서 이야기하고자 하는 핵심은 대부분의 커스텀 메모리 관리자들이, 메모리를 관리할 때, 특정 크기의 힙 블락이나 가상 메모리 블락을 사용한다는 것입니다.


뜬금없이 여기에서 힙에 대해서 이야기한 이유는, 아래에서 메모리 타입을 이야기할 때, 힙에 대해서 언급하기 때문입니다.


Object Creation & Memory Allocation


모든 Vulkan 오브젝트들은 "vkCreate" 라는 접두어를 가진 함수들을 통해서 생성됩니다. 그런데 이런 함수에는 항상 pAllocator 라는 인자를 넘기게 되어 있습니다. 예를 들어 vkCreateInstance() 의 원형은 다음과 같습니다.



만약 pAllocator 에 nullptr 를 넣으면 커스텀 할당이 없이 그냥 OS 기본동작에 의해 메모리 할당이 이루어집니다. VkAllocationCallbacks 의 정의는 다음과 같습니다.



이 구조체의 필드에는 여러 개의 함수 포인터들이 존재합니다. 기본적으로 malloc(), free(), realloc() 과 관련한 호출에 매핑되는 콜백 함수들이 있고, 통지( nofitication )와 관련한 콜백 함수들이 있습니다. 이런 콜백들을 잘 이용하면 메모리 프로우파일러들을 만들 수도 있습니다. 할당하는 예를 들어 보면 다음과 같습니다[ 8 ].



이 글의 주제는 커스텀 메모리 관리자 구현과 관련해서 이야기하는 것이 아니므로 더 깊게 파고들지는 않겠습니다.


Vulkan 에서는 오브젝트 생성과 메모리 할당을 분리해서 생각하고 있습니다. 대표적으로 그런 형태를 띄고 있는 오브젝트를 리소스라 부릅니다. 리소스는 크게 두 가지로 나뉩니다; 버퍼( buffer )와 이미지( image ).


버퍼는 단순한 선형 메모리를 의미합니다. 그리고 이미지는 구조화되고 타입 및 포맷 정보를 가진 메모리입니다. 이러한 리소스들은 오브젝트 생성과 메모리 할당이 분리되어 있습니다. 그래서 이런 개념을 모르는 상태라면, 리소스를 생성했지만 실제로는 아무런 메모리도 할당되어 있지 않아 당황하게 되는 상황에 부딪힐 수 있습니다.


리소스를 온전히 생성하기 위해서는 다음과 같은 과정을 거쳐야 합니다.


  • vkCreateBuffer() 나 vkCreateImage() 를 호출해서 오브젝트를 생성합니다.
  • vkAllocateMemory() 를 호출해서 메모리를 할당합니다.
  • vkBindBufferMemory() 나 vkBindImageMemroy() 를 호출해서 할당된 메모리를 오브젝트와 연관시킵니다.


굳이 이렇게 복잡한 과정을 거치는 이유는 메모리 관리를 유연하게 만들기 위한 것으로 보입니다. 이런 구조를 가지게 되면 호스트측에서 오브젝트를 파괴하지 않고 메모리를 재할당하는 것이 가능해집니다. 그리고 서로 다른 오브젝트가 같은 메모리를 공유할 수 있도록 해 줍니다. 심지어는 ( 서로 다른 디바이스에서 ) 같은 메모리를 공유하도록 만들수도 있다고 합니다.


Memory Properties


앞에서 vkAllocateMemory() 에 대해서 언급했습니다. 이 함수의 원형은 다음과 같습니다.



pAllocator 에 대해서는 앞에서 살펴 봤었죠. pAllocateInfo 에 대해서 살펴 보도록 하겠습니다. VkMemoryAllocateInfo 라는 것은 다음과 같이 정의됩니다.



여기에서 allocationSize 는 바이트 단위 할당 크기므로 별건 없고, memoryTypeIndex 라는 녀석이 중요합니다. 이 memoryTypeIndex 라는 것은 VkPhysicalDeviceMemoryProperties 구조체로부터 가지고 온 인덱스입니다. 이 구조체는 vkGetPhysicalDevicememoryProperties() 함수 호출을 통해서 얻어 올 수 있는 것이며, 다음과 같이 정의되어 있습니다.



이제 슬슬 짜증나기 시작하실텐데요... 곧 정리가 될 겁니다. 새롭게 두 가지 종류의 필드가 존재합니다. 메모리 타입과 힙입니다. 이 메모리 타입과 힙 타입을 잘 골라야 자신이 원하는 형태의 메모리를 할당받을 수 있습니다.


Memory Type


메모리 타입은 다음과 같이 정의됩니다.



heapIndex 는 이 메모리가 연관되어 있는 힙이 무엇인지 지정하는 것입니다. 이건 VkPhysicalDeviceMemoryProperties::memoryHeapCount 보다는 작아야 합니다. 그리고 propertyFlags 는 접근성이나 할당 방식과 관련한 속성들의 비트 플래그입니다. 이는 다음과 같이 정의되어 있습니다.



이 플래그값이 0 이라면 호스트 메모리를 의미합니다. 기본적으로 응용프로그램이 시스템 메모리에 접근하는 것은 ( 응용프로그램이 할당한 메모리라는 가정하에서 ) 제약이 없기 때문에, 디바이스에서 할당되는 메모리에만 이러한 플래그가 조합되는 것으로 보입니다.


그 의미는 다음과 같습니다.


  • VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT : 디바이스 전용 메모리입니다. 호스트에서 접근할 수가 없습니다.
  • VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT : 호스트에서 vkMapMemory() 커맨드를 통해서 접근할 수 있는 디바이스 메모리입니다.
  • VK_MEMORY_PROPERTY_HOST_COHERENT_BIT : 코우히어런트( coherent )는 일관성이라는 뜻이죠. 이것과 관련해서는 언급해야 할 내용이 좀 많아서 아래에서 따로 설명하도록 하겠습니다.
  • VK_MEMORY_PROPERTY_HOST_CACHED_BIT : 호스트에 캐싱된 메모리라는 의미입니다. 캐싱되지 않은 메모리에 호스트가 접근하는 것은 캐싱된 메모리에 접근하는 것 보다 느립니다. 캐싱되지 않은 메모리는 항상 HOST_COHERENT 합니다.
  • VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT : 즉시 할당되는 것이 아니라 필요할 때 할당될 수 있는 디바이스 전용 메모리입니다. VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 와 같이 설정되어서는 안 됩니다.


CPU 나 GPU 는 위의 플래그들에 따라서 메모리에 접근하는 것이 가능합니다. 여기에서 약간 혼란스러운 점이 있을 수 있는데, 물리적으로 호스트에 존재하는 메모리라고 할지라도 디바이스용으로 할당되면 디바이스 메모리입니다. 여기에서 제가 "물리 디바이스 메모리" 가 아니라 "디바이스 메모리" 라고 하고 있다는 데 주의하시기 바랍니다. SoC 가 아니더라도 디바이스가 호스트 메모리의 일부를 공유할 수 있습니다. 순수하게 물리 디바이스 메모리에 할당하기 위해서는 DEVICE_LOCAL 로 만드시면 됩니다.


출처 : [ 8 ].


Vulkan spec 에 따르면 호스트 메모리와 디바이스 메모리는 다음과 같이 정의되어 있습니다.


Host memory is memory needed by the Vulkan implementation for non-device-visible storage.


Device memory is memory that is visible to the device — for example the contents of the image or buffer objects, which can be natively used by the device.


Coherent Memory Access


다중의 코어가 동일한 메모리에 접근하고 있다고 가정해 봅시다. 한 코어는 쓰고 있고 다른 코어는 읽고 있다고 가정해 보죠( 전자를 라이터라 하고 후자를 리더라 하겠습니다 ). 라이터가 메모리에다가 데이터를 쓸 때 그것은 데이터를 메모리에 직접 쓰지 않습니다. 일단 자신의 캐시에다가 쓴 다음에 그것을 메모리로 옮기게 됩니다. 리더는 동일한 메모리에 접근을 해야 하죠. 마찬가지로 리더도 메모리를 바로 읽는 것이 아니라 캐시에서 읽게 됩니다. 


동시에 메모리에 접근을 하는 상황에서 캐시를 공유하고 있다면 별 문제가 없겠죠. 하지만 코어마다 캐시를 따로 유지하는 경우가 많기 때문에 동일한 내용을 획득할 수 있는 방법이 필요합니다. 이것을 캐시 일관성이라고 하죠. 캐시 일관성에 대해 더 알고자 한다면 [ 7 ] 의 문서를 참조하세요. 그림을 통해서 쉽게 설명하고 있습니다.


출처 : [ 6 ].


SoC 를 사용하지 않는 대부분의 머신에서는 CPU 와 GPU 가 다른 디바이스에 존재합니다. 그리고 서로 다른 캐시를 가지고 있습니다. 그래서 GPU 가 CPU 의 캐시에 접근할 수가 없습니다. 같은 프로세서 내의 코어들은 어떻게든 서로의 캐시에 접근해 일관성을 보장할 수 있지만, 장치가 아예 달라지면 그게 불가능합니다. 언제 캐시에 있는 데이터가 메모리에 복사되는지 알 방법이 없습니다.


그래서 GPU 는 메모리 일관성이라는 개념을 제공합니다. 그것이 바로 VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 입니다. 이런 종류의 메모리에 대한 읽기/쓰기 작업은 일관성이 보장된다는 의미입니다.


예를 들어 보겠습니다. vkMapMemeory() 함수는 응용프로그램의 메모리 주소로 오브젝트의 메모리를 매핑합니다. Vulkan 에서는 이를 "호스트에 매핑되었다" 고 표현합니다. 응용프로그램이 그 메모리에 대한 쓰기 작업을 하고 나면 vkFlushMappedMemoryRanges() 를 호출합니다. 그래야 완전히 작업이 끝난 메모리에 GPU 가 접근할 수 있죠. 



코드에서 볼 수 있듯이 VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 가 지정되어 있으면 vkFlushMappedMemoryRange() 호출이 필요하지 않습니다. 일관성이 보장되기 때문입니다.


Memory Heap


메모리 힙은 다음과 같이 정의됩니다.



위에서 제가 64 KB 단위로 전체 메모리를 쪼개서 관리하는 예를 들어 드렸죠? 하지만 그 힙의 크기는 다양할 수 있습니다. 적어도 VkMemoryAllcoateInfo::allocationSize 보다는 큰 힙을 골라야겠죠. 


그런데 여기에 조금 애매한 점이 존재합니다. 메모리 단편화라는 것을 고려해야 하기 때문에 적절한 크기의 힙을 골라야 합니다. 여기에서부터는 응용프로그램마다 다른 최적화 영역이 되는 겁니다.


정리


메모리는 호스트 메모리와 디바이스 메모리로 나뉩니다. 호스트 메모리는 호스트측에 할당된 메모리이고 디바이스 메모리는 디바이스측에 할당된 메모리입니다. 물론 서로 VISIBLE 관계에 존재할 수는 있습니다. 호스트에 물리적으로 장착되어 있는 메모리라 할지라도 디바이스가 사용하게 되면 그것은 디바이스 메모리라 불립니다.


모든 Vulkan 오브젝트는 별도의 메모리 풀을 통해서 관리될 수 있습니다. 특히 리소스( 이미지, 버퍼 )의 경우에는 오브젝트 생성과 메모리 할당/바인딩이 분리되어 있습니다. 이는 재사용성을 높일 수 있고 원하는 형태로 메모리를 관리할 수 있도록 해 줍니다.


여기까지는 기본적인 메모리 관리라고 할 수 있는데요, 사실 리소스 메모리 관리를 위해서는 좀 더 특별한 기법들이 있습니다; staging, alignment, aliasing, offset 등[ 2, 5 ]. 그러한 특별한 기법들에 대해서는 다음에 다루도록 하겠습니다.


참고 자료


[ 1 ] Vulkan 1.1 Specification, Khronos.


[ 2 ] Vulkan Memory Management, NVidia.


[ 3 ] Here's How the Inside of Your Gaming Console Really Works, extreamtech.


[ 4 ] Xbox One SDK & Hardware Leak Analysis CPU, GPU, RAM & More Part One - Tech Tribunal, redgamingtech.


[ 5 ]. Memory management in Vulkan, Vulkan DEVELOPER DAY.


[ 6 ] 캐시 일관성, 위키백과.


[ 7 ] 캐시 일관성과 거짓 공유, 끄적끄적 소소한 일상.


[ 8 ] Vulkan Programming Guide : The Official Guide to Learning Vulkan, informIT.


[ 9 ] Managing Virtual Memory, MSDN.


[ 10 ] Managing Heap Memory, MSDN.

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

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

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

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


 

Command & Queue

 

커맨드라는 것은 디바이스에 그리기, 계산, 메모리 전송 등의 연산을 할 것을 요구하는 함수를 의미합니다. 이러한 명령은 함수 호출과 동시에 디바이스에서 실행되지는 않습니다. 큐로 보내져서 나중에 vkQueueSubmit() 이라는 함수된 후에 실제로 실행됩니다.

 

커맨드와 관련한 함수들은 "vkCmd" 라는 접두어를 가지고 있습니다. 예를 들면 vkCmdBeginRenderPass() 같은 함수가 있습니다. 이름만 봐도 딱 커맨드 관련 함수라는 것을 알 수 있습니다. 그리고 반드시 첫 번째 파라미터로 VkCommandbuffer 를 받습니다.

 

아래 코드는 1.1 명세에서의 커맨드 관련 함수 리스트를 보여 줍니다. 생각보다는 많지 않습니다.

 

 

이러한 커맨드들은 반드시 vkBeginCommandBuffer() 와 vkEndCommandBuffer() 호출 사이에서 호출되어야만 합니다. 커맨드 버퍼에 대해서는 뒤에서 다루도록 하겠습니다.

 

 

이 절의 제목에서 언급하고 있듯이 커맨드들은 큐의 종류에 의해서 카테고리화될 수 있습니다.

 

 

그런데 여기에서 여러분은 하나의 의문을 가지게 될 것입니다. 

 

저 많은 커맨드들이 어떤 큐를 위해 사용되는지 어떻게 알아?

 

다 방법이 있습니다. Vulkan 명세가 꽤 탄탄하므로 모르는 것은 거기에서 찾아 볼 수 있습니다. Vulkan 명세에 보면 커맨드에 대한 설명에는 "Supported Queue Types" 라는 항목이 있습니다. 예를 들어 vkCmdPipelineBarrier() 는 동기화 관련 커맨드인데요, 명세에서 그것을 기술하는 챕터의 마지막에 보면 커맨드 속성 테이블이 나옵니다.

 

 

vkCmdPipelineBarrier() 는 Transfer/Graphics/Compute 타입의 큐에서 사용이 가능한 커맨드라는 것을 알 수 있습니다. 다시 말해 엉뚱한 유형의 큐에 넣으면 제대로 동작할 수 없다는 것이죠.

 

이 외에도 여러 가지 속성들이 있습니다:

 

  • "Command Buffer Levels" 는 이 커맨드가 삽입될 수 있는 커맨드 버퍼의 종류에 대해서 지정하고 있습니다. 자세한 건 뒤에서 언급하도록 하겠습니다.
  • "Render Pass Scope" 는 RenderPass 범위 안에서 정의되어야 하느냐를 의미합니다. vkCmdBeginRenderPass() 호출과 vkCmdEndRenderPass() 호출 사이에 있어야 한다는 의미입니다.
  • "Pipeline Type" 은 이 커맨드가 사용될 수 있는 파이프라인 스테이지의 종류를 의미합니다. 자세한 건 다른 글에서 언급하도록 하겠습니다.

 

Command Buffer & Levels

 

그런데 커맨드의 종류와 양이 많기 때문에 적절히 그룹화해서 관리할 필요가 있습니다. 예를 들어 Shadow 패스를 위한 모두 모을 수도 있고 G-Buffer 패스를 위한 커맨드를 모두 모을 수도 있습니다. 이렇게 커맨드를 그룹화할 수 있는 버퍼가 바로 커맨드 버퍼입니다. 위에서 언급했던 vkBeginCommandBuffer() 와 vkEndCommandBuffer() 가 커맨드 버퍼를 만들기 위한 함수들입니다.

 

출처 : [ 2 ].

 

 

그런데 커맨드 버퍼에는 크게 두 종류가 있습니다. 이것을 Command Buffer Levels 라 부릅니다( 여전히 양키들의 네이밍 센스는 어렵군요 ). 하나는 프라이머리( primary ) 커맨드 버퍼이고 다른 하나는 세컨더리( secondary ) 커맨드 버퍼입니다. 프라이머리 커맨드 버퍼는 큐에 직접적으로 서밋되는 커맨드 버퍼입니다. 세컨더리 커맨드버퍼는 프라이머리 커맨드 버퍼에 의해 간접적으로 실행될 수 있으며, 큐에 직접적으로 서밋되지 않습니다.

 

출처 : [ 2 ].

 

세컨더리 커맨드 버퍼는 프라이머리 커맨드를 위한 vkBeginCommandBuffer() 호출과 vkEndCommandBuffer() 호출 사이에서 실행 커맨드인 VkCmdExecuteCommands() 라는 커맨드를 삽입함으로써 실행됩니다. 나중에 프라이머리 커맨드 버퍼가 실제로 실행되는 중에 그것들이 실행되는 것이죠.

 

이런 세컨더리 커맨드 버퍼는 재사용이 가능하지만 커맨드 버퍼끼리 상태를 상속해 주지 않습니다. 예를 들어 A 와 B 가 세컨더리 커맨드 버퍼인데 A 의 상태가 B 의 상태에 전이되지 않는다는 것입니다.

 

그런데 이 세컨더리 커맨드 버퍼는 왜 사용하는 것일까요? 명세에서는 이유에 대해서 언급하지는 않습니다. 매우 안타까운 일이죠. 구글링을 해 보니 그 이유에 대해서 갑론을박을 하고 있더군요.

 

어쨌든 납득이 갈 수 있는 한도 내에서 정리해 보면 그 이유는 다음과 같습니다. 세컨더리 커맨드 버퍼를 사용하는 가장 큰 이유는 재사용성과 효율성을 높이기 위해서입니다.

 

  • 재사용성 : 세컨더리 커맨드 버퍼는 한 번 만들어 놓으면 다른 프라이머리 커맨드 버퍼에서 사용될 수 있습니다.
  • 효율성 : 프라이머리 커맨드 버퍼를 만들고 있는 도중에 다른 스레드에서 세컨더리 커맨드 버퍼를 만들 수 있습니다.

 

Secondary Command Buffer Sample

 

예제를 통해 세컨더리 커맨드 버퍼를 실행하는 방법에 대해서 설펴 보겠습니다. "multithreading" 샘플에서는 Background 와 UI 를 세컨더리 커맨드로 렌더링합니다. 

 

 

아래의 updateCommandBuffers() 는 프라이머리 커맨드 버퍼를 채웁니다. 그 와중에 vkCmdExecuteCommands() 함수를 호출해 실행 커맨드를 프라이머리 커맨드 큐에 삽입하고 있습니다.

 

 

실제로 서밋은 프라이머리 커맨드 버퍼에 대해서만 수행하죠.

 

 

이 예제는, 프라이머리 커맨드 버퍼가 GPU 에서 실행될 때, SkyBox, UFO, UI 를 순차적으로 그리도록 만듭니다.

 

정리

 

커맨드는 디바이스에 명령을 내리기 위한 함수들이며 "vkCmd" 로 시작합니다. 각 큐마다 사용할 수 있는 커맨드의 종류는 다르며, 명세의 "Supported Queue Types" 에는 그 커맨드가 사용될 수 있는 큐의 종류가 명시되어 있습니다.

 

커맨드 버퍼는 커맨드들의 집합이며, 프라이머리 커맨드 버퍼와 세컨더리 커맨드 버퍼로 나뉩니다. 프라이머리 커맨드 버퍼는 큐에 직접적으로 추가되며 세컨더리 커맨드 버퍼를 실행할 수 있습니다. 세컨더리 커맨드 버퍼는 큐에 직접적으로 추가되지 않습니다.

 

세컨더리 커맨드 버퍼를 사용하면 재사용성과 효율성을 높일 수 있습니다.

 

참고 자료

 

[ 1 ] Vulkan 1.1 Specification, Khronos.

 

[ 2 ] Engaging the Voyage to Vulkan, NVidia.

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

주의 : 허락받고 번역한 것이 아니라 언제든 내려갈 수 있습니다.

주의 : 번역이 개판이므로 이상하면 원문을 참고하세요.

원문 : Leveraging asynchronous queues for concurrent execution, gpuopen.



동시성( concurrency ) 및 그것을 저해하는 것에 대해서 이해하는 것은 최신( modern ) GPU 들을 최적화할 때 매우 중요합니다. DirectX 12 나 Vulkan 과 같은 최신 API 들은 태스크( task )들을 비동기적으로 스케줄링할 수 있는 기능을 제공하는데, 이는 상대적으로 적은 노력으로 GPU 를 더욱 많이 활용( utilization )할 수 있도록 해 줍니다.


Why concurrency is important


렌더링은 embarrassingly parallel( 역주 : 병행 작업을 처리하기 위해서 노력을 거의 할 필요가 없는 작업을 의미. 작업이 서로 독립적일 때 이런 상황이 발생함. 출처 : Embarrassingly parallel, Wikipedia ) 태스크입니다. 메시 내의 모든 삼각형들은 병행적으로 변환될( transformed ) 수 있으며, 겹치지 않는 삼각형들은 병행적으로 래스터화될( rasterized ) 수 있습니다. 결과적으로 GPU 들은 매우 많은 작업들을 병행적으로 수행할 수 있도록 설계됩니다. 예를 들어 Radeon Fury X GPU 는 64 개의 컴퓨트 유닛( compute unit, CU )들로 구성되어 있으며, 각각은 4 개의 Single-Instruction-Multiple-Data( SIMD ) 유닛들을 가지고 있으며, 각각의 SIMD 들은 64 개의 스레드들로 구성된 블락( block )을 실행합니다. 우리는 그 블락들을 "웨이브프런트( wavefront )" 라 부릅니다. 메모리 접근에 대한 지연( latency )는 쉐이더를 실행할 때 엄청난 스톨( stall )을 야기할 수 있는데, 이 지연을 줄이기( hide ) 위해서 10 개까지의 웨이브프런트들이 각 SIMD 에서 스케줄링될 수 있습니다.


실행중에 실제 웨이브프런트의 개수가 이론적인 최대치보다 작아지는 데는 몇 가지 이유가 존재합니다. 가장 일반적인 이유는 다음과 같습니다:


  • 셰이더는 많은 Vector General Purpose Register( VGPR ) 들을 사용합니다. 예를 들어 셰이더가 128 개 이상의 VGPR 을 사용한다면, 단 하나만의 웨이브프런트만이 SIMD 를 위해서 스케줄링될 수 있습니다( 그렇게 되는 이유와 그리고 셰이더가 실행할 수 있는 웨이브프런트들을 계산하는 방법에 대해 세부적으로 알고자 한다면, GPR 사용을 최적화하기 위해서 CodeXL 을 사용하는 방법에 대해서 다루는 기사를 참고하세요 ).
  • LDS 요구사항 : 만약 셰이더가 32KiB 의 LDS 를 사용하고 스레드 그룹당 64 개의 스레드들을 사용한다면, 이는 CU 당 동시에 스케줄링될 수 있는 웨이브프런트가 2 개밖에 안 된다는 것을 의미합니다( 역주 : LDS 는 Local Data Share 의 머리글자입니다. LDS 는 각 CU 를 위한 칩에 위치한 램을 의미합니다. 32 KiB 의 LDS 가 있을 때 CU 당 두 개의 LDS 만 쓸 수 있다고 한 건 다음과 같이 계산하기 때문입니다. 64(CU) * 4(SIMD) * 64(Thread) = 16384 = 16 KiB 이므로 32 KiB 일때 CU 당 2 개 인 것입니다 ).
  • 만약 컴퓨트 셰이더( compute shader )가 충분한 웨이브프런트를 생성하지 않거나, 스크린상에 몇 개 안 되는 픽셀만을 그리는 드로 콜( draw call )을 날리는 작은 지오메트리들이 많다면, 모든 CU 들을 포화상태로 만드는데 충분한 웨이브프런트들을 생성하기 위해 스케줄링되는 작업들이 별로 없을 것입니다.
  • 모든 프레임은 동기화 지점( sync point )를 가지고 있으며 올바른 렌더링 결과를 보장하기 위한 배리어( barrier )들을 가지고 있습니다. 이것들은 GPU 를 놀게 만듭니다( idle ).


비동기 컴퓨트는 그러한 GPU 리소스들에 다가가기 위해서 사용될 수 있으며, 그렇지 않으면 그 리소스들은 테이블에 그냥 남아 있게 될 것입니다.


아래에 있는 두 개의 이미지들은 일반적인 프레임에서 Radeon RX480 GPU 의 셰이더 엔진 중 하나에 대해 무슨 일이 발생하고 있는지를 가시화해주는 스크린샷입니다. 그 그래프들은 게임의 잠재적인 성능을 확인하기 위해서 AMD 내부적으로 사용하고 있는 도구를 통해서 생성되었습니다.


이미지의 위쪽에 있는 섹션들은 하나의 CU 내부의 서로 다른 부분들에 대한 활용도를 보여 줍니다. 아래쪽에 있는 섹션들은 서로 다른 셰이더 타입들을 위해 얼마나 많은 웨이브프런트들이 생성되었는지를 보여줍니다.


첫 번째 이미지는 G-Buffer 렌더링을 위해서 0.25 ms 까지 사용하는 것을 보여 줍니다. 위쪽 부분에서 GPU 는 매우 바빠 보입니다. 특히 익스포트 유닛( export unit )이 그렇습니다. 그러나 CU 내에 있는 어떠한 컴포넌트들도 완전히 포화상태가 아니라는 것에 주의를 기울일 필요가 있습니다.



두 번째 이미지는 깊이만 렌더링하는데 0.5 ms 를 사용하는 것을 보여 줍니다. 왼쪽 절반은 PS 를 사용하지 않으며, 이것은 CU 활용도를 매우 낮게 만듭니다. 중간쯤에서는 일부 PS 웨이브( 역주 : 웨이브프런트의 약자인듯 )들이 생성됩니다. 아마도 알파 테스트를 통해서 반투명 지오메트리를 렌더링하기 때문인 것으로 보입니다( 하지만 그런 그래프들에서 그 이유를 보여 주는 것은 아닙니다 ). 오른쪽 1/4 에서는 생성되는 전체 웨이브 개수가 0 으로 떨어집니다. 이는 렌더타깃들이 다음 드로 콜들에서 텍스쳐로서 사용되기 때문에 GPU 가 기존 태스크들이 끝날 때까지 대기하기 때문일 수 있습니다.



Improved performance through higher GPU utilization


위의 이미지들에서 볼 수 있듯이, 일반적인 프레임에서 GPU 리소스들이 많이 놀고 있습니다. 새로운 API 들은 GPU 에서 태스크가 스케줄링되는 방식을 개발자가 더 많이 제어할 수 있는 방법을 제공하도록 설계되어 있습니다. 차이가 하나 있다면, 거의 대부분의 콜들은 묵시적으로 독립된 것이라는 가정을 깔고 있다는 것입니다. 드로 연산이 이전의 결과에 언제 의존하게 되느냐와 같은 정확성을 보장하기 위해서 배리어를 지정하는 것은 개발자의 책임하에 있습니다. 배리어에 대한 배칭( batching )을 강화하기 위해서 작업들( workloads )을 섞음( shuffling )으로서, 응용프로그램들은 GPU 활용도를 높일 수 있고, 각 프레임에서 배리어를 위해 소요되는 GPU idle time 을 줄일 수 있습니다( 역주 : lock 이 덜 걸리게 독립적인 작업들을 잘 분류한다는 것을 의미 ).


GPU 활용도를 높이는 추가적인 방법은 비동기 컴퓨트( asynchronous compute )입니다: 프레임의 특정 위치에서 다른 작업들과 함께 컴퓨트 셰이더를 순차적으로 실행하는 대신에, 비동기 컴퓨트는 다른 작업들과 동시에 실행되는 것을 허용됩니다. 이는 위의 그래프들에서 보이는 일부 간격들을 채울 수 있으며 추가적인 성능 향상을 제공합니다.


개발자로 하여금 어떤 작업들을 병행적으로 실행할 수 있는지 지정할 수 있도록 하기 위해서, 새로운 API 들은 응용프로그램이 태스크를 스케줄링할 수 있는 다중의 큐들을 정의할 수 있도록 합니다.


세 종류의 큐가 존재합니다:


  • Copy Queue ( DirectX 12 ) / Transfer Queue ( Vulkan ) : PCIe 버스를 통해 데이터를 전송하는 DMA( 역주 : Direct Memory Access ).
  • Compute Queue ( DirectX 12 와 Vulkan ) : 컴퓨트 셰이더를 실행하거나 데이터를 복사하는데, 로컬 메모리를 선호합니다.
  • Direct Queue ( DirectX 12 ) / Graphics Queue ( Vulkan ) : 이 큐는 아무 일이나 수행할 수 있어서, 기존 API 들의 메인 디바이스와 유사합니다.


응용프로그램은 동시성 활용을 위해서 다중의 큐를 생성할 수 있습니다: DirectX 12 에서는 임의의 개수의 큐가 각각의 타입을 위해서 생성될 수 있지만, Vulkan 에서는 드라이버가 지원되는 큐의 개수를 열거해 줄 것입니다.


GCN 하드웨어는 단일 지오메트리 프런트엔드를 포함하고 있어서, DirectX 12 에서 다중의 direct queue 를 생성하더라도 추가적인 성능 향상이 존재하지는 않을 것입니다. Direct queue 에 스케줄링된 모든 커맨드 리스트들은 같은 하드웨어 큐에 직렬화될 것입니다. GCN 하드웨어는 다중 컴퓨트 엔진을 지원하지만, 하나 이상의 컴퓨트 큐를 응용프로그램에서 사용한다고 해서 특별한 성능 향상을 확인할 수는 없습니다. 하드웨어가 지원하는 것보다 더 많은 큐를 생성하지 않는 것이 일반적으로 중요합니다. 그래야 커맨드 리스트 실행에 대한 더 직접적인 제어가 가능합니다.


Build a task graph based engine


어떤 작업이 비동기적으로 스케줄링되어야 하는지 어떻게 결정할까요? 프레임은 태스크에 대한 그래프로 간주되어야 합니다. 여기에서 각 태스크는 다른 태스크들에 대한 의존성을 가지고 있습니다. 예를 들어 여러 개의 섀도우 맵들은 독립적으로 생성될 수 있습니다. 그리고 이것들은 섀도우 맵을 입력으로 사용하여 Variance Shadow Map( VSM )을 생성하는 컴퓨트 셰이더를 사용하는 처리 단계를 포함할 수 있겠죠. 그림자가 드리워진 광원을 동시에 처리하는 타일 기반 라이팅 셰이더도 모든 섀도우 맵들이 만들어지고 G-Buffer 의 처리가 끝난 후에 시작될 수 있습니다. 이 경우 VSM 생성은 다른 섀도우 맵들이 렌더링되고 있는 동안 실행되거나 G-Buffer 를 렌더링하는 동안 배치될( batched ) 수 있습니다.


이와 유사하게, Ambient Occlusion 은 깊이 버퍼에 의존합니다. 하지만 섀도우나 타일 기반 라이팅에는 독립적이죠. 그러므로 비동기 컴퓨트 큐에서 실행하는 것도 좋은 선택입니다.


게임 개발자들이 비동기 컴퓨트의 이점을 취할 수 있는 최적의 시나리오를 찾을 수 있게 도왔던 경험에서, 수동으로 태스크를 병행적으로 실행하도록 지정하는 것이 이 처리를 자동화하려고 시도하는 것보다 더 낫다는 것을 발견했습니다. 컴퓨트 태스크만이 비동기적으로 스케줄링되기 때문에, 가능한 한 많은 렌더링 작업에 대해 컴퓨트 패스를 구현하는 것을 추천합니다. 그래야지 어떤 태스크들이 실행중에 겹치게 되는지를 결정하는데 있어서 더 많은 자유도를 가질 수 있게 됩니다.


마지막으로 작업을 컴퓨트 패스로 옮길 때, 응용프로그램은 각 커맨드 리스트들이 충분히 커지도록 해야 합니다. 이는, 커맨드 리스트를 쪼개는 비용과 서로 다른 큐에서 태스크를 동기화하는 연산에 요구되는 펜스( fence ) 상에서의 스톨을 만회해서, 비동기 컴퓨트로부터 성능 향상을 얻을 수 있도록 해 줄 것입니다.


How to check if queues are working expected


응용프로그램에서 비동기 큐들이 원하는 대로 동작하고 있는지 확실하게 하기 위해서는 GPUView 를 사용하는 것을 추천합니다. GPUView 는 어떤 큐들이 사용되고 있는지, 얼마나 많은 작업들이 각 큐에 포함되어 있는지, 그리고 가장 중요하게는 작업들이 실제로 서로에 대해서 병행적으로 실행되고 있는지를 가시화해 줍니다.


Windows 10 환경에서, 거의 대부분의 응용프로램들은 적어도 하나의 3D 그래픽스 큐와 카피 큐를 보여 줄 것입니다. 카피 큐는 페이징( paging )을 위해 Windows 에 의해 사용됩니다. 아래 이미지에서, GPU 에 데이터를 업로드하기 위한 추가적인 카피 큐를 사용하는 응용프로그램의 한 프레임을 볼 수 있습니다. 렌더링을 시작하기 전에 데이터를 스트리밍하고 동적 상수 버퍼를 업로드하기 위해 카피 큐를 사용하는, 개발중인 게임에서 가지고 온 것입니다. 이 게임 빌드에서 그래픽스 큐는 렌더링을 시작하기 전에 복사가 완료될 때까지 대기할 필요가 있습니다. 아래 그림에서도 보이듯이, 카피 큐는 복사를 시작하기 전에 이전 프레임의 렌더링이 완료될 때까지 대기합니다:



이 경우에, 카피 큐를 사용하는 데는 어떠한 성능 이점도 없습니다. 왜냐하면 업로드된 데이터 상에서 더블 버퍼링이 구현되어 있지 않기 때문입니다. 데이터에 대해 더블 버퍼링이 수행된 후에, 그제서야 업로드가 발생할 것입니다. 그 동안에 이전 프레임은 여전히 3D 큐에 의해서 처리되고 있는 중이며, 3D 큐에서의 그 차이( gap )가 제거됩니다. 이러한 변경은 전체 프레임 시간을 거의 10 % 정도 줄여줍니다.


두 번째 예는 컴퓨트 큐를 엄청나게 사용하는 게임인 Ashes of the Singularity 의 벤치마크 씬에서의 두 프레임을 보여 줍니다.



비동기 컴퓨트 큐가 프레임의 대부분을 위해서 사용됩니다. 그래픽스 큐가 컴퓨트 큐를 기다리기 위해서 스톨되지 않는 것을 보실 수 있습니다. 그것은 비동기 컴퓨트가 성능 향상을 제공하는 가장 좋은 수단임을 확인할 수 있는 좋은 시작점입니다.


What could possibly go wrong?


비동기 커퓨트를 사용할 때, 서로 다른 큐에 존재하는 커맨드 리스트들이 병행적으로 실행됨에도 불구하고 그것들은 여전히 같은 GPU 리소스들을 공유할 수 있다는 데 주의할 필요가 있습니다.


  • 만약 리소스들이 시스템 메모리에 존재한다면, 그래픽스 큐나 컴퓨트 큐에서 그것들에 접근하는 것은 DMA 큐 성능 등에 영향을 줄 수 있습니다.
  • 로컬 메모리에 접근( 예를 들어, 텍스쳐 패칭( fetching ), UAV 에 대한 쓰기, rasterization-heavy task 수행 )하는 그래픽스 큐나 컴퓨트 큐는 대역폭 한계 때문에 서로에게 영향을 줄 수 있습니다.
  • 같은 CU 를 공유하는 스레드는 GPR 과 LDS 를 공유하게 되며, 그래서 이용가능한 모든 리소스들을 사용하는 태스크들은 같은 CU 상에서 수행되는 비동기 작업들을 방해할 수 있습니다.
  • 서로 다른 큐들은 그것들의 캐시들을 공유합니다. 만약 다중 큐가 같은 캐시를 활용한다면, 이는 더 많은 캐시가 버려지거나( trashing ) 성능을 저하시키는 결과를 산출할 수 있습니다.


Due to the reasons above it is recommended to determine bottlenecks for each pass and place passes with complementary bottlenecks next to each other( 역주 : 병목을 없애기 위해서는 동시에 처리해야 할 패스를 잘 선택해서 보완해야 한다는 의미인듯 ):


  • DLS 와 ALU 를 심하게 사용하는 컴퓨트 셰이더는 보통 비동기 컴퓨트 큐를 위한 좋은 후보입니다.
  • 깊이만 그리는 렌더링 패스들은 보통 그것 다음에 실행하는 컴퓨트 태스크를 가지고 있는 좋은 후보입니다.
  • 효율적인 비동기 컴퓨트를 위한 일반적인 해결책은 프레임 N 의 포스트 프로세싱과 프레임 N+1 의 섀도우 맵 렌더링을 겹치는 것입니다.
  • 가능한 한 많은 프레임들을 컴퓨트로 포팅( porting )하면 다음에 스케줄링될 수 있는 태스크들을 시험할 때 더욱 유연한 결과를 산출할 것입니다.
  • 태스크를 서브 태스크들로 나누고 삽입하는 것은 배리어를 줄이고 효율적인 비동기 컴퓨트를 위한 기회를 창출할 것입니다( 예를 들어 "각 광원을 위해 섀도우 맵을 클리어하고, 섀도우를 렌더링하고, VSM 을 계산하는 것" 대신에 "모든 섀도우 맵을 클리어하고, 모든 섀도우 맵을 렌더링하고, 모든 섀도우 맵을 위해 VSM 을 계산하는 것" ). 

비동기 컴퓨트는 적합하게 사용되지 않을 때는 성능을 저하시킨다는 점에 주의해야만 합니다. 이런 경우를 피하기 위해서는, 각 태스크들을 위해서 비동기 컴퓨트 사용이 쉽게 활성화되거나 비활성화될 수 있도록 하는 것을 권장합니다. 이는 여러분이 성능 이점을 측정하고 응용프로그램이 여러 하드웨어에서 최적으로 실행될 수 있도록 해 줄 것입니다.


Stephan Hodes is a member of the Developer Technology Group at AMD. Links to third party sites are provided for convenience and unless explicitly stated, AMD is not responsible for the contents of such linked sites and no endorsement is implied.

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

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

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

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


Supported Hardware Architectures


Vulkan 을 지원하는 하드웨어는 OpenGL 4.0 이상을 지원해야 합니다. 갑자기 뜬금없이 하드웨어 사양을 이야기하는 이유는 Vulkan 의 Command Queue 와 하드웨어 설계가 연관이 있기 때문입니다.


Vulkan 을 지원하는 그래픽스 하드웨어의 최소 아키텍쳐는 다음과 같습니다 [ 1 ]:

  • NVidia Kelper 아키텍쳐( GK110 이후, Geforce 600 시리즈 이후 ).
  • Intel SkyLake 아키텍쳐( Graphics Gen9 아키텍쳐를 포함한 아키텍쳐. 예를 들어 i5 6600 이후 ).
  • Amd GCN 아키텍쳐( Radeon HD 77xx 이후 ).


이러한 아키텍쳐들의 특징은 OpenCL, CUDA, Compute Shader 등을 지원하기 위해서 여러 개의 큐를 만들었다는 데 있습니다. 예를 들어 Nvidia 같은 경우에는 Kelper 아키텍쳐에서 Dynamic Parallelism 과 Hyper-Q 라는 기술을 도입했습니다. 그 중에서 Hyper-Q 가 multiple command queue 와 관련이 높아 보입니다. 백서[ 2 ]에서는 CUDA 를 중심으로 설명하고 있지만 이것은 다른 명령에도 영향을 줬을 것으로 보입니다.


과거에는 최적으로 스케줄링된 여러 스트림의 워크로드를 계속 GPU에 제공하는 것이 문제였다. Fermi 아키텍처는 별도의 스트림에 전달된 16개의 커널을 동시에 실행하도록 지원했지만 결국 모든 스트림이 동일한 하드웨어 작업 대기열로 멀티플렉스되었다. 때문에 잘못된 스트림 내부 종속성이 발생하여 특정 스트림 내의 종속 커널이 다른 스트림의 추가 커널을 실행하기 전에 완료해야 하는 문제가 나타났다. 너비우선(Breath-first) 실행 순서를 사용하면 문제를 어느 정도 완화할 수 있지만 프로그램의 복잡성이 높아지면서 이를 효율적으로 관리하기가 점점 더 어려워졌다.


Kelper GK110에서는 새로운 Hyper-Q 기능으로 이러한 기능을 향상시켰다. Hyper-Q는 호스트와 GPU의 CWD(CUDA Work Distributor) 로직 간의 총 연결(작업 대기열) 수를 늘려 주며, 하드웨어에서 관리되는 동시 연결 수를 32개(Fermi의 경우 단일 연결만 가능)까지 지원한다, Hyper-Q는 여러 CUDA 스트림, 여러 MPI(메시지 전달 인터페이스) 프로세스 또는 프로세스 내의 여러 스레드에서 연결할 수 있도록 하는 유연한 솔루션이다. 이전에 여러 작업에 걸쳐 직렬화 오류가 발생해 GPU 활용에 한계가 있었던 애플리케이션도 기존 코드를 변경하지 않고 성능이 32배까지 개선되는 효과를 볼 수 있다.


각 CUDA 스트림은 자체 하드웨어 작업 대기열에서 관리되며, 스트림 각 종속성이 최적화되고, 특정 스트림의 연산이 더 이상 다른 스트림을 차단하지 않기 때문에 잘못된 종속성이 발생할 가능성을 없애기 위해 실행 순서를 특별히 맞추지 않고도 여러 스트림을 동시에 실행할 수 있다.


Hyper-Q는 MPI 기반 병령 컴퓨터 시스템에서 사용할 경우 막대한 이점을 제공한다. 기존의 MPI 기반 알고리즘은 멀티 코어 CPU 시스템에서 실행하도록 작성하는 경우가 많았다. 각 MPI 프로세스에 할당되는 작업량은 코어 수에 따라 조정된다. 이 경우 단일 MPI 프로세스에 GPU 를 완전히 점유하기에 불충분한 작업량이 할당될 수 있다. 여러 MPI 프로세스가 GPU 하나를 공유할 수도 있지만, 이러한 프로세스에서 잘못된 종속성으로 인해 병목 현상이 발생할 수도 있다. Hyper-Q 는 이러한 잘못된 종속성을 없애 MPI 프로세스간에 공유되는 GPU의 효율성을 크게 높인다.



출처 : [ 2 ].


Queue & Queue Family


큐라는 것은 커맨드들을 전송할 수 있는 버퍼라고 생각하시면 됩니다. 아래 그림에서 Kernel 은 쉐이더 처리를 의미하고 파란색은 리소스 트랜스퍼를 의미합니다.


그림 출처 : [ 5 ].


Vulkan 에서는 4 가지 종류의 큐를 지원합니다[ 3 ].



  • VK_QUEUE_GRAPHCIS_BIT : 그래픽스( 일반 shader ) 관련 연산을 지원하는 큐입니다.
  • VK_QUEUE_COMPUTE_BIT : 컴퓨트( compute shader ) 관련 연산을 지원하는 큐입니다.
  • VK_QUEUE_TRANSFER_BIT : 트랜스퍼( 리소스 전송 ) 관련 연산을 지원하는 큐입니다.
  • VK_QUEUE_SPARSE_BINDING_BIT : 스파스 리소스( Sparse resource, 혹은 Virtual texture ) 관련 연산을 지원하는 큐입니다.


비트 플래그로 되어 있다는 것을 보셨으니 이미 눈치채셨겠지만, 하나의 큐는 여러 가지 역할을 수행할 수 있습니다. 그래서 Vulkan 에서는 같은 구성을 가진 큐를 그룹으로 묶어 놓았는데 그것을 큐 패밀리라고 부릅니다.


vkGetPhysicalDeviceQueueFamilyProperties() 를 통해서 큐 패밀리들을 획득할 수 있습니다. 큐 패밀리라는 것은 실체가 존재하는 것은 아니고, 그냥 그룹화를 위한 정보일 뿐이라는 데 주의하시기 바랍니다. 괜히 이것을 실제 인스턴스와 매핑해서 추상화하려고 하면 골치가 아픕니다.



피지컬 디바이스에서 얻어 온 정보이므로 그 자체는 "단지" 정보일 뿐입니다. 실제 큐 생성에 대해서는 나중에 다루도록 하겠습니다. 이 정보는 다음과 같이 구성되어 있습니다.



  • queueFlags : 이 큐 패밀리에서 사용할 수 있는 연산의 종류입니다.
  • queueCount : 이 큐 패밀리에 소속된 큐의 개수입니다.
  • timestampValidBits : 커맨드 실행시간을 재기 위한 메커니즘을 제공하는 timestamp 관련 변수인데요, 여기에서 다루지는 않겠습니다. 보통 36 에서 64 사이이며, timestamp 를 지원하지 않을 경우에는 0 입니다.
  • minImageTransferGranularity : 큐에서 수행되는 트랜스터 연산을 위해 지원되는 최소 해상도 정보입니다. 예를 들어 minImageTransferGranularity 가 6 이라면 6 텍셀 이상의 너비를 가진 텍스쳐를 전송해야 합니다.


이를 통해서 얻은 정보들은 CapsViewer 의 "Queue Families" 에서 확인할 수 있습니다.



자, 제 GTX 980 카드는 두 종류의 큐 패밀리를 가지고 있군요. 하나는 4 종류의 연산을 위해서 사용할 수 있는 공용 큐 패밀리이며, 다른 하나는 트랜스퍼 전용 큐 패밀리입니다. 이를 도식화하면 다음과 같이 표현할 수 있겠죠. 디바이스는 손을 17 개 가지고 있는데, 그중 16 개는 공용이고, 1 개는 트랜스퍼 전용입니다.



그러면 여기에서 의문이 하나 생깁니다. 대체 이 큐를 어떻게 사용해야 하는 걸까요? 


보통 큐 하나에 스레드 하나를 매핑하고, 큐에 대해서 독립적인 작업을 수행합니다. 지금까지 제가 본 샘플들에서는, 전용 큐를 가지고 있는 경우에는 그것을 사용하고, 그렇지 않은 경우에는 공용 큐를 사용합니다. 하지만 전용 큐를 가지고 있다고 하더라도 자신의 큐 패밀리가 꽉 찼다면 이용할 수 있는 다른 큐 패밀리에 있는 큐를 사용할 수 있겠죠. 어떻게 관리할 것이냐는 구현측의 마음에 달려 있습니다. 


물론 기본 원칙은 있습니다. 큐끼리 동기화하는 경우가 자주 발생해서는 안 된다는 것입니다. 이건 뭐 concurrency/parallelism 의 성능을 극대화하기 위해서는 기본이 되는 원칙이니 당연하겠죠.


정리


다중의 큐를 지원하는 아키텍쳐들부터 Vulkan 을 지원합니다. 각 큐들에서 수행할 수 있는 연산은 4 종류이며, 같은 속성을 가진 큐들끼리 모아서 큐 패밀리라 부릅니다.


이 큐들은 스레드와 1:1 로 매핑될 수 있으며, 그것을 어떻게 관리할 것이냐는 응용프로그램에 달려 있습니다.


다음에는 multithreading 샘플을 살펴 보고, [ 4 ] 와 [ 5 ] 를 번역하도록 하겠습니다.


추가 : multithreading 샘플을 통해 스레드당 큐를 할당하는 방식을 살펴 보려 했으나, 확인해 보니 그 샘플은 단순히 커맨드 버퍼를 스레드에서 만드는 예제였습니다. 그러므로 multithreading 샘플은 커맨드 버퍼를 다루는 시점에 분석하도록 하겠습니다.


참고자료


[ 1 ] 벌컨 (API), 위키백과.


[ 2 ] NVIDIA의 차세대 CUDATM 컴퓨팅 아키텍처: KelperTM GK110, Nvidia.


[ 3 ] Vulkan 1.1 specification, Khronos.


[ 4 ] Leveraging asynchronous queues for concurrent execution, gpuopen.


[ 5 ] Moving To Vulkan, NVidia.

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

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

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

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



Installable Client Driver


Vulkan 로더는 ICD 라는 라이브러리와 통신함으로써 피지컬 디바이스( Physical Device, 실제 그래픽스 카드 )와 통신합니다.



레이어 정보를 기술하기 위해서 json 매니페스트를 유지하듯이, ICD 정보를 기술하기 위한 json 매니페스트 파일도 존재합니다. 


HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Class\{Adapter GUID}\000X\VulkanDriverNameWow

HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Class\{SoftwareComponent GUID}\000X\VulkanDriverNameWow


추가적으로 다음과 같은 경로에 정보가 있다면 읽어들이기도 합니다.


HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\Drivers\


그런데 "{Adapter GUID}" 와 "{SoftwareComponent GUID}" 는 클래스 아이디로서 이미 정해져 있는 값이 있습니다. "{Adapter GUID}" 는 디스플레이 어댑터를 의미하는 클래스 아이디로서 이것도 이미 정해져 있는 값이 있습니다. 실제로 Vulkan-Loader 소스에서도 하드코딩해서 사용하고 있더군요.


장치 관리자에서 찾아 봤더니 "{4d36e968-e325-11ce-bfc1-08002be10318}" 였습니다. 이는 Intel 카드에서도 동일한 값을 가지고 있었습니다.



Software-Component 라는 것은 Windows 10 에서 실질적인 드라이버 소프트웨어를 의미하는 것 같더군요. 이것의 클래스 아이디도 "{5c4c3332-344d-483c-8739-259e934c9cc8}" 로 정해져 있었습니다.


어쨌든 "{Adapter GUID}" 와 관련한 레지스트리를 찾아 보니 "0000" 에는 다음과 같이 Nvidia 그래픽스 카드가 사용하는 Vulkan Driver 의 정보가 들어 있었습니다.



"0001" 에는 Intel 그래픽스 카드를 위한 정보가 들어 있었는데 Vulkan 을 지원하지 않는지 특별한 정보가 없었습니다. 실제로 Physical-Device 를 열거해도 나오지 않습니다.


"{SoftwareComponent GUID}" 와 관련한 레지스트리에는 별 정보가 없었고, "HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\Drivers\" 는 아예 존재하지도 않았습니다.


어쨌든 "VulkanDriverNameWow" 가 가리키는 경로에는 ICD 용 json 파일이 있습니다. 이름이 "nv-v64.json" 이더군요. 열어 보니 다음과 같은 정보가 담겨 있었습니다.



"ICD" 필드에 "library_path" 와 "api_version" 이 기입되어 있어서 적절한 라이브러리와 버전을 찾을 수 있도록 하고 있습니다. 추가적으로 "VK_LAYER_NV_optimus" 레이어도 등록하고 있네요.


Physical Device


Vulkan 에서 physical device 는 VkPhysicalDevice 라는 디스패쳐블 오브젝트로서 정의됩니다. 그런데 이 오브젝트는 응용프로그램에서 생성하는 것이 아닙니다. vkEnumeratePhysicalDevices() 함수를 통해서 열거할 수만 있습니다. 왜냐하면 피지컬 디바이스는 OS 가 로드되면서 이미 생성되어 있는 상태이기 때문입니다.


다른 열거 함수들과 마찬가지로 count 를 획득한 다음에 실제 데이터를 채웁니다.



앞에서 레지스트리에 대해서 이야기할 때 언급했듯이 제 머신에는 Vulkan 을 지원하는 카드가 하나밖에 없기 때문에 "Geforce GTX 980" 만 잡힙니다. 


일단 피지컬 디바이스를 획득하면 거기에서 피지컬 디바이스의 속성과 기능 등을 획득할 수 있습니다.



이 정보들은 CapsViewer 에서 확인할 수 있으며 아래와 같이 대응됩니다.

  • deviceProperties 는 "Limits" 에 대응합니다.
  • deviceFeatures 는 "Features" 에 대응합니다.
  • deviceMemoryProperties 는 "Memory" 에 대응합니다.



QueueFamily 정보도 피지컬 디바이스에서 가지고 오는 것이긴 한데 나중에 따로 언급하도록 하겠습니다.


Physical Device Group


Vulkan 은 1.1 부터 여러 개의 GPU 를 응용프로그램에서 사용하는 기능을 제공합니다. 


[ 3 ] 에 따르면 제조사( vendor )에 중립적( neutral )이라고 합니다. 예전의 SLI 나 Cross-Fire 는 드라이버가 각 GPU 에 태스크를 분배하는 방식이었지만, Vulkan 은 이를 개발자가 할 수 있도록 지원한다고 합니다. 그리고 여기에 사용되는 피지컬 디바이스들은 디바이스 그룹이라는 개념으로 묶여 하나의 로지컬 디바이스로 관리된다고 합니다.


안타깝게도 제 머신의 Intel 카드가 Vulkan 을 지원하지 않아서 테스트는 못 해 봤지만 제조사-중립적인 기능이라니... 정말 혁신적인 것 같습니다.


정리


머신에는 여러 개의 그래픽스 카드가 존재할 수 있으며, Vulkan 은 ICD 를 통해서 그 장치들을 열거하고 거기에 접근할 수 있습니다. 이러한 그래픽스 카드들은 VkPhysicalDevice 로 표현되며, 그것을 통해서 피지컬 디바이스의 여러 가지 속성과 기능들을 열거할 수 있습니다.


일단 피지컬 디바이스를 선택하면, 이제 실제로 사용할 로지컬( logical ) 디바이스를 생성할 준비가 된 것입니다.


참고자료


[ 1 ] Architecture of the Vulkan Loader Interface, LUNAR XCHANGE.


[ 2 ] Vulkan 1.1 Specificiation, Khronos.


[ 3 ] Vulkan 1.1 out today with multi-GPU support, better DirectX compatability, ars TECHNICA.

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

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

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



Vulkan 의 오브젝트는 크게 디스패쳐블( Dispatchable ) 오브젝트와 논디스패쳐블( Non-Dispatchable ) 오브젝트로 나뉩니다. Vulkan 명세에서는 오브젝트라는 용어 대신 핸들( handle )이라는 용어를 사용하기도 합니다.


이 디스패치( dispatch )라는 용어는 Vulkan 소스 전반에 걸쳐 나오고 있기 때문에 기본적인 개념에 대해서 파악할 필요가 있습니다. 원래 "발송하다" 는 뜻을 가지고 있는데요, 보통 프로그래밍 언어에서는 데이터를 어디론가 전송하는 것을 의미합니다. 이런 개념에서 보면 "어딘가로 보낼 수 있는 오브젝트" 정도로 생각하는 것이 타당하겠죠. 하지만 그 목적지가 어딘지, 어떤 오브젝트를 보낼 수 있는지, 이런 정보들을 그냥 "디스패쳐블" 이라는 용어만 가지고 파악하기는 어렵습니다.


그러므로 여기에서 디스패쳐블 핸들과 논디스패쳐블 핸들의 차이에 대해서 간단하게 짚고 넘어 가도록 하겠습니다.


Dispatchable Object


명세에 따르면, Vulkan 에서 디스패쳐블 오브젝트라는 것은 불투명한( opaque ) 포인터이며, 레이어가 API command 를 간섭( interception )하는 데 사용될 수 있는 포인터를 의미합니다( 불투명한 타입의 개념이 이해가 안 된다면, [ Vulkan Opaque Type ] 를 참고하시기 바랍니다 ). 그리고 핸들은 유일해야만 합니다.


이러한 유형의 핸들이 어떻게 구현되어 있는지는 모릅니다. 하지만 명세를 통해서 그것에 접근하기 위한 인터페이스를 정의해 놨기 때문에, 그것을 사용하는 레이어나 ICD( Installable Client Driver ) 등은 그것들을 사용할 수가 있습니다.


디스패쳐블 오브젝트는 다음과 같습니다 :


  • VkInstance
  • VkPhysicalDevice
  • VkDevice
  • VkQueue
  • VkCommandBuffer


이것은 "vulkan_core.h" 에서 다음과 같이 정의됩니다.



Non-Dispatchable Object


명세에 따르면, 논디스패쳐블 오브젝트는 구현측( 예 : 드라이버 제조사 )에 종속적인 64 비트 정수형이며, 이것의 의미는 구현측에 따라서 달라지며, 기저 오브젝트에 대한 참조라기 보다는 오브젝트 정보를 핸들에 직접적으로 인코딩하고 있을 수 있습니다. 그리고 그 핸들이 유일할 필요는 없습니다. 


논디스패쳐블 오브젝트는 다음과 같습니다 :


  • VkSemaphore
  • VkFence
  • VkBuffer
  • VkImage
  • VkEvent
  • VkQueryPool
  • VkBufferView
  • VkImageView
  • VkShaderModule
  • VkPipelineLayout
  • VkRenderPass
  • VkPipeline
  • VkDescriptorSetLayout
  • VkSampler
  • VkDescriptorPool
  • VkDescriptorSet
  • VkFramebuffer
  • VkCommandPool


이것은 "vulkan_core.h" 에서 다음과 같이 정의됩니다.



Difference ???


대부분의 환경에서 VK_DEFINE_HANDLE 이든 VK_DEFINE_NON_DISPATCHABLE_HANDLE 이든 최종 결과는 다음과 같습니다.



x86 환경에서는 두 번째 VK_DEFINE_NON_DISPATCHABLE_HANDLE 정의에 걸릴 수도 있겠지만, 요즘은 거의 x64 환경이므로 잘 모르겠네요.


사실상 디스패쳐블이든 논디스패쳐블이든 응용프로그램 입장에서 보면 불투명한 타입을 가지고 있습니다. 그렇다면 다른 기준에 의해 구분될 수 있어야 하는데, 딱히 구분할 만한 차이점을 느끼기가 어렵습니다.


이와 관련해서 검색을 해 봤는데, 공식적으로 인정받을 만한 명료한 대답을 내 놓는 사람은 없는 것 같습니다( 명세 자체가 이해가 안 가는 상황입니다 ). 단지 그럴싸한 답변은 있었습니다.


The reason is that with 64 bits, many implementations can actually encode the GPU address for the non-dispatchable object directly into the 64-bits, so they don't have to do any sort of dereference or indirection when you hand that object back. They just literally scribble what you hand them.


It's a performance optimization, even on 32-bit OSes.


출처 : Why are non dispatchable objects always typedefed to 64bit?


출처인 스레드에서는 이와 관련해서 여러 논쟁이 있었지만, 정리하자면 논디스패쳐블 오브젝트들은 GPU 메모리 주소에 직접적으로 매핑되어야 하기 때문에 64 bit 메모리 주소를 가지고 있어야만 한다는 겁니다. 


위에서 열거된 디스패쳐블 오브젝트의 종류와 논디스패쳐블 오브젝트의 종류를 비교해 보면 그럴싸 합니다. 그렇게 되면 x86 머신에서도 GPU 데이터에 대해서는 64 bit 주소를 유지할 수 있죠. 저는 이 주장을 믿기로 했습니다.


그렇게 생각하면 논디스패쳐블 오브젝트( GPU 관련 데이터 )는 드라이버 구현에 종속적이므로 간섭할 수 없는 데이터이고, 그렇지 않은 디스패쳐블 오브젝트는 간섭할 수 있는 오브젝트라는 쪽으로 논리를 이어갈 수 있습니다( 물론 이 부분은 조금 애매하긴 하지만 말이죠... ). 이런 관점에서는 디스패치 체인( Dispatch Chain )과 관련이 있는 오브젝트가 디스패쳐블 오브젝트라고 생각할 수 있을 것 같습니다( Dispatch Chain 에 들어 가니까 Dispatchable Object 이다??? ).


Object Lifetime


Vulkan 오브젝트들은 vkCreate* 와 vkAllocate* 커맨드를 통해서 생성됩니다. 그리고 이것과 쌍을 이루는 vkDestroy* 와 vkFree* 커맨드를 통해서 파괴됩니다. 절대 new delete 를 사용하지 않습니다.


vkCreate*/Destroy* 쌍은 일반적인 오브젝트 생성/파괴를 위해서 사용되며, vkAllocate*/Free* 쌍은 리소스 생성/파괴를 위해서 사용됩니다. 


Vulkan 오브젝트들의 타입은 불투명하기 때문에, 응용프로그램에서 생성파괴하는 것은 사실상 불가능하고 API 를 통해서만 생성/파괴하는 것이 가능합니다. 메모리 할당자를 통해서 오브젝트를 위한 메모리를 할당해 줄 수는 있겠지만, 그것은 단순히 메모리 할당이지 오브젝트 생성이 아닙니다. 실제 생성자나 소멸자는 감춰져 있는 모듈에서 호출됩니다.


그림1. VkCreate* 에서 오브젝트를 생성하는 과정.


이러한 오브젝트의 생명주기를 관리하는 것은 응용프로그램의 책임하에 있습니다. 그렇기 때문에 사용중인 오브젝트가 파괴되지 않도록 관리를 잘 해야 합니다. 


이에 대한 세부사항이 [ 1 ] 의 [ 2.3.1. Object Lifetime ] 섹션에 자세하게 나와 있습니다. 하지만 초심자들은 지금 본다고 해서 이해가 갈 수 있는 내용이 아니기 때문에 굳이 안 보셔도 상관이 없습니다. 어떤 오브젝트가 어떤 오브젝트에 소유권을 가지고 있는지를 텍스트로 기술하는 문서입니다. 예를 들면 "VkPipelineLayout 오브젝트가 커맨드 버퍼가 사용중일 때는 파괴되지 말아야 한다" 등의 내용입니다.


제가 생각했을 때, 이런 부분들은 스마트 포인터를 이용해 해결해야 할 부분이지 외워서 해결할 부분은 아닌 것 같습니다. 물론 불투명한 오브젝트의 생성자/소멸자를 응용프로그램에서 호출할 수 없으므로 래퍼( wrapper )를 사용해야 할 것입니다.


정리


Vulkan 에서 오브젝트는 디스패쳐블 오브젝트와 논디스패쳐블 오브젝트로 구분됩니다. 이것의 구분이 조금 모호하기는 하지만 디스패쳐블 오브젝트는 디스패치가 가능한 오브젝트이고 논디스패쳐블 오브젝트는 드라이버 관련 오브젝트라고 생각하는 것이 속이 편합니다.


Vulkan 오브젝트의 생성/파괴는 드라이버 구현에 의해서 블랙박스로 가려져 있는 상태이며, Vulkan 이 제공하는 API 를 통해서만 가능합니다. 이것은 메모리 할당자를 통해서 응용프로그램측에서 메모리를 제공하는 경우에도 마찬가지로 적용됩니다.


응용프로램은 오브젝트가 참조되고 있을 때 파괴되지 않도록 관리해야 합니다.


추가 : [ 2 ] 에서는 디스패쳐블 오브젝트에 대해서 다음과 같이 설명하고 있습니다. 위에서의 추측이 맞는 것 같습니다.




참고 자료


[ 1 ] Vulkan 1.1.89 - A Specification (with all registered Vulkan extensions), The Khronos Vulkan Working Group.


[ 2 ] Vulkan Loader DeepDive, Khronos.

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

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

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

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



Extension Naming Rules


Khronos 는, OpenGL 도 그렇고 Vulkan 도 그렇고, 익스텐션이라는 개념을 사용해서 API 의 확장성을 보장합니다.


익스텐션 이름의 규칙은 다음과 같습니다.


VK_<author>_<name>


<author> 는 다음과 같은 규칙을 가집니다.


  • KHR : Khronos 익스텐션.
  • KHX : Khronos 의 실험적( experimental ) 익스텐션. Vulkan 1.1 에서는 모두 KHR 로 승격되었고 더 이상 사용되지 않음.
  • NV : Nvidia 익스텐션.
  • NVX : Nvidia 의 실험적 익스텐션.
  • VALVE : Valve 익스텐션.
  • EXT : 여러명의 제작자( 사 )가 개발한 경우의 익스텐션.
  • ANDROID : Android 용으로 Google 에서 개발된 익스텐션.


익스텐션에 대한 모든 명세는 [ 1 ] 에 포함되어 있습니다.


Emumerating Extensions for Layer


vkCreateInstance() 호출을 통해 명시적으로 로더를 로드하기 위해서는 현재 머신에서 어떤 레이어 익스텐션을 지원하고 있는지 알 필요가 있습니다. 


특정 레이어를 위해서 필요한 익스텐션들은 json 파일에서 찾아보실 수 있습니다. 예를 들어 "VKLayer_core_validationr.json" 파일에는 지원하고 있는 익스텐션의 리스트가 있습니다.



예를 들어 "VK_LAYER_LUNARG_core_validation" 레이어를 생성하기로 했다면, vkCreateInstance() 를 호출할 때 "instance_extensions" 필드에 있는 "VK_EXT_debug_report" 라는 익스텐션을 추가해 줘야 합니다. 그리고 vkCreateDevice() 를 호출할 때는 "device_extensions" 에 있는 "VK_EXT_debug_marker" 와 "VK_EXT_validation_cache" 라는 익스텐션을 추가해 줘야 합니다. 물론 사용하지 않으려면 넣지 않아도 상관없습니다.


그런데 이런 걸 외우고 다닐 수도 없고, 하드코딩하는 것도 좀 찝찝합니다. 그래서 Vulkan 은 vkEnumerateInstanceExtensionProperties() 와 vkEnumerateDeviceExtensionsProperties() 라는 함수를 지원하고 있습니다.


이 함수들은 다음과 같은 시그너쳐를 가집니다.



그런데 실제 property 개수를 모르기 때문에 처음에는 pProperties 를 nullptr 로 넘겨 개수만 받고 그 다음에 실제 array 를 넘겨서 데이터를 받습니다. 그 예가 아래에 나와 있습니다.



하지만 위의 코드에서는 첫 번째 파라미터인 pLayerName 을 nullptr 로 넘겼기 때문에 레이어 사용여부와는 상관없이 모든 레이어의 익스텐션이 반환됩니다. 그러므로 특정 레이어를 위한 익스텐션만을 뽑아 내고자 한다면 레이어의 이름을 공급해 줘야 합니다.


예를 들어 보죠. 아까 살펴 본 json 파일에서 레이어의 "name" 필드 값이 "VK_LAYER_LUNARG_core_validation" 였기 때문에 이것을 넘겨 보도록 하겠습니다.



이러면 "VK_EXT_debug_report" 라는 결과가 출력됩니다. json 의 내용과 같죠?


하지만 생각해 보면 이것도 좀 삽질이라는 느낌이 들겁니다. 레이어가 한 두 개도 아니고 이렇게 하는 것은 불합리합니다. 그러므로 모든 레이어를 돌면서 이 정보를 추출해 낼 수가 있습니다.



그런데 1.0 초반부터 레이어에 대한 디바이스 익스텐션은 없어진 것으로 보입니다. Vulkan spec 에서는 다음과 같이 이야기하고 있습니다.


30.1.1. Device Layer Deprecation


Previous version of this specification distinguished between instance and device layers. Instance layers were only able to intercept commands that operate on VkInstance and VkPhysicalDevice, except they were not able to intercept vkCreateDevice. Device layers were enabled for indivisual devices when they were created, and could only intercept commands operating on that device or its child objects.


Device-only layers are now deprecated, and this specification no longer distinguishes between instance and device layers. Layers are enabled during instance creation, and are able to intercept all commands operating on that instance or any of its child objects. At the time of deprecation there were no known device-only layers and no compelling reason to create one.


In order to maintain compatibility with implementations released prior to device-layer deprecation, applications should still enumerate and enable device layers. The behavior of vkEnumerateDeviceLayerProperties and valid usage of the ppEnabledLayernames member of VkDeviceCreateInfo maximizes compatibility with applications written to work with the previous requirements.


요약하자면 이제는 디바이스에다가 레이어 익스텐션을 넣을 필요가 없다는 겁니다. 그런 경우가 있다면 호환성을 유지하기 위해서라는 겁니다. 하지만 이제 시간이 꽤 지났기 때문에 그런 호환성조차 유지할 필요가 없을 것으로 보입니다.


Get functionality for extension


"base/VulkanDebug.cpp" 의 debug 에서는 다음과 같이 함수를 가지고 와서 호출합니다. vkGetInstanceProcAddr() 을 사용하여 함수포인터를 획득하는 것을 보실 수 있습니다.



정리


json 파일은 인스턴스 익스텐션 정보와 디바이스 익스텐션 정보를 포함합니다. 인스턴스와 디바이스를 생성할 때 각각의 익스텐션 정보를 추가하면 그 레이어의 기능을 이용할 수 있습니다. 하지만 디바이스 익스텐션은 이제 지원하지 않으므로 그냥 전부 인스턴스 익스텐션인 것처럼 생각하고 사용하시면 됩니다.


vkGetInstanceProcAddr() 를 호출하여 특정 함수에 대한 포인터를 얻어와 실행하는 것이 가능합니다.


참고자료


[ 1 ] Vulkan 1.1.89 - A Specification (with all registered Vulkan extensions), The Khronos Vulkan Working Group.


[ 2 ] Architecture of the Vulkan Loader Interfaces, LUANR XCHANGE.

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

주의 : 허락받고 번역한 것이 아니므로 언제든 내려갈 수 있습니다.

주의 : 번역이 개판이므로 이상하면 원문을 참조하세요.

원문 : Brief guide to Vulkan layers, RenderDoc.



Brief guide to Vulkan layers


Vulkan 은 정말 멋진 개념들을 많이 가지고 있습니다만, 아직까지는 많은 주의를 기울이지 않은 것이 하나 있는데, 그것은 API 에코시스템( ecosystem )에 내장된 레이어 시스템입니다.


다른 API 들은 일반적으로 런타임에 직접적으로 연결됩니다. 이 연결은 완전히 불투명( opaque )하며 그것의 모든 작업들에 대한 호출을 드라이버로 넘깁니다. 이는 그 툴들과 애드인( add-in ) 기능들에 대해 두 가지 옵션이 있음을 의미합니다: 특정 플랫폼에 국한된 인터셉션 핵( platform specific interception hack )을 사용해서 그것들을 응용프로그램과 API 사이에 밀어 넣을 수도 있고, 플랫폼 홀더( platform-holder )나 IHV 에 의해 빌드되어 런타임이나 드라이버 자체에서 훅( hook )을 작성할 수도 있습니다( 역주 : "platform-holder" 라는 것은 게임 소프트웨어가 동작하는 하드웨어를 제조하는 회사를 의미합니다. IHV 는 "Independent Hardware Vendor" 의 머릿글자로 하드웨어 제조사를 의미합니다 ).


이것이 적절한 유스케이스( use-case )인 것으로 보이기는 하지만, 사실은 그렇지 않습니다 - 모든 개발자들은 API 자체의 validation 및 checking 기능을 때때로 사용하고 있으며, Vulkan 과 같은 더 새로운 명시적 API 들에 대해서는 이게 시간을 절약하는 데 있어 매우 중요합니다. 이와 유사하게 디버거에서 프로우파일러까지 매우 다양한 툴들이 API 에 접근하기 위해서 필요하게 될 것입니다. 이 위에 런처 오우버레이( overlay )라든가 성능 측정기, 비디오 캡쳐같이 모두가 접근하기 원하는 것들이 존재합니다.


Having all of these systems trying to fight to themselves with no way of co-operating only leads to trouble, so having system built-in to the API to add-in these different things saves everyone a lot of hassle.


대부분의 개발자들이 validation, profiling, debugging 등을 위해 제공된 레이어들을 그냥 사용만 하고 싶어 하지만, 어떤 개발자들은 그 시스템이 실제로 동작하는 방식에 흥미를 가지며, 그것이 자신만의 레이어를 만드는데 도움이 되기를 원합니다. 이 조각들이 정확히 어떻게 맞춰져 가는 지에 대해서 설명하는 세부 명세가 있는데, 여러분은 반드시 그걸 읽어 봐야 합니다. 하지만 좀 이해하기가 힘들 수 있으므로, 그것들을 모아서 좀 친숙하게 소개해 보고자 합니다.


- baldurk


The Loader


로더는 Vulkan 런타임의 중심 결정권자입니다. 응용프로그램은 로더하고만 직접적으로 대화합니다. 그러면 로더는 요청된 레이어들을 열거( enumerating )하고 유효화( validating )하고, ICD( 역주 : Installable Client Driver ) 들을 열거하고, 그것들을 조직화하고, 응용프로그램에 대한 통합된( unified ) 인터페이스를 제공합니다.


로더는 - Vulkan ecosystem 에서 대부분 그러하듯이 - 오픈 소스이기 때문에, 여러분은 그것들이 어떻게 구현되어 있는지 정확하게 알 수 있습니다. 소스를 오픈해서 여기에서 필자가 이야기하는 개념들을 찾아 보는 것은 좋은 생각입니다. 필자가 앞에서 링크한 명세도 이용할 수 있습니다. 그것은 로더와 레이어, 그리고 ICD 가 상호작용하는 방식에 대해 정확하게 정의하고 있습니다.


응용프로그램이 vulkan-1.dll 이나 libvulkan.so.1 에 대해 링크를 할 대, 그것들은 로드에 대해서 링크하고 있는 것입니다. 로더는 vkCreateImage vkCmdDraw 와 같은 ( 익스텐션이 아닌 ) 모든 핵심 Vulkan 함수들을 익스포트합니다. 응용프로그램이 이들 중 하나를 호출하면, 그것들은 Vulkan 드라이버로 직접 넘겨지는 것이 아니라 로더로 넘겨집니다.


응용프로그램이 하게 될 첫 번째 동작은 인스턴스 익스텐션들과 레이어들에 대해 질의하는 것이고, 그리고 나서 vkCreateInstance 를 호출합니다. 이렇게 하기 위해서는 로더가 이미 어떤 레이어들을 이용할 수 있는지 알고 있어야 하며, 그 방식은 플랫폼마다 다릅니다. Windows 에서는 레이어들은 레지스트리 키( HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan ) 에 등록되어 있으며, Linux 에서는 시스템이나 유저가 지정한 방식에 따라 달라집니다. 등록된 파일들은 .json 매티페스트( manifest ) 인데, 그것은 어떤 레이어가 이용가능하며 어디에서 실제 라이브러리를 찾을 수 있는지를 기술합니다.


이러한 모든 질의는 json 매니페스트를 읽어들임으로써 충족되며, 레이어 모듈 자체를 읽어들일 필요가 없습니다. 각 레이어들이 제공하는 익스텐션들은 매니페스트 내부에 세부적으로 기술되어 있으며, 이 질의를 통해 획득할 수 있는 이름과 버전을 포함합니다. 이는 응용프로그램에 의해서 활성화되어 있지 않은 레이어들은 메모리에 절대 로드되지 않는다는 것을 의미합니다.


로더는 ICD 들을 로드해야만 합니다. 디바이스가 제공하는 인스턴스 익스텐션들을 결정하기 위해서입니다. ICD 는 매니페스트 파일과 매우 유사한 방식으로 등록되어 있습니다. 이 시점에 우리는 응용프로그램이 요청하는 레이어들을 생성할 수 있으며 그 레이어들은 이제 어떤 실제 동작을 취할 수 있게 됩니다.


필자는 단지 Windows 나 Linux 와 같은 데스크탑 플랫폼에서 사용중인 로더에 대해서만 다루고 있습니다. Android 로더는 좀 한계가 있고 제한적입니다. 그래서 필자는 그런 디바이스를 다루기 위해 필요한 부차적인 hoop 과 제약에 대해 다루고 싶지는 않습니다. 전체적인 원리는 여전히 동일합니다.


Dispatch Chains 


이 시점에 우리는 ( vkCreateInstance 같은 ) 단일 함수 호출이 로더와 ICD 들, 그리고 다양한 레이어들에 전달되는 방식에 대해서 이야기해 볼 필요가 있습니다.


응용프로그램이 로더가 정적으로 익스포트한 함수를 호출할 때, 그것은 트램펄린 함수( trampoline function ) 을 호출합니다( 역주 : 우리가 "방방"이라 부르는 그 트램펄린. [ 함수내의 함수선언과 trampoline ], [ Win32 API Hooking - Trampoline Hooking ] 참고하세요 ). 이 트램펄린 함수는 다시, 디스패치 체인( dispatch chain )이라 불리는 것을 호출합니다.


모호함을 줄이기 위해서 이야기하자면, 디스패치 체인의 개념( idea )은 실행이 흘러가는 것을 따라가는 함수 포인터의 체인입니다. 로더의 트램펄린 엔트리 포인트( entry point )에서 시작해, 트램펄린은 첫 번째 레이어를 호출하고, 첫 번째 레이어는 두 번째 레이어를 호출합니다. 이 체인은 최종적으로 실제로 작업을 수행하는 ICD 의 엔드포인트( endpoint )에 도달할 때까지 이어집니다.


vkCreateInstance 는 이런 특별한 함수들 중의 하나입니다. 디스패치 체인의 엔트리 포인트에 있는 트램펄린 함수에서, 그것은 먼저 요청된 레이어들과 익스텐션들이 유효한지를 검사합니다. 모든 것이 유효하다면, 그것은 Vulkan 인스턴스와 디스패치 체인을 할당하고, 첫 번째 레이어에서 vkCreateInstance 를 호출합니다. 첫 번째 레이어는 자신과 내부 구조체들에 대한 초기화를 수행하고, 실행을 다음 레이어의 vkCreateInstance 로 넘기는 식입니다.


이제 응용프로그램의 관점에서 Vulkan 인스턴스는 거의 로더 개념에 가깝습니다. They represent everything all together for its use of Vulkan, but they also combine all the available ICDs into one unified front. This means that when we reach the end of the dispatch chain for vkCreateInstance, we can't end up in an ICD. There could be several in use at the same time, and ICDs don't know about each other to chain together( 역주 : 여러 개의 ICD 가 존재하기 때문에, 특정 ICD 는 다른 ICD 들이 vkCreateInstance 호출과 연결되어서 처리되고 있는지 여부를 알 수 없으므로 함수 호출이 언제 종료되어지 판단하기 힘들다는 의미인듯 ).


로더는 호출하기 위한 마지막 레이어를 위해서 자신만의 종료 함수( terminator function )를 디스패치 체인에 삽입함으로써 이 문제를 해결합니다. 이 종료 함수는 이용가능한 각 ICD 상에서 호출되며, 그것들을 이후의 사용을 위해서 저장합니다. 아래 다이어그램은 로더 명세에서 가지고 왔습니다:



As we were going along, 레이어들은 자신을 초기화하고 디스패치 체인에서 자신의 공간을 준비하고 있었습니다. 특히 모든 레이어들은 vkGetInstanceProcAddr 를 사용해서 다음 레이어에서 호출하기를 원하는 엔트리 포인트를 찾습니다. 각 레이어들은 vkGetInstanceProcAddr 를 다음 레이어에서 호출하고 이들을 디스패치 테이블에 저장합니다. 이것은 그냥 함수 포인터들로 구성된 구조체입니다.


생성에 앞서 이렇게 하기 때문에, 로더가 체인의 첫 번째 레이어의 엔트리 포인트를 호출할 때, 그것은 이미 다음 위치( 역주 : 다음에 호출될 함수 )를 알고 있음을 의미합니다. 이것은 유용한 기능을 가능하도록 만들기도 합니다 - 레이어들은 모든 Vulkan 함수를 후킹할 필요가 없슶니다. 그냥 관심있는 것만 하면 됩니다.


이는 로더와 각 레이어가 vkGetInstanceProcAddr 를 호출해 디스패치 체인 내의 다음 함수를 검색하고 있었을 때 발생합니다. 만약 레이어가 함수 호출에 간섭( intercepting )하고 싶지 않다면, 자신의 함수를 반환할 필요가 없습니다. 대신에 vkGetInstanceProcAddr 호출을 다음 레이어에 전송하고 결과를 반환할 수 있습니다. 각각의 지점에서 디스패치 테이블이 다음에 호출된 함수가 무엇인지 알고 있는 한, 레이어를 한 두 개 스킵한다고 해도 문제가 되지 않습니다.


 

위의 예제에서, 레이어 A 와 레이어 B 는 모든 함수에 간섭하는 것은 아닙니다. The loader's dispatch table will go partly to A and partly to B, likewise A's dispatch table will go partly to B and partly directly to the ICD. 단순함을 위해 우리는 이런 함수들이 로더 종료 함수를 가지고 있지 않다고 가정합니다.


여러분은 이를 각 레이어에서 비균질적으로( sparsely ) 생성된 대규모 디스페치 체인으로 생각해도 되고, 아니면 길거나 짧을 수 있는 함수당 디스패치 체인으로 생각해도 됩니다. vkFunctionB 를 위한 디스패치 체인을 vkFunctionC 를 위한 체인과 비교해 보십시오.


레이어가 특정 함수를 위한 디스패치 체인에 참여하고 있지 않는다고 하더라도 원할 때 그 함수들을 호출할 수 없는 것은 아니라는 점에 주의해야만 합니다. 위의 예제에서, 레이어 A 는 vkFunctionD 에 간섭하는 것에 관심이 없다고 하더라도 자신의 기능의 일부로서 호출하기 원할 수도 있습니다.


vkQueuePresentKHR 에 대한 호출에 대해서만 간섭해서 화면에 그림을 그릴 수 있는 성능 오우버레이의 예를 생각해 봅시다. 하지만 그림을 그리기 위해서는 커맨드 버퍼 레코딩( recording ) 함수를 호출할 수 있어야 할 것입니다. 이 방식에서, 레이어 A 는 로더가 하는 것과 같은 방식으로 레이어 B 의 구현에 대한 포인터를 획득할 수 있을 것입니다.



디스패치 체인에 대해서 마지막으로 언급할 것이 있습니다. 사실 단순하지 않은( non-trivial ) Vulkan 응용프로그램을 만드는 동안에 사용되는 디스패치 체인은 두 개입니다.


그 이유는 다음과 같은 상황을 생각하면 명확해집니다. 위에서 이야기했듯이, Vulkan 인스턴스는 여러 개의 ICD 를 묶고 있어서, 디스패치 체인을 통해서 진행되는 모든 함수 호출들은 결과적으로 다른 ICD 로 이동해야 합니다. 그런데 일단 VkDevice 를 생성하고 나면, ( VkPhysicalDevice 를 제공하는 ) 특정 ICD 를 선택하고, 모든 호출들은 특정 ICD 에 대해서 수행됩니다. On top of that, 만약 각 물리 디바이스를 위해 여러 개의 디바이스를 생성한다면, 서로 다른 ICD 들을 향하는 서로 다른 체인들을 가질 수 있습니다!


그래서, 레이어 명세와 이런 디스패치 체인에 대해서 논의하는 문서들에서, 여러분은 인스턴스 체인과 디바이스 체인이 개별적으로 논의되는 것을 볼 수 있을 것입니다. 인스턴스 체인은 인스턴스, 물리 디바이스, 그리고 vkCreateDevice 상의 모든 함수 호출을 위해서 사용되는 것입니다. 디바이스를 생성하고 나면, 그것과 그것의 자식들에 대한 모든 호출들은 디바이스의 디바이스 체인에서 수행됩니다.


Implementation


이제 실제로 레이어들이 동작하는 방식에 대해서 알게 되었습니다 - this primary concept of dispatch tables and dispatch chains allows layers to be strung along arbitrarily and gives us a well-defined way of including many different functions all together without worrying about clashes in function hooking.


이제 이것이 실제로 어떻게 구현되는지 핵심을 알아 보도록 하겠습니다. 그리고 그 과정에서 각 커맨드 버퍼에 대해 간단한  drawcall 통계를 추적하는 예제 레이어를 만들어 보겠습니다.


JSON Manifest


첫 번째로 사려 볼 것은 앞에서 언급한 json 매니페스트입니다. 로더가 시스템에서 레이어를 검색하는 방법에 대해서 이야기할 때 언급했습니다. 이건 매우 직관적이라서, 바로 샘플 레이어를 위한 단순한 매니페스트를 살펴 보도록 하겠습니다.



대부분의 필드들은 VkLayerProperties 를 획득했을 때 직접적으로 매핑되는 것들입니다. file_format_version 은 매니페스트 포맷의 버전이며, library_path 는 ( JSON 위치에 대한 ) 상대 경로이거나 절대 경로, 혹은 단순한 파일이름일 수 있습니다. 마지막의 경우에, 다른 동적 모듈 로딩을 위해서 하는 것처럼 일반적인 경로 검색 로직이 사용됩니다 - OS 에 의해 정의되어 있는 것처럼 system path 들을 검색합니다. Windows 에서는 이것이 LoadLibrary 검색 경로일 수 있으며, Linux 에서는 dlopen 에서 사용되는 ld.so.conf 와 LD_LIBRARY_PATH 로부터의 경로일 수 있습니다.


type 필드는 이전 버전과의 호환성을 위한 것입니다. 원래 Vulkan 명세에서, 레이어들은 인스턴스 전용, 디바이스 전용, 혹은 둘 다 였습니다. 그래서 이 필드는 INSTANCE, DEVICE, GLOBAL 이어야 했습니다. 하지만 이제 레이어들은 그것들이 디바이스 체인에 존재할 필요가 없음에도 불구하고 원치 않는다면, 항상 둘 다 지원하는 것으로 여겨지고 있습니다. 대부분이 두 체인을 모두 사용할 것입니다. 왜냐하면 그곳이 대부분의 기능이 거주하고 있는 곳이기 때문입니다.


이 매니페스트 정도면 충분합니다. 로더는 이를 사용해 vkGetInstanceProcAddr vkGetDeviceProcAddr 엔트리 포인트들을 검색해서 디스패치 체인을 생성할 것입니다. 이것들이 모듈이 익스포트해야 하는 엔트리 포인트의 전부입니다.  API 함수와 완전히 동일한 이름을 가진 함수들을 익스포트해야 하는 것이 이상하고 불편할 수 있기 때문에, SampleLayer_GetInstanceProcAddr SampleLayer_GetDeviceProcAddr 같은 함수들을 대신 익스포트할 수 있습니다. 매니페스트에서 매핑을 추가하면 가능합니다.



이제 여러분의 레이어가 검색될 것이고 사용자가 레이어를 활성화할 때 GetProcAddr 함수가 호출될 것입니다.


vkGetInstanceProcAddr


이 함수들은 vulkan.h 에 리스팅되어 있는 것과 동일한 시그너쳐( signature, 역주 : 함수 원형 )를 가지고 있으며, 여러분이 원하는 대로 동작할 것입니다 - 여러분이 익스포트하는 각 함수에 대해서 순차적으로 pName 파라미터를 strcmp 할 수 있습니다. 그리고 나서 엔트리 포인트의 주소를 반환합니다. 만약 대량 작업을 해야 한다면 이를 위해서 매크로를 사용할 수도 있을 겁니다.



어이구... 모든 엔트리 포인트들에 간섭할 필요가 없다는 것에 대해서 감탄하려고 하는데, 문제가 발생했습니다 - 우리가 고려하지 않은 어떤 다른 함수들이 있을 때 무엇을 반환해야 할까요? 우리는 아직 다음 레이어가 무엇인지 혹은 그것의 vkGetInstanceProcAddr 이 어디에 있는지 알지 못합니다.


이 구현을 마무리하기 위해서는 vkCreateInstance 가 동작하는 방식에 대해서 먼저 살펴 볼 필요가 있습니다.


vkCreateInstance


vkCreateInstance 는 초기화를 획득하고 디스패치 테이블을 생성하는 곳입니다. 레이어가 인스턴스 자체에 대해서는 신경쓰지 않는다고 하더라도, 여전히 초기화 코드를 수행할 필요가 있는 곳이며, 모든 레이어들이 그 함수를 구현할 것을 요구받는 곳입니다. 이와 유사하게 어떤 디바이스 함수에 간섭하고자 한다면, 반드시 vkCreateDevice 와 초기화를 구현해야만 합니다. 그 함수 구현은 거의 대부분 동일합니다. 왜냐하면 디스패치 체인 개념 역시 같기 때문입니다.


레이어의 vkCreateInstance 가 호출될 때, 로더는 초기화에 사용할 수 있는 추가 데이터를 삽입합니다. 특히 Vulkan 의 sType/pNext 확장성을 사용합니다. VKInstanceCreateInfo 구조체에서, 그것은 다른 많은 Vulkan 구조체들에서와 정확히 같은 방식으로 VkStructureType sType 과 const void *pNext 로 시작합니다. 이 두 개의 요소는 추가적인 익스텐션 정보에 대한 연결 리스트( linked list )를 정의합니다. 그 리스트의 엔트리들에 대해서 인지하지 못하고 있다고 해도 그 리스트를 순회할 수 있습니다. 왜냐하면 그것들은 모두 sType/pNext 에서 시작하기 때문이죠.


로더는 추가적인 요소를 pNext 체인에다가 삽입하는데, 그것은 VK_STRUCTURE_TYPE_LAODER_INSTANCE_CREATE_INFO 타입을 가집니다. 이 구조체는 vk_layer.h 에 정의되어 있습니다. 그것은 이 GitHub 나 vulkan.h 로서 같은 SDK 안에 배포되어 있습니다. 이 구조체는 다음 레이어의 vkGetInstanceProcAddr 를 포함하는데, 그것이 우리가 초기화해야 할 필요가 있는 것의 전부입니다. 그 헤더는 VkLayerDispatchTable 구조체를 포함하기도 하는데, 이는 비확장( unextended ) Vlukan 을 위한 함수 포인터를 모두 포함합니다. 여러분이 그것을 사용해서는 안 되지만, 유용합니다.


각 레이어 호출은 직접적으로 같은 create info 구조체를 사용해서 다음 레이어로 넘겨지기 때문에, VkLayerInstanceCreateInfo 내에서도 작은 연결 리스트를 필요로 합니다. 기본적으로 이 리스트의 프런트를 빼고( pop off ) 넣습니다 - 함수 포인터를 취하고, 리스트 헤드를 이동시킵니다. 그래서 다음 레이어가 체인의 세 번째 레이어를 위한 정보를 획득할 수 있도록 하는 식입니다.


로더는 실제로는 두 개의 서로 다른 구조체를 이 타입과 함께 체인에 삽입하는데, VkLayerFunction 에 대한 서로 다른 값을 사용합니다 - 하나는 vkGetInstanceProcAddr 를 위한 것입니다. 다른 하나는 디스패쳐블 오브젝트를 초기화하는 데 사용될 수 있는 함수를 포함합니다. 디스패쳐블 오브젝트를 레이어에서 생성할 때, 여러분은 로더 콜백을 호출해서 디스패치 테이블을 적절히 초기화해 줄 필요가 있습니다 - 이 포스트의 뒤쪽에 있는 오브젝트 래핑( wrapping )을 참고하세요. 이 작업은 보통 트램펄린 함수에 의해서 수행됩니다.


그것이 작동하는 방식에 대한 세부사항은 이 포스트의 범위를 넘어섭니다. 그리고 샘플 레이어를 위해서는 필요하지 않은 기능이므로 그냥 넘어갈 필요가 있습니다. 명세에서 이것이 설정되는 모든 방식에 대한 정확한 세부사항을 읽어보실 수 있습니다.


이걸 전부 넣어서 이제 SampleLayer_CreateInstance 를 구현할 수 있습니다 :



여기에는 빠져 있는 부분이 하나 있습니다 - 이제 우리는 앞으로 계속 이어지는 함수 포인터들을 가진 디스패치 테이블을 생성했는데, 실제로 그것을 어디에 저장해야 하는 걸까요? 우리가 그것을 전역적으로 저장할 수는 없습니다. 왜냐하면 다중 인스턴스들이 생성되면 깨지기 때문입니다( 역주 : 스레드에서의 다중 접근에 의해 깨짐 ).


필자가 아는 한도에서는 이것을 구현하는 두 가지 방법이 있습니다. 첫 번째는 그냥 인스턴스부터 디스패치 테이블까지의 룩업 맵( look-up map )을 만들고 스레드에서의 접근 문제를 해결하기 위해서 전역 잠금( lock )을 거는 겁니다. 그것이 우리 구현에서 사용하게 될 것입니다. 왜냐하면 멋지고 단순하기 때문입니다. 다른 방식인 오브젝트 래핑에 대해서는 옆길로 새서 짧게 언급하도록 하겠습니다.


We'll see as we finish the implementation that the burden of all these locks grows quite quickly, and we're going to end up forcing a lot of our Vulkan use to be serial. That kind of defeats the purpose of having a highly parallelisable API( 역주 : lock 이 많이 발생해서 병렬화라는 목적에 위배될 수 있다는 의미인듯 ). 어떤 레이어들에 대해서는, 이것이 허용할 수 없는 비용이라 생각될 수 있습니다. 하지만 대안이 존재합니다.


Object Wrapping


생성 함수에 의해서 반환받은 핸들들은 - 위의 예제에서 *pInstance - 전체적으로 불투명하며, 중요하게는 원한다면 체인마다 서로 다른 뭔가를 반환할 수 있습니다. 사실 새로운 메모리를 할당하거나 디스패치 테이블을 그곳에 할당하거나 그 포인터를 체인으로 반환하고 그것이 실제인 것처럼 가장하는( pretending ) 것을 막을 수 있는 방법은 없습니다. 그러면 디스패치 테이블을 원할 때마다 그냥 커스텀 구조체를 검색하고 그것을 가지고 올 수 있습니다.


자, 그건 거의 아무것도 아닙니다. 첫 번째 문제는 그 핸들들이 불투명하지만 누군가에게는 매우 중요하다는 것입니다. 만약 핸들을 래핑하고 응용프로그램으로 향하는 체인에 서로 다른 뭔가를 반환했다고 한다면, ICD 쪽으로 체인이 되돌아올 때마다 그것을 잘 캐치해야 합니다. 그것을 다음으로 넘기기 전에 원래의 것으로 대체해야만 합니다. 왜냐하면 그렇게 하지 않으면 안 좋은 일이 발생할 것이기 때문입니다. 경험적으로 볼 때, 이것은 커스텀 메모리에 원래의 것을 저장하고 그 유형의 오브젝트를 사용하는 모든 Vulkan 함수들을 구현해서 그것을 다시 언래핑( unwrap )해야만 한다는 것을 의미합니다. 이제 더 이상 레이어에서 관심을 가지지 않는 함수들을 건너뛸 수가 없게 됩니다.


경험적으로 볼 때, 어떤 오브젝트를 래핑하는 거의 모든 레이어들이 그것들을 래핑하게 된다는 것을 의미합니다. 그래서 이는 all-or-nothing 접근법이 될 것입니다. 또한 여러분이 구현 시점에 알고 있지 않은 익스텐션이 그 오브젝트를 사용할 수도 있다는 것을 고려하면 두 배로 까다로워질 것입니다. 그런 익스텐션 함수 호출이 언래핑을 하도록 간섭할 수 있는 방법이 존재하지 않습니다. 이는 오브젝트 래핑을 하는 레이어들이 그 함수들을 추가하는 모든 익스텐션과 호환되지 않는다는 것을 의미합니다.


The second thing is more of an implementation note - 로더는 이 핸들들을 불투명한 것으로 취급하지 않습니다. 디스패쳐블 핸들 - VkInstance, VkPhysicalDevice, VkDevice, VkQueue, VkCommandBuffer - 들의 경우, 그것들이 가리키는 메모리는 반드시 첫 번째 sizeof( void* ) 바이트들 내부에 디스패치 테이블을 포함해야만 합니다. 경험적으로 볼 때, 이것은 커스텀 메모리를 할당할 때 로더의 디스패치 테이블을 첫 번째 포인터로 먼저 복사하고 다음으로 원래 오브젝트를 두 번째 포인터로 복사하고 여러분이 원하는 정보는 그게 무엇이든지 그 이후에 저장해야만 한다는 것을 의미합니다.


이런 개념은 매우 친숙하게 들릴 것입니다 - 이것은 C++ 의 vtable 의 개념과 매우 유사합니다. 가상함수는 기저 클래스에 대한 포인터를 통해서 호출할 필요 없이 오브젝트에 대해서 호출될 수 있습니다. 위의 요구는 사실상 오브젝트를 래핑할 때 vtable 을 예약할 것을 요청하는 것과 같습니다.


단점들이 존재하기는 하지만, 오브젝트 래핑은 오브젝트에 접근하기 위한 전역 잠금을 요구하지 않는다는 큰 이점을 가지고 있습니다 - 특정 데이터나 디스패치 테이블과 같은 오브젝트. 만약 조그만한 레이어를 작성하고 있다면, 이 방식을 따라하고 싶지 않을 것이지만, 많은 함수들에 대해 간섭하는 레이어들에 대해서는 오브젝트 래핑을 사용하는 것이 적절할 것입니다.  오브젝트 래핑이 가지는 책임에 대해서 주의하기만 하면 됩니다.


이제 전역 맵과 잠금을 정의할 수 있으며, 디스패치 테이블을 인스턴스에 저장할 수 있습니다:



I've also snuck in a little detail here - 나중에 명확해지기를 바라기 때문에, 인스턴스 핸들 자체를 맵의 키로 사용하는 것이 아니라 로더의 디스패치 테이블 포인터를 키로 사용합니다. GetKey 함수는 그냥 void* 포인터를 반환합니다.


이제 디스패치 테이블을 만들었으니, SamplerLayer_GetInstanceProcAddr 로 돌아가서, 우리가 간섭하지 않을 다른 함수들을 위해 다음 레이어로 전송하는 코드를 마무리할 수 있습니다: 



그렇게 함으로써 이제 우리 레이어는 인스턴스 체인상에 있게 됩니다! 대부분의 레이어들은 스스로를 디바이스 체인에서도 초기화하기를 원할 것입니다. 왜냐하면 관심있는 대부분의 함수들이 거기 있기 때문입니다. 하지만 그것이 엄격히 요구되는 것은 아닙니다.


vkGetInstanceProcAddr 구현에서 알아챘을지도 모르겠지만, 레이어들은 레이어와 익스텐션 프라퍼티( property )들을 위한 열거 함수( enumeration function )들도 익스포트해 줄 필요가 있습니다 - these either forward themselves on, or return the results for themselves if pLayerName matches their own name. 너무 단순해서 여기에서 코드를 포스팅하지는 않겠습니다. 하지만 샘플 코드의 마지막에서 전체 세부사항을 보실 수 있습니다. 이와 유사하게 SampleLayer_DestroyInstance 의 구현도 그냥 isntance_dispatch 의 맵을 제거하기만 합니다.


vkGetDeviceProcAddr & vkCreateDevice


SampleLayer_GetDeviceProcAddr SampleLayer_CreateDevice 함수를 구현하는 것은 인스턴스 버전을 구현했을 때와 거의 정확히 같은 패턴을 따릅니다. 왜냐하면 이 모든 개념들이 동일하게 적용되기 때문입니다. 차이가 있다면 주의해야 할 점이 있다는 겁니다 - VkDevice 와 그것의 자식들은 디바이스 상에 존재하지만, VkCreateDevice 함수는 VkInstance 상에서 호출되고 있으며, 인스턴스 체인에 거주한다는 것입니다.



이제 인스턴스 체인과 디바이스 체인을 초기화했습니다. 그리고 디스패치 체인에서 호출되기를 원하는 디스패치 테이블을 채우고 저장했습니다. 이 레이어는 전체적으로 수동적( passive )이라는 점에 주의하세요 - 그것은 전송되는 동안 어떤 Vulkan 함수도 호출하지 않습니다 - 그래서 우리의 디스패치 테이블은 우리가 간섭하고자 하는 그 함수와 거의 비슷합니다. 하지만 실제 레이어들을 작성할 때는 전체 테이블을 생성하기를 원할 겁니다.


Command buffer statstics


지금까지 실제로는 아무것도 하지 않는 레이어를 만들었습니다. 이제 어떤 기능을 추가해 봅시다. 각 커맨드 버퍼에 기록된 드로콜, 인스턴스, 정점들을 세서 커맨드 버퍼 기록이 끝날 때 출력할 것입니다.


먼저 커맨드 버퍼 데이터를 저장하기 위해서 구조체와 맵을 전역적으로 선언합니다:



멀티스레딩 환경에서의 기록이 문제를 발생시키지 않도록 하기 위해서 이것을 동일한 전역 잠금을 사용해 보호할 것입니다( see why the lock + map approach starts to have issues? ). 커맨드 버퍼의 생명주기를 적절히 추적하기 위해 커맨드 버퍼 해제와 커맨드 풀 생성/삭제 함수들에 대해 간섭한다든가 하는 일을 하지는 않을 겁니다. 그냥 이 맵을 그대로 놔둘 겁니다.


vkBeginCommandBuffer 에서 상태를 0 으로 재설정할 겁니다( 이 경우에 커맨드 버퍼는 이전에 기록되었다가 재설정되었습니다 ). vkEndCommadnBuffer 에서 그것들을 출력할 겁니다. 데이터를 기록하기 위해서 vkCmdDraw vkCmdDrawIndexed 에 대해서도 간섭할 겁니다. 각각의 경우에, 우리의 작업을 수행한 후에 체인의 다음 레이어로 이동할 것입니다.



여기에서 인스턴스 핸들 자체가 아니라 로더의 디스패치 테이블 포인터를, instance_dispatch 와 device_dispatch 맵의 키로 사용하는 이유를 알 수 있습니다. 두 개의 디스패치 체인만이 존재한다는 사실의 이점을 취할 수 있습니다 - 하나는 인스턴스이고 다른 하나는 디바이스 및 그것들의 자식입니다. 우리가 커맨드 버퍼상의 함수를 가지고 있고 디스패치 테이블에서 전송 포인터를 검색하고자 할 때, 모든 커맨드 버퍼들에 대한 포인터는 디바이스를 위한 포인터들과 정확하게 일치합니다. 그것들이 같은 디바이스 체인에 존재하기 때문입니다!


그렇게 함으로써, 믿든지 말든지, 우리 레이어는 실제적으로 완결되었습니다. 단순한 삭제 함수들과 레이어 열거/질의 함수들을 제외하고는 코드를 모두 포스팅했습니다 - 300 라인 정도 됩니다.


Explicit and Implicit layers


여기에서 필자가 짧게 언급하고 싶은 마지막 개념이 있습니다. 위에서 세부적으로 기술한 모든 것들은 여러분에게 "명시적"인 레이어로 알려진 것들에 대한 겁니다. 명시적 레이어는 사용자가 vkCreateInstance 에서 명시적으로 요청할 때만 활성화되는 레이어입니다. 명시적 레이어들의 리스트에 매니페스트가 등록되어 있으며, 일반적인 방식으로 열거됩니다.


하지만 어떤 경우에는 응용프로그램의 코드를 수정하지 않고 검색해서 활성화하기를 원하는 레이어가 있을 수 있습니다. 응용프로그램을 실행하기 위해서 사용하는 디버거나 프로우파일러같은 툴들을 생각해 보시죠. 여러분은 그것들의 레이어 이름이 무엇인지 알거나 컴파일하고 싶지 않을 겁니다.


레이어를 활성화하는 것은 코드를 변경하지 않으며, 여러분은 단지 레이어가 등록되는 방식만 변경하면 됩니다. JSON 매니페스트를 등록할 때, 레이어를 명시적 검색 경로에 등록할지 묵시적 검색 경로에 등록할지 선택하는 것이 가능합니다. 만약 묵시적인 레이어로 등록한다면, 매니페스트에 두 개의 추가적인 엔트리만 정의애햐 합니다.



그 두개의 엔트리는 - enable_environment 와 disable_environment 입니다 - 레이어를 활성화하고 비활성화하는 트리거( trigger )가 됩니다. 어떤 레이어도 항상 활성화되지는 않습니다. 대신에 사용자에 의해 명시적으로 요청되지 않은 레이어들은 로더가 그것을 활성화할 수 있도록 설정하는 환경변수를 가지고 있어야만 합니다.


그것은 또한 'off-switch' 도 가지고 있어야 합니다: 묵시적인 레이어를 관리하고 로드되는 것을 막을 수 잇는 환경변수. 이것은 응용프로그램이 호환되지 않는 묵시적인 레이어를 검색할 때나 로딩을 중지할 때 유용합니다. 만약 변수가 둘다 설정되어 있다면, 'off-switch' 가 우선순위를 가지며 레이어는 로드되지 않을 것입니다.


Conclusion


이 포스트를 읽고 난 후에 로더의 작동 방식, 레이어를 초기화하고 체인을 생성하는 방식, 자신만의 레이어를 구현하는 방식에 대해서 더 잘 이해할 수 있었기를 바랍니다. 여러분이 레이어를 구현하지 않는다고 하더라도, 응용프로그램 코드에서 vkGentInstanceProcAddr vkGetDeviceProcAddr 를 호출하는 것의 차이를 이해할 수 있게 되었을 겁니다.


응용프로램은 디스패치 체인을 사용해 하는 것이 아무것도 없기 때문에, 실제적인 차이는 vkGetDeviceAddr 가 디스패치 체인 내의 첫번째 엔트리를 직접적으로 가리키는 함수 포인터를 어떤 것을 반환하느냐에 따라 달려 있습니다 - 제공되는 레이어가 없다면 ICD 를 직접적으로 가리키는 함수 포인터를 반환합니다. vkGetInstanceProcAddr 는  로더가 익스포트하는 것과 동일한 트램펄린 함수를 반환할 수도 있습니다. 그것은 디스패쳐블 핸들로부터 디스패치 테이블을 로드( fetch )하여 거기로 점프합니다.


여러분이 살펴 볼 수 있도록 하기 위해 마지막 레이어를 위한 소스를 GitHub 에 저장해 놨습니다.


만약 질문이 있다면 twitteremail 을 통해서 자유롭게 하시기 바라며, 저도 최선을 다해 답변을 하거나 필요하다면 이 포스트를 갱신하도록 하겠습니다.

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

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

주의 : 이상하면 참고자료를 참조하십시오.

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



샘플 선정


직접 코드를 작성할까 아니면 이미 나와 있는 샘플을 사용할까 고민하다가, 전문가 중 한 명이 작성한 샘플을 사용하는 것이 좀 더 나은 신뢰성을 제공할 것이라는 생각에 후자를 선택했습니다( 틀려도 내탓하지 마!! ).


이 시리즈를 작성하면서 사용할 샘플은 Sascha Willems 의  [  Vulkan C++ exmaples and demos  ] 입니다. 이 분이 여러 가지 Vulkan Sample 을 만드셨더군요. 특히 이 분이 작성한 [ VulkanCapsViewer ] 는 매우 쓸만한 툴입니다. 이 분에 대해서 궁금하시다면 [ 개인 블로그 ] 를 확인하세요. 


VulkanCapsViewer 의 릴리스 빌드는 [ Vulkan GPU Info ]에서 받으실 수 있습니다. 이 시리즈를 진행하다가 제가 가진 디바이스의 Extension 같은 것들을 확인할 때 이 툴을 사용할 것입니다.


어쨌든 설명에 필요하다면 앞에서 언급한 샘플을 수정해 가면서 진행을 하도록 하겠습니다.


레이어 리스트 확인


VulkanCapsViewer 를 실행하고 가장 오른쪽 "Vulkan" Tap  에서 "Layer" 탭을 선택하면 현재 API 와 Device 에 의해 지원되는 Layer 들의 목록을 확인할 수 있습니다. 이 작업을 하다가 "Upload" 를 눌러 처음으로 "GeForce GTX 980" 을 Database 에 등록하는 영광(?)을 얻었습니다.


그림1. CapsViewer 로 살펴 본 레이어 리스트.


매우 많은 레이어를 지원하고 있네요. 문제는 이것을 어떻게 활성화하느냐는 겁니다.


명시적( Explicit ) 레이어와 묵시적( Implicit ) 레이어


Windows 에서 Vulkan loader 가 두 종류의 레이어를 검색합니다. 하나는 명식적 레이어와 묵시적 레이어입니다. 


Vulkan loader 는 다음과 같은 레지스트리를 검색해서 레이어의 목록을 찾아냅니다.


HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\ExplicitLayers

HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\ImplicitLayers


이 레지스트리 값은 DWORD 형으로서 키 이름이 레이어 파일의 경로를 담고 있습니다.


그림2. 명시적인 레이어 리스트.


그림3. 묵시적인 레이어 리스트.


명시적인 레이어는 사용자의 요청에 의해 VkInstance 생성시에 로드되고, 묵시적인 레이어는 사용자의 요청없이 특정 상황에서 묵시적으로 로드됩니다.


각 레이어를 위한 json 파일은 레이어를 로드하기 위한 여러 가지 정보를 담고 있으며, "VkLayer_core_validation.json" 파일을 살펴 보면 다음과 같이 되어 있습니다.



그리고 어떤 경우에는 여러 레이어를 하나로 묶어서 로드하는 경우도 있습니다. "VkLayer_standard_validation.json" 이 그런 경우입니다. 대부분의 샘플들을 보면 이 레이어를 사용해서 디버그 정보를 뽑습니다.



묵시적인 경우가 필요한 것인가라는 의문이 들 수 있겠지만, RenderDoc 와 같은 프로우파일러( profiler )를 사용해 보면 실시간에 애플리케이션을 어태치( Attach )하는 경우가 있습니다.


그림4. RenderDoc 의 "Attach to Running Instance" 메뉴.


이 경우에는 이미 애플리케이션이 시작되었고 VkInstance 도 생성된 상황이므로 명시적으로 레이어를 요청할 수가 없습니다. 그러므로 RendeDoc 같은 툴들이 이를 묵시적으로 로드할 수 있는 방법이 필요한 것입니다.


레이어 활성화하기


레이어를 활성화하는 방식은 여러 가지가 있지만, 여기에서는 프로그램 코드 내에서 명시적으로 활성화하는 방법에 대해서만 다루도록 하겠습니다. 명령창에서 환경변수 통해 레이어를 활성화하거나 레이어 설정 파일을 미리 만들어 놓는 등의 작업들에 대해서는 [ 1 ] 에서 확인하시기 바랍니다.


샘플의 "base/VulkanDebug.cpp" 파일에 보면 다음과 같이 디버거를 위해 사용할 레이어 이름을 설정하는 것을 보실 수 있습니다. 이 이름은 json 파일에 있는 "name" 오브젝트의 값입니다.



주의하실 점은 [ Vulkan 은 왜 C 로 구현되었을까? ] 에서 언급했듯이 Vulkan 은 C ABI 를 사용하므로 결국 ANSI 문자열을 사용한다는 겁니다. 요새 대부분은  Unicode( UTF-8 ) 문자열 프로젝트를 사용하기 때문에 이에 주의하셔야 합니다. 왠만하면 ANSI 와 UTF8 사이의 변환을 자유롭게 할 수 있는 유틸리티 클래스를 만들고 작업을 시작하시는 것이 좋습니다.


어쨌든 이러한 목록은 VkInstance 를 생성할 때 명시적으로 지정됩니다. 27 ~ 28 라인에서 이 작업을 수행하고 있습니다.



그런데 단순히 레이어를 생성한다고 해서 기능이 동작하는 것은 아닙니다. 이 레이어를 구동하는데 필요한 익스텐션( extension ) 들을 지정할 필요가 있죠. 20 라인에서 validation 을 위한 익스텐션을 추가하고 있는 것을 보실 수 있습니다.


우리가 처음에 "VkLayer_core_validation.json" 파일을 확인했었죠? 거기에는 "instance_extensions" 라는 오브젝트가 있는데 그것의 배열 값에는 해당 레이어를 위해서 요구되는 익스텐션의 리스트가 들어갑니다.


"VkLayer_core_validation.json" 의 경우에는 "VK_EXT_debug_report" 을 요구하구요, 만약 다른 익스텐션이 필요하다면 그것을 추가해 줄 필요가 있습니다. 이러한 익스텐션 이름은 보통 "vulkan_core.h" 에 매크로로 정의되어 있습니다. 이 경우에는 VK_EXT_DEBUG_REPORT_EXTENSION_NAME 입니다. 물론 직접 텍스트를 입력하셔도 무방하지만, 실수를 줄이려면 이런 매크로를 사용하시는 것이 좋겠죠.



이렇게 해서 VkInstance 와 함께 레이어를 로드하면, VkGetInstanceProcAddr() 를 사용해서 해당 레이어의 익스텐션을 위한 API 를 가지고 와서 사용할 수 있습니다. 다음은 "VulkanDebug.cpp" 에서 VK_EXT_Debug_Report 익스텐션을 위한 콜백들을 생성하고 파괴하는 메서드를 구현하는 모습을 보여 줍니다.



정리


레이어는 명시적 혹은 묵시적으로 로드되며, 명시적으로 로드되는 경우에는 VkInstance 생성시에 지정됩니다. 이 때 필요한 익스텐션들도 지정해 줄 필요가 있습니다. 인스턴스 생성 후에는 VkGetInstanceProcAddr() 함수를 통해서 관련 API 를 호출할 수 있습니다.


다음 시간에는 Vulkan Layer 의 내부적인 동작 방식에 대해서 자세히 알아 보도록 하겠습니다. [ 2 ] 에 대한 번역이 될 것입니다.


참고자료


[ 1 ] Layers Overview and Configuration, LUNAR XCHANGE.


[ 2 ] Brief guide to Vulkan layers, RenderDoc.

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

주의 : 완벽히 이해하고 쓴 글이 아니라 공부하다가 정리한 것이므로 잘못된 내용이 있을 수 있습니다.

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



Ecosystem


"Software Ecosystem" 이라는 것은 우리 말로 하면 "소프트웨어 생태계" 정도가 될 것 같습니다. 이것의 의미는 다음과 같습니다.


같은 환경에서 개발되고 같이 진화하는( coevolve ) 소프트웨어 프로젝트의 집합


출처 : [ 1 ]


기본적으로 Vulkan 의 기능을 구성하는 많은 extension 및 layer 들은 이러한 ecosystem 을 공유하고 있다고 보시면 됩니다. 그런데 이런 환경을 구성하기 위해서는 중심이 되는 컴포넌트가 필요합니다.


이를 Vulkan 에서는 이런 역할을 수행하는 컴포넌트를 "Loader" 라 부르고 있으며, [ 2 ] 에서 소스 코드를 제공하고 있습니다. 물론 LunarG 를 설치하시면 따로 소스 코드를 빌드할 필요는 없습니다.


High level architecture


[ 3 ] 에서는 Vulkan ecosystem 의 고수준 아키텍쳐를 보여 주고 있습니다.


그림1. Vulkan High Level Architecture. 출처 : [ 3 ].


그림1 에서 볼 수 있듯이 Vulkan Loader 는 Appliation, Layer, ICD 를 연결하는 인터페이스( interface ) 역할을 해 주고 있습니다. 


"ICD" 는 "Installable Client Driver" 의 머릿글자로 Physical Device 와 Loader 사이에서 인터페이스 역할을 합니다. 우리가 OS 개발자나 Driver 개발자는 아니기 때문에, 현재 단계에서는 이 부분에 대해서 깊게 살펴 보지는 않겠습니다( 뭐... 능력도 안 됩니다 ㅠㅠ ).


Layer


Vulkan 은 기본적으로 런타임에 Error-Checking 이나 Debugging 을 위한 기능을 지원하지 않습니다. Memory 관리나 Multi-Threading 과 같은 기능이 성능 향상에 큰 영향을 줬지만, 이러한 기능을 배제하는 것도 성능에 영향을 줍니다.


하지만 개발 도중에 그런 기능들을 사용할 수 없다면, 안 그래도 사용하기 어려운 Vulkan 을 더욱 사용하기 어렵게 만들게 될 것입니다. 그래서 Vulkan 은 Layer 라는 형태로 그런 기능들을 지원하고 있습니다.


Layer 는 라이브러리 형태로 배포되며 이것은 Vulkan 의 API 를 hooking 하거나 기능을 추가합니다. 예를 들어 validation 을 해야 한다고 하면 특정 API 를 hooking 해서 로그를 삽입한다든가 하는 일을 할 수 있겠죠.


현재 LunarG 1.1.84 버전에서는 다음과 같은 layer 들을 제공하고 있습니다.


  • VK_LAYER_LUNARG_api_dump
  • VK_LAYER_LUNARG_assistant_layer
  • VK_LAYER_LUNARG_core_validation
  • VK_LAYER_LNUARG_divce_simulation
  • VK_LAYER_LUNARG_monitor
  • VK_LAYER_LUNARG_object_tracker
  • VK_LAYER_LUNARG_parameter_validation
  • VK_LAYER_LUNARG_screenshot
  • VK_LAYER_GOOGLE_thrading
  • VK_LAYER_GOOGLE_unique_objects


이런 레이어들은 전부 Open Source 입니다. 예를 들어 validation layer 와 관련한 Source 는 [ https://github.com/KhronosGroup/Vulkan-ValidationLayers ] 에 있습니다.


다음 몇 개의 연구 시리즈에서는 각 레이어들이 어떤 의미를 가지고 있고, 어떻게 사용되는지 알아 보도록 하겠습니다.


참고자료


[ 1 ] Software Ecosystem, Wikipedia.


[ 2 ] KhronosGroup/Vulkan-Loader, GitHub.


[ 3 ] Architecture of the Vulkan Loader Interfaces, LunarG.


[ 4 ] Vulkan Specification, Khronos.

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

경고 : Vulkan 에 대해서 완벽하게 이해하고 작성된 글은 아닙니다. 공부하면서 정리한 글이라 오류가 있을 수 있으니 이상하면 참고자료를 확인하세요.



원래 AMD 에는 Mantle 이라는 저수준 Graphics API 가 있었습니다. 


예전에 다니던 회사에 AMD 직원이 와서 홍보를 하던 기억이 나네요. 그 당시에 혹하기는 했지만, AMD 의 특정( 최신 ) 하드웨어에서만 지원된다는 점에서 그냥 "와~ 놀랍네" 하고 말았던 기억이 납니다.


역시나 특정 하드웨어에 종속되어 있기 때문에 대중화에 실패한 것 같습니다. 일부 콘솔 게임( video game )들만이 Mantle 로 출시되었습니다. 하지만 이것이 보여준 드라마틱한 성능은 Vulkan, Direct3D 12 와 같은 저수준 API 의 시대를 열었다는데 의의가 있다는 생각이 듭니다.


특히나 Vulkan 의 경우에는 Mantle 의 자손이라고 할 수 있습니다. AMD 가 Mantle 을 Khronos Group 에 기증했고 그걸 기반으로 Vulkan 이 만들어졌기 때문입니다. 역시 Open Source 핵심 진영인 Khronos 답게 이것을 Cross-Platform API 로 만들었습니다. 그리고 특정 언어에 종속되지 않는 SPIR-V binary 를 사용하여 언어의 호환성을 유지합니다. 그러므로 GLSL 이나 HLSL 을 사용해 shader 를 작성하는 것이 가능합니다.


재밌는 건 이것이 OpenGL ES 3.1 이나 OpenGL 4.x 이상을 지원하고 있다는 것입니다. Direct3D 12 가 Window 만을 지원하고 있고 Geforce GTX 급의 카드에서만 안정적으로 지원된다는 것을 고려하면, 요즘과 같은 Cross-Platform 시대에는 Vulkan 이 대세가 되지 않을까 싶습니다.


게다가 성능은 구현측에 달려 있기는 하지만 벤치마킹 업체에서 내 놓은 결과를 보면 의미심장합니다. 아래 이미지들은 [ Quick Look: Comparing Vulkan & DX12 API Overhead on 3DMark ] 에서 가지고 왔습니다.




흥미로운 점은 Mantle 을 만든 AMD 의 카드에서 Vulkan 성능이 더 안 나온다는 겁니다. 참 이상한 결과가 아닐 수 없습니다. 역시 AMD 가 Radeon 을 인수하고 나서 그래픽스 카드쪽으로는 별로 힘을 못 쓰는 것 같네요.


어쨌든 제 예상으로는 Cross-Platform 환경에서 Vulkan 은 대세가 될 것이라 봅니다. 특히나 extension 이나 layer 를 통해서 API 를 확장하고 강화할 수 있는 가능성을 열어 두고 있기 때문에, 성능과 기능을 개선할 수 있는 연구들이 많이 나오지 않을까라는 생각이 듭니다.


그런데 문제는 Vulkan 을 배우는 것이 너무 어렵다는 것입니다. Open Source 의 한계 때문인지 Specification 문서나 API 사용 튜토리얼 류의 글만 잔뜩 있습니다. 게다가 C 로 구현되어 있기 때문에 추상화된 관점에서 API 를 익히기가 어렵습니다.


한 달이 넘는 시간 동안 ( 시간이 날 때마다? ) 열심히 공부해서 렌더러를 구성해 보려고 노력했지만, 추상화와 Glslang 및 reflection 의 벽에 막혀 삼각형 하나도 렌더링하지 못했습니다. D3D9 이나 D3D11 으로 렌더러를 구현했을 때와 비교해 보면 정말 어렵다는 생각이 듭니다.


그래서 이번 기회에 저의 실력을 향상시키고 Vulkan 에 입문하는 분들에게 조금이라도 도움이 될 수 있었으면 하는 바람으로 [ Vulkan 연구 ] 시리즈를 만들어 보기로 했습니다. 


제대로 아는 것이 아니기 때문에 목차는 정하지 않았지만, Vulkan Specification 의 순서대로 진행을 하려고 하지만, 명세를 읽다가 궁금해지는 부분들을 집중적으로 파보는 방향으로 진행될 가능성이 높습니다.

주의 : 허락받고 번역한 것이 아니므로 언제든 내려갈 수 있습니다.

주의 : 2018-10-28 기준 spec 이므로 최신버전이 반영되어 있지 않을 수 있습니다.

주의 : 번역이 개판이므로 이상하면 원문을 참고하십시오.

원문 : https://raw.githubusercontent.com/KhronosGroup/GLSL/master/extensions/khr/GL_KHR_vulkan_glsl.txt




Name


KHR_vulkan_glsl


Name Strings


GL_KHR_vulkan_glsl


Contact


John Kessenich (johnkessenich 'at' google.com), Google


Contributors


Jeff Bolz, NVIDIA

Kerch Holt, NVIDIA

Kenneth Benzie, Codeplay

Neil Henning, Codeplay

Neil Hickey, ARM

Daniel Koch, NVIDIA

Timothy Lottes, Epic Games

David Neto, Google


Notice


Copyright (c) 2015 The Khronos Group Inc. Copyright terms at

http://www.khronos.org/registry/speccopyright.html


Status


Approved by Vulkan working group 03-Dec-2015.

Ratified by the Khronos Board of Promoters 15-Jan-2016.


Version


Last Modified Date: 25-Jul-2018

Revision: 46


Number


TBD.


Dependencies


이 익스텐션( extension ) 은 OpenGL GLSL versions 1.40( #version 140 ) 이상 버전에 적용될 수 있습니다.


이 익스텐션은 OpenGL ES ESSL versions 3.10( #version 310 ) 이상 버전에 적용될 수 있습니다.


이 모든 버전들은 GLSL/ESSL 시맨틱( semantics )들을 동일한 SPIR-V 1.0 시맨틱으로 매핑합니다( 가장 최근의 GLSL/ESSL 버전으로 근사( approximating )합니다 ).


Overview


이것은 GL_KHR_vulkan_glsl 익스텐션의 100 버전입니다.


이 익스텐션은 Vulkan API 에서 고수준 언어로서 GLSL 을 사용할 수 있도록 수정한 것입니다. GLSL 은 SPIR-V 로 컴파일되는데, Vulkan API 는 그것을 사용하게 됩니다.


다음과 같은 기능들이 제거되었습니다:

* opaque 타입( 역주 : [ Vulkan Opaque Type ] 참고 )을 배제한 기본 uniform 들( uniform 블락에 존재하지 않는 유니폼 변수들 )

* 자동 카운터( atomic-counters )( atomic_uint 에 기반한 것들 )

* 서브루틴( subroutines )

* shared 및 packed 블락 레이아웃( layout )들

* 이미 사장된( deprecated ) 텍스쳐링 함수들( 예 : texture2D() )

* 이미 사장된 노이즈 함수들( 예 : noise1() )

* 호환 모드 전용 기능들

* gl_DepthRangeParametersgl_NumSamples

* gl_VertexID 와 gl_InstanceID


다음과 같은 기능들이 추가되었습니다:

* 푸시 상수 버퍼( push-constant buffers )

* 개별 텍스쳐 및 샘플러에 대한 셰이더 조합( shader-combining ).

* 디스크립터 셋들( descriptor sets )

* 특수화 상수들( specialization constants )

* gl_VertexIndexgl_InstanceIndex

* 서브패스 입력들( subpass inputs )

* uniform/buffer 블락들을 지원하지 않는 버전들을 위해 uniform/buffer 를 위한 offset 및 align 레이아웃 한정자( qualifiers )를 지원


다음과 같은 기능들이 변경되었습니다:

* 정밀도( precision ) 한정자들( mediumplowp )이 모든 버전에 대해 요구됩니다. 데스크탑 버전에 대해서도 누락되지 않습니다( 데스크탑 버전을 위한 기본 정밀도는 모든 타입에 대해 highp 입니다 )

* gl_FragColor 는 더 이상 implicit broadcast 를 지시하지 않습니다.

* uniform/buffer 블락의 배열들은 전체 오브젝트에 대해 하나의 바인딩 번호만을 가집니다. 배열 요소별로 번호를 가지는 것이 아닙니다.

* 기본 원점( origin )은 origin_lower_left 가 아니라 origin_upper_left 입니다.


이들 각각에 대해서는 아래에서 더욱 세부적으로 논의하겠습니다.


이 기능들을 활성화하는 법


이 익스텐션은 다른 익스텐션들처럼 #extension 을 사용해서 활성화되는 것이 아닙니다. 또한 profile 이나 #version 의 사용을 통해 활성화되는 것도 아닙니다. Vulkan 에서 지정한 용도( usage )를 제외하면, 의도된 GLSL/ESSL 기능들의 수준( level )은 #version, profile, #extension 에 대한 전통적인 사용을 통해서 결정됩니다.


GLSL 프런트 엔드( front-end )는 Vulkan 을 위해 SPIR-V 를 생성하기 위해서 사용됩니다. 그런 도구를 사용하는 것은 Aulkan API 나 GLSL 및 익스텐션을 정의하는 영역의 외부에서 수행됩니다. Vulkan 을 위해 SPIR-V 의 생성을 요청하는 방법에 대해서 알고자 한다면, 컴파일러 문서를 확인하시기 바랍니다( 역주 : Vulkan 을 이것을 "glslang" 이라고 부릅니다. [ GitHub ] 참고. 기본적으로 LunarG 에 포함되어 있으니 별도의 설치가 필요하지는 않습니다  ).


프런트 엔드가 이 익스텐션을 받아들이기 위해서 사용될 때, 그것은 반드시 에러를 검사하고 이 명세를 고수하지 않는 셰이더들을 거부해야만 합니다. 구현측-의존 최대값( maximums )이나 능력치( abilities )는 프런트 엔드 혹은 그 일부에 제공되어야 합니다. 그래야 그것들에 대한 에러 검사를 할 수 있습니다.


셰이더는 다음과 같은 정의를 사용해서 Vulkan 이 지원하는 레벨을 지정할 수 있습니다.



예를 들어, 이는 다음과 같은 셰이더 코드를 작성할 수 있도록 해 줍니다.



Specialization Constants


SPIR-V 특수화 상수들은, 나중에 클라이언트 API 에 의해서 설정될 수 있는데, 이는 layout(constant_id=...) 를 사용해서 선언됩니다. 예를 들어 기본값 12 를 사용해서 특수화 상수를 만들고자 한다면 다음과 같이 할 수 있습니다:



위에서 17 은 ID 인데, API 나 다른 도구들에서 나중에 이 특수화 상수를 식별하기 위해 참조할 수 있습니다. 그리고 나서 API 나 중간 도구( intermediate tool )는, 그것이 완전히 실행 코드( executable code )로 저수준화되기( lowered ) 전에, 그 값을 다른 상수 정수로 바꿀 수 있습니다. 만약 ID 값이 저수준화 전에 변경되지 않는다면, 그 변수의 값은 여전히 12 로 남아있게 될 것입니다.


특수화 상수는 그것이 folding 되지 않는다는 것을 제외하면 const 시맨틱을 가집니다( 역주 : constant folding 은 컴파일러에서의 상수 최적화를 의미하는 것입니다. 여러 상수가 컴파일시에 하나의 상수로 변합니다. [ constant folding ] 참조 ). 그러므로 배열이 위에 있는 'arraySize' 로 선언될 수 있습니다.



특수화 상수들은 표현식 안에 들어갈 수도 있습니다:



이는 data2 의 크기를 'arraySize' 가 지정하는 것의 2 배로 늘리게 되는데, 이는 셰이더를 실행코드로 저수준화할 때 발생하는 일입니다.


특수화 상수와 함께 구성되는 표현식은 셰이더 내에서 그냥 상수와는 다르게 특수화 상수인 것처럼 동작합니다.



그런 표현식은 상수와 같은 위치에서 사용될 수 있습니다.


constant_id 는 스칼라 정수( int ), 스칼라 실수( float ) 이나 스칼라 불리언( bool )에만 사용될 수 있습니다.


특수화 상수에는 기본 연산자와 생성자들만 적용될 수 있으며 그 역시 특수화 상수를 반환합니다:



SPIR-V 특수화 상수들이 스칼라에 대해서만 존재하는 반면에, 벡터 연산을 위해 스칼라가 사용될 수 있습니다:



내장 변수( built-in variable )에 constant_id 를 붙일 수 있습니다:



이는 그것이 특수화 상수처럼 동작하도록 만듭니다. 그것은 완전한 재선언( full redeclaration )은 아닙니다; 다른 모든 특성들은 원래의 내장 선언과 동일하게 온전히 남아 있게 됩니다.


특수 레이아웃인 local_size_{xyz}_id 들을 사용해서 특수화된 내장벡터 gl_WorkGroupSize 는 in 한정자에 적용됩니다. 예를 들어:



gl_WorkGroupSize.y 는 비-특수화 상수로 남아 있습니다. gl_WorkGroupSize 는 부분적으로 특수화된 벡터인 것입니다. 그것의 x 및 z 요소는 나중에 18 과 19 라는 ID 를 사용해서 특수화될 수 있습니다.



Push Constants


푸시 상수들은 uniform 블락 내에 존재하는데, 새로운 레이아웃 한정자 아이디인 push_constantuniform 블락 선언에 적용함으로써 선언됩니다. 이 API 는 상수 집합을 푸시 상수 버퍼에 씁니다. 그리고 셰이더는 push_constant 블락으로부터 그것들을 읽어들입니다.



푸시 상수 유니폼 블락을 위해 사용되는 메모리 회계( memory accounting )는 다른 유니폼 블락들과는 다릅니다: 이것이 맞춰야만 하는 개별적인 작은 풀( small pool )이 존재합니다. 기본적으로 푸시 상수 버퍼는 std430 packing rule 을 따라야만 합니다( 역주 : [ Interface Block (GLSL) ] 의 "Memory layout" 참조, [ ARB_shader_storage_buffer_object ] 참조 ).



Descriptor Sets


디스크립터 셋 내의 각 셰이더 리소스는 ( 셋 번호( set number ), 바인딩 번호( binding number ), 배열 요소( array element ) ) 로 구성된 튜플( tuple )을 할당하는데, 그것은 디스크립터 셋 레이아웃 내의 위치를 정의하게 됩니다.


GLSL 에서는 셋 번호와 바인딩 번호가 setbinding 이라는 레이아웃 한정자에 의해서 할당되었습니다. 그리고 배열 요소는 배열의 첫 번째 요소를 0 으로 하여 인덱스를 순서대로 증가시켜서 묵시적으로 할당되었습니다( 그리고 비-배열 변수를 위해서는 배열 요소가 0 이었습니다 ):



예를 들어, 결합된 두 개의 texture/sampler 오브젝트들은 서로 다른 디스크립터 셋에 다음과 같이 선언될 수 있습니다.



디스크립터 셋의 연산 모델에 대한 세부적인 사항에 대해서 알고자 한다면 API 문서를 참고하시기 바랍니다( 역주 : [ Vulkan spec ] 의 "13.2. Descriptor Sets" 참조 ).


Storage Images


GLSL 셰이더 소스에서 저장소 이미지는 적절한 차원성( dimentionality )과 ( 필요하다면 ) 포맷 레이아웃 한정자로 구성된 uniform image 변수들을 사용해서 선언됩니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다.



Samplers


GLSL 셰이더 소스에서 샘플러는 uniform sampler 변수를 사용해서 선언되는데, 여기에서 샘플러 타입은 텍스쳐 차원성과 관련이 없습니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다.



Textures ( Sampled Images )


GLSL 셰이더 소스에서 텍스쳐는 적절한 차원성을 가진 uniform texture 변수를 사용해서 선언됩니다 :



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다:



Combined Texture + Samplers


GLSL 셰이더 소스에서 텍스쳐와 샘플러의 결합은 적절한 차원성을 가진 uniform sampler 변수를 사용해서 선언됩니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다:



결합된 이미지 샘플러의 디스크립터는 위의 섹션에서처럼 셰이더 내에서 선언된 이미지들이나 샘플러들만 참조할 수 있다는 것에 주의하시기 바랍니다.


Combining spearate samplers and textures


sampler 키워드를 사용해서 선언된 샘플러는 텍스쳐나 이미지가 아니라 필터링 정보만을 포함합니다:



texture2D 같은 키워드를 사용해서 선언된 텍스쳐는 필터링 정보가 아니라 이미지 정보만을 포함합니다:



그러면 텍스쳐 검색( lookup ) 호출을 만드는 시점에 샘플러와 텍스쳐를 결합하기 위해서 생성자가 사용될 수 있습니다:



Texture Buffers ( Uniform Texel Buffers )


GLSL 셰이더 소스에서 텍스쳐 버퍼는 uniform textureBuffer 변수를 사용해서 선언될 수 있습니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다:



Image Buffers ( Storage Texel Buffers )


GLSL 셰이더 소스에서 이미지 버퍼는 uniform imageBuffer 변수를 사용함으로써 선언됩니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다:



Storage Buffers


GLSL 셰이더 소스에서 저장소 버퍼는buffer storage 한정자와 block 구문( syntax )을 사용함으로써 선언됩니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다:



Uniform Buffers


GLSL 셰이더 소스에서 저장소 버퍼는uniform storage 한정자와 block 구문을 사용함으로써 선언됩니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다:



Subpass Inputs


렌더링 패스 내에서, 서브패스는 츨력 타깃에 결과를 쓸 수 있는데, 그러면 다음 서브패스는 입력 서브패스로서 그 결과를 읽어들일 수 있습니다. "서브패스 입력" 기능은 출력 타깃을 읽을 수 있는 기능이 있다고 간주합니다.


서브패스들은 새로운 유형의 집합( set )들을 통해 읽어들여질 수 있는데, 프래그먼트( fragment ) 셰이더에 대해서만 가능합니다:


subpassInput

subpassInputMS

isubpassInput

isubpassInputMS

usubpassInput

usubpassInputMS


샘플러나 이미지 오브젝트들과는 다르게, 서브패스 입력은 프로그래먼트의 ( x, y, layer ) 좌표에 의해 묵시적으로 주소지정이 됩니다.


입력 어태치먼트( input attachment )들은 input_attachment_index 와 descriptor set, 그리고 binding 번호들을 포함합니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다:



i 에 대한 input_attachment_index 는 입력 패스 리스트에서 i 번째 엔트리( entry )를 선택합니다. ( 더 많은 정보를 원한다면 API 명세를 확인하세요 )


이러한 오브젝트들은 서브패스 입력을 다음과 같은 함수들을 통해서 읽어들일 수 있도록 지원합니다:



gl_FragColor


프래그먼트 스테이지 내장 gl_FragColor 는 모든 출력에 대한 broadcast 를 내포하며, SPIR-V 에 제출되지 않습니다. gl_FragColor 에 쓰는 것이 허용되는 셰이더들은 여전히 거기에 쓰기는 하는데, 단지 출력으로 쓰고 있음을 의미할 뿐입니다:

- gl_FragColor 와 같은 타입으로

- location 0 번에

- 내장 변수로 기능하지 않고

Implicit broadcast 는 존재하지 않습니다.


gl_VertexIndex 와 gl_InstanceIndex


새롭게 두 개의 내장 변수가 추가되었는데, gl_VertexIndex 와 gl_InstanceIndex 는 기존의 gl_VertexID 와 gl_InstanceID 를 대체합니다.


어떤 기저( base offset ) 에 대해 상대적인 색인( indexing )을 하는 상황에서, Vulkan 을 위한 이 내장 변수들이 정의되었는데, 이는 다음과 같은 값들을 취합니다:



여기에서 그것은 기저가 실제로 무엇이냐에에 달려 있습니다.


Mapping to SPIR-V


( 명세는 아니고 ) 정보 제공의 목적으로 이야기하자면, 다음은 GLSL 생성자를 SPIR-V 생성자로 매핑하는 구현을 위해 기대되는 방식들을 보여 줍니다:


storage class 매핑:


uniform sampler2D...;        -> UniformConstant

uniform blockN { ... } ...;  -> Uniform, with Block decoration

in / out variable            -> Input/Output, possibly with block (below)

in / out block...            -> Input/Output, with Block decoration

buffer  blockN { ... } ...;  -> Uniform, with BufferBlock decoration, or

  StorageBuffer, when requested

N/A                          -> AtomicCounter

shared                       -> Workgroup

<normal global>              -> Private


입출력 블락들이나 변수들을 매핑하는 것은 다른 버전의 GLSL 이나 ESSL 과 같습니다. 확장 변수들이나 멤버들을 이 버전에서 이용할 수 있으며, 그것의 위치는 다음과 같습니다:


이들은 SPIR-V 에 개별 변수들로 매핑되는데, ( 따로 표기된 것을 제외하고는 ) 내장 decoration( 역주 : prefix 나 한정자 등을 의미하는 듯 ) 들과 유사하게 발음됩니다:


Any stage :


in gl_NumWorkGroups

in gl_WorkGroupSize

in gl_WorkGroupID

in gl_LocalInvocationID

in gl_GlobalInvocationID

in gl_LocalInvocationIndex


in gl_VertexIndex

in gl_InstanceIndex

in gl_InvocationID

in gl_PatchVerticesIn      (PatchVertices)

in gl_PrimitiveIDIn        (PrimitiveID)

in/out gl_PrimitiveID      (in/out based only on storage qualifier)

in gl_TessCoord


in/out gl_Layer

in/out gl_ViewportIndex


patch in/out gl_TessLevelOuter  (uses Patch decoration)

patch in/out gl_TessLevelInner  (uses Patch decoration)


Fragment stage only:


in gl_FragCoord

in gl_FrontFacing

in gl_ClipDistance

in gl_CullDistance

in gl_PointCoord

in gl_SampleID

in gl_SamplePosition

in gl_HelperInvocation

out gl_FragDepth

in gl_SampleMaskIn        (SampleMask)

out gl_SampleMask         (in/out based only on storage qualifier)


These are mapped to SPIR-V blocks, as implied by the pseudo code, with the members decorated with similarly spelled built-in decorations:


Non-fragment stage:



적어도 하나의 입력 및 출력 블락이 SPIR-V 의 스테이지마다 존재합니다. 하위집합( subset ) 및 멤버의 순서는 인터페이스( interface )를 공유하고 있는 스테이지 사이에서 일치할 것입니다.


Mapping of precision qulifiers:


lowp     -> RelaxedPrecision, on storage variable and operation

mediump  -> RelaxedPrecision, on storage variable and operation

highp    -> 32-bit, same as int or float


portablility tool/mode   -> OpQuantizeToF16


Mapping of precise:


precise -> NoContraction


Mapping of images:


subpassInput  -> OpTypeImage with 'Dim' of SubpassData

subpassLoad() -> OpImageRead

imageLoad()   -> OpImageRead

imageStore()  -> OpImageWrite

texelFetch()  -> OpImageFetch


imageAtomicXXX(params, data)  -> %ptr = OpImageTexelPointer params 

OpAtomicXXX %ptr, data


XXXQueryXXX(combined) -> %image = OpImage combined 

OpXXXQueryXXX %image


Mapping of layouts:


std140/std430  ->  explicit offsets/strides on struct

shared/packed  ->  not allowed

<default>      ->  not shared, but std140 or std430


max_vertices   ->  OutputVertices


Mapping of barriers:


barrier() (compute) -> OpControlBarrier(/*Execution*/Workgroup,

/*Memory*/Workgroup,

/*Semantics*/AcquireRelease |

WorkgroupMemory)


barrier() (tess control) -> OpControlBarrier(/*Execution*/Workgroup,

/*Memory*/Invocation, 

/*Semantics*/None)


memoryBarrier() -> OpMemoryBarrier(/*Memory*/Device,

/*Semantics*/AcquireRelease | 

UniformMemory | 

WorkgroupMemory | 

ImageMemory)


memoryBarrierBuffer() -> OpMemoryBarrier(/*Memory*/Device,

/*Semantics*/AcquireRelease |

UniformMemory)


memoryBarrierShared() -> OpMemoryBarrier(/*Memory*/Device,

/*Semantics*/AcquireRelease |

WorkgroupMemory)


memoryBarrierImage() -> OpMemoryBarrier(/*Memory*/Device,

/*Semantics*/AcquireRelease |

ImageMemory)


groupMemoryBarrier() -> OpMemoryBarrier(/*Memory*/Workgroup,

/*Semantics*/AcquireRelease |

UniformMemory |

WorkgroupMemory |

ImageMemory)


Mapping of atomics


all atomic builtin functions -> Semantics = None(Relaxed)

atomicExchange()      -> OpAtomicExchange

imageAtomicExchange() -> OpAtomicExchange

atomicCompSwap()      -> OpAtomicCompareExchange

imageAtomicCompSwap() -> OpAtomicCompareExchange

N/A                   -> OpAtomicCompareExchangeWeak


Mapping of other instructions:


%     -> OpUMod/OpSMod

mod() -> OpFMod

N/A   -> OpSRem/OpFRem


===== 이 아래는 [ OpenGL Shading Language Specification ] 의 변경사항과 관련한 내용이므로 따로 번역하지 않았습니다. 

하지만 본문에 언급되어 있지 않은 정보들도 꽤 있어 확인해 보시는 것이 좋을 것 같습니다 =====


Changes to Chapter 1 of the OpenGL Shading Language Specification


    Change the last paragraph of "1.3 Overview":  "The OpenGL Graphics System

    Specification will specify the OpenGL entry points used to manipulate and

    communicate with GLSL programs and GLSL shaders."


    Add a paragraph: "The Vulkan API will specify the Vulkan entry points used

    to manipulate SPIR-V shaders.  Independent offline tool chains will compile

    GLSL down to the SPIR-V intermediate language. Vulkan use is not enabled

    with a #extension, #version, or a profile.  Instead, use of GLSL for Vulkan

    is determined by offline tool-chain use. See the documentation of such

    tools to see how to request generation of SPIR-V for Vulkan."


    "GLSL -> SPIR-V compilers must be directed as to what SPIR-V *Capabilities*

    are legal at run-time and give errors for GLSL feature use outside those

    capabilities.  This is also true for implementation-dependent limits that

    can be error checked by the front-end against constants present in the

    GLSL source: the front-end can be informed of such limits, and report

    errors when they are exceeded."


Changes to Chapter 2 of the OpenGL Shading Language Specification


    Change the name from


    "2 Overview of OpenGL Shading"


    to


    "2 Overview of OpenGL and Vulkan Shading"


    Remove the word "OpenGL" from three introductory paragraphs.


Changes to Chapter 3 of the OpenGL Shading Language Specification


    Add a new paragraph at the end of section "3.3 Preprocessor":  "When

    shaders are compiled for Vulkan, the following predefined macro is

    available:


        #define VULKAN 100


    Add the following keywords to section 3.6 Keywords:


        texture1D        texture2D        texture3D

        textureCube      texture2DRect    texture1DArray

        texture2DArray   textureBuffer    texture2DMS

        texture2DMSArray textureCubeArray


        itexture1D        itexture2D       itexture3D

        itextureCube      itexture2DRect   itexture1DArray

        itexture2DArray   itextureBuffer

        itexture2DMS      itexture2DMSArray

        itextureCubeArray


        utexture1D        utexture2D        utexture3D

        utextureCube      utexture2DRect    utexture1DArray

        utexture2DArray   utextureBuffer    utexture2DMS

        utexture2DMSArray utextureCubeArray


        sampler    samplerShadow


        subpassInput      isubpassInput     usubpassInput

        subpassInputMS    isubpassInputMS   usubpassInputMS


    Move the following keywords in section 3.6 Keywords to the reserved

    section:


        atomic_uint

        subroutine


Changes to Chapter 4 of the OpenGL Shading Language Specification


    Add into the tables in section 4.1 Basic Types, interleaved with the

    existing types, using the existing descriptions (when not supplied

    below):


        Floating-Point Opaque Types


            texture1D

            texture2D

            texture3D

            textureCube

            texture2DRect

            texture1DArray

            texture2DArray

            textureBuffer

            texture2DMS

            texture2DMSArray

            textureCubeArray

            subpassInput            | a handle for accessing a floating-point

                                    | subpass input

            subpassInputMS          | a handle for accessing a multi-sampled

                                    | floating-point subpass input


        Signed Integer Opaque Types


            itexture1D

            itexture2D

            itexture3D

            itextureCube

            itexture2DRect

            itexture1DArray

            itexture2DArray

            itextureBuffer

            itexture2DMS

            itexture2DMSArray

            itextureCubeArray

            isubpassInput     | a handle for accessing an integer subpass input

            isubpassInputMS   | a handle for accessing a multi-sampled integer

                              | subpass input


        Unsigned Integer Opaque Types


            utexture1D

            utexture2D

            utexture3D

            utextureCube

            utexture2DRect

            utexture1DArray

            utexture2DArray

            utextureBuffer

            utexture2DMS

            utexture2DMSArray

            utextureCubeArray

            usubpassInput     | a handle for accessing an unsigned integer

                              | subpass input

            usubpassInputMS   | a handle for accessing a multi-sampled unsigned

                              | integer subpass input


    Remove the entry from the table in section 4.1 Basic Types:


        atomic_uint


    Add a new category in this section


      "Sampler Opaque Types


          sampler       |    a handle for accessing state describing how to

                        |    sample a texture"

          ---------------------------------------------------------------------

          samplerShadow |    a handle for accessing state describing how to

                        |    sample a depth texture with comparison"


    Remove "structure member selection" from 4.1.7 and instead add a sentence

    "Opaque types cannot be declared or nested in a structure (struct)."


    Modify subsection 4.1.3 Integers, for desktop versions of GLSL, to say:


        "Highp unsigned integers have exactly 32 bits of precision.  Highp

        signed integers use 32 bits, including a sign bit, in two's complement

        form. Mediump and lowp integers are as defined by the RelaxedPrecision

        decoration in SPIR-V."


    Add a subsection to 4.1.7 Opaque Types:


        "4.1.7.x Texture, *sampler*, and *samplerShadow* Types


        "Texture (e.g., *texture2D*), *sampler*, and *samplerShadow* types are opaque

        types, declared and behaving as described above for opaque types.  When

        aggregated into arrays within a shader, these types can only be indexed

        with a dynamically uniform expression, or texture lookup will result in

        undefined values. Texture variables are handles to one-, two-, and

        three-dimensional textures, cube maps, etc., as enumerated in the basic

        types tables. There are distinct

        texture types for each texture target, and for each of float, integer,

        and unsigned integer data types. Textures can be combined with a

        variable of type *sampler* or *samplerShadow* to create a sampler type

        (e.g., sampler2D, or sampler2DShadow). This is done with a constructor,

        e.g., sampler2D(texture2D, sampler),

        sampler2DShadow(texture2D, sampler),

        sampler2DShadow(texture2D, samplerShadow), or

        sampler2D(texture2D, samplerShadow)

        and is described in more detail in section 5.4 "Constructors"."


        "4.1.7.x Subpass Inputs


        "Subpass input types (e.g., subpassInput) are opaque types, declared

        and behaving as described above for opaque types.  When aggregated into

        arrays within a shader, they can only be indexed with a dynamically

        uniform integral expression, otherwise results are undefined.


        "Subpass input types are handles to two-dimensional single sampled or

        multi-sampled images, with distinct types for each of float, integer,

        and unsigned integer data types.


        "Subpass input types are only available in fragment shaders.  It is a

        compile-time error to use them in any other stage."


    Remove the section 4.1.7.3 Atomic Counters


    Change section 4.3.3 Constant Expressions:


      Add a new very first sentence to this section:


        "SPIR-V specialization constants are expressed in GLSL as const, with

        a layout qualifier identifier of constant_id, as described in section

        4.4.x Specialization-Constant Qualifier."


      Add to this sentence:


        "A constant expression is one of...

          * a variable declared with the const qualifier and an initializer,

            where the initializer is a constant expression"


      To make it say:


        "A constant expression is one of...

          * a variable declared with the const qualifier and an initializer,

            where the initializer is a constant expression; this includes both

            const declared with a specialization-constant layout qualifier,

            e.g., 'layout(constant_id = ...)' and those declared without a

            specialization-constant layout qualifier"


      Add to "including getting an element of a constant array," that


        "an array access with a specialization constant as an index does

        not result in a constant expression"


      Add to this sentence:


        "A constant expression is one of...

          * the value returned by a built-in function..."


      To make it say:


        "A constant expression is one of...

          * for non-specialization-constants only: the value returned by a

            built-in function... (when any function is called with an argument

            that is a specialization constant, the result is not a constant

            expression)"


      Rewrite the last half of the last paragraph to be its own paragraph

      saying:


        "Non-specialization constant expressions may be evaluated by the

        compiler's host platform, and are therefore not required ...

        [rest of paragraph stays the same]"


      Add a paragraph


        "Specialization constant expressions are never evaluated by the

        front-end, but instead retain the operations needed to evaluate them

        later on the host."


    Add to the table in section 4.4 Layout Qualifiers:


                             | Individual Variable | Block | Allowed Interface

      ------------------------------------------------------------------------

      constant_id =          |     scalar only     |       |      const

      ------------------------------------------------------------------------

      push_constant          |                     |   X   |      uniform

      ------------------------------------------------------------------------

      set =                  |     opaque only     |   X   |      uniform

      ------------------------------------------------------------------------

      input_attachment_index | subpass types only  |       |      uniform


    (The other columns remain blank.)


    Also add to this table:


                         | Qualifier Only | Allowed Interface

      -------------------------------------------------------

      local_size_x_id =  |        X       |       in

      local_size_y_id =  |        X       |       in

      local_size_z_id =  |        X       |       in


    (The other columns remain blank.)


    Expand this sentence in section 4.4.1 Input Layout Qualifiers:


      "Where integral-constant-expression is defined in section 4.3.3 Constant

      Expressions as 'integral constant expression'"


    To include the following:


      ", with it being a compile-time error for integer-constant-expression to

      be a specialization constant:  The constant used to set a layout

      identifier X in layout(layout-qualifier-name = X) must evaluate to a

      front-end constant containing no specialization constants."


    Change the rules about locations and inputs for doubles, by removing


      "If a vertex shader input is any scalar or vector type, it will consume

      a single location. If a non-vertex shader input is a scalar or vector

      type other than dvec3 or dvec4..."


    Replacing the above with


      "If an input is a scalar or vector type other than dvec3 or dvec4..."


    (Making all stages have the same rule that dvec3 takes two locations...)


    At the end of the paragraphs describing the *location* rules, add this

    paragraph:


      "When generating SPIR-V, all *in* and *out* qualified user-declared

      (non built-in) variables and blocks (or all their members) must have a

      shader-specified *location*. Otherwise, a compile-time error is

      generated."


    [Note that an earlier existing rule just above this says "If a block has

    no block-level *location* layout qualifier, it is required that either all

    or none of its members have a *location* layout qualifier, or a compile-

    time error results."]


    Change section 4.4.1.3 "Fragment Shader Inputs" from


      "By default, gl_FragCoord assumes a lower-left origin for window

      coordinates ... For example, the (x, y) location (0.5, 0.5) is

      returned for the lowerleft-most pixel in a window. The origin can be

      changed by redeclaring gl_FragCoord with the

      origin_upper_left identifier."


    To


      "The gl_FragCoord built-in variable assumes an upper-left origin for

      window coordinates ... For example, the (x, y) location (0.5, 0.5) is

      returned for the upper-left-most pixel in a window. The origin can be

      explicitly set by redeclaring gl_FragCoord with the origin_upper_left

      identifier.  It is a compile-time error to change it to

      origin_lower_left."


    Add to the end of section 4.4.3 Uniform Variable Layout Qualifiers:


      "The /push_constant/ identifier is used to declare an entire block, and

      represents a set of "push constants", as defined by the API.  It is a

      compile-time error to apply this to anything other than a uniform block

      declaration.  The values in the block will be initialized through the

      API, as per the Vulkan API specification.  A block declared with

      layout(push_constant) may optionally include an /instance-name/.

      There can be only one push_constant

      block per stage, or a compile-time or link-time error will result. A

      push-constant array can only be indexed with dynamically uniform indexes.

      Uniform blocks declared with push_constant use different resources

      than those without; and are accounted for separately.  See the API

      specification for more detail."


    After the paragraphs about binding ("The binding identifier..."), add


      "The /set/ identifier specifies the descriptor set this object belongs to.

      It is a compile-time error to apply /set/ to a standalone qualifier or to

      a member of a block.  It is a compile-time error to apply /set/ to a block

      qualified as a push_constant.  By default, any non-push_constant uniform

      or shader storage block declared without a /set/ identifier is assigned to

      descriptor set 0.  Similarly, any sampler, texture, or subpass input type

      declared as a uniform, but without a /set/ identifier is also assigned

      to descriptor set 0.


      "If applied to an object declared as an array, all elements of the array

      belong to the specified /set/.


      "It is a compile-time error for either the /set/ or /binding/ value

      to exceed a front-end-configuration supplied maximum value."


    Remove mention of subroutine throughout section 4.4 Layout Qualifiers,

    including removal of section 4.4.4 Subroutine Function Layout Qualifiers.


    Change section 4.4.5 Uniform and Shader Storage Block Layout Qualifiers:


      Change


      "If the binding identifier is used with a uniform or shader storage block

      instanced as an array, the first element of the array takes the specified

      block binding and each subsequent element takes the next consecutive

      uniform block binding point. For an array of arrays, each element (e.g.,

      6 elements for a[2][3]) gets a binding point, and they are ordered per the

      array-of-array ordering described in section 4.1.9 'Arrays.'"

"


      To


      "If the binding identifier is used with a uniform block or buffer block

      instanced as an array, the entire array takes only the provided binding

      number.  The next consecutive binding number is available for a different

      object. For an array of arrays, descriptor set array element numbers used

      in descriptor set accesses are ordered per the array-of-array ordering

      described in section 4.1.9 'Arrays.'"


    Change section 4.4.6 Opaque-Uniform Layout Qualifiers:


      Change


      "If the binding identifier is used with an array, the first element of

      the array takes the specified unit and each subsequent element takes the

      next consecutive unit."


      To


      "If the binding identifier is used with an array, the entire array

      takes only the provided binding number.  The next consecutive binding

      number is available for a different object."


    Remove section 4.4.6.1 Atomic Counter Layout Qualifiers


    Add a new subsection at the end of section 4.4:


      "4.4.x Specialization-Constant Qualifier


      "Specialization constants are declared using "layout(constant_id=...)".

      For example:


        layout(constant_id = 17) const int arraySize = 12;


      "The above makes a specialization constant with a default value of 12.

      17 is the ID by which the API or other tools can later refer to

      this specific specialization constant. If it is never changed before

      final lowering, it will retain the value of 12. It is a compile-time

      error to use the constant_id qualifier on anything but a scalar bool,

      int, uint, float, or double.


      "Built-in constants can be declared to be specialization constants.

      For example,


        layout(constant_id = 31) gl_MaxClipDistances;  // add specialization id


      "The declaration uses just the name of the previously declared built-in

      variable, with a constant_id layout declaration.  It is a compile-time

      error to do this after the constant has been used: Constants are strictly

      either non-specialization constants or specialization constants, not

      both.


      "The built-in constant vector gl_WorkGroupSize can be specialized using

      the local_size_{xyz}_id qualifiers, to individually give the components

      an id. For example:


          layout(local_size_x_id = 18, local_size_z_id = 19) in;


      "This leaves gl_WorkGroupSize.y as a non-specialization constant, with

      gl_WorkGroupSize being a partially specialized vector.  Its x and z

      components can be later specialized using the ids 18 and 19.  These ids

      are declared independently from declaring the work-group size:


        layout(local_size_x = 32, local_size_y = 32) in;   // size is (32,32,1)

        layout(local_size_x_id = 18) in;                   // constant_id for x

        layout(local_size_z_id = 19) in;                   // constant_id for z


      "Existing rules for declaring local_size_x, local_size_y, and

      local_size_z are not changed by this extension. For the local-size ids,

      it is a compile-time error to provide different id values for the same

      local-size id, or to provide them after any use.  Otherwise, order,

      placement, number of statements, and replication do not cause errors.


      "Two arrays sized with specialization constants are the same type only if

      sized with the same symbol, involving no operations.


        layout(constant_id = 51) const int aSize = 20;

        const int pad = 2;

        const int total = aSize + pad; // specialization constant

        int a[total], b[total];        // a and b have the same type

        int c[22];                     // different type than a or b

        int d[aSize + pad];            // different type than a, b, or c

        int e[aSize + 2];              // different type than a, b, c, or d


      "Types containing arrays sized with a specialization constant cannot be

      compared, assigned as aggregates, declared with an initializer, or used

      as an initializer.  They can, however, be passed as arguments to

      functions having formal parameters of the same type.


      "Arrays inside a block may be sized with a specialization constant, but

      the block will have a static layout.  Changing the specialized size will

      not re-layout the block. In the absence of explicit offsets, the layout

      will be based on the default size of the array."


    Add a new subsection at the end of section 4.4:


      "4.4.y Subpass Qualifier


      "Subpasses are declared with the basic 'subpassInput' types.  However,

      they must have the layout qualifier "input_attachment_index" declared

      with them, or a compile-time error results.  For example:


        layout(input_attachment_index = 2, ...) uniform subpassInput t;


      This selects which subpass input is being read from. The value assigned

      to 'input_attachment_index', say i (input_attachment_index = i), selects

      that entry (ith entry) in the input list for the pass.  See the API

      documentation for more detail about passes and the input list.


      "If an array of size N is declared, it consume N consecutive

      input_attachment_index values, starting with the one provided.


      "It is a compile-time or link-time error to have different variables

      declared with the same input_attachment_index.  This includes any overlap

      in the implicit input_attachment_index consumed by array declarations.


      "It is a compile-time error if the value assigned to an

      input_attachment_index is greater than or equal to

      gl_MaxInputAttachments."


    Remove all mention of the 'shared' and 'packed' layout qualifiers.


    Change section 4.4.5 Uniform and Shader Storage Block Layout Qualifiers


      "The initial state of compilation is as if the following were declared:


        layout(std140, column_major) uniform;  // without push_constant

        layout(std430, column_major) buffer;


      "However, when push_constant is declared, the default layout of the

      buffer will be std430. There is no method to globally set this default."


    Add to this statement:


      "The std430 qualifier is supported only for shader storage blocks; using

      std430 on a uniform block will result in a compile-time error"


    the following phrase:


      "unless it is also declared with push_constant"


    Add to section 4.4.5 Uniform and Shader Storage Block Layout Qualifiers,

    for versions not having 'offset' and 'align' description language,

    or replace with the following for versions that do have 'offset' and

    'align' description language:


      "The 'offset' qualifier can only be used on block members of 'uniform' or

      'buffer' blocks. The 'offset' qualifier forces the qualified member to

      start at or after the specified integral-constant-expression, which will

      be its byte offset from the beginning of the buffer. It is a compile-time

      error to have any offset, explicit or assigned, that lies within another

      member of the block. Two blocks linked together in the same program with

      the same block name must have the exact same set of members qualified

      with 'offset' and their integral-constant-expression values must be the

      same, or a link-time error results. The specified 'offset' must be a

      multiple of the base alignment of the type of the block member it

      qualifies, or a compile-time error results.


      "The 'align' qualifier can only be used on block members of 'uniform' or

      'buffer' blocks. The 'align' qualifier makes the start of each block

      buffer have a minimum byte alignment. It does not affect the internal

      layout within each member, which will still follow the std140 or std430

      rules. The specified alignment must be greater than 0 and a power of 2,

      or a compile-time error results.


      "The actual alignment of a member will be the greater of the specified

      'align' alignment and the standard (e.g., std140) base alignment for the

      member's type. The actual offset of a member is computed as follows:

      If 'offset' was declared, start with that offset, otherwise start with

      the offset immediately following the preceding member (in declaration

      order). If the resulting offset is not a multiple of the actual

      alignment, increase it to the first offset that is a multiple of the

      actual alignment. This results in the actual offset the member will have.


      "When 'align' is applied to an array, it affects only the start of the

      array, not the array's internal stride. Both an 'offset' and an 'align'

      qualifier can be specified on a declaration.


      "The 'align' qualifier, when used on a block, has the same effect as

      qualifying each member with the same 'align' value as declared on the

      block, and gets the same compile-time results and errors as if this had

      been done. As described in general earlier, an individual member can

      specify its own 'align', which overrides the block-level 'align', but

      just for that member."


    Remove the following preamble from section 4.7, which exists for desktop

    versions, but not ES versions.  Removal:


      "Precision qualifiers are added for code portability with OpenGL ES, not

      for functionality. They have the same syntax as in OpenGL ES, as

      described below, but they have no semantic meaning, which includes no

      effect on the precision used to store or operate on variables.


      "If an extension adds in the same semantics and functionality in the

      OpenGL ES 2.0 specification for precision qualifiers, then the extension

      is allowed to reuse the keywords below for that purpose.


      "For the purposes of determining if an output from one shader stage

      matches an input of the next stage, the precision qualifier need not

      match."


    Add:


      "For interface matching, uniform variables and uniform and buffer block

      members must have the same precision qualification. For matching *out*

      variables or block members to *in* variables and block members, the

      precision qualification does not have to match.


      "Global variables declared in different compilation units linked into the

      same shader stage must be declared with the same precision qualification."


    More generally, all versions will follow OpenGL ES semantic rules for

    precision qualifiers.


    Section 4.7.2 Precision Qualifiers (desktop only)


      Replace the table saying "none" for all precisions with this statement:


        "Mediump and lowp floating-point values have the precision defined by

        the RelaxedPrecision decoration in SPIR-V."


    Section 4.7.4 Default Precision Qualifiers:


      For desktop versions, replace the last three paragraphs that state the

      default precisions with the following instead:


        "All stages have default precision qualification of highp for all types

        that accept precision qualifiers."


Changes to Chapter 5 of the OpenGL Shading Language Specification


    Add a new subsection at the end of section 5.4 "Constructors":


        "5.4.x Sampler Constructors


        "Sampler types, like *sampler2D* can be declared with an initializer

        that is a constructor of the same type, and consuming a texture and a

        sampler.  For example:


          layout(...) uniform sampler s;   // handle to filtering information

          layout(...) uniform texture2D t; // handle to a texture

          layout(...) in vec2 tCoord;

          ...

          texture(sampler2D(t, s), tCoord);


        The result of a sampler constructor cannot be assigned to a variable:


          ... sampler2D sConstruct = sampler2D(t, s);  // ERROR


        Sampler constructors can only be consumed by a function parameter.


        Sampler constructors of arrays are illegal:


          layout(...) uniform texture2D tArray[6];

          ...

          ... sampler2D[](tArray, s) ...  // ERROR


        Formally:

         * every sampler type can be used as a constructor

         * the type of the constructor must match the type of the

           variable being declared

         * the constructor's first argument must be a texture type

         * the constructor's second argument must be a scalar of type

           *sampler* or *samplerShadow*

         * the dimensionality (1D, 2D, 3D, Cube, Rect, Buffer, MS, and Array)

           of the texture type must match that of the constructed sampler type

           (that is, the suffixes of the type of the first argument and the

           type of the constructor will be spelled the same way)

         * there is no control flow construct (e.g., "?:") that consumes any

           sampler type


         Note: Shadow mismatches are allowed between constructors and the

         second argument. Non-shadow samplers can be constructed from

         *samplerShadow* and shadow samplers can be constructed from *sampler*.


    Change section 5.9 Expressions


      Add under "The sequence (,) operator..."


        "Texture and sampler types cannot be used with the sequence (,)

        operator."


      Change under "The ternary selection operator (?:)..."


        "The second and third expressions can be any type, as long their types

        match."


      To


        "The second and third expressions can be any type, as long their types

        match, except for texture and sampler types, which result in a

        compile-time error."


    Add a section at the end of section 5


      "5.x Specialization Constant Operations"


      Only some operations discussed in this section may be applied to a

      specialization constant and still yield a result that is as

      specialization constant.  The operations allowed are listed below.

      When a specialization constant is operated on with one of these

      operators and with another constant or specialization constant, the

      result is implicitly a specialization constant.


       - int(), uint(), and bool() constructors for type conversions

         from any of the following types to any of the following types:

           * int

           * uint

           * bool

       - vector versions of the above conversion constructors

       - allowed implicit conversions of the above

       - swizzles (e.g., foo.yx)

       - The following when applied to integer or unsigned integer types:

           * unary negative ( - )

           * binary operations ( + , - , * , / , % )

           * shift ( <<, >> )

           * bitwise operations ( & , | , ^ )

       - The following when applied to integer or unsigned integer scalar types:

           * comparison ( == , != , > , >= , < , <= )

       - The following when applied to the Boolean scalar type:

           * not ( ! )

           * logical operations ( && , || , ^^ )

           * comparison ( == , != )

       - The ternary operator ( ? : )


Changes to Chapter 6 of the OpenGL Shading Language Specification


    Remove mention of subroutine throughout, including removal of

    section 6.1.2 Subroutines.


Changes to Chapter 7 of the OpenGL Shading Language Specification


    Changes to section 7.1 Built-In Language Variables


      Replace gl_VertexID and gl_InstanceID, for non-ES with:


        "in int gl_VertexIndex;"

        "in int gl_InstanceIndex;"


      For ES, add:


        "in highp int gl_VertexIndex;"

        "in highp int gl_InstanceIndex;"


      The following definition for gl_VertexIndex should replace the definition

      for gl_VertexID:


        "The variable gl_VertexIndex is a vertex language input variable that

        holds an integer index for the vertex, [See issue 7 regarding which

        name goes with which semantics] relative to a base.  While the

        variable gl_VertexIndex is always present, its value is not always

        defined.  See XXX in the API specification."


      The following definition for gl_InstanceIndex should replace the definition

      for gl_InstanceID:


        "The variable gl_InstanceIndex is a vertex language input variable that

        holds the instance number of the current primitive in an instanced draw

        call, relative to a base. If the current primitive does not come from

        an instanced draw call, the value of gl_InstanceIndex is zero."

        [See issue 7 regarding which name goes with which semantics]


    Changes to section 7.3 Built-In Constants


      Add


        "const int gl_MaxInputAttachments = 1;"


    Remove section 7.4 Built-In Uniform State (there is none in Vulkan).


Changes to Chapter 8 of the OpenGL Shading Language Specification


    Add the following ES language to desktop versions of the specification:


      "The operation of a built-in function can have a different precision

      qualification than the precision qualification of the resulting value.

      These two precision qualifications are established as follows.


      "The precision qualification of the operation of a built-in function is

      based on the precision qualification of its input arguments and formal

      parameters:  When a formal parameter specifies a precision qualifier,

      that is used, otherwise, the precision qualification of the calling

      argument is used.  The highest precision of these will be the precision

      qualification of the operation of the built-in function. Generally,

      this is applied across all arguments to a built-in function, with the

      exceptions being:

        - bitfieldExtract and bitfieldInsert ignore the 'offset' and 'bits'

          arguments.

        - interpolateAt* functions only look at the 'interpolant' argument.


      "The precision qualification of the result of a built-in function is

      determined in one of the following ways:


        - For the texture sampling, image load, and image store functions,

          the precision of the return type matches the precision of the

          sampler type:

             uniform lowp sampler2D sampler;

             highp vec2 coord;

             ...

             lowp vec4 col = texture (sampler, coord); // texture() returns lowp


        Otherwise:


        - For prototypes that do not specify a resulting precision qualifier,

          the precision will be the same as the precision of the operation.

          (As defined earlier.)


        - For prototypes that do specify a resulting precision qualifier,

          the specified precision qualifier is the precision qualification of

          the result."


    Add precision qualifiers to the following in desktop versions:


      genIType floatBitsToInt (highp genFType value)

      genUType floatBitsToUint(highp genFType value)

      genFType intBitsToFloat (highp genIType value)

      genFType uintBitsToFloat(highp genUType value)


      genFType frexp(highp genFType x, out highp genIType exp)

      genFType ldexp(highp genFType x,  in highp genIType exp)


      highp uint packSnorm2x16(vec2 v)

      vec2 unpackSnorm2x16(highp uint p)

      highp uint packUnorm2x16(vec2 v)

      vec2 unpackUnorm2x16(highp uint p)

      vec2 unpackHalf2x16(highp uint v)

      vec4 unpackUnorm4x8(highp uint v)

      vec4 unpackSnorm4x8(highp uint v)


      genIType bitfieldReverse(highp genIType value)

      genUType bitfieldReverse(highp genUType value)

      genIType findMSB(highp genIType value)

      genIType findMSB(highp genUType value)

      genUType uaddCarry(highp genUType x, highp genUType y,

                         out lowp genUType carry)

      genUType usubBorrow(highp genUType x, highp genUType y,

                          out lowp genUType borrow)

      void umulExtended(highp genUType x, highp genUType y,

                        out highp genUType msb, out highp genUType lsb)

      void imulExtended(highp genIType x, highp genIType y,

                        out highp genIType msb, out highp genIType lsb)


    Remove section 8.10 Atomic-Counter Functions


    Remove section 8.14 Noise Functions


    Add a section


      "8.X Subpass Functions


      "Subpass functions are only available in a fragment shader.


      "Subpass inputs are read through the built-in functions below. The gvec...

      and gsubpass... are matched, where they must both be the same floating

      point, integer, or unsigned integer variants.


    Add a table with these two entries (in the same cell):


      "gvec4 subpassLoad(gsubpassInput   subpass)

       gvec4 subpassLoad(gsubpassInputMS subpass, int sample)"


    With the description:


      "Read from a subpass input, from the implicit location (x, y, layer)

      of the current fragment coordinate."


Changes to the grammar


    Arrays can no longer require the size to be a compile-time folded constant

    expression.  Change


      | LEFT_BRACKET constant_expression RIGHT_BRACKET


    to


      | LEFT_BRACKET conditional_expression RIGHT_BRACKET


    and change


      | array_specifier LEFT_BRACKET constant_expression RIGHT_BRACKET


    to


      | array_specifier LEFT_BRACKET conditional_expression RIGHT_BRACKET


    Remove the ATOMIC_UINT type_specifier_nonarray.


    Remove all instances of the SUBROUTINE keyword.


Issues


1. Can we have specialization sizes in an array in a block?  That prevents

   putting known offsets on subsequent members.


   RESOLUTION: Yes, but it does not affect offsets.


2. Can a specialization-sized array be passed by value?


   RESOLUTION: Yes, if they are sized with the same specialization constant.


3. Can a texture array be variably indexed?  Dynamically uniform?


   Resolution (bug 14683): Dynamically uniform indexing.


4. Are arrays of a descriptor set all under the same set number, or does, say,

   an array of size 4 use up 4 descriptor sets?


   RESOLUTION: There is no array of descriptor sets.  Arrays of resources

   are in a single descriptor set and consume a single binding number.


5. Which descriptor set arrays can be variably or non-uniformly indexed?


   RESOLUTION: There is no array of descriptor sets.


6. Do we want an alternate way of doing composite member specialization

   constants?  For example,


       layout(constant_id = 18) gl_WorkGroupSize.y;


   Or


       layout(constant_id = 18, local_size_y = 16) in;


   Or


       layout(constant_id = 18) wgy = 16;

       const ivec3 gl_WorkGroupSize = ivec3(1, wgy, 1);


    RESOLUTION: No. Use local_size_x_id etc. for workgroup size, and

    defer any more generalized way of doing this for composites.


7. What names do we really want to use for

        gl_VertexIndex             base, base+1, base+2, ...

        gl_InstanceIndex           base, base+1, base+2, ...


     RESOLUTION: Use the names above.


     Note that gl_VertexIndex is equivalent to OpenGL's gl_VertexID in that

     it includes the value of the baseVertex parameter. gl_InstanceIndex is

     NOT equivalent to OpenGL's gl_InstanceID because gl_InstanceID does NOT

     include the baseInstance parameter.


8. What should "input subpasses" really be called?


   RESOLVED: subpassInput.


9. The spec currently does not restrict where sampler constructors can go,

   but should it?  E.g., can the user write a shader like the following:


     uniform texture2D t[MAX_TEXTURES];

     uniform sampler s[2];


     uniform int textureCount;

     uniform int sampleCount;

     uniform bool samplerCond;


     float ShadowLookup(bool pcf, vec2 tcBase[MAX_TEXTURES])

     {

         float result = 0;


         for (int textureIndex = 0; textureIndex < textureCount; ++textureIndex)

         {

             for (int sampleIndex = 0; sampleIndex < sampleCount; ++sampleIndex)

             {

                 vec2 tc = tcBase[textureIndex] + offsets[sampleIndex];

                 if (samplerCond)

                     result += texture(sampler2D(t[textureIndex], s[0]), tc).r;

                 else

                     result += texture(sampler2D(t[textureIndex], s[1]), tc).r;

             }


   Or, like this?


     uniform texture2D t[MAX_TEXTURES];

     uniform sampler s[2];


     uniform int textureCount;

     uniform int sampleCount;

     uniform bool samplerCond;


     sampler2D combined0[MAX_TEXTURES] = sampler2D(t, s[0]);

     sampler2D combined1[MAX_TEXTURES] = sampler2D(t, s[1]);


     float ShadowLookup(bool pcf, vec2 tcBase[MAX_TEXTURES])

     {

         for (int textureIndex = 0; textureIndex < textureCount; ++textureIndex) {

             for (int sampleIndex = 0; sampleIndex < sampleCount; ++sampleIndex) {

                 vec2 tc = tcBase[textureIndex] + offsets[sampleIndex];

                 if (samplerCond)

                     result += texture(combined0[textureIndex], tc).r;

                 else

                     result += texture(combined1[textureIndex], tc).r;

             }

         ...


    RESOLUTION (bug 14683): Only constructed at the point of use, where passed

    as an argument to a function parameter.


Revision History


    Rev.    Date         Author    Changes

    ----  -----------    -------  --------------------------------------------

    46    25-Jul-2018    JohnK    No longer require sampler constructors to

                                  check shadow matches: mismatches are allowed

    45    15-Dec-2017    TobiasH  moved resource binding examples in from Vulkan API spec

    44    12-Dec-2017    jbolz    Document mapping of barrier/atomic ops to

                                  SPIR-V

    43    25-Oct-2017    JohnK    remove the already deprecated noise functions

    42    07-Jul-2017    JohnK    arrays of buffers consume only one binding

    41    05-Jul-2017    JohnK    allow std430 on push_constant declarations

    40    21-May-2017    JohnK    Require in/out explicit locations

    39    14-Apr-2017    JohnK    Update overview for StorageBuffer storage

                                  class.

    38    14-Apr-2017    JohnK    Fix Vulkan public issue #466: texture2D typo.

    37    26-Mar-2017    JohnK    Fix glslang issue #369: remove gl_NumSamples.

    36    13-Feb-2017    JohnK    Fix public bug 428: allow anonymous

                                  push_constant blocks.

    35    07-Feb-2017    JohnK    Add 'offset' and 'align' to all versions

    34    26-Jan-2017    JohnK    Allow the ternary operator to result in a

                                  specialization constant

    33    30-Aug-2016    JohnK    Allow out-of-order offsets in a block

    32     1-Aug-2016    JohnK    Remove atomic_uint and more fully subroutine

    31    20-Jul-2016    JohnK    Have desktop versions respect mediump/lowp

    30    12-Apr-2016    JohnK    Restrict spec-const operations to non-float

    29     5-Apr-2016    JohnK    Clarify disallowance of spec-const arrays in

                                  initializers

    28     7-Mar-2016    JohnK    Make push_constants not have sets

    27    28-Feb-2016    JohnK    Make the default by origin_upper_left

    26    17-Feb-2016    JohnK    Expand specialized array semantics

    25    10-Feb-2016    JohnK    Incorporate resolutions from the face to face

    24    28-Jan-2016    JohnK    Update the resolutions from the face to face

    23     6-Jan-2016    Piers    Remove support for gl_VertexID and

                                  gl_InstanceID since they aren't supported by

                                  Vulkan.

    22    29-Dec-2015    JohnK    support old versions and add semantic mapping

    21    09-Dec-2015    JohnK    change spelling *subpass* -> *subpassInput* and

                                  include this and other texture/sample types in

                                  the descriptor-set-0 default scheme

    20    01-Dec-2015    JohnK    push_constant default to std430, opaque types

                                  can only aggregate as arrays

    19    25-Nov-2015    JohnK    Move "Shadow" from texture types to samplerShadow

    18    23-Nov-2015    JohnK    Bug 15206 - Indexing of push constant arrays

    17    18-Nov-2015    JohnK    Bug 15066: std140/std43 defaults

    16    18-Nov-2015    JohnK    Bug 15173: subpass inputs as arrays

    15    07-Nov-2015    JohnK    Bug 14683: new rules for separate texture/sampler

    14    07-Nov-2015    JohnK    Add specialization operators, local_size_*_id

                                  rules, and input dvec3/dvec4 always use two

                                  locations

    13    29-Oct-2015    JohnK    Rules for input att. numbers, constant_id,

                                  and no subpassLoadMS()

    12    29-Oct-2015    JohnK    Explain how gl_FragColor is handled

    11     9-Oct-2015    JohnK    Add issue: where can sampler constructors be

    10     7-Sep-2015    JohnK    Add first draft specification language

     9     5-Sep-2015    JohnK    - make specialization id's scalar only, and

                                    add local_size_x_id... for component-level

                                    workgroup size setting

                                  - address several review comments

     8     2-Sep-2015    JohnK    switch to using the *target* style of target

                                  types (bug 14304)

     7    15-Aug-2015    JohnK    add overview for input targets

     6    12-Aug-2015    JohnK    document gl_VertexIndex and gl_InstanceIndex

     5    16-Jul-2015    JohnK    push_constant is a layout qualifier

                                  VULKAN is the only versioning macro

                                  constantID  -> constant_id

     4    12-Jul-2015    JohnK    Rewrite for clarity, with proper overview,

                                  and prepare to add full semantics

     3    14-May-2015    JohnK    Minor changes from meeting discussion

     2    26-Apr-2015    JohnK    Add controlling features/capabilities

     1    26-Mar-2015    JohnK    Initial revision


Vulkan specification 을 보다가 보면 "opaque" 라는 표현이 자주 나옵니다.


Command pools are opaque objects that command buffer memory is allocated from, and which allow the implementation to amortize the cost of resource creation across multiple command buffers.


- 출처 : 5.2. Command Pools, Vulkan 1.1.83 Specification.


이게 무슨 의미인지 궁금해서 찾아 보니 Wikipedia 에 "opaque data type" 이라는 개념이 있었습니다.


컴퓨터 과학에서 opaque data type 은 인터페이스에서 그것의 concrete data structure 가 정의되지 않은 data type 입니다. 이는 정보 은닉( information hiding )을 강화하는데, 그것의 값들이, 그 알 수 없는 정보에 대한 접근을 허용하는, 서브루틴을 호출함으로써만 조작될 수 있기 때문입니다.


- 출처 : Opaque data type, Wikipedia.


"Opaque" 가 "불투명한" 혹은 "이해하기 힘든" 라는 뜻을 가지고 있기 때문에 말이 되는 표현인 것 같습니다. "concrete data structure" 라는 것은 "abstract data structure" 와 대칭되는 개념인 것 같더군요.


C/C++ 을 사용한지 15 년도 넘었는데 이런 기본적인 개념도 제대로 이해하고 있지 않다니 자괴감이 드네요. 어쨌든 C++ 로 치면 "abstract data type" 이라는 것은 interface( abstract class ) 이고 "concrete data type" 은 실제 class 라고 생각하시면 됩니다.


Vulkan 에서 특정 object 들에 대한 정의를 찾아 가면 다음과 같은 매크로들을 볼 수 있습니다. VK_DEFINE_HANDLE 은 dispatchable object 를 의미하고 VK_DEFINE_NON_DISPATCHABLE_HANDLE 은 non-dispatchable object 를 의미합니다. 여기에 대한 설명은 이 문서의 주제를 벗어나므로 여기에서 구체적인 언급은 하지 않겠습니다. 나중에 따로 다루도록 하겠습니다.



어쨌든 이 매크로의 정의를 보면 다음과 같습니다.



해당 type 에 대한 구체적인 정의는 없고 단순히 pointer 를 선언하고 있을 뿐입니다. 실제 내용은 라이브러리를 구현하는 측에서 채우게 되는 것이죠. 처음에는 이 선언이 매우 당황스러웠는데 opaque object 를 구현하기 위한 선언이라고 생각하니 이해가 가는군요.


같은 이름만 가지고 있다면 구현측에서 내부에 어떤 내용을 채워도 상관이 없다는 것입니다. Vulkan 에서는 object 생성시에 vkCreateXXX() 메서드를 호출하고 필요한 정보를 VkXXXCreateInfo 라는 구조체를 통해서 전달하게 하고 있습니다. 위에서 설명하듯이 구조체 내부 구현은 알 수 없지만 특정 메서드를 통해서 접근할 수 있도록 하고 있는 것입니다.


보시면 알겠지만 전부 pointer 형으로 선언되어 있습니다. 그래서 가끔 이러한 type 을 선언할 때 기본이 type 이 pointer 형이라는 것을 까먹는 경우가 많은데, Vulkan 을 사용할 때 주의해야 할 점 중에 하나입니다.

Motive


최근에 Vulkan 에 관심이 좀 생겨서 tutorial 을 따라하면서 구현하고 있습니다. 그런데 이걸 추상화해서 C++ class 로 만들어 내는 데 스트레스가 심해졌습니다. 


Vulkan 은 기본적으로 C 로 구현되어 있기 때문에 오브젝트가 다 structure 로 되어 있고 member function 을 사용하고 있지 않습니다. 그래서 함수가 매우 많은데 일일이 찾아야 하고 구조체에 일일이 sType 을 넣어 줘야 합니다. ( C++ template 을 사용하는 ) Type-Traits 가 존재하지 않기 때문에 구조체의 유효성 검사를 위해서는 어쩔 수 없겠죠.



게다가 호출할 때도 파라미터 개수가 많아지고 생성하고자 하는 오브젝트가 어디에 소속되어 있는 건지 알기가 어렵습니다.



D3D 처럼 vkPhysicalDevice 와 관련한 메서드가 클래스에 모여 있으면 얼마나 좋을까요?



저는 C++ 에 익숙해져 있는 개발자다 보니 Vulkan 이 D3D 보다 좀 산만하고 어렵게 느껴집니다.


그래서 회사에서 동료분이랑 "왜 Vulkan 은 C 로 작성되었을까?" 라는 주제에 대해서 이야기해 봤는데, "개발자가 C++ 을 싫어한다", "성능 차이가 있다", "OpenGL 과 API 의 호환성을 유지하려고 한다", "별로 편하게 해줄 생각이 없는 애들이다" 등등 여러 가지 의견이 나왔지만, 납득할 만한 답을 찾지는 못했습니다.


대체 왜 C++ 이 아닌 C 를 사용하게 되었는지 알 수가 없었습니다. 그런데 "Vulkan Specification" [ 1 ] 을 읽다가 보니 단서를 찾았습니다.


2.4 절에 보면 다음과 같은 내용이 나옵니다.


ABI 는 Vulkan 이 응용프로그램을 플랫폼 혹은 구현측( implementation )에 맞춰 정의될 수 있도록 해 주는 메카니즘입니다. 다양한 플랫폼상에서, 이 명세에서 기술된 C 인터페이스는 공유 라이브러리에 의해서 제공됩니다. 공유 라이브러리들은 그것을 사용하는 응용프로그램과는 독립적으로 변경될 수 있기 때문에, 그것들은 특정한 호환성 문제를 겪게 되며, 이 명세는 그것들에 대한 요구사항을 제시합니다.


공유 라이브러리 구현은 반드시 그 플랫폼을 위한 표준 C 컴파일러의 Application Binary Interface ( ABI ) 를 사용하거나, 응용프로그램 코드가 구현측의 non-default API 를 사용하도록 만드는 customized API 헤더를 제공해야만 합니다. 이 문맥에서 ABI 는 C 데이터 타입의 size, alignment, layout 을 의미합니다; 프로시저의 호출 규약( calling convention ); 그리고 C 함수와 연관되는 공유 라이브러리 심볼의 이름 규약( naming convention ). 플랫폼에 대해 호출 규약을 커스터마이징하는 것은 보통 vk_platform.h 에 있는 호출규약 매크로를 적절히 정의함으로써 가능합니다.


Vulkan 을 공유 라이브러리로서 제공하는 플랫폼에서, 라이브러리 심볼ㄹ은 "vk" 로 시작하고 구현측에서 사용하기 위해서 예약된 숫자와 대문자가 그 다음에 오게 됩니다. Vulkan 을 사용하는 응용프로그램은 절대 이런 심볼들에 대한 정의를 제공해서는 안 됩니다. 이렇게 해야, Vulkan 공유 라이브러리가 새로운 API 나 extension 을 위해 추가적인 심볼들을 갱신할 때, 그것들이 현재 존재하는 응용프로그램의 심볼과 충돌하지 않습니다.


출처 : [ 1 ].


즉 동일한 ABI 를 사용하기 위해서 C 를 사용한다는 것을 알 수 있습니다. 하지만 C++ 도 ABI 를 제공하는 것은 아닌데 왜 굳이 C 여야 하는지 의문이 생겼습니다.


C ABI vs C++ ABI


Application Binary Interface 는 Android 개발에 의해서 cross-platform 개발이 대중화되면서 잘 알려지기 시작했습니다. 저만해도 그때까지는 Windows Platform 만 대상으로 개발했기 때문에 ABI 존재를 알지 못했습니다. 


사실 Mobile 개발에 큰 관심은 없었기에 ABI 라는 게 Android 에서만 사용하는 개념인줄 알았습니다. "armeabi" 라든가 "armeabi-v7a", "arm64-v8a" 같은 것들을 봐도 별로 느낌이 안 왔습니다 [ 3 ]. 보통 UE4 나 Unity 3D 같은 엔진들이 자동화를 통해서 빌드해 주기 때문에, 그냥 체크 몇 개만 해도 되기 때문이었습니다. 하지만 Gradle 같은 것들을 사용해서 커스터마이징을 하다가 보니 이런 개념들에 대해서 몰라서는 일을 하기가 힘들어졌습니다. 이제는 "이런 개념들에 대해서 모르고 있다가는 밥줄이 끊기겠구나" 싶더군요. 


칸텐츠 개발자라면 모르겠지만 엔진 개발자들은 이런 주제에 대해서 공부해야 할 기회( 당위성 )가 생기면 간단하게라도 개념을 이해하고 넘어가야 겠다는 생각이 들었습니다. 그래서 이번 기회에 ABI 가 뭔지 확인해 보기로 했습니다.


Wikipedia 에서는 ABI 를 다음과 같이 정의합니다.


컴퓨터 소프트웨어에서, application binary interface( ABI ) 는 두 이진 프로그램 모듈간의 인터페이스를 의미합니다; 보통 이러한 모듈들 중 하나는 라이브러리나 운영체제 기능이며, 다른 하나는 사용자에 의해서 실행되고 있는 프로그램입니다.


ABI 는 데이터 구조와 계산 루틴( computational routine )들이 머신 코드에서 어떻게 접근되어야 하는지를 정의합니다. 이것은 저수준( low-level )이며 하드웨어 의존적인 포맷입니다; 반면에 API 는 이 접근이 소스 코드에서 이루어지고, 그것은 상대적으로 고수준( high-level )이며 상대적으로 하드웨어에 비의존적이며, 보통 인간이 읽을 수 있는 포맷입니다. ABI 에 대한 일반적인 관점은 호출 규약이며, 이는 데이터가 입력이나 계산 루틴으로부터의 출력으로서 어떻게 제공되어야 하는지를 정의합니다; 예를 들면 x86 호출 규약이 있습니다.


출처 : [ 4 ].


그런데 문제는 C++ ABI 의 경우에는 호환성 보장이 어렵다는 데 있습니다. 


같은 플랫폼에서 컴파일러 간에 C++ name mangling, exception propagation, calling convention 같은 세부사항을 표준화하는 ABI 들이 있기는 하지만 cross-platform 에서의 호환성을 요구하지는 마십시오.


C++ 은 method overloading 을 지원하기 때문에 같은 클래스 내에 같은 이름이 존재하는 것이 가능하죠. 그렇기 때문에 C++ 컴파일러는 name mangling 이라는 것을 수행하게 되는데, 이게 컴파일러마다 다릅니다. 아래 이미지는 [ 2 ] 에서 가지고 왔습니다. [ 2 ] 에서는 여러 가지 예제를 만들어서 C 와 C++ 에서 심볼 이름을 만들어 내는 과정에 대해서 자세히 설명하고 있습니다.



보시면 알겠지만 컴파일러마다 너무 이름이 달라서 심볼이름으로 검색할 때 차이가 발생할 수밖에 없음을 알 수 있습니다. 예를 들어서 Vulkan 에서 validation layer 에 대한 모니터 개체를 생성하려면 다음과 같이 해야 합니다.



만약 "vkCreateDebugUtilsMessengerEXT" 함수가 C++ mangling 규칙을 따르게 된다면 어떤 규칙으로 이름을 찾아야 하는지 알 수가 없게 되었을 겁니다.


하지만 C 는 mangling 규칙이라는 것이 존재하지 않습니다. 그냥 함수 이름 앞에 "_" 가 하나 붙게 되는 형식입니다. 이에 대해서 자세하게 알고자 한다면 [ 2 ] 를 확인하시는 것이 좋습니다.


Vulkan.hpp


호환성을 위해서 C 를 사용하는 것까지는 좋지만, 너무 불편합니다. 


그래서 Khronos 에서는 "Vulkan.hpp" 헤더를 통해서 C++ 을 지원하고 있습니다. 다음과 같이 header 를 include 할 수 있습니다.



만약 VULKAN_HPP_NAMESPACE 를 지정하지 않으면 기본값이 "vk" 가 됩니다. 왠지 멋이 없어서 저는 그냥 "Vulkan" 이라고 해 봤습니다.


다음과 같이 클래스로 메서드들이 encapsulation 되어 있으니 매우 편합니다.



그리고 enumeration 들도 wrapping 되서 짧아졌더군요.



매우 편하게 가지고 놀면 될 것 같습니다.


하지만 Extension 을 사용할 때는 external symbol link error 가 나는 경우가 있는 것 같더군요. 그쪽에서는 C++ 쪽 대응을 하지 않은 것으로 보입니다. 그래서 저같은 경우에는 DebugUtilMessenger 같은 걸 사용할 때는 그냥 C-style 로 작업을 했습니다.


Conclusion


Cross-Platform 개발을 위해서는 호환성을 유지하는 것이 매우 중요하므로, Vulkan 은 기본 컴파일러의 C ABI 를 사용한다는 결론을 내릴 수 있었습니다. 또한 개발의 편의성을 위해 C++ 래퍼 클래스를 제공하고 있기 때문에 C++ 을 선호하는 사람은 그것을 사용하면 편리합니다. 단점이 있다면 거의 대부분의 예제가 C API 를 사용한다는 것입니다.


References


[ 1 ] Vulkan 1.1.86 Specification, Khronos.


[ 2 ] C++ 상에서 발생하는 name mangling 에 관한 내용, 미카 프로젝트.


[ 3 ] ABI 관리, Android Developers.


[ 4 ] Application Binary Interface, Wikepedia.

+ Recent posts