원문 : https://gpuopen.com/performance-root-signature-descriptor-sets/


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


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



Direct3D 12 와 Vulkan 이전에는 리소스들이 "슬롯( slot )" 을 통해서 셰이더에 바인딩되었습니다. 여러분들 중 일부는 하드웨어가 첫 번째 유닛에 텍스쳐를 바인딩하고 두 번째 유닛에 라이트 맵을 바인딩할 것을 요구했던 소수의 고정함수( fixed-function ) 유닛( unit )들만을 가지고 있었다는 것을 기억할 것입니다. OpenGL 와 Direct3D 11 까지의 바인딩 시스템은 이러한 잔재( heritage )를 보여줍니다. 두 API 에서는 심지어 하드웨어가 이 모델에서 발전했음에도 불구하고 리소스를 바인딩할 수 있는 슬롯 집합이 존재했습니다.


새로운 바인딩 모델에 대해서 살펴 보기 전에, 현대의 GCN 기반 GPU 가 리소스를 식별하는 방법에 대해서 살펴 보겠습니다. 우리가 텍스쳐( texture )에 접근하려고 한다고 해 봅시다 -- 이것이 어떻게 텍스쳐 샘플링 명령( instruction )으로 전달되는 것일까요? GCN ISA 문서를 살펴 보면, 다음과 같은 구절을 찾을 수 있을 것입니다:


