주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ 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 을 통해서 자유롭게 하시기 바라며, 저도 최선을 다해 답변을 하거나 필요하다면 이 포스트를 갱신하도록 하겠습니다.

+ Recent posts