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

+ Recent posts