모든 벡터 메모리 연산( operation )들은 이미지 리소스 상수( constant ) (T#) 을 사용하는데, 그것은 SGPR 들 안의 128 비트 혹은 256 비트 값입니다. 이 상수는 명령이 실행될 때 텍스쳐 캐시로 전송됩니다. 이 상수는 메모리 상의 서피스에 대한 주소, 데이터 포맷, 특성( characteristics )을 정의합니다.


그러므로 이제 슬롯으로 사용되는 것은 텍스쳐 샘플링 명령으로 넘겨지는 레지스터 집합( couple of registers )들입니다. 다음 구절은 더 흥미롭습니다:


일반적으로 이 상수들은 메모리로부터 페치( fetch, 전송 )됩니다.


이는 텍스쳐를 기술하기 위해서 필요로 하는 모든 것들이 메모리의 아무 곳에나 배치될 수 있는 작은 디스크립터( descriptor, 기술자 )( 128 비트 혹은 256 비트 )라는 것을 의미합니다. 그것이 레지스터에 로드되어 있는 한 올바르게 진행되고 있는 것입니다. 이 문서의 나머지 부분을 읽게 되면, 모든 다른 리소스 유형들에 대해서도 같은 패턴이 사용되고 있다는 것을 알게 될 것입니다. 사실, 리소스 접근을 하게 될 때 "슬롯" 이라는 것은 존재하지 않습니다 -- 대신에 모든 것은 텍스쳐 디스크립터( 혹은 T# ), 샘플러( sampler ) 디스크립터( S# ), 혹은 상수 ( V# ) 을 통해서 진행됩니다. Direct3D 12 와 Vulkan 을 사용하면, 이러한 디스크립터들이 최종적으로 그 자체로서 노출됩니다 -- 일부 GPU 메모리.


GCN 하드웨어에는 특별한 레지스터 집합 -- "사용자 레지스터" 라 불림 -- 들이 존재하는데, 이것들은 디스크립터( 그리고 상수들 -- 아래에서 더 살펴 볼 것임 )들을 저장하는 데 사용됩니다. 이런 레지스터들의 개수는 디스크립터 저장소마다 다른데요, 셰이더 스테이지( stage ), PSO , 드라이버에 의존합니다. 하지만 일반적으로 대충 12 개( dozen ) 정도 됩니다. 각 레지스터는 디스크립터, 상수, 포인터를 사용해 프리로드( pre-load )될 수 있습니다. 레지스터 공간이 넘치면, 드라이버가 메모리의 spill space 를 사용해 더 큰 테이블( table )을 에뮬레이트( emulate )합니다; 이 때 CPU 비용( spill table 관리 -- spill 영역이 변경될 때마다 발생 )과 GPU 비용( 추가적인 포인터 간접 참조( indirection ) )이 모두 발생합니다. 결과적으로, 일제히 변경하는 디스크립터들이나 상수들의 블록들을 가지고 있다면, 그것들을 개별적으로 저장하고 포인터를 사용하여 그것들이 넘치지 않도록 하는 것이 더 낫습니다.


Vulkan resource binding


Vulkan 에서 바인딩 모델은 다음과 같이 설명될 수 있습니다. 디스크립터들은 디스크립터 셋에 배치되고, 하나 이상의 디스크립터 셋을 바인딩할 수 있습니다. 디스크립터 셋 내부에서는 모든 디스크립터 유형들을 자유롭게 섞을 수 있습니다. 또한 레지스터에 프리로드할 수 있는 상수들인 "push constants" 들도 존재합니다.



왼쪽의 블록은 API 에서 암시적( implicit )입니다 -- 여러분은 현재 바인딩되어 있는 것을 볼 수 없습니다 -- 그리고 오른쪽의 블록들은 개별 디스크립터 셋들입니다. 위에서 배웠듯이, 디스크립터 셋들은 플레인 메모리( plain memory, 역주 : 메모리의 구조가 명확함 )입니다. 그래서 모든 패킹( packing ), 캐시 적중( cache hit ) 등과 같은 모든 조언들이 여기에도 적용됩니다. 같이 사용하려고 하는 디스크립터를 가깝게 배치하십시오. 가능하면 인접한( consecutive ) 엔트리( entry )들을 로드하십시오. 메모리 주소들에 접근할 때 큰 점프( jump )를 피하십시오. 텍스쳐와 함께 사용하고자 하는 샘플러들에 대한 정보를 더 알고 있다면, 그것들을 샘플러와 텍스쳐를 가깝게 배치하는 "combined image sampler" 로 결합함으로써 디스크립터 셋을 최적화할 수 있습니다.


동적 버퍼( dynamic buffer ) 디스크립터들에 대해서 궁금할 것입니다: 그것들은 버퍼와 옵셋( offset )에 대한 접근을 제공합니다. 그것들은 기본적으로 플레인 상수를 가진 혼합 버퍼 디스크립터입니다. 이 상수들은 레지스터들을 사용하게 되므로, 많은 동적 버퍼들에서 문제가 될 수 있습니다. 이를 우회하기 위한 두 가지 방법이 존재합니다. 인덱싱( indexing )중인 데이터가 동일한 너비( uniform stride )를 가지고 있다면, 그냥 그 버퍼들을 바인딩하고 단일 상수( 혹은 다른 버퍼 내에 저장된 다중 상수들 )를 사용해 인덱싱하면 됩니다. 만약 너비가 고정되지 않았지만 옵셋을 알고 있다면, 디스크립터 배열을 생성하고 대상 위치로 점프하도록 인덱싱할 수 있습니다. 다시 말해, 여러분이 처음 사용하는 인덱스가 단일 상수인지 다른 버퍼로부터의 인덱스인지 확인해야 합니다.


Direct3D resource binding


Direct3D 는 루트 시그너쳐( root signature )를 통해서 암시적 바인딩을 노출하는데, 이는 실시간에 관리됩니다. 이는 위에서 설명했던 사용자 레지스터들 상에서 매우 직관적으로 매핑합니다. 하지만 API 에는 몇 가지 제약들이 존재합니다: 테이블 내의 다른 디스크립터들을 사용해 샘플러 디스크립터들을 저장할 수 없습니다. 또한 루트 시그너쳐에는 디스크립터 테이블, 루트 시그너쳐, 버퍼 포인터에 대한 포인터만이 포함될 수 있습니다. 컴파일타임에 샘플러 디스크립터를 지정할 수 있는 한 가지 대안이 있기는 하지만, 텍스처 디스크립터나 샘플러 디스크립터를 루트 시그너쳐에 배치할 수는 없습니다.


혼합된 텍스쳐 디스크립터와 샘플러 디스크립터는 존재하지 않습니다; 대신에 분리된 디스크립터 힙( heap )들에 저장되어야만 합니다.



그것을 제외하고는 바인딩 모델은 Vulkan 과 동일합니다. 그리고 디스크립터를 통해 리소스들에 접근할 것을 요구합니다. 둘의 주요한 차이는 루트 시그너쳐가 특정한 종류의 디스크립터들이 인라인( in-line )으로 저장되는 것을 허용한다는 것입니다. 반면에 Vulkan 은 모든 경우에 디스크립터 셋을 통해서 진행하는 것을 기대합니다.


Performance guidelines


최적화의 시작점은 루트 시그너쳐를 가능한 한 작게 유지하는 것입니다. 루트 시그너쳐가 커진다는 것은 spill 이 될 확률이 올라가고 파라미터들을 유효화( validating )할 때 드라이버에 전달될 더 많은 엔트리들이 필요함을 의미합니다. 이는 디스크립터 테이블을 적게 바인딩하고, 큰 상수들을 설정하는 것을 피하고, 피할 수 있다면 Direct3D 에서 버퍼 뷰를 사용하지 않아야 함을 의미합니다. 단일 상수 버퍼 뷰가 용납될 수 있기는 하지만, 항상 바인딩되어 있는 큰 상수 버퍼 내의 옵셋을 제공하는 상수를 사용하는 것이 좋습니다. 아래의 루트 시그너쳐를 살펴 봅시다:



그것은 포인터로 시작해서, 몇 개의 루트 상수들, 몇 개의 포인터들, 그리고 두 개의 디스크립터들로 끝납니다. 그 두 개의 디스크립터들이 넘쳐흐를 가능성이 존재하므로, 드라이버가 관리하는 오우버플로우( overflow ) 버퍼들을 만나게 될 것입니다. 루트 시그너쳐를 가능한 한 작게 유지해서 이러한 문제를 피하십시오!


어쨌든 항상 가장 자주 변경되는 파라미터들을 목록의 앞에 유지하도록 노력해야 합니다. 만약 spill 이 발생하면 주파수가 급격히 떨어지며, 파라미터들을 유효화시 드라이버 부하도 줄어들 것입니다. 이는 드라이버가 오우버플로우 처리 상수( overflow handling constant )를 유지할 수 있도록 합니다. 그리고 가장 자주 변경되는 엔트리가 성능을 위해 레지스터에 남아 있도록 합니다.


오늘은 이만! 질문이 있다면 자유롭게 댓글을 남기거나 Twitter 에서 ping 해 주세요:  @NThibieroz & @NIV_Anteru.


Tweets


  • 07: Keep your root descriptor set small and place most frequently changed entries first.
  • 17: Order root signature parameters by change frequency (most frequent to least frequent).
  • 27: Keep your descriptor sets constant, reference resources that belong together in the same set.
  • 36: Use as few root signature slots as possible. Frequently updated slots should be grouped at the beginning of the signature
  • 44: Try to make root signatures as small as possible (create multiple root signatures if needs be).
  • 49: If using multiple root signatures, order/batch draw calls by root signature.
  • 54: Only constants or constant buffers changing every draw should be in the root signature.


Matthäus Chajdas is a developer technology engineer at AMD. Links to third party sites, and references to third party trademarks, are provided for convenience and illustrative purposes only. Unless explicitly stated, AMD is not responsible for the contents of such links, and no third party endorsement of AMD or any of its products is implied.

+ Recent posts