원문 : https://developer.samsung.com/game/usage


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


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



Introduction


Vulkan 용례 가이드는 독자가 이미 API 와 친숙하지만 여러 종류의 Galaxy 디바이스에서 효율적으로 사용하는 방법에 대해서 알고자 한다고 가정합니다. 또한 일반적으로 모바일 디바이스들에서 찾아볼 수 있는 타일 기반 렌더링( Tile Based Rendering, TBR ) GPU 아키텍쳐의 기초에도 익숙하다고 가정합니다. Vulkan 초심자라면 API 개념을 코드 예제와 함께 소개하는 추천 SDK 들부터 시작하실 수 있습니다. GPU 아키텍쳐에 대해서는 여기에서 학습하실 수도 있습니다.


이 문서를 읽기 전에, Game Asset Optimization Recommendations 에도 익숙해지기를 권장합니다.


Understand your target



고성능 애플리케이션을 개발할 때, 여러분이 대상으로 하는 하드웨어 및 API 의 기능( capabilities )과 성능 특성( characteristics )을 이해하는 것이 필수적입니다.


Vulkan 의 경우, 다음을 포함합니다:


  • 최대 그래픽스 API 버전.
  • 기능들
    • 예를 들어 최대 텍스쳐 해상도.
  • 확장( extension )들.


다른 성능 집중 API 와 마찬가지로, Vulkan 에는 API 사용자가 고려하지 않으면 정의되지 않는 동작을 발생시킬 수 있는 상황들이 존재합니다. 크로노스( Khronos ) Vulkan Validation LayersGraphics API debugging tools 는 API 의 오용을 식별하는 데 도움을 줍니다. 또한 개발 초기 단계에 버그를 식별하기 위해 애플리케이션을 광범위한 디바이스, 칩셋( chipset ), GPU 아키텍쳐에 대해서 테스트하는 것이 매우 중요합니다.


  • GPU 아키텍쳐의 차이를 고려하십시오.
  • 런타임에 기능들과 확장들을 검사하십시오. 대안( fall-back )들을 구현하십시오.
  • 애플리케이션을 여러 디바이스에서 테스트하십시오.


Asset optimization


Game Asset Optimization Recommendations 을 확인하세요.


Shader Precision


SPIR-V 는 정밀도 한정자( precision qualifier )들을 지원합니다( OpDecorate RelaxedPrecision ). 정밀도 힌트( hint )는 ALU 연산들의 성능을 개선하기 위해서, 개발자가 감소된( reduced ) 정밀도를 사용할 위치를 컴파일러에게 알려줄 수 있도록 하며, 결국 GPU 의 전력 소비를 줄이게 됩니다.


변수에 대해 요청된 정밀도를 컴파일러가 받아들이는 것은 유효합니다. 예를 들어 개발자가 RelaxedPrecision 을 지정할 때는 32 비트 부동소수점 정밀도를 사용합니다. 컴파일러는 정밀도 변환을 위해 도입된 명령어가 완전한( full ) 정밀도로 계산을 실행하는 것보다 더 많은 부하를 발생시키는 경우에 이 방법을 사용하는 경향이 있습니다.


  • 컴파일러가 정밀도를 높이는( promote ) 것에 주의하십시오.  A 디바이스( promoted precision )에서 완벽하게 동작하는 셰이더가  B 디바이스( honors precision qualifier )에서 아티팩트( artifact )를 발생시킬 수 있습니다.
  • 감소된 정밀도로 인해 일부 디바이스들에서 렌더링 에러들이 감춰져 있는 상황에 주의하십시오.


Recommendations


  • 성능을 향상시키고 전력 소비를 줄이기 위해서 감소된 정밀도를 사용하십시오.
  • 컴파일러가 정밀도를 높이는 것에 주의하십시오. 셰이더 정밀도 아티팩트들을 초기에 발견하려면 여러 디바이스에서 테스트하십시오.
  • 감소된 정밀도로 인해 일부 디바이스들에서 렌더링 에러들이 감춰져 있는 상황에 주의하십시오.


Pipeline management


파이프라인을 드로( draw ) 시점에 생성하는 것은 성능 끊김( stutter )을 유발합니다. 파이프라인을 애플리케이션을 실행할 때 가능한 한 초기에 생성하는 것을 권장합니다. 파이프라인을 드로 시점 전에 생성하도록 렌더링 엔진을 재구성하는 것이 불가능하다면, 파이프라인을 한 번만 생성하고 그것들을 맵에 넣어서 연속되는 드로 호출들에서 해싱된( hashed ) 스테이트( state )들을 통해 검색할 수 있도록 하는 것을 권장합니다.


파이프라인 캐시는 새로운 파이프라인이 생성될 때 드라이버가 캐싱된 파이프라인들로부터 스테이트들을 재사용할 수 있도록 해 줍니다. 이는 셰이더 컴파일같은 반복되는 비싼 연산들 대신에 이미 구워진 스테이트를 재사용함으로써 성능을 상당히 개선할 수 있습니다. 단일 파이프라인 캐시를 사용해서 드라이버가 이전에 생성되었던 모든 스테이트들을 재사용할 수 있도록 보장하는 것을 권장합니다. 또한 파이프라인 캐시를 파일에 써서 다음 번에 애플리케이션이 실행될 때 재사용될 수 있도록 하기를 권장합니다.


파이프라인 상속( derivative )은 애플리케이션이 "자식" 파이프라인들을 유사한 "부모" 파이프라인으로부터의 증분 스테이트 변경( incremental state changes )으로서 표현할 수 있도록 해 줍니다; 일부 아키텍쳐들에서 이는 유사한 스테이트들 간의 전환 비용을 줄여줄 수 있습니다. 많은 모바일 GPU 들은 주로 파이프라인 캐시를 통해 성능을 개선합니다. 그래서 파이프라인 상속은 종종 휴대용( portable ) 모바일 애플리케이션에 대해서는 이점을 제공하지 않습니다.


Recommendations


  • 애플리케이션 실행 초기에 파이프라인들을 생성하십시오. 파이프라인 생성이 드로 시점에 발생하는 것을 피하십시오.
  • 모든 파이프라인 생성에 대해서 단일 파이프라인 캐시를 사용하십시오.
  • 파이프라인 캐시를 애플리케이션 실행되는 사이에 파일에 쓰십시오.
  • 파이프라인 상속을 피하십시오.


Descriptor set management


디스크립터 셋은 드로를 위한 리소스 바인딩을 정의합니다. 이상적으로 볼 때, 디스크립터 셋들은 빌드 시점에 생성되어야 하고 런타임 실행을 위해 캐싱되어야 합니다. 그것이 불가능하면, 디스크립터 셋들을 가능한 한 애플리케이션 실행 초기에 생성해야 합니다( 애플리케이션 로드나 레벨 로드 ).


Vulkan 의 대부분의 리소스들과 마찬가지로, API 사용자는 디스크립터 셋 갱신( update )을 동기화( synchronizing )할 책임이 있습니다. 그래야 대기중인 디바이스 읽기가 존재할 때 호스트에서 변경이 발생하지 않는 것이 보장됩니다. 리소스 동기화를 단순화하기 위해서 스왑 인덱스( swap index ) 별 디스크립터 풀( pool )을 사용하고 같은 바인딩을 사용하는 드로 사이에서 디스크립터 공유( sharing )를 이용( facilitate )하는 것을 권장합니다. 렌더링 엔진을 재구성하는 것이 불가능하고 드로 시점에 디스크립터 셋을 갱신할 필요가 있다면, 사용중인( in-flight ) 디스크립터를 변경하는 것을 피하기 위해, 디스크립터 셋과 버퍼를 관리하는 전략이 매우 주의깊게 고려되어야 합니다. 아래의 Buffer management 에서도 기술하듯이, 엔진이 최소 VkPhysicsDeviceLimits::maxUniformBufferRange 값에 대처하도록 하는 것이 중요합니다. 왜냐햐면 디스크립터들 사이에서 버퍼를 공유할 때 그 제한을 쉽게 초과할 수 있기 때문입니다.


유니폼( uniform ) 버퍼나 스토리지( storage ) 버퍼의 옵셋( offset )이 자주 변경된다면( 예를 들어 드로 때마다 ), VK_DESCRIPTOR_TYPE_*_BUFFER_DYNAMIC 을 사용해 버퍼를 동적으로 바인딩하고 vkCmdBindDescriptorSets() 가 호출될 때 옵셋을 pDynamicOffsets 로 설정하는 것을 권장합니다. 이는 그 옵셋들이 드로 실행 전에 디스크립터 셋을 수정하지 않고 변경될 수 있도록 해 줍니다.


Recommendations


  • 디스크립터 셋을 가능한 한 초기에 빌드하십시오( 이상적으로는 빌드 시점에 ).
  • 드로에 의해 참조되지 않는 리소스들을 바인딩하지 마십시오.
  • 버퍼 옵셋이 자주 변경될 필요가 있다면( 예를 들어 드로 사이에서 ), 디스크립터를 바인딩할 때 pDynamicOffset 를 사용하십시오.
  • 높은 빈도로 디스크립터 셋을 수정하지 마십시오. 동적 업데이트가 요구된다면, 읽기와 쓰기에 대한 동기화를 보장해야 합니다.


Buffer management


Game Asset Optimizations: Interleaved vertex attributes 에서 기술했듯이, position 애트리뷰트들은 다른 애트리뷰트들과는 다른 버퍼에 저장되어야 합니다. 이는 현대 모바일 GPU 들이 버텍스( vertex ) 셰이딩을 더 효율적으로 실행할 수 있게 해 줍니다. 서로 다른 빈도로 업데이트를 하지 않는 한에는, 다른 모든 애트리뷰트들은 단일 버퍼에 삽입되어야 합니다.


유니폼 버퍼들은 애플리케이션 실행시에 가능한 한 초기에 할당되어야 하며, 프레임 당 할당은 피해야 합니다( 이전의 할당을 재사용하는 것이 훨씬 빠릅니다 ). 실행중인 렌더러들이 현재 프레임의 API 호출에 의해 변경되는 버퍼 영역에 접근하는 것을 끝마치도록 보장하기 위해 펜스( fence )를 사용해야 합니다.


유니폼 버퍼는 서로 다른 빈도로 업데이트되는 데이터를 포함해서는 안 됩니다. 예를 들어, 드로가 정적인 데이터와 프레임 당 설정되는 데이터( 예를 들어 변환 행렬 )에 의존한다면, 두 개의 버퍼가 사용되어야 합니다. 다중 드로에 대해 공유되는 유니폼 데이터는 단일 버퍼에 저장되어야 합니다.


중복 전송( redundant transfer )들을 막기 위해서, 유니폼 데이터 메모리는 VK_MEMORY_PEROPERTY_HOST_VISIBLE_BIT 셋으로 할당되어야 합니다. 이 플래그는 vkMapMemory() 가 호스트로부터 효율적인 수정을 위해 사용될 수 있도록 합니다( 스테이징( staging) 버퍼에 비하면 복사 한 번이 더 적습니다 ). 잦은 Map/Unmap 호출은 피해야 합니다. 가능하다면 버퍼는 VkMemoryProeprtyFlagBits::VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 를 설정함으로써 영구적으로 매핑될 수 있습니다. 주의 : 영구적인 매핑은 API 캡쳐 도구들이 버퍼 수정을 트래킹하는 것을 더 어렵게 만듭니다. 이러한 이유로, 전용 GPU 메모리를 사용하는 플랫폼( platform )을 위해 영구적으로 매핑되지 않는 대안( non-persistently mapped fallback ) 경로를 구현하여 디버깅을 단순화하는 것을 권장합니다.


버퍼는 VkPhysicalDeviceLimits::min*Alignment 제한으로 정렬( align )되어야 합니다. 디스크립터 셋 바인딩과 버퍼 할당의 최대 크기는 다음과 같이 질의될 수 있습니다:


 Limit

 Desriptions

 VkPhysicalDeviceLimits::maxUniformBufferRange

 최대 유니폼 버퍼 메모리 범위.

 VkPhysicalDeviceLimits::maxDescriptorSetUniformBuffers

 디스크립터 셋에 바인딩될 수 있는 유니폼 버퍼의 최대 개수.

 VkPhysicalDeviceLimits::maxDescriptorSetUniformBuffersDynamic

 디스크립터 셋에 바인딩될 수 있는 동적 유니폼 버퍼의 최대 개수.

 VkPhysicalDeviceLimits::maxPerStageDescriptorUniformBuffers

 단일 셰이더 스테이지( stage )에서 접근할 수 있는 유니폼 버퍼의 최대 개수.


Descriptor set management 에서 기술했듯이, 디스크립터 셋 갱신을 최소화하기 위해서 동적 버퍼들과 동적 옵셋들을 사용해야 합니다.


셰이더 입력의 경우에는 항상 스토리지 버퍼보다는 유니폼 버퍼를 선호( prefer )해야 합니다.


Recommendations


  • 애트리뷰트 데이터를 두 개의 버퍼에 저장하십시오 - 하나는 버텍스 position 을 위해, 다른 하나는 다른 모든 애트리뷰트들을 위해.
  • 유니폼 버퍼들의 옵셋을 VkPhysicalDeviceLimits::minUniformBufferOffsetAlignment 로 정렬하십시오.
  • 통합( unified ) 메모리를 사용하는 디바이스 상에서는, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 를 사용하고, 플러싱( flush )들과 잦은 map/unmap 호출들에 대해 영구적인 버퍼 매핑을 선호하십시오.


Shader inputs


Vulkan 은 셰이더 상수들을 설정하기 위한 다양한 메커니즘을 제공합니다; 유니폼 버퍼, 특수화( specialization ) 상수, 푸시( push ) 상수.


특수화 상수들은 정적인 값들인데, 파이프라인 생성 시점에 설정될 수 있습니다( SPIR-V 바이너리가 컴파일될 때 ).


푸시 상수들은 버퍼 오브젝트가 아니라 Vulkan 커맨드를 통해서 셰이더 상수 입력 데이터를 설정하기 위한 메커니즘을 제공합니다. 푸시 상수 저장 공간은 제한적입니다. 푸시 상수 저장소를 위한 최대 바이트 수는 VkPhysicalDeviceLimits::maxPushConstantsSize 를 사용해 질의될 수 있습니다. 가능하다면 동적 유니폼 퍼버 옵셋과 같은 특수화 옵셋 메커니즘을 푸시 상수보다는 더 선호해야 합니다. 렌더링 엔진이 모든 드로들을 푸시 상수 데이터( 128 바이트 )를 위해 최소 maxPushConstantsSize 바이트 미만으로 사용하는 것을 보장할 수가 없다면, 유니폼 버퍼 기반 대안을 구현해 둘 필요가 있습니다. 이는 가장 작은 크기의 푸시 상수 바이트만을 지원하는 Vulkan 구현에서 렌더링 엔진이 작동하는 것을 보장합니다. 동적 유니폼 버퍼 옵셋을 사용하는 경우, vkCmdBindDescriptorSets() 를 호출해 드로 당 옵셋을 설정할 수 있습니다.


아래 목록들은 셰이더에서 상수값을 설정하는 것과 관련한 권장 사향들을 정리한 것입니다:


  • 정적 브랜칭( static branching ): SPIR-V 생성 시점에 실행 경로들을 지정할 수 있다면, 실행 경로를 위해 전용 SPIR-V 를 생성하십시오( 예를 들어 GLSL 의 #define 을 사용 ). 그렇지 않으면, SPIR-V 파이프라인 생성 시점에 특수화 상수를 사용하십시오.
  • 파이프라인 생성 시점 상수들: 파이프라인 생성 시점에 상수값을 설정할 수 있다면, 특수화 상수들을 사용하십시오.
  • 유니폼 버퍼에 대한 잦은 갱신들: vkCmdBindDescriptorSets() 호출 후에 유니폼 데이터 갱신이 여러 드로들에 적용되면, 동적 유니폼 버퍼를 사용하고 vkCmdBindDescriptorSet() 이 호출될 때 동적 옵셋들을 사용하십시오. 그래야 디스크립터 셋에 대한 변경을 피할 수 있습니다( 자세한 내용을 알고자 한다면 Descriptor set management 섹션을 참조하세요 ). 유니폼 데이터가 자주 갱신된다면, 예를 들어 드로마다 갱신된다면, 동적 유니폼 버퍼 옵셋 대신에 푸시 상수를 사용하는 것을 고려해 보십시오.


Recommendations


  • 생성 시점 정적 브랜칭을 위해 전용 SPIR-V 를 생성하십시오( 예를 들어 셰이더 코드에서의 #define ).
  • SPIR-V 컴파일 시점 정적 브랜칭을 위해 특수화 상수를 사용하십시오.
  • SPIR-V 컴파일 시점 상수 값들을 위해 특수화 상수를 사용하십시오.
  • 유니폼 데이터 갱신이 vkCmdBindDescriptorSets() 호출 후에 여러 드로에 적용된다면, 동적 옵셋 유니폼 퍼버를 사용하십시오.
  • 드로 호출마다 유니폼 데이터가 갱신되다면 푸시 상수를 고려해 보십시오.
  • 여러분의 엔진이 높은 빈도로 128 바이트보다 작은 데이터를 사용하고 있다면 푸시 상수들을 사용하십시오. 이것이 보장되지 않는다면, 동적 유니폼 버퍼 대안 경로를 구현하십시오.


Beware: No Implicit uniform type conversions


OpenGL ES 와는 다르게, Vulkan 은 묵시적으로 유니폼 타입 변환을 수행하지 않습니다. 개발자들은 버퍼 바인딩의 내용이 그것들이 바인딩하려고 하는 셰이더 유니폼 버퍼들과 일치하도록 보장할 책임이 있습니다.


Recommendations


  • 유니폼 버퍼 데이터 타입들이 셰이더 유니폼 변수들과 일치하도록 보장하십시오.


View frustum culling


드라이버와 GPU 가 처리하는 가장 싼 드로는 제출되지 않는 드로입니다. 드라이버와 GPU 의 중복 처리를 피하기 위해, 일반적인 최적화된 렌더링 엔진은 뷰 프러스텀 경계내에 존재하거나 겹치는 것들만을 그래픽스 API 에 제출합니다. 뷰 프러스텀 컬링은 보통 CPU 상에서 싸게 수행되며, 복잡한 3D 씬들을 렌더링할 때는 항상 고려되어야 합니다.


Recommendations


  • 드라이버와 GPU 의 중복 처리를 막기 위해 뷰 프러스텀 컬링을 항상 시도하십시오.


Command buffer binding


Vulkan 은 커맨드 버퍼들이 다중 스레드에서 빌드되는 것을 허용하도록 설계되었으며, 이는 이 비싼 작업들이 다중 CPU 코어들에서 수행될 수 있도록 해 줍니다. 또한, 세컨더리( secondary ) 커맨드 버퍼들이 생성될 수 있으며, 그것은 작업을 더 작은 청크( chunk )로 쪼개는 것을 쉽게 해 줍니다. 세컨더리 커맨드 버퍼는 생성 후에 프라이머리( primary ) 커맨드 버퍼에 커밋( commit )되어야 합니다. 하지만, 일부 구현에서는, GPU 가 렌더 패스 내의 모든 커맨드 버퍼가 단일한 연속된 메모리 블락에 속해 있는 것을 요구합니다 -- 이런 GPU 들을 위한 Vulkan 드라이버들은 커맨드 버퍼가 실행되기 전에 세컨더리 커맨드 버퍼를 프라이머리 커맨드 버퍼로 memcpy() 할 필요가 있습니다. 이러한 부하때문에, 세컨더리 커맨드 버퍼들보다는 프라이머리 커맨드 버퍼들을 선호할 것을 권장합니다. 렌더링을 다중 CPU 코어들 사이에서 다중 스레드로 처리하는 것을 선호한다면, 세컨더리 커맨드 버퍼들을 고려하기 보다는 프라이머리 커맨드 버퍼들을 병렬적으로 빌드하는 것을 권장합니다.


세컨더리 커맨드 버퍼를 사용하기로 결정했다면, 분할 계획( partitioning scheme )을 주의깊게 고려하십시오. 씬이 청크들로 빌드될 때, 여러분의 엔진이 드로 호출 제출 순서를 최적화하고 스테이트( state ) 변경을 최소화하도록 만드는 것이 더 어렵습니다. 세컨더리 커맨드 버퍼 빌드 경로가 구현되었다면, 런타임( run-time )에 그 경로가 요구되는지 혹은 프라이머리 커맨드 버퍼 빌드가 더 빠른지 결정해야 합니다.


Recommendations


  • 세컨더리 커맨드 버퍼들을 사용하기 보다는 프라이머리 커맨드 버퍼들에 서밋( submit )하는 것을 선호하십시오.
  • GPU 제한이 있는 디바이스들에서는 세컨더리 커맨드 버퍼를 사용하는 것을 피하십시오.
  • 세컨더리 커맨드 버퍼들을 고려하기 보다는 병렬적으로 프라이머리 커맨드 버퍼들을 빌드하는 것을 고려하십시오.
  • 세컨더리 커맨드 버퍼를 사용한다면, 분할 계획을 주의깊게 고려하십시오.


Instanced draws


모든 Vulkan CmdDraw* 함수들은 instanceCount 파라미터를 받습니다. 인스턴스 당 데이터는 VkVertexInputBindingDescription::inputRate 를 VK_VERTEX_INPUT_RATE_INSTANCE 로 설정된 버퍼를 바인딩함으로써 제공됩니다.


Recommendations


  • 항상 단일 호출 및 인스턴스 당 데이터를 사용하여 인스턴스화된 오브젝트를 렌더링하십시오.


Clearing framebuffer attachments


Vulkan 에는 프레임버퍼 어태치먼트를 클리어하기 위한 세 가지 메커니즘이 존재합니다 :


  • 렌더 패스 로드 연산( VK_ATTACHMENT_LOAD_OP_CLEAR ).
  • vkCmdClearAttachments() 함수.
  • vkCmdClearColorImage() 함수 및 vkCmdClearDepthStencilImage() 함수.


연산들이 효율적으로 수행되도록 하기 위해서는, 주어진 시나리오를 위해 사용되는 메커니즘을 올바르게 선택하는 것이 중요합니다:


Recommendations


  • 렌더 패스의 시작부에서 어태치먼트들을 클리어할 때는, VK_ATTACHMENT_LOAD_OP_CLEAR 를 사용하시오.
  • 서브 패스 내에서 어태치먼트들을 클리어할 때는 VkCmdClearAttachements() 를 사용하십시오.
  • vkCmdClearColorImage() 와 vkClearDepthStencilImage() 는 렌더 패스 외부에서 클리어하는 데 사용될 수 있습니다. 이 함수들은 타일 기반 GPU 아키텍쳐들에서 가장 비효율적인 메커니즘들입니다.


Efficient render pass upscaling


고화질( high-fidelity ) 3D 게임들에서 일반적인 병목은 프래그먼트( fragement ) 셰이딩 실행 시간입니다. 프레임당 프래그먼트 셰이딩 비용을 줄이기 위해, 게임 씬이 줄어든 해상도로 렌더링되고 나서 디바이스의 원래 해상도로 사용자 인터페이스를 렌더링하기 전에 크게 스케일링될 수 있습니다.


  • 렌더 패스 A( 줄어든 해상도 )
    • 게임 씬 렌더링.
  • 렌더 패스 B( 원래 해상도 )
    • 게임 씬 이미지를 업스케일링.
    • UI 를 렌더링.


업스케일링은 두 가지 방식으로 수행될 수 있습니다:


  1. vkCmdBlitImage() 호출.
    1. 소스 이미지의 영역들을 대상 이미지로 복사하는데, 잠재적으로 포맷 변환, 임의의 스케일링 및 필터링을 수행합니다.
    2. 구현에 따라, 이 연산은 전용 블리팅( blitting ) 하드웨어에 의해 GPU 나 GPU 에서 수행될 수도 있습니다.
  2. 전체 화면 사각형 렌더링.
    1. 렌더 패스 B 를 이미지를 샘플링하는 전체 화면 드로 호출로 시작합니다.


vkCmdBlitImage() 가 최적의 선택인 것으로 보이겠지만, 모바일 GPU 들에서는 전체 화면 사각형을 렌더링하는 것보다 덜 효율적인 경향이 있습니다. 그 이유는 그것이 하나의 VkImage 로부터 다른 VkImage 로의 명시적인 복사를 요구하기 때문입니다. 블리트 연산을 위해서 GPU 를 사용하는 구현들 상에서는, 이것이 렌더 패스 A 와 B 사이에서 추가적으로 구현되어야 할 것입니다 -- 낭비될 수도 있는 메모리 대역폭과 GPU 사이클을 소비하게 됩니다. 반면에, 전체 화면 사각형 접근법은 단지 한 VkImage 에 대한 레이아웃 트랜지션( transition )만을 요구합니다. 트랜지션의 유형에 따라 이것은 "무료" 로 수행될 수 있도록 구현될 수 있습니다.


Recommendations


  • 프래그먼트 셰이더 제약이 있을 때 게임 씬 업스케일링만을 고려하십시오. 업스케일링의 대역폭 비용에 주의하십시오.
  • vkCmdBlitImage() 보다는 전체 화면 사각형 업스케일링을 선호하십시오.


Subpasses


모바일 디바이스들은 제한된 메모리 대역폭을 가집니다. 게다가 메모리 대역폭 데이터 전송( transfer )들은 이를 위해 전력을 소비하게 되므로, 가능한 한 적게 사용하는 것이 최상입니다.


3D 그래픽스 렌더링에서, 프레임버퍼는 하나 이상의 어태치먼트들을 필요로 할 것이고 일부 어태치먼트 데이터들은 보존( preserve )될 필요가 있습니다 -- 다른 모든 어태치먼트 데이터는 임시적( temporary )입니다. 예를 들어 컬러 버퍼는 렌더링된 이미지를 위해 요구될 것이고, 뎁스 버퍼는 프리미티브들이 의도된 순서로 렌더링되는 것을 보장하기 위해서 요구될 것입니다. 이 시나리오에서, 뎁스 데이터는 보존될 필요가 없습니다 .그러므로 GPU 메모리에서 시스템 메모리로 그것을 쓰는 것은 대역폭을 낭비하게 됩니다. 또한 N - 1 프레임에 있는 컬러 및 뎁스 버퍼의 내용은 N 프레임에서 요구되지 않습니다. 이 데이터를 업로드하는 것은 메모리 대역폭을 중복으로 사용하기 때문에, 드라이버에게 그런 연산들이 요구되지 않음을 전달하고 싶을 것입니다.


Attachment load op


각 어태치먼트들의 VkAttachmentLoadOp 속성은 어태치먼트들이 서브패스 시작할 때 초기화되어야 하는 방법을 지정합니다.


  • VK_ATTACHMENT_LOAD_OP_DONT_CARE
    • 이는 가낭 효율적인 선택입니다. 어태치먼트가 초기화될 필요가 없을 때 사용되어야 합니다. 예를 들어 모든 픽셀이 스카이 박스나 어떤 드로에 의해서 칠해지는 경우입니다.
  • VK_ATTACHMENT_LOAD_OP_CLEAR
    • 이 연산은 타일러( tiler ) 매우 효율적입니다. Clearing framebuffer attachements 섹션에서 기술했듯이, 이것은 렌더 패스를 시작할 때 어태치먼트를 클리어하는 가장 효율적인 방법입니다.
  • VK_ATTACHMENT_LOAD_OP_LOAD
    • 이것은 가장 비용이 드는 연산입니다. 타일 기반 렌더러에서, 타일( on-tile ) 메모리는 어태치먼트의 보존된 데이터를 시스템 메모리로부터 읽어들임으로써 초기화됩니다.


Attachment store op


각 어태치먼트들의 VkAttachmentStoreOp 속성은 어태치먼트가 서브패스의 끝에서 저장되는 방법을 지정합니다.


  • VK_ATTACHMENT_STORE_OP_DONT_CARE
    • 이는 가장 효율적인 선택입니다. 어태치먼트가 보존될 필요가 없을 때 사용되어야 합니다. 예를 들어 뎁스와 스텐실 데이터가 이 패스를 렌더링하기 위해서만 사용되는 경우입니다.
  • VK_ATTACHMENT_STORE_OP_STORE
    • 이는 가장 비용이 드는 연산입니다. 타일 기반 렌더러에서, 타일 메모리는 어태치먼트 이미지를 시스템 메모리로 저장함으로써 보존됩니다.


어태치먼트 이미지가 결코 로드되거나 저장되지 않는다면, VkImage 는 VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT 를 사용해 생성되어야 하며, VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT 속성을 사용해 메모리에 바운딩되어야 합니다. 이는 드라이버가 그 이미지를 임시적( transient )으로 처리할 수 있도록 해 줍니다. 그런 메모리는 나중에 할당될 수 있습니다.


Advanced subpass usage


디퍼드 라이팅( deferred lighting ) 시스템을 위한 같은 멀티 패스 렌더링같은 발전된 서브패스 주제들은 Introduction to Vulkan Render Passes 기사에서 다루고 있습니다.


Recommendations


  • VK_ATTACHMENT_LOAD_OP_DONT_CARE 를 기본으로 두고, 어태치먼트가 클리어되어야 할 필요가 있다면 VK_ATTACHMENT_LOAD_OP_CLEAR 를, 이전의 어태치먼트 데이터가 보존될 필요가 있다면 VK_ATTACHMENT_LOAD_OP_LOAD 를 사용하십시오.
  • 모든 어태치먼트들이 보존될 필요가 없다면 VK_ATTACHMENT_STORE_OP_DONT_CARE 를 사용하십시오.
  • 어태치먼트 이미지가 결코 로드되거나 저장되지 않는다면, VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT 를 사용해 할당하고 VK_MEMORY_LAZILY_ALLOCATED_BIT 속성을 가지고 할당된 메모리를 사용하십시오.


Synchronization


Vulkan 에서 동기화는 복잡하며, 이를 지원하는 게임에들에서 일반적인 버그들의 온상입니다. 다음 챕터들은 동기화 권장 사항들에 대해서 설명합니다.


Terminology


동기화 개체( primitive )들에 대해서 이해하려고 하기 전에, 이 챕터들을 통해서 사용되는 Vulkan 의 용어들에 대해서 이해하는 것이 중요합니다:


  • 동기화 스코우프( Synchronization scope ): 연산들의 집합입니다. 대부분의 동기화 커맨드들에서, 소스( source ) 파이프라인 스테이지 마스크와 대상( destination ) 파이프라인 스테이지 마스크들에 의해 동기화 스코우프들이 정의됩니다.
  • 실행 종속성( Execution dependency ): 대부분의 동기화 커맨드들은 실행 종속성을 정의합니다. 첫 번째 동기화 스코우프에서 정의된 모든 연산들은 두 번째 동기화 스코우프 내에서 정의된 모든 연산들보다 먼저 실행되어야 합니다.
  • ( 역주 : 접근 ) 가능성 연산( Availability operation ): 이는 다음 메모리 접근이 발생하기 전에 완료되어야 하는 메모리 연산들을 지정합니다. 예를 들어, 이미지 메모리 배리어( barrier )는 VkImage 의 컬러 어태치먼트 쓰기가 다음 접근들 전에 완료되어야 함을 지정합니다.
  • ( 역주 : 읽기 ) 가시성 연산( Visibility operation ): 이는 주어진 가능성 연산이 발생하기 전에 한 번만 발생해야 하는 메모리 연산을 지정합니다. 예를 들어, 이미지 메모리 배리어는 지정된 가능성 연산이 완료되기 전에 셰이더가 어태치먼트를 한 번 읽어들이게 됨을 지정할 수 있습니다.
  • 리소스 트랜지션( Resource transition ): 어떤 VkImage 리소스들은 가능성 연산과 가시성 연산 사이에서 한 레이아웃에서 다른 레이아웃으로 전이( transition )될 필요가 있습니다. 이미지 메모리 배리어의 경우에, 리소스 트랜지션은 oldLayout 과 newLayout 으로 정의됩니다.
  • 메모리 종속성( Memory dependency ): 이는 가시성 연산들의 집합 이전에 완료되어야만 하는 가능성 연산들의 집합을 정의합니다. 메모리 종속성은 리소스 트랜지션을 정의하기도 합니다.


Semaphores


세마포어는 다중 큐 사이에서 리소스 접근을 제어하는 데 사용될 수 있습니다. 가장 일반적인 세마포어의 용례는 그래픽스 큐와 프리젠테이션 큐를 동기화하는 것입니다.


예제: 그래픽스 큐 <--> 프리젠테이션 큐 동기화


코드 블락 1 : Per-Frame



더 많은 정보를 원한다면 이 페이지를 참고하세요.


Fences


펜스는 큐에서 호스트와 통신하기 위해서 사용될 수 있습니다. 가장 일반적인 용례는 그래픽스 렌더링이 완료되었을 때를 시그널( signal )링하는 것입니다. 그러므로 그 리소스들이 다음 프레임을 위해 재사용될 수 있습니다. 최적의 성능을 위해서는 프리젠트할 이미지들과 리소스들의 개수들 사이를 1:1 로 매핑되도록 하는 것을 추천합니다.


프레임 루프에서 vkWaitForFences() 호출을 피할 것을 추천합니다. 왜냐하면 이것은 실행을 중지( stall )시키고 성능을 감소시키는 결과를 낳기 때문입니다( 우리가 프로우파일링한 게임에서 1-3 fps 정도가 떨어지는 것을 발견했습니다 ). 대신에 vkGetFenceStatus() 를 호출해서 프리젠트할 수 있는 이미지가 이용가능한지를 확인하는 것을 권장합니다.


Example: graphics queue --> host synchronization


코드 블록 2 : Initialization



코드 블록 3 : Per-frame rendering loop content


Recommendations


  • 그래픽스 큐를 호스트와 동기화하려면 항상 펜스를 사용하십시오.
  • 원형( circular ) 버퍼에 있는 동적 리소스들에 대한 참조를 유지하고 각 리소스들이 재사용될 수 있는지 확인하기 위해서 펜스를 사용하십시오.
  • vkWaitForFences() 를 프레임 루프에서 호출하는 것을 지양하고, 대신에 vkGetFenceStatus() 를 사용하십시오.


Barriers


Vulkan 배리어는 API 사용자가 같은 큐 혹은 같은 서브패스 내의 커맨드들 사이에 종속성을 삽입할 수 있도록 해 줍니다. 실행 종속성은 파이프라인 스테이지 동기화 스코우프에 의해 정의됩니다. 실행 종속성과 함게 vkCmdPipelineBarrier() 호출은 세 가지 유형의 메모리 접근 배리어 -- 전역( global ), 버퍼( buffer ), 이미지( image ) -- 들을 받아들일 수 있습니다. 메모리 배리어들은 API 사용자에게 첫 번째 동기화 스코우프가 완료되는 동안( 혹은 전에 ) 두 번째 동기화 스코우프 내의 읽기 연산전에 쓰기 연산을 보장해 줍니다. 이름이 제안하고 있듯이, 전역 메모리 배리어들은 특정 리소스를 지정하는 것이 아니라 모든 메모리 접근들을 동기화하는 데 사용됩니다. 더 자세한 동기화를 위해서, 버퍼 메모리 배리어와 이미지 메모리 배리어를 사용할 수 있습니다:


 Host

 Transfer

 Compute

 Graphics

 TOP_OF_PIPE_BIT

 HOST_BIT

 

 

 

 

 TRANSFER_BIT

 

 COMPUTE_SHADER_BIT

 DRAW_INDIRECT_BIT

 DRAW_INDIRECT_BIT

 

 VERTEX_INPUT_BIT

 VERTEX_SHADER_BIT

 TESSELLATION_CONTROL_SHADER_BIT

 TESSELLATION_EVAULATION_SHADER_BIT

 GEOMETRY_SHADER_BIT

 EARLY_FRAGEMENT_TESTS_BIT

 FRAGMENT_SHADER_BIT

 LATE_FRAGMENT_TESTS_BIT

 COLOR_ATTACHMENT_OUTPUT_BIT

 BOTTOM_OF_PIPE_BIT


위의 표의 네 열은 Vulkan 파이프라인들입니다. TOP_OF_PIPE_BIT 와 BOTTOM_OF_PIPE_BIT 는 모든 파이프라인에 대해 공통입니다. 상대적으로, 그것들은 실행을 시작하고 실행을 종료하는 첫 번째 커맨드를 위한 일반 파이프라인 스테이지들을 표시합니다.


파이프라인 버블( bubble )을 막기 위해서는, API 사용자가 배리어들의 실행 종속성을 매우 주의깊게 고려하는 것이 중요합니다. 배리어 호출을 타일 기반 GPU 아키텍쳐의 서브패스 내에서 하고 있을 때는 특히 중요합니다. 예를 들어 첫 번째 동기화 스코우프에서 BOTTOM_OF_PIPE_BIT 이고 두 번째 동기화 스코우프에서 TOP_OF_PIPE_BIT 인 서브패스 내에서 배리어가 설정되었다면, 배리어 앞에 있는 모든 GPU 커맨드들이 플러싱( flush )되고 배리어 뒤에 있는 커맨드들이 실행을 시작하기 전에 플러싱이 종료되는 것을 위해 대기해야만 할 것입니다.


이러한 버블을 피하려면, 배리어의 첫 번째 동기화 스코우프가 파이프라인의 가능한 한 앞쪽으로 설정되고 두 번째 동기화 스코우프가 가능한 한 가장 마지막의 파이프라인 스테이지로 지정되어야 합니다. scrStageMask 를 TOP_OF_PIPE_BIT 로 설정하는 것은 결코 배리어를 블락킹하지 않으며, 이는 첫 번째 동기화 스코우프가 비어 있는 것처럼 동작합니다. 이와 유사하게 dstStageMask 를 BOTTOM_OF_PIPE_BIT 로 설정하는 것은 두 번째 동기화 스코우프가 비어 있는 것을 의미하게 됩니다. 이러한 동작이 기대되는 경우들이 존재합니다 -- 다른 동기화( 예를 들어 세마포어 )가 이미 요청된 종속성들을 강제하고 있는 경우 -- 하지만 이런 옵션들은 주의깊게 사용되어야 합니다.


자세한 버퍼 동기화가 요구되지 않는 한은 전역 메모리 배리어들이 버퍼 메모리 배리어보다 선호됩니다. -- 예를 들어 버퍼의 특정 영역을 쓰는 동기화입니다. 같은 동기화 스코우프에 의존하는 이미지 메모리 배리어와 버퍼 메모리 배리어는 단일 vkCmdPipelineBarrier() 호출 내부에 배칭( batch )해야 합니다.


Recommendations


  • 소스 종속성을 파이프라인 내에서 가능한 한 앞쪽으로 설정하십시오. 그리고 대상 종속성을 가능한 한 늦게 설정하십시오.
  • 타일 기반 아키텍쳐 상에서 서브패스 내에서 설정되는 배리어들에 의해 만들어질 수 있는 파이프라인 버블에 조심하십시오.
  • 버퍼 메모리 배리어보다는 전역 메모리 배리어를 선호하십시오.
  • 이미지 메모리 배리어와 버퍼 메모리 배리어가 같은 스코우프를 사용하고 있다면 그것들은 단일 vkCmdPipelineBarrier() 호출 내에 배칭하십시오.
  • 용례에 기반한 권장 사항에 대해서 알고자 한다면 크로노스 그룹의 동기화 예제를 찾아 보십시오.


Events


이벤트들은 자세한 동기화 메커니즘을 제공합니다:


  • 호스트에서 그래픽스 큐로의 동기화.
  • 렌더 패스 내부에서의 커맨드 종속성들.


vkCmdWaitEvents() 는 vkCmdPipelineBarrier() 와 유사한 인자들을 취합니다. 추가적인 인자들은 eventCount 와 pEvents 입니다. pEvents 와 srcStageMask 에 의해 정의되는 동기화 스코우프는 vkCmdWaitEvents() 와 dstStageMask 뒤쪽의 커맨드들이 실행되기 전에 종료되어야 합니다.


호스트에서 그래픽스 큐로의 이벤트 동기화는 커맨드 버퍼에 쓰여진 리소스들에 대한 종속성이 있는 커맨드들 뒤쪽에서 리소스 쓰기가 발생할 필요가 있을 때 유용합니다. 예를 들어, 사용자 입력과 GPU 실행 사이의 지연을 줄이기 위해서, VR 합성기( compositor )는 타임 워프 합성이 수행되기 전에 유니폼 버퍼에 씬이 렌더링되므로 머리 방향 델타( delta )를 표현하는 행렬을 쓸 수 있습니다. vkCmdWaitEvents() 는 이 이벤트가 시그널링될 때까지 실행을 블로킹합니다. 하지만 너무 많은 시간이 걸리는 GPU 제출( submission )은 시스템에 의해 무시( kill )될 수 있다는 점을 기억하십시오. 그래서 이러한 접근법을 사용하려면 극도의 주의가 필요합니다.


Recommendations


  • 이벤트보다는 배리어를 사용하세요.


Wait idle


아이들 대기는 동기화에서 매우 무거운 형식입니다. vkQueueWaitIdle() 은 모든 큐 연산들이 종료될 때까지 대기하며 기능적으로는 펜스를 기다리는 것과 동일합니다. vkDeviceWaitIdle() 은 모든 디바이스 연산이 종료될 때까지 대기합니다. 아이들 대기 함수들은 중복이 없음이 보장하며, and should only be used for rendering engine tear down.


Recommendations


  • Only use WaitIdle functions for rendering engine tear down.


SwapChains


스왑체인을 생성할 때, VK_PRESENT_MODE_FIFO_KRH 프리젠테이션 모드를 사용하고 minImageCount 를 3 으로 설정하는 것을 권장합니다.


VK_PRESENT_MODE_MAILBOX_KHR 프리젠테이션 모드는 잠재적으로 프레임율을 안정화시켜 줍니다; 하지만 이는 렌더링된 전체 프레임을 날림( throwing )으로써 수행됩니다. 이는 GPU 가 필요 이상으로 무거워지도록 만듭니다( 그리고 결국 더 많은 전력을 소비합니다 ). FIFO 를 사용하여 프로우파일링하고 최적화할 것을 강력히 권장합니다. 그리고 MAILBOX 는 최소 지연이 절대적으로 요구될 때만 사용하십시오.


스왑체인 이미지의 개수를 결정하는 데 대한 주요 고려사항은 메모리 사용과 부드러움 사이의 균형입니다. 안드로이드는 2 개의 이미지만을 가진 스왑체인을 생성하는 것을 지원합니다. 이는 요구되는 메모리를 줄여주지만, 프레임이 v-sync 를 위한 시간 내에 렌더링되지 못한다면 렌더링 파이프라인에서 버블을 만듭니다. 3 개 보다 많은 스왑체인 이미지를 요청할 수 있기는 하지만, 이는 이러한 이점과 추가적인 메모리 소비량 사이에서 주의깊게 고민해 봐야 합니다.


그래픽스 큐와 프리젠테이션 큐를 동기화하기 위해서 세마포어를 사용해야 합니다. 펜스는 그래픽스 큐와 호스트를 동기화하기 위해 사용되어야 합니다. 더 많은 정보를 원한다면 다음 예제들을 참고하십시오:


  • 예제 : 위의 코드블락 1, 2.


Recommendations


  • VK_PRESENT_MODE_FIFO_KHR 프리젠테이션 모드를 사용하십시오.
  • 스왑체인의 minImageCount 를 3 으로 설정하십시오.
  • 그래픽스 큐와 프리젠테이션 큐를 동기화하기 위해 세마포어를 사용하십시오.
  • 그래픽스 큐와 호스트를 동기화하기 위해 펜스를 사용하십시오.


Minimizing overdraw


프래그먼트들은 프리미티브가 그래픽스 큐에 제출된 순서대로 래스터화( rasterize )됩니다. 여러 개의 프리미티브들이 겹쳐 있으면, 결과 프래그먼트들이 최종 이미지의 다른 것들에 의해 가려지는 상황이라고 할지라도 모든 프래그먼트들이 렌더링됩니다. 이후의 프래그먼트들에 의해 겹쳐지게 되는 값을 가진 프래그먼트들을 렌더링하는 것은 오우버드로( overdraw )라 알려져 있습니다.


일부 아키텍쳐들만 나중에 무시될 프래그먼트에 대한 셰이딩 부하를 줄이는 최적화를 포함하고 있습니다. 그럼에도 불구하고, 최상의 휴대용 최적화를 획득하기 위해서는, 초기 뎁스/스텐실 테스트를 사용하고 불투명 드로 호출들을 깊이 순서대로( 일반적으로 앞에서 뒤로, 뎁스 테스트 모드에 따라 다름 ) 제출하는 것을 권장합니다. 이는 GPU 가 셰이딩을 위한 가시적인 우선순위를 가지고 있는 프리미티브들이 어떤 것인지 판단할 수 있도록 해 줍니다. 투명 프리미티브들은 불투명 프리미티브들 후에 블렌딩 동작을 유지할 수 있는 순서로 렌더링되어야 합니다.


Recommendations


  • 불투명 드로를 앞에서 뒤로 정렬하고 나서 투명 프리미티브들을 렌더링하십시오. 순서 결정은 오우버드로 감소를 위해 완벽할 필요는 없습니다. CPU 정렬 부하와 오우버드로 감소 사이의 좋은 균형을 제공하는 알고리즘을 찾으십시오.


Avoid redundant API calls


렌더링없이 반복적으로 스테이트를 설정하는 것과 같은 Vulkan 중복 호출들은 병목을 유발하지 않습니다만, 여전히 비용이 듭니다.


Recommendations


  • API 를 중복호출하는 것을 피하십시오.


Robust Buffer Access


Vulkan 드라이버들은 API 가 그것을 호출하는 애플리케이션 내에서 올바르게 사용되고 있다고 가정합니다. 이러한 가정은 드라이버가 런타임 유효성 검사를 피할 수 있도록 해 줍니다. 애플리케이션들은 robustBufferAccess 기능을 활성화함으로써 더 강력한 신뢰성 보장을 요구할 수 있습니다. 신뢰성있는 버퍼 접근의 주요 목적은 버퍼 경계를 넘어서는 것과 관련한 검사들을 제공하는 것입니다. Vulkan 명세는 robustBufferAcess 가 물리 디바이스 상에서 이용가능한 기능임을 보장하지만, 그것을 활성화하는 것은 일부 아키텍쳐에서 심각한 성능 페널티를 발생시킬 수 있습니다.


Recommendations


  • robustBufferAccess 를 개발 단계에서 사용하여 버그를 잡으십시오. 릴리스 빌드에서는 비활성화하십시오.


Validation Layers


API 오용을 식별하기 위해서 개발 단계 동안 주기적으로 크로노스 그룹의 Vulkan 유효성 레이어들을 사용해야 합니다. 최상의 결과를 위해서, 최신 버전의 유효성 레이어들을 사용하는 것을 권장합니다. 유효성 레이어들의 소스 코드는 깃허브에서 호스팅되고 있으며 그것을 빌드하는 것은 직관적입니다. 각 레이어의 릴리스들에는 sdk-* 라는 태그가 붙어 있습니다.


이용가능한 레이어들의 하위 집합에 의존해서는 안 됩니다. 모든 잠재적인 문제들을 발견하기 위해서는 크로노스에서 제공하는 모든 레이어들을 주기적으로 사용할 것을 권장합니다.


렌더링 엔진은 서로 다른 디바이스 상에서 서로 다른 Vulkan 기능들( 예를 들어 텍스쳐 포맷 )을 사용할 것입니다. 그래서 여러 Galaxy 디바이스를 사용해 유효성 레이어들을 실행하는 것을 권장합니다. 게임을 릴리스하기 전에 모든 유효성 레이어 메시지들을 해결해야 합니다.


에러코드는 메시지에 특정되지 않습니다.그것들은 문제의 유형을 분류하며 하나 이상의 메시지들에 이해 재사용될 것입니다. 레이어 출력을 파싱하는 것을 권장하지는 않습니다. 왜냐하면 에러 코드와 메시지 텍스트는 유효성 레이어 릴리스마다 다양하기 때문입니다.


레이어에 의해 보고된 메시지의 의미가 명확하지 않다면, 원인을 더 잘 이해하기 위해서 유효성 소스 코드를 검색해 볼 것을 권장합니다.


잘못된 긍정( positive )이나 잘못된 API 용례가 발견되지 않는 시나리오들을 인지했다면, 깃허브의 레이어에 대한 문제에다가 보고해 주십시오.


크로노스 유효성 레이어들에 대해 더 알고자 한다면, LunarG 의 Vulkan Validation Layers Deep Dive 슬라이드를 읽어보실 것을 권장합니다.


Recommendations


  • 크로노스 유효성 레이어들을 개발 단계에서 주기적으로 사용하십시오.
  • 항상 최신 버전의 레이어 릴리스를 사용하십시오.
  • 다중의 디바이스 상에서 레이어들을 사용해 디바이스-지정 메시지들을 수집하십시오.
  • 주의 : 에러 코드들은 문제를 분류합니다 -- 그것들은 주어진 메시지에 대한 유일한 식별자가 아닙니다.
  • 메시지가 불명확하다면, 레이어 소스 코드를 참고하십시오.


원문 : 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.

주의 : 초심자 튜토리얼은 아닙니다. 그러므로, 실제 API 호출 용례를 알고자 한다면, 샘플이나 튜토리얼을 찾아서 확인해 보세요.

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

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



D3D 에 익숙한 사람이 벌칸을 할 때 발견하게 되는 어려운 개념 중에 하나는 디스크립터 셋 레이아웃( Descriptor Set Layout )입니다. 일단 디스크립터라는 것의 개념 자체를 모르는 상황이기 때문에 당연합니다.


사실 어려운 개념은 아니지만 생소하기 때문에 어렵게 느껴집니다. 디스크립터에 대해서 이해하기 위해서는 벌칸 명세에 있는 파이프라인 블록 다이어그램을 보는 것이 도움이 됩니다.


그림 1. Block diagram of the Vulkan pipeline. 출처 : 1 ].


그림 1 에 나타나 있듯이 디스크립터는 셰이더에 바인딩할 수 있는 리소스에 대한 정보를 의미하며 그것들의 모임이 디스크립터 셋이 됩니다.


이제 명세로부터 정확한 정의를 찾아 보도록 하죠.


디스크립터는 버퍼, 버퍼 뷰, 이미지 뷰, 조합된 이미지 샘플러와 같은 셰이더 리소스들을 표현하는 불투명( opaque ) 데이터 구조입니다. 디스크립터들은 디스크립터 셋을 구성하는데요, 그것들은 일련의 드로 커맨드들 내에서 사용할 커맨드들을 기록하는 동안 바인딩됩니다. 각 디스크립터 셋에서 칸텐트의 배열( arrangement )은 디스크립터 셋 레이아웃에 의해 결정됩니다. 그것은 어떤 디스크립터들이 저장되어 있는지를 결정합니다. 일련의 디스크립터 셋 레이아웃들은 파이프라인 레이아웃( pipeline layout )에 지정되어 있는 파이프라인에 의해 사용될 수 있습니다. 각 파이프라인 오브젝트들은 maxBoundDescriptorSets 만큼 까지의 디스크립터 셋들을 사용할 수 있습니다.


셰이더들은 디스크립터 셋과 디스크립터 셋 내에서 리소스와 디스크립터를 연결하는 바인딩 번호가 부여된( decorated ) 변수들을 통해 리소스들에 접근하게 됩니다. 디스크립터 셋들을 바운딩하기 위한 셰이더 인터페이스 매핑( shader interface mapping )에 대해서는 Shader Resource Interface 섹션에서 다루고 있습니다.


출처 : Chapter 13. Resource Descriptors, [ 1 ].


우리는 디스크립터 셋 레이아웃을 렌더패스/서브패스 개념과 유사하게 생각해 볼 수 있습니다. 렌더패스와 서브패스는 프레임버퍼의 설계도처럼 동작합니다. 렌더패스와 서브패스가 프레임버퍼의 바인딩 구조를 결정하고 실제 렌더링시에는 프레임버퍼가 바인딩됩니다. 그리고 이 프레임버퍼에는 실제 이미지 뷰들이 바인딩되어 있죠. 


마찬가지로 디스크립터 셋 레이아웃은 디스크립터 셋의 설계도라고 생각하시면 됩니다. 그리고 실제 렌더링시에는 디스크립터의 묶음인 디스크립터 셋이 바인딩되는 것입니다.


그림 2. 디스크립터 셋 레이아웃. 출처 : [ 2 ] 의 7 분 47 초 부분.


디스크립터 종류는 VkDescriptorType 에 기술되어 있습니다.



그런데 이 디스크립터라는 것은 리소스 오브젝트를 의미하고 있기 때문에 실제로는 디스크립터라는 오브젝트가 따로 존재하지는 않습니다. 디스크립터 셋에 의해서 디스크립터라는 것이 규정되는 것이죠. 마치 프레임버퍼라는 오브젝트에 이미지 뷰를 바인딩하는 것과 유사합니다.


디스크립터 셋은 디스크립터 풀( Descriptor Pool )인 VkDescriptorPool 오브젝트에 할당됩니다. 디스크립터 셋이라는 것은 셰이더의 종류가 늘어나면 많이 존재할 수 있기 때문에 풀이 필요합니다. 


풀은 외부 동기화( externally synchronized )되기 때문에 여러 스레드에서 동시에 접근해서는 안 됩니다. 그러므로 가급적이면 스레드마다 하나씩 존재하거나 직접 동기화 처리를 해야겠죠. 하지만 동기화 비용을 없애기 위해서는 스레드 별로 존재하는 것이 좋을 것입니다.


이 시점에 다음과 같은 그림을 그려 볼 수 있습니다.



그림 3. 디스크립터 풀과 디스크립터 셋.


이것을 파이프라인 스테이지와 연동시켜서 보면 다음과 같은 형태를 띠게 됩니다.


그림 4. 파이프라인 스테이지와 디스크립터 셋 레이아웃. 출처 : [ 3 ].


그런데 여기에서 하나 알아 둘 것이 있습니다. 명세에서는 파이프라인 내의 스테이지들이 같은 바인딩 번호를 통해 리소스에 접근할 수 있다고 하고 있습니다.


그림 4 를 보시면 알겠지만, 하나의 파이프라인에 여러 개의 디스크립터 셋들이 바인딩될 수 있습니다. 그러므로 그 디스크립터 셋들을 정의하면서 바인딩 번호를 동일하게 설정하면 같은 데이터에 접근하는 것이 가능합니다. 셰이더에서 레이아웃은 다음과 같은 식으로 지정됩니다.



만약 HLSL 과 DXC 를 사용한다면 register 를 통해서 디스크립터 셋의 레이아웃을 지정할 수 있습니다.



'X' 는 바인딩 번호이고, 'Y' 는 디스크립터 셋을 의미합니다. 


그런데 register 에 의존하지 않고 [[vk::binding(X[, Y])]] 와 [[vk::counter_binding(X)]] 애트리뷰트를 사용하는 것도 가능합니다. 자세한 사항은 [ HLSL to SPIR-V Feature Mapping Manual ] 에서 확인하시기 바랍니다.


튜토리얼 개념으로 쓴 글이 아니다 보니 여기에서 구체적인 API 와 코드에 대해서 집중적으로 다루지는 않았습니다. 개념을 이해하는 것이 중요하다는 생각에 정리해 봤으니 자세한 코드는 튜토리얼들에서 살펴 보시기 바랍니다. 잘못된 내용이 있으면 지적해 주시기 바랍니다.


참고자료


[ 1 ] Vulkan Specification 1.1.123. Khronos Group.


[ 2 ] Vulkan Tutorial 5: Triangle Sample Walkthrough, Qualcomm Developer Network.


[ 3 ] Siggraph 2016 - Vulkan and NVidia : the essentials.

원문 : https://community.arm.com/developer/tools-software/graphics/b/blog/posts/mali-performance-7-accelerating-2d-rendering-using-opengl-es

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

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


성능 블로그를 쓴지 한참 지났습니다. 지난 밤동안 작성하고 싶은 블로그에 대한 점심 커피 토론 중 하나가 wasim abbas 가 만든 기술 데모가 되었습니다. 글을 다시 쓰게 만들어 준 그에게 감사드립니다. 이번에는 2D 렌더링과 OpenGL ES 가 도울 수 있는 부분에 대해서 살펴 보도록 하겠습니다.

매우 많은 모바일 칸텐트들은 여전히 2D 게이밍이거나 2D 유저 인터페이스 애플리케이션입니다. 애플리케이션들은 스크린상에 생성되는 스프라이트( sprite ) 레이어들이나 UI 요소( element )들을 렌더링합니다. 이런 거의 대부분의 애플리케이션들은 OpenGL ES 를 사용해 렌더링을 수행하는데요, 일부 애플리케이션들은 3D 기능들을 사용하며, 알파 투명도를 다루기 위해 블렌딩을 사용하는 전통적인 back-to-front 알고리즘의 단순함( simplicity )을 선호합니다.

이 접근법이 동작하기는 하지만, 하드웨어의 3D 기능들을 이용하지는 않습니다. 그래서 많은 경우에 GPU 는 필요 이상으로 더 빡세게( harder ) 작동합니다. 이것의 영향은 저성능부터 포함된 GPU 에 의존하는 배터리 수명까지 다양합니다. 그리고 이 영향들은 모바일 디바이스에서 고해상도를 향할수록 더 증폭되는 경향이 있습니다. 이 블로그는 스프라이트 렌더링 엔진들에 대한 간단한 변경사항들에 대해서 살펴 볼 것입니다. 그 엔진은 3D 렌더링 API 가 제공하는 도구들과 비교했을 때 렌더링을 훨씬 더 빠르게 만들어 주며, 에너지 효율이 더 높습니다.

Performance inefficency of 2D content

2D 게임들에서 OpenGL ES 프래그먼트 셰이더들은 보통 단순합니다( trivial ) -- 텍스쳐 좌표를 보간( interpolate )하고, 텍스쳐 샘플을 로드( load )하고, 프레임 버퍼에 블렌딩( blend )합니다 -- 그러므로 최적화할 부분이 많지 않습니다. 이러한 칸텐트 유형을 위한 성능 최적화는 거의 대부분 중복 작업을 완전히 없애는 방식을 찾는 것과 관련이 있습니다. 그러므로 일부 프래그먼트에서는 셰이더가 심지어는 아예 실행되지 않습니다.

소개 섹션에서의 그림은 배경 레이어에 위에서의 사각 스프라이트의 전형적인 블리트( blit, 역주 : 블록 데이터를 빠르게 메모리로 옮기거나 복사하는 기법 )를 보여 줍니다; 실드 스프라이트의 바깥 부분이 투명( transparent )이며, 경계 부분은 부분적으로 투명합니다. 그래서 배경으로 갈수록 에일리어싱( aliasing ) 아티팩트( artifact )없이 완전히 투명하게 사라집니다. 그리고 몸통 부분은 불투명( opaque )합니다. 이 스프라이트 블리트는 알파 블렌딩을 활성화한 상태로 프레임버퍼에서 back-to-front 렌더링 순서로 최상위에 렌더링됩니다.

여기에서 비효율성이 발생하는 두 가지 주요 원인이 존재합니다:

  • 먼저, 이 스프라이트를 둘러 싼 주변 영역들이 완전히 투명하므로, 그것은 출력 결과에 전혀 영향을 미치지 않고 있습니다. 하지만 처리하는데 시간은 걸립니다.
  • 둘째로, 스프라이트의 몸통 부분은 완전히 불투명하므로, 그 밑에 있는 배경 많은 픽셀들을 완전히 무시하고 있습니다. 그래픽스 드라이버는 배경이 무시되는 빠르게 알 수 없기 때문에, 이 배경 프래그먼트들이 GPU 에 의해 렌더링되어야만 합니다. 이는 최종 씬에 유용하게 영향을 주지 않는 뭔가를 렌더링하기 위해서 처리 사이클과 나노줄( nanojules )의 에너지를 낭비합니다.

이는 상대적으로 단일 오우버드로( overdraw ) 만이 존재하는 합성 예제이지만, 1080p 화면에서 렌더링된 프래그먼트가 절반 이상 중복되는 실제 애플리케이션을 볼 수 있습니다. 만약 애플리케이션들이 OpenGL ES 를 다른 방식으로 사용해 이 중복을 제거할 수 있다면, GPU 는 애플리케이션을 더 빠르게 렌더링할 수 있거나, clock rate 와 operating voltage 를 줄이기 위해서 생성된 성능 헤드룸( performance headroom )을 사용할 수 있을 것입니다. 즉 상당한 양의 에너지를 절약할 수 있습니다. 이런 결과들은 어느 하나라도 매력적입니다. 그래서 "애플리케이션 개발자가 이를 성취하기 위한 방법은 무엇인가?" 라는 질문을 할 수 있습니다.

Test scene

이 블로그를 위해서, 위의 실드 아이콘들을 cover-flow 스타일의 정렬로 구성된 간단한 테스트 씬을 렌더링할 것입니다. 하지만 그 기법은 불투명 영역을 가진 모든 스프라이트 셋에 대해 동작할 것입니다. 테스트 씬은 아래와 같이 렌더링합니다:

여기에서 각 실드 아이콘들은 실제로는 알파 투명도를 사용하여 보이지 않는 부분들을 가리는 사각 프라이트입니다.

Tools of the trade

전통적인 전용 2D 렌더링 하드웨어에는, 사용할 옵션들이 별로 없었습니다; 애플리케이션은 스프라이트 레이어를 back-to-front 로 렌더링해 블렌딩 함수가 올바로 작동하도록 보장해야만 했습니다. 우리의 경우에는 애플리케이션이 3D API 를 사용하여 2D 씬을 렌더링하며, 3D API 가 애플리케이션에 중복 작업 제거를 위해 제공하는 도구가 무엇이냐는 질문을 할 수 있습니다.

완전한 3D 씬을 렌더링하는 데 있어 중복 작업을 제거하기 위해 사용되는 기본 도구는 깊이( depth ) 테스트입니다. 삼각형 안의 모든 버텍스들은 그것의 위치에 "Z" 요소를 가지고 있으며, 이는 버텍스 셰이더로부터 노출됩니다. 이 Z 값은 버텍스와 카메라의 거리를 인코딩하며, 래스터화( rasterization ) 프로세스는 버텍스 값을 보간해서, 프래그먼트 셰이딩을 할 필요가 있는, 각 프래그먼트에 깊이를 할당합니다. 이 프래그먼트 깊이 값은 현재 깊이 버퍼에 저장된 현존하는 값들에 대해 검사될 수 있습니다. 만약 그 값이 프레임버퍼에 있는 현재 데이터보다 카메라와 가깝지 않다면, GPU 는 그 프래그먼트를 버리게( discard ) 되는데, 그려면 그것은 셰이더 코어에 제출되지 않습니다. 왜냐하면 그 프래그먼트가 불필요하다는 것을 안전하게 알고 있기 때문입니다.

Using depth testing in "2D" rendering

스프라이트 렌더링 엔진은 이미 각 스프라이트의 레이어링( layering )을 트래킹했습니다. 그래서 그것들은 올바른 순으로 쌓여( stack )있습니다. 그래서 우리는 이 레이어 번호를 GPU 에 보내진 각 스프라이트의 버텍스에 할당된 Z 좌표 값으로 매핑할 수 있습니다. 그리고 실제로 그것이 3D 깊이를 가진 것처럼 씬을 렌더링할 수 있습니다. 만약 깊이 어태치먼트( attachment )를 가진 프레임버퍼를 사용하고, 깊이-쓰기( depth-write )를 활성화하고, 스프라이트들과 배경 이미지를 front-to-back 순서로 렌더링한다면( 예를 들어 back-to-front 인 일반적인 블리팅 패스의 반대 순서로 ), 깊이 테스트가 스프라이트와 백그라운드의 다른 스프라이트에 의해 가려지는 부분을 제거할 것입니다다.

이를 우리의 간단한 테스트 씬을 위해 실행하면, 다음의 결과르 얻을 수 있습니다:

오! 뭔가 잘못되었습니다.

여기에서 문제는 사각 스프라이트 지오메트리가 정확하게 불투명 픽셀의 모양과 일치하지 않는다는 것입니다. 카메라와 더 가까운 스프라이트의 투명 부분이 알파테스트로 인해 어떠한 컬러 값도 생성하지 않고 있음에도 여전히 깊이 값을 설정하고 있습니다. 아래쪽 레이더의 스프라이트가 렌더링될 때, 깊이 테스트를 하면 이전의 스프라이트의 투명 부분의 밑에서 가시적이어야만 하는 부분들이 부적절하게 제거( kill )된다는 것을 의미합니다. 그래서 OpenGL ES 클리어 컬러만이 보이고 있습니다.

Sprite geometry

이 문제를 해결하기 위해서, 스프라이트를 위해 유용한 지오메트리를 설정하는 방법에 대한 연구를 좀 할 필요가 있었습니다. 스프라이트 내에서 완전히 불투명한 픽셀을 위해 front-to-back 으로 렌더링할 때 깊이 값을 안전하게 설정할 수 있습니다. 그래서 스프라이트 아틀라스 생성은 각 스프라이트를 위해 두 가지 셋( set )을 제공할 필요가 있습니다. 한 셋은 아래의 가운데 이미지에서의 녹색 영역으로 지정되어 있으며, 이는 불투명 지오메트리만을 다룹니다. 그리고 두 번째 셋은 아래의 오른쪽 이미지에서 녹색 영역으로 지정되어 있으며, 완전히 투명( 완전히 버려질 수 있는 경우 )하지 않은 모든 것들을 포함하고 있습니다.

버텍스들은 상대적으로 비쌉니다. 그래서 이런 지오메트리 셋을 생성할 때는 가능한 한 적은 개수의 부가적인 지오메트리들을 사용하십시오. 불투명 영역은 완전히 불투명한 픽셀들만을 포함해야 합니다. 하지만 투명 영역은 안전하게 불투명 픽셀들과 완전하게 투명한 픽셀들을 부수 효과( side-effect )없이 포함할 수 있습니다. 그러므로 "best fit" 를 획득하려고 시도할 필요없이 "good fit" 를 위해 대충 근사계산하십시오. 일부 스프라이트의 경우에는, 불투명 영역을 생성할 가치가 없다는 것을 기억하세요( 불투명 텍셀을 가지고 있지 않거나 매우 작은 영역만이 불투명할 때 ). 그래서 일부 스프라이트들은 투명 렌더링으로 렌더링될 단일 영역만으로 구성될 수도 있습니다. 경험적으로 볼 때, 불투명 영역이 256 픽셀보다 적을 때, 그것들은 부가적인 지오메트리 복잡성을 사용해 경계처리될 가치가 없습니다. 하지만 항상 시도하고 확인할 가치는 있습니다.

이런 지오메트리를 생성하는 것은 상대적으로 성가십니다. 하지만 스프라이트 텍스쳐 아틀라스들은 보통 정적이며 이것은 애플리케이션 칸텐트 저작 과정에의 일부로 오프라인으로 수행될 수 있습니다. 그리고 런타임에 플랫폼 상에서 라이브로 수행될 필요가 없습니다.

Draw algorithm

각 스프라이트에 대해 두 개의 지오메트리 셋을 사용하여, 이제 테스트 씬의 최적화된 버전을 렌더링할 수 있습니다. 먼저 불투명 스프라이트 영역들과 배경을 font-to-back 순서로 렌더링합니다. 이 때 깊이 테스팅과 깊이-쓰기를 활성화합니다. 그 결과는 다음과 같습니다: 

다른 스프라이트 밑에서 가려진 스프라이트나 백그라운드의 영역은 절약될 수 있는 렌더링 작업입니다. 왜냐하면 그것은 셰이딩이 발생하기 전에 빠른 깊이 테스트를 통해 제거되었기 때문입니다.

불투명 지오메트리를 렌덜이했다면, 이제 각 스프라이트의 투명영역을 back-to-front 순서로 렌더링할 수 있습니다. 깊이 테스트를 켜 둔 채로 남겨 놨기 때문에, 아래쪽 레이어들의 스프라이트들은 논리적으로 위쪽에 있는 레이어들의 불투명 영역을 덮어쓰지 않습니다. 하지만 조금이라도 전력을 절약하려면 깊이 버퍼를 비활성화하십시오.

만약 불투명 스테이지의 컬러 출력을 지워버리고 깊이 값을 유지하고 투명 패스를 그리면, 이 패스에 의해서 추가된 부가적인 렌더링 결과를 가시화할 수 있습니다. 이는 아래 그림처럼 보입니다:

링들 외부의 불완전한 영역들은 작업이 절약된 영역을 가리킵니다. 왜냐하면 없어진 부분들은 첫 번째 드로잉 패스에서 렌덜이된 카메라에 더 가까운 불투명 스프라이트 영역의 깊이 값을 사용하는 깊이 테스트에 의해 제거되었기 때문입니다.

두 패스를 모두 같은 이미지에 함께 배치하고 렌더링하면, 원래의 back-to-front 렌더링과 동일한 가시적 결과를 다시 얻을 수 있습니다:

하지만 35 % 정도 더 적은 프래그먼트 스레드들이 시작되었고, 이는 이 씬을 렌더링하기 위해서 필요한 MHz 의 35 % 를 줄인 것으로 해석됩니다. 성공적입니다!

필요로 하는 마지막 연산 논리( operational logic )는 우리가 씬에 추가한 깊이 버퍼가 메모리에 다시 쓰여지지 않음을 보장하는 것입니다. 만약 애플리케이션이 EGL 윈도우 서피스에 직접적으로 렌더링하고 있다면, 여기에서 할 일은 없습니다. 왜냐하면 윈도우 서피스를 위한 깊이가 묵시적으로 버려지기 때문입니다. 하지만 여러분의 엔진이 오프스크린 FBO 에 렌더링하고 있다면, 오프스크린 타깃으로부터 FBO 바인딩을 변경하기 전에 ( OpenGL ES 3.0 이상에서는 ) glInvalidFramebuffer() 이나 ( OpenGL ES 2.0 에서는 ) glDiscardFramebufferEXT() 에 대한 호출을 추가하는 것을 보장하십시오. 세부사항을 원한다면 이 블로그를 확인하세요.

Summary

이 블로그에서 우리는 깊이 테스팅과 깊이-지향( depth-aware ) 스프라이트 기법의 사용이 3D 그래픽스 하드웨어를 사용하는 렌더링을 가속하는데 사용되는 방법에 대해서 살펴 보았습니다.

스프라이트의 불투명 영역과 투명 영역 사이의 분리를 제공하기 위해 부가적인 지오메트리를 추가하는 것은 복잡도를 증가시킵니다. 그래서 스프라이트들을 위한 버텍스의 개수를 최소화하려고 해야 하며, 그렇지 않으면 부가적인 버텍스와 작은 삼각형 크기를 처리하는데 드는 비용이 이점을 없애버릴 것입니다. 부가적인 지오메트리가 너무 복잡해질 것이 요구되거나 화면을 덮고 있는 영역이 너무 작다면, 그냥 불투명 영역을 무시하고 전체 스프라이트를 투명인 것처럼 렌더링하십시오.

이 기법이 3D 게임에서 2D UI 요소들을 렌더링하기 위해서도 사용될 수 있다는 것을 언급할 가치가 있습니다. 3D 씬을 렌더링하기 전에 near clip plane 과 매우 가까운 깊이로 UI 의 불투명 부분을 깊이와 함께 렌더링하십시오. 그리고 나서 3D 씬을 일반적인 경우처럼 렌더링하십시오( 불투명 UI 요소들의 뒤에 있는 부분들이 건너뛰어지게 될 것입니다 ). 마지막으로 UI 의 투명 부분들이 3D 출력의 최상위에서 렌더링되고 블렌딩될 수 있습니다. 3D 지오메트리들이 UI 요소에 간섭하지 않는다는 것을 보장하기 위해서 glDepthRange() 를 사용해 3D 패스에 의해 노출되는 깊이 값들의 범위를 매우 약간 제한할 수 있습니다. 이는 UI 요소들이 3D 렌더링보다 항상 near clip plane 에 더 가깝도록 보장해 줍니다.

Tune in next time,

Pete.

원문 : https://community.arm.com/developer/tools-software/graphics/b/blog/posts/mali-performance-6-efficiently-updating-dynamic-resources

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

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


지난 번 블로그에서는 애플리케이션이 3D 칸텐트로부터 최적의 성능을 끌어낼 수 있도록 효율적으로 구현해야만 하는 중요한 영역들에 대해서 살펴 보았습니다. 씬의 큰 부분에 대한 개괄( Broad-Brush ) 컬링같은 것은 그 부분이 보이지 않음을 보장하기 때문에, 그것들이 GPU 에 전혀 보내지지 않게 됩니다. 이 블로그의 댓글 중의 하나에서 seanlumly01 님은 "드로 호출 사이에서 텍스쳐들을 수정하는 애플리케이션에 대해서는 성능 페널티가 없나요?" 라고 질문하셨습니다. 매우 좋은 질문입니다. 그것에 대한 답은 사소하지 않다( non-trivial )입니다. 그러므로 완전한 대답은 이 블로그 포스트로 연기해 놓았습니다.

Pipelined Rendering

리소스 관리를 해야 할 때 가장 중요한 것은 OpenGL ES 구현들은 거의 모두 심하게( heavily ) 파이프라인화되어 있다는 사실입니다. 이는 이전의 블로그에서 자세하게 언급되어 있지만, 요약을 하자면...

무언가를 그리기 위해서 glDraw...() 를 호출할 때, 그 드로는 즉시( instantly ) 이루어지는 것이 아닙니다. 대신에 GPU 에게 이 드로를 어떻게 수행해야 하는지를 알려 주는 커맨드가 연산 큐에 추가되며, 이것은 나중에 어떤 지점에서 수행됩니다. 이와 유사하게 eglSwapBuffers() 는 화면의 프론트 버퍼와 백 버퍼를 실제로 교환하는 게 아닙니다. 실제로는 그래픽스 스택( stack )에 애플리케이션이 렌더링과 렌더링을 표현하는 큐들에 대한 프레임 생성( composing )을 완료했음을 통보할 뿐입니다. 둘 다 행위에 대한 논리적인 명세 -- API 호출 -- GPU 상의 실제 작업 처리가 수십 밀리세컨드 길이일 수 있는 버퍼링 프로세스에 의해서 분리되어 있는 경우입니다.

Resource Dependencies

대부분의 경우에, OpenGL ES 는 동기화( synchronous ) 프로그래밍 모델을 정의합니다. 몇 가지 명시적인 예외들을 제외하고는, 드로 호출을 만들 때, 렌더링은 드로 호출이 만들어진 지점에서 발생하는 것처럼 보여야 하며, ( API 함수 호출과 이전에 지정된 GPU 커맨들에 기반해 ) 그 시간과 지점에 화면상의 모든 커맨드 플래그들, 텍스쳐들, 버퍼들의 상태를 올바로 반영하는 것처럼 보여야 합니다. 동기화 렌더링처럼 보여지는 것은 API 의 기저에 깔린 드라이버 스택이 만드는 정교한 환상입니다. 그것은 잘 동작하지만, 여러분이 최적의 성능과 최소한의 CPU 부하를 획득하고자 한다면 애플리케이션의 동작에 약간의 제약이 걸립니다.

앞에서 언급한 파이프라인 프로세스 때문에, 이 동기화 환상은 텍스쳐나 버프를 읽어들이는 대기중인( pending ) 드로 호출이 GPU 상에서 실제로 그 드로 호출에 대한 렌더링이 완료될 때까지 그 리소스에 대한 수정 잠금( modification lock )을 유지해야 한다는 것을 의미합니다.

예를 들어 다음과 같은 코드 시퀀스를 가진다고 합시다:

그러면 첫 번째 드로 호출이 실제로 GPU 에 의해 처리가 되기 전까지는 glTexSubImage2D() 가 텍스쳐 메모리를 수정할 수 없습니다. 그렇지 않으면 첫 번째 렌더링 호출이 API 호출이 만들어진 지점에서의 GL 에 대한 스테이트( state )를 올바로 반영하지 못할 것입니다( 텍스쳐 버전 2 가 아니라 텍스쳐 버전 1 을 반영하는 물리 메모리의 칸텐츠를 사용해 드로를 렌더링할 필요가 있습니다 ). OpenGL ES 드라이버가 소비하는 시간의 많은 부분들은 이처럼 동기화 프로그래밍 "환상"을 유지하고 그 연산들이 너무 빠르거나( 리소스가 이용가능하기 전에 ) 느리게( 리소스 수정이 이미 된 후에 ) 발생하지 않도록 보장하기 위해 리소스 종속성들을 트래킹( tracking )하는 데 사용됩니다.

Breaking Resource Dependencies

리소스 종속성 충돌이 발생하는 시나리오에서 -- 예를 들어 여전히 대기중인 읽기 잠금( read lock )을 가지고 있을 때 버퍼 쓰기가 요구되는 경우 -- Mali 드라이버들은 어떤 특별한 처리 없이는 리소스 수정을 즉시 적용할 수 없습니다; 여기에 충돌을 자동으로 해결하기 위해 드라이버들에 열려 있는 여러 개의 가능한 방법들을 정리했습니다.

Pipeline Finish

충돌된 리소스들을 해결하기 위해, 대기중인 모든 GPU 에 대한 읽기와 쓰기를 하는 곳에서, 렌더링 파이프라인을 비울( drain ) 수 있습니다. 그 피니시( finish )가 완료된 후에, 리소스의 수정을 일반적인 것처럼 처리할 수 있습니다. 만약 이러한 일이 프레임버퍼 드로잉을 통해 발생한다면, 중간( intermediate ) 렌더링 스테이트를 메인 메모리에 강제로 플러싱( flush )하도록 하는 곳에서 점증적( incremental ) 렌더링 비용이 발생합니다; 세부사항에 대해서 더 알고자 한다면 이 블로그를 참고하세요.

파이프라인을 완전히 비우는 것은 CPU 가 다음 워크로드를 생성하는 동안 GPU 가 유휴상태가 된다는 것을 의미합니다. 이는 하드웨어 사이클을 낭비하는 것이며, 경험적으로 볼 때 좋지 않은 해결책입니다.

Resource Ghosting

약간의 메모리를 더 낭비한다면 동기화 프로그래밍 모델의 환상과 애플리케이션의 즉시 업데이트 처리를 모두 유지할 수 있습니다. ( 만약 그 변경이 부분 버퍼와 텍스쳐만을 대체하는 것이라면 ) 현재 리소스 메모리의 물리 칸텐츠를 수정하지 않고, 응용프로그램의 업데이트와 원본 버퍼로부터 데이터를 조립함으로써, 새로운 버전의 논리 텍스쳐 리소스를 간단하게 생성할 수 있습니다. 리소스의 최신 버전은 API 레벨에서 모든 연산을 위해 사용되며, 예전 버전들은 대기중인 렌더링이 완료될 때까지만 필요합니다. 이 시점에서 그것들의 메모리를 해제할 수 있습니다. 이러한 접근법은 리소스 고스팅( ghosting ) 혹은 copy-on-write 라고 알려져 있습니다( 역주 : Copy-On-Write 기법에 대해서는 [ [Study] Copy On Write (COW) ] 라는 포스트를 참고하세요 ).

이는 드라이버들이 가장 일반적으로 사용하는 접근법입니다. 왜냐하면 그것은 파이프라인을 온전히 유지하면서 GPU 하드웨어를 바쁘게 유지할 수 있도록 보장하기 때문입니다. 이 접근법의 단점은 고스트 리소스들이 살아 있는 동안의 추가적인 메모리들과 새로운 버전의 리소스를 메모리에서 할당하고 조합하기 위한 일부 추가적인 처리 로드( Processing load )입니다.

기억할 점이 하나 더 있는데요, 리소스 고스팅이 항상 가능한 것은 아니라는 것입니다; 특히 리소스들이 UMP, Gralloc, dma_buf 같은 메모리 공유 API 를 사용해서 외부 소스로부터 임포트( import )될 때입니다. 이런 경우에, 카메라, 비디오 디코더, 이미지 프로세서같은 다른 드라이버들이 이런 버퍼에다가 쓰기작업을 할 수 있으며, Mali 드라이버는 그런 작업이 발생했는지 여부를 알 방법이 없습니다. 이런 경우에, 일반적으로는 copy-on-write 메커니즘( mechanism )을 적용할 수 없습니다. 그래서 드라이버는 이 문제를 해결하기 위해서 대기중인 종속성들을 위해 멈춰서 대기하는 경향이 있습니다. 대부분의 애플리케이션의 경우에 이런 부분에 대해서 걱정할 필요는 없습니다. 하지만 다른 미디어 가속기( accelerator )로부터 가지고 온 버퍼들을 가지고 작업할 때는 주의해야 합니다.

Application Overrides

파이프라인 깊이 때문에 모든 하드웨어 렌더링 시스템들 상에서 리소스 종속성들이 문제가 된다는 점을 감안하면, 최신 버전의 OpenGL ES 에는, 응용프로그램 개발자가 필요할 때 순수하게 동기화 렌더링 환상을 재정의해( override ) 더욱 세부적으로 제어할 수 있도록 하는, 기능들이 추가되었다는 사실이 별로 놀랍지 않습니다.

OpenGL 3.0 의 glMapBufferRange() 함수는 애플리케이션 개발자가 버퍼를 애플리케이션의 CPU 주소 공간으로 매핑할 수 있도록 해 줍니다. 버퍼를 매핑하는 것은 애플리케이션이 GL_MAP_UNSYNCHRONIZED_BIT 라는 접근 플래그를 지정할 수 있도록 하는데, 이는 대충 해석하면 "리소스 종속성은 신경 꺼. 나는 내가 뭘 하고 있는지 알고 있어" 비트입니다. 버퍼 매핑이 비동기화되면, 드라이버는 동기화 렌더링 환상을 강제하려 시도하지 않으며, 애플리케이션은, 대기중인 렌더링 연산으로부터 여전히 참조되고 있는, 버퍼 영역들을 수정할 수 있습니다. 그러므로 버퍼 업데이트가 에러를 만든다면, 그러한 연산들을 위한 렌더링이 잘못될 수 있습니다.

Working With Resource Dependencies

GL_MAP_UNSYNCHRONIZED_BIT 와 같은 기능들을 직접적으로 사용하는 것과 더불어, 많은 애플리케이션들은, 지나친 고스팅 부하를 발생시키지 않고 유연한 렌더링을 생성하기 위해 파이프라인화된, 리소스 용법( usage )에 대한 지식을 사용해 작업하고 있습니다.

Seperate Out Volatile Resources

정적 리소스와 휘발성( volatile ) 리소스들이 분리되어 있음을 보장함으로써, 고스팅을 덜 비싸게 수행할 수 있습니다. 이는 할당되거나 복사될 필요가 있는 메모리 영역을 가능한 한 작게 만듭니다. 예를 들어, glTexSubImage2D() 를 사용해서 업데이트되는 애니메이션되는 글자( glyph )가 절대 변경되지 않는 정적 이미지들을 사용하는 텍스쳐 아틀라스를 공유하지 않음을 보장하거나, CPU 상에서 ( 애트리뷰트나 인덱스 업데이트를 통해 ) 소프트웨어적으로 애니메이션되는 모델이 정적 모델들과 같은 버퍼 안에 존재하지 않음을 보장하는 것입니다.

Batch Updates

이상적으로 FBO 렌더링이 발생하기 전에 대부분의 리소스 업데이트를 단일 블록( block )에서 수행( 하나의 큰 업데이트나 다중의 연속된 서브 버퍼/텍스쳐 업데이트들 )함으로써, 버퍼 업데이트와 관련된 부하를 감소시키고 고스팅된 복사의 개수를 최소화할 수 있습니다. 다음과 같이 드로 호출에 리소스 업데이트를 삽입하는 것을 피하십시오...

... GL_MAP_UNSYNCHRONIZED_BIT 를 사용할 수 있지 않는다면 말입니다. 이걸 다음과 같이 변경하는 것이 보통 훨씬 효율적입니다: 

Application Pipelined Resources

애플리케이션의 성능을 더욱 예측 가능하게 만들고, 드라이버에서 메모리를 재할당하는 고스팅의 부하를 피하고자 한다면, 적용할 만한 기법이 하나 있습니다. 렌더링 파이프라인에 존재하는 각 지연 시간 프레임마다 애플리케이션에서 각 휘발성 리소스들의 다중 복사본을 명시적으로 생성하는 것입니다( 일반적으로 Android 같은 시스템에 대해서는 3 개 입니다 ). 이 리소스들은 라운드-로빈( round-robin ) 순서로 사용되므로, 리소스에 대한 다음 변경이 발생할 때는 그 리소스를 사용하는 대기중인 렌더링이 완료되어야 합니다. 이는 애플리케이션의 변경들이 드라이버에서의 특별한 처리를 요구하지 않고 물리 메모리에 직접적으로 제출( commit )될 수 있음을 의미합니다.

애플리케이션의 파이프라인 길이를 결정하는 쉬운 방법은 없습니다. 하지만 텍스쳐를 사용하는 드로 호출 뒤에다가 glFenceSync() 를 호출함으로서 펜스 오브젝트를 삽입하고, N 프레임 후에 변경을 만들기 바로 직전에 glClientWaitSync() 를 호출함으로써 펜스 오브젝트를 빼내는 식으로, 디바이스에서 경험적으로 테스트해 볼 수는 있습니다. 만약 GL_TIMEOUT_EXPIRED 를 반환한다면, 렌더링이 여전히 대기중인 것이고, 여러분이 사용하고 있는 리소스 풀에다가 추가적인 리소스 버전을 추가할 필요가 있습니다.

좋은 질문을 찾아 준 Sean 에게 감사드립니다. 이게 대답이 되었으면 좋겠네요!

Cheers,

Pete

원문 : https://community.arm.com/developer/tools-software/graphics/b/blog/posts/mali-performance-5-an-application-s-performance-responsibilities

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

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


OpenGL ES 를 사용해 Mali GPU 상에서 그래픽스 렌더링 성능을 살펴 보는 블로그 시리즈의 다음 회에 오신 것을 환영합니다. 이번 시간에는 3D 애플리케이션을 개발 중일때 OpenGL ES 호출을 전혀 하지 않은 상태에서 고려할 중요한 애플리케이션 측 최적화중 일부에 대해서 살펴 볼 것입니다. 나중의 블로그에서는 OpenGL ES API 의 용례의 특정 영역들에 대해서 더 세부적으로 살펴 볼 것입니다. 이 블로그에 서술된 기술들은 Mali 전용이 아닙니다. 어떤 GPU 에서든 잘 동작할 것입니다.

With Great Power Comes Great Responsibility

OpenGL ES API 는 드로잉( drawing ) 연산( operation )들에 대한 직렬 스트림( serial stream )을 지정하는데, 그것들은 드로잉 연산들이 처리되는 방식을 제어하기 위한 명시적인 스테이트( state )들과 함께 GPU 가 수행해야 하는 하드웨어 커맨드( command )들로 바뀝니다. 이 연산의 저수준 모드는 애플리케이션에 GPU 가 렌더링 작업들을 수행하는 방식을 제어할 수 있도록 해 줍니다. 그러나 그것은 디바이스 드라이버가 애플리케이션이 렌더링하고자 하는 전체 씬에 대한 정보를 별로 가지고 있지 않다는 것을 의미하기도 합니다. 전체 정보의 결핍은 디바이스 드라이버가 GPU 로 보내는 커맨드 스트림을 재구성할 수 없어서, 최대 효율과 고성능을 달성하기 위해서는, 애플리케이션이 API 를 통해 적절한 렌더링 커맨드들을 순서대로 보내야 한다는 것을 의미합니다. 고성능 렌더링 및 최적화의 첫 번째 규칙은 "작업을 적게 하라" 는 것이며, OpenGL ES API 호출이 밠애하기 전에 애플리케이션에서 그 작업을 시작할 필요가 있습니다.

Geometry Culling

모든 GPU 들은 뷰잉 프러스텀( viewing frustum ) 외부에 존재하거나 카메라와 멀어지는 방향을 향하고 있는 프리미티브들을 버리는 컬링을 수행할 수 있습니다. 이것은 매우 저수준 컬링이며, 이는 프리미티브 별로 수행되고, 버텍스 셰이더가 각 버텍스를 위한 클립-스페이스( clip-space ) 좌표계를 계산한 이후에는 적용될 수 없습니다. 만약 전체 오브젝트가 프러스텀 바깥쪽에 존재한다면, 이것은 GPU 처리 시간, 메모리 대역폭, 에너지를 굉장히 낭비하고 있을 수 있습니다.

3D 애플리케이션이 수행해야 하는 가장 중요한 최적화는 최종 렌더링 결과에서 보이지 않는 오브젝트를 미리 컬링하는 것과 이런 오브젝트들을 위한 OpenGL ES API 호출을 완전히 건너뛰는 것입니다. 여기에서 사용될 수 있는 몇 가지 기법들이 있는데요, 복잡도에 따라서 다양합니다만, 밑에서 몇 가지 예에 대해서 설명하도록 하겠습니다.

Object Bounding Box

애플리케이션을 위한 가장 단순한 정책은 각 오브젝트를 위한 바운딩 박스를 유지하는 것입니다. 박스는 각 축에 대한 최소/최대 버텍스( vertex )들을 가집니다. 8 개의 버텍스를 오브젝트 스페이스에서 클립 스페이스로 계산하는 것은 CPU 상의 소프트웨어에서 각 드로 연산들에 대해서 계산될 수 있을 만큼 충분히 가볍고, 박스는 클립 스페이스 볼륨과의 간섭( intersection )을 위해 테스트될 수 있습니다. 이 테스트에 실패한 오브젝트는 프레임 렌더링에서 배제될 수 있습니다.

화면 공간에서 많은 양을 차지하는 매우 기하학적으로 복잡한 오브젝트들에 대해서는, 오브젝트를 각각의 바운딩 박스를 가지는 더 작은 조각으로 나누는 것이 유용합니다. 이는 현재 카메라 위치에서 이득을 볼 수 있다면 오브젝트의 일부 섹션( section )을 거부하도록 만들어 줍니다.

위의 이미지는 우리의 오래된 Mali 테크 데모중 하나를 보여 줍니다. 인도어( indoor ) 공상 과학 공간 역( station ) 환경을 날아 다닙니다. 최종 3D 렌더링은 왼쪽에 보입니다. 오른쪽의 칸텐트 디버그 뷰는 씬 안의 다양한 오브젝트들의 파랗게 강조된 바운딩 박스들을 보여줍니다.

Scene Hierarchy Bounding Boxes

이 바운딩 박스 정책은 추가로 취할 수 있는 것입니다. 이것은 렌더링되고 있는 월드를 위한 더 완벽한 씬 데이터 구조로 바뀝니다. 예를 들어 월드의 각 빌딩을 위해서 바운딩 박스들이 생성될 수 있으며, 각 빌딩의 각 방들을 위해서 바운딩 박스가 생성될 수 있습니다. 만약 빌딩이 화면 밖에 있다면, 그것은 단일 바운딩 박스 체크에 기반해 빠르게 배제될 것입니다. 빌딩이 포함하고 있는 개별 오브젝트들을 위한 수 백개의 검사가 필요하지 않습니다. 이 계층에서, 방들은 부모 빌딩이 가시적일 때만 검사되며, 그것들의 부모 방이 가시적일 때만 렌더링 가능한 오브젝트들이 테스트됩니다. 이러한 유형의 계층 정책은 실제로는 렌더링을 위해 GPU 로 보내질 워크로드를 변경하지는 않습니다. 하지만 이 검사들을 위한 CPU 성능 부하를 더욱 관리가능하게 만드는 데 도움을 줍니다.

Portal Visibility

많은 게임 월드들에서 뷰잉 프러스텀에 대한 단순한 바운딩 박스 검사들은 많은 중복된 작업들을 제거하게 될 것입니다. 하지만 여전히 제출해야 할 양이 엄청나게 남아 있습니다. 이는 특히 서로 연결된 방들로 구성된 월드에서 일반적입니다. 왜냐하면 공간적으로 인접한 방들의 뷰가 많은 카메라 각도로부터 벽, 바닥, 천장 등에 의해 완전히 차폐될 수 있습니다. 하지만 뷰잉 프러스텀 내부에 포함될 수 있을 정도로 충분히 가깝습니다( 그래서 단순한 바운딩 박스 컬링을 통과합니다 ).

그러므로 바운딩 박스 정책은 미리 계산된 가시성 정보를 사용해 보충될 수 있으며, 이는 씬 내의 오브젝트들에 대한 더 공격적인 컬링을 가능하게 합니다. 예를 들어 아래에 보이는 것처럼 세 개의 방으로 구성된 씬에서, Room C 내부의 어떠한 오브젝트도 플레이어가 서 있는 Room A 에서 보이지 않습니다. 그러므로 플레이어가 Room B 로 이동하기 전까지는 Room C 내부의 모든 오브젝트들에 대한 OpenGL ES 호출을 그냥 건너뛸 수 있습니다.

이러한 유형의 가시성 컬링은 레벨 디자이너( level designer )들에 의해 종종 게임 디자인 요소( factor )로 포함됩니다; 만약 레벨 디자인이 모든 지점에서 가시적인 방들의 개수를 항상 적게 유지할 수 있다면, 게임들이 더 높은 가시 품질과 프레임 율을 달성할 수 있습니다. 이러한 이유로, 인도어 설정들을 사용하는 많은 게임들은 S 와 U 모양의 방과 회랑들을 매우 많이 만듭니다. 왜냐하면 그것들은 문이 적절하게 배치된다면 그 방을 통과하는 시선( line of sight )이 없다는 것을 보장하기 때문입니다.

이 정책은 추가로 취할 수 있는 것입니다. 이는 어떤 경우에는 테스트 바닥에서 심지어 Room B 를 컬링할 수 있도록 해 줍니다. 이는 프러스텀에 대해서 현재 방과 인접한 방을 연결하는 포탈( portal ) -- 문, 창문 등 -- 들의 좌표를 테스트함으로써 수행됩니다. 만약 Room A 와 Room 를 연결하는 포탈이 현재 카메라 각도에서 보이지 않는다면, 우리는 Room B 에 대한 렌더링도 배제할 수 있습니다.

이러한 유형의 개괄적인( broad-brush ) 컬링 검사는 GPU 워크로드를 줄여주는 데 있어서 효율적이며, CPU 나 GPU 드라이버가 자동으로 수행하기에는 불가능합니다 -- 그것들은 렌더링되고 있는 씬에 대한 이러한 수준의 정보를 알 수가 없습니다 -- 그러므로 애플리케이션이 이러한 유형의 빠른 컬링을 수행하는 것이 중요합니다.

Face Culling

GPU 에 대해 화면 외부의 지오메트리들을 전송하지 않는 것 이외에도, 애플리케이션은 가시적인 오브젝트들을 위한 렌더링 스테이트들이 효율적으로 설정되는 것을 보장해야 합니다. 이는 컬링 목적을 위해 불투명 오브젝트들에 대한 후면( back-face ) 컬링을 활성화하는 것을 의미합니다. 이는 GPU 가 카메라로부터 멀어지는 방향을 향하는 삼각형들을 가능한 한 빠르게 배제할 수 있도록 해 줍니다.

Render Order

OpenGL ES 는 깊이( depth ) 버퍼를 제공하는데, 이것은 애플리케이션이 지오메트리를 순서대로 보내는 것을 허용해 주며, 깊이-테스트는 올바른 오브젝트들이 최종 렌더링 결과에 나올 수 있도록 해 줍니다. GPU 에 지오메트리를 순서 없이 렌더링할 수 있기는 하지만, 애플리케이션이 앞에서 뒤( front-to-back ) 순서로 오브젝트를 그린다면 더 효율적입니다. 왜냐하면 이것은 빠른 깊이 및 스텐실 테스트 유닛의 효율성을 극대화하기 때문입니다( The Mali GPU: An Abstract Machine, Part 3 - The  Midgard Shader Core 를 참고하세요 ). 만약 뒤에서 앞의 순서로 오브젝트를 렌더링한다면, GPU 가 일부 프래그먼트( fragment )를 렌더링하는 데 몇 사이클이 소비될 수 있습니다. 카메라에 더 가까운 프래그먼트들을 사용해 나중에 겹쳐그리는 것은 매우 많은 GPU 사이클들을 낭비하게 됩니다.

삼각형들이 완벽하게 정렬( sort )되는 것을 요구하는 것은 아닙니다. 그것은 CPU 사이클 관점에서 매우 무겁습니다; 그냥 거의 올바르게 만든다는 목적을 가지고 있을 뿐입니다. 바운딩 박스를 사용하거나 그냥 월드 스페이스에서의 오브젝트 원점 좌표를 사용하여 오브젝트 기반 정렬을 수행하는 것만으로도 보통 충분합니다; 삼각형들이 약간 순서에 맞지 않아도 GPU 에서 완전한 깊이 테스트에 의해서 깔끔히 정리될 것입니다.

블렌딩되는( blended ) 삼각형들은 뒤에서 앞의 순서로 렌더링되어야 올바른 블렌딩 결과를 얻을 수 있다는 것을 기억하세요. 그러므로 모든 불투명 지오메트리들을 앞에서 뒤의 순서로 먼저 렌더링한 후에 블렌딩되는 삼각형들을 나중에 렌더링할 것을 권장합니다.

Using Server-Side Resources

OpenGL ES 는 클라이언트-서버( client-server ) 메모리 모델을 사용합니다; 클라이언트측 메모리는 애플리케이션과 드라이버에 의해 소유되는 리소스들과 비슷하고, 서버측은 GPU 하드웨어에 의해 소유되는 리소스들과 비슷합니다. 리소스들을 애플리케이션으로부터 서버측으로 전송하는 것은 일반적으로 무겁습니다:

  • 드라이버가 데이터를 포함하기 위한 메모리 버퍼를 할당해야만 합니다.
  • 데이터가 애플리케이션 버퍼로부터 드라이버가 소유한 메모리 버퍼로 복사되어야 합니다.
  • 메모리가 메모리의 GPU 뷰와 일관성( coherent ) 있게 만들어져야 합니다. 통합( unified ) 메모리 구조에서 이는 캐시 유지( maintenance )를 내포하고 있을 것이며, 카드 기반 그래픽스 구조에서 이는 시스템 메모리로부터 전용 그래픽스 RAM 으로의 전체 DMA 전송을 의미할 것입니다.

그래서 초기 OpenGL 구현에서는 -- 지오메트리 처리는 CPU 에서 수행되고 GPU 하드웨어를 전혀 사용하지 않는 구현들에서는 -- OpenGL 과 OpenGL ES 는 지오메트리를 위한 클라이언트측 버퍼들이 API 에 넘겨지도록 하는 많은 API 들을 가지고 있었습니다.

  • glVertexAttributePointer 는 사용자가 버텍스당 애트리뷰트 데이터를 지정할 수 있도록 합니다.
  • glDrawElements 는 사용자가 드로 당 인덱스 데이터를 지정할 수 있도록 합니다.

이런 방식으로 지정되는 클라이언트측 버퍼들을 사용하는 것은 매우 비효율적입니다. 거의 대부분의 경우에 각 프레임에서 사용되는 그 모델들은 변경되지 않습니다. 그러므로 이것은 단순히 드라이버가 메모리를 할당하고 그 데이터를 그래픽스 서버로 전송하는 많은 양의 작업을 별 이득없이 수행하도록 강제합니다. OpenGL ES 의 훨씬 더 효율적인 대안( alternative )들은 애플리케이션이 버텍스 애트리뷰트와 인덱스 정보를 위한 데이터를 서버측 버퍼 오브젝트들에 업로드할 수 있도록 합니다. 이는 일반적으로 레벨 로드 시점에 수행됩니다. 버퍼 오브젝트를 사용할 대의 각 드로 연산에 대한 프레임 당 데이터 트래픽( traffic )은 그냥 GPU 에게 이들 버퍼 오브젝트들이 어디에서 사용되는 지를 알려주는 핸들( handle ) 집합일 뿐이며, 이는 명백히 더 효율적입니다.

이 규칙의 한 가지 예외는 유니폼 버퍼 오브젝트( Uniform Buffer Objects, UBOs )들입니다. 이것들은 셰이더 프로그램들에서 사용되는 드로 호출 당 상수들을 위한 서버측 저장소입니다. 유니폼 값들은 드로 호출 내부의 모든 버텍스 및 프래그먼트 스레드들에 의해 공유되므로, 그것들을 가능한 한 효율적으로 셰이더 코어에 의해 접근될 수 있도록 하는 것이 중요합니다. 그러므로 디바이스 드라이버들은 일반적으로 그것들이 하드웨어 접근 효율성을 최대화하기 위해서 메모리에 패킹( packed )되는 방식을 공격적으로 최적화할 것입니다. 서버측 UBO 들을 사용하는 대신에 드로 호출 당 작은 크기의 유니폼 데이터를 glUniform<x>() 함수 패밀리를 통해 설정하는 것이 선호됩니다. 왜냐하면 그것이 유니폼 데이터가 GPU 에 전달되는 방식에 대한 더 많은 제어를 제공하기 때문입니다. 유니폼 버퍼 오브젝트들은 여전히 버텍스 셰이더에서의 스켈레탈 애니메이션( skeletal animation )을 위해 사용되는 긴 행렬 배열과 같은 큰 유니폼 배열들을 위해서 사용됩니다.

State Batching

OpenGL ES 는 스테이트 기반 API 이며 각 드로잉 연산들을 위해 구성될 수 있는 매우 많은 스테이트 설정들을 가지고 있습니다. 단순하지 않은 씬들에서는 여러 개의 렌더 스테이트들이 사용됩니다. 그러므로 애플리케이션은 일반적으로 스테이트 변경 연산들을 수행해 드로 호출 자체가 제출되기 전에 각 드로 연산들을 위한 구성들을 설정합니다.

드라이버의 CPU 부하를 줄이고 GPU 에서 최적의 성능을 획득하려고 시도할 때 유념해야 할 두 가지 유용한 목표가 존재합니다:

  • 대부분의 스테이트 변경은 낮은 비용을 소비합니다만, 공짜는 아닙니다. 왜냐하면 드라이버가 에러 검사를 수행해야 하며 내부 데이터 구조에 어떤 스테이트를 설정해야 하기 때문입니다.
  • GPU 하드웨어는 상대적으로 큰 묶음( batch )의 작업을 다루도록 설계되어 있습니다. 그러므로 각 드로 연산은 상대적으로 커야만 합니다.

이 두 영역을 모두 개선하기 위한 애플리케이션 최적화의 가장 일반적인 형태 중 하나는 드로 콜 배칭( batching )입니다. 배칭을 통해 같은 렌더 스테이트를 사용하는 여러 개의 오브젝트들을 같은 데이터 버퍼로 미리 패키징하고, 단일 드로 연산을 사용해서 그것들을 렌더링할 수 있습니다. 이는 CPU 로드를 줄여 줍니다. 왜냐하면 더 적은 스테이트 변경과 드로 연산이 GPU 를 위해 패키징되어 있고 이는 GPU 가 더 큰 작업 묶음을 처리하도록 하기 때문입니다. 내 동료인 stacysmith 는 효율적인 배칭을 위한 전용 블로그를 가지고 있습니다: Game Set and Batch. 얼마나 많은 드로 호출들을 단일 프레임에 포함해야 하느냐에 대해서는 어렵거나 빠른 규칙이 존재하지 않습니다. 왜냐하면 대부분이 시스템 하드웨어의 능력( capability )과 원하는 프레임율에 의존하고 있기 때문입니다. 하지만 일반적으로 프레임 당 몇 백개의 드로 호출을 넘지 않는 것을 권장합니다.

또한 최적의 배칭을 하는 것과 깊이 정렬 및 컬링을 통해 중복 작업을 제거하는 것 사이에는 종종 충돌이 존재한다는 것을 기억하십시오. 드로 호출 카운트가 합리적이고 여러분의 시스템이 CPU 제약에 걸려있지 않다면, 일반적으로는 향상된 컬링과 front-to-back 오브젝트 렌더링 순서를 통해 GPU 워크로드를 줄이는 것이 더 낳습니다.

Maintain The Pipeline

이전의 블로그들 중 하나에서 언급했듯이, 최근의 그래픽스 API 들은 동기 실행에 대한 환상을 유지하지만 실제로는 성능을 극대화하기 위해 깊게( deeply ) 파이프라인화되어 있습니다. 애플리케이션은 이 렌더링 파이프라인의 동작을 저해하는 API 호출드을 사용하는 것을 피해야 합니다. 그렇지 않으면 성능은 급격하게 떨어지게 됩니다. 왜냐하면 CPU 는 GPU 가 종료될 때까지 대기하게 되고 GPU 는 CPU 가 더 많은 작업을 던져 줄 때까지 유휴상태가 되기 때문입니다. 이 주제에 대한 더 많은 정보를 얻기 원한다면 Mali Performance 1: Checking the Pipeline 을 참고하세요 -- it is was important to warrant and entire blog in its own right!

Summary

이 블로그에서는 Mali 를 사용하는 고성능 3D 렌더링을 성공적으로 성취하기 위해 고려해야만 하는 중요한 애플리케이션측 최적화들과 동작들에 대해서 살펴 보았습니다. 요약하면 기억해야 할 핵심들은 다음과 같습니다:

  • GPU 에다가 보일 수 있는 가능성이 있는 오브젝트들만 보내세요.
  • 불투명 오브텍트들을 앞에서 뒤의 순서로 렌더링하세요.
  • 서버측 데이터 리소스들은 버퍼 오브젝트들에 저장됩니다. 클라이언트측 리소스들은 그렇지 않습니다.
  • 렌더링 스테이트들을 배칭해서 불필요한 드라이버 부하를 줄이세요.
  • 깊은( deep ) 렌더링 파이프라인을 유지하고 애플리케이션이 파이프라인 고갈( drains )을 유발하지 않도록 하세요.

다음 시간에는 튜닝하고 OpenGL ES API 자체를 사용하는 더 기술적인 관점에 대해서 살펴 보기 시작하도록 하겠습니다. 그리고 코드 샘플들을 추가하도록 하겠습니다.

TTFN,

Pete

원문 : https://community.arm.com/developer/tools-software/graphics/b/blog/posts/mali-performance-4-principles-of-high-performance-rendering

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

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


이 시리즈의 이전 블로그에서, 타일 기반 렌더링 구조를 가능한 한 효율적으로 사용하기 위한 기본적인 필수 항목들에 대해서 살펴 보았습니다. 특히 불필요한 메모리 접근을 최소화하기 위해서 가장 효율적으로 프레임버퍼를 사용하는 애플리케이을 만드는 방법에 대해서 보여 주었습니다. 그런 기본들에서 벗어나서, 필자는 이제 OpenGL ES API 를 가장 효율적으로 사용해 Mali 를 사용하는 플랫폼에서 최상의 결과를 획득하는 방법의 세부사항에 대해서 살펴 보기 시작할 겁니다. 하지만 그렇게 하기 전에, 성능 최적화의 5 개 원칙에 대해서 소개하자 합니다.

Principle 1: Know Your Goals

최적화를 시작할 때, 그 활동은 끝내는 시점에 대한 매우 명확한 목표들을 가지고 있어야 합니다. 최적화에 대한 많은 목표들이 존재합니다; 가장 일반적인 것들을 이야기하자면 더 빠른 성능, 더 낮은 전력 소비, 더 낮은 메모리 대역폭, 더 낮은 CPU 부하 등이 있습니다. 애플리케이션을 리뷰할 때 보게되는 문제의 종류들은 여러분이 해야 할 개선의 종류에 따라서 매우 다양합니다. 그러므로 이를 시작부터 정확하게 하는 것이 매우 중요합니다.

또한 작은 성취를 위해 점점 더 많은 시간을 소비하기 쉽상입니다. 그리고 많은 최적화들이 애플리케이션의 복잡도를 증가시키고 유지보수 문제를 발생시킬 것입니다. "이제 충분해" 라고 이야기할 때를 결정하기 위해서, 작업을 하는 동안 정기적으로 진척도를 리뷰하고, 이 시점에 도달하면 작업을 중단하세요.

Principle 2: Don't Just Try to Make Things Fast

필자는 종종 Mali 를 사용하는 개발자들로부터 칸텐트의 특정 부분을 더 빠르게 실행할 수 있는 방법에 대해서 알려달라는 질문을 받습니다. 이러한 유형의 질문은 더 세부적인 질문들로 이어지게 마련입니다. 셰이더 코드의 특정 부분으로부터 좀 더 나은 성능을 짜내려면 어떻게 해야 하는지, Mali 구조에 가장 들어 맞는 특정 지오메트리( geometry ) 메시( mesh )를 튜닝( tune )하려면 어떻게 해야 하는지 등이 있습니다. 이런 것들은 모두 유효한 질문들이기는 하지만, 제 생각에는 프로세스 초기에 최적화 활동 범위를 지나치게 좁혔고, 가장 유망한 공격 수단들을 살펴 보지 않은 채 남겨둔 것으로 보입니다.

두 질문 모두 고정 워크로드들을 최적화하려고 시도하는 것이며, 둘 다 워크로드가 충분하다는 가정을 내포하고 만들어진 것입니다. 실제 그래픽스에서는 씬들이 종종 엄청난 양의 중복 - 화면을 벗어난 오브젝트들, 다른 오브젝트에 의해서 겹쳐 그려지는 오브젝트들, 삼각형의 절반이 사용자가 볼 수 없는 곳을 향하는 오브젝트들 등 - 을 포함하며, 그것들은 최종 렌더링에 어떠한 기여도 하지 않습니다. 그러므로 최적화 활동들은 두 개의 기본적인 질문들에 대답하려고 시도할 필요가 있습니다:

  1. 어떻게 씬으로부터 중복 작업들을 가능한한 효율적으로 줄일 수 있을까?
  2. 어떻게 남아 있는 것들에 대한 성능을 미세 조정( fine tune )할 수 있을까?

요약하면 - 무엇인가를 마냥 빠르게 만들려고 시도하지 말고, 가능할 때마다 그렇게 하는 것을 피하려고 시도하십시오! 이런 "작업 회피( work avoidance )"들의 일부는 애플리케이션에서 전적으로 처리되어야 하지만, 많은 경우에 OpenGL ES 와 Mali 는 그것들을 올바로 수행하는 데 도움을 줄 수 있는 도구들을 제공합니다. 뒤쪽의 블로그에서 이에 대해서 더 다루도록 하겠습니다.

Principle 3: Graphics is Art not Science

여러분이 CPU 상에서 전통적인 알고리즘을 최적화하고 있다면, 그것은 보통 올바른 답변이지만, 그렇지 않은 시스템에서는 잘못된 답일 것입니다. 그래픽스 워크로드의 경우에, 우리는 단순하게 가능한한 빠르게 보기 좋은 그림을 생성하려고 시도합니다; 만약 최적화된 버전이 정확하지 않아도 사람들은 알아 차리지 못할 것입니다. 그러므로 성능에 도움이 된다면 알고리즘을 사용하는 것을 두려워하지 마십시오.

그래픽스를 위한 최적화 활동은 사용된 알고리즘을 살펴 봐야 합니다. 그리고 그것의 비용이 그것이 가져오는 가시적인 이점들에 비해 합당하지 않다면, 그것을 없애버리고 완전히 다른 알고리즘으로 대체하는 것을 두려워하지 마십시오. 실시간 렌더링은 아트 형식이며 최적화와 성능은 그 아트의 일부입니다. 많은 경우에 부드러운 프레임율( framerate )과 빠른 성능이 단일 프레임에 포함되는 약간의 디테일( detail )보다는 더 중요합니다.

Principle 4: Data Matters

GPU 들은 데이터 영역( data-plane ) 프로세서( 역주 : [ Single-Chip Control/Data-Plane Processors ] 참조 )들이며, 그래픽스 렌더링 성능은 종종 데이터 영역 문제에 의해 지배됩니다. 많은 개발자들은 문제를 확인하기 위해서 OpenGL ES API 함수 호출 시퀀스들을 살펴 보느라 많은 시간을 허비하는데, 그들이 그런 함수들에 넘긴 데이터를 살펴 보지는 않습니다. 이는 거의 항상 심각한 실수가 됩니다.

물론 OpenGL ES API 호출 시퀀스들은 중요합니다. 그리고 많은 로직 이슈들이 최적화 작업을 하는 동안 이것들을 살펴 봄으로써 발견될 수 있습니다. 하지만 데이터 애샛( asset )의 포맷( format ), 크기, 패킹( packing )이 매우 중요하며, 무엇인가를 더 빠르게 만들기 위한 기회를 살필 때 잊어서는 안 되는 것입니다.

Principle 5: Measure Early, Measure Often

씬 렌더링 성능에서 단일 드로( draw ) 호출의 영향은 API 레벨에서 이야기하는 것은 보통 불가능합니다. 그리고 많은 경우에 무해하게 보이는 드로 콜들이 종종 큰 성능 부하의 일부가 되기도 합니다. 필자는 많은 성능 팀들이 며칠 혹은 심지어는 몇 주 동안 최적화를 하는데 시간을 보내고 나서야 뒤늦게 그들이 튜닝하던 셰이더가 단지 전체 씬의 비용 중의 1 % 밖에 기여하지 않음을 깨닫는 것을 보아 왔습니다. 그래서 그들이 2 배 더 빠르게 만드는 환상적인 작업을 했지만, 전체 성능은 0.5 % 만이 개선되었습니다.

필자는 항상 DS-5 Streamline 과 같은 툴을 사용해 일찍 그리고 자주 측정하는 것을 권장합니다. DS-5 Streamline 을 사용하면 통합 하드웨어 성능 카운터들을 통해 GPU 하드웨어 성능의 정확한 관점을 획득할 수 있으며, Mali Graphics Debugger 를 사용하면 렌더링 워크로드에 기여하는 드로 호출이 무엇인지 찾을 수 있습니다. 최적화 주요 지점( hot spot )을 식별하는 것 뿐만 아니라 애플리케이션이 여러분이 원하는 대로 동작하고 있는지 확인하기 위한 새너티( sanity ) 체크를 하기 위해서도 성능 카운터를 사용할 수 있습니다. 예를 들어 프레임 당 픽셀( pixel ) 개수, 텍셀( texel ) 개수, 메모리 접근 횟수들을 수동으로( 역주 : 애플리케이션에 삽입한 코드를 이야기하는 듯 ) 수집하고 이를 하드웨어의 카운터들과 비교하십시오. 만약 기대했던 것보다 2 배 많은 렌더링된 픽셀들을 확인했다면, 셰이더를 튜닝하는 것보다 훨씬 더 많은 이점을 주는 먼저 조사해야만 하는 구조적인 이슈들이 존재할 수도 있습니다.

Next Time

그래픽스에서 최적의 성능은 애플리케이션이 OpenGL ES API 에 데이터를 제출하는 방식의 구조적인 부분을 만들 때 가장 방해를 받습니다, so in my next blog I will be looking at some of the things an application might want to consider when trying very hard to not do any work at all.

TTFN,

Pete

원문 : https://community.arm.com/developer/tools-software/graphics/b/blog/posts/mali-performance-3-is-egl_5f00_buffer_5f00_preserved-a-good-thing

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

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


이번 주에 필자는 EGL_BUFFER_PRESERVED 를 통해 애플리케이션 프레임버퍼 관리의 영역으로의 전환( diversion )과 어디에서 이 기법을 사용하면 좋을지 결정하는 방법에 대해서 이야기하는 것을 마무리하고 있습니다. 이것은 사용자 인터페이스( user-interface ) 개발과 관련해 고객과 이야기할 때 정기적으로 제기되는 질문이며, 다른 그래픽스 요소들과 마찬가지로 그것의 효율성은 여러분이 수행중인 작업에 달려 있기 때문에, 이 블로그가 그것들을 명확하게 ( 혹은 적어도 약간 덜 혼란스럽게( murky ) ) 만들어 줄 수 있기를 바랍니다.

What is EGL_BUFFER_PRESERVED?

이전 블로그인 [ Mali Performance 2: How to Correctly Handle Framebuffers ] 에서 설명했듯이, 일반적인 상황에서는 윈도우 서피스의 칸텐츠가 한 프레임에서 다음 프레임으로 보존되지 않습니다. Mali 드라이버는 프레임버퍼( framebuffer )의 칸텐츠가 버려진다( discard )고 가정합니다. 그러므로 컬러( color ), 뎁스( depth ), 스텐실( stencil ) 버퍼의 어떠한 스테이트( state )도 유지될 필요가 없습니다. EGL 명세에서는 기본 EGL_SWAP_BEHAVIOR 를 EGL_BUFFER_DESTROYED 로 지정하고 있습니다.

윈도우 서피스를 EGL 을 통해서 생성할 때, 서피스를 EGL_SWAP_BEHAVIOR 를 EGL_BUFFER_PRESERVED 로 구성해서 생성할 수 있습니다. 이는 프레임버퍼의 컬러 데이터가 프레임 N 의 렌더링 끝에서 프레임 N+1 의 렌더링을 위한 컬퍼 버퍼의 시작 컬러로 사용된다는 것을 의미합니다. 이러한 보존은 컬러 버퍼에만 적용된다는 점을 기억하십시오; 뎁스 버퍼와 스텐실 버퍼는 보존되지 않으며, 그것들의 값은 모든 프레임의 끝에서 소실됩니다.

Great, I can render only what changed!

대부분의 사람들이 하는 일반적인 실수는 이 기법이 현존하는 프레임버퍼에 대한 적은 양의 렌더링만을 패치( patch )할 수 있도록 해 준다고 믿는 것입니다. 만약 이전 프레임 이후로 시계의 1 초만이 화면에서 변경되었다고 하면, 그냥 태스크바( taskbar )에서 시계를 수정하기만 하면 될 것입니다. 맞습니까? 아닙니다!

대부분의 실제 시스템들은 N-버퍼링 렌더링 체계( scheme )를 실행하고 있다는 점을 기억하십시오. 일부는 더블( double ) 버퍼링지만, 트리플( triple ) 버퍼링이 일반적이 되고 있습니다. N+1 프레임을 렌더링할 때의 최상위에 추가하고 있는 메모리 버퍼는 N 프레임의 컬러 버퍼가 아니라 N-2 프레임의 컬러 버퍼일 것입니다. EGL_BUFFER_PRESERVED 는, 단순한 패치 연산을 수행하는 것이 아니라, 드라이버로 하여금, 프레임 N 의 컬러 버퍼를 포함하는, 텍스쳐를 적용한( textured ) 사각형을 프레임 N+1 을 위해 작업중인 타일( tile ) 메모리에 렌더링하게 만드는 것입니다.

이전 블로그 중의 하나와 seanellis 의 블로그의 Forward Pixel Kill( FPK )에서 언급했듯이, Mali GPU 패밀리의 최근 멤버들은 겹쳐 그려지는( overdrawn ) 프래그먼트( fragment )들이 GPU 에 대한 주요한 비용이 되기 전에 제거하기 위한 기능을 지원하고 있습니다. 이전 프레임의 최상위에서 겹쳐 그려지는 것이 불투명( 블렌딩( blending ) 이 없고 프래그먼트 셰이더가 "discard" 를 호출하지 않음 )이라면, 겹쳐 그려지는 부분에 대한 리드백( readback )이 억제될 수 있으며, 결과적으로 성능이나 대역폭에 대한 영향이 없어집니다. 또한, EGL_BUFFER_PRESERVED 가 활성화되어 있지만 모든 것을 겹쳐 그리고 싶다면, 그냥 프레임의 렌더링의 시작 부분에서 glClear() 를 호출해서 리드백을 전혀 하지 않도록 할 수 있습니다. 

Is EGL_BUFFER_PRESERVED worth using?

전체 화면 리드백의 필요성을 받아들이면 멀티 프레임 렌덜이 파이프라인의 개념을 생각하기 시작할 때 상대적으로 직관적이 됩니다. 다음 질문은 "사용자 인터페이스 애플리케이션에 EGL_BUFFER_PRESERVED 를 사용해야 하느냐" 는 것입니다.

많은 가치있는 엔지니어링( engineering ) 질문들처럼, 그 대답은 단순하게 "네" 혹은 "아니요" 가 되기보다는 "그때 그때 달라요" 에 가깝습니다.

올바른 시작 컬러를 사용하는 프레임을 생성하기 위한 EGL_BUFFER_PRESERVED 의 비용은 ( FPK 에 의해서 건너 뛰는( killed ) 경우를 제외하면 ) 이전 프레임 데이터에 대한 전체 화면( full-screen ) 로드( load )입니다. 다른 대안은 클리어( clear ) 컬러를 사용해 프레임을 처음부터 다시 렌더링하는 것입니다. EGL_BUFFER_PRESERVED 를 사용하는 것이 좋을지의 여부는 다음의 두 가지 상대적인 비용에 달려 있습니다:

  • 만약 UI 애플리케이션이 투명도를 많이 사용하는 다중의 비압축 레이어들로 구성되어 있다면, EGL_BUFFER_PRESERVED 가 합리적일 것입니다. 하나의 레이어에서 이전 컬러 데이터를 리드백하는 비용은 다중 레이어 + 블렌딩 루트( route )를 통해 처음부터 컬러를 재생성하는 것보다 훨씬 저렴할 것입니다.
  • 만약 대부분이 단일 레이어이며 압축된 텍스쳐를 읽어들이는 단순한 UI 나 2D 게임을 가지고 있다면, EGL_BUFFER_PRESERVED 는 잘못된 것입니다. 이전 프레임의 컬러를 리드백하기 위한 대역폭 부하가 처음부터 프레임을 생성하는 것보다 훨씬 비쌀 것입니다.

이를 항상 칼로 자르듯이 명확하게 나누는 것은 불가능합니다 -- 이 두 개의 극단 사이에는 중간 지대가 존재합니다 -- 그러므로 분석을 수행할 때 주의를 기울여야 합니다. 만약 의심할 여지가 있다면, 제품 플랫폼에서 실행중인 실제 애플리케이션에서, EGL_BUFFER_PRESERVED 를 껐다 켰다 하면서, GPU 성능 카운터들을 사용해 성능을 리뷰( review )하십시오. 실제 장치에서 실제의 경우를 측정하는 것보다 더 나은 답을 주는 것은 없습니다. 이 시리즈의 다른 블로그에서는 애플리케이션 성능 분석을 하는 가이드를 제공하며 앞으로 몇 달 동안 계속해서 이 영역에 더욱 많은 내용들을 추가하도록 하겠습니다.

그런데, 그런 성능 경험을 수행할 때, 최적의 애플리케이션은 EGL_BUFFER_PRESERVED 를 명시적으로 사용( 혹은 비사용 )하도록 설계되어 있는 것이 중요하다는 점을 기억하십시오; 어떤 경로에서든 가장 효율적인 결과를 획득하려면, 일반적으로는 EGL 구성 스위치( configuration switch )를 단순하게 껐다 켰다 하는 것만큼 간단하지는 않습니다( 역주 : 이 옵션이 성능에 큰 영향을 준다는 의미인듯 ).

Mali-DP500 과 같은 ARM Frambuffer Compression( AFBC )가 활성화된 디스플레이 컨트롤러나 Mali-T760 과 같은 GPU 를 사용하는 시스템에서, EGL_BUFFER_PRESERVED 리드백의 대역폭 부하가 엄청나게 줄어들었다는 것을 언급하는 것은 가치가 있습니다. 리드백 대역폭이 압축된 프레임버퍼의 대역폭이므로 일반적으로 비합축 원본보다는 25-50 % 정도 더 작기 때문입니다.

A Better Future?

EGL_BUFFER_PRESERVED 의 동작은 훌륭한 아이디어입니다. 그리고 많은 경우에 여전히 유용하지만, 그것의 이론적인 이점들의 많은 부분들이 N 버퍼링 시스템에서 사라졌습니다. 왜냐하면 이것은 이전 프레임 데이터에 대한 전체 화면 리드백을 필요로 하기 때문입니다.

우리는, 이용가능한 애플리케이션 및 버퍼 보존 체계가 특정 플랫폼에서 N 버퍼 메모리 모델을 명시적으로 노출( 및 이용 )하면, 애플리케이션 -- 특히 사용자 인터페이스 -- 이 훨씬 더 효율적이 될 수 있다고 생각합니다. 만약 애플리케이션이 그 시스템이 더블 버퍼링을 사용하고 있다는 것을 알고 있고 현재 스테이트와 두 프레임 전의 스테이트 사이의 델타를 알고 있다면, 구조적으로 이상적인 렌더링과 변경된 메모리 영역에 대한 합성( compositing )에 가까워질 수 있습니다. 이것은 잠재적으로 대부분 안정적인 상태( steady-state )의 사용자 인터페이스를 위해 에너지 소비와 메모리 대역폭을 줄여줄 수 있습니다.

EGL_KHR_partial_update

EGL_KHR_partial_update 익스텐션( extension )은 애플리케이션이 N 버퍼링 레벨의 시스템-- 버퍼 에이지( buffer age ) --에 대한 질의를 할 수 있도록 허용하고, 그 정보와 버퍼가 마지막으로 렌더링된 후에 애플리케이션에서 변경된 부분에 대한 지식을 사용해, GPU 에 의해 렌더링되어야 하는 "dirty rectangles" 화면 영역을 식별할 수 있도록 허용하기 위해서 설계되었습니다.

이 익스텐션에서 버퍼 에이징 기능은 EGL_EXT_buffer_age 익스텐션에 의해 제공되는 것과 매우 유사합니다만, 타일 기반 렌더링의 경우에는 어떤 타일을 완전히 떨어뜨릴( drop ) 수 있는지 미리 알려 주는 dirty rectangle 을 제공합니다. 왜냐하면 그것들은 수정되지 않는 것이 보장되기 때문입니다. 만약 이 두 개의 익스텐션 중 하나를 선택해야 한다면, 최적의 성능을 위해서는 EGL_KHR_partial_update 기능을 사용하세요; 이러한 이유로 Mali 드라이버는 EGL_EXT_buffer_age 를 노출하지 않습니다.

EGL_KHR_swap_buffers_with_damage

EGL_KHR_swap_buffers_with_damage 익스텐션은 애플리케이션을 위해 시스템 합성 처리에 dirty rectangle 힌트들을 제공하기 위한 수단을 제공합니다. 이는 N 버퍼링 합성기( compositor )들과 디스플레이 컨트롤러들이 EGL_KHR_partial_update 으로부터 클라이언트 렌더링이 획득하는 최적화로부터 이득을 취할 수 있게 해 줍니다.

Do I need to use both extensions to get full benefit?

네, EGL_KHR_partial_update 는 GPU 를 버퍼 생성자( producer )로서 사용하여 애플리케이션이 렌더링하는 내용을 최적화합니다; EGL_KHR_swap_buffers_with_damage 는 버퍼 소비자로서 사용되는 디스플레이에 유효한 출력 이미지를 보낼 수 있도록 하기 위해 시스템 합성기가 리프레시( refresh )해야 하는 내용을 최적화합니다. 각각의 경우에 애플리케이션이 지정해야 하는 damage rectangle 은 일반적으로 다릅니다. 그러므로 두 개의 익스텐션이 모두 필요합니다.

Tune In Next Time

프레임버퍼 관리에 대한 짧은 전환이 마무리되었습니다. 그래서 다음 시간에는 Mali 에서 ARM DS-5 Streamline 을 사용해 애플리케이션 성능 병목과 최적화 기회에 대해서 살펴 보도록 하겠습니다.

TTFN,

Pete

Further reading:


원문 : https://community.arm.com/developer/tools-software/graphics/b/blog/posts/mali-performance-2-how-to-correctly-handle-framebuffers

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

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


이번 주에 렌더링 파이프라인의 하드웨어 중심 관점으로 약간 들어 갔는데요, 지금까지 어떻게 그리고 더욱 중요하게는 언제 Mali 드라이버 스택이 OpenGL ES API 활동( activity )를 렌더링에 필요한 하드웨어 워크로드가 되는지를 살펴 보았습니다. 앞으로 살펴 볼 것이지만, OpenGL ES 는 이 영역에서 특별히 탄탄하게 명세화되어( specified ) 있지는 않습니다. 그래서 개발자들이 피하기 위해서 주의해야만 하는 일반적인 함정들이 존재합니다.

Per-Render Target Rendering: Quick Recap

이전 블로그에서 설명했듯이, Mali 하드웨어 엔진은 2 패스 렌더링 모델 상에서 동작합니다. 렌더 타깃을 위해 모든 지오메트리를 렌더링하고 나서 프래그먼트 처리를 시작합니다. 이는 로컬 메모리에 존재하는 대부분의 작업 상태를 GPU 와 단단히 묶어서 유지할 수 있도록 해 줍니다. 그리고 렌더링 처리를 위해 필요한 전력을 소비하는 외부 DRAM 에 대한 접근을 최소화해 줍니다.

OpenGL ES 를 사용할 때 우리는 이 로컬 메모리 내부에서 프레임 버퍼 데이터의 대부분을 생성하고 사용하고 버릴 수 있습니다. 이는 프레임버퍼들을 외부 메모리에서 읽거나 외부메모리에 쓰는 것을 피하게 해 줍니다. 하지만 컬러 버퍼같은 것들을 유지하기 원한다면 그 버퍼에 대해서는 예외입니다. 하지만 이것은 보장된 동작이 아니며 어떤 패턴의 API 용법들은 GPU 가 추가적인 읽기 및 쓰기를 하도록 강제하는 비효율적인 행위를 유발할 수도 있습니다.

Open GL ES: What is a Render Target?

Open GL ES 에는 두 종류의 렌더 타겟이 존재합니다:

  • 오프 스크린( off-screen ) 윈도우 렌더 타깃들.
  • 오프 스크린 프레임버퍼 렌더 타깃들.

개념적으로는 이것들은 OpenGL ES 에서는 매우 유사합니다; 완전히 동일하지는 않습니다. 하나의 렌더 타깃만이 API 레벨에서 활성화되어 한 번에 하나의 점을 렌더링하는 데 사용될 수 있습니다; glBindFramebuffer( fbo_id ) 호출을 통해 현재의 렌더 타깃이 선택되면, 여기에서 0 인 ID 는 윈도우 렌더 타깃을 되돌려 놓는 데 사용될 수 있습니다( 또한 종종 기본 FBO 라 불리기도 합니다 ).

On-screen Render Targets

온 스크린( on-screen ) 렌더 타깃들은 EGL 에 의해 엄격하게 정의되어 있습니다. 한 프레임을 위한 렌더링 활동은 현재 프레임과 다음 프레임에 대한 매우 명확하게 정의된 경계를 가지고 있습니다; 두 개의 eglSwapBuffers() 호출 사이에서 FBO 0 을 위한 모든 렌더링은 현재 프레임을 위한 렌더링을 정의합니다.

또한 사용중인 컬러, 뎁스( depth ), 스텐실( stencil ) 버퍼들은 칸텍스트( context )가 생성될 때 정의되며, 그것들은 구성( configuration )은 변경될 수 없습니다. 기본적으로 컬러, 뎁스, 스텐실의 값들은 eglSwapBuffers() 후에는 정의되어 있지 않습니다( 역주 : 쓰레기 값이 들어 가 있을 수 있다는 의미인듯 ) - 예전의 값은 이전 프레임으로부터 보존되지 않습니다 - 이는 GPU 드라이버가 그 버퍼들의 사용에 대한 보증된 추정( guaranteed assumption )을 할 수 있도록 해 줍니다. 특히 우리는 뎁스와 스텐실이 일시적인( transient ) 작업 데이터일 뿐이라는 것을 알고 있으며, 결코 메모리로 쓰여질 필요가 없다는 것을 알고 있습니다.

Off-screen Render Targets

오프 스크린 렌더 타깃들은 별로 엄격하게 정의되어 있지 않습니다.

먼저, 드라이버에게 애플리케이션이 FBO 를 렌더링하는 것을 끝냈고 렌더링을 위해 제출될 수 있다는 것을 알려주는 eglSwapBuffers() 와 같은 함수가 존재하지 않습니다; 렌더링 작업을 플러싱하는 것은 다른 API 활동들로부터 추론됩니다. 다음 섹션에서 Mali 드라이버의 지원하는 추론에 대해서 살펴 보겠습니다.

둘째로, 애플리케이션이 컬러, 뎁스, 스텐실 어태치먼트( attachment ) 지점에 부착된( attached ) 버퍼를 사용해 작업을 수행할 수 있는지 여부를 보장하지 않습니다. 애플리케이션은 이것들을 텍스쳐로서 사용하거나 다른 FBO 에다가 다시 부착할 것입니다. 예를 들어 이전 렌더 타깃으로부터 뎁스 값을 다시 로드해 다른 렌더 타깃을 위한 시작 뎁스 값으로 사용하는 것입니다. 애플리케이션이 glInvalidateFramebuffer() 를 호출해서 명시적으로 내용을 버리지 않는다면, OpenGL ES 의 기본 동작은 모든 어태치먼트를 보존합니다. 노트: 이것은 OpenGL ES 3.0 의 새로운 진입점( entry point )입니다; Open GL ES 2.0 에서는 Mali 드라이버가 지원하는 glDiscardFramebufferExt() 확장 진입점을 사용해서 동일한 기능에 접근할 수 있습니다.

Render Target Flush Inference

일반적인 상황에서 Mali 는 렌더 타깃이 "언바운드( unbound )"되었을 때 렌더링 작업을 플러싱하는데, 드라이버가 eglSwapBuffers() 호출을 발견하여 메인 위도우 서피스를 플러싱하는 경우는 예외입니다.

성능이 떨어지는 것을 막기 위해서는 개발자들은 최종 렌더링의 서브셋( sub-set )만을 포함하고 있는 불필요한 플러싱을 피할 필요가 있습니다. 그러므로 프레임 당 오프 스크린 FBO 를 한 번만 바인딩하고 그것을 끝까지 렌더링하는 것을 권장합니다.

잘 구조화된 렌더링 시퀀스( 거의 어쨌든 - 이게 왜 불완전한지는 다음 섹션에서 확인하세요 )는 다음과 같습니다:

반대로 "나쁜" 동작은 다음과 같습니다:

이런 식의 행위는 증분 렌더러( incremental renderer )로 알려져 있으며 그것은 드라이버가 렌더 타깃을 두 번 처리하도록 만듭니다. 첫 번째 처리 패스는 중간( intermediate ) 렌더 스테이트를 메모리( 컬러, 뎁스, 스텐실 )에 모두 씁니다. 그리고 두 번째 패스는 그것을 다시 메모리로 읽어들입니다. 그러므로 그것은 오래된 스테이트의 최상위에 렌더링을 더 "추가"하게 됩니다.

위의 다이어그램에서 보이듯이, 증분 렌더링은 프레임 버퍼 대역폭 관점에서 잘 구조화된 단일 패스 렌더링과 비교했을 때 +400% 의 대역폭 페널티[ 32-bpp 컬러와 D24S8 로 패킹된 뎁스-스텐실이라 가정 ]를 가지고 있음을 알 수 있습니다. 잘 구조화된 렌더링은 중간 스테이트를 메모리에서 다시 읽는 것을 피하고 있습니다.

When to call glClear?

주의깊게 본 독자들은 네 개의 프레임 버퍼들을 위한 렌더링 시퀀스에다가 glClear() 라는 호출을 삽입한 것에 주목했을 것입니다. 이 애플리케이션은 각 렌더 타깃의 렌더링 시퀀스의 초기 부분에서 항상 모든 어태치먼트들에 대한 glClear() 를 호출해야 합니다. 물론 이전의 어태치먼트 칸텐트가 불필요함을 의미합니다. 이는 명시적으로 드라이버에게 이전의 스테이트가 필요하지 않다는 것을 전달하며, 즉 우리는 그것을 메모리로부터 다시 읽어들이는 것을 피할 수 있을 뿐만 아니라 정의되지 않은 버퍼 칸텐트들을 정의된 "clear color" 스테이트에 담습니다.

여기에서 볼 수 있는 일반적인 실수는 프레임버퍼의 일부만을 클리어하는 것입니다; 즉 시저( scissor ) 렉트( rectangle )가 일부 스크린 영역만을 포함한다든가 해서 렌더 타깃의 일부만이 활성화되었을 때 glClear() 를 호출하는 것. 그것이 전체 서피스에 적용될 때 렌더 스테이트가 완전히 버려질 수 있습니다. 그러므로 가능하다면 전체 렌더 타깃에 대한 클리어가 수행되어야 합니다.

When to call glInvalidateFramebuffer?

OpenGL ES API 에서 FBO 를 효율적으로 사용하기 위해서는 애플리케이션이 드라이버에게 컬러/뎁스/스텐실 어태치먼트들이 단지 일시적인 작업 버퍼임을 알려주는 것입니다. 그것들의 값이 현재 렌더 패스에 대한 렌더링 끝에서 버려질 수 있다는 것을 의미합니다. 예를 들어 모든 3D 렌더링은 컬러, 뎁스를 사용하지만, 거의 대부분의 애플리케이션에서는 뎁스 버퍼가 일시적이며 안전하게 무효화될 수 있습니다. 불필요한 버퍼들을 무효화하는 것이 실패하면 그것들에 메모리에 다시 작성되는 결과를 낳습니다. 그렇게 되면 메모리 대역폭을 낭비하게 되고 렌더링 처리에서 소비하는 에너지가 증가합니다.

이 시점에서 가장 일반적인 실수는 glInvalidateFramebuffer() 를 glClear() 와 동일하게 취급해서, 프레임 N 에 대한 무효화 호출을 프레임 N+1 의 FBO 를 처음 사용하는 시점에 하는 것입니다. 이건 너무 늦습니다! 무효화 호출의 목적은 드라이버에게 그 버퍼가 유지될 필요가 없음을 알려주는 것입니다. 그러므로 그런 버퍼들을 생성하는 프레임을 위해 GPU 에 작업 제출을 하도록 수정할 필요가 있습니다. 다음 프레임에 알려주는 것은 원래의 프레임이 처리된 후입니다. 애플리케이션은 드라이버에게 어떤 버퍼들이 프레임버퍼가 플러싱되기 전에 일시적인지 알려주는 것을 보장할 필요가 있습니다. 그러므로 프레임 N 에 있는 일시적인 버퍼들은 프레임 N 에서 FBO 가 언바인딩되기 전에 glInvalidateFramebuffer() 를 호출함으로써 식별될 수 있습니다. 예를 들면:

Summary

이 블로그에서 우리는 Mali 드라이버들이 렌더 패스들의 식별자를 다루는 방식, 일반적인 비효율적인 지점, 애플리케이션 개발자가 OpenGL ES API 를 비효율성을 피할 수 있도록 제어하는 방법 등에 대해서 살펴 보았습니다. 요약에서는 다음과 같이 추천합니다:

  • 각 FBO( FBO 0 이 아닌 것 )를 프레임에서 정확하게 한 번만 바인딩하고 연속되는 API 호출 시퀀스에서 완료될 때까지 렌더링.
  • 기존의 값이 필요로 하지 않는 모든 어태치먼트들에 대해 각 FBO 의 렌더링 시퀀스의 시작점에서 glClear() 를 호출.
  • 중간 스테이트가 그냥 일시적인 작업 버퍼인 모든 어태치먼트들에 대해서 FBO 렌더링 시퀀스의 끝에서 다른 FBO 로 전환되기 전에 glInvalidateFramebuffer() 나 glDiscardFramebufferExt() 를 호출.

다음 시간에는 다음과 관련한 것을 살펴 보겠습니다 -- 윈도우 서피스 컬러를 한 프레임으로부터 다음 프레임을 위한 기본 입력으로서 유지하기 위한 EGL_BUFFER_PRESERVED 의 효율적인 사용과 그것이 성능과 대역폭에 미치는 영향.

Cheers,

Pete.

원문 : https://community.arm.com/developer/tools-software/graphics/b/blog/posts/mali-performance-1-checking-the-pipeline

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

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


이 시리즈에서 처음 몇 개의 블로그 동안, ARM Mali "Midgard" GPU 패밀리가 사용하는 고수준 렌더링 모델에 대해서 설명해 왔습니다. 이 시리즈들의 나머지 부분에서는 ARM 의 시스템 레벨 프로우파일링 툴인 DS-5 Streamline 을 사용하는 방법에 대해서 설명하고, 애플리케이션이 Mali 기반 시스템의 외부에서 최적의 성능을 획득하지 못하는 영역을 식별할 것입니다.

이 블로그에서, 매크로-스케일( macro-scale ) 파이프라이닝과 관련한 이슈들에 대한 디버깅, 항상 GPU 를 바쁘게 만드는 수단, 그리고 프레임 레벨 파이프라인이 멈추는 일반적인 원인에 대해서 살펴 볼 것입니다. 만약 이 시리즈가 처음이라면 첫 번째 블로그를 읽을 것을 권장합니다. 왜햐나면 그것은 여기에서 더 세부적으로 다루게 될 개념에 대해서 소개하고 있기 때문입니다.

노트( note ): 여러분이 이미 DS-5 Streamline 을 가지고 있고 실행중이라고 가정합니다. 만약 아직 실행하지 않았다면, 다양한 Mali 기반 소비자 디바이스에서 설정하는 법에 대한 몇 개의 가이드가 있습니다:

이 블로그의 예제들은 DS-5 v5.16 을 사용해서 수집되었습니다.

What does good content look like?

성능 문제를 분석하기 전에, 우리의 목표와 스트림라인에서 이것이 어떻게 보이는지를 이해하는 것이 좋습니다. 시스템의 성능과 칸텐트의 복잡도에 기반한 두 가지 "좋은" 행위( behavior )들이 존재합니다.

  • GPU 가 병목( bottleneck )인 곳의 칸텐트를 위한 행위.
  • 수직동기화( vsync )가 병목인 곳의 칸텐트를 위한 행위.

이 실험을 위해 필요한 카운터들은 다음과 같습니다:

  • Mali Job Manager Cycles: GPU cycles
    • 이 카운터는 GPU 가 뭔가를 수행하는 클락 사이클에 증가합니다.
  • Mali Job Manager Cycles: JS0 cycles
    • 이 카운터는 GPU 가 프래그먼트 셰이딩을 하는 클락 사이클에 증가합니다.
  • Mali Job Manager Cycles: JS1 cycles
    • 이 카운터는 GPU 가 버텍스 셰이딩을 하거나 타일링을 하는 사이클에 증가합니다.

The GPU is the bottleneck

만약 GPU 가 병목인 곳( 예를 들어 렌더링이 60 FPS 를 달성하기에는 너무 복잡함 )의 칸텐트를 위한 프레임 레벨 렌더링 파이프라인을 생성하고 유지하는 데 성공했다면, 우리는 GPU 워크로드( workload ) 유형( 버텍스나 프래그먼트 처리 )들인 작업들이 항상 전체 능력( capacity )을 이용해 실행되고 있기를 기대하게 됩니다.

거의 대부분의 칸텐트에서 프래그먼트 처리는 GPU 실행에 있어서 가장 지배적인 부분입니다; 애플리케이션들은 보통 버텍스다 한자리 혹은 두자리 수가 더 많은 프래그먼트를 가지고 있습니다. 그러므로 이 시나리오에서는 JS0 이 항상 활성화되어 있고 CPU 와 JS1 은 모든 프레임에서 적어도 일정 시간만큼 유휴상태가 될 것이라 예상할 수 있습니다.

스트림라인을 사용하여 이 카운터 집합들을 수집할 때, 세 가지 활동( activity ) 그래프들을 보게 될 것인데요, 이것들은 GPU 를 위한 날( raw ) 카운터 값들과 함께 툴에 의해 자동으로 생성됩니다. "GPU 프래그먼트" 처리가 완전하게 로드되어 있고, "CPU Activity" 와 "GPU Vertex-Tiling-Compute" 워크로드들은 각 프레임에서 일정시간 동안 유휴상태가 되는 것을 확인할 수 있습니다. 노트 -- 이를 보기 위해서는 1 ms 나 5 ms 의 줌( zoom ) 레벨에 가깝게 확대할 필요가 있습니다 -- 여기에서는 매우 짧은 기간에 대해서 이야기하고 있는 것입니다.

The vsync signal is the bottleneck

vsync 에 의해 조절( throttle )되는 시스템에서는 CPU 와 GPU 가 매 프레임 유휴상태가 되는 것을 기대할 수 있습니다. 왜냐하면 vsync 신호가 발생하고 윈도우 버퍼 교체( swap )이 발생하기 전까지는 다음 프레임을 렌더링하지 않기 때문입니다. 아래 그래프는 스트림 라인에서 이것이 어떻게 보이는지를 보여 줍니다:

만약 여러분이 애플리케이션 개발자가 아니라 플랫폼 통합자( integrator )라면, 60 FPS 로 실행하는 테스트 케이스들은 여러분의 시스템의 DVFS 주파수 선택의 효율성을 리뷰하는 데 있어 좋은 방법이 될 것입니다. 위의 예제에서는 활동들이 나타나는 위치 사이에 많은 시간 간격이 존재합니다. 이는 선택된 DVFS 주파수가 너무 높고 그 GPU 는 필요 이상으로 빠르게 실행중이라는 것을 의미합니다. 이는 플랫폼의 에너지 효율성을 전체적으로 감소시킵니다.

Content issue #1: Limited by vsync but not hitting 60 FPS

더블 버퍼링 시스템에서는 60 FPS 를 달성하지 못하지만 vsync 에는 여전히 제한되는 칸텐트가 존재할 수 있습니다. 이 칸텐트는 위의 그래프와 매우 유사해 보이는데요, 워크로드 사이의 시간 간격이 한 프레임의 길이의 몇 배가 될 것이며, 프레임율( frame rate )은 정확히 최대 스크린의 리프레시율( refresh rate )로 정확히 나눠질 것입니다( 예를 들어 60 PFS 패널은 30 PFS, 20 FPS, 15 FPS 등으로 실행될 수 있습니다 ).

60 FPS 로 실행중인 더블 버퍼링 시스템에서, GPU 는 vsync 버퍼 교체를 위한 시간 안쪽에서 성공적으로 프레임들을 생성하고 있습니다. 아래 그림에서는 두 개의 프레임버퍼( FB0, FB1 )들의 생명주기( lifetime )을 확인할 수 있습니다. 녹색으로 되어 있는 것은 on-screen 의 기간이며 파란색으로 되어 있는 것은 GPU 에 의해 렌더링되고 있는 기간입니다.

GPU 가 이를 수행할 수 있을 만큼 충분히 빠르게 실행되고 있지 않다면, 하나 이상의 vsync 데드라인( deadline )을 놓치게 될 것입니다. 그래서 현재의 프론트 버퍼( front-buffer ) 가 다른 vsync 기간 동안에 스크린상에 남아 있게 됩니다. 아래 다이어그램에서 오렌지 라인이 그어져 있는 곳에서는, 프론트 버퍼가 여전히 스크린에 렌덜이되고 있으며 백 버퍼( back-buffer )는 디스플레이를 위해 큐에 들어 가 있습니다. GPU 는 렌더링할 수 있는 버퍼를 가지고 있지 않기 때문에 대기상태에 빠집니다. GPU 가 45 FPS 이상으로 칸텐트를 실행할 수 있을만큼 충분히 빠름에도 불구하고, 성능은 30 FPS 로 떨어집니다.

안드로이드 윈도우 시스템은 보통 트리플 버퍼링( tripple buffering )을 사용합니다. 그래서 이 문제를 피합니다. 왜냐하면 GPU 가 렌더링하는 데 사용할 버퍼를 가지고 있기 때문입니다. 하지만 여전히 X11 기반 Mali 배포폰에서는 더블 버퍼링을 사용합니다. 만약 이런 이슈가 발생했다면, 성능 최적화를 수행하는 동안에는 vsync 를 끄는 것을 권장합니다; 발생하는 문제를 혼란스럽게 만드는 부차적인 요소들이 없는 상태에서 어떤 것을 최적화해야 할지 결정하는 것이 훨씬 쉽습니다.

Content issue #2: API Calls Break the Pipeline

여러분이 보게 될 두 번째 이슈는 파이프라인 휴식( break )입니다. 이 시나리오에서는 적어도 하나 이상의 CPU 처리 부분이나 GPU 처리 부분이 항상 바쁘지만 동시에 바쁘지는 않습니다; 일종의 직렬화( serialization ) 지점의 형식이 발생한 것입니다.

아래의 예제에서는, 칸텐트가 프래그먼트 중심( dominant )이며, 그래서 프래그먼트 처리가 항상 활성화되어 있을 것이라 기대하고 있지만, 활동이 진동하고 있음을 볼 수 있습니다. GPU 버텍스 처리와 프래그먼트 처리가 직렬화되고 있습니다.

이것의 가장 일반적인 원인은 API 의 동기화 행위를 강제하는 OpenGL ES API 함수를 사용하는 것이고, 이는 드라이버가 모든 대기중( pending )인 연산들을 플러싱( flushing )하게 강제하고, API 요구사항을 존중하기 위해서 렌더링 파이프라인을 비워( drain )버립니다. 가장 일반적인 범인들이 여기에 있습니다:

  • glFinish(): 명시적으로 파이프라인 비우기를 요구합니다.
  • glReadPixels(): 묵시적으로 현재 서피스를 위해 파이프라인 비우기를 요구합니다.
  • glMapBufferRange(): GL_MAP_UNSYNCHRONIZED_BIT 가 설정되지 않았다면; 명시적으로 매핑되고 있는 데이터 리소스를 사용하고 있는 모든 대기중인 서피스들을 위해 파이프라인 비우기를 요구합니다.

이런 API 호출들은 파이프라인 비우기 때문에 빠르게 만들어 지는 것이 거의 불가능합니다. 그러므로 이런 함수들은 가능한 한 피할 것을 제안합니다. OpenGL ES 3.0 이 glReadPixels 함수가 픽셀 복사를 비동기적으로 수행할 수 있는 Pixel Buffer Object( PBO )를 타깃으로 한다는 것은 언급할 가지가 있습니다. 이것은 더 이상 파이프라인 플러싱을 발생시키지 않습니다. 하지만 여러분의 데이터가 도착할 때까지 잠시동안 기다려야만 하며, 메모리 전송은 여전히 상대적으로 비쌉니다.

Content issue #3: NOT GPU limited at all

오늘의 마지막 이슈는 GPU 가 전혀 병목이 아닌 사황입니다. 하지만 종종 형편없는 그래픽스 성능을 보여줄 때가 있습니다.

CPU 가 GPU 가 소비하는 것보다 더 빠르게 새로운 프레임을 생성할 수 있다면, 우리는 그냥 프레임 파이프라인을 유지하기만 할 수 있습니다. 만약 CPU 가 렌더링하는데 5 ms 를 소비하는 프레임을 20 ms 에 만들었다면, 파이프라인은 각 프레임에서 비어 있는 상태로 실행될 것입니다. 아래의 예제에서 GPU 는 매 프레임 유휴상태입니다. 하지만 CPU 는 항상 실행중이고, 이는 CPU 가 GPU 를 따라가지 못한다는 것을 내포하고 있습니다.

"꽉 잡아! CPU 가 단지 25 % 만 로드되었어". 스트림라인은 시스템의 전체 용량을 100% 로 보여 줍니다. 만약 여러분이 4 개의 CPU 코어를 가지고 있다면, 여러분의 시스템에서 단일 프로세서는 하나의 스레드가 최대이므로, 그것은 20 % 로드로 보여질 것입니다. 만약 "CPU Activity" 그래프의 타이틀 박스의 우상단에 있는 화살표를 클릭한다면, 그것은 확장될 것이고 여러분에게 시스템의 CPU 코어 당 개별적인 로드 그래픽스를 제공할 것입니다.

예상했던 대로, 한 코어가 100 % 로드를 사용하고 있습니다. 그러므로 이 스레드는 시스템에서 병목이 되는데요, 이는 전체 성능을 제한하게 됩니다. 이런 문제가 발생하는 여러 가지 원인이 있을 수 있는데요, 그래픽스 동작의 관점에서는 애플리케이션 비효율성보다는 두 가지 주요 원인이 있습니다:

  • glDraw...() 호출이 과도하게 많음.
  • 동적 데이터 업로드가 과도하게 많음.

모든 드로 호출( draw call )은 드라이버에 대한 비용을 가집니다. 컨트롤 구조체들을 만들고 그것들을 GPU 에 제출해야 합니다. 프레임 당 드로 호출의 개수는 유사한 렌더 스테이트( render state )를 가진 오브젝트의 드로잉을 배칭함으로써 최소화되어야 합니다. 배치를 크게 할 것인지 컬링을 고려할 것인지에 대한 균형 문제가 있기는 합니다. 타깃에 따라 다르기도 합니다: 모바일에서 대부분의 고사양 3D 칸텐트는 렌더 타깃 당 대략 100 개의 드로 호출을 사용하며, 많은 2D 게임들은 20-30 개의 드로 호출을 사용합니다( 역주 : 2014 년 이야기입니다 ).

동적 데이터 업로드의 관점에서, 클라이언트 메모리에서 그래픽스 서버로 업로드되는 모든 데이터 버퍼는 드라이버가 그 데이터들을 클라이언트 버퍼에서 서버 버퍼로 복제하는 것을 요구한다는 점에 주의하시기 바랍니다. 만약 서브 버퍼 업데이트보다는 새로운 리소스를 사용한다면, 드라이버가 그 버퍼를 위한 메모리를 할당해야만 합니다. 가장 일반적인 나쁜 짓은 클라이언트측 버텍스 애트리뷰트를 사용하는 것입니다. 정적인 Vertex Buffer Objects( VBOs )를 사용해 그래픽스 메모리에 영구적으로 저장할 수 있으며, 그 버퍼를 다른 일련의 렌더링을 위해서 참조로 사용할 수도 있습니다. 이는 업로드 비용을 한 번만 지불하게 해 주며 여러 렌더링 프레임 동안 그 비용을 분할( amortize )합니다.

Mali 그래픽스 스택이 성능을 전혀 제한하지 않는 경우가 존재합니다. 애플리케이션 로직 자체가 16.6 ms 이상 걸리는 경우에는 OpenGL ES 호출이 무한하게 빠르더라도 60 FPS 를 달성할 수 없습니다. DS-5 스트림라인은 매우 능력있는 소프트웨어 프로우파일러를 포함하고 있으며, 그것은 코드 내에서 병목이 어느 부분인지를 정확하게 식별할 수 있도록 도움을 줄 수 있습니다. 또한 다중 스레드를 사용해서 소프트웨어를 병령화하기 원한다면, 시스템의 다중 CPU 코어 사이에서 균형있는 워크로드를 로드할 수 있도록 도움을 줄 수 있습니다. 하지만 이것은 Mali 의 동작과 직접적으로 연관된 것은 아니기 때문에 여기에서는 다루지 않겠습니다.

Next Time ...

다음 시간에는 Mali 드라이버의 렌더 타깃 유지를 위한 접근법에 대해서 리뷰하겠습니다. 그리고 Frame Buffer Objects( FBOs )를 사용해 이 모델을 멋지게 실행하기 위해서 애플리케이션을 구조화하는 방법에 대해서 리뷰하겠습니다.

댓글과 의견을 환영합니다.

Cheers,

Pete.

원문 : https://community.arm.com/developer/tools-software/graphics/b/blog/posts/the-mali-gpu-an-abstract-machine-part-4---the-bifrost-shader-core

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

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


최근에 Mali Bifrost 아키텍쳐 패밀리인 Mali-G71 에 대해 발표했습니다. 그것의 전체적인 렌더링 모델은 이전의 Mali GPU 들과 유사한데요, - Bifrost 패밀리는 여전히 매우 파이프라인화되어 있는 타일 기반 렌더러입니다 - 원래의 "Abstract Machine" 블로그 시리즈를 따라가기 위해서 요구되는 프로그래머블( programmable ) 셰이더 코어에서 충분한 변화가 있었습니다.

이 블로그에서 필자는 전형적인 Bifrost 셰이더 코어의 블록 레벨 아키텍쳐를 소개하고, 애플리케이션 개발자가 그 하드웨어를 사용했을 때 기대할 수 있는 성능 향상에 대해서 설명할 것입니다. 그런 성능향상은 칸텐트를 최적화하고 DS-5 Streamline 과 같은 툴들을 통해서 노출되는 하드웨어 성능 카운터들을 이해할 때 올 수 있습니다. 이 블로그에서는 여러분이 이 시리즈의 처음 두 파트를 읽었다고 가정합니다. 만약 읽지 않았다면 읽고 오세요.

GPU Architecture

Bifrost GPU 의 탑레벨 아키텍쳐는 이전의 Midgard GPU 에서와 동일합니다.

The Shader Cores

Midgard 와 마찬가지로 Bifrost 도 통합 셰이더 코어 아키텍쳐입니다. 이는 단일 클래스의 셰이더 코어가 설계에 존재하는 모든 종류의 셰이더 프로그램과 컴퓨트 커널들을 실행하는 기능을 가졌다는 것을 의미합니다.

셰이더 코어의 개수는 특정 실리콘 칩마다 다릅니다; 우리 파트너들은 성능 요구와 실리콘 면적 제한에 기반해 셰이더 코어의 개수를 선택합니다. Mali-G71 GPU 는 저사양 디바이스를 위해 하나의 코어부터 고성능 설계를 위해 32 개의 코어까지 다양합니다.

Work Dispatch

GPU 를 위한 그래픽스 작업은 큐의 쌍으로 제출됩니다. 하나는 버텍스/타일링/컴퓨트 워크로드를 위한 것이며, 다른 하나는 프래그먼트 워크로드를 위한 것입니다. 모든 작업은 각 큐에 단일 서브미션( submission )으로서 제출된( submit ) 하나의 렌더타깃을 위한 것입니다.

각 큐의 워크로드는 더 작은 조각으로 쪼개져 GPU 안의 모든 셰이더 코어들에 분배됩니다. 타일링의 경우에는 고정 함수 타일링 유닛에 분배됩니다. 두 큐로부터의 워크로드들은 동시에 셰이더 코어에 의해 처리됩니다; 예를 들어, 서로 다른 렌더 타깃을 위한 버텍스 처리와 프래그먼트 처리가 병렬적으로 실행될 수 있습니다( 파이프라인 방법론에 대한 세부사항을 원한다면 첫 번째 블로그를 확인하세요 ).

Level 2 Cache and Memory Bandwidth

시스템 내의 처리 유닛들은 성능을 증진시키고 반복되는 데이터 페치( fetch )에 의해 발생하는 메모리 대역폭을 줄이기 위해 L2 캐시를 공유합니다. L2 캐시의 크기는 우리 실리콘 파트너들에 의해 그들의 요구에 맞게 구성됩니다. 하지만 일반적으로는 GPU 의 셰이더 코어당 64 KB 입니다.

메인 메모리에 대한 GPU 외부의 버스 포트의 개수는, 그리고 그에 의한 이용가능한 메모리 대역폭은 구현된 셰이더 코어의 개수에 의존합니다. 일반적으로, 우리는 클락 당 코어 당 32 비트 픽셀을 쓸 수 있도록 하는데, 클락 사이클 당 ( 읽기와 쓰기를 위해 ) 256 비트 메모리 대역폭을 가질 수 있도록 8 코어 설계를 하는 것이 합리적입니다. AXI 포트의 개수는 Midgard 보다 증가했으며, 다운스트림( downstream ) 메모리 시스템이 그것을 지원할 수 있다면, 클락 당 더 높은 최대 대역폭에 접근할 수 있도록 하기 위해 12 개가 넘는 코어들과 함께 더 크게 구성되었습니다.

이용가능한 메모리 대역폭은 GPU( 주파수, AXI 포트 폭 ) 와 다운스트림 메모리 시스템( 주파수, AXI 데이터 폭, AXI 지연 )에 모두 의존한다는 점에 주의하십시오. 많은 설계들에서 AXI 클락은 GPU 클락보다 더 낮을 겁니다. 그러므로 이론적인 GPU 대역폭을 실제로 이용할 수는 없습니다.

The Bifrost Shader Core

Mali 셰이더 코어는 프로그래머블 코어를 둘러싼 여러 개의 고정 함수 하드웨어 블록으로써 구조화되어 있습니다. 프로그래머블 코어는 Bifrost GPU 패밀리에서 가장 크게 변화된 영역입니다. 시리즈의 이전 블로그에서 언급한 Midgard "Tripipe" 설계에서 많은 변경이 있었습니다:

Bifrost 프로그래머블 실행( Execution ) 코어는 하나 이상의 실행 엔진들 -- Mali-G71 의 경우에는 세 개 -- 과 여러 개의 공유 데이터 처리 유닛들로 구성되는데, 이것들은 메시징 구조( messaging fabric )으로 연결되어 있습니다.

The Execution Engines

실행 엔진은 실제로 프로그래머블 셰이더 명령들을 실행할 책임이 있습니다. 각 명령은 단일 복합( composite ) 산술( arithmetic ) 처리 파이프라인과 실행 엔진이 처리 중인 스레드를 위해 요구되는 스레드 상태를 모두 포함합니다.

The Execution Engines: Arithemtic Processing

Bifrost 산술 유닛은 기능 유닛 활용도( functional unit utilization )를 향상시키기 위해 쿼드 벡터화 체계( quad-vectorization scheme )를 구현합니다. 스레드들은 4 개의 번들( bundle )로 그룹화되어 있으며, 이를 쿼드라 부르는데, 각 쿼드들은 128 비트 데이터 처리 유닛의 너비를 채웁니다. 단일 스레드의 관점으로에서 볼 때, 이 아키텍쳐는 스칼라 32 비트 연산의 스트림처럼 보입니다. 이는 셰이더 컴파일러를 위한 상대적으로 직관적인 forward task 의 높은 활용도를 성취할 수 있도록 만들어 줍니다. 아래의 예제는 vec3 산술 연산이 순수한 SIMD 유닛에 매핑되는 방식을 보여 줍니다( 파이프라인은 클락 당 한 스레드를 실행합니다 ):

쿼드 기반 유닛과 비교하면 다음과 같습니다( 파이프라인이 클랑 당 네 개의 스레드를 위해 스레드 당 한 레인( lane )을 실행합니다 ):

프로그램에서의 벡터 길이와는 무관하게 유용한 작업으로 하드웨어 유닛을들을 꽉 차게 유지하도록 만드는 기능의 이점들이 이들 다이어그램에서 명확하게 강조되어 있습니다. 데이터가 32 비트 타입보다 더 작아짐으로 인해서 제공되는 성능 향상과 전력 효율성은 모바일 디바이스에서 여전히 매우 중요합니다. 그러므로 Bifrost 는 int8, int16, fp16 데이터 타입들을 위한 네이티브 서포트( native support, 역주 : 애플리케이션 실행 환경에 이미 존재하는 기능 )를 유지합니다. 이 타입들은 데이터 유닛의 128 비트 데이터 너비를 채우기 위해서 패킹( packed )될 수 있습니다. 그러므로 단일 128 비트 수학 유닛은 8x fp16/int16 연산들을 클락 사이클 당 한 번 처리할 수 있으며 16x int8 연산들을 파이클 당 한 번 처리할 수 있습니다.

The Execution Engines: Thread State

복잡한 프로그램의 성능과 성능 확장성( scalability )을 향상시키기 위해서, Bifrost 는 셰이더 프로그램들이 사용하기 위한 다소 큰 범용 목적( general-purpose ) 레지스터( register ) 파일을 구현합니다. Mali-G71 은 64x32 비트 레지스터들을 제공하는데, GPU 가 스레드를 최대로 점유하는 것을 허용하며, [ ARM Mali Compute Architecture Fundamentals ] 에서 설명한 레지스터 파일 용례( usage ) 와 스레드 카운트 사이에 존재하던 이전의 트레이드 오프( trade off )를 제거했습니다.

OpenGL ES 유니폼( uniform ) 상수와 Vulkan 의 푸시( push ) 상수를 저장하기 위해서 사용되는 빠른 상수 저장소( constant storage )의 크기가 증가되었습니다. 이는 프로그램이 상수 저장소를 대량으로 사용할 때 발생하는 캐시 접근 부하를 줄여 줍니다.

Data Processing Unit: Load/Store Unit

로드/스토어 유닛은 ( 텍스쳐를 제외한 ) 모든 범용 목적 메모리 접근을 다룹니다. 버텍스 애트리뷰트( attribute ) 페치, 베어링( varying, 역주 : 버텍스 버퍼 출력이자 프래그먼트 버퍼 입력인 변수 ) 페치, 버퍼 접근, 스레드 스택 접근 등을 포함합니다. 이는 코어 당 16 KB L1 데이터 캐시를 포함하는데, L1 캐시는 공유 L2 캐시에 의해 뒷받침됩니다.

로드/스토어 캐시는 클락 사이클 당 단일 64 바이트 캐시 라인에 접근할 수 있으며, 스레드 쿼드 사이에서의 접근은 요구되는 고유( unique ) 캐시 접근 요청들을 줄이기 위해 최적화됩니다. 예를 들어, 네 개의 스레드가 같은 캐시라인의 데이터에 접근하고 있다고 할 때, 그 데이터는 단일 사이클에 반환될 수 있습니다.

이 로드/스토어 병합 기능은 일반적인 OpenCL 컴퓨트 커널들에서 찾아 볼 수 있는 데이터 접근 패턴들을 매우 가속화해 줄 수 있습니다. 이는 일반적으로 제한된 메모리 접근이며, 알고리즘 설계에서 그것의 활용을 최대화하는 것이 핵심적인 최적화 목표입니다. 또한 Mali 산술 유닛들이 스칼라임에도 불구하고, 데이터 접근 패턴은 잘 작성된 벡터 로드를 통해 여전히 이점을 얻을 수 있으며, 그래서 여전히 가능할 때마다 벡터화된 셰이더와 커널 코드를 작성하는 것이 좋습니다.

Data Processing Unit: Varying Unit

베어링 유닛은 전용 고정 함수 베어링 보간기( dedicated fixed-function varying interpolator )입니다. 그것은 프로그래머블 산술 유닛과 같은 최적화 전략을 구현합니다; 그것은 스레드 쿼드 사이의 보간값을 벡터화해서 기능 유닛 활용도를 보장하며, 더 빠른 fp16 최적화를 지원합니다.

이 유닛은 클락 당 쿼드 당 128 비트를 보간할 수 있습니다; 즉, mediump ( fp16 ) vec4 를 보간하는 데는 네 스레드 쿼드 당 2 사이클이 걸립니다. 베어링 변수 벡터 길이를 최소화하기 위한 최적화와 fp32 보다는 fp16 을 공격적으로 사용하면 결과적으로 애플리케이션 성능을 향상시킬 수 있습니다.

Data Processing Unit: ZS/Blend

ZS 와 블렌드 유닛은 모든 타일 메모리 접근을 다룰 책임이 있으며, 둘 다 뎁스/스텐실 테스트나 컬러 블렌딩과 같은 내장 OpenGL ES 연산들을 위한 것입니다. 또한 그 타일 버퍼에 프로그램이 접근하는데 필요한 기능들은 다음과 같습니다:

LS 파이프가 타일 버퍼 접근, 베어링 보간, 로드/스토어 캐시 접근을 제어하는 모놀리식( monolithic ) 파이프라인이었던 Midgard 의 설계와는 다르게, Bifrost 는 세 개의 더 작고 더 효율적인 병렬 데이터 유닛을 구현했습니다. 이는 그 타일 버퍼 접근이 베어링 보간을 위해 비동기적으로 실행될 수 있다는 것을 의미합니다. 예를 들면, 그래픽스 알고리즘이 프로그램에서 타일 버퍼 접근을 사용하고 있다면 처리중인 리소스들에 대한 경쟁이 줄어드는 것을 확인할 수 있을 것입니다. Midgard 에서는 LS 파이프라인이 매우 무거운 경향이 있었습니다.

Data Processing Unit: Texture Unit

텍스쳐 유닛은 모든 텍스쳐 접근을 구현합니다. 그것은 코어 당 16 KB L1 데이터 캐시를 포함하며, 공유 L2 캐시에 의해 뒷받침됩니다. Mali-G71 에서는 이 블록의 아키텍쳐 성능이 이전 Midgard GPU 에서와 동일합니다; 클락 당 하나의 바이리니어( bilinear ) 필터링된 텍셀( GL_LINEAR_MIPMAP_NEAREST )을 반환합니다. 예를 들어 바이리니어 텍스쳐 룩업( lookup )을 네 개의 스레드 쿼드의 각 스레드에서 보간하는 데는 4 사이클이 걸립니다.

어떤 텍스쳐 접근 모드들은 데이터를 생성하기 위해서 다중 사이클을 요구합니다:

  • 트라이리니어( trilinear ) 필터링( GL_LINEAR_MIPMAP_LINEAR )은 텍셀당 두 개의 바이리니어 샘플들을 요구하며, 텍셀 당 2 사이클이 필요합니다.
  • 볼륨( volumetric ) 3D 텍스쳐는 2D 텍스쳐보다 두 배의 사이클을 요구합니다; 즉, 트라이리니어 필터링된 3D 텍스쳐는 4 사이클을 요구합니다. 바이리니어 필터링된 3D 텍스쳐는 2 사이클을 요구합니다.
  • 와이드( wide ) 유형의 텍스쳐 포맷( 컬러 채널 당 16 비트 이상 )은 픽셀 당 여러 개의 사이클을 요구할 수 있습니다.

Bifrost 에서 새롭게 최적화된 기능인데, 와이드 포맷 규칙의 예외는 뎁스 텍스쳐 샘플링입니다. 일반적으로 셰도우 매핑 기술이나 디퍼드 라이팅 알고리즘에 의해서 사용되는 DEPTH_COMPONENT16 이나 DEPTH_COMPONENT24 텍스쳐들로부터 샘플링하는 것은 최적화되었으며, 이제 단일 사이클 룩업입니다. 이는 Midgard 패밀리 GPU 에서보다 상대적으로 두 배의 성능을 달성합니다.

The Bifrost Geometry Flow

셰이더 코어 변경에 더해, Bifrost 는 새로운 인덱스 주도 버텍스 셰이딩( Index-Driven Vertex Shading, IDVS ) 지오메트리 처리 파이프라인을 소개합니다. 이전의 Mali GPU 들은 타일링 전에 버텍스 셰이딩을 모두 처리했고, 보통 이는 삼각형을 컬링( cull, 프러스텀 바깥인 경우나 면 방향( facing ) 테스트 실패했을 때 자름 )하기 위해서만 사용되는 베어링과 비교했을 때 계산과 대역폭을 낭비하는 결과를 낳았습니다.

IDVS 파이프라인은 버텍스 데이터를 절반으로 나눕니다; 하나는 위치를 처리하는데 사용되고, 다른 하나는 남은 베어링을 처리하는 데 사용됩니다:

이 플로우는 두 개의 중요한 최적화를 제공합니다:

  • 인덱스 버퍼가 먼저 읽히고, 버텍스 셰이딩은 인덱스 버퍼에 의해서 참조되고 있는 버텍스가 하나 이상 존재하는 버텍스들의 배치( batch )만이 제출( submit )됩니다. 이는 인덱스 버퍼에 있는 공간적 공백( gap )을 버텍스 셰이딩이 스킵할 수 있도록 해 줍니다.
  • 베어링 셰이딩은 clip-and-cull 단계에서 살아 남은 프리미티브들에 대해서만 제출됩니다; 이는 컬링된 삼각형에 대해서만 기여하고 있는 버텍스에 대한 중복 계산과 대역폭을 상당히 감소시켜 줍니다.

Bifrost 지오메트리 플로우를 최대한 활용하려면 패킹된 버텍스 버퍼들을 부분적으로 디인터리빙( deinterleave )하는 것이 좋습니다; 포지션( position )에 기여하는 애트리뷰트들을 하나의 패킹된 버퍼에 배치하고, 포지션 베어링에 기여하지 않는 애트리뷰트들은 다른 패킹된 버퍼에 배치합니다. 이는 화면에 기여하지 않거나 컬링된 버텍스들에 대해서는 비(non)-포지션 베어링들이 캐시에 올라가지 않는다는 것을 의미합니다. 필자의 동료인 stacysmith 는 이러한 유형의 지오메트리 처리 파이프라인을 이용하기 위해 버퍼 패킹을 최적화하는 것에 대한 블로그를 작성했습니다.

Performance Counters

이전의 Midgard GPU 와 마찬가지로, Bifrost 하드웨어도 수많은 성능 카운터들을 제공해 애플리케이션 개발자들이 애플리케이션을 프로우파일링하고 최적화할 수 있도록 하고 있습니다. Bifrost 아키텍쳐에 대해 애플리케이션 개발자들이 이용할 수 있는 성능 카운터에 대한 더 많은 내용을 확인하세요.

댓글과 의견을 항상 환영합니다.

Cheers,

Pete.

원문 : https://community.arm.com/developer/tools-software/graphics/b/blog/posts/the-mali-gpu-an-abstract-machine-part-3---the-midgard-shader-core

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

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


( 역주 : 2016 년 4 분기에 나온 Mali-G51 부터는 Bifrost 셰이더 코어입니다. Mali-T 로 시작하는 것이 Midgard 이고, Mali-$(Number)-MP 가 Utgard 입니다. 이 시리즈의 Part 4 에서는 Bifrost 에 대해 다룹니다 )

시리즈의 처음 두 블로그들에서, 필자는 Mali GPU 들에 의해 사용되는 프레임 수준 파이프라이닝과 타일 기반 렌더링 아키텍쳐에 대해서 소개했습니다. 이는 애플리케이션의 성능을 최적화할 때 그래픽스 스택의 동작을 설명하기 위해서 사용될 수 있는 멘탈 모델을 개발자들에게 제공하려는 목적으로 작성되었습니다.

이 블로그에서, 필자는 이 추상 머신의 생성을 완료하고, 최종 요소를 형성할 것입니다: 틀에박힌( stereotypical ) Mali "Midgard" GPU programmable core. 이 블로그는 여러분이 처음 두 파트들을 읽고 왔다고 가정합니다. 그러므로 읽지 않았다면 먼저 읽고 오시기 바랍니다.

GPU Architecture

Mali GPU 의 "Midgard" 패밀리들( Mali-T600, Mali-T799, Mali-T800 )은 통합( unified ) 셰이더 코어 아키텍쳐를 사용합니다. 이는 설계상으로 단일 유형의 셰이더 코어만이 존재한다는 것을 의미합니다. 이 단일 코어는 모든 유형의 프로그래머블( programmable ) 셰이더 코어를 실행하는데, 버텍스 셰이더, 프래그먼트 셰이더, 컴퓨트 커널들을 포함합니다.

셰이더 코어의 정확한 개수는 특정 실리콘( silicon ) 칩( chip )마다 다릅니다; 실리콘 파트너들은 성능 요구사항과 실리콘 면적( area ) 제한에 기반해서 얼마나 많은 셰이더 코어를 구현할지를 선택할 수 있습니다. Mali-T760 GPU 는 저사양 장치들을 위해 단일 코어부터 고성능 설계를 위해 16 코어까지 달라질 수 있습니다. 하지만 거의 대부분의 구현에서는 4 코어나 8 코어 사이를 유지합니다.

GPU 를 위한 그래픽스 작업은 큐 쌍에 들어 가는데, 버텍스/타일링 워크로드( workload )를 위한 것이 하나이고 프레그먼트 워크로드를 위한 것이 다른 하나입니다. 그것들은 하나의 렌더 타깃을 위해서 동작하며, 각 큐에 단일 서브미션( submission )으로서 서밋( submit )됩니다. 두 큐로부터의 워크로드는 GPU 에 의해 동시에 처리될 수 있습니다. 그래서 서로 다른 렌더 타깃들을 위한 버텍스 처리와 프래그먼트 처리는 병렬적으로 실행될 수 있습니다( 파이프라인 방법론에 대한 세부사항을 원한다면 첫 번째 블로그를 확인하세요 ). 단일 렌더 타깃에 대한 워크로드는 작은 조각으로 나뉘어져서 GPU 에 있는 모든 셰이더 코어들 사이에서 분배됩니다. 타일링 워크로드의 경우에는 고정 함수( fixed-function ) 타일링 유닛에 분배됩니다( 타일링에 대한 개요를 원한다면 시리즈의 두 번째 블로그를 확인하세요 ).

시스템 내의 셰이더 코어들은 L2 캐시를 공유해서 성능을 증진시키며 반복적인 데이터 페치( fetch )에 의해 발생하는 메모리 대역폭을 감소시킵니다. 코어 개수와 마찬가지로, L2 캐시의 크기는 실리콘 파트너들에 의해서 구성됩니다. 하지만 일반적으로는 GPU 의 셰이더 코어 당 32 에서 64 KB 의 범위에 있습니다. 이 캐시가 외부 메모리에 대해 가지고 있는 메모리 포트( port ) 의 개수와 버스 폭( bus width )은 구성이 가능하며, 이 역시 실리콘 파트너가 성능, 전력, 면적 요구사항을 튜닝합니다. 보통 우리는 한 클락( clock ) 당 한 코어 당 32 비트 픽셀을 쓸 수 있도록 요구하며, 8 코어 설계로 클락 사이클( cycle ) 당 ( 읽기 및 쓰기 모두를 위해 ) 총 256 비트의 메모리 대역폭을 기대하는 것이 합리적일 겁니다.

The Midgard Shader Core

Mali 셰이더 코어는 여러 개의 고정 함수 하드웨어 블록으로서 구성되는데, 이는 프로그래머들 "트라이파이프( tripipe, 삼중 파이프 )" 실행( execution ) 코어를 감쌉니다. 고정 함수 유닛들은 셰이더 연산을 위한 설정을 수행하거나 - 삼각형 래스터화( rasterizing )이나 뎁스 테스트 - 셰이더 후처리( post-shader activity )를 제어합니다 - 블렌딩( blending )이나 전체 타일의 가치있는 데이터를 렌더링의 끝에서 메모리에 쓰기. 삼중 파이프 자체는 프로그래머블 파트이며, 셰이더 프로그램의 실행에 대한 책임이 있습니다.

The Tripipe

파이프라인 설계에는 세 가지 분류의 실행 파이프라인이 존재합니다: 하나는 산술( arithmetic ) 연산을 다루며, 하나는 메모리 로드/스토어( load/store ) 및 베어링 변수( varying, 역주 : 버텍스 셰이더 출력이자 프래그먼트 셰이더 입력으로 사용되는 변수 ) 접근을, 하나는 텍스쳐 접근을 다룹니다. 셰이더 코어 당 하나의 로드/스토어 와 하나의 텍스쳐 파이프가 존재합니다. 하지만 산술 파이프라인의 개수는 다양하며, GPU 마다 다릅니다; 오늘날의 대부분의 실리콘 제폼은 두 개의 산술 파이프라인을 가지고 있을 겁니다. 하지만 Mali-T880 은 세 개를 가지고 있습니다.

Massively Multi-threaded Machine

단일 코어에서 한 번에 하나의 스레드만을 실행하도록 하는 전통적인 CPU 아키텍쳐와는 다르게, 트라이파이프는 엄청난 다중 스레드 기반 처리 엔진입니다. 수 백개의 하드웨어 스레드가 트라이파이프 내부에서 동시에 실행될 수 있으며, 셰이딩되는 각 버텍스나 프래그먼트 당 하나의 스레드가 생성될 수 있습니다. 이 엄청난 개수의 스레드들은 메모리 지연( latency )를 감추기 위해 존재합니다; 어떤 스레드가 메모리 작업을 위해 대기하고 있다해도 중요하지 않습니다. 왜냐하면 적어도 한 스레드에서는 실행이 가능할 것이고, 효율적인 실행을 유지할 수 있습니다.

Arithmetic Pipeline: Vector Core

산술 파이프라인( A-pipe ) 는 SIMD( single instruction multiple data ) 벡터 프로세싱 유닛입니다. 이는 128 비트 쿼드-워드( quad-word ) 레지스터( register )들 상에서 수행되는 산술 유닛을 가지고 있습니다. 이 레지스터들은 2 x FP64, 4 x FP32, 8 x FP16, 2 x int64, 4 x int32, 8 x int16, 16 x int8 중의 하나로서 유연하게 접근될 수 있습니다. 그러므로 단일 산술 벡터 작업을 8 개의 "mediump" 값으로 단일 연산에서 수행할 수 있습니다. 그리고 OpenCL 커널들은 클락 사이클 당 SIMD 당 16 픽셀을 처리하기 위해서 8 비트 휘도( luminance ) 데이터 상에서 동작합니다.

아키텍쳐 파이프라인의 내부 아키텍쳐를 밝힐 수는 없지만, 각 GPU 를 위한 공용 성능 데이터를 사용해서 이용 가능한 수학 유닛의 개수에 대한 아이디어를 제공할 수는 있습니다. 예를 들어 16 코어를 가진 Mali-T760 은 600 MHz 로 326 FP32 GFLOPS 를 달성합니다. 이는 이 셰이더 코어의 경우에 클락 사이클 당 34 FP32 FLOPS 입니다; 그것이 두 개의 파이프라인을 가지므로 클락 사이클 당 파이프라인당 17 FP32 FLOPS 가 됩니다. 연산( operation )의 관점에서 이용가능한 성능은 FP16/int16/int8 데이터 타입에서는 증가하고 FP64/int64 데이터 타입에서는 감소할 것입니다.

Texture Pipeline

텍스쳐 파이프라인( T-pipe )은 텍스쳐를 사용하는 모든 메모리 접근에 대한 책임이 있습니다. 텍스쳐 파이프라인은 클락 당 바이리니어 필터링된( binlinear filterd ) 하나의 텍셀( texel )를 반환할 수 있습니다; 트라이리니어 필터링( trilinear filtering )은 메모리로에 있는 두 개의 서로 다른 밉맵( mipmap )로부터 샘플( sample )을 로드할 것을 요구하므로, 완료되려면 두 번째 클락 사이클이 요구됩니다.

Load/Store Pipeline

로드/스토어 파이프라인( LS-pipe )은 텍스쳐링과 관련이 없는 모든 메모리 접근에 대한 책임이 있습니다.

그래픽스 워크로드의 경우, 이는 버텍스 애트리뷰트 인풋( vertex attribute input ) 당 읽기, 버텍스 셰이딩 동안 계산된 버텍스 아웃풋( output ) 당 쓰기, 그리고 프래그먼트 셰이딩 동안 버텍스 셰이더에 의해 작성되고 베어링 변수로 보간될 수 있는 버텍스 아웃풋 값 당 읽기를 의미합니다.

일반적으로 모든 명령( instruction )들은 단일 메모리 접근 연산이며, 그것들은 벡터 연산임에도 불구하고 산술 연산처럼 단일 사이클에서 전체 "highp" vec4  베어링을 로드할 수 있습니다.

Early ZS Testing and Late ZS Testing

OpenGL ES 명세의 "fragment operations" 은 -- 그것은 뎁스 테스트와 스텐실 테스트를 포함합니다 -- 파이프라인의 끝에서 프래그먼트 세이딩이 완료된 후에 발생합니다. 이는 명세를 매우 단순하게 만들지만, 무엇인가를 셰이딩하는데 많은 시간을 소비해야 하고 그것이 ZS 테스트에 의해서 무효화( killed )된 것으로 밝혀진다면 프레임의 끝으로 던져져 버리기만 함을 내포합니다. 그것을 그냥 버려 버리는( discard ) 컬러링 프래그먼트들에서는 성능향상과 에너지 절약에 도움이 되므로, ZS 테스트를 미리 할 수가 있습니다( 예를 들어 프래그먼트 셰이딩 전에 ). 늦은 ZS 테스트는 ( 예를 들어 프래그먼트 셰이딩 이후에 ) 피할 수 없습니다( 즉, "discard" 를 호출할 수도 있는 프래그먼트에 대한 의존성같은 것들은 트라이파이프에 존재할 때까지는 비결정된 뎁스 상태를 가집니다 ).

전통적인 이른 Z 구조와 더불어, 우리는 프래그먼트를 중지시킬 수 있는 일부 오우버드로( overdraw ) 제거( removal ) 기능을 가지고 있습니다. 그것은 이미 래스터화되었지만 출력 화면에는 유용한 방식으로 기여하지 않는다면, 실제 렌더링을 막을 수 있습니다. 내 동료인 seanellis 는 이 기술에 대해 살펴 보는 훌륭한 블로그를 작성했습니다 - Killing Pixels - A New Optimization for Shading on Arm Mali GPUs - 그러므로 여기에서 깊게 살펴 보지는 않겠습니다.

Memory System

이 섹션은 나중에 추가되었습니다. 그래서 만약 이 블로그를 이전에 이미 읽었다면 이 섹션을 기억하지 못할 것입니다. 여러분이 이상하게 아니니까 걱정하지 마세요. 우리는 OpenCL 커널과 OpenGL ES 컴퓨트 셰이더를 작성하는 개발자들에게 GPU 캐시 구조에 대해서 더 많은 정보를 달라는 요청을 받아왔습니다. 캐시 지역성( locality )을 최적화하기 위해서는 데이터 구조체와 버퍼들을 깔고 가는게 실제로 이득이 되기 때문입니다. 사실들은 다음과 같습니다:

  • 셰이더 코어 당 두 개의 16 KB L1 데이터 캐시; 하나는 텍스쳐 접근을 위해 하나는 범용 메모리 접근을 위해.
  • 모든 셰이더 코어들에 의해 공유되는 단일 논리 L2 캐시. 이 캐시의 크기는 다양하며, 실리콘 통합자( integrator )에 의해서 구성됩니다. 하지만 일반적으로는 인스턴스화된( instantiated ) 셰이더 코어 당 32 KB 와 64 KB 의 사이입니다.
  • 두 개의 캐시 레벨은 모두 64 byte 캐시 라인을 사용합니다.

GPU Limits

  • 단순한 모델에 기반해, GPU 성능의 근본적인 속성들의 일부를 알아내는 것이 가능합니다:
  • GPU 는 클락 당 셰이더 코어 당 하나의 버텍스를 제출합니다.
  • GPU 는 클락 당 셰이더 코어 당 하나의 프래그먼트를 제출합니다.
  • GPU 는 클락 당 셰이더 코어당 하나의 픽셀을 리타이어( retire, 역주 : 명령이 분기 예측 실패같은 것들 없이 실제로 완료되는 것을 의미 )합니다.
  • 클락 당 파이프 당 하나의 명령을 제출할 수 있습니다. 그러므로 일반적인 셰이더 코어에 대해서는 만약 가능하다면 병렬적으로 4 개의 명령들을 제출할 수 있습니다:
    • A-pipe 당 17 FP32 연산.
    • LS-pipe 당 한 벡터 로드, 한 벡터 스토어, or one vector varying.
    • T-pipe 당 한 바이리니어 필터 텍셀.
  • 클라 당 코어 당 GPU 는 일반적으로 32 비트 DDR 접근( 읽기와 쓰기 )을 가지게 됩니다[ 구성 가능 ].
만약 Mali-T769 MP8 을 600 MHz 로 스케일링하면, 이론적인 최대 성능은 다음과 같을 수 있습니다:
  • Fill rate:
    • 클락 당 8 픽셀 = 4.8 GPix/s.
    • 완전한 1080p 에 대해 초 당 2314 프레임.
  • Texture rate:
    • 클락 당 8 개의 바이리니어 텍셀 = 4.8 GPix/s.
    • 1080p @ 60 FPS 를 위해, 픽셀 당 38 바이리니어 텍스쳐 룩업( lookup ).
  • Arithmetic reate:
    • 코어 당 파이프 당 17 FLOPS = 163 FP32 GFLOPS.
    • 1080p @ 60 FPS 를 위해, 픽셀 당 1311 FPLOPS.
  • Bandwidth:
    • 클락 당 256 비트 메모리 접근 = 19.2 GB/s 일기 및 쓰기 대역폭.
    • 1080p @ 60 FPS 를 위해, 픽셀당 154 바이트.

OpenCL and Compute

주의깊은 독자들은 필자가 버텍스와 프래그먼트에 대해서 많이 이야기하고 있음을 눈치챘을 겁니다 - the staple of graphics work - 하지만 OpenCL 과 RenderScript 컴퓨트 스레드가 코어로 들어 오고 있는 과정에 대해서는 거의 언급하지 않았습니다. 이 두 가지 유형의 작업은 버텍스 스레드와 거의 유사하게 동작합니다 - 버텍스 셰이더를 실행하는 것을 1 차원 컴퓨트 문제로서의 버텍스 배열로 볼 수 있습니다. 그러므로 버텍스 스레드 생성자( creator )는 컴퓨트 스레드를 생성( spawn )하기도 합니다. 더 정확하게 이야기하자면, 컴퓨트 스레드 생성자가 버텍스도 생성하고 있는 것입니다.

Performance Counters

문서는 Midgard 패밀리 성능 카운터에 대해 설명하고 있습니다. 이 블로그에서 설명한 블록 아케텍쳐에 매핑되어 있으며, Midgard 패밀리에 대한 필자의 블로그에서 찾아 보실 수 있습니다.

Next Time ...

이 블로그는 시리즈의 첫 번째 챕터를 마무리하며, 추상 머진을 개발했습니다. 추상 머신은 기본 동작을 정의하는데, 애플리케이션 개발자가 Midgard 패밀리인 Mali GPU 를 이해할 수 있도록 합니다. 이 시리즈의 후반에서, 필자는 이 새로운 지식이 작동하도록 하기 시작할 것입니다. 그리고 일반적인 애플리케이션 개발 함정과 유용한 최적화 기술들에 대해서 조사할 것입니다. 이런 것들은 Arm DS-5 Streamline 프로우파일링 툴의 Mali integration 을 사용해 식별되고 디버깅될 수 있습니다.

댓글과 의견을 항상 환영합니다.

Pete

원문 : https://community.arm.com/developer/tools-software/graphics/b/blog/posts/the-mali-gpu-an-abstract-machine-part-2---tile-based-rendering

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

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


이전 블로그에서 추상 머신을 정의하기 시작했으며, 그것은 Mali GPU 와 드라이버의 애플리케이션 단에서 볼 수 있는 동작들에 대해서 기술하는 데 사용될 수 있습니다. 이 머신의 목적은 개발자들에게 OpenGL ES API 의 기저에 깔린 흥미로운 동작들에 대한 멘탈 모델을 제공하는 것입니다. 이 모델은 애플리케이션의 성능에 영향을 주는 이슈들을 설명하는 데도 사용될 수 있습니다. 필자는 이 시리즈의 뒤쪽 블로그에서 이 모델을 사용해 개발자가 그래픽스 애플리케이션을 개발하는 동안 만날 수 있는 일반적인 성능 함정( pot-hole )들에 대해서 살펴 볼 것입니다.

이 블로그는 계속해서 추상 머신의 개발에 대해서 이야기하며, Mali GPU 패밀리의 타일 기반 렌더링에 대해 살펴 볼 것입니다. 여러분이 파이프라인에 관한 첫 번째 블로그를 읽어다고 가정합니다. 만약 읽지 않았다면 먼저 읽고 오시기 바랍니다.

The "Traditional" Approach

메인전원에 의해 전력을 공급받는 전통적인 데스크탑 GPU 아키텍쳐 -- 일반적으로 즉시 모드 아키텍쳐( immediate mode architecture )라 불립니다 -- 에서는 프래그먼트 셰이더( fragment shader )들이 각 드로 콜( draw call )에서 순차적으로 각 프리미티브( primitive ) 상에서 수행됩니다. 각 프리미티브들은 다음 프리미티브를 시작하기 전에 완료되기 위해 렌더링되는데, 대충 다음과 같은 알고리즘을 사용합니다:

스트림 내의 어떤 삼각형은 화면 내의 일부 영역을 덮고 있기 때문에, 이들 렌더러에 의해서 유지되는 데이터 워킹 셋( working set of data )은 큽니다; 일반적으로는 적어도 전체 화면 크기의 컬러 버퍼( color buffer ), 뎁스( depth ) 버퍼, 그리고 스텐실( stencil ) 버퍼. 최신 장치들을 위한 일반적인 워킹 셋은 픽셀당 32 비트 컬러 버퍼, 그리고 32 비트로 패킹된( packed ) 뎁스/스텐실 버퍼입니다. 그러므로 1080p 디스플레이는 16 MB 의 워킹 셋을 가지며, 4k2k TV 는 64 MB 의 워킹 셋을 가집니다. 그것들의 크기 때문에, 이 워킹 버퍼들은 반드시 칩 바깥에 있는 DRAM 에 저장되어야 합니다.

모든 블렌딩( blending ), 뎁스 테스트, 스텐실 테스트 연산은 현재 프래그먼트의 픽셀 좌표를 위한 현재 데이터 값을 요구합니다. 이 값들은 이 워킹 셋으로부터 획득( fetch ) 니다. 셰이딩된 모든 프래그먼트들은 일반적으로 이 워킹 셋을 손대게 되며, 그래서 고해상도에서는 프래그먼트에 대해 읽기-수정-쓰기 연산을 하게 되면, 캐싱이 다소 완화해 준다고 하더라도, 이 메모리 상에서 예외적으로 높은 대역폭을 소비할 수 있습니다. 또한 높은 대역폭 접근에 대한 요구는 매우 많은 핀과 특별한 고주파수 메모리를 가진 넓은 메모리 인터페이스를 요구합니다. 둘 다 외부 메모리 접근을 필요로 하며, 이는 특히 에너지를 많이 사용하는 작업입니다.

The Mali Approach

Mali GPU 패밀리는 매우 다른 접근 방법을 사용하는데, 일반적으로 타일 기반 렌더링이라 불리며, 렌더링 동안 필요한 외부 메모리 접근과 전력( power ) 소비량을 최소화하기 위해서 설계되었습니다. 이 시리즈의 첫 번째 블로그에서 설명했듯이, Mali 는 각 렌더 타깃에 대해 구별되는 두 개의 패스 렌더링 알고리즘을 사용합니다. 그것은 먼저 지오메트리 처리를 모두 실행한 다음에 프래그먼트 처리를 실행합니다. 지오메트리 처리 스테이지 동안, Mali GPU 는 화면을 작은 16x16 픽셀 타일들로 쪼개고, 렌더링 중인 프리미티브들이 어떤 타일에 제출되고 있는지에 대한 리스트를 만듭니다. GPU 프래그먼트 셰이딩 단계가 실행될 때, 각 셰이더 코어는 한 번에 하나의 16x16 픽셀 타일을 처리합니다. 다음 타일을 렌더링하기 전에 렌더링이 끝납니다. 타일 기반 아키텍쳐에서 알고리즘은 다음과 같은 식입니다:

16x16 픽셀 타일은 전체 화면 영역에 비해 작은 조각이므로, GPU 셰이더 코어와 강하게 결합되어 있는 빠른 RAM 에 전체 타일을 위한 전체 워킹 셋( 컬러, 뎁스, 스텐실 )을 유지하는 것이 가능합니다.

이 타일 기반 접근 방법은 여러 개의 이점을 가지고 있습니다. 그것들은 거의 개발자들에게 있어 투명하지만( transparent ) 알아 두면 가치가 있습니다. 특히 여러분의 칸텐트가 가지는 대역폭 비용을 이해하려고 할 때 그렇습니다:

  • 워킹 셋에 대한 모든 접근들은 지역적( local ) 접근입니다. 그것은 빠르면서 전력을 덜 소비합니다. 외부 DRAM 에 대한 읽기나 쓰기에 필요한 전력은 시스템 설계에 따라 다양해지지만, 1 Gbyte/s 의 대역폭을 위해 120 mW 정도는 쉽게 소비됩니다. 내부 메모리 접근들은 대략 이 보다는 자리수가 다르게 낮습니다. 그러므로 이것이 매우 중요하다는 것을 이해할 수 있을 겁니다.
  • 블렌딩은 빠르고 전력 효율이 높습니다. 왜냐하면 블렌드 연산들을 위해 요구되는 대상 컬러 데이터를 손쉽게 이용할 수 있기 때문입니다.
  • 타일은 4x, 8x, 16x 멀티샘플 안티에일리어싱( multisample antialiasing ) 을 허용하기 위해 타일 메모리에 충분한 샘플들을 저장할 수 있을만큼 충분합니다. 이는 고품질이며 부하가 적은 안티 에일리어싱을 제공합니다. 하지만 관련 작업 셋의 크기 때문에 ( 일반적인 단일 샘플링 렌더 타깃의 4, 8, 16 배; 4k2k 디스플레이 패널의 16x MSAA 에는 1 GB 의 워킹 셋 데이터가 필요합니다 ), 개발자에게 MSAA 를 기능으로 제공하는 즉시 모드 렌더러는 드뭅니다. 왜냐하면 일반적으로 외부 메모리 공간과 대역폭이 너무 비싸기 때문입니다.
  • Mali 는 타일 작업의 끝에서 메모리에다가 단일 타일을 위한 컬러 버퍼만을 써야만 합니다. 이 시점에 우리는 그것의 최종 상태를 알게 됩니다. CRC 체크를 통해 메인 메모리에 있는 현재 데이터와 블록의 컬러를 비교할 수 있습니다 -- Transaction Elimination 이라 불리는 처리입니다 -- 이때 타일 칸텐츠가 동일하다면 쓰기를 취소하며 SoC 의 전력을 절약하게 됩니다. 필자의 동료인 tomolson 은 이 기술에 대한 훌륭한 블로그를 작성했습니다. 그는 Transaction Elimination( Angry Birds 라 불리는 어떤 게임에서 이것에 대해 들어 봤을 겁니다 )에 실제 예제를 완성했습니다. 필자는 Tom 의 블로그에서 이 기술을 더욱 자세하게 설명할 것입니다만, 여기에서는 그 기술에 대해서 살짝 훔쳐 보기만 하겠습니다( "extra pink" 타일들만이 GPU 에 의해 쓰여진 것입니다 - 다른 것들은 성공적으로 취소된 것입니다 ).
  • 빠르고, 손실없는 압축 기법을 사용해서 Transaction Elimination 을 실행하는 타일을 위해 컬러 데이터를 압축할 수 있습니다 -- ARM Frame Buffer Compression( AFBC ) -- 이는 대역폭과 전력을 더 줄여 줍니다. 이 압축은 오프스크린 FBO 렌더 타깃들에 대해서 수행될 수 있으며, 그것은 GPU 에 의해 일련의 렌더링 패스들에 텍스쳐로서 읽어들여질 수 있습니다. 또한 거기에 공급되는 메인 윈도우 서피스는 시스템의 Mali-DP500 과 같은 AFBC 호환 디스플레이 컨트롤러입니다.
  • 대부분의 칸텐트는 뎁스 버퍼와 스텐실 버퍼를 가지고 있습니다. 하지만 그것들의 칸텐츠는 일단 프레임 렌더링이 끝낙 나면 유지될 필요가 없습니다. 만약 개발자가 Mali 드라이버에 뎁스 버퍼와 스텐실 버퍼를 유지할 필요가 없다고 알리면 -- 이상적으로는 glDiscardFramebufferEXT ( OpenGL ES 2.0 ) 이나 glInvalidateFramebuffer ( OpenGL ES 3.0 ) 에 대한 호출을 통해, 그리고 어떤 경우에는 드라이버에 의해서 추론될 수도 있지만 -- 타일의 뎁스 칸텐트와 스텐실 칸텐트는 결코 메인 메모리에 써지지 않을 것입니다. 대역폭과 전력을 크게 절약하는 또다른 방법입니다.

위의 리스트들을 통해 명확해 진 것은 타일 기반 렌더링이 여러 가지 이점을 가지고 있다는 것입니다. 특히 프레임버퍼 데이터와 관련한 대역폭과 전력에 있어서 매우 많이 절약해 주며, 저비용 안티 에일리어싱을 제공할 수 있습니다. 단점은 무엇일까요?

모든 타일 기반 구조의 필수적인 부하는 버텍스 셰이더에서 프래그먼트 셰이더로 넘어 가는 시점에 발생합니다. 지오메트리 처리 스테이지의 출력은 버텍스와 타일러 즉시 상태( tiler immediate state ), 반드시 메인 메모리에 쓰여진 후에 프래그먼트 처리 스테이지에서 다시 읽어들여져야 합니다. 그러므로 다양한 데이터와 타일러 상태를 위한 추가 대역폭을 소비하는 것과 프레임 버퍼 데이터를 위한 대역폭을 절약하는 것 사이에서 절충을 하기 위해 균형이 잡혀 있어야 합니다.

오늘날 현대의 소비자 가전제품은 높은 해상도 디스플레이로 이동하고 있습니다; 1080p 는 이제 스마트 폰에서 일반적이며, Google Nexus 10 의 Mali-T604 와 같은 태블릿은 WQXGA( 2560x1600 )에서 동작합니다. 4k2k 는 텔레비전에서는 새로운 "필수품( must have )"이 되고 있습니다. 화면 해상도와 프레임 버퍼 대역폭은 빠르게 커지고 있습니다. 이 영역에서 Mali 가 활약하고 있으며, 대부분 애플리케이션 개발자들에게 있어서는 투명하게 동작합니다 - 여러분은 이런 기능들을 애플리케이션을 변경하지 않고도 그냥 사용할 수 있습니다.

지오메트리 측면에서, Mali 는 복잡도에 잘 대응하고 있습니다. 많은 고사양 벤치마크들이 프레임 당 백만 개의 삼각형들을 사용하며, 이는 Android 앱 스토어에 있는 인기있는 게이밍 애플리케이션들과 비교하면 한 두 자리수 만큼 더 복잡도를 가지고 있습니다. 하지만 즉시 지오메트리 데이터는 메인 메모리에 접근하게 되므로, GPU 성능을 튜닝하고 시스템이 최선을 다하게 하는 데 적용될 수 있는 몇 가지 유용한 팁들과 트릭들이 있습니다. 그것들은 각각 블로그를 써도 될 만큼 가치가 있습니다. 그래서 우리는 이 시리즈의 뒷 부분에서 그것들에 대해 다룰 것입니다.

Summary

이 블로그에서 필자는 데스크탑 스타일 즉시 모드 렌더러와 Mali 에서 사용하는 타일 기반 접근 방법을, 특히 메모리 대역폭을 살펴 봄으로써, 비교하고 그것들의 차이점을 보여 주었습니다.

다음 시간에 튜닝을 하고 Mali 셰이더 코어 자체에 대한 간단한 블록 모델을 살펴 보면서 추상 머신에 대한 정의를 마칠 것입니다. Once we have that out of the way we can get on with the useful part of the series: putting this model to work and earning a living optimizing your applications running on Mali.

항상 댓글과 의견을 환영합니다.

Pete

원문 : https://community.arm.com/developer/tools-software/graphics/b/blog/posts/the-mali-gpu-an-abstract-machine-part-1---frame-pipelining

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

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


그래픽스 워크로드( workload )를 최적화하는 것은 보통 많은 현대 모바일 애플리케이션에서 필수입니다. 거의 대부분의 렌더링은 이제 렌더링 백엔드( back-end ) 에 기반한 OpenGL ES 에 의해 직접 혹은 간접적으로 다뤄집니다. 내 동료 중의 하나인 Michael McGeagh 는 최근에 Mali-T604 GPU 를 사용하는 그래피컬 애플리케이션들을 프로우파일링하고 최적화하기 위한 목적으로 Google Nexus 10 에서 동작하는 프로우파일링 툴인 Arm DS-5 Streamline 에 대한 작업 가이드를 포스팅했습니다. Streamline 은 강력한 도구이며, 전체 시스템의 동작에 대한 고해상도 가시화를 제공합니다. 하지만 그것은 엔지니어가 데이터를 해석( interpret )하고 문제 영역을 식별하고 수정할 점을 제안하는 데 시간을 쏟게 만듭니다.

그래픽스 최적화에 익숙하지 않은 개발자들에게는, 처음 시작할 때 공부할 것이 좀 있다고 할 수 있습니다. 그러므로 이 새로운 블로그 시리즈는 칸텐트 개발자들에게 필수적인 지식들을 제공해서 Mali GPUs 에서 성공적으로 최적화를 할 수 있도록 하기 위해 작성되었습니다. 이 시리즈의 코스 전반에서, 필자는 기본적인 macro-scale architectural structure 들과 동작들에 대해서 살펴 볼 것이므로, 개발자들은 이것을 어떻게 칸텐트에서 발생할 수 있는 가능한 문제로 해석할 것인지 그리고 Streamline 에서 그것들을 어떻게 확인할 것인지에 대해 걱정할 필요는 없습니다.

Abstract Rendering Machine

애플리케이션의 그래픽스 성능을 성공적으로 분석하기 위해서 필요한 가장 필수적인 조각은 OpenGL ES API 함수들을 밑바탕에 있는 시스템들에 대한 멘탈 모델( 역주 : 한글 링크 )입니다. 이는 엔지니어가 그들이 관찰하고 있는 동작들에 대해 추론할 수 있도록 합니다.

개발자들이 제어할 수 없고 결국에는 제한된 가치인 드라이버 소프트웨어와 하드웨어 서브시스템의 구현 세부사항 때문에 개발자들이 압도되지 않도록 하기 위해서는, 단순된 추상 머신을 정의하는 것이 유용합니다. 이는 관찰되는 동작들에 대해 설명하기 위한 기초로 사용될 수 있습니다. 이 머신에는 세 가지 주요 파트가 있으며, 그것들은 거의 직교적( orthogonal, 역주 : 서로 간섭하지 않고 독립적이어서 따로 따로 이해할 수 있다는 의미인듯 )입니다. 그래서 필자는 각각을 시리즈의 처음 몇 개의 블로그를 통해 순서대로 다루려고 합니다. 하지만 이 모델의 세가지 파트들에 대해 미리 살펴 보면 다음과 같습니다 :

  1. The CPU-GPU rendering pipeline.
  2. Tile-based rendering.
  3. Shader core architecture.

이 블로그에서, 우리는 이들 중 첫 번째인 CPU-GPU rendering pipeline 에 대해서 살펴 보겠습니다.

Synchronous API, Asynchronous Execution

이를 이해하는 데 있어서 중요한 지식의 가장 근본적인 조각은 OpenGL ES API 에서 애플리케이션의 함수 호출과 그 API 호출들이 요구하는 렌더링 연산( operations )들의 실행 사이의 임시적 관계( temporal relationship )입니다. OpenGL ES API 는 애플리케이션의 관점에서는 동기적 API 로서 설계되었습니다( specified ). 애플리케이션은 일련의 함수 호출들을 만들어서 그것의 다음 드로잉 태스크에 필요한 상태( state )를 설정합니다. 그리고 나서 glDraw 함수를 호출해서 -- 보통 드로 콜이라 불립니다 - 실제 드로잉 연산을 발생시킵니다. API 는 동기적이므로, 드로 콜이 만들어진 후의 모든 순차적 API 동작은 렌더링 연산이 이미 실행된 것처럼 행동하도록 설계되어 있습니다. 하지만 거의 대부분의 하드웨어 가속 OpenGL ES 구현들에서는, 이는 드라이버 스택에 의해서 유지되는 정교한 환상입니다.

드로 콜에서도 이와 유사한 것이 있습니다. 드라이버에 의해서 유지되는 두 번째 환상은 프레임 버퍼 교체 완료( end-of-framebuffer flip )입니다. OpenGL ES 애플리케이션을 처음 작성하는 대부분의 개발자들은 eglSwapBuffers 호출이 프런트 버퍼와 백 버퍼를 교환( swap )한다고 말할 겁니다. 이것이 논리적으로는 사실이기는 하지만, 드라이버는 또 다시 동기화에 대한 환상을 유지하고 있는 것입니다; 거의 대부분의 플랫폼에서 물리 버퍼 교환은 한참 후에 발생합니다.

Pipelining

이러한 환상이 필요한 이유는, 여러분이 원하는 성능때문입니다. 만약 렌더링 연산을 실제로 동기적으로 수행하도록 강제한다면, CPU 가 다음 드로 연산을 위한 상태를 생성하느라 바쁠 때 GPU 가 놀게( idle ) 될 것입니다. 그리고 GPU 가 렌더링하고 있는 동안 CPU 가 놀 것입니다. 성능의 관점에서는 이런 유휴 시간( idle time )들은 허용될 수 없습니다.

이 유휴 시간을 제거하기 위해서, 우리는 OpenGL ES 드라이버를 사용해 동기적으로 렌더링되는 것처럼 환상을 유지했습니다. 하지만 실제로는 렌더링과 프레임 교환을 비동기적으로 처리하고 있습니다. 비동기로 실행하면 작은 백로그( backlog ) 작업을 빌드할 수 있으므로, GPU 가 파아프라인의 한쪽 끝에서 오래된 워크로드를 처리하는 곳에서 파이프라인을 만들 수 있고 CPU 는 새로운 작업을 다른 쪽에서 밀어 넣을 수 있습니다. 이 접근 방법의 장점은 파이프라인을 가득 채우면 GPU 에서 실행할 수 있는 작업이 항상 최고의 성능을 제공한다는 것입니다.

Mali GPU 파이프라인에서 작업 유닛( unit )들은 렌더 타깃( render target ) 기반( basis )으로 스케줄링됩니다. 여기에서 렌더 타깃은 윈도우 서피스( surface )이거나 오프스크린 렌더 버퍼일 수 있습니다. 단일 렌더 타깃은 두 단계의 절차로 처리됩니다. 먼저 GPU 는 렌더 타깃 내의 모든 드로 콜들에 대한 버텍스 셰이딩( vertex shading )을 처리합니다. 다음으로 전체 렌더 타깃을 위한 프래그먼트( fragment ) 셰이딩이 처리됩니다. 그러므로 Mali 를 위한 논리 렌더링 파이프라인은 세 가지 스테이지의 파이프라인으로 구별됩니다: CPU 프로세싱 스테이지, 지오메트리( geometry ) 프로세싱 스테이지, 프래그먼트 프로세싱 스테이지.

Pipeline Throttling

관찰력있는 독자들은 위의 그림에 있는 프래그먼트 작업이 CPU 및 지오메트리 처리 스테이지들보다 점점 더 느려지고 세 가지 연산 중에 가장 느리다는 것을 눈치챘을 것입니다. 이런 상황이 비정상적인 것은 아닙니다; 대부분의 칸텐트들은 셰이딩되는 버텍스보다는 프래그먼트를 더욱 많이 가지고 있으므로, 프래그먼트 셰이딩이 보통 주요 처리 연산입니다.

사실은 CPU 작업을 완료하는 것부터 프레임이 렌더링되는 것까지의 지연양( amount of latency )을 최소화하는 것이 바람직합니다 -- 최종 사용자 입장에서는 자신들이 터치 이벤트를 입력하고 있는 터치 스크린 디바이스와 상호작용( interacting )이 스크린의 데이터와 몇 백 밀리세컨드 정도 차이가 나는 상황이 가장 혼란스럽습니다 -- 그래서 우리는 프래그먼트 처리 스테이지를 위해 대기하는 밀린 작업이 너무 많아지는 것을 원하지 않습니다. 간단히 말해, CPU 스레드를 주기적으로 느리게 만드는 메커니즘( mechanism )이 필요합니다. 파이프라인이 이미 성능을 유지하기에 충분할 때 작업 대기열( queuing up )을 중단합니다.

이 조절( throttling ) 메커니즘은 보통 그래픽스 드라이버가 아니라 호스트 윈도우 시스템에 의해 제공됩니다. 예를 들어 안드로이드에서는 버퍼의 방향( orientation )을 알기 전까지는 프레임에서 어떠한 드로 연산도 처리할 수 없습니다. 왜냐하면 유저가 장치를 회전시켜서 프레임 크기를 변화시킬 수 있기 때문입니다. SurfaceFlinger -- 안드로이드 윈도우 서피스 관리자 -- 는 렌더링을 위해서 N 개의 이상의 버퍼가 이미 존재할 때 애플리케이션의 그래픽스 스택에 버퍼를 반환하는 것을 거절함으로써 파이프라인 깊이를 단순하게 제어합니다.

만약 이런 상황이 발생한다면, "N" 에 도달하자 마자 CPU 가 프레임당 한 번씩 유휴상태가 되어, EGL 이나 OpenGL ES API 함수들 내부에서 블락킹( blocking )이 발생하는데, 이는 디스플레이가 보류중인( pending ) 버퍼를 소비하고 새로운 렌더링 연산을 위해서 하나를 비워줄( freeing ) 때까지 유지됩니다.

그래픽스 스택이 디스플레이 리프레시 비율( rate )보다 더 빠르게 실행되고 있을 때 이와 동일한 방식으로 파이프라인 버퍼링에 제약이 가해집니다; 이 시나리오에서는 칸텐트는 디스플레이 컨트롤러에 다음 프론트 버퍼를 교체할 수 있다고 알려주는 수직 공백( vertical blank, vsync )를 기다리면서 "vsync limited" 됩니다. 만약 GPU 가 디스플레이가 그것을 보여줄 수 있는 것보다 빠르게 프레임을 생산한다면, SurfaceFlinger 는 렌더링이 완료되었지만 여전히 화면에 출력되어야 할 버퍼들의 개수를 누적할 것입니다; 심지어 이 버퍼들은 더 이상 Mali 파이프라인의 일부가 아님에도 불구하고, 그것들은 애플리케이션 처리를 위한 N 프레임 제약을 위해 카운트됩니다.

위의 파이프라인 다이어그램에서 보이듯이, 만약 칸텐트가 vsync 제한에 걸리면, CPU 와 GPU 가 완전히 유휴상태가 되는 주기를 가지는 것이 일반적입니다. 플랫폼 동적 전압( Platform dynamic voltage )과 주파수 스케일링( frequency scaling, DVFS )은 일반적으로 이런 시나리오에서 현재의 연산 주파수를 줄이려고 시도합니다. 이를 통해 전압과 에너지 소비가 줄어들지만, DVFS 주파수 선택은 보통 상대적으로 거칠기 때문에 일부 유휴 시간이 예상됩니다.

Summary

이 블로그에서 우리는 OpenGL ES API 에 의해 제공되는 동기화 환상과 API 뒤에서 비동기 렌더링 파이프라인이 실제로 실행되는 이유에 대해 살펴 보았습니다. 다음 시간에 필자는 계속해서 추상 머신 개발에 대해서 이야기할 것입니다. Mali GPU 의 타일 기반 렌더링 접근법에 대해서 살펴 보도록 하겠습니다.

댓글과 질문을 환영합니다.

Pete

원문 : Vulkan Debug Utilities.

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

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


Introduction

벌칸 API 는 이제 2 살이 되었습니다. 그리고 다른 것들처럼 개량할 영역이 있어 보입니다( and as with all things it is showing areas that require improvement ). 디버깅이 그런 영역 중의 하나인데요, 여기에서 우리는 벌칸 커뮤니티를 위해 큰 이득을 제공해줄 작은 변화를 줄 수 있습니다. IHV( 역주 : Independent Hardware Vendor ) 들과 몇몇 게임 회사들로부터의 입력을 요청하고 깃허브 유저들로부터 피드백을 받은 후에, 우리는 VK_EXT_debug_report 와 VK_EXT_debug_marker 에 의해 노출되는 디버깅 기능들을 개선하기로 했습니다. 우리가 연구한 결과, 현재 익스텐션( extension )에 다가 새로운 기능을 억지로 집어 넣으려고 시도하는 대신에, 익스텐션을 교체하는 것이 올바른 결정이라고 판단했습니다. 그 변화는 새로운 익스텐션인 VK_EXT_debug_utils 의 창조를 이끌었습니다.

Why the New Extension?

Vulkan Working Group 은 몇몇 소프트웨어 회사들에 근무하는 개발자들로부터 각각의 디버그 메시지들로부터 더 많은 정보를 받아 자신들의 코드에서 유발점( trigger )을 구별( isolate )할 수 있도록 해 달라는 피드백을 받았습니다. 애플리케이션은 다중의 벌칸 오브젝트들을 생성하고 그 중의 하나만이 잘못 다뤄질 수 있기 때문에, Validation messages created a special concern.

소프트웨어 개발자들이 더 효율적으로 이슈( issue )들을 구별하는 것을 돕기 위해, LunarG 는 VK_EXT_debug_report 와 VK_EXT_debug_marker 의 기능들을 병합해 더욱 유용한 디버그 메시지들을 생성하기로 했습니다. 그러나 이 두 가지 개별 익스텐션들 사이에서 협업을 시도하는 중에, 우리는 근본적인 문제가 있다는 것을 깨닫게 되었습니다. VK_EXT_debug_report 는 인스턴스( instance ) 익스텐션이지만 VK_EXT_debug_marker 는 디바이스( device ) 익스텐션이라는 겁니다. 디바이스 익스텐션에 대해 독립적인 인스턴스 익스텐션의 기능을 지정하기 위한 쉽고 깔끔한 방법이 존재하지 않습니다. 단순함을 위해, 그냥 새로운 익스텐션을 만들고, 한 곳에서 필수적인 모든 아이템들을 지원하기로 했습니다.

또한 유저의 디버그 콜백에 반환되는 정보를 확장하기도 했습니다. 이러한 변경은 예전의 익스텐션에도 적용될 수 있었지만, 대부분의 구조체들의 pNext 체인에 아이템들을 추가할 것을 요구했습니다. 그렇게 할 수는 있었지만, 우리가 생각한 것보다는 더 복잡했습니다. 왜냐하면 모든 디버그 콜백이 pNext 체인을 고려해야만 하기 때문이었습니다. 물론 우리는 여전히 나중에는 pNext 체인에 기능을 추가하긴 할 겁니다.

마지막으로 VK_EXT_debug_report 익스텐션은 VkDebugReportObjectTypeEXT 라는 특별한 내부 열거형을 사용해서 오브젝트 타입( type )을 추적( tracking )합니다. 이 열거형은 잠깐 동안 지원되었으며 심지어는 VK_EXT_debug_marker 익스텐션에서도 사용되었습니다. 하지만 벌칸 명세의 최신 버전들에서는 이 구조를 새로운 코어 오브젝트 타입 열거형으로 대체했습니다. 명세가 변경되었으므로, 크로노스는 VkDebugReportObjectTypeEXT 를 확장하는 것을 중지하고 대신에 VkObjectType 에다가만 새로운 열거을 추가하는 것을 지원하기로 결정했습니다. 결과적으로 VkDebugReportObjectTypeEXT 열거형은 시간이 흐르면 점점 더 곰팡내나게 될 것입니다.

이러한 사실들을 고려해서, LunarG 는 벌칸 디버그 유틸리티를 처음부터 새로 만들기로 했습니다.

Benefits of This New Extension

VK_EXT_debug_utils 는 VkDebugUtilsMessengerEXT 라는 디버그 메신저( messenger ) 개념을 소개합니다. 이를 생성하는 동안에, 애플리케이션은 어떤 디버그 메시지 타입들과 심각성( severity )들이 필요한지를 세부적으로 결정합니다. 추가적으로, 애플리케이션은 콜백 메시지 핸들러에 대한 함수 포인터를 제공하는데, 이는 메시지가 적절한 심각성과 타입을 만족시켰을 대 호출될 것입니다. VkDebugReportCallbackEXT 오브젝트 타입은 VK_EXT_debug_report 와 유사한 방식으로 동작합니다. 개선된 유용상은 새로운 콜백에 이제는 데이터가 공급된다는 데 있습니다. 이 콜백에서 공급된 데이터를 읽기 위해서는 "Creating a Debug Messenger Callback" 섹션을 참고하십시오.

VK_EXT_debug_marker 익스텐션과 마찬가지로, 새로운 익스텐션은 VkCommandBuffer 의 특정 위치를 식별할 수 있도록 해 줍니다. 이전에 VK_EXT_debug_marker 에서는, 실별된 위치가 "마커( marker )"라고 불렸습니다. 이제 VK_EXT_debug_utils 에서는, 그것들이 "레이블( label )"이라 불립니다. 또한 Vk_EXT_debug_utils 는 이 레이블들을 VkQueue 에다가 삽입해서 벌칸 큐( queue )에서 처리중인 runtime/driver/hardware 의 프로그레스를 식별할 수 있도록 하는 기능을 추가했습니다. 레이블에 대한 더 많은 정보를 원한다면 "Adding Labels" 섹션을 참고하십시오.

VK_EXT_debug_utils 익스텐션에 의해서 지원되는 VK_EXT_debug_marker 의 또 다른 기능은 애플리케이션이 정의한 데이터를 벌칸 핸들과 연관시키는 기능입니다. 이 기능의 거의 대부분의 용도는 각 벌칸 핸들을 쉽게 식별이 가능한 문자열 이름으로 만드는 것입니다. 벌칸 오브젝트의 핸들값은 로더( loader ), 레이어( layer ), ICD 를 포함한 모든 벌칸 컴포넌트에 대해서 내부적으로 변할 수 있습니다 -- 그러므로 핸들을 명명하지 않으면, 반환된 정보들이 맞는지 헷갈릴 겁니다. 예가 하나 있습니다:

만약 vkCmdBindPipeline 을 잘못 호출했고 validation 레이어를 활성화해 둔 상태라면, 에러 메시지가 에러가 있는 VkCommandBuffer 를 위한 핸들에 대해 언급하는 것을 찾을 수 있을 것입니다. 그런데 VkCommandBuffer 핸들에 대한 여러분의 리스트에서 문제가 있는 핸들을 찾으려고 하면, 어떤 값도 일치하는 것이 없습니다. 그것은 로더나 레이어에 의해서 발생할 수 있는 일입니다. 왜냐하면 그것들은 같은 오브젝트에 대해서 다른 핸들을 가지고 있기 때문입니다.

그러나 애플리케이션이 자신의 벌칸 핸들에 대한 이름을 설정해 놓았다면, 그 이름들은 이 익스텐션을 지원하는 모든 컴포넌트들 내부에서 그 핸들들과 연관됩니다. 이는 오브젝트에 대한 핸들값이 변경되었더라도 올바로 동작합니다. 위의 경우에, VkCommandBuffer 가 "Primary Command Buffer in Thread B" 라고 명명되어 있었다면, 여러분은 특이한 핸들 값을 획득하게 되지만, 그것은 "Primary Command Buffer in Thread B" 라는 이름을 가지고 있을 것입니다. 이름에 대한 더 많은 정보를 원한다면, "Naming Objects" 섹션을 참고하십시오.

VK_EXT_debug_utils 는 태그를 사용하여 특정 오브젝트를 위한 바이너리( binary ) 칸텐트를 정의하는 기능을 계속해서 지원합니다. 이러한 태그가 걸린 칸텐트는 매우 복잡한 경향이 있으며, 거의 대부분 RenderDoc 같은 부가적인 칸텐트를 필요로 하는 디버깅 레이어를 위해서 사용됩니다. 태그들은 어떠한 validation 레이어 메시지에 의해서도 사용되지 않습니다. 그리고 디버그 메신저 콜백에서 유저가 획득할 수도 없습니다. 더 많은 정보를 원한다면 "Tagging Objects" 섹션을 참고하십시오.

위의 정보들을 일고 나면, 여러분은 새로운 익스텐션이 별로 새로운 것을 포함하고 있지 않다고 느낄 것입니다. 그러나 VK_EXT_debug_report 에 등록된 콜백에서 반환된 데이터를 살펴 본다면, 디버그 메시지가 콜백에 반환될 때 단지 하나의 오브젝트와 메시지만을 획득하게 된다는 것을 깨닫게 될 것입니다. 새로운 익스텐션을 사용하고 있다면, VK_EXT_debug_utils 는 여러분의 콜백 함수에 설정하고 넘김수 있는 모든 정보들을 결합합니다. 여기에 이전의 VK_EXT_debug_report 익스텐션이 반환한 콜백 메시지와 더불어 더 포함된 추가 정보들을 정리해 봤습니다:

  • 각 디버그 메시지와 관련이 있는 오브젝트들의 리스트.
  • ( 만약 이름이 설정되었다면 ) 각 오브젝트와 연관된 이름.
  • 해당 시점까지 맞닥뜨린 커맨드 버퍼 레이블 리스트.
    • 오브젝트 리스트에서 VkCommandBuffer 만이 존재하고 그것과 관련이 있는 레이블을 가지고 있을 때만...
  • 해당 시점까지 맞닥뜨린 큐 레이블 리스트.
    • 오브젝트 리스트에 VkQueue 만이 존재하고 그것과 관련된 이름을 가지고 있을 때만...

이 부가 정보들을 사용하면, 가장 복잡한 애플리케이션이라고 할지라도, 디버그 메시지의 위치를 쉽게 좁혀갈 수 있을 것입니다. 이는 VK_LAYER_LUNARG_standard_validation 을 활성화했을 때 특히 유용하며, 일련의 특별한 커맨드들에 대한 validation 메시지로부터 에러를 받게 될 것입니다.

How Do I Use It?

사용하기 전에, VkEnumerateInstanceExtensionProperties 를 사용해서 VK_EXT_debug_utils 익스텐션을 사용할 수 있는지 확인해야 합니다. 그러나 일단 익스텐션이 이용가능하다는 것을 확인했다면, 그것의 디버깅 기능들을 해제할 수 있습니다.

우리는 아래에서 VK_EXT_debug_utils 의 핵심 기능들에 대해서 다룰 겁니다. 하지만 이 새로운 익스텐션을 사용하는 방법은 벌칸 명세의 "Debugging" 섹션에서 자세하게 알아 볼 수 있을 것입니다.

이 익스텐션을 사용하는 것은 이전에 VK_EXT_debug_report 와 VK_EXT_debug_marker 를 사용하는 것과 유사합니다. 그러나 두 개의 익스텐션을 개별적으로 활성화하는 대신에( 하나는 인스턴스 익스텐션, 하나는 디바이스 익스텐션 ), 여럽분은 하나의 인스턴스 익스텐션만을 활성화하면 됩니다.

먼저 새로운 익스텐션을 사용해 디버그 메시지를 받는 것부터 살펴 보겠습니다. 만약 디버그 메시지가 필요하지 않다면, "Naming Objects" 섹션으로 바로 넘어 가십시오.

Creating a Debug Messenger Callback

디버그 메시지를 받겠다고 했으므로, 먼저 콜백 함수를 생성해야 합니다. 그것은 디버그 메시지를 받으며 PFN_vkDebugUtilsMessengerCallbackEXT 함수 포인터의 형태로 되어 있어야 합니다.

새로운 콜백에서는 메시지 심각성( messageSeverity ) 가 메시지 타입( messageType )과 분리되었음을 알아채게 될 것입니다. 심각성은 메시지의 중요도를 지정합니다. 가능한 값들은 현재 다음과 같이 정의되어 있습니다:

메시지의 중요도가 올라갈수록, 열거형 값도 올라갑니다. In addition, we've left space for future types that could fit in between any of the existing values. 그런 이유로, 여러분은 콜백에서 항상 값들을 비교할 수 있습니다.

메시지 타입은 여러분이 받고 있는 메시지의 종류를 기술합니다. 현재 다음과 같은 메시지 타입들을 이용할 수 있습니다( 하지만 나중에 더 추가될 수 있습니다 ):

범용( General ) 메시지는 일반적으로 벌칸 컴포넌트 자체에서 옵니다. Vadiation 비트는 애플리케이션의 행위를 명세에 대해 검증하는 과정과 관련이 있는 메시지를 가리킵니다. 이것들이 validation 레이어로부터 오는 가장 일반적인 메시지들입니다. 왜냐하면 거의 대부분의 validation 에러나 경고들은 벌칸 명세를 위해발 수 있음을 가리키기 때문입니다. 마지막으로, 애플리케이션의 성능을 개선할 것을 제안하는 성능( performance ) 메시지들이 있습니다.

pCallbackData 는 VkDebugUtilsMessengerCallbackDataEXT 구조체를 가리키는데요, 이는 메시지를 유발한 것이 무엇인지에 대한 정보를 포함하고 있으며, 메시지가 발생된 위치를 찾아내는 데 충분히 도움을 줍니다 : 

구조체의 처음 세 개의 파라미터( parameter )들은 대부분의 벌칸 구조체에서 동일합니다. 그래서 그것에 대해서 여기에서 다루지는 않겠습니다.

두 번째 세 개의 파라미터들은 특정 메시지에 대한 모든 세부사항을 제공합니다.

  1.  pMessageIdName 은 문자열인데, 메시지를 유발한 것이 무엇인지를 가리킵니다. Validation 레이어의 경우, 이 문자열은 VUID( valid usage ID ) 문자열 식별자를 포함할 것입니다. 그것은 그 레이어가 위반했다고 생각하는 명세의 특정 위치를 식별하는 데 도움이 됩니다.
  2. messageIdNumber 는 ( 0 이 아니라면 ) 이 메시지에 대한 고유 번호를 가리킵니다. 만약 메시지가 validation 레이어로부터 발생했다면, 그것은 발생한 경고나 에러를 위한 고유 숫자 VUID 를 포함할 것입니다. 이 숫자를 레이어가 위반이 발생했다고 생각하는 벌칸 명세의 위치를 찾기 위한 테이블의 색인으로 사용하십시오.
  3. pMessage 는 ( null 종료 ) C 스타일 문자열입니다. 이는 메시지의 세부사항을 가리킵니다.

콜백은 메시지 ID 번호와 이름을 모두 반환합니다. Validation 레이어들은 현재 주어진 validation 메시지를 위해 messageIdNumber 를 반환합니다. 만약 messageIdNumber 가 제출되었다면, 여러분은 실제 Valid Usage ID 문자열을 vk_validation_error_messages.h 헤더 파일에 접근해서 검색할 수 있습니다. UNIQUE_VALIDATION_ERROR_CODE 열거형에서 값을 찾아서, VUID 를 명세 정보( spec snippet ) 매핑하는 테이블에서 값을 찾으면 됩니다. 명세 정보는 최종 VUID 문자열을 포함하는데요, 이는 명세에서 정확한 섹션을 찾아내기 위해서 사용될 수 있습니다. 우리는 이게 유저에게는 복잡한 작업이라는 것을 깨달았으며, 그래서 앞으로, Validation 레이어들은 새로운 pMessageIdName 필드를 사용해 실제 명세 VUID 문자열을 반환하도록 하는 것을 의도하고 있습니다.

일단 VUID 문자열을 획득했다면, 벌칸 명세를 열어서 해시 심볼( # ) 뒤에 VUID 문자열을 붙이면 그 섹션으로 바로 갈 수 있습니다.

예를 들어 :

VUID 문자열이 다음과 같다고 하면 :

VUID-VkApplicationInfo-pApplicationName-parameter

여러분은 스펙 섹션에 다음과 같이 직접 접근할 수 있습니다:

https://www.khronos.org/registry/vulkan/specs/1.1/html/vkspec.html#VUID-VkApplicationInfo-pApplicationName-parameter

이제 이 익스텐션에서 새로운 아이템들을 살펴 봅시다. queueLableCount 와 pQueueLables 는 애플리케이션이 VkQueue 에다가 적용한 레이블에 대한 정보를 포함합니다. 이 필드들은 VkQueue 오브젝트가 pObjects 리스트에 포함되었을 때만 활성화됩니다. pQueueLables 는 메시지가 발생한 시점까지 특정 VkQueuObject 에서 설정된 레이블들만을 포함합니다. 이 레이블들은 다음과 같은 정보를 포함합니다:

pLabelName 은 애플리케이션이 정의한 레이블의 이름이며 color 는 여러분이 설정한 부동소수점 색상입니다. Validation 레이어들과 디버그 메시지들이 색상을 사용하지는 않지만, 다른 레이어들이나 여러분의 애플리케이션은 이 정보를 사용할 수도 있습니다.

만약 VkQueue 오브젝트가 pObjects 리스트에 없다면, queueLabelCount 는 0 이고 pQueueLabels 는 null 이어야 합니다. pQueueLabels 배열의 아이템들은 정렬되어 있어서 가장 최근의 레이블이 낮은 인덱스 값에 들어 가 있습니다. 그러므로 0 번 인덱스에 있는 레이블이 VkQueue 와 연관된 가장 최근의 레이블입니다.

이와 유사하게 cmdBufLabelCount 와 pCmdBufLabels 는 pObjects 리스트에 있는 VkCommandBuffer 로부터의 모든 레이블들을 포함합니다. 레이블들은 세컨더리( Secondary ) 커맨드 버퍼에 의해 프라이머리( Primary ) 커맨드 버퍼로부터 상속될 수 있습니다. 그러나, 디버그 메시지의 경우에는, 거의 대부분의 레이어들과 로더는 단지 활성화된 커맨드 버퍼나 그것의 자식 오브젝트들에 대한 정보만을 알고 있습니다. 그러므로, VkCommandBuffer 가 pObjects 에 있고 VkCommandBuffer 가 연관된 레이블을 가지고 있다면, cmdBufLabelCount 는 0 이 아닌 값이며 pCmdBufLabels 는 null 이 아닌 값입니다.

VkDebuUtilsMessengerCallbackDataEXT 의 마지막 두 개의 요소는 objectCount 와 pObjects 입니다. pObjects 는 메시지와 쉽게 연관될 수 있는 모든 오브젝트들에 대한 정보를 포함합니다. 그 정보는 VkDebugUtilsObjectNameInfoEXT 구조체에 저장됩니다.

pObjects 내의 각 오브젝트들은 오브젝트의 타입( objectType ) 과 오브젝트의 핸들( objectHandle ) 을 가지고 있습니다. 만약 ( "Naming Objects" 섹션에서 설명하는 것처럼 ) 오브젝트를 위한 이름을 지정한다면, 오브젝트의 이름( pObjectName ) 이 여러분이 제공한 이름을 포함하는 문자열에 대한 포인터로 설정될 것입니다 -- 여러분이 거의 대부분의 오브젝트들을 쉽게 식별할 수 있게 해 줍니다. pObjects 를 레이블과 함께 사용하면, 여러분은 메시지를 발생시킨 코드의 위치를 좁힐 수 있을 겁니다.

마지막으로, 콜백 함수는 유저가 제공한 데이터( pUserData ) 를 받을 수 있습니다. 그것은 메신저를 생성할 때 각 메신저에 제공한 것입니다.

Creating ( and Destroying ) a Debug Messenger

콜백을 설정하고 나면, 디버그 메신저를 생성할 필요가 있습니다. 그것은 메시지가 발생할 때 콜백을 호출하기 위해서 사용될 것입니다.

이 역시 여러분이 벌칸 작업을 하면서 봤던 매우 표준적인 외형을 가졌습니다. 가장 중요한 컴포넌트는 2 번째 파라미터인 pCreateInfo 입니다. 이는 다음과 같은 구조체에 대한 포인터입니다:

messageSeverity 파라미터는 콜백을 호출하기를 원하는 모든 메시지 심각성들을 지정하기 위한 것입니다. 여러분은 "플래그 비트( FlagBit )" 가 아니라 "플래그들( flags )"이 사용된다는 것을 알아차렸을 겁니다. 왜냐하면 이것은 하나 이상의 값들을 취하기 때문입니다. 예를 들어 다음과 같이 메시지 심각성들을 설정할 수 있습니다: 

마찬가지로 messageType 은 여러분이 추적하고자 하는 모든 메시지 타입들의 조합입니다. 그리고 나서 pfnUserCallback 을 위에서 생성한 콜백 함수에 대한 포인터로 설정합니다.

여러분의 콜백에 반환되는 메시지들은 반드시 여러분이 활성화한 심각성과 vkCreateDebugUtilsMessengerEXT 호출 동안 활성화한 타입이어야만 합니다.

그렇지 않다면, 만약 신각성은 일치하는데 타입은 일치하지 않는다든가 한다면, 콜백이 호출되지 않을 것입니다.

마지막으로 여러분은 pUserData 를 사용해서 부가적인 데이터에 대한 포인터를 제공하거나 그것을 null 로 설정할 수 있습니다. 로그를 출력한다든가 하는 작업을 수행하기 위해서, 콜백이 호출되는 동안에 애플리케이션이 구조체나 클래스에 대한 포인터를 사용하는 경우가 많습니다.

모든 표준 벌칸 오브젝트들처럼, VkDebugUtilsMessengerEXT 를 vkDestroyDebugUtilsMessengerEXT 를 호출해서 파괴하게 됩니다.

Naming Objects

네이밍은 애플리케이션이 특정 이름으로 오브젝트를 식별할 수 있도록 해 주며, 오브젝트 핸들 값이 로더, 레이어, 심지어는 런타임에 들어갈 때 변경될 수 있기 때문에 유용합니다. 만약 메시지가 특정 오브젝트를 위해 그런 레이어 중 하나에서 발생했다면, 그 핸들은 유저가 알지 못하는 값이 될 것이며 혼란스럽게 만들 것입니다. 그러므로 오브젝트를 네이밍하는 기능이 생겼습니다.

왜 네이밍이 유용한지 빠르게 살펴 봅시다. 여러분이 validation 레이어에 의해 검출된 잘못된 호출을 만드는 애플리케이션을 가지고 있다고 합시다. 또한 벌칸 로더는 오브젝트 자체를 래핑( wrapping )하고 있으며, 그래서 그것은 오브젝트와 연관된 다른 데이터를 유지할 수 있다고 합시다. 애플리케이션에서는 오브젝트에 대해  0xFEED 라는 핸들을 가지고 있습니다. 그러나 로더는 그 정보를 언랩( uwrap )해서 첫 번째 레이어에 0xF00D 라는 핸들을 넘기고 있습니다.  또한 그 레이어는 그것을 언랩해서 이제 0xBEEF 가 되었습니다. 최종적으로, validation 레이어는 버그를 발견하고 ( 여러분의 Debug Utils Messenger callback 을 사용해 ) 여러분에게 0xBEEF 가 잘못되었다고 알려 줍니다. 하지만 여러분은 0xBEEF 라는 오브젝트가 뭔지 궁금하겠죠?

자, 오브젝트에 어떤 유용한 이름을 부여했다면, 그것은 식별하기 쉬워질 겁니다. 위의 예제로 다시 돌아가 보죠. 여러분이 함수를 호출하기 전에, "Hamburger" 라는 이름을 0xFEED 와 연관된 오브젝트에 부여합니다. 만약 로더가 버그를 만나면, 여러분은 0xF00D 라는 것을 핸들로 받게 될 것입니다. 하지만 이름은 "Hamburger" 죠. 만약 레이어중 하나가 버그를 만나서, 여러분이 0xBEEF 를 받는 다고 해도 그것은 "Hamburger" 입니다. 명확하게도 네이밍이 훨씬 더 유용합니다.

VK_EXT_debug_utils 를 사용해 오브젝트를 네이밍하는 것은 VK_EXT_debug_marker 를 사용해서 오브젝트를 네이밍하는 것과 유사합니다. 그냥 다음 함수를 호출하기만 하면 됩니다 :

그 구조체는 VK_EXT_debug_marker 익스텐션에 있는 VkDebugMarkerObjectNameInfoEXT 와 유사합니다. 중요한 차이는 단순히 멤버 이름이 다르다는 것과 새로운 VkDebugUtilsObjectNameInfoEXT 구조체에서의 타입은 VkDebugReportObjectTypeEXT 열거형이 아니라 VkObjectType 열거형을 사용한다는 사실입니다 이것은 "Creating ( and Destroying ) a Debug Messenger" 에서 설명했던 콜백에 반환되는 정확히 같은 구조체입니다. 하지만 포맷을 확인할 수 있게 여기에서 다시 언급하도록 하겠습니다 :

Tagging Objects

태깅은 네이밍과 유사합니다. 하지만 매우 다른 목적을 가지고 있습니다. 네이밍의 경우에는 애플리케이션이 제공하는 문자열을 특정 오브젝트와 연관시킵니다. 하지만 태깅의 경우에는 정수 ID 와 바이너리 데이터를 오브젝트와 연관시킵니다. 태깅의 가장 잘 사용하려면, 애플리케이션과 레이어 혹은 런타임이 ID 및 데이터의 용례에 대해서 반드시 서로 동의하고 있어야 합니다.

태깅의 예는 다음과 같습니다:

  • 셰이더 오브젝트를 인간이 인식할 수 있는 버텍스 및 프레그먼트 셰이더 칸텐츠로 태깅함.
  • 버퍼를 그 칸텐츠에 대한 칸텐츠나 메타 데이터로 태깅함.

일반적으로, 거의 대부분의 디버그 레이어들과 툴들은 단순히 오브젝트를 이름으로 식별함으로써 그것들이 필요로 하는 필수 정보들을 획득합니다. Debug Utils Messenger 콜백들은 애플리케이션에 반환되기 때문에, 고유 오브젝트를 추적하기 위한 이름을 제외하고는 레이어들을 통해 넘길 필요가 있는 데이터들을 가지고 있지 않습니다. 애플리케이션은 이미 그것이 필요로 하는 모든 정보들에 대한 소유자( keeper )입니다. 그래서 Debug Utils Messenger 콜백을 통해 반환되는 태깅 정보는 존재하지 않습니다.

그러나, 여러분이 태깅을 필요로 한다면, VK_EXT_debug_utils 는 VK_EXT_debug_marker 와 비슷한 방식으로 구현을 합니다. 이 경우에, 여러분은 다음 함수를 호출하게 됩니다:

그 구조체는 VK_EXT_debug_marker 익스텐션에 있는 VkDebugMarkerObjectTagInfoEXT 와 유사합니다. 두 개의 차이점이 존재합니다 : 1) 멤버 이름들이 다름 2) VkDebugUtilsObjectNameInfoEXT 구조체의 타입들이 VkDebugResportObjectTypeEXT 열거형 대신에 VkObjectType 열거형을 사용함:

여러분 중 일부는 이 구조체에 익숙하지 않을 것니다. 그래서 간단하게 다루도록 하겠습니다. objectType 과 objectHandle 은 네이밍의 경우와 정확히 동일합니다. tagName 은 이 태그를 위한 수치 이름이나 식별자이며, 태깅되는 데이터의 유형을 식별하는 데 사용됩니다. 만약 이 정보를 가로채기 위해서 레이어를 구현한다면, 이 값은 식별되는 오브젝트를 설정하려고 시도하는 특정 정보를 가리키고 있을 것입니다. pTag 는 tagSize 바이트의 데이터에 대한 포인터인데, 이는 이 오브젝트와 연관됩니다.

Adding Labels

가끔 어떤 오브젝트가 문제가 되는지를 알아내는 것으로도 부족한 경우가 있습니다. 보통 프레임 전반에서 유사한 방식으로 오브젝트를 여러 번 손댈 수 있습니다. 이런 경우에, 여러분이 어떤 프레임에서 ( 혹은 심지어는 여러 프레임들에서 ) 문제가 발생했는지 좁힐 수 있다면 훌륭할 것입니다 -- 고속도로의 옆쪽에 거리 표지판( mile-marker )이 여러분이 어디쯤 왔는지 알 수 있게 해주는 방법과 비슷합니다.

VK_EXT_debug_utils 를 사용하면, VkQueue 나 VkCommandBuffer 에다가 레이블을 삽입할 수 있습니다. VK_EXT_debug_marker 익스텐션에 의해서 처음 노출된 "markers" 와 유사합니다 -- 그것은 VkCommandBuffer 오브젝트에만 마커들을 추가할 수 있다는 차이는 있습니다.

레이블을 VkQueue 나 VkCommandBuffer 에다가 추가하는 두 가지 방법이 존재합니다:

  • 레이블 영역( region )을 시작하고 끝내기.
  • 그냥 레이블 삽입하기.

어떻게 사용하는지 이해하기 위해서 빠르게 예제를 살펴 봅시다.

여러분이 인간형 모양을 그리고 있다고 합시다. 다음과 같은 방식으로 그릴 겁니다:

Validation 을 실행할 때, 여러분은 드로( draw )에서 에러를 발견했다고 합시다. 하지만 어떤 드로가 문제인지 좁힐 수 있을까요? 레이블을 사용하면, 다음과 같이 할 수 있습니다.

begin/end 와 insert 는 모두 특정 영역을 쉽게 식별할 수 있도록 하는 방시긍로 사용되고 있습니다. 이제, 에러나 경고가 "DrawLeftLeg" 루틴을 실행하는 중에 발생했다면, 여러분은 어떤 드로를 타게팅해야 할지 알 수 있을 겁니다.

레이블들은 커맨드 버퍼나 큐 기저상에 삽입될 수도 있습니다. 레이블 영역을 큐에서 시작하기 위해서는 다음과 같은 커맨드를 사용하게 됩니다:

이 커맨드는 다음 구조체를 받아들입니다:

이것은 "Creating ( and Desetroying ) a Debug Messenger" 에서 기술한 콜백에 반환되는 것과 같은 구조체입니다. color 는 레이어나 툴에서 레이블 이름을 위한 색상있는 텍스트를 생성하기 위해 사용될 수 있습니다. 만약 색상에 신경을 안 쓴다면, 툴이 색상을 사용하는 경우에는 이 파라미터의 각 값들을 "1.0" 으로 설정하십시오.

레이블링된 섹션을 종료하려면, 다음 함수를 호출합니다:

그것은 레이블을 취하지 않는다는 것에 주의하십시오. 이런 방식으로, end 는 "pop" 커맨드와 유사하게 작동합니다. 단지 특정 VkQueue 상에서 VkQueueBeginDebugUtilsLabelEXT 커맨드를 사용해 생성된 마지막 레이블을 종료시킵니다.

전체 영역을 정의하는 대신에, 단일 레이블을 특정 위치에 삽입할 수도 있습니다. 다음을 사용합니다:

만약 VkQueue 와 연관되는 레이블들을 정의했고 메시지가 발생했고 오브젝트 리스트에 특정 VkQueue 가 포함되어 있었다면, 콜백의 queueLabelCount 와 pQueueLables 데이터 필드가 적절한 칸텐츠와 함께 생성되어 있을 것입니다. 이 경우에, 그 데이터는 스택과 유사하게 저장될 것입니다. 그러므로 첫 번째 요소는 가장 최근의 레이블이며, 두 번째 요소는 가장 오래된 레이블입니다.

위에서 VkQueue 커맨드에 대해서 했던 것처럼 레이블을 커맨드를 사용해서 커맨드 버퍼에 삽입할 수 있습니다 :

얼마나 유사한지 확인해 보세요. 실제 차이는 VkQueue 대신에 VkCommandBuffer 를 요구한다는 것 뿐입니다. VkQueue 레이블처럼, 만약 pObjects 리스트에 VkCommandBuffer 오브젝트가 포함되어 있는 상태로 Debug Utils Messenger 콜백이 호출되었다면, 그리고 그 버퍼들이 레이블을 포함하고 있다면, 그 레이블들은 cmdBufLabelCount 와 pCmdBufLabels 필드에 추가될 것입니다. 또한, 큐 칸텐트와 마찬가지로, 데이터는 스택과 유사하게 반환되며, 첫 번째 레이블은 가장 최근이며 마지막 레이블은 가장 오래된 것입니다. 드로에 대한 예제를 살펴 보죠. 만약 버그가 DrawLeftLeg() 루틴에서 발생했다면, 에러가 반환될 때, cmdBufLableCount 와 pCmdBufLables 의 값들은 다음과 같은 식으로 보일 것입니다:

커맨드 버퍼 레이블의 특별한 특성( attribute )은 그것들이 커맨드 버퍼 경계를 넘어서서 활성화/비활성화 될 수 있다는 것입니다. 예를 들어, 프라이머리 커맨드 버퍼에서 레이블을 시작해서 세컨더리 커맨드 버퍼에서 레이블을 종료할 수 있습니다. 혹은 세컨더리 커맨드 버퍼에서 레이블을 시작해서 완전히 다른 세컨더리 커맨드 버퍼에서 레이블을 종료할 수 있습니다. 주의할 점이 있다면, 커맨드 버퍼 종속성 체인을 validation 메시지가 발생할 때 알 수 없다는 것입니다. Therefore, for those kind of messages you can only count on the contents of the one command buffer. However, for tools like RenderDoc, the spanning of command buffers should function correctly.

Application Usage Examples

Setting Up a Debug Utils Messenger and Callback

다음 코드 정보는 Debug Utils Messenger 콜백을 설정하고 그것을 사용하는 메신저를 생성하는 것을 보여 줍니다. 또한 이것이 익스텐션이기 때문에 vkGetInstanceProcAddr 를 사용해 VK_EXT_debug_utils 를 위한 벌칸 커맨드를 질의할 필요가 있음을 보여 줍니다 :

Using Object Names and Command Buffer Labels

다음 코드 정보는 레이블과 이름있는 오브젝트를 정의하는 방법을 보여 줍니다:

Updates to the Cube Demo

( 역주 : 그냥 VK_EXT_debug_report 에서 VK_EXT_debug_utils 로의 마이그레이션 샘플이므로 번역을 생략함 )

Future Validation Layer Improvements

이 튜토리얼을 발행하는 시점에, validation 레이어들은 VK_EXT_debug_report 익스텐션 콜백과 VK_EXT_debug_utils 익스텐션 콜백에 대해 거의 같은 정보를 반환할 것입니다. The extensions differ in that the VK_EXT_debug_report extension will also automatically add in VkCommandBuffer and VkQueue labels to the callback results and separate out some of the information so that the returned messages don’t seem as cluttered. 앞으로, 우리는 그런 에러 메시지들에 도움이 된다고 생각하는 부가적인 오브젝트 정보들을 추가할 계획입니다. validation 에러 메시지들이 부가적인 오브젝트 정보를 더 받아야 한다고 느낀다면, GitHub Issue 에 제출해 주십시오. 어떠한 메시지에 어떤 오브젝트 정보를 추가하기를 원하는지 명확하게 언급해 주세요. GitHub Pull Request 를 제공해 주실 수도 있습니다.

Conclusion

여러분도 보셨듯이, VK_EXT_debug_utils 익스텐션은 validation 레이어에 매우 많이 필요한 디버그 정보를 가지고 옵니다. 우리는 Vk_EXT_debug_utils 익스텐션을 Vulkan 1.1 을 이용할 수 있는 로더들에서부터 지원하기 시작했습니다. You don’t need to use Vulkan 1.1 to expose the extension functionality as the release of this extension and Vulkan 1.1 just happen to be timed together. 만약 이 익스텐션을 어떤 식으로든 사용하고자 한다면, 그냥 LunarG's Vulkan 1.1 SDK 를 시스템에 다운로드하시면 됩니다. VK_EXT_debug_utils 익스텐션을 찾아 봤으면 하는 바램입니다. 그것은 사용하기 편하고 유용하기 때문입니다.

Acknowlegements

이 아티클을 완료할 수 있도록 도움을 준 LunarG 의 Erika Johnson, Mark Lobodzinski, Mike Schuchardt 에게 감사드리고 싶습니다. 그리고 다시 한 번 검토해준 NVIDIA 의 Piers Daniel 에게도 감사드립니다.

주의 : 초심자 튜토리얼은 아닙니다. 그러므로, 실제 API 호출 용례를 알고자 한다면, 샘플이나 튜토리얼을 찾아서 확인해 보세요.

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

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



Interface Block


일단 HLSL 의 경우에는 일반 구조체를 사용해서 각 셰이더 스테이지( Shader Stage )의 입력과 출력을 정의할 수 있도록 하고 있습니다. 버텍스( Vertex ) 셰이더의 예를 하나 살펴 보죠.



VertexInputVertexOutput 이라는 structGlobalBuffer 라는 cbuffer 가 정의되어 있습니다. 그리고 CommonVertexShader() 함수에서는 VertexInput 을 입력으로 받아 VertexOutput 을 산출합니다.


그리고 VertexInput VertexOutput 의 각 필드)에는 시맨틱( Semantic ) 이 지정되어 있습니다. 언어에서 신택스( Syntax )는 우리말로는 "문법" 으로 표현될 수 있으며, 언어의 문법이 옳은지 판단할 때 사용될 수 있습니다. 시맨틱은 "의미" 로 표현될 수 있으며, 말 그대로 의미상 옳은 지를 판단할 때 사용될 수 있습니다. 시맨틱을 사용하는 것의 장점이라면, 변수 이름이 아니라 시맨틱으로 그 변수의 의미를 특정할 수 있다는 것입니다.


OpenGL 이나 Vulkan 같은 경우에는 셰이더의 입력이나 출력을 인터페이스( Interface )라 정의하고 있습니다. 두 개의 스테이지를 각각 노드라 생각하고 데이터가 인터페이스를 통해서 왔다갔다 한다고 생각하면 무리가 없는 설정인 것 같습니다.


그런데 그 인터페이스에 매우 많은 데이터가 왔다갔다 하면 관리하기가 힘드니까 그것을 인터페이스 블록( Block )이라는 것으로 묶을 수 있도록 하고 있습니다[ 3 ][ 4 ]. 


인터페이스 블록의 구문은 구조체를 선언하는 것과 유사합니다.


storage_qualifier block_name

{

    <define members here>

} instance_name;


storage_qualifier 로는 uniform, in, out, buffer 등이 있습니다. 


  • uniform : global shader variable.

  • in : OpenGL 3.2 이상에서 지원되는 shader input.

  • out : OpenGL 3.2 이상에서 지원되는 shader output.

  • buffer : ARB_shader_storage_buffer_object 에 의해 지원되는 storage_buffer.


몇 개의 예를 [ 3 ] 에서 가져 와 보겠습니다.



Layout Qualifiers


그런데 storage_qualifier 들 중에서는 OpenGL 3.2 이상에서만 블록 형태로 지원되는 것들이 존재하기 때문에, 3.1 까지는 그냥 변수를 사용해서 입출력 데이터를 표현해야 했습니다. 이것 때문에 layout qualifier 라는 것이 필요했죠.


layout(qualifier1​, qualifier2​ = value, ...) variable_definition


여러 개의 변수들을 만들어 놓은 다음에 그것에 순서와 특성을 부여합니다. layout 으로 묶여 있는 괄호 안 쪽에 한정자( qualifier )들을 삽입합니다.


이 한정자의 종류는 다음과 같습니다. 물론 더 많은 한정자들이 있기는 하지만, Vulkan Spec 에서 언급한 한정자들만 나열했습니다.


  • location : 애트리뷰트( attribute ) 바인딩 순서를 지정합니다.

  • component : 애트리뷰트의 컴포넌트 개수를 지정합니다.


다음과 같은 식이죠. 



이렇게 되어 있으면 프로그램에서 position, texCoord, normal 이 들어 있는 구조체를 한 번에 glBindVertexArray() 로 지정했을 때 하나의 버텍스 내부에서 순차적으로 위치가 지정된 것처럼 동작하게 됩니다. location 이 하나 증가하면 float 4 개만큼의 메모리 offset 을 가진다고 보면 됩니다. HLSL 로 치자면 layout 은 레지스터 인덱스라고 생각해도 무방할 것 같습니다.


그러므로 다음과 같이 배열을 가지고 있는 경우에는 일반적으로는 float 4 배열 요소 개수나 구조체의 크기만큼 증가하게 됩니다.



그런데 가끔 float 4 크기보다는 작은 변수들을 하나의 레지스터로 묶고 싶을 때까 있습니다. HLSL 로 치자면 packoffset 과 유사한 개념이겠네요. 이 때 component 라는 한정자를 사용하게 됩니다. 이 컴포넌트 한정자를 지정하지 않으면 기본값은 0 이라고 합니다. 그런데 component qualifier 는 OpenGL 3.2 부터 ARB_explicit_attrib_location 을 통해서 버텍스 셰이더와 프래그먼트( fragment ) 셰이더에서만 지원하는 개념입니다.


아래의 몇 가지 예가 있습니다.



어쨌든 매우 불편한 개념인데요, HLSL 을 사용하면 딱히 신경쓰실 필요는 없습니다. 왜냐하면 HLSL 은 이미 인터페이스 블록같은 개념을 구조체를 통해 지원하고 있을 뿐더러 DXC 로 컴파일하게 되면 레이아웃을 자동으로 지정해 주기 때문입니다. 게다가 DXC 에 실행인자로 "-fspv-reflect" 를 지정하면 시맨틱으로 애트리뷰트를 획득하는 것도 가능합니다.


제일 위쪽에 있는 HLSL 코드 중에 VertexInput 을 리플렉션( reflection )하는 소스 코드를 한 번 살펴 보겠습니다.



그러면 다음과 같은 결과를 얻을 수 있습니다. 저같은 경우에는 사용하기 편하게 일부러 VkVertexInputAttributeDescription 맵의 형태로 만들었습니다. 소스코드를 보면 대부분의 내용은 이해하실 수 있을 것 같네요.


그림 1.


그림 1 의 디버깅 정보에는 "TANGENT0" 시맨틱에 대한 정보가 나와 있습니다.


DXC layout


이 HLSL 에서 이 레이아웃을 강제로 지정하는 방법도 있는데요, [ 4 ] 에서는 [[vk::location(x)]] 애트리뷰트를 사용하도록 하고 있습니다. 실제로 순서를 맘대로 바꿔 보도록 하겠습니다.



그런데 vk 애트리뷰트를 사용해서 레이아웃을 결정하신다면 DXC 의 실행 인자로 "-fvk-use-dx-layout" 를 지정하시는 것을 잊으시면 안 됩니다. 안 그러면 에러가 발생합니다.


어쨌든 DXC 를 실행하면 다음과 같이 "TANGENT0" 시맨틱의 location 이 5 번으로 변경되어 있는 것을 확인하실 수 있습니다.


그림 2.


그런데, 그림 2 를 자세히 보신 분들은 알아 차렸겠지만, offset 이 변하지 않았습니다. 이것은 변수 순서가 변경되지 않았기 때문에 발생하는 문제( ? )입니다.


그러므로 이런 경우에는 offset 을 구할 때 location 순서대로 정렬한 다음에 구하는 것이 좋습니다. 물론 offset 은 구조체 내의 메모리 위치이고 location 은 GPU 레지스터에서의 인덱스와 같은 것이기 때문에 굳이 offset 을 순서와 맞출 필요는 없습니다. 하지만 헷갈릴 우려가 있고 이게 성능에 어떤 영향을 미치는 지는 잘 모르겠습니다. 그러므로 순서를 맞추는 것이 좋을 것 같습니다.


결론


Spirv-Reflect 에 대해서 분석하다가 보니, 굳이 레이아웃에 대한 분석을 하기는 했지만, 일반적으로는 아무것도 하지 말고 그냥 순서대로 레이아웃이 결정되도록 놔두는 것이 좋다는 생각이 듭니다.


참고자료


[ 1 ] Vulkan 1.1.122 Specification. Khronos Group.


[ 2 ] Layout Qualifier (GLSL), OpenGL Wiki. https://www.khronos.org/opengl/wiki/Layout_Qualifier_(GLSL).


[ 3 ] Interface Block (GLSL). https://www.khronos.org/opengl/wiki/Interface_Block_(GLSL).


[ 4 ] HLSL to SPIR-V Feature Mapping Manual. https://github.com/microsoft/DirectXShaderCompiler/blob/master/docs/SPIR-V.rst.

주의 : 초심자 튜토리얼은 아닙니다. 그러므로, 실제 API 호출 용례를 알고자 한다면, 샘플이나 튜토리얼을 찾아서 확인해 보세요.

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

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



일단 LunarG 나 Khronos group 의 문서들을 보면 셰이더 언어로 GLSL 을 리플렉션 라이브러리로 spirv-cross 를 소개하는 경우가 많습니다. 


그런데 HLSL 을 SPIR-V 로 변환하는 것과 관련해 설명하는 문서들에서는 리플렉션을 위해 spirv-reflect 를 소개하는 경우가 많습니다. 보통 이런 문서는 구글의 "Lei Zhang" 이라는 사람이 주로 발표하는데요, 이 분이 dxc 와 spirv-relfect 구현에 참여를 한 것으로 보입니다. 이 분은 github 에서 "antiagainst" 라는 계정을 사용하고 있습니다. 구글에서는 dxc 를 통해서 spirv-reflect 를 사용하도록 권장하는 모양인 것 같습니다. MS 도 아니고 구글이 그런 작업을 한다는 사실이 참 아이러니합니다.


spirv-reflect 는 매우 가볍습니다. 헤더 하나에 소스 하나입니다. [ SPIRV-Reflect ] 에 올라와 있으니 자유롭게 받으실 수 있습니다.


사용방법은 매우 간단한데요, 위의 링크를 참고하시면 쉽게 따라할 수 있습니다. 


단지 문제가 하나 있다면 해당 페이지의 코드 조각 중에서 input_vars 가 SpvReflectInterfaceVariable* 타입이 아니라 SpvReflectInterfaceVariable** 타입이어야 한다는 것입니다. 무작정 따라 하시면 나중에 크래시납니다. 게다가 input_vars 에 대한 메모리 해제도 안 하고 있으니 주의하셔야 합니다.


예제는 spvReflectEnumerateInputVariables() 함수만 소개하고 있는데요, 여러 종류의 열거 함수들이 있습니다. 자신의 필요에 맞게 함수를 호출해서 사용하면 됩니다.



위의 함수와 동일한 기능을 하는 ShaderModule 클래스의 래퍼 메서드들도 존재합니다. 어떤 스타일을 선호하느냐는 사용자가 결정하면 됩니다.



그런데 이건 좀 사용하기에 불편한 점이 있습니다. 예를 들면 아래와 같은 HLSL 코드를 살펴 봅시다.



여기에 대해 spvReflectEnumerateInputVariables() 를 호출하면 main() 함수의 인자인 PSInput 의 내용을 출력해 줍니다. 하지만 이름이 맹글링되어 있고 제대로 유지되지 않는 것을 알 수 있습니다.  Color, Alpha, Scaling 등의 정보가 시맨틱과 비슷하면서도 다릅니다.


Name = in.var.NORMAL

Name = in.var.COLOR_1

Name = in.var.OPACITY_512

Name = in.var.SCALE_987654321

Name = in.var.TEXCOORD0

Name = in.var.TEXCOORD1

Name = in.var.TEXCOORD2


그런데 dxc 의 옵션으로 "-fspv-reflect" 를 지정해서 컴파일했다면 정확한 이름/시맨틱 쌍을 얻을 수 있습니다.


Name = in.var.NORMAL

Semantic = NORMAL

Name = in.var.COLOR_1

Semantic = COLOR_00001

Name = in.var.OPACITY_512

Semantic = OPACITY_512

Name = in.var.SCALE_987654321

Semantic = SCALE_987654321

Name = in.var.TEXCOORD0

Semantic = TEXCOORD0

Name = in.var.TEXCOORD1

Semantic = TEXCOORD1

Name = in.var.TEXCOORD2

Semantic = TEXCOORD2


시맨틱을 얻게 되면 다음과 같은 식으로 시맨틱을 사용해서 리플렉션 정보를 획득하는 것도 가능합니다.



사용자들은 맹글링된 이름이 아니라 원래 이름으로 검색하고 싶겠지만, 안타깝게도 그런 방법은 존재하지 않습니다. dxc 에 이런 저런 옵션을 다 넣어서 확인해 봤지만 정보가 안 나오더군요. 만약 필요하다면 dxc 를 커스터마이징하는 수밖에 없는 것 같습니다.


그래도 확실하게 정보가 유지되는 것은 시맨틱이기 때문에 시맨틱을 사용해서 리플렉션을 하는 것이 현명해 보입니다.

주의 : 초심자 튜토리얼은 아닙니다. 그러므로, 실제 API 호출 용례를 알고자 한다면, 샘플이나 튜토리얼을 찾아서 확인해 보세요.

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

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



HLSL to SPIR-V Feature Mapping Manual ] 에 커맨드라인 옵션들에 대한 설명들이 나와 있기는 하지만, 정확하게 어떤 것을 이야기하는지 이해할 수 없는 것들도 있습니다.


그래서 소스 코드를 분석해서 정확하게 각 옵션들의 의미를 파악해 보기로 했습니다. 아래 블록은 "dxc -help" 를 실행했을 때 나오는 설명들입니다. SPIR-V 전용 옵션들은 여기에서는 배제했습니다. 나중에 다른 챕터에서 다룰 계획입니다.


Common Options:

  -help              Display available options

  -nologo            Suppress copyright message

  -Qunused-arguments Don't emit warning for unused driver arguments


Compilation Options:

  -all_resources_bound    Enables agressive flattening

  -auto-binding-space <value>

                          Set auto binding space - enables auto resource binding in libraries

  -Cc                     Output color coded assembly listings

  -default-linkage <value>

                          Set default linkage for non-shader functions when compiling or linking to a library target (internal, external)

  -denorm <value>         select denormal value options (any, preserve, ftz). any is the default.

  -D <value>              Define macro

  -enable-16bit-types     Enable 16bit types and disable min precision types. Available in HLSL 2018 and shader model 6.2

  -export-shaders-only    Only export shaders when compiling a library

  -exports <value>        Specify exports when compiling a library: export1[[,export1_clone,...]=internal_name][;...]

  -E <value>              Entry point name

  -Fc <file>              Output assembly code listing file

  -Fd <file>              Write debug information to the given file, or automatically named file in directory when ending in '\'

  -Fe <file>              Output warnings and errors to the given file

  -Fh <file>              Output header file containing object code

  -flegacy-macro-expansion

                          Expand the operands before performing token-pasting operation (fxc behavior)

  -flegacy-resource-reservation

                          Reserve unused explicit register assignments for compatibility with shader model 5.0 and below

  -force_rootsig_ver <profile>

                          force root signature version (rootsig_1_1 if omitted)

  -Fo <file>              Output object file

  -Gec                    Enable backward compatibility mode

  -Ges                    Enable strict mode

  -Gfa                    Avoid flow control constructs

  -Gfp                    Prefer flow control constructs

  -Gis                    Force IEEE strictness

  -HV <value>             HLSL version (2016, 2017, 2018). Default is 2018

  -H                      Show header includes and nesting depth

  -ignore-line-directives Ignore line directives

  -I <value>              Add directory to include search path

  -Lx                     Output hexadecimal literals

  -Ni                     Output instruction numbers in assembly listings

  -no-warnings            Suppress warnings

  -not_use_legacy_cbuf_load

                          Do not use legacy cbuffer load

  -No                     Output instruction byte offsets in assembly listings

  -Odump                  Print the optimizer commands.

  -Od                     Disable optimizations

  -pack_optimized         Optimize signature packing assuming identical signature provided for each connecting stage

  -pack_prefix_stable     (default) Pack signatures preserving prefix-stable property - appended elements will not disturb placement of prior elements

  -recompile              recompile from DXIL container with Debug Info or Debug Info bitcode file

  -res_may_alias          Assume that UAVs/SRVs may alias

  -rootsig-define <value> Read root signature from a #define

  -T <profile>            Set target profile.

        <profile>: ps_6_0, ps_6_1, ps_6_2, ps_6_3, ps_6_4, ps_6_5,

                 vs_6_0, vs_6_1, vs_6_2, vs_6_3, vs_6_4, vs_6_5,

                 cs_6_0, cs_6_1, cs_6_2, cs_6_3, cs_6_4, cs_6_5,

                 gs_6_0, gs_6_1, gs_6_2, gs_6_3, gs_6_4, gs_6_5,

                 ds_6_0, ds_6_1, ds_6_2, ds_6_3, ds_6_4, ds_6_5,

                 hs_6_0, hs_6_1, hs_6_2, hs_6_3, hs_6_4, hs_6_5,

                 lib_6_3, lib_6_4, lib_6_5, ms_6_5, as_6_5

  -Vd                     Disable validation

  -Vi                     Display details about the include process.

  -Vn <name>              Use <name> as variable name in header file

  -WX                     Treat warnings as errors

  -Zi                     Enable debug information

  -Zpc                    Pack matrices in column-major order

  -Zpr                    Pack matrices in row-major order

  -Zsb                    Build debug name considering only output binary

  -Zss                    Build debug name considering source information


Optimization Options:

  -O0 Optimization Level 0

  -O1 Optimization Level 1

  -O2 Optimization Level 2

  -O3 Optimization Level 3 (Default)


SPIR-V CodeGen Options:

  -fspv-debug=<value>     Specify whitelist of debug info category (file -> source -> line, tool)

  -fspv-extension=<value> Specify SPIR-V extension permitted to use

  -fspv-reflect           Emit additional SPIR-V instructions to aid reflection

  -fspv-target-env=<value>

                          Specify the target environment: vulkan1.0 (default) or vulkan1.1

  -fvk-b-shift <shift> <space>

                          Specify Vulkan binding number shift for b-type register

  -fvk-bind-globals <binding> <set>

                          Specify Vulkan binding number and set number for the $Globals cbuffer

  -fvk-bind-register <type-number> <space> <binding> <set>

                          Specify Vulkan descriptor set and binding for a specific register

  -fvk-invert-y           Negate SV_Position.y before writing to stage output in VS/DS/GS to accommodate Vulkan's coordinate system

  -fvk-s-shift <shift> <space>

                          Specify Vulkan binding number shift for s-type register

  -fvk-t-shift <shift> <space>

                          Specify Vulkan binding number shift for t-type register

  -fvk-u-shift <shift> <space>

                          Specify Vulkan binding number shift for u-type register

  -fvk-use-dx-layout      Use DirectX memory layout for Vulkan resources

  -fvk-use-dx-position-w  Reciprocate SV_Position.w after reading from stage input in PS to accommodate the difference between Vulkan and DirectX

  -fvk-use-gl-layout      Use strict OpenGL std140/std430 memory layout for Vulkan resources

  -fvk-use-scalar-layout  Use scalar memory layout for Vulkan resources

  -Oconfig=<value>        Specify a comma-separated list of SPIRV-Tools passes to customize optimization configuration (see http://khr.io/hlsl2spirv#optimization)

  -spirv                  Generate SPIR-V code


Utility Options:

  -dumpbin              Load a binary file rather than compiling

  -extractrootsignature Extract root signature from shader bytecode (must be used with /Fo <file>)

  -getprivate <file>    Save private data from shader blob

  -P <value>            Preprocess to file (must be used alone)

  -Qembed_debug         Embed PDB in shader container (must be used with /Zi)

  -Qstrip_debug         Strip debug information from 4_0+ shader bytecode  (must be used with /Fo <file>)

  -Qstrip_priv          Strip private data from shader bytecode  (must be used with /Fo <file>)

  -Qstrip_reflect       Strip reflection data from shader bytecode  (must be used with /Fo <file>)

  -Qstrip_rootsignature Strip root signature data from shader bytecode  (must be used with /Fo <file>)

  -setprivate <file>    Private data to add to compiled shader blob

  -setrootsignature <file>

                        Attach root signature to shader bytecode

  -verifyrootsignature <file>

                        Verify shader bytecode with root signature


이 옵션들을 모두 살펴 볼 것은 아니구요, 그냥 -help 만 봐도 쉽게 이해할 수 있는 것들은 배제하겠습니다. 그리고 별로 빈도가 높아 보이지 않는 것들도 배제했습니다. 댓글로 특정 옵션에 대한 정확한 동작에 대해서 문의하신다면 내용을 추가하도록 하겠습니다.


기본 형식


다음과 같이 옵션 다음에 컴파일할 소스 파일 경로를 넣습니다.


dxc [ options... ] $(SourceFilePath)


$(SourceFilePath) 는 커맨드 실행 디렉토리에 대한 상대 경로일 수도 있고 절대 경로일 수도 있습니다.


옵션을 위한 플래그들은 '-' 접두어로 시작하며, 값을 받는 옵션이라면 "-O" 를 제외하고는 모두 한 칸을 띄고 값을 입력합니다.


-spriv


가장 먼저 지정해야 할 값이 없는 옵션입니다. SPIR-V 파일을 생성하라는 의미입니다. 벌칸을 사용한다는 가정하에서지만, 이것을 빼먹어서는 안 됩니다.


dxc -spirv


-T


셰이더 프로우파일 문자열입니다.


dxc -T $(shader_name)_$(major_version)_$(minor_version)


$(shader_name) 은 각 셰이더 타입의 머리글자 혹은 약자입니다.


  • vs : Vertex Shader.

  • hs : Hull Shader.

  • ds : Domain Shader.

  • gs : Geometry Shader.

  • ps : Pixel Shader.

  • cs : Compute Shader.

  • lib : shader LIBrary. 한 번도 써 본 적이 없어서 모르겠는데, [ Using shader linking ] 에서 정보를 얻을 수 있습니다.


문제는 셰이더의 버전인데요, 이것은 메이저와 마이너로 나뉩니다. 이것을 임의로 지정하는 것은 아니구요, [ Shader Models vs Shader Profiles ] 에 가면 셰이더 모델에서 이용가능한 셰이더 프로우파일들의 목록을 확인하실 수 있습니다.


-E


셰이더 프로우파일에 대한 진입 함수에 대한 문자열입니다. 


dxc -E mainVS


그냥 함수 이름을 기입하면 됩니다. 만약 이를 지정하지 않으면 기본적으로는 "main" 이 사용됩니다.


-D


디파인 매크로를 설정하는 문자열입니다.


만약 여러 개의 매크로를 설정해야 한다면, 여러 개의 -D 플래그를 사용해야 합니다. 예를 들면 아래 블록과 같이 할 수 있습니다.


dxc -D USE_POSITION -D USE_NORMAL=1


다들 아실거라 생각하지만 부연하자면, '=' 을 사용하지 않으면 "#ifdef" 를 사용해서 조건을 검사하고, 사용하면 "#if" 를 사용해서 조건을 검사합니다.


-I


HLSL 파일을 작성하다가 보면 인클루딩을 해야 하는 경우가 있습니다. 인클루딩해야 할 파일이 존재하는 폴더 경로에 대한 문자열입니다.



공용 HLSL 파일을 위한 폴더를 나누거나 종류별로 HLSL 파일을 나눠서 관리하다가 보면, 인클루드해야 하는 파일이 다른 디렉토리에 존재할 수 있습니다. 만약 같은 폴더에 대상 파일이 존재한다면 아무런 문제가 없지만, 그렇지 않다면 아래처럼 경로를 지정해야 합니다.


dxc -I C:\OtherFolder1 -I C:\OtherFolder2 -I ..\


여러 개의 폴더를 지정하려면 여러 개의 -I 플래그를 사용합니다. 경로는 절대 경로여도 되고 커맨드를 실행한 폴더에 대한 상대 경로여도 됩니다.


-O


최적화 수준을 지정하는 문자열입니다. 


이것은 특이하게 한칸 띄고 값을 지정하는 것이 아니라 바로 붙여서 지정합니다( 예를 들어 -O1 ).


dxc -O{0|1|2|3}


0 ~ 3 까지 지정할 수 있고 숫자가 낮을 수록 최적화를 덜 한다는 이야기입니다. 값의 크기는 코드를 생성하는 속도와 반비례하며 코드 실행 속도와 비례합니다.


-Fo


SPIR-V 를 생성할 경로에 대한 문자열입니다.


절대경로여도 되고 커맨드를 실행한 폴더에 대한 상대 경로여도 됩니다( 폴더를 지정하지 않으면 커맨드를 실행한 폴더에 생성됩니다 ).


이름은 마음대로 지정하시면 됩니다. 보통은 "[$(Identifier)_]$(StageName).spv" 라고 지정하는듯 합니다.


dxc -Fo my_vertex.spv


만약 이 옵션을 지정하지 않으면 콘솔창에다가 디스어셈블리 결과를 출력합니다.


-Fc


-Fo 에서 지정한 SPIR-V 파일에 대한 디스어셈블리 파일을 생성할 경로에 대한 문자열입니다. 


절대경로여도 되고 커맨드를 실행한 폴더에 대한 상대 경로여도 됩니다.


이름과 확장자는 마음대로 지정하시면 됩니다. 텍스트 파일로 생성되기 때문에 txt 인 것이 좋을 것 같습니다.


dxc -Fc my_vertex_disassembly.txt


-Fh


코드를 포함하고 있는 헤더 파일의 경로에 대한 문자열입니다.


dxc -Fh my_vertex_header.txt


헤더라고 하니, 엄청나게 헷갈리는데요, C/C++ 에서 사용할 수 있는 이진코드 배열을 정의하는 파일입니다. 열어 보니 디스어셈블리 내용도 포함하고 있더군요.


만약 코드에다가 SPIR-V 를 하드코딩하고 싶다면 이것을 사용할 수 있을 것 같네요.



-Fe


경고나 에러를 출력할 파일의 경로에 대한 문자열입니다. 만약 이 옵션을 지정하지 않으면 콘솔창에다가 결과를 출력합니다.


dxc -Fe my_vertex_output.txt


커맨드라인 프로그램을 사용하면 그 결과를 확인하기 위해서 stderr, stdout 에 대한 파이프를 만들어야 하는 경우가 있습니다. 매우 귀찮은 작업이죠.


그럴 경우에 텍스트로부터 한 번에 출력 결과를 읽어들일 수 있다면 매우 유용합니다.




주의할 점은 다음과 같습니다.


  • 성공시에는 아무런 로그도 남지 않습니다.

  • 표준 출력창의 내용을 redirect 한 것이므로 실패했을 경우에도 콘솔창에는 로그가 남지 않습니다.


주의 : 초심자 튜토리얼은 아닙니다. 그러므로, 실제 API 호출 용례를 알고자 한다면, 샘플이나 튜토리얼을 찾아서 확인해 보세요.

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

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



DirectX Shader Compiler 프로젝트를 빌드하고 나면 엄청나게 많은 executable 및 library 들이 생깁니다. 


하지만 우리가 그것을 다 사용할 것이 아니기 때문에 필요한 것만 가져다가 사용해야 합니다. 


일단 "dxc.exe" 프로젝트만 빌드해서 executable 을 뽑아 봤는데, LLVM table 관련 executable 을 배제하면, 필수적인 바이너리의 목록은 다음과 같습니다.


  • dxc.exe : 커맨드라인 프로그램.

  • dxc.pdf : 디버깅할 거라면 필요함.

  • dxcompiler.dll : 실제 구현이 포함되어 있는 동적 라이브러리.

  • dxcompiler.pdb : 디버깅할 거라면 필요함.


저는 이것만 복사해서 제가 원하는 디렉토리에 복사했습니다.



이제 vertex shader 를 하나 만들어서 테스트해 보겠습니다. 옵션에 대한 자세한 정보는 [ Supported Command-line Options ] 에서 확인하실 수 있습니다.



다음과 같이 파워셸에서 테스트해 봤습니다.


PS C:\GIt\HairDresser\Distribution\Binaries\ThirdParty\dxc> .\dxc.exe -spirv -Fc -T vs_5_0 -E mainVS C:\GIt\HairDresser\Distribution\Binaries\ThirdParty\dxc\test.hlsl -Fo test.vertex


  • -spirv : SPIR-V 를 생성.
  • -E : 엔트리 포인트.
  • -Fo : 아웃풋( SPIR-V )


혹시나 해서 dxcompiler.dll 을 지워 봤는데 에러가 발생하더군요.


dxc failed : error code 0x8007007e


다른 spir-v 관련 옵션들도 잘 동작하는지 테스트해 봤습니다.


PS C:\GIt\HairDresser\Distribution\Binaries\ThirdParty\dxc> .\dxc.exe -spirv -T vs_5_0 -E mainVS C:\GIt\HairDresser\Distribution\Binaries\ThirdParty\dxc\test.hlsl -Fo test.vertex -Fc test_dis.vertex


  • -Fc : SPIR-V 디스어셈블리 생성.


다음과 같이 정상적으로 디스어셈블리가 생성되었습니다. 내용이 정확한지는 아직 모릅니다. 시리즈를 진행하면서 의미를 파악해 볼 계획입니다.



단지 "Location" 과 같은 키워드가 있는 것으로 봐서는 따로 리플렉션 도구를 이용하지 않아도 이것을 파싱함으로써 정보를 얻을 수도 있겠다는 생각이 들긴 합니다.


참고 자료


[ 1 ] HLSL to SPIR-V Feature Mapping Manual, DirectXShaderCompiler.

주의 : 초심자 튜토리얼은 아닙니다. 그러므로, 실제 API 호출 용례를 알고자 한다면, 샘플이나 튜토리얼을 찾아서 확인해 보세요.

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

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


 

문제점

 

[ 2. DXC 빌드 ] 에서 "dxc.exe" 를 빌드하는 과정에 대해서 살펴 봤습니다.

 

그 이후에 어떤 옵션들이 있는지 확인하기 위해서 코드를 한 번 살펴 봤습니다. 그런데 ENABLE_SPIRV_CODEGEN 이라는 preprocess definition 이 하나 보이더군요.

 

이게 비활성화되어 있었습니다.

 

프로젝트 구성에서 추가를 할까 했는데 그냥 [ 1 ] 의 가이드라인을 따라 정상적으로 빌드해 보기로 했습니다. 

 

CMAKE 솔루션 재생성

 

일단 [ 2. DXC 빌드 ] 과정까지 거쳤기 때문에 TAEF 를 다시 설치하거나 할 필요는 없습니다. 단지 프로젝트를 다시 만들어줘야 할 뿐입니다.

 

HLSL console 을 띄운 다음에 다음과 같이 "-spirv" 옵션만 추가해서 "hctbuild.cmd" 를 실행해 주면 됩니다.

 

 

이러면 열심히 CMAKE 프로젝트를 만들고 빌드합니다.

 

"LLVM.sln" 솔루션을 열어 봤더니 새로운 프로젝트들이 추가된 것 같더군요.

 

 

Trouble Shooting

 

역시나 이것도 한 번에 빌드가 안 되는군요.

 

 

에러가 4 개인 것처럼 보이지만 사실 SPIRV-Tools.lib 를 못 찾는 건 그냥 결과입니다. 근본적인 문제는 첫 번째 에러에 있습니다.

 

18>Traceback (most recent call last):

18>  File "C:/GIt/DirectXShaderCompiler/external/SPIRV-Tools/utils/update_build_version.py", line 148, in <module>

18>    main()

18>  File "C:/GIt/DirectXShaderCompiler/external/SPIRV-Tools/utils/update_build_version.py", line 134, in main

18>    software_version = deduce_software_version(sys.argv[1])

18>  File "C:/GIt/DirectXShaderCompiler/external/SPIRV-Tools/utils/update_build_version.py", line 90, in deduce_software_version

18>    for line in f.readlines():

18>UnicodeDecodeError: 'cp949' codec can't decode byte 0xe2 in position 846: illegal multibyte sequence

18>C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\Common7\IDE\VC\VCTargets\Microsoft.CppCommon.targets(209,5): error MSB6006: "cmd.exe" exited with code 1.

 

원인을 찾아 봤는데 "update_build_version.py" 파일의 90 라인에서 읽고 있는 뭔가가 "cp949" 코덱으로 인코딩되어 있었기 때문입니다. 

 

 

그러므로 89 라인의 open() 함수에서 "UTF-8" 로 인코딩을 바꿔주고 이제 사용하지 않는 옵션인 "U" 를 제거하고 "t" 로 바꿔 줍니다.

 

 

이제 빌드가 잘 됩니다.

 

참고자료

 

[ 1 ] SPIR-V CodeGen, DirectXShaderCompiler.

주의 : 초심자 튜토리얼은 아닙니다. 그러므로, 실제 API 호출 용례를 알고자 한다면, 샘플이나 튜토리얼을 찾아서 확인해 보세요.

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

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



DirectX Shader Compiler( DXC ) 는 High-Level Shader Language( HLSL ) 와 DirectX Intermdiate Language( DXIL ) 관련 컴파일러 및 툴체인을 포함하고 있는 프로젝트입니다.


예전에는 DXSDK 와 Windows 10 SDK 에 "fxc.exe" 라는 실행파일을 배포했었는데, 이제 따로 DXC 라는 이름으로 컴파일러를 배포하고 있더군요. [  DXC 프로젝트 위키 ] 를 보면 다중 플랫폼에 대응하고 개발자들의 열정페이를 촉진하기 위해서 만들어진 것 같습니다.


어쨌든 개발자의 요청 때문인지 HLSL 을 표준 셰이더 언어처럼 만들고자 하는 MS 의 야심때문인지는 모르겠지만, 2018 년 초부터 [ SPIR-V CodeGen ] 이라는 툴을 지원하기 시작했습니다. 따로 툴이 존재하는 것은 아니고 "dxc.exe" 의 파라미터를 통해서 실행됩니다.



이 "dxc.exe" 는 Windows 10 SDK 에 포함되어 있습니다.



즉 배포한 프로그램이 설치된 OS 에는 이 바이너리가 없을 수도 있습니다.


만약 개발자 머신에서만 dxc 를 사용하고, 배포된 프로그램에서는 dxc 가 필요하지 않다면 상관이 없습니다만, 배포된 프로그램에서도 이를 사용해 컴파일을 할 필요가 있다면 같이 배포해 줘야 합니다.


게다가 이 바이너리가 Windows SDK 와 함께 배포되므로 버그 수정과 같은 최신 변경사항에는 대응할 수 없습니다. 디버깅도 불가능하죠. 그러므로 직접 빌드해서 사용하는 것이 여러모로 좋다고 생각합니다.


아래에서는 Visual Studio 2017 을 사용해서 프로젝트를 빌드하는 과정에 대해서 살펴 보도록 하겠습니다. 다른 환경을 사용해서 빌드할 수도 있는데, 그것에 대해 궁금한 점이 있다면 [ 2 ] 를 참조하시기 바랍니다.


GitHub 클론


GitHub 나 GitHub Desktop 에 대한 기본 이해는 있다고 가정하고 진행하겠습니다.


먼저 [ 프로젝트 사이트 ] 로 가서 주소를 복사합니다.




바로 "Open in Desktop" 을 해도 되지만 가끔 실패하는 경우가 있어서 저는 그냥 GitHub Desktop 을 열어서 클론을 했습니다.



파이썬 설치


[ 파이썬 3.x ] 이상을 설치해야 합니다. 중요한 건 반드시 Path 에 파이썬 경로가 포함되어야 한다는 겁니다. 아래 그림에 빨간색 네모로 표시했습니다. 잊지 말아 주세요.



HLSL console 만들기


뭔지는 모르겠지만 HCT 라는 유틸이 있습니다. 제 생각에는 [ Hardware Certification Tool ] 의 머리글자인 것 같습니다.


어쨌든 "$(GitProjectRoot)/utils/hct/hctshortcut.js" 를 윈도우즈 탐색기에서 더블클릭해서 실행하면 바탕화면에 "HLSL console" 이라는 바로가기가 만들어집니다.


이것의 정체를 까 보면 다음과 같습니다.


C:\Windows\System32\cmd.exe /k C:\GIt\DirectXShaderCompiler\utils\hct\hctstart.cmd C:\GIt\DirectXShaderCompiler C:\GIt\hlsl.bin


hctstart 라는 커맨드를 실행하는데, 소스가 있는 폴더인 "DirectXShaderCompiler" 와 "hlsl.bin" 라는 폴더를 인자로 넣습니다.


실행해 보면 "HLSL_SRC_DIR" 과 "HLSL_BLD_DIR" 로 설정됨을 알 수 있습니다.



TAEF 설치

이제 TAEF 라는 것을 설치해야 합니다.


[ Test Authoring and Execution Framework( TAEF ) ] 는 MS 에서 하드웨어 관련 자동화 테스트를 하는 프레임워크인 것 같은데 이것을 설치해야 합니다. 


"$(GitProjectRoot)/utils/hct/hctgettaef.py" 를 실행해서 설치할 수가 있습니다.


아까 만든 "HLSL console" 을 실행해서 다음과 같이 입력합니다. 별다른 반응없이 종료되더군요. 이 작업은 처음 실행했을 때 1 번만 수행하면 된다고 합니다.



방법 1 : hctbuild 를 사용해 CMAKE 솔루션 생성


이제 그 상태에서 바로 "hctbuild" 명령을 실행합니다. 추가 : 이렇게 하면 단순하게 DX 만을 위한 컴파일러가 됩니다. "hctbuild -spirv" 를 입력해야 합니다. 자세한 사항은 [ 3. SPIR-V CodeGen 빌드 ] 에서 확인하시기 바랍니다.



한참을 기다리면, CMAKE 솔루션을 생성하고 빌드까지 합니다. 빌드 경로는 위의 "HLSL_BLD_DIR" 경로입니다. 이것을 고치려면 프로젝트 폴더의 "CMakeSettings.json" 에서 "buildRoot" 항목을 수정하시면 됩니다. 물론 구성별로 전부 수정해야 합니다. 


저같은 경우에는 "x86-Debug" 구성과 "x86-Release" 구성은 아예 지워버렸습니다. 필요하지 않기 때문입니다.



방법 2 : 직접 CMAKE 솔루션 생성


만약 "hctbuild" 를 사용하지 않겠다면, 직접 솔루션을 생성할 수도 있습니다( 직접 해 봤는데 추천하지는 않습니다. CMake 를 여는 데 사용했던 솔루션과 실제 CMAKE 솔루션 사이의 관계에 대한 혼동이 오기 때문입니다. ).


 Visual Studio 2017 은 CMake 에 대한 지원을 내장하고 있습니다. 그러므로 단순하게 프로젝트 폴더의 CMakeList.txt 를 읽어 들임으로써 솔루션이 생성됩니다.




이렇게 솔루션을 생성하면 "hctbuild" 를 생성한 것과 크게 다를 건 없습니다. 이 역시 "CMakeSettings.json" 의 "buildRoot" 에 프로젝트를 빌드합니다.


솔루션 열기


앞에서 언급했듯이 "buildRoot" 에 솔루션이 생성됩니다. 



"LLVM.sln" 을 더블 클릭해서 솔루션을 열 수도 있고, HLSL console 에서 "hctvs" 명령을 입력해 솔루션을 열 수도 있습니다. 일단 솔루션이 생성되고 나면 일반 Visual Studio 솔루션들과 다를 것이 없습니다.


Trouble Shooting


안타깝게도 현재 버전에서는 hctbuild 로 빌드에 성공하지 못하더군요. 솔루션을 열어서 다시 빌드해야 합니다.


86>c:\git\directxshadercompiler\tools\clang\lib\sema\semaexpr.cpp(12689): error C2220: warning treated as error - no 'object' file generated

86>c:\git\directxshadercompiler\tools\clang\lib\sema\semaexpr.cpp(12689): warning C4819: The file contains a character that cannot be represented in the current code page (949). Save the file in Unicode format to prevent data loss


코드페이지가 맞지 않아서라는데... 한글이 있는 것도 아니고 난감하네요.


"File >> Save As" 를 한 다음에 "Save" 옆의 드롭다운을 클릭해서 "Save with encoding" 을 클릭한 다음, "Advanced Save Option" 에서 "Unicode ( UTF-8 with signature) - Codepage 65001" 을 선택하시면 됩니다. 처음에 기본값으로 "without signature" 가 선택되어 있는데, 스크롤을 제일 위쪽으로 올리면 "with signature" 가 나옵니다.




빌드에 성공하면 "$(buildRoot)\$(Configuration)\bin" 에 "dxc.exe" 및 관련 dll 들이 생성되어 있는 것을 확인하실 수 있습니다.



참고 자료


[ 1 ] SPIR-V CodeGen, DirectXShaderCompiler.


[ 2 ] Building Sources, DirectXShaderCompiler.

주의 : 초심자 튜토리얼은 아닙니다. 그러므로, 실제 API 호출 용례를 알고자 한다면, 샘플이나 튜토리얼을 찾아서 확인해 보세요.

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

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


 

Descriptor

 

벌칸은 SPIR-V 라는 플랫폼 독립적인 중간언어를 사용합니다. 하지만 이것은 바이너리 파일이므로 사용자가 직접 작성할 수 없습니다. 

그래서 다른 셰이딩/컴퓨팅 언어들을 통해 프로그램을 작성한 다음에 이를 SPIR-V 로 컨버팅하게 됩니다. 이를 위한 언어들은 glsl, hlsl, opencl, cg 등이 될 수 있습니다. 예전부터 널리 사용해 오던 언어들이죠.

그런데 크로노스 그룹에서 정식으로 제공하고 있는 "glslangValidator.exe" 라는 프로그램이 glsl 과 hlsl 만을 지원합니다. 하지만 hlsl 에 대한 지원은 미비합니다( 나중에 언급하겠습니다 ). 크로노스 그룹에서 만든 언어이니 당연한 거겠죠.

벌칸에서는 셰이더에 뭔가를 바인딩하기 위해서 디스크립터( Descriptor )라는 것을 사용합니다. 그 중 버텍스 관련 디스크립터들에 대해서 살펴 보겠습니다.

출처 : [ Vertex input description ], Vulkan Tutorial.

버텍스 셰이더에서 사용하는 입력에 레이아웃이라는 것을 지정하는 것을 보실 수 있습니다. 버텍스 버퍼의 내부 메모리 구성이 어떻게 되어 있는지를 알려주는 거죠.

그러므로 네이티브 코드 단에서는 버텍스 입력을 제공하기 위해서 다음과 같이 클래스를 선언합니다.

출처 : [ Vertex input description ], Vulkan Tutorial.

하나의 버텍스 데이터 구조를 만들기 위해서 두 가지 작업을 하고 있는 것을 알 수 있습니다.

  • 버텍스 버퍼의 메모리 정보( 각 버텍스의 크기( stride ) )를 알려주기 위해 바인딩 디스크립터( VkVertexInputBindingDescription )를 생성.
  • 각 버텍스를 구성하고 있는 속성( attribute )들에 대한 정보( VkVertexInputAttributeDescription )를 생성.

 

SPIR-V Toolchain

 

위의 예제는 기본적으로 셰이더의 내용을 프로그램 작성자가 다 알고 있다는 가정하에서 이루어졌습니다. 하지만 실제 개발과정에서는, 저런 레이아웃의 순서가 바뀔수도 있고, 레이아웃의 순서가 어쨌든지 간에 자동화해서 데이터를 입력해 줘야 할 수도 있습니다. 셰이더 작성자와 코드 작성자가 완전히 다를 수도 있고, 셰이더 언어가 다양할 수도 있습니다.

맘 편하게 position = 0, normal = 1, texcoord = 2 라는 식으로 정할 수는 없다는 겁니다. 물론 하려면 할수도 있겠지만, 그 만큼 셰이더가 프로그램에 종속적이 될 수밖에 없겠죠.

그렇기 때문에 이런 것들( spir-v 변환부터 c++ 리플렉션까지 )을 자동으로 처리해 줄 수 있는 툴들이 필요합니다. 다행히도 크로노스에서는 이와 관련한 기본 툴체인을 제공하고 있습니다[ 1 ].

glsl 을 사용하고 있다고 가정했을 때 spir-v 로의 변환 및 리플렉션 과정은 다음과 같습니다.

  • Code Generation( glslangValidator ) : glsl 에서 spir-v 로 변환합니다. 
  • Optimization( spirv-opt ) : spir-v 파일의 크기를 줄입니다.
  • Validation( spirv-val ) : spir-v 코드가 올바른지 검사합니다. 이 과정에서 인간이 읽어들일 수 있는 포맷으로 변경하기 위해 디스어셈블러나 어셈블러를 사용해서 디버깅하기도 합니다( spirv-dis, spirv-as ).
  • Reflection ( spirv-cross ) : spir-v 파일로부터 리플렉션 정보를 추출합니다.

 

HLSL to SPIR-V

 

glsl 을 사용하고 있다면 그냥 기본 툴체인을 사용하면 되겠지만, hlsl 을 사용하고 있다면 일단 hlsl 에서 glsl 로 변환하는 과정부터 거쳐야 합니다. 그리고 layout 이라는 키워드 자체가 glsl 에서만 지원하는 것이므로 이와 관련한 변환 작업도 필요합니다.

사실 glslang 으로도 HLSL 을 SPIR-V 로 변환하는 것은 가능하지만 지원하지 않는 기능들이 많습니다. Khronos 의 [ HLSL FAQ ] 에서는 glslang 의 한계에 대해서 이야기하고 있습니다. 

일반적으로 어떤 툴체인을 사용하는지 검색해 봤는데, 다음과 같은 식으로 하고 있는 걸 발견했습니다.

  • Unity 3D 같은 경우에는 HLSLcc( hlslcc( James-Hones ) 라는 오픈소스의 변형 )를 사용해 spir-v 를 바로 생성하는 것으로 보입니다.
  • UE4 같은 경우에는 hlslcc( 원본인지 자체 변종인지는 모르겠습니다 )를 사용하여 hlsl 을 glsl 로 변환한 다음에 glslangValidator 를 사용해 spir-v 를 생성합니다.
  • DXC( DirectX Shader Compiler )는 hlsl 을 직접 spir-v 로 변환하고 있는 것( SPIR-V CodeGen )으로 보입니다.

 

나가며

 

저같은 경우에는 HLSL 을 선호하고 툴체인이 길어지는 것을 별로 좋아하지 않기 때문에 dxc 를 사용해 보려고 합니다. 

[ HLSL to SPIR-V ] 시리즈에서는 DXC 를 사용해서 hlsl 파일로부터 spir-v 를 생성하고 C++ 에서 리플렉션하는 과정까지를 다뤄 볼 계획입니다.

추가 : 참고로 위키에 따르면 SPIR-V CodeGen 은 MS 가 아니라 google/shaderc 팀에서 주로 관리한다고 하는군요.

 

참고자료

 

[ 1 ] SPIR-V Toolchain, LUNARG.

주의 : 초심자 튜토리얼은 아닙니다. 그러므로, 실제 API 호출 용례를 알고자 한다면, 샘플이나 튜토리얼을 찾아서 확인해 보세요.

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

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



저수준 API?


Direct3D 11 과 같은 API 에서는 ID3D11DeviceContext::OMSetDepthStencilState() 와 같이 특정 상태를 설정하는 메서드들을 볼 수 있습니다. 



이 상태를 변경하고자 한다면 한 프레임 내부에서 아무때나 호출하는 것이 가능합니다. 그렇기 때문에 상태를 반복적으로 불필요하게 설정하기 위한 비교 루틴들을 넣고는 했습니다.


하지만 벌칸에서는 이러한 상태들이 하나의 오브젝트( VkPipeline )에 캡슐화됩니다. 그렇기 때문에 아무 곳에서나 상태를 변경하는 것은 불가능하고 반드시 하나의 세트로서 상태가 설정되어야만 합니다.


언뜻 보면 이것은 매우 불편합니다. 왜냐하면 저수준 API 라고 했음에도 불구하고 아무 곳에서나 원하는 작업을 하는게 불가능하기 때문입니다. 벌칸에서의 "저수준 API" 의 의미는 이전 API 들보다 저수준에 대한 제어를 할 수 있다는 의미이지, 아무 곳에서나 마음대로 함수를 호출할 수 있다는 의미는 아닙니다.


그런데 이렇게 하는 이유는 무엇일까요?


그 이유는 렌더 패스( RenderPass )와 그것의 역할을 이해하면 명확해집니다. 제가 처음에 Vulkan 을 접하면서 가장 이해하기 어려웠던 것이 렌더패스였습니다. 개념과 필요성 자체를 이해할 수 없었죠. 그래서 이 문서에서는 렌더패스에 대해서 다뤄 보고자 합니다. 


여기에서는 구구절절 코드를 풀어서 설명하기보다는 개념적인 부분을 중심으로 이야기하도록 하겠습니다.


개체 중심 API?


벌칸은 파이프라인 형태로 기능을 설계하기 매우 좋은 API 입니다. 왜냐하면 파이프라인의 특정 단계들이 오브젝트로 캡슐화되어 있기 때문입니다. 이것이 벌칸을 처음 사용하게 되는 기존의 OpenGL 이나 D3D 사용자들에게 많은 혼선을 주는 것 같습니다.


예전의 그래픽스 API 들이 절차적 프로그래밍 형식이었다면 이제는 개체지향 프로그래밍 형식으로 넘어 갔습니다. 물론 예전에도 C++ 코드 단에서는 나름대로 클래스를 만들어서 기능들을 캡슐화해서 사용하곤 했지만, 이제는 라이브러리 내부에서 그런 캡슐화를 수행하고 있습니다.


여기에서 혼선이 옵니다. 분명히 벌칸은 C ABI( Application Binary Interface ) 를 사용해서 구현되어 있는데, 뜬금없이 개체지향을 언급하다니 이상하지 않나요?


물론 제가 기억하기로는 명세에서도 개체지향이라는 표현은 사용하지 않습니다. 여기에서 제가 개체지향이라는 것은 언어적 특징을 이야기하는 것이 아닙니다. 기능들이 개체를 중심으로 구현되어 있다는 의미입니다. 우리가 접하는 API 는 일종의 마샬링( Marshaling ) API 이고 실제 내부 구현은 개체지향적으로 구현되어 있다고 생각하는 게 벌칸 API 를 이해하는 데 도움이 됩니다.


벌칸 API 의 함수들을 몇 개 살펴 보겠습니다.



vkXXX() 함수를 호출할 때는 반드시 작업의 주체가 되는 벌칸 오브젝트를 첫 번째 파라미터로 넣습니다. 그러면 각각은 다음과 같은 명령을 내립니다.


  • VkPhysicalDevice 오브젝트야! 포맷 속성들 좀 정리해서 알려 줘.
  • VkDevice 오브젝트야! 버퍼 하나 생성해 줘.
  • VkCommandBuffer 오브젝트야! 버퍼를 이미지에 복사하는 명령을 추가해 줘.


그렇기에 "Vulkan.hpp" 와 같은 래퍼 클래스를 만드는 것이 용이합니다.


뭐 다른 API 들도 이 정도는 하는 것 아니냐는 반론을 할 수 있습니다. 하지만 저는 이렇게 무리한 주장을 해 봅니다. 왜냐하면 앞에서 언급했듯이 개체를 중심으로 이해해야 벌칸 API 를 이해하기 편하기 때문입니다.


렌더패스


"렌더패스" 라 하면, 일반적으로 프레임 그래프렌더 그래프라는 것이 생각날 겁니다. 


간단하게 예를 들면, "Depth" --> "G-Buffer" --> "Shadow-Depth" --> "Lighting-Accumulation" --> "Translucency" 으로 이어지는 deferred-lighting 기법을 이런 그래프로 표현할 수 있습니다. 사실 포스트 프로세스를 고려하면 좀 더 복잡하게 그려야 하는데 그냥 대충 뭉뚱그립니다. 그냥 "Lighting-Accumulation" 에는 "Shadow-Applying" 까지 포함되어 있다고 생각해 주세요. 그림이 옆으로 길어져서 귀찮아서 뭉뚱그렸습니다.


그림 1.


각 단계별로 사용하는 어태치먼트( Attachment )들이 다르며 그 어태치먼들 간에는 종속성이 발생합니다. 일단 어태치먼트는 실제 리소스에 대한 기술( description )이라 생각하시면 됩니다. 뒤에서 좀 더 설명하겠습니다.


그림 2.


각 단계를 렌더 패스라고 볼 수 있으며, 이것이 사용하는 어태치먼트들 간의 종속성에 의해 렌더패스의 종속성이 발생합니다. 물론 서브패스( Sub-Pass )라는 것도 존재하기는 하지만 여기에서는 구체적인 언급을 피하겠습니다. 좀 설명이 복잡해집니다. 일단은 그냥 렌더패스 당 한 개의 서브패스가 존재한다고 생각하시면 됩니다.


그런데 어태치먼트들을 지정할 때 규칙을 지정합니다. 포맷이 뭔지, 어떤 용도로 사용되는지, 안티에일리어싱을 위한 샘플 카운트는 몇 개인지, 클리어는 하는지, 로드할 때 기존 데이터를 유지해야 하는지, 세이브를 할 필요가 있는지, 레이아웃이 뭔지 등등을 지정합니다. 이를 어태치먼트 디스크립션( VkAttachmentDescription )이라 합니다.



엄청 복잡합니다. 


이 렌더패스 오브젝트는 일종의 템플릿으로서 존재합니다. 실제로 인스턴스화되는 것은 VkCmdBeginRenderPass() 가 호출될 때죠. 이 함수가 호출될 때 각 어태치먼트와 관련된 리소스들을 VkFrameBuffer 라는 오브젝트로 바인딩합니다.



렌더패스 커맨드를 시작하고 종료하기 위해서는 기본적으로 세 가지 오브젝트가 필요하다는 것을 알 수 있습니다.


  • 렌더패스( VkRenderPass ) 오브젝트.
  • 프레임버퍼( VkFrameBuffer ) 오브젝트.
  • 커맨드버퍼( VkCommandBuffer ) 오브젝트.


아까도 언급했지만 엄청 복잡합니다. 왜 이렇게까지 해야 하는 걸까요? 그리고 이것의 장점은 무엇일까요?


No-Validation & No-Exception


렌더패스를 하나 만들면 VkCmdBeginRenderPass() 호출을 통해서 실제 렌더패스 인스턴스가 ( 내부적으로 ) 생성됩니다. 그리고 렌더패스의 어태치먼트에 대응해서 실제 리소스인 프레임버퍼가 바인딩됩니다.


꼭 렌더패스의 경우가 아니더라도 벌칸에서 이러한 Description-Instance 쌍을 띠고 있는 경우는 많습니다. 예를 들면 VkDescriptorSetLayout 같은게 있습니다.


이런 디스크립터들이 존재하는 이유는 벌칸이 실제 리소스의 메모리구조를 알고 있지 못하기 때문입니다. 텍스쳐를 만들면서 메모리가 2D 라고 가정했다고 합시다. 그런데 이 텍스쳐의 메모리를 채울 때 row-major 로 채울수도 있고 column-major 로 채울수도 있습니다. 그리고 한 픽셀의 크기도 다를 수 있습니다.


그러므로 벌칸에 실제 메모리가 어떤 식으로 구성되어 있는지 알려줄 필요가 있습니다. 그래서 대부분이 Descriptor-Instance 쌍으로 구성됩니다.


이게 어떤 이점을 가질까요?


이렇게 하면 개체를 생성하는 시점에서 validation 을 수행할 수 있습니다. 개체를 바인딩해서 처리하는 API 내부에서 validation 루틴 및 예외처리 루틴을 처리할 필요가 없어진다는 의미입니다. 그렇기 때문에 성능상 이점을 획득할 수 있다는 장점과 문제의 근원을 초반에 파악할 수 있다는 장점이 있습니다.


예를 들어 VkFrameBuffer 생성 함수를 살펴 보죠.



VkFramebufferCreateInfo 에 RenderPass 를 넣는 것을 볼 수 있습니다. 만약 프레임버퍼를 생성하는 데 렌더패스에서 기술한 조건들과 맞지 않았다면, 생성하면서 바로 에러가 나는거죠. 어떤 에러가 발생했는지 알고자 한다면, "Debug Layer" 를 켜면 됩니다.


Dependency Management


그림 1 에서 "Shadow-Depth" 패스는 "Depth" 패스와 "G-Buffer" 패스에 대해 비동기적으로 실행될 수 있습니다. 하지만 "Lighting-Accumulation" 패스 전에는 동기화되어야 합니다.


VkCommandBuffer 들은 동기화 개체를 통해서 서로에 대한 의존성을 지정할 수 있습니다. 만약 그러므로 렌더패스는 구조만 기술하고 렌더패스 인스턴스를 생성하면서 커맨드버퍼를 같이 넘기면 렌더패스끼리의 의존성을 지정할 수 있는 겁니다.


이 경우에는 "Shadow-Depth" 패스를 다른 스레드에서 실행한다고 해도 순서에 문제가 발생하지 않겠죠.


Life-Cylcle Management


요즘에는 보통 게임 스레드와 렌더링 스레드를 분리하는 구현을 합니다. 심지어는 렌더링 스레드와 RHI( Rendering-Hardware-Interface ) 스레드를 분리하기도 하죠.


이렇게 되면 리소스 오브젝트의 무결성에 문제가 생깁니다. 예를 들어 일반적으로 스왑체인( swap-chain )은 여러 개의 백퍼버( Swapchain-Image )를 가질 수 있습니다. 최소한 2 개 정도의 백버퍼를 사용하고 있습니다.


만약 여러 개의 백버퍼를 가지고 있다고 가정하고( 게임스레드와 렌더링스레드를 프레임-동기화하지 않는다는 의미 ), 게임 스레드와 렌더링 스레드를 분리했을 때 다음과 같은 형태로 업데이트가 될 겁니다. 각 프레임마다 처리시간이 가변적이라고 가정했습니다.



첫 번째 프레임을 보죠. 붉은색 게임 프레임이 종료되고 주황색 게임 프레임이 시작될 때 렌더링 스레드에서는 여전히 붉은색 프레임을 실행하고 있습니다. 이 때 렌더패스가 템플릿( 혹은 디스크립션 )이 아닌 인스턴스라고 하면 바로 문제가 발생합니다. 주황색 게임 프레임에서는 사용중인 렌더패스, 커맨드버퍼, 프레임버퍼 등을 변경하도록 요청하겠죠.


그러므로 렌더패스 인스턴스 단위로 커맨드와 프레임버퍼를 바인딩하는 것은 생명주기를 관리하는데 큰 도움을 줍니다.


그래서 보통 프레임버퍼를 스왑체인 이미지의 개수만큼만큼 풀( pool )로 만들어서 관리하게 됩니다. 물론 메모리 문제는 발생하겠지만 무결성이 깨지는 상황은 막을 수 있습니다. 메모리가 걱정된다면 프레임-동기화를 하면 됩니다.


결론


벌칸은 C 로 구현되어 있기는 하지만, 개체 중심으로 구조화되어 있고 추상화되어 있습니다. 대부분이 Descrption-Instance 쌍으로 되어 있으며, 그 중 하나가 렌더패스입니다.


렌더패스는 어태치먼트들을 정의하며, 실제로 이것이 인스턴스화 되는 것은 VkBeginRenderPass() 입니다. 이때 렌더패스, 프레임버퍼, 커맨드버퍼가 필요합니다. 


프레임버퍼는 어태치먼트가 지정하는 실제 리소스 인스턴스입니다. 커맨드버퍼는 렌더패스간 의존성을 제어하는데 사용됩니다.


렌더패스 오브젝트를 실제 인스턴스와 분리함으로써 세 가지 이점을 가질 수 있습니다.


  • API 내부의 에러 처리 루틴이 필요하지 않아 성능이 개선됩니다.
  • 커맨드 버퍼를 통해 렌더패스간 의존성을 관리할 수 있습니다.
  • 멀티스레딩 환경에서 렌더패스 인스턴스, 커맨드 버퍼, 프레임 버퍼 등의 생명주기를 관리하는데 용이합니다.


원문 : https://www.techpowerup.com/gpu-specs/docs/amd-gcn1-architecture.pdf

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

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

주의 : 우리말로 번역하기 애매한 것들은 그냥 원문으로 씁니다.

정보 : 그림은 클릭하면 새창에 나옵니다. 새창에서 그림위에 + 버튼이 나오면 누르시면 커집니다.



INTRODUCTION


지난 15 년간 ( 역주 : 이 문서는 2012 년에 작성되었습니다 ) 그래픽스 프로세서들은 지속적으로 발전해 왔으며 컴퓨팅 환경[computing landscape]의 필수 요소가 되는 첨단 기술입니다. 첫 번째 디자인들은 적은 유연성을 가진 특정 목적의 하드웨어를 사용했습니다. 그 후의 디자인들은 셰이더 프로그램들을 통해 제한된 프로그램 작성 기능을 소개했으며, 결국에는 많은 그래픽스 목적 기능들을 여전히 유지하면서 고도로 프로그래밍이 가능한 처리량 컴퓨팅 디바이스[throughput computing device]가 되었습니다.


Figure 1: GPU Evolution


GPU 들의 성능과 효율성에서의 잠재력은 믿을 수 없을 정도입니다. 게임들은 산업계 필름 영화들과 비견될 만큼의 가시 품질을 제공하며, 과학 커뮤니티의 얼리 어댑터들은 4 TFLOPS ( Floating point Operations per Second ) 를 초과하는 능력을 갖춘 최첨단 GPU 들을 사용해 크기정도[order of magnitude]( 역주 : 수학에서 서로 다른 크기를 비교할 때 사용되는 크기 등급. 몇 자리 단위의 숫자만큼 차이가 난다는 의미임. 위키백과 참고 바람 )의 성능 향상을 확인했습니다. 전력 효율도 주목할 만합니다; 예를 들면, AMD Radeon[TM] HD 7770M GPU 는 최대 45 W 의 전력 드로[power draw]에서 1 TFLOPS 이상을 달성합니다.


OpenCL[TM], DirectCompute, C++ AMP 와 같은 핵심 산업 표준들은 프로그래머들이 GPU 에 접근할 수 있도록 해 줍니다. 앞으로의 도전은 메인스트림 애플리케이션을 위한 매끄러운 이종 컴퓨팅 솔루션[seamless heterogeneous computing solution]을 제공하는 것입니다( 역주 : GPU 를 그래픽스 전용이 아니라 범용 목적으로 사용할 수 있도록 만들겠다는 의미로 보임 ). 이는 성능과 전력 효율에서의 향상을 수반하지만, 프로그래밍 가능성과 유연성의 향상도 수반할 것입니다. 메인스트림 애플리케이션들은 GPU 와 CPU 모두를 사용하는 현대의 에코시스템[echosystem]과 태블릿에서 슈퍼 컴퓨터에 이르기까지 다양한 형태 인자[form factor]들에 적용될 산업 표준을 필요로 합니다.


AMD 의 Graphics Core Next (GCN) 은 GPU 하드에어를 위한 근본적인 변화를 나타내며, 프로그래밍 가능하고 이종으로 구성된 미래의 시스템들을 위한 아키텍쳐입니다. GCN 은 28 nm 노드에서 전력 및 면적 효율성을 위해 신중하게 최적화되었으며, 몇 년 안에 향후의 공정 기술[process technology]들로 활장될 것입니다.


COMPUTE UNIT OVERVIEW


컴퓨트 유닛[compute unit]들은 GCN 아키텍쳐에서 기본이 되는 계산 빌딩 블록[computational building block]( 역주 : 빌딩 블록이라는 것은 특별한 기능을 수행하도록 미리 만들어 둔 부품을 의미 )입니다. 이러한 CU 들은 완전히 새로운 명령 집합[instruction set]들을 구현합니다. 그 명령 집합들은 이전 디자인에서보다 컴파일러나 소프트웨어 개발자들이 사용하기에 더욱 단순해졌으며 일관된 성능을 제공합니다.


이전 세대의 AMD GPU 들에서, 셰이더 배열[shader array]들은 여러 개의 SIMD 엔진들로 구성되었으며, 각 엔진들은 16 개의 ALU( 역주 : Arithmetic Logic Unit, 산술 논리 장치. 사칙연산과 논리연산을 계산하는 디지털 회로 )로 구성되었습니다. 각 ALU 는 셰이더 컴파일러를 사용해 4 개 혹은 5 개의 독립적인 명령들의 묶음[bundle]들을 VLIW ( Very Long Instruction Word ) 포맷으로 실행할 수 있었습니다. 그 셰이더 컴파일러는 동시에 발행가능한 명령들을 검색하고 스케줄링할 책임이 있었습니다. SIMD 엔진들은 웨이브프런트[wavefront]라 불리는 64 개의 작업 아이템들의 그룹들을 발행받았으며, 한 번에 하나의 웨이브프런트를 실행했습니다. 데이터 플로우 패턴[data flow pattern]에 걸맞는 이 디자인은 그래픽스 처리에서 ( 예를 들어 픽셀 셰이더에서 RGBA 컬러 값들을 조작하기 위해 ) 매우 일반적이며, 대부분의 경우에 높은 수준으로 ( 역주 : GPU 를 ) 지속적으로 활용하는 것을 가능하게 해 줍니다. 하지만 범용 목적 애플리케이션[general purpose application] 관점에서는 기저 데이터 포맷들이 더 복잡해지고 예측불가능해질 수 있습니다. 이는, 매 사이클마다 병렬적으로 실행될 수 있고 처리중인 리소스들을 완전히 활용할 수 있는, 4 개나 5 개의 연산들의 집합을 지속적으로 찾는 것을 어렵게 만듭니다.


GCN 에서는 각 CU 가 벡터 처리를 위한 4 개의 독립된 SIMD 유닛들을 포함합니다. 이러한 각 SIMD 유닛들은 16 개의 작업 아이템들에 대해 하나의 연산을 동시적에 실행합니다만, 각 유닛들은 독립된 웨이브프런트 상에서 동작합니다. 단일 웨이브프런트 내의 독립된 연산들을 찾아내기 위해서 컴파일러에 의존하지 않습니다. 이는 병렬적으로 처리될 수 있는 웨이브프런트들을 찾아내는 데 중점을 둡니다.


Figure 2: VLIW4 vs. GCN


효율성을 위해서, 각 GCN 컴퓨트 유닛 내의 SIMD 들은 전용[private] 리소스와 공유[shared] 리소스들의 조합을 소유하게 됩니다. 버퍼링, 레지스터, 벡터 ALU 같은 명령들은 높은 성능과 활용도를 유지하기 위해서 4 개의 SIMD 들 각각에 대해서 전용됩니다. 프런트-엔드, 브랜치 유닛, 데이터 캐시와 같은 다른 리소스들은 면적 및 전력 효율성을 위해서 SIMD 들간에 공유됩니다.


Figure 3: GCN Compute Unit



GCN 에서 또 다른 핵심적인 혁신은 일관성 캐싱[coherent caching]( 역주 : 여러 스레드에서 동시에 접근해도 '일관성'을 유지할 수 있다는 의미입니다 )입니다. 역사적으로 GPU 들은 메모리의 일관성 뷰[coherent view]를 유지하지 않는 ( 읽기 전용 텍스쳐 캐시들과 같은 ) 특수화된 캐시들에 의존해 왔습니다. GPU 내의 코어들 사이에서 통신하기 위해서는, 프로그래머나 컴파일러가 반드시 명시적인 동기화[explicit synchronization] 명령을 삽입해서 공유된 데이터를 메모리에 플러시[flush]해야 합니다. 이 접근법이 디자인을 단순화해 주기는 했지만, 애플리케이션이 데이터를 공유하는데 있어서 부하를 증가시켰습니다. GCN 은 범용 목적 작업[workload]들을 위해서 설계되었습니다. 이 작업들에서는 코어간 통신 알고리즘이 일반적입니다. 캐시 일관성 규약[cache coherency protocol]은 L2 캐시를 통해 데이터를 공유하며, 이는 그래픽스칩 외부 메모리를 사용하는 것보다 훨씬 더 빠르고 전력 효율이 좋습니다.


캐시 일관성과 함께, GCN 은 하드웨어와 드라이버 지원의 조합을 통해 가상 메모리[virtual memory]를 제공합니다. 가상 메모리는 메모리 관리의 관점에서 거의 대부분의 어려움들을 제거해 주며, 새로운 기능들을 열어 둡니다. 고성능 그래픽스 및 마이크로프로세서에서의 AMD 만의 전문 기술이 특히 도움이 되었습니다. 왜냐하면 GCN 의 가상 메모리 모델은 x86 과의 호환성을 유지할 수 있도록 신중하게 정의되었기 때문입니다. 이는 CPU 와 개별 GPU 사이에서 데이터를 이동시키는 것을 단순화합니다. 더욱 중요한 것은 CPU 와 GPU 에 의해 끊김없이[seamlessly] 공유되는 단일 주소 공간을 위한 길을 열었다는 것입니다. 데이터를 복사하는 것이 아니라 공유하는 것은, 성능 및 전력 효율을 위해 필수이며 AMD 의 Accelerated Processing Units (APUs) 같은 이종 시스템들에서 핵심적인 요소입니다.


CU FRONT-END


( 역주 : 이해를 돕기 위해서 이 챕터에서 설명하는 블록을 구분해 봤습니다. 어둡지 않은 부분입니다.


)


GCN 에서 각 SIMD 유닛은 10 개의 웨이브프런트를 위해 자신만의 40-bit 프로그램 카운터( 역주 : Figure 3 에서 "SIMD" 유닛의 "PC" )와 명령 버퍼(  역주 : Figure 3 에서 "SIMD" 유닛의 "IB" )를 할당받습니다. 즉 전체 CU 들은 40 개의 웨이브프런트들을 동시에 처리할 수 있으며, 각각은 잠재적으로 다양한 작업그룹[work-group]이나 커널[kernel]로부터 올 수 있습니다. 이는 이전 디자인에 비해 훨씬 더 유연합니다. 이는 32 CU 를 가진 AMD Radeon[TM] HD7970 과 같은 GCN GPU 가 동시에 81,920 개의 작업 아이템들에 대해서 동작할 수 있다는 것을 의미합니다.


4 개까지의 컴퓨트 유닛으로 구성된 클러스터[cluster]는 단일 32 KB L1 명령 캐시들을 공유하는데, 이 캐시는 4-way associative( 역주 : "Set Associative Cache" 방식중 하나인데, 간단하게는 캐시 라인이 4 개가 있다고 생각하시면 됩니다. 즉 동시에 4 개의 캐시라인에 접근이 가능합니다 )이며 L2 캐시에 의해 지원[backed by]됩니다. 캐시라인은 64 B long 이며 일반적으로 8 개의 명령들을 저장할 수 있습니다. 캐시가 가득 차면, 새로운 요청은 Least Recently Used( 역주 : 최근에 가장 덜 사용된 ) 캐시 라인을 퇴출시키고 새로운 명령들을 위한 공간을 할당합니다. 공유된 L1 명령 캐시는 4 개의 뱅크[bank]들을 가지며 4 개의 CU 에 대해서 사이클당 32 B 의 명령 전송[instruction fetch]를 유지할 수 있습니다. 명령 전송은 컴퓨트 유닛 내부 SIMD 사이에서 나이, 스케줄링 우선순위, 웨이브프런트 명령 버퍼 활용도에 따라 스케줄링[arbitrate]됩니다( 역주 : AMD 는 "schedule" 대신에 "arbitrate" 라는 용어를 사용하는 것으로 보입니다. 꼭 AMD 만 그런 것은 아닌 것 같고 일반적으로 사용하는 용어로 보입니다. 역자는 익숙함 때문에 "스케줄링"을 사용하겠습니다 ).


일단 명령들이 웨이브프런트 버퍼들에 전송되고 나면, 다음 단계는 명령들에 대한 디코딩[decoding] 및 발행[issuing]입니다. 컴퓨트 유닛은 각 사이클에 하나의 SIMD 를 선택해서 디코딩 및 발행을 수행하는데, 라운드-로빈 스케줄링[round-robin arbitration]을 사용합니다( 역주 : 프로세스들 사이의 우선순위를 두지 않고 시간단위로 작업을 할당하는 방식입니다 ). 선택된 SIMD 는 10 개의 웨이브프런트 버퍼로부터 5 개까지의 명령들을 디코딩해 실행 유닛[execution unit]으로 발행합니다. 추가적으로, 특별한 명령( 예를 들어 NOP, 배리어[barrier], 중단[halt], 예측된 벡터 명령 건너뛰기[skipping] 등 )들은 어떠한 기능 유닛[functional unit]들을 사용하지 않고 웨이브프런트 버퍼 내부에서 실행될 수 있습니다. 각 CU 들은 16 개의 버퍼를 가지고 있는데, 이는 배리어 명령들을 트래킹[track]합니다. 배리어는 웨이브프런트를 전역적으로 동기화하게 만듭니다.


CU 프런트엔드는 7 개의 다양한 종류의 명령들을 디코딩하고 발행할 수 있습니다: 브랜치[branch] 명령, 스칼라[scalar] ALU 명령, 스칼라 메모리 명령, 벡터 ALU 명령, 벡터 메모리 명령, 지역 데이터 공유[local data share] 명령. 전역 데이터 공유[global data share] 및 익스포트[export] 명령, 특수[special] 명령. SIMD 당 각 타입에 대해서 하나의 병령만이 한 번에 제출될 수 있습니다. 이는 실행 파이프라인[execution pipeline]의 한계를 초과하지 않기 위함입니다. 실행 순서를 보존하기 위해서, 각 명령들은 서로 다른 웨이브프런트로부터 와야만 합니다; 각 SIMD 에 대해서 10 개의 웨이브프런트를 가지고 있으므로, 일반적으로 그것으로부터 선택할 수 있는 많은 경우의 수가 있습니다. 이러한 두 가지 제약하에서, 모든 혼합[mix]이 허용되며, 이는 컴파일러가 다양한 방식으로 실행을 위한 명령을 발행할 수 있도록 자유도를 부여합니다.


CU 프런트엔드는 매 사이클 당 5 개의 명령들을 발행할 수 있습니다. 이는 2 개의 레지스터 파일을 사용해 여섯 개의 벡터 실행 파이프라인과 스칼라 실행 파이프라인들을 혼합합니다. 벡터 유닛들은 그래픽스 셰이더 뿐만 아니라 범용 목적 애플리케이션들을 위해서 핵심이 되는 계산 능력을 제공합니다. 명령 버퍼에서 다뤄지는 특수 명령들과 함께 두 개의 스칼라 유닛들은 GCN 아키텍스쳐에서 모든 제어 흐름[control flow]들을 담당합니다.


SCALAR EXECUTION AND CONTROL FLOW


( 역주 : 이해를 돕기 위해서 이 챕터에서 설명하고 있는 블록을 구분해 봤습니다. 어둡지 않은 부분입니다 


)


성능 및 전력 효율성을 위해서 GCN 컴퓨트 유닛에는 새로운 스칼라 파이프라인들이 필수입니다. 제어 흐름 처리는 셰이더 코어[shader core]들을 통해서 분산[distributed]되어 있는데, 이는 응답지연[latency]를 줄이고 중앙화된 스케줄러[centralized scheduler]를 사용해서 통신하기 위한 전력 부하[power overhead]를 피할 수 있게 해 줍니다. 특히 그래픽스보다는 더 복잡한 제어 흐름을 가지는 범용 목적 애플리케이션을 위해 유용합니다.


각 컴퓨트 유닛들은 8 KB 의 스칼라 레지스터 파일을 가지는데, 이것은 각 SIMD 를 위해 512 개의 엔트리[entry]들로 나뉘어 있습니다. 이 스칼라 레지스터들은 SIMD 상에 존재하는 10 개의 웨이브프런트들 모두에 의해서 공유될 수 있습니다; 웨이브프런트는 112 개의 사용자 레지스터와 구조적 상태[architectural state]를 위해 예약되어 있는 몇 가지 레지스터들을 할당할 수 있습니다. 이 레지스터의 크기는 32 비트이며, 64 비트 값을 저장할 수 있도록 하기 위해서 연속적인 엔트리들이 사용될 수 있습니다. 웨이브프런트 제어 흐름을 위해 이것이 필수적입니다; 예를 들어, 웨이브프런트 내의 64 개의 작업아이템들 각각에 대해 비교[comparison]하는 결과를 산출할 수 있습니다.


첫 번째 스칼라 파이프라인은 조건 분기[conditional branch]를 다루는데 명령에 인코딩되어 있는 16 비트 부호있는 옵셋[singed offset]을 사용합니다. 이는 또한 특정 유형의 동기화와 인터럽트[interrupt]를 다룰 책임이 있습니다. 인터럽트는 GCN 에서 추가된 완전히 새로운 기능입니다. 이는 디버깅을 위한 핵심 기능입니다. 왜냐하면 그것들은 중단점[breakpoint]을 위해 제어 흐름을 재전송[redirect]할 수 있기 때문입니다.


두 번째 스칼라 파이프라인은 완전한 정수 ALU 입니다. 이는 주소 생성 유닛[address generation unit, AGU]로 기능하여 스칼라 데이터 캐시로부터 데이터를 읽어들입니다. 실제 ALU 는 64 비트 크기입니다. 이 파이프라인은 점프[jump], 콜[call], 리턴[return]을 포함한 다양한 제어 흐름 명령들을 돕습니다. 이것들은 예전의 프로그램 카운터를 인접한 레지스터들로 구성된 64 비트 값으로 대체[replace]( 혹은 교체[swap] )함으로써 처리됩니다. 예측[prediction]도 스칼라 ALU 에 의해서 관리되는데, 분기[fork] 연산과 병합[join] 연산을 관리하기 위해서 보조 하드웨어[assist hardware]를 사용합니다.


스칼라 L1 데이터 캐시는 읽기 전용 구조입니다. 스칼라 파이프라인들은 주로 제어 흐름과 궁합이 맞기 때문에, 메모리에다가 결과를 다시 써야 할 필요가 없습니다. 그것의 구조[organization]는 L1 명령 캐시와 매우 유사합니다. 16 KB 스칼라 데이터 L1 은 4-way associative 이며 64 B 라인 및 LRU replacement( 역주 : LRU 에 의해서 페이지를 교체하는 정책 )를 가지고 있습니다; 또한 4 개까지의 컴퓨트 유닛으로 구성되어 있는 클러스터 사이에서 공유되며, L2 캐시에 의해 지원됩니다. 스칼라 읽기는 4 개의 64 B long 이며 인접한 스칼라 레지스터를 채웁니다. 그것은 4 개의 뱅크들을 가지며, 4 개의 컴퓨트 유닝에 대해서 사이클당 16 B 의 처리량을 가지고 있습니다. 스칼라 데이터 L1 캐시는 이전 세대에 존재하던 상수 캐시[constant cache]를 대체합니다. 왜냐하면 그것이 좀 더 유연하기 때문입니다.


VECTOR EXECUTION


( 역주 : 이해를 돕기 위해서 이 챕터에서 설명하고 있는 블록을 구분해 봤습니다. 어둡지 않은 부분입니다. VECTOR EXECUTION, VECTOR REGISTER, VECTOR ALUS 챕터는 모두 이 부분에 대해서 설명합니다.


)


GCN 의 엄청난 처리 성능은 고수준으로 병렬화된 SIMD 에서 나옵니다. SIMD 는 범용 목적 애플리케이션과 최신 그래픽스를 위한 계산을 수행합니다. GCN 에서는 더 나은 프로그래밍 가능성과 일관된 성능을 제공하기 위해서 SIMD 가 완전히 점검[overhaul]되었습니다.


이전의 VLIW 컴퓨트 유닛 아키텍쳐는 단일 SIMD 를 가지고 있었지만, 그것들은 단일 웨이브프런트로부터 병렬 연산들을 실행하도록 요구되었습니다. 이는 2 개의 직접적인 결과를 가지고 있었습니다. 첫째, 성능은 컴파일러에 의해서 근본적으로 제한되었습니다; 웨이브프런트 내 병렬성을 띄고 있는 작업들이 적다면 하드웨어가 별로 활용되지 못했습니다. 만약 웨이브프런트 내에 2 개의 명령만이 병렬적으로 실행될 수 있다면, 성능의 절반이 사용되고 있지 않은 것입니다. 두 번째 결과는 컴파일러가 레지스터 파일에 대한 읽기와 쓰기를 주의 깊게 스케줄링해야만 한다는 것입니다; 포트 충돌[port conflict]이 발생하면 하드웨어 활용도가 떨어지기 때문입니다. 잠재적인 포트 충돌을 제거하기 위해서, 웨이브프런트들은 ALU 연산들을 밀집해서[back-to-back] 발행할 수 없었습니다. 그래서 지연을 감추기 위한 웨이브프런트들이 끼워 넣어졌습니다[interleaved]. VLIW 아키텍쳐는 그래픽스를 위해서 상대적으로 좋기는 했지만, 복잡한 작업들에 대해서는 성능 예측이 잘 되지 않았으며 스프트웨어 튜닝을 열심히 해야 했습니다.


GCN 아키텍쳐는 더 단순하며 성능이 훨씬 더 좋습니다. 근본적인 변화는 웨이브프런트 내의 예측불가능한 명령어 수준 병렬화를 피하는 것입니다. 그리고, 하드웨어를 포화상태로 만들기 위해서, 소프트웨어가 적절한 개수의 데이터 병렬 웨이브프런트[data parallel wavefront]들을 공급하도록 하는 것입니다. 이는 GPU 들을 사용하는, 특히 범용 목적의 작업들을 위해 사용하는, 애플리케이션을 위해서 엄청난 이점을 제공합니다.


VECTOR REGISTERS


GCN 아키텍쳐의 가장 큰 이점 중 하나는 더욱 단순하고 고성능을 가진 벡터 레지스터 파일 디자인입니다. 각 SIMD 가 독립적인 웨이브프런트를 실행하고 있기 때문에, 레지스터 파일은 4 개의 독립적인 슬라이스[slice]들로 나뉠 수 있습니다.


벡터 범용 목적 레지스터들 (vGPRs) 은 64 개의 레인[lane]들을 포함하는데, 각 레인은 32 비트 크기까지 될 수 있습니다. 인접한 vGPR 들은 64 비트 혹은 128 비트 데이터들로 조합됩니다. 각 SIMD 는 64 KB 파티션[partition]으로 된 vGPR 들을 가집니다. 그래서 CU 를 위한 전체 레지스터의 개수는 일정합니다. 각 SIMD 파티션은 매우 강력하게 뱅크화되어 있으며, X 레지스터들을 읽고 Y 레지스터들을 쓸 수 있습니다.


레지스터 파일로부터의 대량의 대역폭[bandwidth]은 포트 충돌을 줄여주며, 이는 벡터 ALU 명령들이 SIMD 내에 끼워져 있지 않고 조밀하게 발행될 수 있다는 것을 의미합니다. 이는 컴파일러의 작업을 엄청나게 단순화해주며, 개발자가 고성능의 코드를 작성할 수 있도록 해 줍니다.


VECTOR ALUS


각 SIMD 는 16 개의 레인 벡터 파이프라인을 포함합니다. 이는 예측가능하며 단일 정밀도 및 배정밀도 소수점 연산, 최고 속도 비정규화[full speed denormal], 모든 올림 모드[rounding mode]를 지원하는 IEEE-754 표준과 완전하게 호환됩니다. 각 레인들은 single precision fused or unfused multiply-add 연산이나 24 비트 정수 연산을 자연스럽게 실행할 수 있습니다. 정수 multiply-add 는 작업 그룹[work-group] 내에서 주소를 계산하는데 특히 유용합니다. 웨이브프런트는 단일 사이클에서 SIMD 에 발행되지만, 64 개의 작업 아이템을 위한 연산을 모두 실행하려면 4 사이클이 걸립니다.


GCN 은 벡터 ALU 에다가 새로운 미디어[media] 및 이미지[image] 처리 명령들을 추가했습니다. 특히, 2 개의 새로운 명령들이 존재합니다: 4x1 sum-of-absolute-differnces (SAD) 와 quad SAD 인데, 이는 8 비트 색상을 가진 32 비트 픽셀 상에서 동작합니다. 이러한 새로운 명령들을 사용하면, 컴퓨트 유닛은 사이클당 64 개의 SAD 를 실행할 수 있습니다. 이는 클럭[clock]당 256 개의 연산들을 옮기는[translate] 것입니다. 이 명령들은 매우 강력하며 제스쳐 인식이나 비디오 검색과 같은 최신의 GPU 애플리케이션들을 위해 필수적입니다. 예를 들어 AMD 의 Steady Video 2.0 기술은 새로운 SAD 및 QASD 명령을 사용해 실시간에 녹화되고 스트리밍되는 비디오로부터 흔들림[shakiness]을 제거합니다.


배정밀도 및 32 비트 정수 명령들은 SIMD 내부에서는 감소된 속도로 실행됩니다. GCN 아키텍쳐는 유연하며, 배정밀도 성능은 단일 정밀도 성능에 비해 1/2 에서 1/16 이며, 그에 따라 지연도 증가합니다. 배정밀도 및 32 비트 정수 성능은 특정 GCN 구현에서는 매우 안 좋을 수 있으며, 이는 대상 애플리케이션에 의존하고 있습니다.


64 비트 초월 함수[transcendential function]나 IEEE 나누기와 같은 더 복잡한 명령들은 마이크로 코드[microcode]에 의해 지원됩니다. SIMD 는 개선된 분기 유닛의 이점을 이용해 하드웨어에서 부동소수점 예외[exception]를 제공히기도 하고, 벡터 조건 코드를 위해 스칼라 GPR 을 사용하기도 합니다.


LOCAL DATA SHARE AND ATOMICS


GCN 과 같은 처리량 컴퓨팅 플랫폼에서는, 통신 및 동기화가 고성능을 보장하기 위해 필수입니다. 특히나 최신의 범용 목적 애플리케이션에 대해서는 더욱 그러합니다. 지역 데이터 공유는 명시적으로 주소화된 메모리입니다. 이는 제 3 의 레지스터 파일로서 기능하는데, 특히 작업 그룹내에서의 동기화나 그래픽스를 위한 보간을 위해서 사용됩니다.


Figure 4: Local Data Share (LDS)



GCN 에서 LDS 용량[capacity]은 16 비트나 32 비트 뱅크를 가진 64 KB 로 두 배가 되었습니다( 제품마다 다릅니다 ). 각 뱅크는 512 개의 엔트리들을 포함하는데, 각 엔트리들의 크기는 32 비트 크기입니다. 뱅크는 모든[all-to-all] 크로스바[crossbar]에서 32 비트 값들을 읽거나 쓸수 있으며( 역주 : 크로스바는 특별한 의미는 아닌 것 같고 메모리 상에 중간중간 막대처럼 영역이 배치되어 있어서 크로스바라고 부르는 것 같습니다 ), 32 비트 아토믹 정수 유닛[atomic integer unit]을 포함한 유닛을 스위즐[swizzle]할 수 있습니다( 역주 : 스위즐은 여러 개로 구성된 컴포넌트의 값들을 선택적으로 혼합하는 기능입니다. 예를 들어 rgba 를 rrrr 로 만들수도 있고 abgr 로 만들수도 있습니다 ). 일반적으로 LDS 는 각 사이클에 서로 다른 두 개의 SIMD 로부터 16 개의 레인들을 합칩니다[coalesce]. 그래서 두 개의 웨이브프런트들이 매 4 사이클마다 완료됩니다. 웨이브프런트로부터 온 32 개의 레인들에 대해서 충돌이 자동으로 검출되며 하드웨어 내부에서 리졸브[resolve]됩니다. 같은 뱅크 내에서 서로 다른 요소에 접근하는 명령은 종료되기 위해서 추가적인 사이클을 소비합니다. 브로드캐스트[broadcast]들은 투명[transparent]하며( 역주 : 브로드캐스트하는 방식이 사용자에게 노출되어 있지 않다는 의미인듯. 아마도 비트수를 지정할 필요가 없다는 말이 아닌가 싶음. ), 8, 16, 32 비트 데이터는 어떠한 제약도 없이 벡터 ALU 명령을 위한 입력 피연산자[operand]로서 사용될 수 있습니다.


그래픽스의 경우, LDS 는 텍스쳐 데이터 상에서 최고 속도 보간을 수행하는데 사용하는데 사용되며, 접근 패턴 덕분에 충돌이 발생하지 않는다는 것이 보장됩니다. 범용 목적 컴퓨트 커널의 경우, SIMD 는 LDS 에서 데이터를 로드하고 저장할수 있습니다. 이를 통해 산란[scatter] 혹은 수집[gather] 접근을 사용하여 캐시 계층을 오염시키는 것을 피할 수 있습니다. 혹은 이를 사용해 캐시 대역폭을 증폭시킬 수도 있습니다. 추가적으로, 아토믹 유닛들은 작업 그룹 내부에서 고성능 추론[reduction]을 사는 데 있어 필수적입니다. 그리고 그것은 부동소수점 최대[max], 최소[min], 그리고 비교[compare] 및 교환[swap] 연산들을 수행할 수 있습니다.


LDS 명령들은 주소[address], 2 개의 데이터 값들, 그리고 목적지[destination]를 사용합니다. 주소는 vGPR 로부터 오고, 목적지는 vGPR 이거나 LDS 를 직접적으로 읽고 있는 SIMD 일수 있습니다. 2 개의 데이터 값들은 L1 데이터 캐시 ( LDS 에 저장하기 위해 ) 나 vGPR ( 로드하거나 저장하기 위해 ) 로부터 올 수 있습니다. 다른 디자인들과는 다르게, 전용 파이프라인은 LDS 를 레지스터에 로드하는 것과 같은 데이터 이동을 위해서 벡터 ALU 명령을 사용하지 않습니다.


EXPORT


익스포트 유닛은 고정 함수 그래픽스 하드웨어와 전역 데이터 공유 ( GDS ) 에 대한 컴퓨트 유닛의 윈도우[window]입니다. 모든 계산이 종료되었을 때, 일반적으로 그 결과는 그래픽스 파이프라인의 다른 유닛들로 전달됩니다. 예를 들어 픽셀이 셰이딩되고 나면, 그것들은 일반적으로 디스플레이에 대한 최종 출력 이전에 깊이 테스트와 스텐실 테스트, 그리고 블렌딩을 위한 렌더 백엔드[render back-end]로 전달됩니다. 익스포트 유닛은 결과들을 그래픽스 파이프라인의 프로그래밍 가능한 스테이지로부터 테셀레이션[tessellation] 스테이지나 래스터화[rasterization] 스테이지, 그리고 렌더 백엔드와 같은 고정 함수 스테이지로 전달됩니다.


GDS 는 지역 데이터 공유와 동일합니다. 단지 그것이 모든 컴퓨트 유닛에 의해 공유될 수 있어서 모든 웨이브프런트들 사이에서 명시적인 전역 동기화 지점으로서 기능한다는 차이가 있습니다. GDS 의 아토믹 유닛들은 좀 더 복잡하며 순서있는 카운트 연산들을 다룰 수 있습니다.


VECTOR MEMORY


While the SIMDs in each Compute Unit can execute 128 floating point operations per clock cycle, delivering enough bandwidth to match the impressive computational resources. GCN 에서 거의 대부분의 중요한 변화는 의심할 여지가 없이 캐시 계층에 존재합니다. 이는 그래픽 디자인에 매우 특화되어 있는 구조에서 고성능 및 프로그래밍 가능한 계층 구조로 변경되었습니다. 이 구조는 범용 목적 애플리케이션과도 잘 들어 맞고, x86 프로세서와의 통합을 위해서 준비되었습니다. 이 변화들은 컴퓨트 유닛들 내부에서 시작되었지만, 전체 시스템을 통해 확장되었습니다.


GCN 메모리 계층은 가상 메모리 지원과 훌륭한 아토믹 연산 성능을 가진 통합된[unified] 읽기/쓰기 캐싱 시스템입니다. 이는 이전 세대에서 사용되었던 독립된 읽기 캐싱 및 쓰기 버퍼링 패스[path]에 대한 중대한 개선이 있었음을 보여 줍니다. 벡터 메모리 연산들은 주소, 데이터를 위한 다양한 처리량[granularity]을 지원하는데, 이것은 32 비트부터 128 비트 쿼드[quad]까지의 범위를 가집니다.


Figure 5: Vector Memory


L1 데이터 ( L1D ) 캐시는 16 KB 이며 4-way set associative 인데, 64 B 라인들과 LRU replacement 를 가지고 있습니다. 이는 L2 및 다른 캐시들처럼 극단적으로 느슨한[relaxed] 일관성[consistency] 모델이라는 점에서 일관됩니다. 개념상으로는 L1D 캐시는 L2 캐시를 통한 궁극적인 전역 일관성과 함께 작업 그룹 일관성을 가집니다. L1D 는 더티 바이트 마스크[dirty byte mask] 를 사용하는 write-through, write-allocate 디자인을 가지고 있습니다( 역주 : write-through, write-allocate 는 캐시 쓰기 정책입니다. "문c 블로그"의 [ Cache -Policies ] 에 잘 정리되어 있습니다 ). 캐시라인은 웨이브프런트 명령의 모든 64 ( 역주 : 64 개의 레인 ) 가 저장을 완료할 때 L2 에 다시 써집니다. 더티 데이터를 가진 라인들도 역시 L1D 에 유지됩니다. 반면에 부분적으로 깨끗한 모든 라인은 L1D 캐시에서 추방됩니다. 특별한 일관성 로드 명령도 존재합니다. 그것은 L2 캐시로부터 불러와[fetch] 가장 최근의 값이 사용되었음을 보장합니다( 역주 : 실제로 사용한게 아닌데 사용했다고 우선수위를 높이는듯? ).


AGU 가 합병된 주소[coalesced address]를 계산하고 나면, 그 요청은 L1D 캐시 태그들을 탐색합니다. 맞는게 존재하면[ On a hit], 그 캐시는 전체 64 B 라인을 읽어 냅니다. 완전히 합병된 요청들을 사용하면, 이것은 웨이브프런트의 16 개의 데이터 값 혹은 1/4 와 연관됩니다. 비록 낮은 지역성[poor locality]로 인해 부가적인 사이클이 필요하긴 합니다. 계산 작업을 위해 캐시 라인은 vGPR 나 LDS 에 작성됩니다.


L1D 캐시에 저장하는 것은 약간 더 복잡합니다. 쓰기 데이터는 적절한 저장소 포맷[storage format]으로 변환되어야 하며, 그리고 나면, 맞는 캐시를 찾고 궁극적으로 L2 캐시로 쓰기를 하기 전에, 쓰기 주소가 가능하면 새로운 독립된 트랜잭션으로 합쳐집니다.


만약 메모리 요청이 L1D 캐시에서 실패하면, 그것은 통일되고 일관성 있는 L2 캐시로 전달됩니다. 이것은 셰이더 코어의 외부에 존재하며 메모리 컨트롤러들과 연관됩니다.


효율성을 증진시키고 부하를 줄이기 위해서, 약간 전용 하드웨어를 사용하는 유연한 메모리 계층이 그래픽스를 위해 재사용됩니다. 주소 생성 유닛은 사이클당 4 개의 텍스쳐 주소를 받습니다. 그리고 나서 16 개의 샘플링 주소를 가장 가까운 이웃을 위해 계산합니다. L1 데이터 캐시로부터 샘플들이 읽혀지고 텍스쳐 매핑 유닛[Texture Mapping Unit]( 혹은 TMU )에서 압축이 해제됩니다. 그리고 나서 TMU 는 클럭당 가능한 한 4 개의 보간된 텍셀[texel]을 산출하기 위해서 인접 샘플들을 필터링합니다. TMU 출력은 원하는 포맷[desired format]으로 변환되며 궁극적으로 이후의 사용을 위해 벡터 레지스터에 작성됩니다. 어떤 값들을 그래픽스 커널의 메모리로 쓰기위해서 사용되는 포맷 변환 하드웨어[format conversion hardware]도 존재합니다.


L2 CACHE, COHERENCY AND MEMORY


GCN 에서 분산된 L2 캐시는 GPU 에서 일관성의 핵심입니다. 그것은 읽기 전용 L1 명령들과 스칼라 캐시들을 위한 후방 방어벽[backstop]으로 기능합니다. 이는 CU 클러스터들에 의해 공유되며, 모든 CU 내의 L1 데이터 캐시에 의해서도 공유됩니다. L2 캐시는 물리적으로는 메모리 채널들과 쌍을 이루는 슬라이스로 구분됩니다. 그리고 컴퓨트 유닛에서 캐싱된 메모리 파티션까지 이어진 크로스바 섬유[crossbar fabric]를 통해서 흐름에 접근합니다.


Figure 6: Cache Hierarchy


L1 데이터 캐시와 마찬가지로 L2 는 가상으로 주소화되어 있습니다. 그래서 TLB( 역주 : Translation Lookaside Buffer. 가상주소를 물리 주소로 변환하는 캐시 ) 가 전혀 필요하지 않습니다. L2 캐시는 16-way associative 이며, 64 B 캐시 라인과 LRU replacement 를 가집니다. 그것은 write-back, write-allocate 디자인이며, 그래서 L1 데이터 캐시에서의 모든 쓰기 실패[write miss]를 흡수합니다. 각각의 L2 슬라이스는 64 KB 에서 128 KB 이며 L1 캐시들에 64 B 캐시 라인을 전송할 수 있습니다.


일관성 L2 캐시의 가장 큰 이점중 하나는 그것이 전역 아토믹 연산을 실행하고 서로 다른 웨이브프런트 사이에서 동기화를 하기 위한 가장 자연스런 위치라는 점입니다. 웨이브프런트 내부의 아토믹 연산을 위해서 LDS 가 사용될 수 있지만, 어떤 지점에서는, 서로 다른 웨이브프런트들로부터의 결과들이 합쳐질 필요가 있습니다. 이것이 정확히 L2 가 활동하게 되는 위치입니다. L2 슬라이스는 각 사이클에 캐시 라인에 대해 가능한 한 16 개의 아토믹 연산들을 실행할 수 있습니다.


GCN 의 전체적인 일관성 규약은 하이브리드 모델입니다. 이는 GPU 의 엄청난 성능과 대역폭을 전통 CPU 의 프로그래밍 가능성과 함께 녹여냅니다. 관점이 미래의 통합을 향해 있습니다. 개념상으로는 L1 데이터 캐시는 작업 그룹 내의 지역 접근을 위한 엄격한 일관성을 유지합니다. 웨이브프런트의 작업이 끝나는 시점이나 배리어가 실행되면, 그 데이터는 L2 로 작성되며 GPU 전체에 대해 전역적인 일관성이 됩니다.  이 모델은 종종 이완된 일관성[relaxed consistency]로서 설명되며 많은 이점을 가지고 있습니다. 수많은 지역 접근들은 낮은 부하와 높은 성능을 가지게 되지만, L2 는 프로그래머 친화적인 일관성을 제공합니다.


똑같이 중요한 것은, 캐시 계층이 x86 마이크로 프로세서들과 통합하기 위해서 설계되었다는 것입니다. GCN 의 가상 메모리 시스템은 4 KB 페이지들을 지원할 수 있으며, 이것은 x86 주소 공간을 위해 자연스러운 매핑 단위[granularity]입니다 - 미래의 공유된 메모리 공간을 위한 길을 연 것입니다. 사실 DMA( 역주 : Direct Memory Access, CPU 를 통하지 않고 메모리에 직접 접근하는 것 ) 전송을 위해 사용되는 IOMMU 가 이미 x86 주소 공간으로의 요청을 변환하여 데이터를 GPU 에 옮기는 데 도움을 주고 있으며 이 기능은 시간이 지날 수록 발전할 것입니다. 추가적으로, GCN 에서 캐시들은 64 B 라인들을 사용하는데, 이것은 x86 프로세서들에서의 크기와 같습니다. 이는 GPU 와 CPU 사이에서 전통적인 캐싱 시스템을 통해서 프로그래머가 명시적으로 제어하지 않고도 투명하게 데이터를 공유하기 위한 ( 이종 시스템들을 위한 ) 단계를 설정합니다.


메모리 컨트롤러는 GPU 들을 서로 묶으며 시스템의 거의 모든 파트들에 데이터를 제공합니다. 커맨드 프로세서, ACE, L2 캐시, PBE, DMA 엔진, PCI Express, 비디오 가속기, 크로스파이어 상호연결[Crossfire interconnection], 디스플레이 컨트롤러 등은 모두 지역 그래픽스 메모리에 대한 접근을 합니다. 각각의 메모리 컨트롤러들은 64 비트 크기이며, 두 개의 독립적인 32 비트 GDDR5 메모리 채널로 구성되어 있습니다. 저가 제품의 경우에는 GDDR5 가 DDR3 로 대체될 수도 있습니다. 메모리 처리량을 늘리기 위해서, GDDR5 컨트롤러는 채널당 두 개의 DRAM 을 사용하여 용량을 뻥튀기하기 위해 clamshell mode 에서 동작할 수도 있습니다.


PROGRAMMING MODEL


GPU 의 성능은 애플리케이션 프로그래밍 인터페이스[Application Programming Interface]( API )를 통해 발휘됩니다. 이는 개발자들을 위한 일관된 인터페이스를 제공하는 추상화입니다. 결정적으로, API 는 다양한 GPU 패밀리들 뿐만 아니라 하드웨어 세대들과 소프트웨어를 넘나 드는 호환성을 제공합니다. 그 목적은 프로그래머가 기반 하드웨어에 대한 걱정을 하지 않고 애플리케이션에만 집중할 수 있게 하는 것입니다.


업계 표준을 만족시키기 위해서 새로운 GCN ISA 가 디자인되었습니다. AMD 의 이종 시스템 아키텍쳐[Heterogeneous System Architecture] ( HSA ) 는 이종 컴퓨팅을 위한 모델로서 구상되었습니다. 그것은 CPU 와 GPU 가 통신하는 방식을 정의하며, 가상 ISA ( HSA 중간 언어[Intermediate Language] ) 를 포함합니다. 이것은 하드웨어마다 구현이 다릅니다. HSAIL 코드는 기반 하드웨어에서 동적으로 컴파일됩니다( 아마도 Vulkan 의 spir-v 같은 느낌인듯 합니다 ). 즉 모든 벤더의 CPU 와 GPU 에 대해 호환됩니다. GCN 은 HSAIL 에 대한 완벽한 지원을 제공하며, 그 이유는 부분적으로는 더욱 유연한 명령어 집합[instruction set]으로 전환하기 위함입니다.


GCN 은 산업 표준 컴퓨트 API 들에 애한 완벽한 지원을 합니다. 특히 OpenCL[TM] 1.2, DirectCompute 11.1, C++ AMP 와 호환되는 첫 번째 GPU 입니다. 이러한 표준들을 사용함으로써, 프로그래머들은 모든 운영체제를 대상으로 하고 다양한 하드웨어 상에서 호환되는 애플리케이션을 작성할 수가 있습니다. 이러한 산업 표준들은 개발자를 위해 성공적인 장기 로드맵[long term loadmap]을 보장해 주며, 불확실한 미래에 독점 프로그래밍 모델이나 하드웨어 플랫폼을 피하기 위해서 필수적입니다.


그래픽스 영역에서, GCN 은 DirectX 11.1 을 위해 준비되었으며, 이는 Windows[(R)] 8 에 소개될 것입니다. 그리고 세 가지 특별한 핵심 기능을 포함합니다. 첫 번째는 타깃-독립적인 래스터화입니다. 이것은 2D 이미지들을 위한 래스터화를 고정 함수 그래픽스 하드웨어로 이동시킵니다. 이는 CPU 로부터 2D 안티 에일리어싱[anti-aliasing]을 없애버리며, 동일한 이미지 품질을 제공하면서 전력을 줄이게 됩니다. 두 번째 변경은 범용 목적 데이터 배열 ( Unordered Access View 라 불립니다 ) 들이 6 가지 유형의 프로그래밍 가능한 셰이더와 비디오 가속을 위해서 이용될 수 있다는 것입니다. 이는 전체 GPU 프로그래밍 가능성을, 컴퓨트와 픽셀 셰이더로 국한하는 것이 아니라, 그래픽스와 비디오 API 를 통해서 확장합니다. 마지막으로 DirectX[(R)] 11.1 은 Stereo 3D 를 위한 표준 API 를 포함합니다. 이는 특정 벤더용 미들웨어나 서드 파티 미들웨어의 단편화된 에코시스템[ecosystem]들을 대체합니다.


GCN 에는 범용 목적 프로그래밍 가능성과 관련한 매우 중요한 개선점들이 많이 존재합니다만, 그것들은 그래픽스의 문맥에서도 유리합니다. 예를 들어, 개발자는 Partially Resident Texture (PRT) 를 사용하여 거의 무한대의 텍스쳐 디테일을 가진 월드를 생성할 수 있습니다. GPU 의 가상 메모리 시스템은 메모리에 필요한 부분만을 로드합니다. GCN 에서는 32 TB 까지의 가상 메모리가 텍스쳐를 위해 사용될 수 있습니다. 이것이 실제 가용 메모리를 줄이기 때문에, 드라이버와 GPU 는 텍스쳐를 64 KB 타일 단위로 필요한 만큼 페이징합니다. 이 절차는 투명하며( 역주 : 프로그래머가 제어할 수 없다는 말임 ), 프로그래머가 명시적으로 데이터를 메모리에 프리로딩해야 하는 수고를 덜어 줍니다. PRT 는 id Tech 5 와 같은 선도적인 멋진 게임 엔진들에서 이미 사용중이며, 차세대의 게임 엔진들과 히트한 게임들에서 일반적이 될 것입니다.


SYSTEM ARCHITECTURE


궁극적으로, GCN 은 2-3 W 로 제한되는 태블릿에서 전체 건물을 채우는 슈퍼컴퓨터에 이르기까지 다양한 종류의 제품에서 사용될 것입니다. 이는 성능 및 파워의 스펙트럼이 100 배 정도 차이가 남을 보여주며, 극단적으로 다른 환경들을 보여 줍니다. 고성능 GPU 들은 일반적으로 전용 고 대역폭 메모리 서브시스템을 가지고 있으며 크로스파이어로 연결되어 작동할 수도 있습니다. 여기에서 렌더링은 최대 성능을 위해 다중 GPU 들 사이에서 분할됩니다. 반대로 고도로 통합되어 있는 태블릿이나 PC 를 위한 System-On-Chip 은 CPU, GPU, 그리고 다른 On-die 컴포넌트 사이의 메모리 컨틀롤러를 공유할 것입니다. 이러한 다양성을 다루기 위해서, GCN 의 시스템 아키텍쳐는 범용 목적 셰이딩 코어들과 고정 함수 그래픽스 하드웨어에서의 범용성과 확장성을 위해 재설계되었습니다.


GCN 은 호스트 프로세서와의 인터페이싱을 위해서 PCI Express[TM] 3.0 을 사용하는 첫 번째 아키텍쳐입니다. AMD Radeon[TM] HD 7970 같은 GCN 기반 독립 GPU 들은 16 배의 링크를 사용하는데, 이것은 32 GB/s 의 대역폭을 제공합니다. 이 CPU to GPU 인터페이스는 범용 목적 작업에서는 특히 병목이 됩니다. 여기에서 거대 데이터 집합들은 두 프로세서 사이에서 움직입니다. GCN 은 두 개의 양방향 DMA 엔진들을 가지고 있어서, 두 개의 데이터 스트림들이 동시에 PCI Express[TM] 3.0 링크의 모든 방향을 사용할 수 있으며 효율적으로 가용 대역폭을 이용합니다.


DMA 엔진들은 x86 마이크로 프로세서에 대한 AMD 의 경험이 성과를 올린 영역입니다. GCN 은 I/O 메모리 관리 유닛[Memory Management Unit] (IOMMU) 를 포함하는데, 이는 GPU 를 위한 x86 주소를 투명하게 매핑할 수 있습니다. 이는 GCN 의 DMA 엔진들이 페이지화가 가능한 CPU 메모리에 쉽게 접근할 수 있으며, 데이터를 이동시키기 위해서 주소 변환을 하는 부하를 가지고 있지 않음을 의미합니다. IOMMU 는 이종 시스템 통합을 더욱 강하게 하는 단계이며 시간이 지날 수록 발전할 것입니다.


GCN 커맨드 프로세서는 고수준 API 커맨드를 드라이머로부터 받아서 다양한 처리 파이프라인으로 그것들을 매핑할 책임이 있습니다. GCN 에는 두 개의 주요 파이프라인들이 존재합니다. 비동기 컴퓨트 엔진[Asynchronous Compute Engine] (ACE) 은 컴퓨트 셰이더를 관리하며, 반면에 그래픽스 커맨드 프로세서는 그래픽스 셰이더와 고정 함수 하드웨어를 다룹니다. 각 ACE 는 커맨드의 병렬 스트림을 다룹니다. 그리고 그래픽스 커맨드 프로세서는 각 셰이더 유형을 위한 개별 커맨드 스트림을 가질 수 있으며, GCN 의 멀티 태스킹의 이점을 살리기 위해서 풍부한 작업을 생성하게 됩니다.


스케줄링은 GCN 이 산업을 발전시키고 있는 또 다른 영역입니다. 멀티 태스킹을 위해서는 가상 메모리가 필요하며, 그래서 메모리에 대한 경쟁적인 요청을 하는 애플리케이션들이 안전하게 동시에 존재할 수 있습니다. 멀티 태스킹은 1980 년대 이래로 컴퓨터에서 일반적이며, 프로그래머의 생산성을 위해서 매우 가치가 있습니다. GCN 은 기본적인 기반[infrastructure]을 생성하고 고수준으로 병렬화된 GPU 의 활용도와 효율성을 증신시키기 위해서 멀티 태스킹을 사용합니다.


ACE 는 모든 컴퓨트 셰이더 스케줄링과 리소스 할당을 다룹니다. 제품은 다수개의 ACE 를 가질 수 있으며, 그것들은 성능의 관점에서 확장하거나 축소하기 위해서 서로 독립적으로 동작하게 됩니다. 각 ACE 는 캐시나 메모리로부터 커맨드를 불러와서 태스크 큐를 형성합니다. 이는 스케줄링의 시작점입니다. 각 태스크들은 스케줄링을 위해 background 로부터 real-time 까지의 우순위 레벨을 가지고 있습니다. ACE 는 가장 높은 우선순위 태스크에 대한 하드웨어 요청을 검사하여, 리소스를 충분하게 이용할 수 있을 때 그 태스크를 GCN 셰이더 배열로 보낼[dispatch] 것입니다.


많은 태스크들이 동시에 실행될 수 있습니다; 이 제한은 하드웨어 리소스에 의해서 많아지거나 적어질 수 있습니다. 태스크는 순서없이[out-of-order] 완료되며, 이는 리소스를 일찍 릴리스하지만, 정확성을 위해서는 ACE 에서 트래킹되어야만 합니다. 태스크가 GCN 셰이더 배열에 보내질 때, 그것은 여러 개의 작업 그룹으로 분할되는데, 작업 그룹들은 실행을 위해서 개별 컴퓨트 유닛들로 보내질 수 있습니다. 매 사이클마다, ACE 는 작업그룹을 생성하고 작업그룹에서 하나의 웨이브프런트를 컴퓨트 유닛으로 보냅니다.


ACE 는 보통 독립적인 방식[fashion]으로 연산을 수행하지만, 그것들은 캐시, 메모리, 혹은 64 KB 전역 데이터 공유를 사용하여 동기화하거나 통신할 수 있습니다. 이것은 ACE 가 실제로는 태스크 그래프를 형성할 수 있다는 것을 의미하며, 여기에서 개별적인 태스크들은 서로에 대한 의존성을 가지게 됩니다. 그래서 실제로는, ACE 안의 태스크는 다른 ACE 나 그래픽스 파이프라인의 태스크들에 의존하고 있을 수 있습니다. 예를 들어, 현재 실행중인 태스크 그래프가 의존성때문에 그래픽스 파이프라인으로부터의 입력을 위해 대기하고 있다면, ACE 는 스케줄링될 준비가 되어 있는 다른 태스크 큐로 전환할 수 있습니다. ACE 는 이전 태스크와 연관된 모든 작업그룹을 플러싱할 것이고, 그리고 나서 새로운 태스크로부터의 작업그룹을 셰이더 배열에 발행할 것입니다.


GRAPHCIS ARCHITECTURE


그래픽스 커맨드 프로세서는 전통적인 렌더링 파이프라인을 형성합니다. 3D 파이프라인은 여러 유형의 프로그래밍 가능한 셰이더들( 예를 들어, vertex, hull, domain, geometry, pixel shader )과 삼각형과 픽셀을 조작하는 다양한 고정함수 하드웨어로 구성되어 있습니다. 셰이더 성능은 매우 가변적입니다. 그러므로 진짜 문제는 고정 함수 하드웨어를 확장하는 것입니다.


3D 렌더링은 클럭 당 단일 삼각형을 조립[assemble]하는 프리미티브 파이프라인에서 시작합니다. GCN 에서 프리미티브 파이프라인의 개수는 성능 요구사항과 연관되어 매우 다양합니다. 다중 프리미티브 파이프라인은 화면 공간을 분할하고 올바른 삼각형 순서를 유지하기 위해 동기화에 의존합니다. 그리고 조립된 삼각형은 버텍스 셰이딩과 헐[hull] 셰이딩을 위한 셰이더 배열로 전달됩니다. 헐 세이더는 버텍스 좌표를 테셀레이션 좌표로 옮기고 제어 정보를 설정합니다.


프리미티브 파이프라인은 도메인을 2-64 개의 더 작은 오브젝트로 실제로 분할하는 테셀레이션 스테이지를 위한 고정 함수 하드웨어도 포함하고 있습니다. GCN 의 테셀레이터는 이전 세대보다 4 배까지 빠릅니다. 훨씬 느린 off-chip 메모리 대신에 일관성 L2 캐시를  더 큰 파라미터 캐시를 이용할 수 있는 더 큰 파라미터 캐시를 사용합니다. 테셀레이션 이후에는 도메인 셰이더가 오는데, 이것은 분할된[tessellated] 출력을 표준 버텍스로 변환하고 그 결과를 지오메트리 셰이더로 넘깁니다.


다른 주요 그래픽스 함수들은 픽셀 파이프라인에 집중되어 있습니다. GCN 픽셀 파이프라인들은 스크린 공간을 독립적인 타일로 분할함으로써 확장됩니다. 버텍스를 픽셀로 변환하는 실제 레스터화가 첫 번째 작업입니다. 각 래스터라이저는 사이클당 단일 삼각형을 읽어들일 수 있으며, 16 개의 픽셀을 쓸 수 있습니다. 나중에 계층적인 Z 테스트를 통해서 이전 픽셀 셰이딩에 의해서 가려진 픽셀들을 제거하게 됩니다.


타일 안에서 조각화된[fragmented] 픽셀들이 셰이딩되고 나면, 그것들은 렌더 백엔드[Render Back-End] (RBE) 로 흘러들어 갑니다. RBE 는 뎁스 테스트, 스텐실 테스트, 알파 테스트를 적용하여 픽셀 조각이 최종 프레임에서 가시화되어야 하는지를 결정합니다. 그리고 나서 가시화된 픽셀 조각들은 커버리지[coverage] 와 컬러[color]를 샘플링해 최종 출력 픽셀을 생성합니다. GCN 의 REB 는 16 KB 컬러 캐시로부터 픽셀당 8 개 까지의 컬러 샘플들( 예를 들어 8x MSAA )에, 4 KB 의 뎁스 캐시로부터 픽셀당 16 개의 커버리지 샘플들( 예를 들어 16x EQAA 까지 )에 접근할 수 있습니다. 컬러 샘플들은 최종 안티 에일리어싱 픽셀 컬러를 생성하기 위해서 커버리지 샘플들에 의해서 결정되는 가중치[weight]들을 사용해 블렌딩될 수 있습니다. 그 결과는 메모리 컨트롤러들을 통해서 프레임 버퍼로 작성됩니다.


그래픽스 파이프라인은 ACE 와 동일한 기술 집합을 사용해서 조직되었습니다. 3D 파이프라인의 각 스테이지들은 ACE 처럼 동시에 실행될 수 있습니다. 프리미티브 및 픽셀 파이프라인들은 크로스바 섬유를 통해서 프로그래밍 가능한 GCN 셰이더에 연결됩니다. 그 태스크 큐는 다른 셰이더와 고정 함수 하드웨어에 대해 캐시나 메모리를 통해 동기화됩니다.


The advantage of GCN's flexibility is evident in the first few products that have scaled across all four dimensions. AMD Radeon[TM] HD 7970 은 스크린을 2 개의 프리미티브 파이프라인과 4 개의 픽셀 파이프라인으로 분할합니다. 그리고 셰이딩을 위해서 32 개의 컴퓨트 유닛과 384 비트의 메모리 인터페이스를 사용합니다. GCN 픽셀 파이프라인들은 2 개의 REB 와 3 개의 메모리 컨트롤러로 조직화되어, 메모리 대역폭에서 50 % 의 향상이 있었습니다. 반대로, AMD Radeon[TM] 7770 GHz 에디션은 단일 프리미티브 파이프라인, 2 개의 픽셀 파이프라인, 10 개의 컴퓨트 유닛을 가지고 있습니다. 7770 에디션에서의 픽셀 파이프라인들은 2 개의 메모리 컨트롤러로 축소되었으며, 128 비트 크기의 인터페이스를 사용합니다.


Figure 7: AMD Radeon[TM] HD 7970


Figure 8: AMD Radeon[TM] HD 7870 GHz Edition


Figure 9: AMD Radeon[TM] HD 7770 GHz Edition


PROGRAMMABLE VIDEO PROCESSING


범용 프로그래밍 가능성은 전체 GCN 아키텍쳐 사이에서 최 우선순위입니다. 특히 비디오 처리가 그러합니다. 비디오의 유일한 특징중 하나는 그것들이 매우 특정된[specific] 데이터 유형이며 고정 함수 하드웨어의 이점을 취할 수 있는 많은 기회가 존재한다는 것입니다. 비디오 전용 UVD3 디코더 엔진이 MPEG-4 와 DivX 포맷을 지원하기 위해서 추가되었으며, 이와 함께 Multi-View Codec 이 추가되어 Stereo 3D 와 HD picture-in-picture  디스플레이에서 사용됩니다.


Figure 10: Hybrid Video Encoding


GCN 은 전력 효율성을 가진 비디오 코덱 엔진[Video Code Engine] ( VCE ) 을 통합했는데, 이는 60 frames/sec 로 1080p 를 재생할 수 있는 H.264 인코딩에 대한 완벽한 하드웨어 구현을 포함합니다. 그러나 VCE 는 프로그래밍을 고려해 두고 명시적으로 설계되었습니다 - VCE 의 엔트로피 인코딩 블록[entropy encoding block] 은 소프트웨어에 완전하게 접근할 수 있습니다. AMD 의 Accelerated Parallel Programming SDK 와 OpenCL[TM] 을 사용하는 개발자는 custom motion estimation, inverse discrete cosine transform, motion compensation 을 하드웨어 엔트로피 인코딩과 함께 사용하는 하이브리드 인코더를 생성하여 실시간 인코딩보다 더 빠른 결과를 얻을 수 있습니다.


RELIABLE COMPUTING


범용 목적 컴퓨팅은 어느 정도 수준의 신뢰성을 요구하는데, 이는 전통적인 그래픽스 월드보다는 훨씬 큽니다. 하지만 AMD 에게는 매우 친숙합니다. 2003 년 이후로 AMD 의 Opteron 프로세서들은 세계에서 가장 큰 슈퍼 컴퓨터와 업무 전용 시스템[mission critical system] 들의 일부로서 사용되어 왔습니다. 이런 시스템들은 고장시간[downtime]과 데이터 손상[corruption]은 허용하지 않습니다. AMD 는 매우 신뢰도 있는 디자인을 하는 경험을 했고, 서버 룸, 슈퍼 컴퓨터, 단일 워크스테이션, 게이밍 노트북 등의 어떠한 시스템에서라도 적용이 가능한 제품을 생성하기 위해서 GCN 아키텍쳐에 그 경험을 적용했습니다. GCN 아키텍쳐의 중요한 목표는 신뢰성이지만, 보증되지 않은 상황에서의 부하는 피해야 합니다.


현대 칩들에서의 가장 큰 신뢰성은 on-chip 메모리에서의 soft errors ( 혹은 bit-flips ) 입니다. AMD Radeon[TM] HD 7970 과 같은 고성능 GPU 는 12 MB 가 넘는 SRAM 과 CU 및 캐시들을 통해 퍼져 있는 레지스터 파일들을 가지고 있습니다. GCN 은 single error correct 와 double error detect ( SECDED ) 를 모든 on-chip 메모리를 위해 완벽히 다룰 수 있도록 설계되었습니다. 이는 면적과 전력 측면에서 약간의 부하를 가지고 있습니다만, 정확하고 신뢰성있는 결과가 필수적인 전문 애플리케이션에서는 별로 중요하지 않을 수 있습니다.


두 번째 신뢰성 문제는 외부 메모리입니다. GDDR5 는 산업 표준에 기반하고 있으며 매우 다양한 서드 파티들에 의해서 제조되고 있습니다. 표준 GDDR5 메모리 인터페이스는 CRC 를 사용해서 전송된 데이터를 검사하고 손상된 데이터를 반송합니다. 그러나 ECC 는 존재하지 않습니다. 즉 DRAM 이 가지고 있는 데이터가 soft error 에 의해서  손상되었는지 여부를 알 방법이 없습니다.


GCN 메모리 컨트롤러는 SECDED 를 DRAM 에 적용하는 선택적인 모드를 가지고 있습니다. ECC 로 보호되는 데이터는 약 6% 정도 커지는데, 이는 전체적인 메모리 용량과 대역폭을 약간 감소시킵니다.


아키텍쳐로서 GCN 은 전문적인 제품이나 소비자 제품을 위해서 커스터마이징이 가능합니다. 전문적인 애플리케이션들은 최소한의 성능 저하와 함게 최대한의 신뢰성을 제공하기 위해서 on-chip SRAM 과 선택적인 외부 DRAM 을 위한 ECC 보호의 이점을 취할 수 있습니다. ECC 를 지원하는 CPU 제품은 그래픽스 드라이버를 통해 그것을 활성화하거나 비활성화할 수 있습니다.


CONCLUSION


( 역주 : 그냥 내용 요약이 아니라 AMD 자랑이라서 번역이 귀찮아졌네요. 건너 뜁니다 )


AMD's GCN Architecture comes at a time of change for the industry. Graphics is an increasingly important part of the user experience, and a crucial component for SoCs that integrate CPUs and GPU side-by-side. The mandate for GPUs is expanding to include not just 3D rendering, but new general purpose, heterogeneous applications such as facial recognition, which are only feasible using the parallel performance of the GPU.


As a company, AMD is uniquely positioned with deep expertise and a long history of excellence in both CPUs and GPUs for the PC. GCN is a marriage of these domains, melding the reliability and programmability of traditional CPUs with the efficient parallel performance of a GPU.


GCN is huge step forward, firmly placing AMD in the new era of heterogeneous computing, but without losing sight of efficiency or performance. The GCN Architecture encompasses innovations such as virtual memory, coherent caching and an elegant hybrid vector/scalar instruction set that are revolutionary. At the system level, GCN is the only discrete graphics architecture that is compatible with the x86 programming model, paving the way for future software and hardware integration.


Most importantly, AMD has carefully crafted an architecture that is a tremendous advance in programmability, but does not sacrifice performance or efficiency. Features such as a unified instruction stream and scheduling in the scalar pipelines enhance utilization and boost the achievable performance on real workloads. The first GPUs manufactured on 28nm were based on GCN and improved performance/watt and performance/mm2 by roughly 50% over the prior generation. The AMD Radeon™ HD 7970 GHz Edition achieves peak performance of over 1 TFLOPS double precision and 4 TFLOPS single precision, a testament to the underlying architecture. GCN will eventually revolutionize AMD's entire product line, from tablets to supercomputers.


DISCLAIMER


The information presented in this document is for informational purposes only and may contain technical inaccuracies, omissions and typographical errors. AMD reserves the right to revise this information and to make changes from time to time to the content hereof without obligation of AMD to notify any person of such revisions or changes.


AMD MAKES NO REPRESENTATIONS OR WARRANTIES WITH RESPECT TO THE CONTENTS HEREOF AND ASSUMES NO RESPONSIBILITY FOR ANY INACCURACIES, ERRORS OR OMISSIONS THAT MAY APPEAR IN THIS INFORMATION.

AMD SPECIFICALLY DISCLAIMS ANY IMPLIED WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR

PURPOSE.


IN NO EVENT WILL AMD BE LIABLE TO ANY PERSON FOR ANY DIRECT, INDIRECT, SPECIAL OR OTHER CONSEQUENTIAL DAMAGES ARISING FROM THE USE OF ANY INFORMATION CONTAINED HEREIN, EVEN IF AMD IS EXPRESSLY ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.

한 동안 뜸했습니다.


개인적으로 진행하는 VulkanMonkey 라는 프로그램의 UI( WPF ) 에서 사용할 노드그래프 라이브러리를 만드느라 한 달 가까이 시간을 소비했네요.



GitHub 의 [ lifeisforu/NodeGraph ] 에 MIT 라이선스로 공개해 놨으니 필요한 분들은 사용하시면 될 것 같습니다. NUGET 에 [ Lifeisforu.NodeGraph ] 라는 항목으로도 등록되어 있습니다.


한글 소개 링크는 다음과 같습니다 : [ https://github.com/lifeisforu/NodeGraph/wiki/An-introduction-for-WPF-NodeGraph(-Koeran-) ]


문서화가 많이 안 되어 있는데, 차근차근 추가해 갈 계획입니다. 


구현을 하는 데 있어 리플렉션이 워낙 많이 들어 가기 때문에 WPF 가 아닌 UI 언어로 이런 걸 제작하는 건 상상도 못하겠군요. 어쨌든 WPF 사용하는 분들에게는 도움이 되었으면 하네요.


Frostebite 가 GDC 2017 에서 발표한 [ FrameGraph: Extensible Rendering Architecture in Frostbite ] 에 영감을 받아서 이를 위해 UI 라이브러리를 구현하는데, 완성도를 높이려고 하다가 보니 배보다 배꼽이 더 큰 상황이 되었네요.


하지만 이 라이브러리를 만들면서 기존에 WPF 와 C# 리플렉션에 대해서 더 수준높은 이해를 할 수 있었고 기존에 추상적으로 알고 있던 것들이나 헷갈리던 것들의 개념이 명확해졌습니다. 역시 실전을 겪어 봐야지 실력이 느는 것 같습니다.


이제 VulkanMonkey 를 구현하면서 다시 공부를 진행해 보려고 합니다. 이 프로젝트를 진행하면서도 Vulkan 에 대한 이해도가 한층 더 높아졌으면 좋겠네요.

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

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

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



저는 벌칸에 대해서 공부하면서 RenderMonkey 와 유사한 VulkanMonkey 라는 셰이더 편집 도구를 만든다는 최종 목표를 가지고 있습니다. 이 연구 시리즈도 그것을 구현하는 과정에서 발생하는 여러 가지 의문들에 대해서 스스로 답을 내면서 만들어지고 있는 것입니다.



하지만 프로젝트를 진행하다가 벽에 부딪히게 되었습니다. 기존의 D3D9 이나 OpenGL 과는 다르게 Vulkan 은 여러 개의 큐를 가질 수 있으며 커맨드들이 비동기적으로 실행될 수 있기 때문에, RenderMonkey 와 같은 TreeView 구조의 워크스페이스 관리는 비직관적입니다.


이 때문에 구조가 맘에 안 들어 디바이스 생성후 더 이상 진행을 못하고 있었습니다. 물론 마샬링 구조를 결정하는 문제 때문에 프로젝트를 3 차례나 엎고 다시 만드는 데 걸리는 시간이 꽤 길긴 했지만( 현재는 C# <--> C++/CLI <---> C++ 의 구조를 가집니다. 이 부분에 대해서는 나중에 한 번 공유하도록 하겠습니다 ), 그건 과거의 문제이고 미래로 나아가지 못하는 원인은 워크스페이스 구조였습니다.


그런데 오늘 갑자기 나아가야 할 방향이 떠 올랐습니다. 벌칸은 매우 높은 수준으로 파이프라인화되어 있는 API 입니다. 이미 이런 형태를 잘 반영하고 있는 도구들이 존재하고 있는데 삽질을 했다는 생각이 들었습니다. 그건 바로 Graphics Profiler 들입니다. 예를 들어 NSight Graphics 를 보면 다음과 같습니다.



Scrubber View 에서 큐당 커맨드의 흐름을 확인할 수 있습니다. 그리고 이벤트 뷰와 API 인스펙터 뷰에서 세부사항을 확인할 수 있죠.


VulkanMonkey 에서는 커맨드 단위가 아니라 렌더 패스 단위의 흐름을 파악할 수 있도록 만들면 되겠다는 생각이 들었습니다. 커맨드 버퍼 내부에서 동기화를 해야 하는 경우는 거의 없을 것이라고 생각하기 때문입니다.


그러면 아래와 같은 형태로 렌더 패스를 배치할 수 있습니다. 물론 실제 걸리는 시간은 알 수 없으니 적절히 너비를 사용자가 조정하게 만들어야겠죠. 아래 그림에서는 세마포어를 사용해서 "Light Accumulation" 렌더 패스가 시작되기 전에 "CSM3Depth" 렌더 패스에 대해 웨이트하도록 하는 상황을 상정했습니다. 물론 실제 구현 측면에서 보면, "Light Accumulation" 의 첫 번째 커맨드 버퍼를 Submit 할 때 Wait 를 하는 상황이겠죠.




그리고 각각의 렌더 패스의 인스펙터창에서는 세부적인 설정들을 할 수 있도록 하는 거죠. 이렇게 하면 전체적인 흐름을 볼 수 있습니다. 


여기에다가 특정 시점까지의 렌더 패스 실행 결과만을 실행할 수 있는 기능까지 추가하면, 프레임 디버거처럼 중간과정을 확인하는 것도 가능할 것입니다. 


좀 더 나아가면 렌더 패스 내의 커맨드 퍼버들까지 가시화해 줄 수 있으면 금상첨화라는 생각이 듭니다. 그러면 하나씩 그리는 과정도 추적할 수 있겠죠.


이제 좀 앞으로의 구현 방향이 잡히는 기분입니다. 물론 이런 UX 를 구현하려고 생각하니 끔찍하긴 하지만 말이죠.


더 좋은 아이디어가 있으면 댓글 남겨 주시면 감사하겠습니다.

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

주의 : 번역이 너무 껄끄러우면 원문을 그대로 사용하겠습니다.

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

원문 : Performance Tweets series: Barriers, fences, synchronization. GpuOpen.



DX12 시리즈에 오신 걸 환영합니다. 가장 화끈한 주제 중 하나로 들어 가 보죠: 동기화, 즉 배리어와 펜스입니다!


Barriers


배리어는 이전에는 드라이버 단에 숨겨져 있지만 개발자에게 노출된 새로운 개념입니다. 만약 여러분이 이것을 동기화라고 생각한다면, 아주 크게 벗어는 것은 아닙니다. 왜냐하면 이것 또한 배리어의 일부이기 때문입니다. 동기화 부분은 CPU 측에 대해서는 잘 알려져 있습니다: 버퍼를 갱신하는 다중의 쓰기 스레드를 가지고 있고, 모든 쓰기 작업이 종료되도록 만들기 위해서 동기화를 하고, 다중의 읽기 쓰레드를 사용해 데이터를 처리할 수 있습니다. 하지만 그것이 전부는 아닙니다. GPU 배리어가 존재합니다( ResourceBarrier vkCmdPipelineBarrier ). 전체적으로 볼 때 3 가지 기능들이 있습니다:


  • Synchronization 은 새로운 작업을 시작하기 전에 이전의 의존성 작업이 완료되는 것을 보장합니다.
  • Visibility 는 특정 유닛에서 캐싱되어야 하는 데이터가 그것을 확인하고자 하는 다른 유닛에서 가시적임을 보장합니다.
  • Reformatting 은 유닛에 의해서 읽어들여진 데이터가 유닛이 이해할 수 있는 형식임을 보장합니다.


여러분은 리포매팅이 발생하는 경우에 대해서 궁금할 겁니다. 이는 압축을 하는 경우에 종종 요구됩니다. 예를 들어 전통적으로 렌더 타깃은 읽기와 쓰기를 위해 요구되는 대역폭을 줄이기 위해서 압축이 됩니다. GPU 의 어떤 유닛들은 모든 압축 모두를 제공하지는 않으며, 압축해제를 요구합니다. 보통 배리어는 캐시 플러시( cache flush )( 컬러 버퍼, 깊이 버퍼같은 개별 유닛에 붙어 있는 L1 캐시나 좀더 일원화된( unified ) L2 캐시 )로 변환됩니다. 그리고 나서 대기하고( 예를 들어, 결과를 사용하고자 하는 그래픽스 셰이더 이전에 컴퓨트 셰이더가 완료되기를 기다립니다 ), 압축을 해제합니다.


이제 배리어가 뭔지 알게 되었습니다. 그런데 그게 왜 중요할까요? 배리어는 보통 Direct3D 12 와 Vulkan 에서 버그의 온상이 됩니다. 흔들림( flickering ), 깨짐( blocky artifact ), 데이터 손상( corrupted data ), 깨진 지오메트리와 같은 많은 현상들이 잘못된 배리어의 책임일 수 있습니다. 일단, 서브리소스 당 트래킹( per-subresource tracking )을 포함하는 100 % 올바른 배리어가 있다고 가정해 봅시다. 즉 여러분이 텍스쳐 배열의 한 레이어에 쓰기 작업을 하면, 이 레이어는 올바른 상태로 전이( transition )될 필요가 있습니다 -- subresources are often overlooked. You also have to make sure to have all required transitions in place. Some might be easy to overlook like transitioning for presentation.


배리어를 만들었으면, 이제 최적화를 할 시간입니다. Barriers are expensive and every barrier counts. 그것을 요구하는 리소스들만 트랜지션 해야 합니다 -- 그 예로는 텍스쳐로서 읽어들여지는 렌더 타깃이 있습니다. 일반적으로는 쓰기 작업을 하는 리소스보다 더 많은 배리어를 만들어서는 안 됩니다. 다른 모든 리소스들은 프레임 당 트랜지션을 하지 말아야 합니다. 특히 리소스에 대해서 쓰기 작업이 한 번만 수행되고 여러 방식으로 읽어들여진다면, 다중의 읽기-읽기( read-read ) 상태 트랜지션들을 사용하기 보다는, 대부분의 읽기 상태에 대해 리소스를 한 번만 트랜지션하십시오.


배리어를 배칭하는 것도 좋은 성능을 내는 데 있어 중요합니다. 배리어는 GPU 플러싱( flushing )을 요구하므로 배리어 호출 당 더 많은 리소스들을 트랜지션 하십시오. 그러면 배리어를 필요로 하는 모든 리소스들에 대해 단 한 번의 플러싱만이 수행될 것입니다. 이는 성능 향상에 많은 도움이 됩니다 -- 그러므로 가능한 한 배리어 호출들을 배칭하십시오.  이를 구현하는 한 가지 방법은 상태를 식별하는 것입니다. 모든 리소스들은 커맨드 리스트에 존재해야 하며, 커맨드 리스트 시작이나 이전 커맨드 리스트의 끝에서 그것들을 모두 함께 트랜지션하는 것입니다. 물론 실제 및 트랜지션 작업이 실행되기 바로 전에 리소스 상태를 검사하는 안 좋은 패턴은 피해야 할 것입니다 -- 이는 필요한 것 보다 더 많은 트랜지션 호출이 발생하도록 만들 것입니다.


Fences


배리어와 관련이 있는 것으로 펜스가 있습니다( CreateFence 와 vkCreateFence ). 이것들은 CPU 와 GPU 뿐만 아니라 GPU 상의 큐들을 동기화하기 위해서 사용됩니다. 펜스는 매우 무거운 동기화 개체입니다. 왜냐하면 그것은 적어도 GPU 가 모든 캐시들을 플러싱할 것을 요구하며, 잠재적으로 일부 추가적인 동기화를 요구합니다. 그런 비용들 때문에, 펜스는 드물게 사용되어야 합니다. 특히, 프레임 당 리소스들을 그룹화하고, 미세한( fine-grained ) 리소스 당 트래킹을 사용하는 대신에, 단일 펜스를 사용해 함께 트래킹하도록 시도하십시오. 예를 들어, 한 프레임 내에서 사용되는 모든 프레임 버퍼들은 커맨드 버퍼 당 펜스를 사용하기 보다는 하나의 펜스를 사용해서 보호되어야 합니다.


펜스는 컴퓨트 큐, 카피 큐, 그래픽스 큐들을 프레임 단위에서 동기화하기 위해서 드물게 사용될 수도 있습니다. 이상적으로는, 모든 잡들이 종료되는 조짐이 보이는 끝 부분에서 단일 펜스를 사용해서 비동기 컴퓨트 잡들의 큰 배치를 제출하려고 시도하시기 바랍니다. 카피에 대해서도 동일합니다. 가장 가능성있는 성능을 획득하기 위해서 모든 카피의 끝에서 단일 펜스를 사용해서 시그널링을 하십시오.


늘 그렇듯이, 질문이 있으면 편한하게 댓글을 남기거나 Twitter 에 알려 주세요 : @NThibieroz & @NIV_Anteru.


Tweets


02 : 배리어 개수는 쓰기 작업을 하는 리소스 개수와 대충 비슷해야 합니다.

12 : 각 펜스는 ExecuteCommandList 와 비슷한 비용이 듭니다( CPU 와 CPU 비용 ).

22 : 읽기-읽기 배리어를 사용하지 마십시오. 올바른 상태의 리소스를 먼저 획득하십시오.

31 : 항상 리소스를 그것을 작성하는 마지막 큐에서 트랜지션하십시오.


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.

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

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

주의 : 번역이 좀 껄끄럽거나 귀찮으면 원문을 그대로 쓰겠습니다.

원문 : Vulkan barriers explained, GpuOpen.



벌칸( Vulkan )의 배리어( barrier ) 시스템은 독특합니다. 왜냐하면 그것은 트랜지션( transitioning )중인 리소스들을 여러분이 제공할 것을 요청할 뿐만 아니라, 소스 파이프라인 스테이지( source pipeline stage )와 타깃( target ) 파이프라인 스테이지를 지정할 것도 요구하기 때문입니다. 이는 트랜지션이 실행될 때 더욱 미세한( fine-grained ) 제어를 할 수 있도록 해 줍니다. However, you can also leave quite some performance on the table if you just use the simple way, 그래서 오늘은 vkCmdPipelineBarrier 에 대해서 자세히 알아 보도록 하겠습니다.


Pipeline Overview


GPU 는 엄청나게 파이프라인화되어 있는 디바이스라는 것은 일반적인 상식입니다. 커맨드들은 탑( top )으로 들어가며, 버텍스 셰이딩( vertex shading )이나 프래그먼트( fragment ) 셰이딩과 같은 개별 스테이지들이 순서대로 실행됩니다. 마지막으로 커맨드들은 실행이 완료될 때 파이프라인의 바텀( bottom )에서 리타이어( retire )됩니다( 역주 : 일반적으로 CPU 에서는 리타이어되었다는 의미가 out-of-order execution 에서의 실행을 종료하고 올바른 결과를 산출했다는 의미입니다. 예를 들어 branch-prediction 같은 것에 의해 취소되지 않았다는 의미입니다 ).


이런 스테이지들은 VK_PIPELINE_STAGE 열거형을 통해서 벌칸에 노출됩니다. 아래와 같이 정의되어 있습니다.


  • TOP_OF_PIPE_BIT
  • DRAW_INDIRECT_BIT
  • VERTEX_INPUT_BIT
  • VERTEX_SHADER_BIT
  • TESSELLATION_CONTROL_SHADER_BIT
  • TESSELLATION_EVALUATION_SHADER_BIT
  • GEOMETRY_SHADER_BIT
  • FRAGMENT_SHADER_BIT
  • EARLY_FRAGMENT_TESTS_BIT
  • LATE_FRAGMENT_TESTS_BIT
  • COLOR_ATTACHMENT_OUTPUT_BIT
  • TRANSFER_BIT
  • COMPUTE_SHADER_BIT
  • BOTTOM_OF_PIPE_BIT


이 열거형은 커맨드가 실행되는 순서와 같은 것은 아닙니다 -- 일부 스테이지들은 병합될 수도 있고 일부 스테이지들은 사라질 수도 있지만, 전체적으로 이것들은 커맨드가 통과할 파이프라인 스테이지들입니다.


세 개의 의사 스테이지( pseudo-stage )들이 존재하는데, 이것들은 다중의 스테이지들을 합치거나 특별한 접근을 제어하는데 사용됩니다:


  • HOST_BIT
  • ALL_GRAPHICS_BIT
  • ALL_COMMANDS_BIT


이 아티클에서는 TOP_OF_PIPE_BIT 와 BOTTOM_OF_PIPE_BIT 사이의 리스트들에 대해 논의할 것입니다. 자, 배리어 문맥에서 소스와 타깃은 뭘까요? 그것들을 "생산자( producer )" 스테이지와 "소비자( consumer )" 스테이지로 생각할 수 있습니다 -- 소스 스테이지가 생산자가 되고, 타깃 스테이지가 소비자가 됩니다. 소스 및 타깃 스테이지를 지정함으로써, 드라이버에게 전이가 실행되기 전에 어떤 연산들이 완료될 필요가 있는지 그리고 어떤 연산들이 아직 시작되면 안 되는지를 알려줄 수 있습니다.


예 1: 느린 배리어, 파이프의 바텀을 소스 스테이지로 지정하고 파이프의 탑을 타깃 스테이지로 지정.


가장 단순한 경우를 살펴 봅시다. BOTTOM_OF_PIPE_BIT 를 소스 스테이지로 지정하고 TOP_OF_PIPE_BIT 를 타깃 스테이지로 지정합니다( 예 1 ). 이를 위한 소스 코드는 다음과 같습니다:



이 트랜지션은, 현재 GPU 에서 실행중( in flight )인 모든 커맨드들이 완료되고 나서 그 트랜지션이 실행되어야 하며, 트랜지션을 종료하기 전에 어떠한 커맨드들도 실행되어서는 안 된다는 것을 의미합니다. 이 배리어는 실행중인 모든 것을 종료하고 어떠한 작업도 실행되지 않도록 막기 위해서 대기할 것입니다. 그것은 일반적으로는 이상적이지 않습니다. 왜냐하면 불필요한 파이프라인 버블( bubble, 역주 : pipeline stall 을 pipeline bubble 이라 부르기도 합니다 )을 겪도록 만들기 때문입니다.


예 2: 최적의 배리어. 모든 녹색 파이프라인 스테이지들이 실행되는 것이 허용됨.


버텍스 셰이더에서 데이터를 imageSotre 를 통해서 저장하고 컴퓨트 셰이더가 그것을 소비하기를 원한다고 상상해 봅시다. 이 경우에, imageStore 를 통해 저장하는 작업이 완료될 때까지 시간이 너무 오래 걸릴 수도 있기 때문에, 프래그먼트 셰이더를 처리하지 못하고 대기해야할 수 있습니다. 여러분은 이런 상황을 원하지는 않을 겁니다. 여러분이 정말 원하는 건 버텍스 셰이더가 완료되자 마자 컴퓨트 셰이더를 시작하는 것일 겁니다. 이를 표현하는 방식은 소스 스테이지를 VERTEX_SHADER_BIT 로 설정하고 타깃 스테이지를 COMPUTE_SHADER_BIT 로 설정하는 겁니다( 예 2 ).



만약 렌더 타깃을 쓰고 그것을 프래그먼트 셰이더에서 읽고자 한다면, 이 소스 스테이지는 VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT 일 것이고 타깃 스테이지는 VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT 일 겁니다. 이는 G-Buffer 렌더링에서 일반적입니다. 셰도우 맵의 경우, 소스는 VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT 가 될 겁니다. 다른 일반적인 예는 데이터 복사입니다 - 복사를 통해 데이터를 생성할 때 소스 스테이지를 VK_PIPELINE_STAGE_TRANSFER_BIT 로 설정하고 타깃 스테이지를 그것을 필요로 하는 스테이지로 설정하면 됩니다. 버텍스 버퍼에서 사용한다고 하면 이것은 VK_PIPELINE_STAGE_VERTEX_INPUT_BIT 일 겁니다.


일반적으로 여러분은 언블락킹된( unblocked ) 스테이지들의 개수를 최대화하려고 시도해야 합니다. 즉, 데이터를 빨리 생성하고 그것을 나중에 기다리는 겁니다. 생산자 측에서 파이프라인의 바텀을 향해 이동하는 것은 항상 안전합니다. 왜냐하면 더 많은 스테이지들이 끝날 때까지 대기해야 하기 때문입니다. 하지만 성능은 개선되지 않겠죠. 비슷하게 소비자 측에서 안전하기를 원한다면, 파이프라인의 위쪽인 탑으로 이동하면 됩니다 - 하지만 그것은 더 많은 스테이지들을 실행하는 것을 방해하게 될 것입니다. 그래서 그것 또한 피해야 합니다.


마지막으로 주의할 점: 앞에서 언급했듯이, 하드웨어는 내부적으로 모든 스테이지들을 가지고 있는게 아닙니다. 혹은 특정 스테이지에서는 시그널링하거나 웨이트할 수 없을 수도 있습니다. 그런 경우에는, 드라이버가 마음대로 소스 스테이지를 파이프라인의 탑이나 바텀으로 이동시키거나 타깃 스테이지를 탑으로 이동시킬 수 있습니다. 이는 구현측-명세에 따르기는 하지만 여러분이 그것에 대해서 걱정할 필요가 없습니다 - 여러분의 목적은 스테이지들은 가능한 한 짜임새있게( tight ) 설정하고 블락킹된 스테이지의 개수를 최소화하는데 있기 때문입니다.


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.

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

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

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

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



Vulkan 명세 [ 6. Synchronization and Cache Control ] 에서 소개하고 있는 주요 동기화 메커니즘은 다음과 같습니다. 


  • Fences : 펜스는 디바이스 상에서 실행되는 어떤 태스크가 완료되었음을 호스트에게 알려주기 위해서 사용될 수 있습니다.
  • Semaphores : 세마포어는 여러 개의 큐 사이에서 리소스에 대한 접근을 제어하기 위해서 사용될 수 있습니다.
  • Events : 이벤트는 미세한 작업 단위의 동기화( fine-grained synchronization )이며, 단일 큐 내에서만 사용이 가능합니다. 작업의 순서를 지정하기 위해서 사용됩니다.
  • Pipeline Barriers : 파이프라인 베리어는 같은 큐에 제출되는 커맨드들이나 같은 서브패스 내의 커맨드들 사이에 종속성을 주입하기 위해 사용됩니다.
  • Render passes : 렌더 패스는 거의 대부분의 렌더링 태스크를 위한 유용한 동기화 프레임워크를 제공합니다. 다른 동기화 요소들을 사용하기 보다는 렌더 패스 단위로 동기화하는 것이 효율적입니다.


일단 이 문서에서는 커맨드 제어를 위해서 가장 많이 사용되는 펜스, 세마포어, 배리어에 대해서 알아보도록 하겠습니다.


[ 1 ] 에서는 동기화 개체에 대해 아래와 같이 다이어그램으로 잘 정리해 뒀습니다.


동기화 용례 : 출처 : [ 1 ].


위의 다이어그램에서는 다음과 같은 용례를 보여 줍니다.


  • 이벤트를 사용해서 커맨드 버퍼 내의 커맨드들 끼리의 동기화를 보장합니다.
  • 세마포어를 사용해 큐 사이의 동기화를 보장합니다.
  • 펜스를 사용해 호스트와 디바이스 사이의 동기화를 보장합니다.


그런데 멀티스레딩에 대해 "제대로" 공부해 보지 않은 분들은 펜스, 이벤트, 세마포어, 배리어 등의 개념에 익숙하지 않을 겁니다( 저도 그렇습니다 ). 그러므로 그 개념들에 대해서 살펴 보고 오시는 것을 추천합니다. 저같은 경우에는, 이벤트나 세마포어같은 걸 스레드 동기화에 대해 공부하면서 종종 접해 봤지만, 펜스와 배리어라는 개념은 그래픽스를 하면서 처음 봤습니다. 


동기화 개체들에 대해서 대해서 간단하게( ? ) 잘 정리한 분들이 있는데, 아래 링크를 확인하시면 좋을 것 같습니다.



사실 뮤텍스와 이벤트는 모두 세마포어의 변종이라고 할 수 있습니다. 뮤텍스는 키가 한개인 세마포어이고, 이벤트는 통지 기능을 가진 뮤텍스나 세마포어죠. 그리고 [ 2 ] 에 의하면 배리어와 펜스는 기존의 드라이버 단에 존재하던 동기화 기법이 호스트단에 노출된 것에 불과합니다.


그러므로 동기화 메커니즘에 사용되는 동기화 개체들에 대해서 사고할 때, 원래의 의미에 대해서 너무 깊게 생각하는 것은 Vulkan 에서 동기화 개체를 사용할 때 혼란의 여지를 준다고 생각합니다. 그래서 어떤 오브젝트에 대해 어떤 시점에 어떤 방식으로 동기화 개체들이 사용되는지를 이해하는 것이 중요하다고 생각합니다.


세마포어


Vulkan 에서 세마포어는 큐들에 제출된 배치들( batches submitted to queues ) 사이에 의존성을 삽입하는 데 사용될 수 있습니다. 세마포어는 시그널( signaled )과 언시그널( unsignaled ) 상태를 가집니다.


세마포어는 커맨드 배치가 완료된 후에 시그널 상태가 변할 수 있습니다. 일반적으로 어떤 배치를 실행하기 전에 세마포어가 시그널 상태로 되기를 기다렸다가, 배치를 실행하기 전에 세마포어를 언시그널 상태로 설정할 수 있습니다. 이 과정을 통해서 순서대로 배치가 실행되도록 보장하는거죠.


다음과 같이 두 개의 커맨드 버퍼가 있다고 가정해 보죠; A 와 B. 그런데 A 와 B 가 순차적으로 제출되는데 반드시 A 의 작업이 종료된 후에 B 가 제출되었으면 한다고 합시다. 그렇다면 동기화를 걸 필요가 있겠죠. 이럴 때 세마포어를 사용할 수 있습니다.


예를 들면  "deferredshadows" 샘플에서는 다음과 같은 식으로 세마포어를 사용해 커맨드버퍼들의 제출 시점을 제어합니다.



VkSubmitInfo 에다가 pWaitSemaphores 와 pSignalSemaphores 를 지정하는 것을 확인하실 수 있습니다. pWaitSemaphores 는 종료될 때까지( 시그널링될 때까지 ) 대기해야 하는 세마포어이고, pSignalSemaphores 는 자신이 완료되면 시그널링시킬 세마포어입니다.


배리어와 펜스


배리어와 펜스의 개념에 대해서는 잘 정리한 글들이 있으므로 제가 직접 정리하기 보다는 그것들을 참조하는 게 좋을 것 같습니다.


[ 번역 : Vulkan barriers expained ].

[ 번역 : Performance Tweets series : Barrier, fences, synchronization ].


커맨드 풀


[ 1 ] 에서는 커맨드 풀을 프레임 단위로 유지함으로써 커맨드 버퍼의 라이프사이클을 유지하는 수단으로 사용할 수 있다고 이야기하고 있습니다. 왜냐하면 커맨드 풀을 통째로 리셋함으로써 커맨드 버퍼를 모두 지워버릴 수 있기 때문입니다.



커맨드 풀 자체는 동기화 개체는 아닙니다. 하지만 어떻게 보면 커맨드 버퍼가 삭제되지 않도록 만드는 일종의 배리어같은 역할을 한다는 생각이 듭니다.


정리


벌칸은 여러 종류의 동기화 개체를 가지고 있습니다. 대표적인 것이 세마포어, 배리어, 펜스, 이벤트 등인데요, 각각의 사용처가 다릅니다. 적절한 위치에서 사용하는 것이 중요합니다. 


안타깝게도 이벤트같은 경우에는 자세한 설명이나 샘플을 찾아 볼 수가 없어서 여기에서 정리를 하지 못했습니다.


일단은 개념적인 부분들에 대해서 살펴 보았지만, 기회가 되면 나중에 실제로 구현해 보고 공유하도록 하겠습니다( 아직까지 렌더러 구조에 대해 고민하는 중이라 샘플을 못 만든 상태입니다 ).


참고자료


[ 1 ] Muti-Threading in Vulkan. arm Community.


[ 2 ] Performance Tweets series: Barriers, fences, synchronization. GPU Open.


[ 3 ] Vulkan 1.1 Specification. Khronos Group.

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

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

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



Vulkan 명세의 [ 2.6. Threading Behavior ] 에서는 다음과 같이 이야기하고 있습니다.


Vulkan 은 다중 호스트 스레드를 사용할 때 확장가능한 성능을 제공하도록 설계되어 있습니다. 모든 커맨드들은 다중 스레드에서 동시적으로 호출되도록 지원하고 있습니다. 하지만 특정 파라미터나 파라미터의 요소들은 외부적으로 동기화되도록( externally synchronized ) 정의되어 있습니다. 이는 호출자가 그런 파라미터를 동시에 하나 이상의 스레드에서만 호출되도록 보장해야만 한다는 것을 의미합니다.


좀 더 정확하게 이야기하자면, Vulkan 커맨드들은 Vulkan 오브젝트의 상태를 갱신하기 위한 단순한 저장소( simple stores )들을 사용합니다. 외부적으로 동기화되도록 선언된 파라미터들은 호스트가 커맨드를 실행하는 동안에 언제든 갱신될 수 있습니다. 만약 같은 오브젝트에 대해 동작하는 두 개의 커맨드가 있는데, 둘 중의 하나라도 외부적으로 동기화되도록 오브젝트를 선언했다면, 호출자는 커맨드들이 동시적으로 실행되지 않도록 보장해야할 뿐만 아니라, 그 두 개의 커맨드들이 ( 필요하다면 ) 적절한 메모리 배리어에 의해서 분리되도록 보장해야만 합니다.


요약하자면, Vulkan 은 커맨드를 다중의 스레드에서 실행함으로써 동시성을 보장한다는 것입니다. 하지만 외부 동기화를 요구하는 파라미터( externally synchronized parameter )들을 사용하는 커맨드들은 동시에 실행되서는 안 된다는거죠. 


매우 단순한 예를 하나 들어 보겠습니다. 버퍼 A 와 A 에 쓰기 작업을 하는 두 개의 스레드가 존재한다고 합시다. 그리고 각각의 스레드에서는 개별적으로 커맨드들을 생성했다고 가정하겠습니다. 그러면 각각의 스레드에서 생성한 커맨드들은 서로 관계가 없으므로 동시적으로 실행될 수 있습니다. 하지만 A 에 동시에 쓰기를 할 수는 없겠죠. 그러므로 이런 커맨드들이 동시적으로 실행되지 않도록 보장해 줄 필요가 있습니다.


External Synchronization


"외부적으로 동기화된다( externally synchronized )" 는 의미는 상당히 혼란스럽습니다. 저로서는 상당히 생소한 개념인데요, 일단 구글링을 해 봤습니다. 


그런데 이 용어 자체에 대해서 근본적으로 설명하는 글들은 없고, 대부분이 "Clock Synchronization" 혹은 "Time Synchronization" 에 대한 글들이더군요.


[ 1 ] 에 의하면 internal synchronization 과 external synchronization 의 차이는 다음과 같습니다.


분산 시스템에서 클락 동기화는 보통 하나나 두 개의 목적을 가집니다: (1) 분산 시스템을 구성하는 모든 노드들이 같은 내부 시계( clock )을 소유하는 것을 보장하고, (2) 그 분산 시스템이 다른 외부 시계와 동기화되는 것을 보장합니다.


내부 동기화는 보통 컴퓨팅 클러스터들이 자신들의 로컬 시계들로 동기화되도록 허용하는 동기화 프로토콜을 통해서 수행됩니다. 그 머신은 공통 시간을 사용한다고 동의하는거죠. 하지만, 이 그들이 동의한 시간은 외부 시계와 동기화될 필요는 없습니다. 예를 들면 특정 타임존( time-zone )에 대해서 말이죠.


외부 동기화는 컴퓨팅 시스템들이 NTP 프로토콜을 사용하여 제공되는 서버와 같은 외부 시간에 자신들의 시계가 동기화되도록 만듭니다. 이것의 목적은 컴퓨팅 시스템의 시간을 특정 타임존과 동기화시키는 것입니다. 만약 정확한 시간이 요구된다면, 원자 시계( atomic clock )으로부터 시간을 생성하는 NTP 시스템들이 사용됩니다.


두 경우 모두, NTP 프로토콜이 사용될 수 있으며, 광범위하게 사용됩니다.


결국 Vulkan 명세에서 이야기하는 외부 동기화는 이런 의미가 아니라는 것을 알 수 있습니다. 좀 더 구글링을 해 보니, 다음과 같은 질문이 있었습니다[ 2 ].


컬렉션 프레임워크( collection framework )에서, 왜 외부 동기화가 내부 동기화( Vector, HashTable 등 )보다 빠른가요? 심지어는 같은 메커니즘을 사용함에도 불구하구요?


정확히 내부 동기화와 외부 동기화의 의미가 무엇이며, 그것들이 왜 다른가요?


누가 예제를 가지고 설명해 주면 정말 도움이 될 것 같네요.


답변에서는 외부 동기화에 대해 다음과 같이 설명하고 있더군요.


외부 동기화는 호출자가 synchronized 키워드나 lock 을 사용해서 다중 스레드에서 접근되는 다른 클래스를 보호하는 것을 의미합니다. 이것은 보통 클래스가 자체적으로 동기화되지 않을 때 사용됩니다.


즉 Vulkan 에서의 "외부 동기화"는 오브젝트의 동기화를 오브젝트 외부에서 보장해야 한다는 것을 의미한다고 볼 수 있습니다. 


예를 들자면, 버퍼 A 는 자체적인 동기화 기능을 가지고 있지 않기 때문에, 쓰기 커맨드가 다른 스레드에서 동시에 실행되지 않도록 외부에서 동기화해 줘야 한다는 겁니다. 또한 생성되면 다시는 수정되지 않는 immutable( non-writable ) 속성의 오브젝트들도 다른 스레드에서 사용중일 때 파괴되지 않도록 외부 동기화를 해 줘야만 합니다.


Internal Synchronization


명세에 의하면 어떤 오브젝트들은 내부 동기화를 사용한다고 합니다. 예를 들면 vkCreateGraphicsPipelines() 과 vkCreateComputePipelines() 에서의 VkPipelineCache 파라미터가 있습니다. 이 경우에는 외부 동기화를 하는게 무겁기 때문에 내부 동기화를 한다고 하는군요.


커맨드 파라미터에 대해서 명시적으로 "외부 동기화된다"고 적혀 있지 않다면, 해당 파라미터들은 내부 동기화를 한다고 합니다.


Implicit External Synchronization


명세에 의하면 동기화해야 할 오브젝트가 한 종류 더 있습니다. 묵시적인 외부 동기화인데요, 이 경우는 커맨드의 파라미터와 연관되어 있는 오브젝트들에 대한 외부 동기화를 의미합니다. 예를 들면 Command Pool 과 Descriptor Pool 이 있습니다.


Which objects are externally synchronized?


이제 내부 동기화와 외부 동기화의 개념에 대해서 알게 되었습니다. 하지만 어떤 오브젝트가 외부 동기화되는지 알 수 있을까요?


기본적으로 외부 동기화되거나 묵시적으로 외부 동기화되는 커맨드나 오브젝트의 리스트는 [ 2.6. Threading Behavior ] 섹션에서 찾아볼 수 있습니다. "Externally Synchronized Parameters", "Externally Synchronized Parameter Lists", "Implicit Externally Synchronized Parameters" 라는 항목들에서 그 리스트를 볼 수 있습니다.



물론 이 리스트를 뽑아 놓고 지금 파라미터가 동기화되어야 하는지 찾아 보는 방법도 있지만, 특정 함수에 대해서 동기화 여부를 찾아 볼 수도 있습니다.


예를 들어 "Externally Synchronized Parameters" 리스트에 있는 vkDestroyInstance() 명세에 가보면 "Host Synchronization" 이라는 항목을 볼 수 있습니다.



만약 동기화가 필요하다면 "Host Synchronization" 항목이 반드시 있으므로 이를 확인하시면 됩니다.


정리


Vulkan 오브젝트들은 일부 오브젝트를 제외하고는 외부 동기화를 요구하게 됩니다. VkPipelineCache 같은 특정 오브젝트를 제외한 대부분의 오브젝트가 자체적인 동기화 기능을 내장하고 있지 않으므로, 반드시 외부에서 동기화를 해 줘야 합니다.


외부 동기화의 실례는 다음 글에서 다루도록 하겠습니다.


참고자료


[ 1 ] What is the difference between internal clock synchronization and external clock synchronization in distributed systems?, stack overflow.


[ 2 ] why external synchronization is faster than internal one?, stack overflow.


[ 3 ] Vulkan Specification 1.1, Khronos Group.

+ Recent posts