효율적인 불칸 렌더러 작성하기
2020년 2월 27일 저자: 아르세니 카폴킨번역: 이정섭 (e-mail)
방문자: 55
(원문 보기)
저는 2018년도에 “효율적인 불칸 렌더러 작성하기” 라는 글을 썼고 이는 2019년에 출간된 “GPU Zen 2”라는 책에 실렸습니다. 그 글에서 불칸 성능과 관련된 정보를 최대한 광범위하게 다루려 노력했습니다. 불칸의 특정한 측면 또는 응용법에만 주목하지 않고 될 수 있는 한 넓은 범위의 주제를 다룸으로서 독자에게 실제 기기상에서의 다양한 API의 동작 방식을 이해시키려 하였고, 이를 통해 독자가 당면하는 각각의 문제에 대한 다양한 선택지를 제시하고 싶었습니다.
이 글을 공개하는 시점에서 이 책의 킨들 버전은 2.99달러에 아마존에서 판매되고 있습니다. 이것은 한잔의 커피값보다 저렴한 가격임에도 불구하고 기대 이상으로 많은 정보를 얻을 수 있다고 확신합니다. 또한 이 책은 다양한 저자가 쓴 다수의 렌더링 효과와 설계에 관한 훌륭한 내용으로 채워져 있습니다.
어쨋거나 이 글은 책에 실린 내용을 온전히 포함 하면서도 여전히 무료입니다. 이 글을 통해 그래픽 프로그래머들이 불칸을 최대한 활용하는데 도움이 되기를 기대합니다.
자 이제 시작해 볼까요?
요약
불칸은 다양한 운영체제를 지원하는 새로운 그래픽 API 입니다. 따라서 경험이 많은 개발자에게 조차 생소한 개념들이 다수 등장합니다. 불칸의 핵심 목표는 성능입니다. 그러나 우수한 성능을 위해서는 불칸의 다양한 개념과 이것을 효율적으로 적용하는 방법 그리고 개별적인 드라이버 구현에 대한 깊은 이해가 필요합니다. 이 글은 메모리 할당, 설명자 집합(descriptor set) 관리, 명령(command) 버퍼 기록, 파이프라인 장벽(barrier), 렌더 패스와 같은 주제를 다룹니다. 또한 현재 개발중인 데스크탑/모바일 불칸 렌더러의 CPU와 GPU 성능을 최적화하는 방법을 논하고, 미래 지향적인 불칸 렌더러는 설계부터 어떻게 달라져야 하는지 살펴 보겠습니다.
그래픽 렌더러의 구현은 최근 들어 점점 더 복잡해지고 있습니다. 다양한 그래픽 API를 지원해야 할 뿐 아니라 각각의 API는 기기 추상화의 단계도 다르고 개념도 통일되지 않고 제각각 입니다. 이 때문에 모든 운영체제를 지원하면서 동일한 수준의 성능을 유지하는게 때로는 쉽지 않습니다. 다행스럽게도 우리는 불칸을 사용할 수 있습니다. 여기서 개발자는 쉬운 길과 어려운 길중 하나를 선택해야 합니다. 기존 API에 존재했던 개념을 불칸으로 재구현 하는것은 쉬운길이며 프로젝트의 구체적인 정보를 활용하므로 기존 API방식 보다 효율적입니다. 불칸에 최적화된 시스템을 완전히 새롭게 설계하는것은 어려운 길이지만 충분한 시간이 주어진다면 도전해 볼만 합니다. 우리는 이 두 가지 극단적 선택지를 모두 다루려고 노력할 것입니다. 궁극적으로는 정답은 없습니다. 개발자는 최대의 효율성과 개발 및 유지/관리 비용 사이에서 적당히 타협해야 합니다. 추가적으로, 효율성은 많은 경우 응용프로그램에 종속적임을 항상 염두해둬야 합니다. 이 글의 대부분은 일반론이며 최상의 성능을 위해서는 특정 운영체제에서 동작하는 특정 응용프로그램의 성능을 측정한 결과를 기반으로 구현상의 결정을 내려야합니다.
이 글은 불칸 API에 대한 기본 지식이 있는 상태에서 좀더 깊이 있는 지식을 얻으려거나 API를 효율적으로 사용하는 방법을 터득하려는 독자를 가정하여 쓰여졌습니다.
메모리 관리
메모리 관리는 여전히 매우 복잡한 주제이며 불칸의 경우 더욱 그렇습니다. 불칸에서는 기기마다 다를 수 있는 다양한 힙 구성을 다뤄야 하기 때문입니다. 이전 API들은 자원 중심의 개념을 채택했습니다. 프로그래머에게는 그래픽 메모리의 개념이 필요하지 않았으며 그래픽 자원의 개념만 요구되었습니다. 이와 같은 상황에서 드라이버는 개발자가 지정한 사용 플래그를 단서 삼아 제 각각의 경험 지식(hueristic)(때에 따라 완벽할 수 없는)을 기반으로 한 자원 관리를 힘겹게 수행해야 했습니다. 그러나 불칸에서는 개발자의 그래픽 메모리 관리가 필수입니다. 자원을 생성하려면 먼저 그래픽 메모리를 개발자가 직접 할당하도록 강제 하기 때문입니다.
개발 첫 단계에서 고려해 볼만한 것으로 VMA로도 불리는 VulkanMemoryAllocator
라는 오픈 소스 라이브러리를 소개합니다. AMD에 의해 개발된 이 라이브러리는 내부적으로 불칸의 기능을 사용하여 세부적인 메모리 관리 작업을 개발자 대신 수행해 줍니다. 개발자는 단지 VMA가 제공하는 범용 자원 할당 기능을 사용하면 됩니다.
메모리 힙 선택하기
불칸에서 자원을 생성하려면 메모리 할당이 이루어질 공간인 힙을 먼저 선택해야합니다. 불칸 기기는 여러개의 메모리 타입을 드러냅니다. 각 타입은 해당 메모리의 동작을 정의하는 플래그 집합과 가용 크기를 정의하는 힙 인덱스를 가집니다.
대부분의 불칸 구현은 아래 플래그 조합을 두개 내지 세개 드러냅니다1:
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT
– 이 플래그는 일반적으로 CPU가 접근할 수 없는 GPU 전용 메모리를 나타냅니다. 이것은 GPU가 접근할 수 있는 최고로 빠른 메모리이며 렌더 타겟이나 계산용 버퍼등 GPU에서만 사용될 자원 그리고 정적 텍스처, 정적 기하 버퍼(정점 버퍼, 색인 버퍼 등)처럼 정적인 자원을 저장하기 위해 사용됩니다.VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT
– AMD 기기에서 이 플래그 조합을 가진 메모리는 최대 256MB 용량을 가지는 비디오 메모리이며 CPU가 직접 기록할 수 있습니다. 이 용량은 매 프레임 CPU가 기록하게 될 유니폼 버퍼, 동적 정점/색인 버퍼용 공간으로는 충분한 크기 입니다.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
2 – 이 플래그 조합은 GPU에 동기화되는 CPU 메모리를 나타냅니다. 이 형식의 메모리의 변경 사항은 별도의 명령이 없어도 자동으로 CPU와 GPU사이에 동기화 됩니다. 만약 위의 메모리 형식이 지원되지 않는다면 대신 사용될 수 있습니다. 또한VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT
를 통해 GPU 전용 메모리에 할당된 리소스에 값을 채워넣을 용도로 사용하는 스테이징 버퍼도 이 유형의 메모리를 사용합니다.VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT
– 이 특수한 플래그 조합은 특정 기기에서 렌더 타겟을 위해 사용될 수 있는 메모리를 나타냅니다. 이 플래그 조합을 타일 아키텍처 기기에서 사용하면 메모리 할당이 발생하지 않습니다. 큰 사이즈의 MSAA나 깊이 이미지등을 다룰때 사용하면 많은 메모리 용량을 아낄 수 있습니다. 단 할당된 메모리가 아니므로 이미지의 내용은 필요할 경우 새로운 내용으로 덮어 씌워질 수 있습니다. 통합형 GPU의 경우 CPU 메모리와 GPU 메모리의 구분이 없습니다. 이 경우 정적 자원을 위한 메모리 조차VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT
플래그 조합을 사용하면 됩니다.
동적 자원을 다룰 경우 보통 비-기기 메모리(GPU에 동기화되는 CPU 메모리)에 할당해도 문제가 없습니다. 이렇게 하면 응용프로그램의 관리가 쉬울 뿐만 아니라 GPU의 입장에서 읽기 전용 자료라서 캐싱을 통한 효율적인 접근이 가능하기 때문입니다. 그러나 대량의 임의 접근을 필요로하는 예를 들어 동적 텍스처의 경우라면 오히려 정적 텍스처 다루듯이 해야합니다. 정적 텍스처 처럼 VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT
로 메모리를 할당하고 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT
형식으로 할당된 별도의 스테이징 버퍼를 사용해서 데이타를 업로드하는 방식 말입니다. 때때로 이것은 버퍼에도 적용됩니다. 대부분 유니폼 버퍼에서는 문제가 없겠지만, 대용량의 스토리지 버퍼에서 대량의 임의 접근 패턴을 가지는 응용프로그램이라면 너무 많은 PCIe 트랜잭션을 유발 할 것입니다. 따라서 이 경우 버퍼를 먼저 GPU쪽에 복사해서 사용해야합니다. 또한 호스트 메모리는 GPU 입장에서 높은 접근 지연이 발생하므로 다수의 작은 그리기 호출에 불리합니다.
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT
를 사용하여 기기 메모리를 과도하게 할당하면 메모리 부족 에러가 충분히 발생할 수 있습니다. 이 경우에는 어쩔 수 없이 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT
형식의 호스트 메모리에 자원을 할당 해야합니다. 당연하게도 렌더 타겟과 같이 대용량의 빈번히 사용되는 자원들을 먼저 할당하는 것이 좋습니다. 기기 메모리 부족 현상을 해결할 다른 방법이 존재합니다. 자주 사용되지 않는 GPU 메모리를 CPU 메모리로 이동시키는 방법이 그것입니다. 하지만 이는 글의 주제를 벗어나므로 여기서는 더 다루지 않을 것입니다. 또한 윈도우 10과 같은 특정 운영 체제에서는 현재 불칸에서 지원하지 않는 API를 사용해야 정확한 메모리 부족에 대한 처리가 가능합니다.
메모리 세분 할당
여타 그래픽 API의 경우 적어도 자원(텍스처, 버퍼 등) 하나 하나마다 고유한 메모리 할당이 이루어지는것 처럼 보입니다. 하지만 불칸에서는 이것은 결코 올바른 메모리 할당 방법이 아닙니다. 사실 그래픽 드라이버는 최대 4096개 이상의 할당된 메모리를 가질 수 없으며 이것은 운영체제의 제약입니다. 전체 할당 개수의 제약 이외에도 일대일 메모리 할당 전략에는 몇가지 문제가 더 있습니다. 일단 불칸에서 메모리 할당은 느릴 수 있습니다. 또한 메모리의 정렬 요구사항 때문에 버려지는 메모리 공간이 생길 수 있습니다. 또 메모리 상주성을 보장하기위해 명령 버퍼를 제출할때 추가 오버 헤드가 발생할 수 있습니다. 이러한 문제들 때문에 세분 할당을 사용해야 하는 것입니다. 일반적인 불칸 메모리 할당 전략은 먼저 커다란 메모리를 (얼마나 메모리 요구사항이 동적인가에 따라 16MB 에서 256MB 까지) vkAllocateMemory()
함수를 사용하여 할당해 놓고 이 메모리 안에서 개발자 재량껏 각 자원을 위한 메모리를 세분 할당 하는 것입니다. 여기서 중요한것은 개발자는 세분 메모리 요청마다 적절한 메모리 정렬을 일일이 신경써야 한다는 것입니다. 그에 더해 버퍼와 이미지를 하나의 할당된 메모리안에서 같이 혼용할 경우 VkPhysicalDeviceLimits
구조체의 bufferImageGranularity
값을 이용해서 적절히 메모리 정렬을 추가적으로 해야합니다.
다시 말해 bufferImageGranularity
의 값이 1(바이트) 이상의 값이고 버퍼와 이미지 자원을 하나의 할당된 메모리안에서 세분 할당해서 사용할 계획이라면 연속된 세분 할당 사이에 적절히 패딩 바이트들을 삽입해야합니다. 이런 상황에 사용할 몇가지 해결책이 있습니다:
- 첫째는 이미지 자원의 경우 그 시작 주소와 크기를 결정할때
bufferImageGranularity
값과 요구된 정렬(required alignment)값 중 더 큰 값을 정렬값으로 항상 사용하는 비효율적이지만 비교적 간단한 해결책입니다. - 둘째는 메모리 관리자가 세분 할당 하나하나를 추적 및 관리 하여 세분 할당하려는 자원의 이전 또는 다음 자원과 형식이 불일치 할 때만 필요한 패딩 바이트를 삽입하게 하는 해결책으로 비교적 복잡한 할당 알고리즘이 요구 됩니다.
- 셋째는 이미지와 버퍼는 서로 독립된 할당된 메모리를 사용하게 하는것으로 이로서 전체적인 문제를 피할 수 있고 패딩 크기가 작아져서 내부 조각화를 완화할 수는 있지만 큰 할당된 메모리(256MB)를 사용할 경우 메모리가 많이 낭비되게 됩니다.
많은 GPU에서 이미지의 요구된 정렬값은 버퍼의 것보다 훨씬 크기 때문에 세번째 해결책이 매력적이긴 합니다. 추가 패딩으로인한 메모리 낭비를 최소화 할수 있고 이미지가 버퍼 다음에 위치할때 정렬 요구사항 때문에 생기는 내부 조각화도 줄여주니까요. VMA는 두번째 방법을 기본값으로 제공하며 세번째 방법도 원하면 사용이 가능합니다. VMA_POOL_CREATE_IGNORE_BUFFER_IMAGE_GRANULARITY_BIT
를 참고하세요.
전용 할당
불칸에서의 메모리 할당 전략은 세분 할당을 기본으로 하지만 때에 따라서는 자원과 메모리를 일대일 대응시키면 높은 효율성을 얻을 때도 있습니다. 이렇게 하면 특수한 상황에서 드라이버가 해당 자원을 더 빠른 메모리에 할당하게 유도할 수 있습니다.
이를 위해 불칸 버전 1.1 기본 내장된 확장 기능인 전용 할당을 사용하면 이것이 특정 자원 전용이라는 사실을 알리는게 가능합니다. 이것이 언제 필요한지 알기위해서는 vkGetImageMemoryRequirements2KHR()
함수나 vkGetBufferMemoryRequirements2KHR()
함수를 호출하고 결과로서 VkMemoryDedicatedRequirementsKHR
구조체의 requiresDedicatedAllocation
값이나 prefersDedicatedAllocation
값을 검사하면 됩니다. requiresDedicatedAllocation
값이 VK_TRUE
이면 아마도 다른 프로세스들과 공유되어야할 자원일 수 있습니다.
기기와 드라이버 특성에 따라 달라질 수 있지만 일반적으로 많은 양의 읽기/쓰기 대역폭이 필요한 대형 렌더 타겟의 경우 전용 할당을 통해 많은 성능 향상을 얻을 수 있습니다.
메모리 매핑
불칸에서는 메모리 객체로부터 메모리 포인터를 얻기위해 매핑을 사용하며 아래 두가지 패턴이 가능합니다:
- CPU가 메모리에 기록하려는 시점에 메모리 매핑을 하고 기록이 완료되면 바로 메모리 언맵을 수행합니다.
- 호스트에 가시적인 메모리를 할당하고 바로 메모리 매핑을 하며 얻어진 포인터를 다른곳에 저장하여 필요할때 마다 사용합니다. 메모리 언맵은 하지 않습니다.
두번째 선택지는 영구적인 매핑으로 알려져 있습니다. vkMapMemory()
함수는 일부 드라이버에서 호출 비용이 결코 적지 않으므로 일반적으로 더 나은 절충안입니다. 또한 할당된 메모리안에 존재하는 여러 자원들이 메모리 쓰기를 동시적으로 수행하려는 복잡한 상황에서도 맵/언맵에 대한 처리는 잊어버려도 되므로 코드를 단순화하는데도 도움이 됩니다.
유일한 단점은 AMD GPU에 존재하는 256MB크기의 호스트에서 접근 가능한 기기 지역메모리(이전에 '메모리 힙 선택하기'에서 소개함)에는 적용할 수 없다는 것입니다. 윈도우7 / AMD GPU 환경에서 이 형식의 메모리에 영구적인 매핑을 적용하면 WDDM에 의해 할당된 메모리가 시스템 메모리로 이동되어져 버립니다. 이와 같은 상황이라면 필요할때마다 맵/언맵을 수행하는 첫번째 방법이 적절할 것입니다.
설명자 집합
기존 API들의 슬롯 기반 바인딩 모델과는 달리 불칸은 자원들을 쉐이더(shader)에 넘겨주는 방식에 있어서 응용프로그램에게 더 많은 자유를 부여합니다. 자원들은 그룹화되어 설명자 집합의 형태로 쉐이더에서 사용되어집니다. 설명자 집합은 응용프로그램이 지정한 배치(layout) 정보 또한 가지고 있습니다. 쉐이더는 이런 설명자 집합 여러개를 동시에 독립적으로 연결해 사용할 수 있습니다. GPU가 설명자 집합을 사용하고 있는 동안에는 CPU가 해당 설명자 집합을 갱신하지 않게 조심해야하며 이것은 순전히 응용프로그램의 몪입니다. 또한 CPU의 갱신 비용과 GPU의 접근 비용간에 적절히 균형을 이루도록 설명자 집합의 배치 정보를 만드는 노력도 필요합니다. 덧붙이자면, 어떤 기존 렌더링 API도 불칸과 정확히 대응하는 자원묶는(binding) 방식을 가지고 있지 않으므로 다중 플랫폼을 위한 효율적인 불칸의 사용은 그만큼 큰 도전 과제라할 수 있습니다. 이후에 사용성과 성능에 다양하게 영향을 미치는 설명자 집합의 사용 방식들을 여러개 알아볼 것입니다.
정신 모델
설명자 집합을 본격적으로 다루기 전에 이것이 실제 기기내에서 어떻게 구현될 수 있는가에 대한 일종의 정신 모델을 갖추면 도움이 될 것입니다. 한가지 가능한 또한 실제 기대되는 설계중 하나는, 설명자 집합은 설명자 자료구조의 GPU 메모리 뭉치이며, 설명자 하나 하나는 GPU 메모리상에서 16에서 64바이트정도의 크기의 불투명한 자료구조로 표현되는 것입니다. 이 자료구조는 쉐이더가 해당 자원을 접근하는데 필요한 모든 인자값을 포함합니다. 이제 쉐이더가 일을 시작할때 CPU는 소수의 설명자 집합에 대한 포인터들만 지정 하면됩니다.
이것을 염두에두면 불칸 API는 어느정도 이 모델에 직접적으로 연관될 수 있습니다. 설명자 풀을 생성하는 것은 결국 지정된 최대 갯수의 설명자들을 포함할 수 있을만큼 큰 GPU 메모리 뭉치를 할당하는것으로 이해할 수 있습니다. 또한 설명자 풀에서 설명자 집합을 하나 할당한다는 것은 단순히 풀의 메모리 포인터를 적당히 증가시키는 것 만큼이나 간단해 집니다. 여기서 증가할 값은 요청한 개수와 VkDescriptorSetLayout
의 내용으로 얻을 수 있는 설명자 집합 하나당 크기를 곱하면 쉽게 얻을 수 있습니다. (주의할 것은 이렇게 구현된 설명자 풀은 설명자 각각을 독립적으로 해제하는 기능을 지원하지 않을 수 있으며, 오로지 vkResetDescriptorPool()
함수를 통한 풀 전체를 리셋하는 방식만을 제공할 가능성이 크다는 겁니다.) 이제 vkCmdBindDescriptorSets()
함수를 호출함으로써 설명자 집합 포인터에 해당하는 GPU 레지스터를 설정하게끔 명령 버퍼에 명령들을 추가합니다.
하지만 여기서 제시하는 모델은 동적 버퍼 옵셋기능이라든가 설명자 집합용 기기 자원의 개수가 제한되는 등의 여러 복잡한 문제들은 무시하였다는것은 염두해 두십시요. 또한 이것은 여러 구현중 가능성있는 하나의 예일 뿐입니다. 어떤 GPU들 에서는 덜 일반적인 설명자 집합 모델을 채택하며, 설명자 집합을 파이프라인에 연결할때 드라이버로 하여금 추가적인 처리 비용을 요구하기도 합니다. 하지만 적어도 이 모델은 설명자 집합의 할당 및 사용을 계획할때 유용한 지침이 될 것입니다.
동적인 설명자 집합 관리
위의 정신 모델에 따르면 설명자 집합을 GPU 가시적인 메모리로 단순화해 생각할 수 있습니다. 설명자 집합들을 여러 설명자 풀을 사용해 그룹짓고 GPU가 읽기를 마치기 전까지는 유지하도록 하는등의 일은 응용프로그램의 몫입니다.
이를 위해 잘 작동하는 방법이 하나 있는데 설명자 집합 풀의 자유 목록을 사용하는 것입니다. 설명자 집합 풀이 필요할때마다 자유 목록에서 하나를 꺼내서 현재 스레드의 현재 프레임에 사용될 설명자 집합을 순차적으로 할당하기위해 사용할 수 있습니다. 현재 풀에 여분의 설명자 집합이 남아있지 않을 경우 다른 풀을 자유 목록에서 꺼내서 쓰면 그만입니다. 현재 프레임에 사용되어진 모든 풀은 프레임이 그리기를 마칠때 까지는 유지 되어야합니다. 다시말해 관련된 펜스 객체에 의해 사용이 끝났다고 판단되면 vkResetDescriptorPool()
함수를 호출해서 풀을 리셋하고 다시 자유 목록에 삽입해서 재활용합니다. 비록 VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT
플래그를 적용해서 각각의 설명자 집합을 독립적으로 해제하는 기능이 지원되기는 하지만 이것은 드라이버 단에서의 메모리 관리를 복잡하게 만드므로 추천하지 않습니다.
설명자 집합 풀을 생성할때 최대 용량을 지정해야하며 그 이상 할당하면 오류입니다. 또한 설명자 각각에 대해서도 최대 개수를 지정하게 되어있고 그 이상 할당은 역시 허용되지 않습니다. 불칸 1.1 버전에서는 이것이 조금은 완화되었습니다. vkAllocateDescriptorSets()
함수를 호출할때 위의 상황이 발생하면 에러 코드를 확인하여 단지 새로운 풀에서 재시도 하면 그만입니다. 불행히도 불칸 1.0 버전에서 확장이 있지 않다면 최대 용량 이상으로 vkAllocateDescriptorSets()
함수를 호출하는 것은 런타임 오류입니다. 따라서 이때에는 응용프로그램이 풀에서 사용되는 설명자 집합과 설명자 각각의 사용에대해 추적 관리하여 언제 새로운 풀을 사용해야할지 알 수 있어야 합니다.
각각의 파이프라인 객체가 사용하는 설명자의 개수는 제각각일 것이고 이 때문에 풀 구성을 어떻게 해야할지 고민하도록 만듭니다. 가장 단순한 해결책이라면 가장 최악의 상황을 고려하여 모든 풀의 구성을 통일하는 것입니다. 예를들어 파이프라인당 최대로 사용하는 텍스처는 16개고 버퍼는 8개라면 모든 상황에서 maxSets=1024, texture poolSize=16*1024, buffer poolSize=8*1024 와 같이 풀을 구성합니다. 이것은 최소한 동작은 합니다만 실제로 사용하기에는 많은 메모리 낭비를 발생 시킵니다. 위와같은 구성이라면 1024개 이상의 설명자 집합을 할당할 수 없으므로 대부분의 파이프라인이 단지 4개의 텍스처만 사용하는 상황이라면 대략 75%의 텍스처 설명자용 메모리를 낭비하게 되는 것입니다.
좀더 균형있는 메모리 사용을 위한 대안 두가지를 설명합니다:
- 첫번째 방법은 특징적인 장면에서 사용되는 설명자의 평균적인 개수를 실제로 측정하여 풀의 크기를 결정하는데 활용하는 것입니다. 예를들어 주어진 장면에서 사용되는 설명자 집합 및 설명자의 개수를 알아봤더니 3000개의 설명자 집합, 13400개의 텍스처 설명자, 1700개의 버퍼 설명자가 사용되었다면 설명자 집합 한개당 평균적인 텍스처 설명자의 개수는 4.47개(반올림 해서 5) 버퍼 설명자의 개수는 0.57개(반올림 해서 1)가 됩니다. 따라서 합리적인 풀의 구성은 maxSets=1024, texture poolSize=5*1024, buffer poolSize=1024가 될 것입니다. 해당 형식(텍스처 또는 버퍼)의 설명자가 풀에서 다 할당되어 남은게 없다면 새로운 풀을 다시 할당하면 그만입니다. 따라서 이런 체계는 정상적인 동작을 항상 보장하며 또한 평균적으로 합리적인 효율성도 보장합니다.
- 두번째 방법은 쉐이더 파이프라인 객체를 크기 특성별로 그룹화 하는 것입니다. 설명자의 공통된 사용 패턴에 기반하여 크기 특성의 근사값을 얻을 수 있습니다. 그리고 이 크기 특성을 기반으로 설명자 집합 풀을 선택하면 됩니다. 이것은 첫번째 방법에서 하나 이상의 크기 특성을 사용하도록 확장한 것입니다. 예를 들어 일반적으로 하나의 장면을 그릴때 다수의 그림자/깊이용 그리기 호출과 다수의 일반 그리기 호출이 사용됩니다. 이 두가지 상황에서 요구되는 설명자 개수는 많이 다를 것입니다. 그림자용 그리기 호출에서 일반적으로 1개 이하의 텍스처(설명자)가 사용되며 동적 버퍼 옵셋을 적용한다면 1개 이하의 버퍼(설명자)가 사용됩니다. 메모리 사용을 최적화 하려면 그림자/깊이용과 그외 그리기 호출용 설명자 집합 풀을 분리하는게 더 적합합니다. 주어진 응용프로그램에 최적화된 크기 특성을 가질 수 있는 범용 할당자가 있겠지만, 이 방법을 사용하면 더 낮은 수준의 설명자 집합 관리 계층에서도 동일한 효과를 얻을 수 있겠습니다.
적절한 설명자 형식을 선택하기
불칸에서 쉐이더에서 접근하는 자원은 기존보다 더 구체적인 형식으로 존재합니다. 그 중에서 가장 알맞은 설명자 형식을 선택하는 것은 응용프로그램의 몫입니다.
버퍼의 경우 유니폼 버퍼와 스토리지 버퍼중 하나를 선택해야하고 또한 동적 옵셋을 사용할지 말지를 선택해야합니다. 유니폼 버퍼에서는 최대 주소 범위에 제한이 존재합니다. 데스크탑 기기에서는 최대 64KB 까지 지원합니다만 일부 모바일 기기용 GPU들 에서는 16KB의 크기만 지원합니다. 이것은 기술 사양에 명시된 최소값이기도 합니다. 버퍼 자원의 메모리 크기는 이보다 커도 되지만 쉐이더가 하나의 설명자를 통해 자료를 접근할 때는 언급된 제한된 범위 만큼만 접근할 수 있습니다.
어떤 기기에서는 유니폼 버퍼와 스토리지 버퍼사이에 접근 속도 차이가 없지만 다른 종류의 기기에서는 접근 패턴에 따라 유니폼 버퍼가 훨씬 더 빠를 수 있습니다. 작거나 중간크기의 자료를 다루면서 접근 패턴이 고정적이라면 유니폼 버퍼를 사용하십시요. 예를들어 재질용 버퍼라든가 장면 상수 자료가 여기에 해당됩니다. 유니폼 버퍼의 제약보다 더 큰 자료 배열이 필요하고 쉐이더에서 인덱스 값에의해 동적으로 접근하는 경우에 스토리지 버퍼를 사용하십시요.
텍스처의 경우 필터링이 필요하다면 결합된 이미지/샘플러 설명자(OpenGL의 경우처럼 텍스처와 필터링/주소 지정(addressing) 정보가 결합된 형태)와 이미지와 샘플러가 분리된 설명자(Direct3D 11의 경우 처럼) 그리고 불변(immutable) 샘플러 설명자(샘플러 속성은 파이프라인을 생성할 때 정해짐)를 가지는 이미지 설명자 이렇게 셋중 하나를 선택하면 됩니다.
이 세개의 상대적 성능은 사용 패턴에 상당히 종속적이지만, 일반적으로 불변 설명자가 Direct3D 12와 같은 최신 API들이 권장하는 사용 모델과 잘 일치합니다. 그리고 드라이버가 쉐이더를 최적화할때 좀더 도움이 되는 방식이기도 합니다. 하지만 이것을 지원하려면 텍스처 스트리밍을 위해 페이드인 기능을 구현할때처럼 텍스처당 LOD 바이어스를 쉐이더 ALU 명령을 사용하도록 하는등의 동적인 부분에 대한 처리를 위해 기존 렌더러 설계를 일부 변경해야 할 수도 있습니다.
슬롯 기반 바인딩
불칸 바인딩 모델의 대안으로서 보다 단순한 메탈이나 Direct3D 11의 모델이 있습니다. 이런 방식에서 응용프로그램은 자원을 슬롯에 연결하며 런타임이나 드라이버는 내부적으로 설명자 메모리와 설명자 집합 인자값들을 관리하게됩니다. 이 모델은 불칸의 설명자 집합을 기반으로 구현이 가능합니다. 최적의 결과는 아니겠지만 기존 렌더러를 불칸에 이식하고자 할때 처음으로 시도해 볼만하고 제대로 구현만 한다면 놀라울 정도로 효율적일 수 있습니다.
이 모델을 작동시키려면 응용프로그램은 얼마나 많은 자원 이름 공간이 필요한지 결정해야하고 어떻게 불칸의 설명자 집합 / 슬롯 색인과 연관시킬지 고려해야합니다. 예를들어 메탈의 각 단계(VS, FS, CS)는 각각 세개의 자원 이름 공간을 제공합니다. 그것들은 텍스처, 버퍼, 샘플러이고 유니폼 버퍼와 스토리지 버퍼의 구별은 없습니다. Direct3D 11에서는 이름 공간이 좀더 복잡합니다. 읽기 전용 구조적 버퍼는 텍스처와 이름 공간을 공유합니다. 그러나 비순차적 접근에 사용되는 텍스처와 버퍼는 별도 이름 공간을 가집니다.
불칸 기술 상세는 전체 파이프라인(모든 단계들)에 걸쳐 4개의 설명자 집합만을 보장합니다. 이런 제약때문에 모든 파이프라인 단계가 같은 구성의 자원 바인딩을 공유하게 하는게 편리합니다. 예를 들어 텍스처 슬롯 3번은 파이프라인 단계와 상관없이 모두 같은 텍스처 자원을 보도록 만듭니다. 그리고 집합 0은 버퍼만, 집합 1은 텍스처만, 집합 2는 샘플러만 제공하게 만듭니다. 대안으로 응용프로그램은 단계당3 하나씩의 설명자 집합을 사용하고 정적 색인 리매핑(예를들어 0-16 슬롯은 텍스처, 17-24 슬롯은 유니폼 버퍼, 기타등등)을 적용하는 방식도 고려할 수 있지만 이렇게 하면 아주 많은 설명자 집합 메모리가 사용되야하므로 추천하지는 않습니다. 마지막으로 최적화된 크기를 가지는 동적 슬롯 리매핑을 각 쉐이더 단계에 사용하는 방법이 있습니다. 예를 들어 만약 정점(vertex) 쉐이더가 텍스처 슬롯 0, 4, 5번을 사용하면 이것을 불칸 설명자 집합 0번의 설명자 색인 0, 1, 2에 매핑하고 런타임에 응용프로그램은 이 재매핑 테이블을 가지고 적절한 텍스처 정보를 추출합니다.
모든 경우에 있어 텍스처를 슬롯에 설정하는 구현은 어떤 불칸 명령도 실행하지 않으며 단지 그림자 상태값을 갱신할 뿐입니다. 그리기 호출하기 바로전에 또는 디스패치 전에 설명자 집합을 적절한 설명자 집합 풀로부터 할당하고 이것을 새로운 설명자들로 덮어씌운후 모든 설명자 집합을 vkCmdBindDescriptorSets()
함수를 통해 바인드하면 마무리 됩니다. 하지만 설명자 집합이 5개의 자원을 가지고있고 마지막 그리기 호출 이후로 그 중 단지 한개만 변경되었더라도 새로운 설명자 세트를 할당하고 5개 자원을 위해 모두 다시 갱신해야 한다는 단점이 있습니다.
이런 상황에서 좋은 성능을 원한다면 다음 몇가지 지침을 따르십시요:
- 변경사항이 없다면 불필요하게 설명자 집합을 할당하거나 갱신하지 마십시요. 모든 단계가 슬롯을 공유하는 모델일때 연속된 그리기 호출 사이에 텍스처가 설정되지 않았다면 새로운 설명자 집합을 할당하거나 텍스처 설명자를 갱신할 필요가 없습니다.
- 될 수 있으면
vkAllocateDescriptorSets()
함수 호출을 일괄 처리 하십시요. 특정 드라이버에서 이 함수 호출은 측정가능한 오버헤드를 가집니다. 따라서 여러개의 설명자 집합들을 갱신할 필요가 있을때 이것들을 한번의 함수 호출로 할당하는것이 더 빠릅니다. - 설명자 집합을 갱신할때
vkUpdateDescriptorSets()
함수에 설명자 쓰기 배열을 사용하거나 불칸 1.1 버전의vkUpdateDescriptorSetWithTemplate()
함수를 사용하십시요. 동적 설명자 관리시vkUpdateDescriptorSets()
함수의 설명자 복사 기능을 사용해서 이전에 할당된 배열에서부터 대부분의 설명자들을 복사하고 싶을 수도 있지만 일부 드라이버는 쓰기 결합 메모리에서 설명자를 할당하며 이 경우 느려질 수 있습니다. 설명자 템플릿은 갱신을 위한 응용프로그램의 많은 작업을 줄여줍니다. 응용프로그램이 관리하는 그림자 상태로부터 설명자 정보를 읽어오도록 한 설계에서 설명자 템플릿은 드라이버에게 이 그림자 상태의 배치 정보를 알려줄 수 있고 특정 드라이버에서는 갱신 작업을 상당히 빠르게 만들어줄 수 있습니다. - 마지막으로 유니폼 버퍼 설명자를 갱신하기위해 동적인 유니폼 버퍼를 선호하십시요. 동적 유니폼 버퍼는 버퍼상의 옵셋값을 지정할 수 있게 해줍니다. 별도의 설명자를 갱신하거나 할당할 필요없이
vkCmdBindDescriptorSets()
함수의pDynamicOffsets
인자값만 설정하면 됩니다. 이것은 동적 상수 관리에 잘 작동하며 그리기 호출을 위한 상수값들은 아주 큰 유니폼 버퍼에 포함됩니다. 이것은 CPU 오버헤드를 상당히 줄여주고 GPU입장에서도 더 효율적입니다. 특정 GPU들에서는 드라이버의 추가적인 오버헤드를 줄이기위해 동적 버퍼의 개수를 최소한으로 유지할 필요가 있으며 하나 내지 두개의 동적 유니폼 버퍼는 모든 아키텍처에서 잘 작동할 것입니다.
일반적으로 위에 언급한 접근법들은 성능면에서는 상당히 효율적입니다. 아래에 언급 예정인 좀더 정적인 설명자 집합 방식보다는 덜 효율적입니다만 잘만 구현된다면 기존의 낡은 API방식만큼의 성능은 가능합니다. 일부 드라이버에서는 불행히도 할당과 갱신의 과정이 아주 빠르지는 않습니다. 일부 모바일 기기에서는 같은 프레임에서 재사용될 수 있는 설명자 집합이 포함된 설명자에 기반하여 캐시하여 사용할것을 고려해 보십시요.
사용 빈도수에 기반한 설명자 집합
슬롯 기반 자원 바인딩 모델은 간단하고 익숙하기는 하지만 최적의 성능을 바랄수는 없습니다. 어떤 모바일 기기는 다중 설명자 집합을 지원하지 않을 수도 있습니다. 하지만 일반적으로 불칸 API와 드라이버는 응용프로그램이 설명자 집합을 변경 빈도수에 기반하여 관리하기를 기대합니다.
좀더 불칸 중심적인 렌더러에서는 쉐이더가 접근해야하는 자료들을 변경 빈도수에 기반하여 그룹화할 것입니다. 그리고 집합=0은 가끔 변경되고 집합=3은 자주 변경되는 것처럼 빈도수에 대응하는 설명자 집합을 사용합니다. 예를들어 일반적인 구성은 아래와 같습니다:
- 집합=0 설명자 집합은 전역 유니폼 버퍼, 프레임당 또는 뷰당 데이타, 그림자 맵처럼 전역에서 사용하는 텍스처들을 포함합니다.
- 집합=1 설명자 집합은 각 재질을 위한 유니폼 버퍼와 텍스처 설명자를 포함합니다. 반사도(albedo) 맵이나 프레넬 계수등이 여기에 해당합니다.
- 집합=2 설명자 집합은 그리기 호출에 사용되는 동적인 유니폼 버퍼를 포함합니다. 세계 변환 배열등이 포함됩니다.
집합=0의 경우 프레임당 몇번 정도만 변경하면 적당합니다. 이전 장에서와 비슷하게 동적인 할당 체계를 사용하는것으로 충분합니다.
집합=1의 경우 모든 물체에 걸쳐 재질은 한번 설정되며 프레임이 변경되도 유지됩니다. 게임 진행상 재질이 변경되는 경우에만 할당 및 갱신이 이루어 집니다.
집합=2의 경우 자료는 완전히 동적입니다. 동적 유니폼 버퍼의 사용으로 인해 설명자 집합을 할당하고 갱신하는일은 거의 안 일어납니다. 동적인 상수들은 커다란 프레임당 버퍼에 업로드됩니다. 대부분의 그리기 호출을 할때 버퍼를 상수 자료로 갱신하고 vkCmdBindDescriptorSets()
함수에 새로운 옵셋을 지정하여 호출하면 됩니다.
파이프라인 객체간의 호환성 규칙으로 인해 재질이 변경될때 대부분의 경우 집합 1과 집합 2만 바인딩하면 됩니다. 그리고 이전 그리기 호출과 재질이 동일하다면 집합 2만 설명하면 됩니다. 결과적으로 그리기 호출당 한번의 vkCmdBindDescriptorSets()
함수 호출만 하게 됩니다.
복잡한 렌더러에서는 쉐이더 마다 각자의 배치(layout)정보를 사용합니다. 예를들어 모든 쉐이더가 여러 재질 자료를 위해 하나로 통일된 배치 정보를 사용하지는 않습니다. 드물게 프레임 구조상 세개 이상의 집합을 사용하는게 더 어울릴때가 있습니다. 추가적으로 불칸의 유연성을 고려할때 장면의 그리기 호출들을 위해 통일된 자원 바인딩 체계를 사용할 필요는 없습니다. 예를들어 후처리를위한 연쇄적인 그리기 호출에서는 텍스처와 상수 자료가 그리기 호출마다 급격하게 변경되는 등 고도로 동적입니다. 일부 렌더러는 초기에 이전 장에 소개된 동적 슬롯 기반 모델을 사용하여 구현되고, 이후 추가적으로 사용 빈도수 기반을 사용하여 세계를 렌더링합니다. 렌더링 파이프라인의 좀더 동적인 부분에서는 슬롯 기반 모델의 단순성을 유지하면서 성능이 필요한곳에만 빈도수 기반 모델을 적용합니다.
위에 언급된 체계는 대부분의 경우에 그리기 호출당 자료는 푸시 상수로 효율적으로 설정되기에는 너무 큰 경우만을 가정합니다. 푸시 상수는 설명자 집합을 재바인딩이나 갱신하지 않고도 설정이 가능합니다. 그리기 호출당 128바이트라는 제약이 존재하지만 그리기 호출마다 4x3 행렬 지정을 위해 사용하기에 알맞은 크기로 보입니다. 그렇지만 일부 아키텍처에서는 재빠르게 설정할 수 있는 실제 사용가능한 상수의 개수가 설명자의 구성에 영향을 받으며 12 바이트 정도가 실제로 사용할 수 있는 크기일 겁니다. 이 크기 제한을 넘어가면 드라이버는 푸시 상수들을 드라이버가 관리하는 링버퍼로 이동시킵니다. 이것은 응용프로그램이 상수 자료를 위해 동적 유니폼 버퍼를 사용하는것보다 고비용입니다. 제한적으로 푸시 상수들을 사용하는것은 일부 설계의 경우 여전히 좋은 생각이지만, 다음장에 소개할 완전히 바인딩이 필요없는 체계에서 활용하는게 더 나은 방법입니다.
바인딩없는(bindless) 설명자 설계
사용 빈도에 기반한 설명자 집합은 설명자 집합 바인딩 오버헤드를 줄여줍니다. 그렇지만 여전히 그리기 호출당 하나 내지 두개의 설명자 집합을 바인딩해야 합니다. 재질 설명자 집합을 유지하려면 재질 인자값이 변경될 때마다 GPU 가시적인 설명자 집합을 갱신해주는 관리 계층이 필요합니다. 추가적으로 텍스처 설명자는 재질 자료에 캐싱되므로 전역 텍스처 스트리밍 기능을 구현하는데 장애요인이 됩니다. 텍스처의 밉맵 레벨이 스트림으로 적재되고 폐기될때 마다 해당 텍스처를 참고하는 모든 재질 또한 갱신해야합니다. 이로인해 재질시스템과 텍스처 스트리밍 시스템간의 복잡한 상호작용이 필요해집니다. 텍스처가 조정될때마다 약간의 오버헤드를 감수해야하며 일부 빈도수 기반 체계의 이점을 상쇄시킵니다. 마지막으로 그리기 호출마다 설명자 집합을 설정해야 하기때문에 GPU에 기반한 컬링이나 명령 제출같은 체계를 적용하기 어렵게 만듭니다.
세계 렌더링에 걸쳐 바이딩 호출수를 작게 고정할 수 있는 이른바 바인딩없는 설계가 가능합니다. 이것은 재질로부터 텍스처 설명자를 분리시키게 되며, 텍스처 스트리밍 기능을 구현하기 쉽게 만들고 GPU 기반 제출을 용이하게 합니다. 이전의 체계와 마찬가지로, 후처리와같이 유연성이 중요하고 그리기 호출수는 상대적으로 작은 상황에서는 임시방편으로 동적 설명자 갱신과 함께 사용될 수 있습니다.
바인드없는 방식을 완전히 활용하려면 불칸 핵심기능만으로는 부족할 수도 있습니다. 일부 바인드없는 구현들은 설명자 집합을 갱신후 다시 바인딩하지 않아야 할 수 있습니다. 이것은 불칸 1.0이나 1.1 버전의 핵심기능에서는 지원하지 않습니다. 하지만 불칸 1.2 버전의 VK_EXT_descriptor_indexing
확장기능에서는 가능합니다. 그러나 아래 설명할 기본적인 설계에서 설명자 집합의 제한이 충분히 높다면 이 확장은 필요없습니다. 배열이 GPU에의해서 수시로 사용될 것이므로 텍스처 설명자 배열의 더블 버퍼링을 필요로합니다.
사용빈도에 기반한 설계와 마찬가지로 쉐이더 데이타를 전역 유니폼과 텍스처용(집합 0)과 재질 데이타 그리고 그리기 호출당 데이타로 구분할 것입니다. 전역 유니폼 및 텍스처는 이전 장에서 설명한 방식대로 설명자 집합을 통해 설정합니다.
재질당 데이타를 위해 텍스처 설명자들을 하나의 큰 텍스처 설명자 배열로 통합시킵니다 (이것은 텍스처 배열과는 다른 개념입니다. 텍스처 배열은 하나의 설명자를 사용하고 모든 텍스처의 크기와 형식이 같아야합니다. 설명자 배열은 이런 제약이 없고 임의의 텍스처 설명자들을 배열의 요소로서 가질 수 있습니다. 심지어 텍스처 배열 설명자조차 배열의 요소가 될 수 있습니다.) 재질 데이타의 각 재질은 텍스처 설명자 대신 이 텍스처 설명자 배열의 색인을 가지게됩니다. 이 색인은 재질 상수와 더불어 재질 데이타의 구성원이 됩니다.
장면의 모든 재질들의 모든 재질 상수는 하나의 커다란 스토리지 버퍼에 상주합니다. 이 체계에서 여러개의 재질 형식을 지원하는게 가능은 하지만 여기서는 단순함을위해 모든 재질들을 위한 하나의 형식만을 사용하겠습니다. 재질 자료 구조의 예는 아래와 같습니다:
struct MaterialData
{
vec4 albedoTint;
float tilingX;
float tilingY;
float reflectance;
float unused0; // pad to vec4
uint albedoTexture;
uint normalTexture;
uint roughnessTexture;
uint unused1; // pad to vec4
};
비슷하게 장면의 모든 물체를 위해 모든 그리기 호출당 상수들은 또 다른 커다란 스토리지 버퍼에 상주합니다. 단순성을 위해 모든 그리기 호출당 상수들이 같은 구조를 가진다고 가정합니다. 이와 같은 체계에서 스키닝 물체를 지원하려면 이 변환 데이타를 추출해서 제삼의 스토리지 버퍼에 넣어야 합니다:
struct TransformData
{
vec4 transform[3];
};
정점 자료의 지정은 지금까지 우리가 무시해 왔습니다. 불칸은 vkCmdBindVertexBuffers()
함수를 통해 정점 자료를 지정하는 최상위 방법을 제공합니다만 정점 버퍼를 그리기 호출당 바인딩하는것은 바인딩 없는 설계에서는 정상 동작이 어려울 수 있습니다. 추가적으로 일부 기기는 정점 버퍼를 최상위 객체로서 대접하지 않습니다. 이 경우 드라이버는 정점 버퍼를 바인딩하기위해 일종의 에뮬레이션을 사용하며 이때 vkCmdBindVertexBuffers()
함수를 사용하면 CPU단에서의 성능 저하를 가져오기도 합니다. 완전히 바인딩없는 설계에서 모든 정점 버퍼들은 하나의 큰 버퍼에 세분 할당된 상태로 존재한다고 가정합니다. 그리고 그리기 호출당 정점 옵셋을 사용하거나 (vkCmdDrawIndexed()
함수의 vertexOffset
인자값) 쉐이더에 이 버퍼상의 색인을 전달하여 쉐이더가 이 커다란 버퍼에서 정점 자료를 가져오도록 합니다. 두 가지 방법 모두 잘 작동하며 GPU에 따라 약간 더 효율적이나 약간 덜 효율적일 수 있습니다. 여기서는 쉐이더를 사용한 정점 가져오기 방식을 사용할 것입니다.
따라서 각 그리기 호출때 쉐이더에 세가지 정수값을 전달 해야합니다:
- 첫째로 재질 색인을 전달하며 재질용 스토리지 버퍼에서 재질을 가져오는데 사용합니다. 이제 재질 자료의 색인을 사용해 텍스처를 설명자 배열에서 얻어올 수 있습니다.
- 둘째로 변환 자료 색인이며 변환용 스토리지 버퍼에서 변환 자료를 얻어오는데 사용합니다.
- 셋째로 정점 자료 옵셋값이며 정점 스토리지 버퍼에서 정점 속성값을 얻어오기위해 사용합니다.
그리기 자료를 통해 이런 인자값과 추가적인 자료를 지정합니다:
struct DrawData
{
uint materialIndex;
uint transformOffset;
uint vertexOffset;
uint unused0; // vec4 패딩
// ... 추가적인 게임 동작용 자료들 추가
};
쉐이더는 MaterialData
, TransformData
, DrawData
가 있는 스토리지 버퍼 및 정점 자료가 있는 스토리지 버퍼에 접근해야합니다. 이것들은 모두 전역 설명자 집합을 통해 쉐이더에 바인딩됩니다. 마지막으로 한가지 남은 정보는 그리기 자료 색인이며 이것은 푸시 상수로 전달할 것입니다.
이와 같은 체계에서 재질과 그리기 호출에서 사용되는 스토리지 버퍼들을 매 프레임 갱신해야하고 전역 설명자 집합에 바인딩 해야합니다. 추가적으로 색인 자료를 vkCmdBindIndexBuffer()
함수를 통해 바인딩해야하며 이것 또한 커다란 하나의 인덱스 버퍼에 저장합니다. 전역적인 설정이 마무리된 후 각 그리기 호출시 사용하는 쉐이더가 다르다면 vkCmdBindPipeline()
함수를 호출하여야 합니다. 그리고 vkCmdPushConstants()
함수를 호출하여 그리기 자료 버퍼4 를 위한 색인을 지정합니다. vkCmdDrawIndexed()
함수를 호출하여 그리기 호출을 마무리하면 끝입니다.
GPU 중심적인 설계에서 vkCmdDrawIndirect()
함수나 vkCmdDrawIndirectCountKHR()
함수를 (불칸 1.2 버전에서는 핵심기능으로 진급한 KHR_draw_indirect_count
확장에서 제공) 사용하고 그리기 호출당 상수는 gl_DrawIDARB
를 (KHR_shader_draw_parameters
확장에서 제공) 통해 색인등을 푸시 상수 대신 얻어 오는게 가능합니다. 한가지 주의할 점은 GPU 기반 제출에서는 파이프라인 객체에 기반하여 그리기 호출을 그룹지어야 한다는 것입니다. 그렇지 않으면 달리 파이프라인을 전환할 방법이 없기 때문입니다.
이런점을 고려하여 정점을 변환하는 정점 쉐이더 코드는 다음과 같습니다:
DrawData dd = drawData[gl_DrawIDARB];
TransformData td = transformData[dd.transformOffset];
vec4 positionLocal = vec4(positionData[gl_VertexIndex + dd.vertexOffset], 1.0);
vec3 positionWorld = mat4x3(td.transform[0], td.transform[1], td.transform[2]) * positionLocal;
재질 텍스처를 샘플링하는 조각(fragment) 쉐이더는 대략 아래와 같습니다:
DrawData dd = drawData[drawId];
MaterialData md = materialData[dd.materialIndex];
vec4 albedo = texture(sampler2D(materialTextures[md.albedoTexture], albedoSampler), uv * vec2(md.tilingX, md.tilingY));
이 체계는 CPU측 오버헤드를 최소화합니다. 당연히 이것은 기본적으로 다양한 요소간의 균형의 결과입니다:
- 이 체계는 다중의 재질/그리기/정점 자료 형식으로 확장될 수 있지만 관리는 그만큼 복잡해집니다.
- 유니폼 버퍼 대신 스토리지 버퍼만을 독점적으로 사용하는것은 일부 아키텍처에서는 GPU시간을 증가시킬 수 있습니다.
- 재질 색인으로 재질을 가져오고 재질에서 다시 색인을 가져와 텍스처 설명자 배열에서 텍스처를 얻어오는 과정은 다른 대안 설계에 비해 GPU의 간접적 접근 비용이 많이 발생 할 수 있습니다.
- 일부 기기에서는 다양한 설명자 집합에 대한 제약사항이 이 기술의 구현을 비실용적으로 만들 수 있습니다. 쉐이더에서 동적으로 임의의 텍스처를 색인 접근하기 위해서는
maxPerStageDescriptorSampledImages
값이 모든 재질 텍스처들을 수용할 만큼 커야합니다. 많은 데스크탑 드라이버는 충분히 큰 값을 가지지만 기술 상세는 단지 16개만을 보장합니다. 따라서 바인딩없는 설계는 여전히 일부 하드웨어에서는 사용할 수 없는 기술입니다.
렌더러들이 점점 복잡해짐에따라 바인드없는 설계는 점점 많아질 것이고 결국 더욱 더 많은 부분을 GPU상에서 구현하게 될 것입니다. 기기 제약때문에 이 설계는 모든 불칸 호환 기기에서 실용적인건 아니지만 미래의 기기를 위한 새로운 렌더링 경로를 설계할때 충분히 고려할 가치가 있습니다.
명령 버퍼 기록 및 제출
낡은 API들에서는 GPU 명령들을 위한 단 하나의 시간축만 존재합니다. 일반적으로 기록하는 스레드는 한개이므로 CPU에서 실행된 명령들은 GPU에서 동일한 순서로 실행됩니다. 그리고 언제 CPU가 명령들을 GPU에게 제출하는지에 대한 세밀한 제어방법이 없습니다. 드라이버는 명령 스트림에의해 사용되어지는 메모리와 제출 시기를 최적으로 관리해야할 책임을 가집니다.
반면에 불칸에서 명령 버퍼 메모리와 명령을 다중 스레드에서 여러 명령 버퍼에 기록하는것, 이것을 적절한 입도(granularity)를 사용해 제출하는것은 모두 응용프로그램의 몫입니다. 잘 작성된 불칸 코드는 단일 코어로 동작하더라도 기존 API들에 비해 훨씬 더 빠르게 동작하긴 하지만 최고의 효율과 최소의 지연 시간을 얻기위해서는 명령 기록을 위해 시스템상의 많은 코어를 모두 활용할 필요가 있습니다. 다만 조심스러운 메모리 관리가 요구됩니다.
정신 모델
설명자 집합과 비슷하게 명령 버퍼는 명령풀로부터 할당합니다. 사용시 비용과 숨은 영향등을 추론하려면 드라이버가 이것을 어떻게 구현하는가를 이해하는것이 가치가 있을것입니다.
명령풀은 CPU에 의해 채워지고 GPU의 명령 처리기에의해 바로 읽혀 처리되는 명령들을 위한 메모리를 관리합니다. 명령들을 위해 필요한 메모리 용량은 정적으로 결정할 수 없습니다. 따라서 풀의 일반적인 구현에는 고정크기 페이지들의 자유 리스트가 주로 사용됩니다. 명령 버퍼는 여러개의 페이지 리스트로 구성되기도 하므로 페이지간에 제어를 이동시키는 특수한 점프 명령을두어 GPU가 페이지간의 명령들을 순차적으로 실행가능하게 해줍니다. 명령버퍼에 명령을 할당할때마다 현재 페이지에 엔코딩되어 기록됩니다. 현재 페이지의 여유공간이 바닥나면 드라이버는 새로운 페이지를 연동된 풀의 자유 리스트에서 할당하고 새로운 페이지로의 점프 명령을 현재 페이지에 기록합니다. 이렇게 해서 여러 페이지에 걸쳐 기록된 명령들을 GPU가 순차적으로 실행하게 해줍니다.
각 명령 풀은 동시에 한 스레드에서만 접근가능하므로 위의 연산들은 스레드 안전5을 고려하지 않습니다. vkFreeCommandBuffers()
함수 호출로 명령 버퍼를 해제하면 해당 명령 버퍼가 사용했던 페이지들은 다시 자유 리스트에 되돌려집니다. 명령풀을 리셋하면 풀에서 할당된 모든 명령 버퍼가 사용중인 모든 페이지들을 자유 리스트로 되돌립니다. 함수 호출시 VK_COMMAND_POOL_RESET_RELEASE_RESOURCES_BIT
플래그를 사용하면 페이지들은 시스템으로 되돌려져 다른 풀들이 재사용할 수 있게 해줍니다.
주의할점은 vkFreeCommandBuffers()
함수의 호출이 항상 메모리를 풀에 반환하도록 보장하지 않는다는 것입니다. 또다른 구현에서는 여러 명령 버퍼가 하나의 큰 페이지들에 포함되어 vkFreeCommandBuffers()
함수가 메모리를 재사용하는데 걸림돌로 작용하기도 합니다. 사실상 한 모바일 공급업체의경우 풀이 VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT
를 통해 생성되지 않았다면 미래의 명령 기록을위해 메모리를 재사용하려면 vkResetCommandPool()
함수의 호출이 필요할 수 있습니다.
다중 스레드 명령 기록
명령풀 사용시 두가지 중요한 제한사항은 다음과 같습니다:
- 하나의 풀에서 할당한 여러 명령 버퍼들은 여러 스레드가 동시에 독립적으로 접근하지 못할 수 있습니다.
- GPU가 아직 관련 명령들을 실행중이라면 명령 버퍼와 풀은 해제되거나 재설정될 수 없습니다.
이렇기 때문에 일반적인 다중 스레딩 구성을 위해서는 명령 버퍼 풀들의 묶음 구성이 필요합니다. 한 묶음은 F*T 만큼의 풀이 필요합니다. F는 프레임 큐의 길이이고 일반적으로 2 (하나가 CPU에 의해 기록될 동안 다른 하나는 GPU에의해 실행됨) 거나 3입니다. T는 명령을 동시적으로 기록할 스레드의 개수입니다. 이것은 시스템의 코어 개수만큼 클 수 있습니다. 스레드에서 명령을 기록할때 스레드는 현재 프레임 및 스레드와 연관된 풀로부터 명령 버퍼를 할당하여 사용합니다. 명령 퍼버가 연속된 프레임간에 명령을 기록하지 않는다는것을 가정하고 이전 프레임이 실행이 끝나기를 기다려서 프레임 큐 길이가 보장되면 해당 프레임을 위한 명령 버퍼들을 비로소 해제할 수 있습니다. 이제 관련 명령 풀도 해제가 가능합니다.
추가적으로 명령 버퍼들을 해제하는 대신 vkResetCommandPool()
함수를 호출한다음 이것들을 재사용하는게 가능합니다. 이렇게하면 명령 버퍼들을 재할당할 필요가 없어집니다. 이론적으로 명령 버퍼를 할당하는것은 저비용 연산이지만 일부 드라이버 구현에서 측정가능한 상당한 오버헤드가 명령 버퍼 할당시 발생할 수 있습니다. 이것은 또한 드라이버가 명령 메모리를 시스템에 돌려줄 필요를 없애주므로 명령을 제출하는 작업을 좀더 쉽게해줍니다.
프레임 구조에 따라 위와같은 설정은 스레드간의 메모리 소비의 불균형을 초래하기도 합니다. 예를 들어 그림자 그리기 호출들은 더 적은 설정과 더 적은 명령 메모리를 사용합니다. 여러 작업 스케쥴러가 생산하는 작업량이 여러 스레드들간에 적절히 무작위로 분산되는점과 같이 조합되면 모든 명령 풀은 최악의 소비 패턴에 맞게 크기가 평준화 될 수 있습니다. 이것은 응용프로그램이 메모리에 제약이 있는경우 문제가 될수 있습니다. 각 패스의 동시성을 제약하고 기록되고있는 패스를 기반으로 명령 버퍼와 풀을 선택한다면 이런 낭비를 줄일 수 있습니다.
이것을 위해서 명령 버퍼 관리자에 크기 특성의 개념을 추가할 필요가 있습니다. 위에 제안한 방식처럼 할당된 명령 버퍼를 수동으로 재사용하거나 스레드당 명령 풀을 사용하는것과 더불어 자유 리스트를 크기 특성별로 관리할 수 있습니다. 그리기 호출 수에 기반하여 (100이하, 100-400, 등) 크기 특성을 정의하거나(또는 동시에) 각 그리기 호출의 복잡도에 (오직 깊이버퍼 사용, gbuffer) 기반하여 정의할 수도 있습니다. 버퍼를 사용예에 기반해 선택하면 더 안정적인 메모리 소비를 이뤄낼수 있습니다. 덧붙이자면 아주 작은 크기의 패스들은 이것들을 기록할때 동시성을 줄이는게 좋습니다. 예를들어 하나의 패스가 100이하의 그리기 호출을 사용한다면 이것을 4개의 코어 시스템에서 4개의 기록 작업으로 분리하기 보다는 하나의 작업 스레드만 사용하십시요. 이렇게 해야 명령 메모리 관리와 명령 버퍼 제출을 위한 오버헤드를 줄일 수 있습니다.
명령 버퍼 제출
효율성을 위해서는 다중 스레드에서 다수의 명령 버퍼를 기록하는것이 중요하지만 명령 버퍼의 크기는 GPU가 명령 처리중 쉬지않을 만큼의 큰 크기를 가져야합니다. 상태값은 명령 버퍼간에 재사용되지 않고 또한 여타 스케쥴링 제약이 있기때문입니다. 또한 제출 하나 하나마다 CPU와 GPU 양단 모두 약간씩의 오버헤드가 있습니다. 일반적으로 불칸 응용프로그램은 프레임당 10개 미만의 제출을 (각각의 제출당 0.5ms 또는 그 이상의 GPU 작업량에 해당) 목표로 해야합니다. 그리고 프레임당 명령 버퍼 100개 미만을 (명령 버퍼당 0.1ms나 그 이상의 GPU 작업량에 해당) 목표로합니다. 이를 위해 개별 패스의 명령 기록을 위한 동시성을 적절히 제한해야합니다. 예를들어 특정 광원을 위한 그림자 패스가 100 미만의 그리기 호출을 사용한다면 이 패스를 위한 명령 기록을 위한 스레드를 1개로 제한해야합니다. 추가적으로 좀더 작은 패스의 경우 이웃하는 패스들을 하나의 명령 버퍼로 통합한다면 성능에 이득이 될 수 있습니다. 마지막으로 프레임당 제출의 개수가 적으면 적을수록 좋습니다. 프레임의 시작 시기에 충분한 GPU 작업을 제출함으로서 CPU와 GPU의 병렬성을 높일 수 있습니다. 예를들어 프레임의 다른 부분을 시작하기전에 그림자 렌더링을 위한 모든 명령 버퍼들을 제출하는 방법이 있습니다.
결정적으로 제출의 수는 vkQueueSubmit()
함수 호출의 수가 아니라 모든 함수 호출에 걸쳐 모든 VkSubmitInfo
구조체의 제출의 수를 말합니다. 예를들어 10개의 명령 버퍼를 제출할때 VkSubmitInfo
구조체 한개를 사용해서 10개의 명령 버퍼들을 한번에 제출하는게 좋습니다. 모두 한번의 vkQueueSubmit()
함수 호출을 사용하지만 명령 버퍼당 한개씩의 VkSubmitInfo
를 사용해서 총 10개의 구조체를 사용하는 방식에 비해 훨씬 효율적입니다. 본질적으로 VkSubmitInfo
는 GPU의 동기화와 스케쥴링의 단위입니다. 왜냐하면 각 구조체는 자신만의 펜스와 세마포어를 가지기 때문입니다.
보조 명령 버퍼
만약 하나의 렌더패스가 gbuffer의 예처럼 너무 많은 그리기 호출을 수행한다면 CPU 제출을 효율적으로 하기위해 그리기 호출들을 여러 그룹으로 나누고 여러 스레드에서 기록하도록 만드는게 중요합니다. 이렇게 하는 두가지 방법이 있습니다:
- 프레임버퍼를 위해 한 뭉치의 그리기 호출을
vkCmdBeginRenderPass()
함수와vkCmdEndRenderPass()
함수를 통해 주 명령 버퍼들에 기록합니다. 결과 명령 버퍼들을vkQueueSubmit
함수로 실행합니다. (효율성을 위해 일괄 제출) - 그리기 호출 뭉치들을 위해 보조 명령 버퍼에 기록합니다. 또한
vkBeginCommandBuffer()
를 호출할때VK_COMMAND_BUFFER_USAGE_RENDER_PASS_CONTINUE_BIT
플래그를 사용합니다. 다음으로 주 명령 버퍼에서vkCmdBeginRenderPass()
함수를 호출할때VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS
플래그를 지정합니다. 그 다음vkCmdExecuteCommands()
함수를 호출하여 모든 보조 명령 버퍼를 실행합니다.
직접 모드의 GPU들에서는 첫번째 접근 방식이 적합하기도하고 CPU에서의 동기화 시점을 관리하기도 조금 더 쉽기도 합니다. 하지만 타일 방식의 렌더링을 수행하는 GPU들에서는 두번째 접근방식을 쓰는게 중요합니다. 타일 기기에 첫번째 접근 방식을 사용하면 명령 버퍼마다 타일들의 내용이 메모리로 이동후 비워져야하고(flush) 다시 메모리에서 타일로 복구 되어야합니다. 이것은 성능에는 최악의 상황이 될 것입니다.
명령 버퍼 재사용
위에서의 명령 버퍼 제출에 관한 안내 지침에서는 대부분의 경우 하나의 명령 버퍼를 한번 기록후 여러번 재사용하는것은 실용적이지 않습니다. 일반적으로 장면의 부분을 위해 미리 명령 버퍼를 기록해뒀다가 사용하는 방식은 비생산적입니다. 왜냐하면 타일 렌더러의 경우 비효율적인 코드 경로가 선택될 수 있습니다. 또한 명령 버퍼를 될 수 있으면 크게 잡아서 작업량을 크게해야 하는데 이렇게 되면 비효율적인 컬링으로인해 GPU에 과부하가 걸리기 때문입니다. 응용프로그램은 대신 CPU에서의 스레딩과 그리기 호출 제출 비용의 효율성을 향상시키는데 집중해야합니다. 따라서 응용프로그램은 VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT
플래그를 통해 드라이버가 재사용이 필요없는 명령들을 생성하도록 유도해야합니다.
가끔 이 규칙에 대한 예외가 있습니다. 예를들어 VR 렌더링의 경우 응용프로그램은 좌우 시야를 포함한 결합된 절두체를 사용해 명령 버퍼를 한번 기록하고자 할 것입니다. 이후 단일 유니폼 버퍼에서 시야당 정보를 읽는경우 이 버퍼를 vkCmdUpdateBuffer()
함수를 통해 명령 버퍼 사이에 갱신합니다. 다음으로 보조 명령 버퍼를 사용할 경우 vkCmdExecuteCommands()
함수를 호출하거나 아니면 vkQueueSubmit()
함수를 호출합니다. VR을 위해서는 가능하다면 (불칸 1.1 버전에서는 핵심기능) VK_KHR_multiview
확장을 사용하면 좋습니다. 이 확장은 위와 비슷한 최적화를 드라이버가 대신 수행해줍니다.
파이프라인 장벽(barriers)
파이프라인 장벽은 불칸 코드에서 가장 어려운 부분 중 하나입니다. 이전 API들에서 런타임과 드라이버는 기기의 세부적인 동작에 필요한 동기화를 수행할 책임이 있었습니다. 조각 쉐이더가 바로전에 렌더링된 텍스처로부터 텍스처를 읽는 위험 상황(hazard)의 경우가 한 예입니다. 이를위해서 자원 하나하나 바인딩할 때 마다 세심한 추적이 요구 되었으며 때로는 과도하기까지 한 GPU 동기화를 수행하기 위한 과도한 CPU 오버헤드를 유발했습니다. 예를들어 Direct3D 11의 드라이버는 하나의 UAV를 사용하는 연속된 계산 디스패치에는 항상 장벽을 삽입했는데, 응용프로그램의 로직에 따라서는 이런 장벽이 불필요한 상황도 많습니다. 가장 신속하고 최적으로 장벽을 삽입하는 일은 자원 사용에 관련된 충분한 정보가 필요하며, 불칸은 따라서 이를 제일 잘 알고있는 응용프로그램에 장벽의 삽입을 위임합니다.
최적의 렌더링을 위해서는 파이프라인 장벽 설정이 완벽해야합니다. 응당 있어야할 장벽이 누락되면 아직 검사되지 않은 또는 아직 등장하지 않은 아키텍처에서 응용프로그램에 미묘한 시간차에(timing-dependent)의한 버그를 야기할 수 있습니다. 이것은 최악의 경우 GPU 크래시를 유발합니다. 장벽이 과도하게 사용되면 잠재적인 병렬 실행의 기회를 줄여 GPU 사용률이 감소됩니다. 최악의 경우 아주 비싼 압축 풀기 작업을 유발할 수도 있습니다. 상황을 더 어렵게 만드는것은 과도한 장벽의 사용은 Radeon Graphics Profiler와 같은 가시화 도구들을 통해 알아낼 수 있는 반면 누락된 장벽을 검출하기위한 도구는 아직 존재하지 않는다는 것입니다.
따라서 장벽의 동작을 이해하는것과 장벽을 과도하게 지정하는 것의 악영향 그리고 정확한 사용법을 아는것이 무엇보다 중요해졌습니다.
정신 모델
기술 상세는 실행 종속성과 파이프라인 단계간의 메모리 가시성에 기반하여 장벽을 설명합니다. 자원이 계산 셰이더에의해 기록되어졌고 곧바로 전송 단계에서 읽여지는 상황이 그 예입니다. 이미지의 레이아웃 변경도 그 중 하나입니다. 예를들어 이미지 자원이 색상 부착(attachment) 출력에 최적인 형식이었다가 쉐이더에서 읽기에 최적인 형식으로 이전(transition)될 때 발생합니다. 장벽이 GPU에 사용됬을때 어떤 결과를 가져오는지를 알면 장벽을 이해하는데 도움이 됩니다. GPU의 동작 방식은 당연하게도 공급업체와 아키텍처에따라 천차만별입니다. 하지만 이런 추상적 개념인 장벽을 좀더 구체적인 예로서 연결시키면 개별 사용에 따른 성능을 예측하는데도 도움이 됩니다.
장벽은 다음의 세가지를 유발합니다:
-
첫째로 다른 단계가 실행중인 작업을 모두 소모할때까지 특정 단계의 실행을 일시 정지 시켜버립니다. 예를들어 만약 렌더패스가 어떤 자료를 텍스처에 렌더링하고 바로 다음 렌더패스의 버텍스 쉐이더가 이것을 읽어와야하는 상황이라면, GPU는 조각 쉐이더와 모든 ROP 작업이 끝나기를 기다린 다음에서야 다음 패스의 정점 작업을 위해 쉐이더 스레드를 시작할 수 있게됩니다. 장벽 연산은 거의 대부분 일부 단계(stage)6 의 중단을 유발합니다.
-
둘째로 GPU내의 캐시를 무효화(invalidate)시키거나 플러시하며 메모리 트랜잭션들이 모두 끝나기를 기다려야 다음 단계가 결과 작업물을 제대로 받아볼 수 있게 해줍니다. 예를들어 일부 아키텍스처에서 ROP 쓰기 연산의 결과는 L2 텍스처 캐시로 보내지지만 전송 단계(transfer stage)는 메모리를 직접적으로 사용합니다. 만약 텍스처가 렌더패스에의해 렌더링된 경우 캐시가 미리 플러시되지 않는다면 이후의 전송 연산은 오래된 자료를 읽게됩니다. 마찬가지로 텍스처 단계가 전송 단계에의해 복사된 이미지를 읽어와야 하는경우 L2 텍스처 캐시를 무효화시켜야만 오래된 자료가 읽히는 문제를 방지할 수 있습니다. 하지만 모든 장벽 연산이 이렇게 동작하는것은 아닙니다.
-
셋째로 자원 저장소를 압축해제하는 대부분의 경우처럼 자원의 형식을 변환해줍니다. 예를들어 일부 아키텍처는 MSAA 텍스처를 압축된 형태로 보관합니다. 이는 픽셀에 유일한 색상들을 나타내는 샘플 마스크용 저장소와 샘플 자료를 위한 또 다른 저장소로 이뤄져 있습니다. 전송 단계와 쉐이더 단계는 이런 압축된 텍스처로부터 직접적으로 읽어오는게 불가능할 수 있습니다. 따라서
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
로부터VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
이나VK_IMAGE_USAGE_TRANSFER_SRC_BIT
로 이전해주는 장벽은 텍스처의 압축을 풀어서 모든 픽셀의 모든 샘플 데이타를 메모리로 이동시키게 됩니다. 모든 장벽이 이렇게 동작하지는 않지만 해당 동작은 아주 비싼 연산일 수 있습니다.
이런 내용을 염두에 두면 장벽 사용 지침의 이해에 도움이 될 것입니다.
성능 지침
개별 장벽 명령을 생성할때 드라이버는 과거 또는 미래의 또 다른 장벽에 대한 어떤 정보도 알 수 없습니다. 때문에 첫번째 중요한 규칙은 장벽을 될 수 있는한 최대한 공격적으로 일괄 처리해야합니다. 장벽이 조각 단계를 기다리거나 L2 텍스처 캐시를 플러시하도록 설정되었다면 vkCmdPipelineBarrier()
호출마다 드라이버는 무조건 그렇게 동작합니다. vkCmdPipelineBarrier()
함수를 호출할 때 다수의 자원을 지정하면 드라이버는 필요할 경우 단 한번의 L2 텍스처 캐시 플러시 명령만 생성하므로 비용을 줄일 수 있습니다.
장벽을 생성할때도 꼭 필요한 단계만을 포함시켜서 필요 이상으로 비용이 증가하지 않게 해야합니다. 예를들어 가장 자주쓰이는 장벽의 종류중 하나가 자원을 VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
에서 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
로 전이시키는 것입니다. 이 장벽을 사용할때는 dstStageMask
를 통해서 이 자원을 실제로 사용할 쉐이더 단계만을 지정해야합니다. 계산 쉐이더나 정점 쉐이더 읽기를 고려하여 단계 마스크를 VK_PIPELINE_STAGE_ALL_COMMANDS_BIT
로 지정하고 싶을 수도 있습니다. 하지만 그렇게하면 이어지는 그리기 명령에서 정점 쉐이더 작업이 시작될 수 없는 문제가 발생합니다:
-
이것은 직접 모드 렌더러에서 그리기 호출간에 약간의 병렬성을 감소시키며, 정점 스레드가 시작하려면 모든 조각 스레드들이 마무리되기를 기다리게 만듭니다. 결과적으로 패스가 끝나는 시점에서 GPU 사용율이 0%로 감소하고 다음 렌더 패스가 시작하면 서서히 100%로 올라가게 됩니다.
-
타일 모드 렌더러에서 일부 설계에서는 이후 패스의 모든 정점 작업이 끝나야만 조각 작업이 시작하게 됩니다. 모든 정점 작업을 시작하기위해 조각 작업이 끝나기를 기다려야하며 이는 정점과 조각 단계간의 병렬성을 완전하게 없애버립니다. 이것은 무작정 불칸으로 포팅된 타이틀에서 볼수 있는 가장 큰 잠재적 성능 문제중 하나입니다.
장벽이 정확하게 지정되었다고 해도 (이 경우에 텍스처가 조각 단계에서 읽힌다면 dstStageMask
는 VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT
값을 가져야함) 실행 종속성은 여전히 존재하며 여전히 GPU 사용율을 저하시킵니다. 이것은 계산 셰이더를 포함하여 다양한 상황에서 발생할 수 있습니다. 다른 계산 쉐이더에서 생성된 자료를 읽으려는 계산 쉐이더가 있다면 계산 셰이더와 계산 셰이더 사이에 실행 종속성을 표현해야 하지만 파이프라인 장벽을 지정하게 되면 계산 작업용 GPU가 완전히 고갈되며 이후 서서히 복구 됩니다. 대신에 분할 장벽(split barrier)이라 불리우는 것을 사용해 종속성을 지정하는게 나을 수 있습니다. 쓰기 연산이 완료되면 vkCmdPipelineBarrier()
함수 대신 vkCmdSetEvent()
함수를 사용하고 읽기 연산이 시작하기 전에 vkCmdWaitEvents()
함수를 사용하십시요. 문론 vkCmdSetEvent()
함수 바로 다음에 vkCmdWaitEvents()
함수를 호출하는것은 비생산적이며 vkCmdPipelineBarrier()
함수 방식보다 느릴 수 있습니다. 대신에 알고리즘의 구조를 다시 잡아서 Set과 Wait사이에 충분한 작업이 있게 해야합니다. 그렇게 하면 GPU가 Wait를 처리하는 시점에서는 대부분의 경우 이벤트는 활성 신호(signaled) 상태가 될테고 더 이상의 효율성 낭비는 없게 됩니다.
대안으로, 알고리즘을 개선하여 동기화 지점의 개수를 줄이면 파이프라인 장벽을 사용하더라도 오버헤드를 무시할정도로 작게 만들 수 있습니다. 예를들어 GPU 기반 입자 시뮬레이션에서 입자 효과를 위해 두개의 계산 처리(dispatch)를 실행시킨다고 가정합시다. 하나는 입자를 방출하는 용도, 다른 하나는 입자를 시뮬레이션하는 용도입니다. 이런 상황에서 둘 간에 실행을 동기화하기위해 파이프라인 장벽이 필요합니다. 입자 시스템들이 순차적으로 시뮬레이션 된다면 입자 시스템당 파이프라인 장벽이 필요합니다. 보다 최적화된 구현에서는 모든 입자 방출을 위한 처리를 먼저 수행합니다. 이런 처리들간에는 종속성이 없습니다. 그리고 나서 방출과 시뮬레이션 처리를 동기화하기위한 장벽을 제출합니다. 마지막으로 입자 시뮬레이션을위한 처리를 수행합니다. 이렇게하면 더 오래 GPU 사용율을 좋게 유지할 수 있습니다. 그런다음 분할 장벽을 사용하면 동기화 비용을 완전히 숨기는데 도움이 됩니다.
자원의 압축 풀기에 관해서는 일반적인 조언은 쉽지않습니다. 일부 아키텍처에서는 전혀 일어나지 않지만 다른데서는 발생하고 알고리즘에 따라서는 회피가 불가능한 경우도 있습니다. 압축 풀기가 프레임의 성능에 얼마가 큰 영향을 주는지를 알기위해서는 공급업체 전용 도구인 Radeon Graphics Profiler등을 사용하는게 중요합니다. 문론 장벽을 과도하게 사용하면 자원 압축 풀기가 불필요하게 발생한다는것을 염두에 두십시요. 예를들어 깊이 버퍼를 포함하는 프레임 버퍼에 렌더링을 하였지만 결과물인 깊이 버퍼의 내용을 미래에 절대 읽지 않는 상황이라면 VK_IMAGE_LAYOUT_DEPTH_STENCIL_OPTIMAL
레이아웃을 유지하십시요. 불필요하게 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
로 이전하게되면 압축 풀기 작업이 발생합니다. 드라이버는 미래에 실제로 자원 읽기가 이뤄지는지 여부는 상관하지 않는다는것을 명심하십시요.
장벽의 사양을 단순화하기
장벽을 지정하는것은 복잡한 문제입니다. 이해를 돕기위해 공통적으로 요구되는 장벽의 예제들을 몇개 가지고 있는게 좋습니다. 다행히도 크로노스 그룹은 동기화를 위한 유효하면서 최적화된 다양한 장벽 예제들을 GitHub의 Vulkan-Docs 레포지토리에서 제공합니다. 이것은 일반적인 장벽의 동작을 이해하는데 도움을 줄 뿐만아니라 바로 출시용 제품에 사용해도 무방합니다.
코드를 단순화하고 보다 정확하게 만들기위한 대안적인 모델이 있습니다. 접근 마스크, 단계들, 이미지 레이아웃등을 모조리 지정하는 대신 오로지 자원을 사용하는 단계들과 공통된 접근 타입을 위한 상태만을 신경쓰도록 구조를 단순화하면 됩니다. 그리고 나서 모든 이전(transition)은 자원을 상태 A에서 상태 B로의 이전으로 단순화해 생각할 수 있습니다. 이것을 위해 크로노스 그룹의 멤버이고 불칸 기술 상세 문서의 공동 저자인 토비아스 헥터는 simple_vulkan_synchronization이라는 오픈소스 라이브러리를 제작하였습니다. 이 라이브러리는 자원의 상태 전이(라이브러리에서는 접근 타입이라고 부름)를 불칸의 장벽으로 번역해 줍니다. 이 라이브러리는 작고 간단하며 분할 장벽과 더불어 완전한 파이프라인 장벽도 지원합니다.
렌더 그래프로 미래 예측하기
이전 장에서 제시한 성능 지침은 실제로 적용하기에는 어려움이 있습니다. 특히나 전통적인 직접 모드 렌더링 아키텍처의 경우 더욱 그렇습니다.
단계나 이미지 레이아웃 이전이 과도하게 지정되지 않게 하기위해 자원이 미래에 어떻게 사용될지를 아는것이 중요합니다. 렌더 패스가 끝난후 파이프라인 장벽을 지정하는 상황에서 이 정보가 없다면 일반적으로 목적 단계 마스크에 모든 단계를 지정해야하며 비효율적입니다.
이 문제를 해결하기 위해 자원이 읽히는 시점에 (자원이 어떻게 기록되었는지도 알 수 있음) 장벽을 지정하고 싶을 수 있으나 이렇게 하면 장벽의 일괄 처리가 힘들어집니다. 예를들어 A, B, C 3개의 렌더 패스를 가지는 프레임을 가정합시다. C는 A와 B의 출력을 두개의 분리된 그리기 호출에서 읽어야합니다. 텍스처 캐시 플러시의 수나 다른 장벽 작업을 최소화하기위해 일반적으로 C 이전에 정확하게 A와 B의 출력물을 이전시키기위해 장벽을 지정할 것입니다. 하지만 실제로는 C의 두개의 그리기 호출 각각에 장벽이 작동합니다. 분할 장벽은 때로는 관련된 비용을 감소시키지만 일반적으로 적시(just-in-time) 장벽은 과도하게 고비용입니다.
추가적으로 적시 장벽의 사용은 이전의 레이아웃을 알기위해 자원의 상태의 추적을 요구합니다. 이것은 멀티스레드 환경에서는 상당히 어려운 작업입니다. 왜냐하면 GPU에서의 실행 순서는 모든 명령들이 기록되고 선형화 되고나서야 알 수 있기 때문입니다.
앞서 언급한 문제들 때문에 많은 현대 렌더러들은 렌더 그래프를 실험하기 시작했습니다. 렌더 그래프에서는 모든 프레임 자원들간의 종속성을 선언적으로 지정하게 됩니다. 결과 DAG 구조에 기반하여 정확한 장벽을 설정하는게 가능합니다. 이것은 다수의 큐간에 필요한 장벽을 지원하며 임시 자원의 경우 최소한의 메모리를 할당하게 해줍니다.
렌더 그래프 시스템에 대한 완전한 설명은 이 글의 범위를 벗어납니다. 하지만 관심있는 독자는 아래의 강연과 문서를 읽어볼것을 권합니다:
- FrameGraph: 프로스트바이트의 확장성있는 렌더링 아키텍처, 유리 오도넬, GDC 2017
- 고급 그래픽 기술: DirectX 12으로 이동: 교훈, 티아고 로드리게스, GDC 2017
- 렌더 그래프와 불칸 - 깊이 빠져들기, 한스 크리스티안 안첸
서로 다른 엔진들은 그만의 해결책을 사용합니다. 예를들어 프로스트바이트의 렌더 그래프는 응용프로그램이 최종 실행 순서를 지정하게 합니다. (해당 글의 저자는 이것이 좀더 예측가능하여 선호된다고 봄) 반면에 다른 두 프레젠테이션은 더 최적의 실행 순서를 찾기위해 휴리스틱을 사용하여 그래프를 선형화 합니다. 이런 차이에도 불구하고 공통적인 부분이라면 장벽을 적절하게 생성하기 위해서 전체 프레임에 사용되는 패스들간의 종속성을 미리 선언한다는 것입니다. 중요한것은 프레임 그래프 시스템이 개수가 한정된 임시 자원에 대해 잘 작동한다는 것입니다. 자원 업로딩이나 유사한 스트리밍 작업을 위한 장벽 지정기능을 시스템에 추가하는게 가능은 하지만 이는 그래프를 과도하게 복잡하게 만들수 있고 처리시간도 길어지기때문에 일반적으로 프레임 그래프 시스템에서는 제외합니다.
렌더 패스
렌더 패스는 이전의 API들이나 다른 최근의 명시적 API들과 비교해서 상대적으로 불칸만이 유일하게 가지는 특징입니다. 렌더 패스는 렌더 프레임의 큰 부분을 최상위 객체로서 다룰 수 있게 해줍니다. 작업량을 개별 하위 패스들로 분할하고 하위 패스들간의 종속성을 명시함으로서 드라이버가 작업을 스케쥴링하고 적절한 위치에 동기화 명령을 자동으로 삽입하게 해줍니다. 그런 의미에서 렌더 패스는 앞서 소개한 렌더 그래프와 유사합니다. 렌더 그래프를 구현하는데 사용될 수도 있지만 약간의 제약이 있습니다. 예를 들어 렌더 패스는 현재 래스터(rasterization) 작업만을 표현할 수 있기때문에 계산(compute) 작업을 위해서는 다수의 렌더 패스를 사용해야합니다. 이번 장에서는 렌더 패스의 간단한 사용법에 집중하겠습니다. 이는 기존의 렌더러에 렌더 패스를 적용하는데에 적합하며 성능 향상도 기대할 수 있습니다.
적재(load) & 저장(store) 연산
렌더 패스의 가장 중요한 기능중 하나는 적재와 저장 연산을 지정할 수 있다는 것입니다. 이것들을 사용하면 응용프로그램은 각자의 프레임버퍼 부착(attachement)들의 초기 내용이 지워져야하는지 또는 메모리로부터 적재되어야하는지 또는 지정하지 않고 응용프로그램이 사용하지 않을지를 결정하게 해줍니다. 그리고 렌더 패스가 완료되었을때 부착의 내용이 메모리에 저장되어야 하는지 여부도 지정하게 해줍니다.
이런 연산들을 정확히 사용하는것이 중요합니다. 타일 기반 아키텍처에서 불필요한 적재나 저장 연산들을 사용하면 대역폭이 낭비되고 성능 저하와 전력 소비 증가를 가져오기 때문입니다. 아키텍처가 타일기반이 아닌경우 드라이버는 이런 연산을 통해 이후 렌더링을 위한 특정 최적화를 수행 하기도합니다. 예를 들어 부착의 기존 내용이 불필요하지만 부착에 압축 메타데이타가 연관되어있는 경우 드라이버는 이 메타데이타를 지움으로서 이후의 렌더링이 효율적이도록 해줍니다.
드라이버에 최대한의 자유를 보장하기위해 가능한 약한(weak) 적재/저장 연산을 지정하는것이 중요합니다. 예를들어 부착에 전체화면(full-screen) 사각형을 렌더링하여 모든 픽셀에 기록하는 상황이라면 타일기반 GPU에서는 VK_ATTACHMENT_LOAD_OP_CLEAR
가 VK_ATTACHMENT_LOAD_OP_LOAD
보다 더 빠를 것입니다. 그리고 직접(immediate) 모드 GPU에서는 LOAD가 더 빠를 수 있습니다. 따라서 VK_ATTACHMENT_LOAD_OP_DONT_CARE
를 지정하여 드라이버가 최적의 선택을 사용하게 해줍시다. 일부의 경우에 VK_ATTACHMENT_LOAD_OP_DONT_CARE
가 LOAD나 CLEAR보다 나을 수 있습니다. 이렇게 함으로서 드라이버가 이미지의 내용을 지우는 비싼 연산을 피할 수 있게 해주면서도 이미지의 메타데이타는 지워지기 때문에 후속 렌더링의 속도가 향상됩니다.
비슷하게 부착에 렌더링된 자료를 앞으로 읽을 일이 없다고 판단되면 VK_ATTACHMENT_STORE_OP_DONT_CARE
를 사용해야합니다. 대부분의 깊이 버퍼와 MSAA 타겟에 해당합니다.
빠른 MSAA 리졸브(resolve)
MSAA 텍스처에 자료를 렌더링한 후 추가 처리를 위해 비-MSAA 텍스처에 내용을 리졸브하는 경우가 많습니다. 고정 기능(fixed-function) 리졸브 기능만으로 충분하다면 불칸에는 두 가지 구현 방법이 있습니다:
- 첫째는 MSAA 텍스처를 위해
VK_ATTACHMENT_STORE_OP_STORE
를 사용하고 렌더 패스가 끝날때vkCmdResolveImage()
함수를 호출하는 방법입니다. - 둘째는 MSAA 텍스처를위해
VK_ATTACHMENT_STORE_OP_DONT_CARE
를 사용하고VkSubpassDescription
의 멤버인pResolveAttachments
를 통해 대상의 리졸브를 지정하는 방법입니다.
두번째 방법에서 드라이버는 하위 패스와 렌더패스가 끝날때 이뤄지는 작업의 일환으로 MSAA의 내용을 리졸브하기 위한 작업을 수행합니다.
두번째 방법이 현저히 더 효율적일 수 있습니다. 타일 아키텍처에서 첫번째 방식은 MSAA 텍스처를 메인 메모리에 적재해야합니다. 그리고 나서 다시 메모리에서 읽어와서 목적지에 리졸브 내용을 적재해야합니다. 두번째 방식은 타일 자체적으로 리졸브가 수행되는 방식이므로 더 효율적입니다. 직접 모드 아키텍처의 일부 구현에서는 압축된 MSAA 텍스처를 전송 단계(transfer stage)에서 지원하지 않을 수 있습니다. 이 경우에는 vkCmdResolveImage()
함수를 호출하기전에 VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL
를 사용해서 텍스처를 전이해야하며 이 과정에서 MSAA 텍스처의 압축 풀기가 발생해서 대역폭 낭비와 성능 저하가 불가피 합니다. pResolveAttachments
를 사용하면 드라이버는 아키텍처와 관계없이 최적의 리졸브 연산을 수행할 수 있습니다.
때에 따라 고정 기능 MSAA 리졸브는 비효율적입니다. 이 경우 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
를 사용해서 텍스처를 전이하고 실제 리졸브는 별도 렌더 패스를 통해 수행하는게 필요합니다. 이런 방법은 타일 아키텍처에서 vkCmdResolveImage
를 사용하는 고정 기능 방법과 같은 효율성을 보여줍니다. 직접 모드 아키텍처에서의 효율성은 GPU나 드라이버에 의존적입니다. 한가지 가능한 대안은 입력 부착을 통해 MSAA 텍스처를 읽는 하위 패스를 사용하는 것입니다.
이것이 동작하려면 MSAA 텍스처에 런더하는 첫번째 하위 패스는 pColorAttachments
를 통해 MSAA 텍스처를 지정해야하며 저장 연산으로 VK_ATTACHMENT_STORE_OP_DONT_CARE
를 지정해야 합니다. 실제 리졸브를 수행하는 두번째 하위 패스는 pInputAttachments
를 통해 MSAA 텍스처를 지정하고 pColorAttachments
를 통해 리졸브 타겟을 지정해야합니다. 하위 패스는 그리고 나서 subpassInputMS 자원을 사용해서 MSAA 자료를 읽는 쉐이더를 통해 전체화면 사각형이나 삼각형을 렌더링합니다. 추가적으로 응용프로그램은 두 하위 패스간의 종속성을 위해 파이프라인 장벽과 비슷하게 단계/접근 마스크를 지정하고 VK_DEPENDENCY_BY_REGION_BIT
종속성 플래그를 사용합니다. 이렇게 해서 드라이버는 실행을 정렬(arrange) 하기위한 충분한 정보를 가질 수 있습니다. 타일 GPU에서는 MSAA 내용이 절대 타일 메모리를 떠나지 않으며 타일 내부에서 리졸브가 수행됩니다. 단지 결과만 메인 메모리7에 쓰여질 뿐입니다. 이것의 실제 동작 여부는 드라이버에 크게 의존하며 직접 모드 GPU에서는 크게 이득이 없을 수 있습니다.
파이프라인 객체
이전의 API들은 GPU 상태를 동작의 단위에 기반해서 구분했습니다. 예를들어 Direct3D 11에서 자원 바인딩을 제외한 모든 상태는 다음과 같습니다. 다양한 단계(VS, PS, GS, HS, DS)들을 위한 쉐이더 객체들, 다양한 상태 객체들(라스터, 혼합, 깊이스텐실), 입력 조립(assembly) 구성(입력 배치, 프리미티브 토폴로지) 그리고 출력 렌더 타겟의 형식(format)등을 위한 암시적인 비트등이 그것입니다. API의 사용자는 이제 하부 기기의 설계 및 복잡도와는 관계없이 상태들의 비트들을 개별적으로 설정할 수 있습니다.
불행히도 이런 모델은 기기가 실제로 동작하는 방식과는 잘 맞지 않기 때문에 때로는 성능에 악역향이 발생합니다. 아래가 그 예입니다:
-
첫째로 개별적인 상태 객체는 GPU의 독립적 상태를 모델링하며, 대응하는 명령을 전송하여 바로 GPU의 상태를 설정하게 됩니다. 하지만 일부 GPU는 상태를 설정하기 위해 다양한 상태 블록으로부터의 자료가 필요합니다. 이 때문에 드라이버는 모든 상태값의 복사본(shadow)을 내부적으로 유지해서 그리기 호출이 요청될때 GPU 명령을 생성하기위해 이용합니다.
-
둘째로 라스터 파이프라인이 점점 복잡해지고 프로그램 가능한 단계들이 늘어나면서 일부 GPU는 이들 단계를 위한 전용 기기를 구현하는 대신 마이크로 코드 방식으로 대체합니다. 따라서 다른 단계의 활성화 여부 또는 때에 따라서 다른 단계의 특정 마이크로코드가 현 단계의 쉐이더 마이크로코드에 영향을 주게 되었습니다. 결과적으로 그리기 호출이 발생하는 시점에서만 최종 쉐이더 마이크로코드를 결정할 수 있는 경우가 생겨버립니다.
-
셋째로 API에서 제공하는 일부 고정 기능 유닛의 경우 실제로는 쉐이더 단계를 사용해서 구현되는 경우가 있습니다. 예를들어 정점 입력 형식, 혼합 설정, 렌더 타겟 형식등이 해당됩니다. 이런 상태들은 그리기 호출 시점에서만 알 수 있으므로 최종 마이크로코드도 이 시점에서만 컴파일될 수 있습니다.
첫번째 문제의 경우 큰 문제가 아닐 수 있지만 두번째와 세번째 문제는 렌더링에 상당한 중단 현상을 가져오게 됩니다. 최근에 쉐이더와 파이프라인이 복잡해짐에 따라 쉐이더의 컴파일 작업은 수십에서 수백 밀리초가 걸립니다. 이 문제를 해결하기 위해 불칸과 여타 새로운 API들은 파이프라인 객체라는 개념을 만들어 냈습니다. 파이프라인 객체는 거의 모든 GPU 상태를 포함합니다. 이것은 정점 입력 형식과 렌더 타겟 형식 그리고 모든 단계를 위한 상태와 쉐이더 모듈을 포함합니다. 결과적으로 모든 GPU에 적용할 정도로 충분한 상태 정보를 파이프라인 객체가 가지게되어 쉐이더 마이크로코드와 GPU 명령들을 파이프라인 생성 시점에 빌드할 수 있게해줍니다. 따라서 드라이버는 그리기 시점에서 마이크로코드를 컴파일할 이유가 전혀 없게되고, 파이프라인 객체를 최적으로 설정할 가능성도 제공합니다.
하지만 이제 렌더러가 해결해야할 과제가 하나 생긴 셈입니다. 해결 방법은 여러가지가 있겠지만 모두 복잡도, 효율성, 설계상의 이슈 사이에서 적절한 타협을 요구합니다.
적시(just-in-time) 컴파일
불칸의 파이프라인 객체를 위한 가장 직관적인 방법은 파이프라인 객체를 적시 컴파일하는 것입니다. 대부분의 엔진이 불칸의 새로운 개념에 대한 고려가 없이 구현되므로, Direct3D 11의 드라이버가 했던 작업들을 직접 수행해야합니다. 예를들어 여러 설정 호출들로부터 정보를 수집해서 파이프라인 상태로 사용해야합니다. 그리고 전체 상태 정보를 알수있는 시점인 그리기나 디스패치 호출 바로 전에 모든 상태값의 비트들을 조합해서 해시키를 생성합니다. 이제 해시 테이블상에서 파이프라인을 가져오거나 새 파이프라인을 필요에 따라 추가하면 됩니다.
이 계획은 동작은 하지만 두가지 측면에서 성능상의 문제가 있을 수 있습니다.
첫번째 우려는 해시를 생성하기위한 원본 자료의 크기가 상당히 클 수 있습니다. 이미 캐시에 사용할 객체들이 준비되어 있는 상태에서는 매번 그리기 호출에서의 해시 연산은 상대적으로 고비용입니다. 어느 정도 이 문제를 완화하기위해 상태들을 여러 객체들로 그룹짓고 그 포인터 값으로만 해시를 생성할 수도 있고 또는 좀 더 상위 레벨의 시점으로 상태를 표현하는 방법도 있습니다.
우려되는 점은 파이프라인 상태 객체마다 드라이버가 다수의 쉐이더를 컴파일해서 최종 GPU 마이크로코드를 생성해야할 수 있습니다. 이런 처리는 시간이 많이 소모됩니다. 추가적으로 적시 컴파일 모델에서는 스레드를 최적으로 사용하기 어렵습니다. 응용프로그램이 명령 제출을위해 하나의 스레드만 사용한다면 이 스레드에서 파이프라인 상태 객체의 컴파일도 같이 이뤄집니다. 다중 스레드의 경우에도 빈번히 여러 스레드가 하나의 파이프라인 객체를 요청하는 일이 발생하는데 이는 컴파일 작업을 직렬화 해버립니다. 하나의 스레드가 여러 새로운 파이프라인 객체를 필요로 할 경우, 이미 작업을 완료한 다른 스레드의 대기를 유발해서 전체적인 지연을 유발합니다.
멀티 스레드 제출에서 캐시 접근은 코어간의 경쟁(contention)을 유발합니다. 다행히도 아래와 같이 이중 레벨 캐시를 통해 해결이 가능합니다:
캐시를 프레임에 걸쳐 불변인 부분과 수정가능 부분으로 나눕니다. 일차로 파이프라인을 불변 캐시에서 조회하며 이때에는 동기화가 불필요합니다. 캐시 미스가 발생하면 임계 영역을 잠그고(lock) 수정가능 캐시를 조회합니다. 조회가 실패하면 임계 영역을 풀고(unlock) 파이프라인 객체를 생성합니다. 다시 임계 영역을 잠근후 객체를 캐시에 추가합니다. 두 스레드가 같은 객체를 요청하는 상황을 위해 추가적인 동기화가 필요할 수 있으며 드라이버에 컴파일 요청을 중복으로 보내는것을 방지합니다. 프레임의 종료 시점에서 수정가능 캐시를 모두 불변 캐시로 이동시킵니다.그리고 수정가능 캐시를 클리어 합니다. 이렇게 하면 이후 프레임들에서는 해당 객체들을 동기화 없이 접근할 수 있습니다.
파이프라인 캐시와 캐시 예열(pre-warming)
적시 컴파일 방식은 동작에는 문제가 없지만 게임 플레이동안 멈춤 현상은 불가피 합니다. 프레임에 새로운 쉐이더와 상태를 가지는 물체가 표시되는 순간 파이프라인 객체를 새로 컴파일 해야하고 이것은 느린 작업이기 때문입니다. 이것은 Direct3D 11으로 작성된 타이틀들에서 비슷하게 발생가능한 현상입니다. 하지만 Direct3D 11에서는 드라이버가 보이지 않는곳에서 아주 많은 일을 해서 이런 컴파일 지연시간을 최소화하려고 노력합니다. 예를들어 쉐이더를 일찍 먼저 컴파일해놓거나, 런타임 코드 패치를 통해 불필요한 전체 컴파일을 회피합니다. 반면에 불칸에서는 응용프로그램이 파이프라인 객체의 생성을 스스로 해야하고 제대로 못하면 최적의 결과를 얻을 수 없습니다.
적시 컴파일을 보다 실용적으로 만들기위해 불칸의 파이프라인 캐시를 사용하는게 바람직합니다. 파이프라인 캐시를 응용프로그램 실행과 실행사이에 직렬화하여 저장 및 적재하고 응용프로그램 시작 시점에서 다수의 스레드를 사용하여 예열 작업을 수행합니다.
불칸은 VkPipelineCache
라는 파이프라인 캐시 객체를 제공합니다. 이것은 드라이버 종속정인 상태 비트들과 쉐이더 마이크로코드를 포함하여 파이프라인 객체의 컴파일 속도를 향상시켜줍니다. 예를들어 응용프로그램이 컬링 모드를 제외한 모든 상태가 같은 두 개의 파이프라인 객체를 생성한다면 두 객체의 쉐이더 마이크로코드는 같을 것입니다. 드라이버가 쉐이더 객체를 한번만 컴파일하게 하려면 하나의 VkPipelineCache
인스턴스를 두번의 vkCreateGraphicsPipelines()
함수 호출에 동일하게 제공하면 됩니다. 이렇게 하면 첫번째 호출에서는 쉐이더의 마이크로코드 컴파일이 필요하지만 두번째 호출에서는 첫번째 결과를 재사용할 수 있습니다. 이것이 두개의 스레드에서 동시적으로 발생한다면 드라이버는 여전히 두번의 쉐이더 컴파일을 수행해야할 수 있습니다. 둘중 하나의 스레드가 종료되는 시점에서 결과가 캐시에 추가되기 때문입니다.
하나의 VkPipelineCache
객체를 사용해서 모든 파이프라인 객체를 생성하는것이 중요합니다. 또한 매 프로그램 실행마다 vkGetPipelineCacheData
와 VkPipelineCacheCreateInfo
의 멤버인 pInitialData
를 사용해서 자료를 디스크로 직렬화하는것이 핵심입니다. 이렇게 함으로서 응용프로그램의 후속 실생에서는 컴파일된 객체를 재사용할 수 있고 프레임 프레임 튐 현상(spike)을 최소화할 수 있습니다.
불행히도 최초 실행시에는 파이프라인 캐시가 모든 조합을 미리 가지고있지 않기때문에 여전히 쉐이더 컴파일로인한 프레임 튐 현상이 발생하게 됩니다. 추가적으로 파이프라인 캐시가 필요한 마이크로코드를 포함하고 있다고해도, vkCreateGraphicsPipelines()
함수의 호출 비용이 아예 없는것은 아니며 새 파이프라인 객체를 컴파일 하는것과 마찬가지로 일부 프레임률의 변동을 야기합니다. 이를 해결하기 위해 메모리 캐시(또는 VkPipelineCache
)를 로딩 시간에 예열할 수 있습니다.
한가지 해결책은 게임플레이가 끝나는 시점에서 렌더러는 파이프라인 캐시 자료(각 쉐이더와 상태 정보의 조합8, 불칸 파이프라인 캐시와는 구별됨)를 데이타베이스에 저장합니다. 그리고 나서 QA 플레이 테스트 단계에서 이 데이타베이스를 다양한 그래픽 설정에 걸쳐 실측값들로 채웁니다. 이렇게 하면 실제 게임 플레이에 있을법한 자료들을 미리 수집할 수 있습니다.
그리고 이 데이타베이스를 실제 게임에 탑재합니다. 게임을 시작할때 이 데이타베이스의 데이타들로부터 모든 메모리 캐시를 생성합니다. 생성해야할 전체 파이프라인 상태가 방대할때는 현재 그래픽 설정에 한해서만 생성해도 무방합니다. 생성 작업에 다수의 스레드를 사용하여 로딩 시간을 최소화해야 합니다. 처음 실행은 여전히 긴 로딩 시간을 갖지만 (Steam의 프리캐싱(pre-caching) 같은 기능을 사용해 좀 더 줄일 수 있음) 적시 파이프라인 객체 생성으로인한 대부분의 프레임 튐현상을 없애는게 가능합니다.
만약 QA 플레이 테스트 단계에서 모든 상태의 조합을 얻어내지 못하면, 시스템은 정상 작동 하지만 약간의 멈춤 현상은 불가피합니다. 결과적으로 이 계획은 어느정도 만능이며 실용적이지만, 유용한 자료를 얻기위해 많은 시간을 QA 플레이 테스트에 소모하게 됩니다.
사전(Ahead of time) 컴파일링
불칸이 애초에 의도했던 완벽한 해결책이 있습니다. 바로 모든 가능한 파이프라인 객체를 명시적으로 미리 준비하는 것입니다. 이렇게하면 적시 컴파일과 캐시 시스템 그리고 예열 과정은 아예 필요없게 됩니다.
이를 위해서는 렌더러의 설계를 변경해야합니다. 파이프라인 상태의 개념을 재질 시스템에 포함하고 재질은 모든 가능한 상태값을 지정하게 해야합니다. 설계의 결과는 다양할 수 있습니다. 여기서는 그 중 한가지만을 언급하겠지만 중요한 것은 핵심 개념입니다.
물체는 재질과 연결되고 재질은 물체 렌더링에 쓰일 그래픽 상태와 자원 바인딩을 가집니다. 여기서 중요한것은 그래픽 상태와 자원 바인딩을 분리하는것 입니다. 모든 그래픽 상태의 조합을 미리 열거할 수 있게 만드는게 목적이기 때문입니다. 그래픽 상태의 모음을 기술(technique)이라고 부르겠습니다. 이것은 Direct3D 효과(effect) 프레임워크의 용어이며 거기서는 상태값을 패스에 기록하긴 하지만 여기서는 의도적으로 그렇게 부르겠습니다. 하나의 효과(effect)는 여러 기술을 포함합니다. 그리고 재질은 이 효과를 참조하며, 일종의 키값을 통해 효과 안의 기술을 지정합니다.
이펙트와 이펙트안의 기술은 정적으로만 지정됩니다. 효과는 기술처럼 파이프라인 객체를 미리 컴파일하는데는 필수적이지는 않습니다. 하지만 하나의 물체를 위한 여러 패스(그림자 패스, gbuffer 패스, 반사 패스)들, 동적 효과들(하이라이트등)을 표현하기위한 의미 집합를 하나의 효과라는 개념으로 통합하기위해 필요합니다.
결정적으로 기술은 파이프라인 객체를 생성하기위한 모든 상태를 정적으로 미리 지정해야합니다. 보통 JSON이나 XML등의 파일 또는 D3DFX와 유사한 DSL형식의 텍스트 파일에 정적으로 지정하게 합니다. 여기에는 모든 쉐이더와 혼합 상태, 컬링 상태, 정점 형식, 렌더타겟 형식들, 깊이 형식등이 포함되어야 합니다. 아래가 그 예시입니다:
technique gbuffer
{
vertex_shader gbuffer_vs
fragment_shader gbuffer_fs
#ifdef DECAL
depth_state less_equal false
blend_state src_alpha one_minus_src_alpha
#else
depth_state less_equal true
blend_state disabled
#endif
render_target 0 rgba16f
render_target 1 rgba8_unorm
render_target 2 rgba8_unorm
vertex_layout gbuffer_vertex_struct
}
모든 그리기 호출이(후처리를 포함) 효과 시스템을 통해 렌더 상태를 지정한다고 가정합니다. 또한 모든 효과와 기술은 정적으로만 지정된다고 가정합니다. 그러면 모든 파이프라인 객체를 미리 생성하는것은 아주 쉽습니다. 기술당 파이프라인을 한개씩 생성하면 됩니다. 이제 로딩시 다수의 스레드를 사용하여 파이프라인을 미리 생성해두고 게임 루프에서는 아주 효율적으로 파이프라인을 지정만 하면 됩니다. 더 이상 캐시를 사용할 필요도 없으며 프레임 멈춤 현상을 걱정안해도 됩니다.
실제로 이것을 현대의 렌더러에 적용할때에는 사실상 복잡도 관리를 잘 해내는것이 관건입니다. 복잡한 쉐이더나 상태의 조합은 일반적입니다. 예를들어 양면(two-sided) 렌더링을위해 컬링 상태를 변경하거나 때로는 양면 광원효과를위한 특수한 쉐이더로의 변경이 필요할 수도 있습니다. 스키닝을 지원하기위해 정점 형식을 변경하고 쉐이더가 스키닝 행렬을 사용해 위치 속성을 변환하도록 정점 쉐이더를 변경해야합니다. 일부 그래픽 설정에서 대역폭의 절약을 위해 렌더타겟 형식을 FGBA16F에서 부동소수점 R10G11B10형식으로 변경하고 싶을수도 있습니다. 이 조합의 크기는 빠르게 증가하며 기술(technique)에 이를 표현할 정확하고 효율적인 방법이 필요해집니다. 예를들어 위의 예제처럼 #ifdef를 기술의 선언부에 허용하게 할 수 있습니다. 중요한것은 조합의 크기가 점진적으로 증가하는것을 항상 염두에 두고 때때로 리팩토링과 코드 단순화 작업을 수행하는 것입니다. 일부 효과는 아주 드물게 사용되기때문에 별도의 패스에서 렌더링하도록하면 조합의 크기를 증가하지 않게 할 수 있습니다. 일부 계산은 단순해서 모든 쉐이더가 포함하도록 하는게 전체 조합의 크기를 증가시키는것보다 오히려 나을수가 있습니다. 또한 일부 렌더링 기술(technique)들은 문제의 분리를 손쉽게해주기 때문에 조합을 크기를 줄이는데 활용할 수 있습니다.
여기서 상태의 조합까지 고려하면 문제는 더 복잡해지긴 하지만 달라질것은 없습니다. 많은 렌더러들은 이미 다량의 쉐이더 조합이라는 문제를 해결해왔고 일단 모든 렌더 상태를 쉐이더/기술 상세에 이미 포함했다면 단지 기술의 조합을 최소화하는데만 집중하면 됩니다. 결국 이 두가지 문제는 공통된 해결책을 가지게 됩니다. 이와 같은 시스템을 구현하는데 따른 장점은 모든 조합에 대한 완벽한 정보를 소유하는것입니다. 이것은 기존의 깨지기 쉬운 조합(permutation) 발견(discovery) 시스템과는 대비됩니다. 결과적으로 높은 성능과 최초 로딩을 포함한 매프레임의 안정적인 프레임률을 보장하고 렌더링 코드의 복잡성을 줄여주는 효과도 제공합니다.
결론
불칸 API는 드라이버 개발자의 많은 책임을 응용프로그램 개발자에게 넘겼습니다. 많은 구현상의 선택지가 생겼으므로 다양한 렌더링 기능을 시험해 보는것은 큰 도전과제가 되었습니다. 정확히 동작하는 불칸 렌더러를 구현하는 것조차 힘에 부치지만, 성능과 메모리 사용을 최적화하는 것은 그에 못지않게 중요한 작업입니다. 이 글에서 특정 문제에 대해 많은 중요한 고려사항들에 관해 논하려고 노력하였습니다. 복잡도, 사용 편리성, 성능 사이에 다양한 타협을 시도하는 여러개의 구현상 접근법을 소개하였습니다. 또한 기존 렌더러를 포팅하는것과 완전히 새로 렌더러를 디자인하는것을 모두 다루었습니다.
궁극적으로 모든 공급 업체에 걸쳐 또는 모든 렌더러에 잘 작동하는 일반화된 조언을 하는것은 아주 어려운 일입니다. 이런 이유로 목표 운영체제와 공급업체에대해 결과 코드의 성능을 측정하는것은 필수입니다. 불칸의 경우 게임이 출시를 목표로하는 모든 공급업체를 위해 성능을 항상 모니터하는것은 아주 중요합니다. 불칸에서는 응용프로그램이 내리는 결정은 무엇보다 중요합니다. 고정 기능 정점 버퍼 바인딩처럼 특정 기능의 경우 한 공급업체에서의 가장 빠른 경로가 다른 공급업체에서는 느린 경로이기도 하기 때문입니다.
코드의 정확성을위해 유효성 검사 계층을 사용하거나 공급업체가 제공하는 계측 도구들을 (AMD Radeon Graphics Profiler 또는 NVidia Nsight Graphics) 사용함과 더불어서 당신의 불칸용 렌더러를 최적화 하게 도와주는 많은 오픈소스 라이브러리들이 있습니다:
-
VulkanMemoryAllocator - 편리하고 성능이 좋으며 불칸을 위한 메모리 할당자는 문론 조각 방지기능 등의 여러 메모리 관련 알고리즘을 제공합니다.
-
volk - 드라이버가 제공하는 불칸 진입점을 드라이버에서 직접 사용하는 손쉬운 방법을 제공하면서도 함수 호출 오버헤드도 줄여줍니다.
-
simple_vulkan_synchronization - 단순화된 접근 형식 모델을 사용하여 불칸 장벽을 지정하는 방법을 제공하여 성능과 정확성간에 균형을 잡도록 도와줍니다.
-
Fossilize - 다양한 불칸 갤체들의 직렬화를 제공합니다. 특히 파이프라인 캐시를 위해 예열을 할때 필요한 파이프라인 상태 생성 정보도 포함됩니다.
-
perfdoc - 유효성 검사 계층과 비슷한 계층들을 제공하며 ARM GPU들에서 렌더링 명령 스트림을 분석하고 잠재적인 성능 문제를 찾아줍니다.
-
niagara - 이 글에서 제시하는 여러 조언들에 기반한 바인딩없은 렌더러의 예제를 제공합니다. (글의 전체 내용을 포함하지는 않음)
-
Vulkan-Samples - 불칸 렌더링 기술 구현상 다양한 타협점을 탐구하는 많은 예제를 제공합니다. 모바일에서의 성능관련 자세한 정보도 포함합니다.
마지막으로 일부 공급업체는 리눅스를 위한 오픈소스 불칸 드라이버를 개발합니다. 그들의 소스코드를 연구함으로서 특정 불칸 구성 요소의 성능에 관련하여 통찰력을 얻을 수 있습니다:
GPUOpen-Drivers for AMD - 불칸 드라이버 소스를 가지고있는 xgl을 포함합니다. 또한 xgl에서 PAL이라는 라이브러리를 사용합니다. 많은 불칸 함수 호출은 결국 xgl이나 PAL 호출로 연결됩니다.
mesa3d/radv for AMD - 공동체에서 개발된 오픈소스 radv 드라이버를 포함합니다.
mesa3d/anvil for Intel - Anvil 드라이버를 포함합니다.
저자는 알렉스 스미스(Feral Interactive), 다니엘 라코스(AMD), 한스 크리스티안 안첸(ex. ARM), 매튜 차이다스(AMD), 웨삼 바나시(INFramez Technology Corp) 그리고 볼프강 엥겔(CONFETTI) 에게 초안을 검토하고 개선하는데 도움을 준데 대해 감사를 전하고 싶습니다.
-
호스트에서 쓰기가능하고 GPU에서 읽기가능 또는 쓰기가능한 메모리 할당 형식만을 다룹니다. GPU에서 쓰여지고 CPU가 읽어와야하는 메모리는
VK_MEMORY_PROPERTY_HOST_CACHED_BIT
플래그 사용이 더 적절합니다. ↩ -
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
는 일반적으로 메모리가 쓰기-결합이 될거란거 암시합니다. 일부 기기에서는 비일관성 메모리를 할당하고vkFlushMappedMemoryRanges()
함수를 호출해서 수동으로 플러시할 수 있습니다. ↩ -
파이프라인당 4개의 설명자를 사용하는경우 이 접근방식은 VS, GS, FS, TCS 그리고 TES 를 위한 완성된 파이프라인 설정을 처리할 수 없습니다. 4개의 설명자 집합만을 제공하는 드라이버에서 테셀레이션을 사용할 경우에만 문제가 됩니다. ↩
-
GPU 아키텍처에 따라 재질 색인이나 정점 자료 옵셋등을 푸시 상수들를 통해 넘겨주는것이 이득일 수 있습니다. 이렇게 하면 정점/조각 쉐이더에서의 메모리 간접 접근수를 줄일 수 있습니다. ↩
-
아쉽게도 불칸은 드라이버가 명령 버퍼 기록에 스레드 안정성을 구현해서 하나의 명령 풀을 여러 스레드간에 재사용하는 방법은 제공하지않습니다. 설명된 체계에서는 스레드간 동기화는 페이지 전환시에만 필요하며 이것은 상대적으로 드물게 발생하고 대부분 락없는 구현이 가능합니다. ↩
-
주의할 점은 각각의 그리기 호출이 다른 작업과 겹쳐지지않고 분리된채로 실행된다고 생각하는것은 잘못된 믿음이라는 것입니다. GPU는 보통 연속된 그리기 호출들을 병렬적으로 실행합니다. 렌더 상태, 쉐이더, 심지어 렌더 타겟 전환에 걸쳐 병렬적으로 실행됩니다. ↩
-
문론 드라이버가 이와같은 최적화를 수행할 지는 보장되지 않습니다. 이것은 기기 아키텍처와 드라이버 구현에 의존합니다. ↩