No Graphics API
Sebastian Aaltonen - No Graphics API를 번역한 글입니다

· 8 min read
- 이 포스트는 해당 글을 번역해 옮겼습니다. 번역 과정에서 일부 의역을 포함했습니다.
No Graphics API #
소개 #
그래픽스 API, 셰이더 프레임워크 그리고 드라이버들의 복잡성이 지난 수십년간 빠르게 증가했습니다. 파이프라인 상태 객체(Pipeline State Object, PSO) 폭발은 더 이상 손쓸 수 없을 지경입니다. 어쩌다 100GB에 달하는 로컬 셰이더 파이프라인 캐시와 이들을 호스팅하는 거대한 클라우드 서버가 탄생하게 된 걸까요? 이젠 어떻게 우리가 GPU와 상호작용하기 위한 추상화와 API 표면을 줄일 수 있을지에 대한 방법을 논의하기 시작해야 할 때입니다.
업계에서의 저수준 그래픽스 API들의 변화 #
십년 전, 새로운 저수준 PC 그래픽스 API의 소개와 함께 실시간 컴퓨터 그래픽스 분야에 중대한 변화가 일어났습니다. AMD는 2013년 Xbox One과 Playstation 4의 부품 계약을 모두 따냈습니다. 그들의 새로운 GCN(Graphics Core Next) 아키텍쳐는 사실상 AAA(*역주: Triple-A 라고 부르며, 블록버스터급 규모를 의미합니다) 게임 개발을 위한 주력 플랫폼이 되었습니다. 해당 시점의 PC 그래픽스 API들이었던 DirectX 11과 OpenGL 4.5는 무거운 드라이버 오버헤드가 있었으며 싱글 스레드 렌더링을 위해 설계되어 있었습니다. AAA 게임 개발자들은 더 높은 성능의 API를 요구했습니다. DICE는 아예 AMD GCN에 특화된 PC 그래픽스 API인 Mantle 제작에 함께 참여하게 되었으며, 이에 응답하듯 Microsoft, Khronos, 그리고 Apple은 그들만의 저수준 API를 개발하기 시작했고, 그 결과 각각 DirectX 12, Vulkan, Metal이 탄생하게 되었습니다.
이러한 새로운 저수준 API들에 대한 초기 반응은 엇갈렸습니다. 합성 벤치마크들과 데모들은 이들이 이전 API들에 비해 상당한 성능 향상을 이루어냈음을 보여주었지만, Unreal Engine, Unity와 같은 주요한 게임 엔진들에서는 성능 향상이 보이지 않았습니다. 제가 Ubisoft에서 일할 때, 저희 팀은 기존에 개발되어 있던 DirectX 11 기반 렌더러를 DirectX 12로 포팅할 때 종종 성능 저하가 발생하는 것을 발견했습니다. 이는 무언가 잘못된 것이었습니다.
기존에 존재하던 고수준 API들(*역주: DirectX 11, OpenGL 등)은 최소한의 영구(Persistent) 상태만을 제공하며, 세부적인 상태 설정기와 개별적인 데이터 입력은 드로우 콜 호출 직전에 셰이더에 바인딩됩니다. 새로운 저수준 API 들은 셰이더 파이프라인 상태와 바인딩들을 영구 객체로 미리 묶음으로써 드로우 콜의 비용을 더 낮추는 것을 목표로 합니다. 이전까지의 GPU 아키텍쳐들은 매우 이질적(Heterogeneous)이었습니다. 데이터 리매핑, 유효성 검증(validation) 및 사전 업로드를 수행하는 것이 큰 도움이 되었습니다. 그러나 기존 게임 엔진들의 렌더링 하드웨어 아키텍쳐(RHI, Rendering Hardware Architecture)는 세밀한 즉각적(immediate) 모드 렌더링을 위해 설계된 반면, 새로운 저수준 API는 데이터를 영구 객체로 묶어야 했습니다.
이러한 비호환성을 해결하기 위해 새로운 저수준 그래픽스 리매핑 레이어가 RHI 아래에 생겨났습니다. 이 레이어는 이전에는 OpenGL과 DirectX 11 그래픽스 드라이버가 처리했던 복잡성을 담당하며 리소스를 추적하고 세분화된 동적 사용자 영역과 영구적인 저수준 GPU 상태 간의 매핑을 관리합니다. 이로 인해 그래픽스 프로그래머는 두 가지 구분된 역할로 전문화되기 시작했습니다. 새로운 저수준 ‘드라이버 레이어’와 RHI 계층에 집중하는 저수준(Low-level) 그래픽스 프로그래머들과, 그들이 구현한 RHI계층 위에서 시각적 알고리즘(비주얼 프로그래밍)에 집중하는 고수준(High-level) 그래픽스 프로그래머들로 말이죠. 물론 비주얼 프로그래밍 또한 물리 기반 라이팅 모델들, 컴퓨트 셰이더, 이후에는 레이 트레이싱이 등장하며 더 복잡해졌습니다.
모던 API? #
DirectX 12, Vulkan, 그리고 Metal은 종종 ‘모던 API(Modern API)’ 로 불려집니다. 이 API들은 이제 등장한 지 10년도 더 넘었습니다. 그들이 설계될 당시 지원하고자 했던 GPU들은 지금으로부터 13년 전의 제품이며, 이는 GPU의 역사에서는 놀라울 만큼 긴 시간입니다. 오래 전 GPU 아키텍쳐들은 오늘날 널리 사용하는 연산(Compute) 집약적인 워크로드보다 전통적인 정점 및 픽셀 셰이더 작업들에 최적화되어 있습니다. 그들은 제조사(Vendor) 별로 구분된 바인딩 모델들과 데이터 경로를 가지고 있습니다. 하드웨어 차이는 동일한 API 속에서 래핑되어야 했습니다. 이로 인해 사전에 생성된 영구 객체는 매핑, 업로드, 유효성 검증 및 바인딩 비용을 줄이는 데 매우 중요했습니다.
반면, 콘솔 API들과 Mantle은 그 당시로써는 선구적인 시각으로 설계된 AMD의 GCN 아키텍쳐만을 위해 독점적으로 디자인되었습니다. GCN은 복합적인 읽기/쓰기 캐시 계층과 텍스쳐/버퍼 디스크립터를 저장하는 스칼라 레지스터를 자랑하며 사실상 모든 것을 메모리처럼 취급했습니다(*역주: 포인터 연산과 같은 방식으로 모든 자원에 자유롭게 접근 가능하다는 것을 의미합니다). 데이터를 리매핑하는 데 어떠한 복잡한 API도 필요하지 않았고, (드로우 콜 이전에 필요한)사전 작업의 필요량이 상당하게 줄어들었습니다. 콘솔 API들과 Mantle은 단 하나의 모던 GPU 아키텍쳐만을 위해 설계했기 때문에 더 적은 API 복잡도을 가졌습니다.
10년이 지났고, GPU들은 상당한 진화를 거쳤습니다. 모든 최신 GPU 아키텍쳐들은 이제 일관성 있는 최종 레벨 캐시를 갖춘 완전한 캐시 계층 구조를 특징으로 합니다. PCIe ReBAR(*역주, AMD에선 동일한 기술을 SAM-Smart Access Memory-이라 부릅니다)나 UMA를 이용해 CPU는 GPU 메모리에 직접적으로 쓰기 동작이 가능하며, 64비트 GPU 포인터가 셰이더에서 직접적으로 지원됩니다. 텍스쳐 샘플러들은 바인딩이 필요가 없으므로(Bindless) CPU 드라이버가 디스크립터 바인딩을 구성할 필요가 없으며, 텍스쳐 디스크립터는 GPU 메모리(디스크립터 힙 으로 불립니다) 안에 배열 형태로 곧바로 저장될 수 있습니다. 만약 우리가 오늘날의 최신 GPU들을 위한 API를 설계한다면, 앞선 ‘모던 API’들의 특징인 영구적인 ‘유지 모드’ 객체 대부분은 필요하지 않을 것입니다. DirectX 12.0, Metal 1, Vulkan 1.0이 감수해야 했던 타협점들은 더 이상 필요하지 않습니다. API를 극적으로 단순화할 수 있습니다.
지난 10년은 ‘모던 API’들의 약점이 드러난 시간이었습니다. PSO 순열 폭발은 우리가 해결해야 할 가장 큰 문제입니다. 제조사들(Valve, Nvidia 등)은 서로 다른 각각의 아키텍쳐/드라이버 조합을 위한 테라바이트 단위의 PSO를 저장하기 위한 거대 규모의 클라우드 서버를 가지고 있으며, 유저들의 로컬 PSO 캐시 사이즈는 100GB를 초과하기도 합니다. 게이머들이 게임의 로딩 시간이 너무 오래 걸리고 끊김(스터터링)이 심하다고 불평하는 것이 전혀 놀랍지 않습니다.
GPU와 그래픽스 API의 역사 #
그래픽스 API의 표면을 벗겨내는 것에 대해 이야가히기 전에, 그래픽스 API들이 왜 이러한 방식으로 설계되었는지에 대한 역사적인 이해가 필요합니다. OpenGL은 일부러 느리게 만들어진 것이 아니며, Vulkan도 이유 없이 복잡하게 만든 것이 아닙니다(*역주: Vulkan은 RGB 삼각형 하나를 그리는 단순한 튜토리얼에도 1000줄이 넘는 코드가 필요한 것으로 악명높습니다). 10-20년 전 GPU 하드웨어들은 극도로 다양했으며 빠르게 진화했습니다. 이러한 다양한 하드웨어 조합을 위한 크로스 플랫폼 API를 설계하기 위해선 타협이 필요했습니다.
고전(Classic)부터 시작해봅시다. 3dFX Voodoo 2 12MB (1998)은 세 개의 칩 설계를 가지고 있었는데, 이는 4MB 프레임버퍼 메모리와 연결된 하나의 단일 래스터라이저 칩과 각각 자신만의 4MB 텍스쳐 메모리와 연결된 두 개의 텍스쳐 샘플링 칩입니다. 기하 파이프라인과 프로그래밍 가능한 셰이더는 존재하지 않았습니다. CPU는 사전 변환된 삼각형 정점들을 래스터라이저에 보냈습니다. 래스터라이저는 전달받은 정점의 색상과 두 텍스쳐 샘플러를 어떻게 결합될지를 컨트롤하기 위해 구성 가능한 블렌딩 방정식을 가졌습니다. 두 텍스쳐 샘플러들은 서로간의 메모리 혹은 프레임버퍼의 값을 읽을 수 없었습니다. 그러므로 멀티 렌더 패스 역시 지원되지 않았죠. 하드웨어가 윈도우 합성을 지원하지 않았기 때문에 전용 2D 비디오 카드를 연결하기 위한 루프백 케이블이 있었습니다. 3D 렌더링은 오직 전체 화면 모드에서만 정상적으로 수행 가능했습니다. 3D 그래픽카드는 오늘날의 GPU 및 대규모 프로그래밍 가능한 SIMD 배열들과는 공통점을 찾아보기 힘든 매우 특수한 하드웨어였습니다. 이 시대의 하드웨어는 DirectX(1995)와 OpenGL(1992)의 설계애 막대한 영향을 미쳤습니다. 하위 호환성을 위해 API는 적극적인 변화 대신 점진적인 개선이 이루어졌고, 30년 전의 이러한 API설계 방식은 오늘날 우리가 소프트웨어를 작성하는 방식에 여전히 영향을 미치고 있습니다.
Nvidia의 Geforce 256은 GPU라는 용어를 만들어냈습니다. 해당 제품은 래스터라이저 외에도 최초로 기하 프로세서를 가졌습니다. 기하 프로세서, 래스터라이저 그리고 텍스쳐 샘플링 유닛은 모두 동일한 다이(die)에 통합되었고 메모리를 공유했습니다. 이에 발맞춰 DirectX 7은 두 가지 새로운 컨셉을 소개했습니다. 바로 렌더 타겟 텍스쳐(Render Target Textures)와 유니폼 상수(Uniform Constants)입니다. 멀티 패스 렌더링은 텍스쳐 샘플러들이 래스터라이저의 출력을 읽을 수 있음을 의미했으며, 이로 인해 3dFX Voodoo2의 별도 메모리 설계가 무용지물이 되었습니다.
기하 프로세서 API는 변환 행렬들(float4x4), 빛의 위치나 색상을 위한 유니폼 데이터 입력을 특징으로 합니다. 이에 대한 GPU의 구현 방식은 제조사들마다 다양했으나, 많은 제조사가 기하 엔진 내부에 작은 상수 메모리 블록을 내장하는 방식을 택했습니다. 물론 이것이 유일한 방법은 아니었습니다. OpenGL API에선 각 셰이더가 자신만의 전용 유니폼 데이터를 가질 수 있습니다. 이러한 설계는 드라이버가 상수를 셰이더 연산 스트림 안에 곧바로 임베드하는것이 가능하게 만들었으며, 이는 오늘날 OpenGL 4.6 및 OpenGL ES 3.2에도 여전히 남아 있는 API 특이점입니다.
그 당시 GPU들은 범용 읽기/쓰기 캐시가 없었습니다. 래스터라이저는 블렌딩과 깊이값 저장(Depth Buffering)을 위한 스크린 로컬 캐시를 가지고 있었고, 텍스쳐 샘플러는 데이터 프리페치를 위해 선형 보간된 정점 UV에 의존했습니다. DirectX 8 셰이더 모델 1.0에서 셰이더가 도입되었을 때, 픽셀 셰이더에서 텍스쳐의 UV를 계산하는 것은 지원되지 않았습니다. UV는 정점 단위로 계산되었으며, 하드웨어를 통해 보간되고 텍스쳐 샘플러로 곧장 전달되었습니다.
DirectX 9는 셰이더 명령어 제한을 크게 증가시켰지만, 셰이더 모델 2.0은 여전히 새로운 데이터 경로를 노출시키지 않았습니다. 정점/픽셀 셰이더 모두는 여전히 1:1 입/출력 방식으로 동작했으며, 사용자는 정점 및 속성(attributes)의 변환 계산과 픽셀 색상만 정의할 수 있었습니다. 프로그래머블한(*역주. ‘프로그래머블-Programmable’은 코드 단위로 통제 가능함을 의미합니다) load/store 연산도 지원되지 않았고, 정점 페치, 유니폼(상수) 메모리와 텍스쳐 샘플러라는 고정 기능(fixed-function) 입력 블록이 그대로 유지되었습니다.
정점 셰이더는 분리된 연산 단위였습니다. 인덱스 상수(float4 배열로 제한되었지만)와 같은 새로운 기능들을 얻었지만 여전히 텍스쳐 샘플링 지원은 미진했습니다.
Direct9 셰이더 모델 3.0은 명령어 제한을 65536개 까지 증가시켜 인간이 더 이상 셰이더 어셈블리를 작성하거나 유지보수하기 어렵게 만들었습니다. 이로 인해 HLSL(2002)과 GLSL(2002-2004)같은 고수준 쉐이딩 언어가 등장했습니다. 이러한 언어들은 각 셰이더 계산 요소들과의 1대1 대응 변환 설계를 채택했습니다. 각 셰이더 실행(Invocation)은 단일 데이터 요소(정점, 혹은 픽셀)에 대해 연산되었습니다. 프레임워크 스타일의 셰이더 설계는 그 이후 그래픽스 API 설계에 무거운 영향을 끼쳤습니다. 이것은 그 당시의 하드웨어들 강늬 차이를 추상화하는 매우 멋진 방법이었지만, 오늘날에는 확장성 문제를 드러내고 있습니다.
DirectX 11은 컴퓨트 셰이더, 범용 읽기-쓰기 버퍼, 그리고 Indirect Drawing의 지원에 대해 발표했고 이는 데이터 모델에 대한 중대한 변화(Shift)였습니다. GPU는 (상술한 기능들을 활용해)자체적으로 충분히 데이터를 공급받을 수 있게 되었습니다. 범용 버퍼의 포함은 셰이더 프로그램이 코드 수준에서 메모리 위치를 수정하고 접근할 수 있도록 했고, 이는 하드웨어 벤더들이 범용 캐시 계층을 구현하도록 강제해습니다. 셰이더들은 간단한 1대1 데이터 변환을 넘어서, 특수화되고 하드코딩된 데이터 경로의 종말을 알렸습니다. GPU 하드웨어는 범용 SIMD 설계를 향해 변화하기 시작했습니다. SIMD 유닛들은 이제 정점(Vertex), 픽셀(Pixel), 기하(Geometry), 헐(Hull), 도메인(Domain) 그리고 컴퓨트까지 서로 다른 모든 셰이더 타입들을 실행할 수 있게 되었습니다. 오늘날 이 프레임워크는 서로 다른 셰이더 시작 지점(Entry Point) 가집니다. 이는 많은 API 표면을 추가했고 구성을 어렵게 만들었습니다. 그 결과 GLSL과 HLSL은 여전히 활발한 라이브러리 생태계를 갖추지 못하고 있습니다.
DirectX 11은 수많은 버퍼 타입의 지원을 추가했는데, 각각은 특정한 하드웨어 데이터 경로의 특징을 수용하도록 설계되었습니다. 타입 지정 SRV(Shader Resource View)와 UAV(Unordered Access View), 바이트 주소 SRV & UAV, 구조화된(Structured) SRV & UAV, Append와 Consume(counter를 포함해서), 상수, 정점, 그리고 인덱스 버퍼 등입니다. 텍스쳐와 마찬가지로, DirectX에서 이 버퍼들은 불퉁명한 디스크립터를 활용합니다. 디스크립터들은 사이즈, 포맷, 프로퍼티들과 GPU 메모리상에서 데이터의 주소를 인코딩한 하드웨어 종속적인(일반적으로 128-256 비트의) 데이터 블롭입니다. DirectX 11를 지원하는 GPU들은 그들의 텍스쳐 샘플러들을 버퍼 로드 연산을 위한 지렛대로 사용합니다. 이것은 샘플러가 이미 타입 변환 하드웨어와 작은 읽기 전용 데이터 캐시를 가지고 있었던 점에서 자연스러운 결과였습니다. 타입 지정 버퍼들은동일 포맷의 텍스쳐로 지원되었으며, DirectX는 동일한 SRV 추상화를 텍스쳐와 버퍼 양쪽 모두에 사용했습니다.
불투명 버퍼 디스크립터의 사용은 버퍼 포맷이 셰이더 컴파일 시점엔 알 수 없음을 의미했습니다. 이러한 점은 텍스쳐 샘플러에 의해 관리되는 읽기 전용 버퍼들은 문제가 없었습니다. 읽기-쓰기 버퍼(DirectX의 UAV)는 초기에는 32비트와 128비트(float4) 유형으로 제한되었습니다. 이후 API 및 하드웨어 개정을 통해 UAV의 크기 제한은 점차 해결되었지만, 여전히 중요한 문제가 지속되었습니다. 바로 디스크립터가 간접 참조를 필요(포인터 포함)로 하고, 컴파일러 최적화는 제한적이며(데이터 유형을 런타임에만 알 수 있으므로), 포맷 변환 하드웨어가 (raw한 L1 캐시 로드 대비) 지연 시간을 발생시키고, 로드 시 확장은 레지스터를 더 오래 점유하먀(사용 시 확장 대비), 디스크립터 관리는 CPU 드라이버의 복잡성을 증가시키고, 서로 다른 10개의 버퍼 타입을 지원해야 함으로 인해 API 자체도 복잡하다는 것입니다.
(*역주. “사용/로드 시 확장"의 확장(expand)은 UAV, Texel Buffer등에 패킹된 데이터-RGBA8 혹은 R11G11B10등-를 실제 데이터로 사용하기 위해 벡터/스칼라 레지스터에 언패킹하는 것을 의미합니다)
-작성중-