Unity 때 엔진팀 리드였던(DOTS hybrid) 세바스티앙 알토넨의 블로그 글.
No Graphics API
Introduction
그래픽스 API, 셰이더 프레임워크, 드라이버의 복잡성은 지난 수십 년 동안 급격히 증가해왔다. 파이프라인 상태 객체(PSO-Pipeline State Object)의 폭증은 이미 통제 불가능한 수준에 이르렀다. 우리는 어떻게 해서 100GB에 달하는 로컬 셰이더 파이프라인 캐시와 이를 호스팅하기 위한 대규모 클라우드 서버를 사용하게 되었을까? 이제는 GPU와 상호작용하는 방식을 단순화하기 위해 추상화 계층과 API 표면을 어떻게 줄일지 논의할 때다.
이 블로그 글에는 많은 저수준 하드웨어 세부 내용이 포함되어 있다. 이 글을 작성하면서 나는 공개된 Linux 오픈소스 드라이버를 교차 참고하기 위해 “GPT5 Thinking” AI 모델을 사용했으며, NDA(비밀 유지 계약) 정보가 포함되지 않았음을 확인했다. 참고 자료로는 AMD RDNA ISA 문서와 GPUOpen, Nvidia PTX ISA 문서, Intel PRM, Linux 오픈소스 GPU 드라이버(Mesa, Freedreno, Turnip, Asahi), 그리고 각 벤더의 최적화 가이드 및 발표 자료가 있다. 이 블로그 글은 공개 전에 여러 업계 관계자들의 검토를 거쳤다.
Low-level graphics APIs change the industry
10년 전, 새로운 저수준 PC 그래픽스 API가 등장하면서 실시간 컴퓨터 그래픽스 분야에 큰 전환점이 찾아왔다. AMD는 Xbox One(2013)과 PlayStation 4(2013) 계약을 모두 따냈고, 그들의 새로운 Graphics Core Next(GCN) 아키텍처는 AAA 게임 개발의 사실상 기준 플랫폼이 되었다. 당시 PC 그래픽스 API였던 DirectX 11과 OpenGL 4.5는 드라이버 오버헤드가 크고 단일 스레드 렌더링을 전제로 설계되어 있었다. AAA 개발자들은 PC에서 더 높은 성능을 낼 수 있는 API를 요구했다. 이에 DICE는 AMD와 협력해 PC용 저수준 AMD GCN 전용 API인 Mantle을 만들었다. 그에 대한 대응으로 Microsoft, Khronos, Apple이 각자의 저수준 API 개발에 착수했고, 그 결과 DirectX 12, Vulkan, Metal이 탄생했다.
이 새로운 저수준 API에 대한 초기 반응은 엇갈렸다. 합성 벤치마크와 데모에서는 상당한 성능 향상이 나타났지만, Unreal이나 Unity 같은 주요 게임 엔진에서는 뚜렷한 성능 개선이 보이지 않았다. Ubisoft에서는 기존 DirectX 11 렌더러를 DirectX 12로 포팅할 경우 오히려 성능이 떨어지는 경우가 많다는 사실을 확인했다. 뭔가 잘못되어 있었다.
기존의 고수준 API는 지속 상태가 거의 없고, 세분화된 상태 설정 함수와 개별 데이터 입력을 드로우 호출 직전에 셰이더에 바인딩하는 구조였다. 반면 새로운 저수준 API는 드로우 호출을 더 저렴하게 만들기 위해 셰이더 파이프라인 상태와 바인딩을 사전에 묶어 영속 객체로 만드는 방식을 채택했다. 당시 GPU 아키텍처는 매우 이질적이었기 때문에, 데이터 재매핑·검증·업로드를 미리 수행하는 것은 큰 이점이었다. 그러나 기존 게임 엔진의 렌더링 하드웨어 인터페이스(RHI)는 세분화된 즉시 모드 렌더링에 맞춰 설계되어 있었고, 새로운 저수준 API는 데이터를 영속 객체로 묶는 방식을 요구했다.
이러한 비호환성을 해결하기 위해 RHI 아래에 새로운 저수준 그래픽 리매핑 레이어가 생겨났다. 이 레이어는 과거 OpenGL 및 DirectX 11 드라이버가 처리하던 복잡성을 떠안아, 세분화된 동적 사용자 공간 상태와 영속적인 저수준 GPU 상태 사이의 리소스 추적 및 매핑을 관리했다. 그 결과 그래픽스 프로그래머는 두 가지 역할로 분화되기 시작했다. 새로운 저수준 “드라이버” 레이어와 RHI를 다루는 저수준 그래픽스 프로그래머와, RHI 위에서 시각적 그래픽 알고리즘을 구현하는 고수준 그래픽스 프로그래머다. 한편 물리 기반 조명 모델, 컴퓨트 셰이더, 이후의 레이 트레이싱 도입 등으로 시각 프로그래밍 자체도 점점 더 복잡해지고 있었다.
Modern APIs?
DirectX 12, Vulkan, Metal은 흔히 “모던 API”라고 불린다. 하지만 이 API들도 이제 10년이 되었다. 이들은 오늘날 기준으로 13년이나 된 GPU를 지원하기 위해 설계되었는데, GPU 역사에서 13년은 엄청나게 긴 시간이다. 과거의 GPU 아키텍처는 오늘날처럼 범용적이고 연산 집약적인 워크로드보다는 전통적인 버텍스/픽셀 셰이더 작업에 최적화되어 있었다. 또한 벤더마다 서로 다른 바인딩 모델과 데이터 경로를 가지고 있었기 때문에, 이러한 하드웨어 차이를 하나의 공통 API 아래로 감싸야 했다. 이 과정에서 매핑, 업로드, 검증, 바인딩 비용을 사전에 처리하기 위해 미리 생성된 영속 객체(persistent object)가 중요했다.
반면 콘솔 API와 Mantle은 AMD의 GCN 아키텍처만을 대상으로 설계되었으며, 당시로서는 매우 선구적인 구조였다. GCN은 완전한 읽기/쓰기 캐시 계층과 텍스처 및 버퍼 디스크립터를 저장할 수 있는 스칼라 레지스터를 갖추고 있어, 모든 것을 사실상 메모리처럼 취급할 수 있었다. 복잡한 데이터 리매핑 API가 필요하지 않았고, 사전 작업도 훨씬 적었다. 단일하고 현대적인 GPU 아키텍처만을 대상으로 했기 때문에, 콘솔 API와 Mantle은 API 복잡성이 훨씬 낮았다.
그로부터 10년이 흐르는 동안 GPU는 크게 진화했다. 이제 모든 현대 GPU 아키텍처는 일관성을 갖춘 최상위 캐시를 포함한 완전한 캐시 계층을 제공한다. PCIe REBAR나 UMA를 통해 CPU가 GPU 메모리에 직접 쓸 수 있고, 64비트 GPU 포인터도 셰이더에서 직접 지원된다. 텍스처 샘플러는 바인드리스(bindless) 방식으로 동작해 CPU 드라이버가 디스크립터 바인딩을 구성할 필요가 없다. 텍스처 디스크립터는 GPU 메모리 내 배열(흔히 descriptor heap이라 불림)에 직접 저장될 수 있다. 오늘날의 GPU를 기준으로 API를 새롭게 설계한다면, 이러한 영속적인 “retained mode” 객체의 상당수는 더 이상 필요하지 않을 것이다. DirectX 12.0, Metal 1, Vulkan 1.0이 감수해야 했던 타협들은 이제 필요하지 않으며, API를 대폭 단순화할 수 있다.
지난 10년은 이들 모던 API의 약점도 드러냈다. 그중 가장 큰 문제는 PSO(파이프라인 상태 객체) 조합 폭발이다. 벤더(Valve, Nvidia 등)는 아키텍처와 드라이버 조합별로 테라바이트 단위의 PSO를 저장하는 대규모 클라우드 서버를 운영하고 있다. 사용자 로컬 PSO 캐시가 100GB를 넘는 경우도 있다. 게이머들이 로딩 시간이 길고 끊김(stutter)이 발생한다고 불평하는 것도 무리가 아니다.
The history of GPUs and APIs
API 표면을 줄이는 이야기를 하기 전에, 그래픽스 API가 역사적으로 왜 이런 방식으로 설계되었는지를 이해할 필요가 있다. OpenGL이 일부러 느리게 설계된 것도 아니고, Vulkan이 일부러 복잡하게 만들어진 것도 아니다. 10~20년 전의 GPU 하드웨어는 매우 다양했고 빠르게 진화하고 있었다. 이렇게 이질적인 하드웨어 집합을 아우르는 크로스 플랫폼 API를 설계하려면 타협이 불가피했다.
고전적인 사례부터 살펴보자. 3dFX Voodoo 2 12MB(1998)는 세 개의 칩으로 구성된 설계였다. 하나의 래스터라이저 칩이 4MB 프레임버퍼 메모리에 연결되어 있었고, 두 개의 텍스처 샘플링 칩이 각각 4MB 텍스처 메모리에 연결되어 있었다. 지오메트리 파이프라인도 없었고, 프로그래머블 셰이더도 존재하지 않았다. CPU가 변환을 마친 삼각형 정점을 래스터라이저로 직접 전송했다. 래스터라이저에는 정점 색상과 두 텍스처 샘플러 결과를 어떻게 결합할지 제어하는 블렌딩 방정식이 구성 가능하게 마련되어 있었다. 텍스처 샘플러는 서로의 메모리나 프레임버퍼를 읽을 수 없었기 때문에, 다중 렌더 패스를 지원하지 않았다. 또한 하드웨어가 윈도우 합성을 지원하지 못했기 때문에, 별도의 2D 비디오 카드와 연결하기 위한 루프백 케이블이 필요했다. 3D 렌더링은 전용 전체 화면 모드에서만 동작했다.
당시의 3D 그래픽 카드는 오늘날의 대규모 프로그래머블 SIMD 배열을 갖춘 GPU와는 거의 공통점이 없는, 매우 특수화된 하드웨어였다. 이 시대의 하드웨어 특성은 DirectX(1995)와 OpenGL(1992)의 설계에 큰 영향을 미쳤다. 하위 호환성은 매우 중요한 요소였고, API는 점진적으로 개선되어 왔다. 그렇게 30년 전에 설계된 API 구조가 오늘날 우리가 소프트웨어를 작성하는 방식에도 여전히 영향을 미치고 있다.

Nvidia의 GeForce 256은 “GPU”라는 용어를 처음 사용했다. 이 칩은 래스터라이저에 더해 지오메트리 프로세서를 포함하고 있었다. 지오메트리 프로세서, 래스터라이저, 텍스처 샘플링 유닛은 모두 하나의 다이에 통합되어 메모리를 공유했다. DirectX 7은 렌더 타깃 텍스처와 유니폼 상수라는 두 가지 새로운 개념을 도입했다. 멀티패스 렌더링이 가능해지면서 텍스처 샘플러가 래스터라이저의 출력을 읽을 수 있게 되었고, 이는 3dFX Voodoo 2의 분리된 메모리 구조를 무력화했다.
지오메트리 프로세서 API는 변환 행렬(float4x4), 광원 위치, 색상(float4) 등을 위한 유니폼 데이터 입력을 제공했다. GPU 구현은 제조사마다 달랐으며, 많은 경우 지오메트리 엔진 내부에 소규모 상수 메모리 블록을 포함하는 방식을 택했다. 하지만 이것만이 유일한 방식은 아니었다. OpenGL API에서는 각 셰이더가 자체적인 영속 유니폼을 가졌다. 이 설계 덕분에 드라이버가 상수를 셰이더 명령 스트림에 직접 삽입할 수 있었는데, 이러한 API 특성은 오늘날 OpenGL 4.6과 ES 3.2에도 여전히 남아 있다.
당시 GPU에는 범용 읽기/쓰기 캐시가 존재하지 않았다. 래스터라이저는 블렌딩과 깊이 버퍼링을 위한 화면 지역 캐시를 갖고 있었고, 텍스처 샘플러는 선형 보간된 정점 UV를 기반으로 데이터 프리패칭을 수행했다. DirectX 8의 셰이더 모델 1.0(SM 1.0)에서 셰이더가 도입되었을 때, 픽셀 셰이더 단계는 텍스처 UV를 계산하는 기능을 지원하지 않았다. UV는 정점 단위에서 계산되어 하드웨어에 의해 보간된 뒤, 텍스처 샘플러로 직접 전달되었다.
DirectX 9는 셰이더 명령 수 제한을 크게 늘렸지만, 셰이더 모델 2.0은 새로운 데이터 경로를 노출하지는 않았다. 버텍스 셰이더와 픽셀 셰이더는 여전히 1:1 입력-출력 구조로 동작했으며, 사용자는 정점 위치와 속성의 변환 수학, 그리고 픽셀 색상만을 커스터마이즈할 수 있었다. 프로그래머블 로드/스토어는 지원되지 않았다. 고정 기능 입력 블록(버텍스 페치, 유니폼/상수 메모리, 텍스처 샘플러)은 계속 유지되었다. 버텍스 셰이더는 별도의 실행 유닛이었으며, 상수 인덱싱(float4 배열로 제한됨) 같은 새로운 기능을 얻었지만 여전히 텍스처 샘플링은 지원하지 않았다.
DirectX 9 셰이더 모델 3.0은 명령 수 제한을 65,536으로 늘려, 사람이 셰이더 어셈블리를 직접 작성하고 유지하기 어렵게 만들었다. 이에 따라 고수준 셰이딩 언어가 등장했다. HLSL(2002)과 GLSL(2002~2004)이다. 이 언어들은 1:1 요소 단위 변환 설계를 계승했다. 각 셰이더 인보케이션은 하나의 데이터 요소(정점 또는 픽셀)에 대해 동작했다. 이러한 프레임워크식 셰이더 설계는 이후 그래픽스 API 설계에 큰 영향을 미쳤다. 당시에는 하드웨어 차이를 추상화하는 좋은 방법이었지만, 오늘날에는 확장성 측면에서 한계를 드러내고 있다.
DirectX 11은 데이터 모델 측면에서 큰 전환점이었다. 컴퓨트 셰이더, 범용 읽기/쓰기 버퍼, 간접 드로우를 지원하기 시작하면서 GPU가 스스로 작업을 생성하고 처리할 수 있게 되었다. 범용 버퍼의 도입으로 셰이더는 프로그래머블 메모리 위치에 접근하고 수정할 수 있게 되었고, 이에 따라 하드웨어 벤더들은 범용 캐시 계층을 구현해야 했다. 셰이더는 더 이상 단순한 1:1 데이터 변환에 머물지 않게 되었고, 특수화된 고정 데이터 경로의 시대가 끝났다. GPU 하드웨어는 점차 범용 SIMD 설계로 이동했다. 이제 SIMD 유닛은 버텍스, 픽셀, 지오메트리, 헐, 도메인, 컴퓨트 등 모든 셰이더 유형을 실행한다. 오늘날 프레임워크에는 16개의 서로 다른 셰이더 엔트리 포인트가 존재한다. 이는 API 표면을 크게 늘리고 조합을 어렵게 만든다. 그 결과 GLSL과 HLSL은 여전히 활발한 라이브러리 생태계를 갖추지 못하고 있다.
DirectX 11에는 하드웨어 데이터 경로의 특수성을 반영한 다양한 버퍼 유형이 존재했다. typed SRV & UAV, byte address SRV & UAV, structured SRV & UAV, append & consume(카운터 포함), constant, vertex, index 버퍼 등 일종의 “버퍼 동물원”이었다. 텍스처와 마찬가지로 DirectX의 버퍼는 불투명 디스크립터를 사용한다. 디스크립터는 하드웨어에 특화된(보통 128~256비트) 데이터 블록으로, 리소스의 크기, 포맷, 속성, GPU 메모리 주소를 인코딩한다. DirectX 11 GPU는 버퍼 로드(gather) 연산에 텍스처 샘플러를 활용했다. 샘플러는 타입 변환 하드웨어와 소형 읽기 전용 캐시를 이미 갖추고 있었기 때문이다. Typed 버퍼는 텍스처와 동일한 포맷을 지원했고, DirectX는 텍스처와 버퍼 모두에 동일한 SRV(Shader Resource View) 추상화를 사용했다.
불투명 디스크립터를 사용한다는 것은 버퍼 포맷이 셰이더 컴파일 시점에 알려지지 않는다는 의미다. 읽기 전용 버퍼에서는 샘플러가 이를 처리했기 때문에 문제가 크지 않았다. 그러나 읽기/쓰기 버퍼(UAV)는 초기에는 32비트 및 128비트(vec4) 타입으로 제한되었다. 이후 API와 하드웨어 개정으로 typed UAV 로드의 제약이 완화되었지만, 근본적인 문제는 남아 있었다. 디스크립터는 간접 참조(포인터 포함)를 요구하고, 데이터 타입이 런타임에만 알려지므로 컴파일러 최적화가 제한되며, 포맷 변환 하드웨어는 지연(latency)을 추가하고, 로드 시 확장(expand at load)은 레지스터 점유 시간을 늘리며, 디스크립터 관리는 CPU 드라이버 복잡성을 증가시키고, API 자체도 지나치게 복잡하다(열 가지 버퍼 유형).
DirectX 11에서 structured buffer는 사용자 정의 struct 타입을 허용한 유일한 버퍼였다. 다른 버퍼 유형은 단순한 스칼라/벡터 요소의 균질 배열이었다. 하지만 structured buffer는 다른 버퍼 유형과 레이아웃 호환이 되지 않았다. structured buffer 뷰를 typed buffer, byte address buffer, vertex/index buffer에 적용할 수 없었다. 그 이유는 structured buffer가 구형 vec4 아키텍처에 최적화된 AoSoA 스위즐 방식을 내부적으로 사용했기 때문이다. 이러한 하드웨어 특화 최적화는 structured buffer의 활용성을 제한했다.
DirectX 12는 모든 버퍼를 선형 메모리 구조로 만들어 서로 호환되도록 했다. SM 6.2는 byte address buffer에 대해 load<T> 문법을 추가해 임의 오프셋에서 구조체를 깔끔하게 로드할 수 있게 했다. 하지만 하위 호환성 때문에 기존 버퍼 유형은 여전히 유지되고 있으며, 모든 버퍼는 여전히 불투명 디스크립터를 사용한다. HLSL은 여전히 64비트 GPU 포인터를 지원하지 않는다. 반면 Nvidia CUDA(2007)는 처음부터 64비트 포인터에 전적으로 의존했지만, 초기에는 학술 분야에 주로 사용되었다. 오늘날 CUDA는 AI 분야의 선도 플랫폼이 되었고, 하드웨어 설계에도 큰 영향을 미치고 있다.
DirectX 12 출시 당시 16비트 레지스터 및 16비트 연산 지원은 혼란스러웠다. Microsoft는 DirectX 12를 Windows 7에 백포트하지 않는 결정을 내렸고, Windows 8용 셰이더 바이너리는 16비트 타입을 지원했지만 대부분의 게이머는 여전히 Windows 7을 사용했다. 개발자들은 두 종류의 셰이더를 배포하고 싶어하지 않았다. OpenGL의 lowp/mediump 명세도 혼란스러웠고, 비트 깊이가 명확히 표준화되지 않았다. mediump는 모바일 게임에서 인기 있는 최적화였지만, 많은 PC 드라이버는 이를 무시했다. PS4 Pro가 2016년에 double-rate fp16을 지원하기 전까지 AAA 게임은 16비트 연산을 거의 사용하지 않았다.
AI, 레이 트레이싱, GPU 기반 렌더링이 부상하면서 GPU 벤더들은 원시 데이터 로드 경로를 최적화하고 더 크고 빠른 범용 캐시를 제공하는 데 집중하기 시작했다. 텍스처 샘플러를 통한 로드는 현대 셰이더에서 흔한 종속적 로드 체인에 과도한 지연을 초래했다. 하드웨어는 8비트, 16비트, 64비트 타입과 포인터를 네이티브로 지원하게 되었다.
대부분의 벤더는 고정 기능 버텍스 페치 하드웨어를 제거하고, 대신 버텍스 셰이더에서 표준 raw load 명령을 사용하도록 했다. 완전한 프로그래머블 버텍스 페치는 클러스터 기반 GPU 주도 렌더링과 같은 새로운 알고리즘을 가능하게 했다. 고정 기능 하드웨어에 쓰이던 트랜지스터 예산은 다른 곳에 활용될 수 있었다.
메시 셰이더는 래스터라이저 진화의 정점이라 할 수 있다. 인덱스 중복 제거 하드웨어와 포스트 트랜스폼 캐시를 제거하고, 모든 입력을 원시 메모리로 취급한다. 사용자는 메시를 내부적으로 정점을 공유하는 독립적인 메시릿(meshlet)으로 나누어야 하며, 이는 보통 오프라인에서 수행된다. GPU는 드로우 호출마다 병렬 인덱스 중복 제거를 수행할 필요가 없어 전력과 트랜지스터를 절약한다. 오늘날 Nvidia 매출의 90%가 AI에서 나오고 게임은 10%에 불과하며, 레이 트레이싱도 성장하고 있다는 점을 고려하면, 고정 기능 지오메트리 하드웨어가 최소 수준으로 축소되고 드라이버가 자동으로 버텍스 셰이더를 메시 셰이더로 변환하는 시점이 머지않았을 가능성이 크다.
모바일 GPU는 타일 기반 렌더러다. 타일러는 삼각형을 작은 타일(보통 16x16~64x64 픽셀)로 분할한다. 메시 셰이더는 이 목적에는 너무 거칠다. 메시릿을 작은 타일로 다시 분할하면 과도한 지오메트리 오버셰이딩이 발생한다. 따라서 명확한 수렴 경로는 없으며, 여전히 버텍스 셰이더 경로를 지원해야 한다.
DirectX 12.0, Vulkan 1.0, Metal 1.0이 등장한 10년 전에는 GPU 하드웨어가 바인드리스 리소스를 널리 지원하지 않았다. API는 하드웨어 차이를 추상화하기 위해 복잡한 바인딩 모델을 도입했다. DirectX는 스테이지당 최대 128개 리소스 인덱싱을 허용했지만, Vulkan과 Metal은 초기에는 디스크립터 인덱싱을 지원하지 않았다. 개발자들은 텍스처 아틀라스 패킹이나 메시 병합 같은 전통적 우회 기법을 계속 사용해야 했다. 지난 10년 동안 GPU 하드웨어는 크게 발전했고, 범용 바인드리스 SIMD 설계로 수렴했다.
이제 현대적인 바인드리스 하드웨어만을 전제로 그래픽 API와 셰이더 언어를 설계한다면 얼마나 단순해질 수 있을지 살펴보자.
Modern GPU memory management
메모리 관리부터 살펴보자. 과거의 그래픽스 API는 GPU 메모리 관리를 완전히 추상화했다. 이는 옛 GPU들이 분리된 메모리 구조를 갖거나, 캐시 일관성 문제가 있는 특수 데이터 경로를 사용했기 때문에 필요한 설계였다. 10년 전 DirectX 12와 Vulkan이 등장했을 무렵에는 GPU 하드웨어가 충분히 성숙해져, 사용자에게 배치 힙(placement heap)을 노출할 수 있게 되었다. 콘솔은 이미 여러 세대 전부터 메모리를 직접 노출해왔고, 개발자들도 PC와 모바일에서 비슷한 유연성을 요구했다. Apple은 Vulkan과 DirectX 12가 나온 지 4년 뒤 Metal 2에서 배치 힙을 도입했다.
현대 API에서는 GPU 드라이버가 제공하는 메모리 유형을 파악하기 위해 사용자가 힙 타입을 열거해야 한다. 큰 덩어리로 메모리를 미리 할당하고 사용자 공간 할당자(suballocator)로 나누어 쓰는 것은 좋은 관행이다. 그러나 Vulkan에는 설계상의 문제가 있다. 먼저 텍스처나 버퍼 객체를 생성한 뒤에야, 해당 리소스와 호환되는 힙 타입을 질의할 수 있다. 이는 사용자를 지연 할당(lazy allocation) 패턴으로 몰아넣으며, 런타임에서 성능 끊김이나 메모리 급증을 유발할 수 있다. 또한 GPU 메모리 할당을 크로스 플랫폼 라이브러리로 감싸기 어렵게 만든다. 예를 들어 AMD VMA는 Vulkan 전용 버퍼/텍스처 객체를 생성하는 동시에 메모리 할당 까지 수행한다. 우리는 이 두 관심사를 완전히 분리하고자 한다.
오늘날 CPU는 GPU 메모리를 완전히 가시적으로 접근할 수 있다. 통합 GPU는 UMA를 사용하고, 최신 외장 GPU는 PCIe Resizable BAR를 지원한다. 전체 GPU 힙을 매핑할 수 있다. Vulkan의 힙 API는 자연스럽게 CPU 매핑 GPU 힙을 지원하며, DirectX 12도 2023년에 HEAP_TYPE_GPU_UPLOAD를 통해 이를 지원하게 되었다.
CUDA는 GPU 메모리 할당에 대해 단순한 설계를 갖는다. GPU malloc API는 크기를 입력받아 CPU에서 매핑된 포인터를 반환하고, GPU free API는 해당 메모리를 해제한다. CUDA는 CPU 매핑 GPU 메모리를 지원하지 않는다. GPU는 PCIe 버스를 통해 CPU 메모리를 읽는다. CUDA는 GPU 메모리 할당도 지원하지만, 이 메모리는 CPU가 직접 쓸 수 없다.
우리는 CUDA의 malloc 설계와 CPU 매핑 GPU 메모리(UMA/ReBAR)를 결합한다. 이는 양쪽의 장점을 취한 방식이다. 데이터는 CPU가 쓰기에도 빠르고 GPU가 읽기에도 빠르며, 동시에 깔끔하고 사용하기 쉬운 설계를 유지할 수 있다.
// Allocate GPU memory for array of 1024 uint32 uint32 * numbers = gpuMalloc(1024 * sizeof(uint32)); // Directly initialize (CPU mapped GPU pointer) for (int i = 0; i < 1024; i++) numbers[i] = random(); gpuFree(numbers); |
기본 gpuMalloc 정렬(alignment)은 16바이트(vec4 정렬)이다. 더 큰 정렬이 필요하면 gpuMalloc(size, alignment) 오버로드를 사용하면 된다. 예제 코드에서는 gpuMalloc<T> 래퍼를 사용해 gpuMalloc(elements * sizeof(T), alignof(T)) 형태로 호출한다.
GPU 메모리에 데이터를 직접 쓰는 방식은 드로우 인자, 유니폼, 디스크립터 같은 작은 데이터에 최적이다. 그러나 큰 영속 데이터의 경우에는 여전히 복사(copy) 연산을 수행하는 것이 바람직하다. GPU는 캐시 지역성을 개선하기 위해 텍스처를 Morton-order와 유사한 스위즐 레이아웃으로 저장한다. DirectX 11.3과 12는 이 스위즐 레이아웃을 표준화하려 했지만, 모든 GPU 제조사를 설득하지는 못했다. 일반적으로 텍스처 스위즐은 드라이버가 제공하는 복사 명령을 통해 수행된다. 이 복사 명령은 CPU 매핑 “업로드” 힙에서 선형 텍스처 데이터를 읽어, 프라이빗 GPU 힙의 스위즐 레이아웃으로 기록한다.
모든 최신 GPU는 무손실 델타 컬러 압축(DCC)도 지원한다. 최신 GPU의 복사 엔진은 DCC 압축과 해제를 처리할 수 있다. DCC와 Morton 스위즐은 텍스처를 프라이빗 GPU 힙으로 복사하려는 주요 이유다. 최근에는 버퍼 데이터에 대해서도 범용 무손실 메모리 압축이 추가되었다. 하지만 메모리 힙이 CPU 매핑 상태라면, CPU가 해당 압축 형식을 이해하지 못하기 때문에 GPU는 벤더별 무손실 압축을 활성화할 수 없다. 따라서 데이터를 압축하려면 복사 명령을 사용해야 한다.
프라이빗 GPU 메모리를 지원하려면 gpuMalloc 함수에 메모리 타입 파라미터가 필요하다. 기본 메모리 타입은 CPU 매핑 GPU 메모리(write-combined CPU 접근)여야 한다. 이는 GPU가 읽기에 빠르고, CPU도 일반 포인터처럼 직접 쓸 수 있다. GPU 전용 메모리는 텍스처와 대형 GPU 전용 버퍼에 사용된다. CPU는 이 GPU 포인터에 직접 쓸 수 없다. 사용자는 먼저 CPU 매핑 GPU 메모리에 데이터를 작성한 뒤, 복사 명령을 발행해 데이터를 최적의 압축 형식으로 변환한다. 최신 텍스처 샘플러와 디스플레이 엔진은 압축된 GPU 데이터를 직접 읽을 수 있으므로, 이후 별도의 데이터 레이아웃 변환은 필요하지 않다(“Modern barriers” 장 참고). 업로드된 데이터는 즉시 사용 가능하다.
GPU 포인터에는 두 가지 유형이 있다. 하나는 CPU 매핑 가상 주소이고, 다른 하나는 GPU 가상 주소다. GPU는 GPU 주소만 역참조할 수 있으므로, GPU 데이터 구조 내의 모든 포인터는 GPU 주소를 사용해야 한다. CPU 매핑 주소는 CPU가 데이터를 쓸 때만 사용된다. CUDA는 CPU 매핑 주소를 GPU 주소로 변환하는 API(cudaHostGetDevicePointer)를 제공한다. Metal 4의 버퍼 객체는 .contents(CPU 매핑 주소)와 .gpuAddress(GPU 주소) 두 가지 접근자를 제공한다. gpuMalloc API는 Metal처럼 관리 객체 핸들을 반환하는 대신 포인터를 반환하므로, 여기서는 CUDA 방식(gpuHostToDevicePointer)을 선택한다. 이 주소 변환 호출은 비용이 없지 않다. 드라이버는 이를 해시 맵으로 구현할 가능성이 높으며(기본 주소 외의 변환이 필요하면 트리 구조가 필요할 수도 있다). 이상적으로는 할당당 한 번만 주소 변환을 수행하고, 사용자 공간 구조체에 (void *cpu, void *gpu) 형태로 캐시하는 것이 좋다. 내가 구현한 사용자 공간 GPUBumpAllocator도 이 방식을 사용한다(전체 구현은 부록 참고).
// Load a mesh using a 3rd party library auto mesh = createMesh("mesh.obj"); auto upload = uploadBumpAllocator.allocate(mesh.byteSize); // Custom bump allocator (wraps a gpuMalloc ptr) mesh.load(upload.cpu); // Allocate GPU-only memory and copy into it void* meshGpu = gpuMalloc(mesh.byteSize, MEMORY_GPU); gpuMemCpy(commandBuffer, meshGpu, upload.gpu); |
Vulkan에는 최근 VK_EXT_host_image_copy라는 새로운 확장이 추가되었다. 이 확장은 드라이버가 CPU에서 GPU로 직접 이미지 복사를 수행하도록 하며, 이 과정에서 하드웨어 특화 텍스처 스위즐을 CPU에서 처리한다. 현재 이 확장은 UMA 아키텍처에서만 지원되지만, PCIe ReBAR 환경에서도 기술적으로 불가능한 것은 아니다. 다만 이 API는 DCC를 지원하지 않는다. CPU에서 DCC 압축을 수행하는 것은 비용이 너무 크기 때문이다. 이 확장은 주로 블록 압축 텍스처에 유용한데, 블록 압축 텍스처는 DCC가 필요하지 않기 때문이다. 따라서 이 방식은 하드웨어 복사를 통한 GPU 프라이빗 메모리 전송을 완전히 대체할 수는 없다.
또한 리드백(readback)을 위해 세 번째 메모리 타입, 즉 CPU 캐시드 메모리도 필요하다. 이 메모리 타입은 CPU와의 캐시 일관성 때문에 GPU 쓰기 속도가 느리다. 게임에서는 리드백을 자주 사용하지 않으며, 대표적인 용도는 스크린샷이나 가상 텍스처링 리드백이다. 반면 AI 학습 및 추론과 같은 GPGPU 알고리즘은 CPU와 GPU 간의 효율적인 데이터 교환에 크게 의존한다.
CUDA의 malloc 방식의 단순함과 CPU 매핑 GPU 메모리를 결합하면, 최소한의 API 표면으로도 유연하고 빠른 GPU 메모리 할당 시스템을 만들 수 있다. 이는 최소주의적인 현대 그래픽스 API를 설계하기 위한 훌륭한 출발점이 된다.
Modern data
CUDA, Metal, OpenCL은 64비트 포인터 의미론을 지원하는 C/C++ 기반 셰이더 언어를 사용한다. 이러한 언어는 적절히 정렬된 GPU 메모리의 임의 위치로부터 구조체를 로드하고 저장할 수 있다. 넓은 로드(combine), 레지스터 매핑, 비트 추출과 같은 최적화는 컴파일러가 내부적으로 처리한다. 많은 최신 GPU는 레지스터의 8/16비트 부분을 추출하는 무료 명령어 수정자(instruction modifier)를 제공하므로, 컴파일러는 8비트 및 16비트 값을 하나의 레지스터에 패킹할 수 있다. 이는 셰이더 코드를 깔끔하고 효율적으로 유지하는 데 도움이 된다.
예를 들어 32비트 값 8개로 이루어진 구조체를 로드하면, 컴파일러는 대개 128비트 폭의 로드 두 번(각각 4개 레지스터 채움)으로 변환한다. 이는 로드 명령 수를 4배 줄이는 효과가 있다. 특히 구조체에 8비트 또는 16비트 필드가 포함된 경우, 넓은 로드는 훨씬 빠르다. GPU는 ALU 밀도가 높고 레지스터 파일이 크지만, CPU에 비해 메모리 경로는 상대적으로 느리다. CPU는 보통 두 개의 로드 포트를 통해 매 사이클 로드를 수행할 수 있지만, 최신 GPU에서는 4사이클당 하나의 SIMD 로드가 가능하다. 따라서 넓은 로드 후 셰이더에서 언팩(unpack)하는 방식이 데이터 처리에 가장 효율적인 경우가 많다.
DirectX 게임에서는 전통적으로 8~16비트의 압축 데이터가 텍셀 버퍼(Buffer<T>)에 저장되어 왔다. 그러나 현대 GPU는 컴퓨트 워크로드에 최적화되어 있다. 현재 원시(raw) 버퍼 로드 명령은 텍셀 버퍼 대비 최대 2배 높은 처리량과 최대 3배 낮은 지연 시간을 제공한다. 텍셀 버퍼는 더 이상 최적의 선택이 아니다. 텍셀 버퍼는 구조체 데이터를 지원하지 않아, 사용자는 데이터를 여러 텍셀 버퍼에 SoA(Structure of Arrays) 형태로 분리해야 한다. 각 텍셀 버퍼는 자체 디스크립터를 갖고 있으며, 데이터 접근 전에 이를 로드해야 한다. 이는 리소스(SGPR, 디스크립터 캐시 슬롯)를 소모하고, 단일 64비트 원시 포인터를 사용하는 것보다 초기 지연을 증가시킨다. 또한 SoA 레이아웃은 비선형 인덱스 조회(예: 머티리얼, 텍스처, 삼각형, 인스턴스, 본 ID)에서 캐시 미스를 크게 증가시킨다. 텍셀 버퍼는 정규화된([0,1] 및 [-1,1]) 타입을 부동소수점 레지스터로 무료 변환해준다는 장점이 있다. ALU 비용은 없지만, 넓은 로드를 지원하지 못하고(로드 결합 불가), 명령이 느린 텍스처 샘플러 경로를 통과한다. 좁은 텍셀 버퍼 로드는 레지스터 사용량도 증가시킨다. 예를 들어 RGBA8_UNORM을 vec4로 로드하면 즉시 4개의 벡터 레지스터가 할당된다. 샘플러 하드웨어는 결국 이 레지스터에 값을 기록한다. 컴파일러는 로드 지연을 숨기기 위해 로드→사용 간 거리를 최대화하려고 셰이더 초반에 로드 명령을 배치한다. 이렇게 하면 ALU 연산과 겹치게 하여 지연을 숨기고 여러 로드를 중첩시킬 수 있다. 반면 넓은 원시 로드를 사용하면 uint8x4 데이터는 단 하나의 32비트 레지스터만 차지한다. 실제 사용 시점에 8비트 채널을 언팩하면 되므로 레지스터 수명도 훨씬 짧다. 현대 GPU는 언팩 없이도 16비트 레지스터의 상·하위 절반에 직접 접근할 수 있고, 일부는 8비트 접근도 가능하다(예: AMD의 SDWA 수정자). 패킹된 double-rate 연산은 2x16비트 변환 명령을 더 빠르게 수행할 수 있다. 또한 일부 GPU 아키텍처(Nvidia, AMD)는 64비트 포인터 기반 원시 로드를 VRAM에서 직접 그룹 공유 메모리로 수행할 수 있어, 지연 숨김을 위한 레지스터 사용량을 더욱 줄일 수 있다. 64비트 포인터를 사용하면, 게임 엔진은 AI 하드웨어 최적화의 이점을 자연스럽게 활용할 수 있다.
포인터 기반 시스템은 메모리 정렬을 명시적으로 드러낸다. DirectX나 Vulkan에서 버퍼 객체를 할당할 때는 API를 통해 정렬 요구사항을 질의해야 하며, 버퍼 바인딩 오프셋도 올바르게 정렬되어야 한다. Vulkan은 바인딩 오프셋 정렬을 질의하는 API를 제공하고, DirectX는 고정된 정렬 규칙을 갖는다. 이러한 정렬 계약은 저수준 셰이더 컴파일러가 정렬된 4×32바이트 폭 로드와 같은 최적 코드를 생성할 수 있게 해준다. 하지만 DirectX의 ByteAddressBuffer 추상화에는 설계상의 결함이 있다. load2, load3, load4 명령은 4바이트 정렬만 요구한다. SM 6.2의 load<T> 역시 요소 단위 정렬만 요구한다(예: half4 = 2, float4 = 4). 일부 GPU 벤더(예: Nvidia)는 ByteAddressBuffer.load4를 네 개의 개별 로드 명령으로 분할해야 한다. 버퍼 추상화가 항상 잘못된 코드 생성(codegen)을 가려주지는 못하며, 잘못된 코드 생성은 수정하기도 어렵다. CUDA나 Metal과 같은 C/C++ 기반 언어에서는 alignas 속성을 통해 구조체 정렬을 명시할 수 있다. 예제 코드의 루트 구조체에는 모두 alignas(16)을 사용한다.
기본적으로 GPU 쓰기는 동일한 스레드 그룹(= 동일한 컴퓨트 유닛) 내의 스레드에서만 가시적이다. 이는 비일관성(non-coherent) L1 캐시 설계를 가능하게 한다. 그룹 간 가시성은 일반적으로 배리어로 보장한다. 단일 디스패치 내에서 그룹 간 메모리 가시성이 필요하다면, 사용자는 버퍼 바인딩에 [globallycoherent] 속성을 지정한다. 그러면 셰이더 컴파일러는 해당 버퍼 접근에 대해 일관성 있는 로드/스토어 명령을 생성한다. 우리는 버퍼 객체 대신 64비트 포인터를 사용하므로, 명시적인 일관성(coherent) 로드/스토어 명령을 제공한다. 문법은 원자적 로드/스토어와 유사하다. 또한 전체 캐시 계층을 우회하는 비일시적(non-temporal) 로드/스토어 명령도 제공할 수 있다.
Vulkan은 2019년에 도입된 VK_KHR_buffer_device_address extension을 통해 64비트 포인터를 지원한다. 이 확장은 모든 GPU 벤더(모바일 포함)에서 널리 지원되지만, Vulkan 1.4의 코어에는 포함되지 않았다. BDA의 주요 문제는 GLSL과 HLSL 셰이더 언어에서 포인터를 지원하지 않는다는 점이다. 사용자는 대신 원시 64비트 정수를 사용해야 한다. 64비트 정수는 구조체로 캐스팅할 수 있으며, 구조체는 별도의 BDA 전용 문법으로 정의된다. 배열 인덱싱을 위해서는 인덱스 주소 계산을 컴파일러가 생성하도록, 배열을 포함하는 별도의 BDA 구조체 타입을 선언해야 한다. 현재 디버깅 지원도 제한적이다. 사용성은 매우 중요하며, HLSL과 GLSL이 포인터를 네이티브로 지원하기 전까지 BDA는 틈새 기능에 머물 것이다. 이는 포인터 지원이 언어의 핵심인 CUDA, OpenCL, Metal과는 대조적이다. 이들 환경에서는 디버깅도 원활하다.
DirectX 12는 셰이더에서 포인터를 지원하지 않는다. 그 결과 HLSL은 배열을 함수 매개변수로 전달할 수 없다. UBO/SSBO 안에 머티리얼 배열을 두는 것조차 매크로를 동원한 우회가 필요하다. groupshared 메모리 배열을 함수 간에 전달할 수 없기 때문에, prefix sum이나 정렬 같은 재사용 가능한 함수 작성도 사실상 불가능하다. 각 유틸리티 헤더/라이브러리마다 별도의 전역 배열을 선언할 수는 있지만, 컴파일러는 각 배열마다 groupshared 메모리를 따로 할당해 점유율(occupancy)을 낮춘다. groupshared 메모리를 쉽게 별칭(alias) 처리할 방법도 없다. GLSL도 동일한 문제를 가진다. 반면 CUDA나 Metal MSL 같은 포인터 기반 언어에서는 배열과 관련해 이런 제약이 없다. CUDA는 방대한 서드파티 라이브러리 생태계를 갖추고 있으며, 이 생태계는 Nvidia를 세계에서 가장 가치 있는 기업으로 만드는 데 기여했다. 그래픽 셰이딩 언어도 현대적 기준에 맞게 진화해야 한다. 우리에게도 라이브러리 생태계가 필요하다.
이후 예제에서는 CUDA와 Metal MSL에 가까운 C/C++ 스타일 셰이딩 언어를 사용하되, 그래픽스 관련 요소에는 일부 HLSL 스타일의 시스템 값(SV) 시맨틱을 혼합해 설명할 것이다.
Root arguments
운영체제의 스레딩 API는 일반적으로 스레드 함수에 하나의 64비트 void 포인터만 전달한다. 운영체제는 사용자의 데이터 입력 레이아웃에 관여하지 않는다. 이와 같은 철학을 GPU 커널의 데이터 입력에도 적용해보자. 셰이더 커널은 단 하나의 64비트 포인터를 전달받고, 커널 함수 시그니처에서 이를 원하는 구조체로 캐스팅한다. 개발자는 CPU와 GPU 양쪽에서 동일한 C/C++ 공용 헤더를 사용할 수 있다.
// Common header... struct alignas(16) Data { // Uniform data float16x4 color; // 16-bit float vector uint16x2 offset; // 16-bit integer vector const uint8* lut; // pointer to 8-bit data array // Pointers to in/out data arrays const uint32* input; uint32* output; }; // CPU code... gpuSetPipeline(commandBuffer, computePipeline); auto data = myBumpAllocator.allocate<Data>(); // Custom bump allocator (wraps gpuMalloc ptr, see appendix) data.cpu->color = {1.0f, 0.0f, 0.0f, 1.0f}; data.cpu->offset = {16, 0}; data.cpu->lut = luts.gpu + 64; // GPU pointers support pointer math (no need for offset API) data.cpu->input = input.gpu; data.cpu->output = output.gpu; gpuDispatch(commandBuffer, data.gpu, uvec3(128, 1, 1)); // GPU kernel... [groupsize = (64, 1, 1)] void main(uint32x3 threadId : SV_ThreadID, const Data* data) { uint32 value = data->input[threadId.x]; // TODO: Code using color, offset, lut, etc... data->output[threadId.x] = value; } |
예제 코드에서는 GPU 인자 할당을 위해 단순한 선형 범프 할당자(myBumpAllocator)를 사용한다(구현은 부록 참고). 이 할당자는 { void * cpu, void * gpu } 구조체를 반환한다. CPU 포인터는 영구 매핑된 GPU 메모리에 직접 데이터를 쓰는 데 사용하고, GPU 포인터는 GPU 데이터 구조에 저장하거나 디스패치 명령 인자로 전달할 수 있다.
대부분의 GPU는 웨이브를 실행하기 직전에 루트 유니폼(64비트 포인터 포함)을 상수 또는 스칼라 레지스터로 미리 로드한다. 이러한 최적화는 여전히 유효하다. 드로우/디스패치 명령은 기본 데이터 포인터를 전달하고, 모든 입력 유니폼(다른 데이터에 대한 포인터 포함)은 이 기본 포인터로부터 작은 고정 오프셋에 위치한다. 셰이더는 사전 컴파일되고, PSO 생성 시 장치별 마이크로코드로 추가 최적화되므로, 드라이버는 레지스터 프리로드나 루트 데이터 최적화를 충분히 수행할 수 있다. 일부 아키텍처에서는 루트 데이터 크기가 제한되어 있으므로, 가장 중요한 데이터는 루트 구조체의 앞부분에 배치하는 것이 좋다. 우리의 루트 구조체는 하드한 크기 제한이 없으며, 나머지 필드에 대해서는 셰이더 컴파일러가 일반적인(스칼라/유니폼) 메모리 로드를 생성한다. 셰이더에 전달되는 루트 데이터 포인터는 const다. 이는 명령 프로세서가 새 웨이브를 시작할 때 데이터를 재사용할 수 있기 때문에, 셰이더가 루트 입력 데이터를 수정해서는 안 되기 때문이다. 출력은 const가 아닌 포인터(예: 예제의 Data::output)를 통해 수행한다. 루트 데이터를 const로 강제하면 GPU 드라이버가 특수한 유니폼 데이터 경로 최적화를 적용할 수 있다.
그렇다면 별도의 유니폼 버퍼 타입이 필요할까? 현대 셰이더 컴파일러는 자동 유니폼성 분석(uniformity analysis)을 수행한다. 어떤 명령의 모든 입력이 유니폼이면, 그 출력도 유니폼이 된다. 유니폼성은 셰이더 전체로 전파된다. 모든 최신 아키텍처는 스칼라 레지스터/로드(또는 Intel의 SIMD1과 같은 유사 개념)를 갖고 있다. 유니폼성 분석은 벡터 로드를 스칼라 로드로 변환하는 데 사용되며, 이는 레지스터를 절약하고 지연을 줄인다. 이 분석은 버퍼 타입(UBO vs SSBO)에 의존하지 않는다. 다만 리소스는 읽기 전용이어야 한다(그래서 GLSL에서는 SSBO에 readonly를 붙이고, DirectX 12에서는 UAV 대신 SRV를 사용하는 것이 좋다). 또한 컴파일러는 해당 포인터가 별칭(alias)되지 않는다는 것을 증명할 수 있어야 한다. C/C++의 const는 해당 포인터를 통해 수정할 수 없다는 의미일 뿐, 다른 읽기/쓰기 포인터가 같은 메모리를 참조하지 않는다는 보장은 하지 않는다. 이를 위해 C99는 restrict 키워드를 도입했으며, CUDA 커널에서도 자주 사용된다. Metal의 루트 포인터는 기본적으로 no-alias(restrict)이며, Vulkan과 DirectX 12의 버퍼 객체도 동일하다. 우리는 동일한 관례를 채택해 컴파일러가 더 자유롭게 최적화하도록 해야 한다.
컴파일러가 항상 컴파일 시점에 주소의 유니폼성을 증명할 수 있는 것은 아니다. 현대 GPU는 동적으로 유니폼한 주소 로드를 기회주의적으로 최적화한다. 메모리 컨트롤러가 벡터 로드 명령의 모든 레인이 동일한 주소를 참조한다는 것을 감지하면, SIMD 폭의 gather 대신 단일 레인 로드를 수행하고 그 결과를 모든 레인에 복제한다. 이 최적화는 투명하게 이루어지며, 셰이더 코드 생성이나 레지스터 할당에 영향을 주지 않는다. 동적 유니폼 데이터의 성능 비용은 과거에 비해 훨씬 줄었으며, 특히 최신의 빠른 raw 로드 경로와 결합될 때 그 영향이 작다.
일부 GPU 벤더(ARM Mali, Qualcomm Adreno)는 유니폼성 분석을 한 단계 더 확장한다. 셰이더 컴파일러가 유니폼 로드와 유니폼 연산을 추출해, 셰이더 실행 전에 스칼라 프리앰블을 실행한다. 유니폼 메모리 로드와 연산은 드로우/디스패치당 한 번만 수행되고, 결과는 특수 하드웨어 상수 레지스터(루트 상수에 사용되는 동일한 레지스터)에 저장된다.
이러한 모든 최적화를 종합하면, 전통적인 16KB/64KB 유니폼/상수 버퍼 추상화보다 더 나은 유니폼 데이터 처리 방식이 가능하다. 많은 GPU는 여전히 루트 상수, 시스템 값, 프리앰블 실행을 위한 특수 유니폼 레지스터를 갖고 있다.
Texture bindings
이상적으로는 텍스처 디스크립터도 GPU 메모리의 일반 데이터처럼 동작해, 다른 데이터와 함께 구조체 안에 자유롭게 섞어 쓸 수 있어야 한다. 그러나 이러한 수준의 유연성은 아직 모든 최신 GPU에서 보편적으로 지원되지는 않는다. 다행히 지난 10년간 바인드리스 텍스처 샘플러 설계는 점차 수렴해왔으며, 현재는 크게 두 가지 방식만 남아 있다. 256비트 원시(raw) 디스크립터 방식과 인덱스 기반 디스크립터 힙 방식이다.
AMD의 원시 디스크립터 방식은 256비트 디스크립터를 GPU 메모리에서 직접 로드해 컴퓨트 유닛의 스칼라 레지스터에 저장한다. 32비트 스칼라 레지스터 8개가 하나의 디스크립터를 구성한다. SIMD 텍스처 샘플 명령 실행 시, 셰이더 코어는 256비트 텍스처 디스크립터와 각 레인의 UV를 샘플러 유닛에 전달한다. 이를 통해 샘플러는 추가적인 간접 참조 없이 텍셀을 주소 지정하고 로드할 수 있다. 단점은 256비트 디스크립터가 많은 레지스터 공간을 차지하며, 샘플 명령마다 다시 전달해야 한다는 점이다.
인덱스 기반 디스크립터 힙 방식은 32비트 인덱스(구형 Intel iGPU는 20비트)를 사용한다. 32비트 인덱스는 구조체에 저장하기 쉽고, 표준 SIMD 레지스터에 로드하기도 간단하며, 전달 비용도 적다. SIMD 샘플 명령 시 셰이더 코어는 텍스처 인덱스와 각 레인의 UV를 샘플러에 전달한다. 샘플러는 디스크립터 힙에서 디스크립터를 가져온다: 힙 기본 주소 + 텍스처 인덱스 × 스트라이드(현대 GPU에서는 256비트). 텍스처 힙의 기본 주소는 Vulkan/Metal에서는 드라이버가 추상화하며, DirectX 12에서는 사용자가 SetDescriptorHeaps로 설정한다. 힙 기본 주소 변경은 구형 하드웨어에서 내부 파이프라인 배리어를 유발할 수 있다. 현대 GPU에서는 샘플 명령 데이터에 64비트 힙 기본 주소가 포함되어 여러 힙을 자연스럽게 샘플링할 수 있다(64비트 기본 주소 + 레인당 32비트 오프셋). 샘플러 유닛은 최초 접근 이후 간접 읽기를 피하기 위해 작은 내부 디스크립터 캐시를 갖는다. 디스크립터 힙이 수정되면 캐시를 무효화해야 한다.
몇 년 전까지만 해도 AMD의 스칼라 레지스터 기반 디스크립터 방식이 장기적으로 더 유리해 보였다. 스칼라 레지스터는 디스크립터 힙보다 유연해, 디스크립터를 GPU 데이터 구조 안에 직접 포함할 수 있기 때문이다. 하지만 단점도 있다. 최신 GPU 워크로드(예: 레이 트레이싱, Nanite 기반 지연 텍스처링)는 비균일(non-uniform) 텍스처 인덱스를 많이 사용한다. SIMD 웨이브 내에서 텍스처 힙 인덱스가 균일하지 않다. 32비트 인덱스는 4바이트에 불과해 레인마다 전달할 수 있지만, 256비트 디스크립터는 32바이트이므로 레인마다 전체 디스크립터를 전달하는 것은 비현실적이다. Nvidia, Apple, Qualcomm의 최신 GPU는 샘플 명령에서 레인별 디스크립터 인덱스를 지원해 비균일 경우를 효율적으로 처리한다. 필요 시 샘플러 유닛이 내부 루프를 수행한다. 입력/출력은 힙 인덱스의 균일성과 무관하게 한 번만 전달된다. 반면 AMD의 스칼라 레지스터 기반 방식은 비균일 인덱스 처리 시 셰이더 컴파일러가 스칼라화 루프를 생성해야 한다. 이는 추가 ALU 비용을 발생시키고, 샘플러 데이터 송수신을 여러 번(부분 마스크 포함) 수행해야 한다. Nvidia가 레이 트레이싱에서 AMD보다 빠른 이유 중 하나다. ARM과 Intel도 32비트 힙 인덱스를 사용하지만, 최신 아키텍처에서도 레인별 힙 인덱스 모드를 완전히 지원하지는 않아 AMD와 유사한 스칼라화 루프를 생성한다.
이러한 차이점은 통합된 텍스처 디스크립터 힙 추상화로 감쌀 수 있다. 사실상의 디스크립터 크기는 256비트이며(Apple은 텍스처 디스크립터 192비트 + 샘플러 32비트 분리), 텍스처 힙은 256비트 디스크립터 블롭의 균질 배열로 표현할 수 있다. 인덱싱은 단순하다. DirectX 12 SM 6.6은 이와 유사한 추상화를 제공하지만, 디스크립터 힙 메모리에 대한 CPU 또는 컴퓨트 셰이더의 직접 쓰기를 허용하지 않는다. 디스크립터 생성 및 CPU→GPU 복사를 위한 별도 API를 사용하며, GPU는 디스크립터를 직접 쓸 수 없다. 오늘날에는 이 API 추상화를 제거하고 CPU와 GPU가 디스크립터 힙에 직접 쓸 수 있도록 할 수 있다. 필요한 것은 256비트(uint64[4]) 하드웨어 특화 디스크립터 블롭을 생성하는 간단한 사용자 공간 드라이버 헬퍼 함수뿐이다. 최신 GPU는 UMA 또는 PCIe ReBAR를 지원하므로 CPU가 GPU 메모리에 직접 디스크립터 블롭을 쓸 수 있다. 사용자는 컴퓨트 셰이더로 디스크립터를 복사하거나 생성할 수도 있다. 셰이더 언어에도 디스크립터 생성 내장 함수가 있어, 하드웨어 특화 uint64x4 디스크립터 블롭을 반환하도록 한다(이는 CPU API와 유사하다). 이러한 접근은 API 복잡성을 크게 줄이면서 DirectX 12의 디스크립터 업데이트 모델보다 더 빠르고 유연하다. Vulkan의 VK_EXT_descriptor_buffer 확장(2022)은 이 제안과 유사하게 CPU 및 GPU의 직접 쓰기를 허용하며, 대부분의 벤더가 지원하지만 아직 Vulkan 1.4 코어 사양에는 포함되지 않았다.
// App startup: Allocate a texture descriptor heap (for example 65536 descriptors) GpuTextureDescriptor *textureHeap = gpuMalloc<GpuTextureDescriptor>(65536); // Load an image using a 3rd party library auto pngImage = pngLoad("cat.png"); auto uploadMemory = uploadBumpAllocator.allocate(pngImage.byteSize); // Custom bump allocator (wraps gpuMalloc ptr) pngImage.load(uploadMemory.cpu); // Allocate GPU memory for our texture (optimal layout with metadata) GpuTextureDesc textureDesc { .dimensions = pngImage.dimensions, .format = FORMAT_RGBA8_UNORM, .usage = SAMPLED }; GpuTextureSizeAlign textureSizeAlign = gpuTextureSizeAlign(textureDesc); void *texturePtr = gpuMalloc(textureSizeAlign.size, textureSizeAlign.align, MEMORY_GPU); GpuTexture texture = gpuCreateTexture(textureDesc, texturePtr); // Create a 256-bit texture view descriptor and store it textureHeap[0] = gpuTextureViewDescriptor(texture, { .format = FORMAT_RGBA8_UNORM }); // Batched upload: begin GpuCommandBuffer uploadCommandBuffer = gpuStartCommandRecording(queue); // Copy all textures here! gpuCopyToTexture(uploadCommandBuffer, texturePtr, uploadMemory.gpu, texture); // TODO other textures... // Batched upload: end gpuBarrier(uploadCommandBuffer, STAGE_TRANSFER, STAGE_ALL, HAZARD_DESCRIPTORS); gpuSubmit(queue, { uploadCommandBuffer }); // Later during rendering... gpuSetActiveTextureHeapPtr(commandBuffer, gpuHostToDevicePointer(textureHeap)); |
이론적으로는 CPU 측 텍스처 객체(GpuTexture)를 거의 완전히 제거하는 것도 가능하다. 그러나 아쉽게도 모든 최신 GPU의 삼각형 래스터라이저 유닛은 아직 바인드리스가 아니다. CPU 드라이버는 렌더 타깃, 깊이-스텐실 버퍼의 바인딩, 클리어 및 리졸브 등을 위해 명령 패킷을 준비해야 한다. 이러한 API는 256비트 GPU 텍스처 디스크립터를 사용하지 않는다. 따라서 드라이버 전용의 추가 CPU 데이터가 필요하며, 이는 GpuTexture 객체에 저장된다.
셰이더에서 텍스처를 참조하는 가장 단순한 방법은 32비트 인덱스를 사용하는 것이다. 하나의 인덱스는 디스크립터 범위의 시작 오프셋을 나타낼 수도 있다. 이는 별도의 API 없이 DirectX 12의 디스크립터 테이블 추상화와 Vulkan의 디스크립터 세트 추상화를 구현할 수 있는 간단한 방법을 제공한다. 또한 빠른 머티리얼 전환(use case)에 대해서도 우아한 해결책을 제시한다. 필요한 것은 머티리얼 데이터 구조체(머티리얼 속성 + 32비트 텍스처 힙 시작 인덱스 포함)를 가리키는 하나의 64비트 GPU 포인터뿐이다. Vulkan의 vkCmdBindDescriptorSets나 DirectX 12의 SetGraphicsRootDescriptorTable은 비교적 빠른 API 호출이지만, 영구 매핑된 GPU 메모리에 단일 64비트 포인터를 쓰는 것만큼 빠르지는 않다. 리소스 바인딩 API 객체를 생성·업데이트·삭제할 필요가 없어지면서 많은 복잡성이 제거된다. 또한 사용자 측에서 디스크립터 세트 해시 맵을 유지할 필요가 없어 CPU 시간도 절약된다. 이는 게임 엔진에서 즉시 모드와 보존 모드 간의 불일치를 해결하기 위해 흔히 사용되던 접근 방식이었다.
// Common header... struct alignas(16) Data { uint32 srcTextureBase; uint32 dstTexture; float32x2 invDimensions; }; // GPU kernel... const Texture textureHeap[]; [groupsize = (8, 8, 1)] void main(uint32x3 threadId : SV_ThreadID, const Data* data) { Texture textureColor = textureHeap[data->srcTextureBase + 0]; Texture textureNormal = textureHeap[data->srcTextureBase + 1]; Texture texturePBR = textureHeap[data->srcTextureBase + 2]; Sampler sampler = {.minFilter = LINEAR, .magFilter = LINEAR}; // Embedded sampler (Metal-style) float32x2 uv = float32x2(threadId.xy) * data->invDimensions; float32x4 color = sample(textureColor, sampler, uv); float32x4 normal = sample(textureNormal, sampler, uv); float32x4 pbr = sample(texturePBR, sampler, uv); float32x4 lit = calculateLighting(color, normal, pbr); TextureRW dstTexture = TextureRW(textureHeap[data->dstTexture]); dstTexture[threadId.xy] = lit; } |
Metal 4는 텍스처 디스크립터 힙을 자동으로 관리한다. 텍스처 객체에는 .gpuResourceID라는 64비트 힙 인덱스가 있으며(Xcode GPU 디버거에서는 0x3 같은 작은 값으로 보인다), 이를 DirectX SM 6.6이나 Vulkan(디스크립터 버퍼 확장)에서 텍스처 인덱스를 사용하는 것처럼 GPU 구조체에 직접 기록할 수 있다. 하지만 Metal에서는 힙 관리가 자동이기 때문에 사용자가 텍스처 디스크립터를 연속된 범위로 할당할 수 없다. 일반적으로는 첫 번째 텍스처의 32비트 인덱스를 저장하고, 나머지 텍스처의 인덱스를 계산하는 방식을 사용한다(위 코드 예시 참고). Metal은 이를 지원하지 않는다. 사용자는 각 텍스처마다 64비트 핸들을 개별적으로 기록해야 한다. 예를 들어 5개의 텍스처 세트를 참조하려면 Metal에서는 40바이트(5 × 64비트)가 필요하지만, Vulkan과 DirectX 12에서는 4바이트(32비트 인덱스 1개)면 충분하다. Apple GPU 하드웨어는 SM 6.6 스타일의 텍스처 힙을 구현할 수 있지만, 제약은 Metal API(소프트웨어) 쪽에 있다.
텍셀 버퍼는 하위 호환성을 위해 계속 지원할 수 있다. DirectX 12에서는 텍셀 버퍼 디스크립터를 텍스처 디스크립터와 동일한 힙에 저장한다. 텍셀 버퍼는 1D 텍스처(비필터링 tfetch 경로)와 유사하게 동작한다. 주 용도가 하위 호환성이라면, 드라이버 벤더가 이를 내부적으로 raw 메모리 로드 같은 더 빠른 코드 경로로 교체하기 위해 복잡한 우회 구현을 할 필요는 없다. 개인적으로는 드라이버 백그라운드 스레드나 셰이더 교체 방식은 선호하지 않는다.
비균일 텍스처 인덱스(Non-Uniform texture index)는 GLSL과 HLSL의 NonUniformResourceIndex 표기법과 유사한 방식으로 처리해야 한다. 이는 저수준 GPU 셰이더 컴파일러가 레인별 힙 인덱스를 사용하는 특수 텍스처 명령을 생성하거나, 균일 디스크립터만 지원하는 GPU에서는 스칼라화 루프를 생성하도록 지시한다. 버퍼는 디스크립터가 아니므로 NonUniformResourceIndex가 필요 없다. 단순히 레인당 64비트 포인터를 전달하면 된다. 이는 모든 최신 GPU에서 동작하며, 스칼라화 루프도 필요 없고 복잡성도 없다. 또한 언어는 ptr[index] 형태의 메모리 로드를 네이티브로 지원해야 한다. 여기서 index는 32비트이다. 일부 GPU는 32비트 레인 오프셋을 사용하는 raw 메모리 로드 명령을 지원해 레지스터 압박을 줄일 수 있다. GPU 벤더에게 제안하자면, 아직 지원하지 않는 아키텍처라면 64비트 공유 베이스 + 32비트 레인 오프셋을 사용하는 raw 로드 명령과 16비트 UV(W) 텍스처 로드 명령을 추가해주기 바란다.
const Texture textureHeap[]; [groupsize = (8, 8, 1)] void main(uint32x3 threadId : SV_ThreadID, const Data* data) { // Non-uniform "buffer data" is not an issue with pointer semantics! Material* material = data->materialMap[threadId.xy]; // Non-uniform texture heap index uint32 textureBase = NonUniformResourceIndex(material.textureBase); Texture textureColor = textureHeap[textureBase + 0]; Texture textureNormal = textureHeap[textureBase + 1]; Texture texturePBR = textureHeap[textureBase + 2]; Sampler sampler = {.minFilter = LINEAR, .magFilter = LINEAR}; float32x2 uv = float32x2(threadId.xy) * data->invDimensions; float32x4 color = sample(textureColor, sampler, uv); float32x4 normal = sample(textureNormal, sampler, uv); float32x4 pbr = sample(texturePBR, sampler, uv); color *= material.color; pbr *= material.pbr; // Rest of the shader } |
현대적인 바인드리스 텍스처링을 사용하면 모든 텍스처 바인딩 API를 제거할 수 있다. 전역적으로 인덱싱 가능한 텍스처 힙을 두면, 모든 텍스처가 모든 셰이더에서 가시적으로 접근 가능해진다. 물론 텍스처 데이터는 여전히 DCC 및 Morton 스위즐을 활성화하기 위해 복사 명령을 통해 GPU 메모리에 로드되어야 한다. 또한 텍스처 디스크립터 생성에는 얇은(thin) GPU 특화 사용자 공간 API가 필요하다. 그러나 텍스처 힙 자체는 CPU와 GPU 모두에 원시 GPU 메모리 배열로 직접 노출할 수 있으며, 이를 통해 DirectX 12 SM 6.6과 비교해 텍스처 힙 관련 API 복잡성을 대부분 제거할 수 있다.
Shader pipelines
셰이더의 루트 데이터가 단일 64비트 포인터이고, 텍스처가 단순한 32비트 인덱스라면 셰이더 파이프라인 생성은 매우 단순해진다. 텍스처 바인딩, 버퍼 바인딩, 바인드 그룹(디스크립터 세트, 아규먼트 버퍼), 루트 시그니처를 정의할 필요가 없다.
auto shaderIR = loadFile("computeShader.ir"); GpuPipeline computePipeline = gpuCreateComputePipeline(shaderIR); |
DirectX 12와 Vulkan은 루트 시그니처, 푸시 디스크립터, 푸시 상수, 디스크립터 세트 등을 바인딩하고 설정하기 위해 복잡한 API를 사용한다. 하지만 현대 GPU 드라이버의 본질적인 동작은 GPU 메모리에 하나의 구조체를 구성하고, 그 포인터를 커맨드 프로세서에 전달하는 것에 가깝다. 우리는 이러한 API 복잡성이 불필요하다는 점을 보였다. 사용자는 루트 구조체를 영구 매핑된 GPU 메모리에 직접 작성하고, 64비트 GPU 포인터를 드로우/디스패치 함수에 그대로 전달하면 된다. 또한 구조체 내부에 64비트 포인터와 32비트 텍스처 힙 인덱스를 포함해 원하는 간접 데이터 레이아웃을 자유롭게 구성할 수 있다. 루트 바인딩 API와 DirectX 12의 복잡한 버퍼 체계는 64비트 포인터로 효율적으로 대체할 수 있다. 이는 셰이더 파이프라인 생성을 획기적으로 단순화하며, 데이터 레이아웃을 별도로 정의할 필요도 없다. 거대한 API 복잡성을 제거하면서 사용자에게 더 많은 유연성을 제공하는 셈이다.
Static constants
Vulkan, Metal, WebGPU에는 셰이더 파이프라인 생성 시 고정되는 정적(특수화, specialization) 상수 개념이 있다. 드라이버의 내부 셰이더 컴파일러는 이 상수를 입력 셰이더 IR에 리터럴로 적용하고, 이후 상수 전파 및 데드 코드 제거를 수행한다. 이를 통해 파이프라인 생성 시 동일한 셰이더의 여러 변형을 만들 수 있으며, 모든 셰이더 조합을 오프라인에서 컴파일하는 데 필요한 시간과 저장 공간을 줄일 수 있다.
Vulkan과 Metal은 특수화 상수와 그 값을 기술하기 위한 별도의 API와 전용 셰이더 문법을 제공한다. 하지만 셰이더 측에서 정의한 상수 구조체와 동일한 C 구조체를 그대로 전달하는 방식이 더 간단하고 바람직하다. 이는 API 표면을 최소화하면서도 중요한 개선을 제공할 수 있다.
Vulkan의 특수화 상수에는 설계상의 한계가 있다. 특수화 상수는 디스크립터 세트 레이아웃을 변경할 수 없다. 입력과 출력 데이터 레이아웃이 고정된다. 사용자는 모든 가능한 입력/출력을 포함하는 ‘우버 레이아웃’을 정의하고 사용하지 않는 디스크립터를 갱신하지 않는 방식으로 우회할 수 있지만, 이는 번거롭고 비효율적이다. 우리가 제안하는 설계에는 이러한 문제가 없다. 상수에 따라 분기하면(반대편 경로는 데드 코드 제거됨) 셰이더 입력 포인터를 다른 구조체로 재해석할 수 있다. C++ 상속 레이아웃을 모방할 수도 있다. 입력 구조체의 앞부분은 공통 레이아웃으로 두고, 특수화된 데이터는 뒤에 배치하는 방식이다. 이를 통해 정적 다형성(static polymorphism)을 깔끔하게 구현할 수 있다. 런타임 성능은 수작업으로 최적화한 셰이더와 동일하다. 특수화 구조체에는 GPU 포인터도 포함할 수 있어, 런타임 메모리 위치를 상수처럼 하드코딩할 수 있다. 이는 간접 참조를 피하게 해주며, 기존 셰이더 언어에서는 불가능했던 방식이다. 과거에는 GPU 벤더가 런타임에 셰이더를 분석해 유사한 교체 최적화를 수행하기 위해 백그라운드 스레드를 사용해야 했고, 이는 CPU 비용과 드라이버 복잡성을 크게 증가시켰다.
// Common header... struct alignas(16) Constants { int32 qualityLevel; uint8* blueNoiseLUT; }; // CPU code... Constants constants { .qualityLevel = 2, blueNoiseLUT = blueNoiseLUT.gpu }; auto shaderIR = loadFile("computeShader.ir"); GpuPipeline computePipeline = gpuCreateComputePipeline(shaderIR, &constants); // GPU kernel... [groupsize = (8, 8, 1)] void main(uint32x3 threadId : SV_ThreadID, const Data* data, const Constants constants) { if (constants.qualityLevel == 3) { // Dead code eliminated } } |
셰이더 퍼뮤테이션 지옥은 현대 그래픽스에서 가장 큰 문제 중 하나다. 게이머는 끊김(stutter)을 호소하고, 개발자는 오프라인 셰이더 컴파일에 몇 시간이 걸린다고 불평한다. 이 새로운 설계는 사용자에게 더 큰 유연성을 제공한다. 셰이더 내부에서 정적 동작과 동적 동작을 전환할 수 있어, 범용(fallback) 경로와 필요 시 특수화를 쉽게 함께 구현할 수 있다. 이는 셰이더 퍼뮤테이션 수를 줄이고, 파이프라인 생성으로 인한 런타임 정지를 감소시킨다.
Barriers and fences
현대 그래픽스 API에서 가장 미움받는 기능은 아마도 배리어일 것이다. 배리어는 두 가지 목적을 가진다 : 생산자→소비자 실행 의존성을 보장하는 그리고 텍스처를 서로 다른 레이아웃 간에 전환하는 것.
많은 그래픽스 프로그래머는 GPU 동기화에 대해 잘못된 정신 모델을 가지고 있다. 흔히 GPU 동기화가 개별 텍스처와 버퍼 단위의 세밀한 의존성 추적으로 이루어진다고 믿지만, 실제로 현대 GPU 하드웨어는 개별 리소스 자체에는 크게 관심이 없다. 우리는 사용자 공간에서 각 리소스의 상태와 레이아웃 변화를 정리하느라 많은 CPU 시간을 쓰지만, 현대 GPU 드라이버는 사실상 그 목록을 대부분 무시한다. 추상화와 실제 하드웨어 동작이 일치하지 않는다.
현대의 바인드리스 아키텍처는 GPU에 많은 자유를 준다. 셰이더는 전역 디스크립터 힙의 어떤 텍스처든, 어떤 64비트 포인터든 쓸 수 있다. CPU는 GPU가 어떤 결정을 내릴지 알 수 없다. 그렇다면 CPU는 어떻게 각 리소스에 대해 전환 배리어를 발행할 수 있을까? 이는 바인드리스 아키텍처와 전통적인 CPU 주도 렌더링 API 사이의 명백한 불일치다. 왜 10년 전 API가 이렇게 설계되었는지 살펴보자.
AMD의 GCN 아키텍처는 현대 그래픽 API 설계에 큰 영향을 미쳤다. GCN은 비동기 컴퓨트와 바인드리스 텍스처링(스칼라 레지스터에 디스크립터 저장)에서 시대를 앞서갔지만, 델타 컬러 압축(DCC)과 캐시 설계에는 중요한 제약이 있었다. 이 제약은 오늘날 배리어 모델이 복잡해진 이유를 잘 보여준다. GCN은 일관성 있는 마지막 레벨 캐시가 없었다. ROP(래스터 연산, 즉 픽셀 셰이더 출력)는 VRAM에 직접 연결된 특수 비일관성 캐시를 가지고 있었다. 픽셀 셰이더 출력이 다른 셰이더나 샘플러에서 보이도록 하려면, 드라이버는 먼저 ROP 캐시를 메모리에 플러시하고, 그다음 L2 캐시를 무효화해야 했다. 커맨드 프로세서 또한 L2 캐시의 클라이언트가 아니었다. 컴퓨트 셰이더에서 작성한 간접 인자는 L2 캐시를 전체 무효화하고 더티 라인을 VRAM으로 플러시하지 않으면 커맨드 프로세서에 보이지 않았다. GCN 3는 ROP용 델타 컬러 압축(DCC)을 도입했지만, AMD의 텍스처 샘플러는 DCC 압축 텍스처나 압축 깊이 버퍼를 직접 읽을 수 없었다. 드라이버는 내부적으로 디컴프레스 컴퓨트 셰이더를 실행해 압축을 제거해야 했다. 디스플레이 엔진도 DCC 압축 텍스처를 직접 읽을 수 없었다. 렌더 타깃을 샘플링하는 일반적인 경우에는 두 번의 내부 배리어와 모든 캐시 플러시가 필요했다(ROP 완료 대기 → ROP 캐시 및 L2 플러시 → 디컴프레스 컴퓨트 실행 → 컴퓨트 완료 대기).
AMD의 최신 RDNA 아키텍처는 여러 중요한 개선을 이뤘다. 모든 메모리 연산을 포괄하는 일관성 있는 L2 캐시를 갖추고 있으며, ROP와 커맨드 프로세서도 L2의 클라이언트다. 비일관성 캐시는 컴퓨트 유닛 내부의 작은 L0 캐시와 스칼라 캐시(K$)뿐이다. 이제 배리어는 작은 캐시의 미완료 쓰기를 상위 캐시로 플러시하는 것만으로 충분하다. 드라이버가 L2 캐시를 VRAM으로 플러시할 필요가 없어 배리어가 훨씬 빨라졌다. RDNA의 개선된 디스플레이 엔진은 DCC 압축 텍스처를 직접 읽을 수 있고, L2와 L0 텍스처 캐시 사이에는 (디)컴프레서가 위치한다. 텍스처를 샘플링하기 전에 VRAM에 디컴프레스할 필요가 없어져, 압축/비압축 레이아웃 전환이 필요 없다. 오늘날 데스크톱과 모바일 GPU 벤더들은 비슷한 결론에 도달했다. 병목은 대역폭이다. 리소스를 VRAM에 디코딩하는 데 대역폭을 낭비해서는 안 된다. 레이아웃 전환은 더 이상 필요하지 않다.

리소스 목록은 DirectX 12와 Vulkan에서 배리어의 가장 성가신 측면이다. 사용자는 각 리소스의 상태를 개별적으로 추적하고, 각 배리어에 대해 이전 상태와 다음 상태를 그래픽스 API에 알려야 한다. 이는 10년 전 GPU에서는 필요했다. 벤더들이 다양한 디컴프레스 명령을 배리어 API 아래에 숨겨두었기 때문이다. 배리어 명령은 디컴프레스 명령의 역할을 했기 때문에, 어떤 리소스가 디컴프레스가 필요한지 알아야 했다. 오늘날 하드웨어는 텍스처 레이아웃이나 디컴프레스 단계를 필요로 하지 않는다. Vulkan은 최근 VK_KHR_unified_image_layouts 확장(https://www.khronos.org/blog/so-long-image-layouts-simplifying-vulkan-synchronisation) (2025)을 도입해 배리어 명령에서 이미지 레이아웃 전환을 제거했다. 그러나 여전히 사용자가 개별 텍스처와 버퍼를 나열하도록 요구한다. 왜 그럴까?
주된 이유는 레거시 API 및 툴링과의 호환성이다. 사람들은 리소스 의존성에 대해 생각하는 방식에 익숙하며, 기존 Vulkan 및 DirectX 12 검증 레이어도 그 방식으로 설계되어 있다. 그러나 GPU가 실행하는 배리어 명령에는 텍스처나 버퍼에 대한 정보가 전혀 없다. 리소스 목록은 오직 드라이버에서만 소비된다.
현대 드라이버는 사용자의 리소스 목록을 순회하며 플래그 집합을 채운다. 드라이버는 더 이상 리소스 레이아웃이나 마지막 레벨 캐시 일관성을 걱정할 필요는 없지만, 여전히 특정 경우에 플러시해야 하는 작은 비일관성 캐시가 존재한다. 현대 GPU는 대부분의 비일관성 캐시를 모든 배리어에서 자동으로 플러시한다. 예를 들어 AMD의 L0$와 K$(스칼라 캐시)는 항상 플러시된다. 모든 패스가 어떤 출력을 쓰며, 이 출력은 이러한 캐시에 존재하기 때문이다. 모든 쓰기 주소를 세밀하게 추적하는 것은 비용이 너무 크다. 작은 비일관성 캐시는 대개 포함(inclusive) 구조다. 수정된 라인은 상위 캐시 레벨로 플러시된다. 이는 빠르며 VRAM 트래픽을 발생시키지 않는다. 일부 아키텍처에는 자동으로 플러시되지 않는 특수 캐시가 있다. 예: 텍스처 샘플러의 디스크립터 캐시(위 장 참고), 래스터라이저 ROP 캐시 및 HiZ 캐시. 커맨드 프로세서는 웨이브 생성 지연을 줄이기 위해 일반적으로 앞서 실행된다. 셰이더에서 간접 인자를 썼다면, 레이스를 피하기 위해 커맨드 프로세서 프리패처를 멈추도록 GPU에 알려야 한다. GPU는 실제로 컴퓨트 셰이더가 간접 인자 버퍼에 썼는지 알지 못한다. DirectX 12에서는 해당 버퍼를 D3D12_RESOURCE_STATE_INDIRECT_ARGUMENT로 전환하고, Vulkan에서는 소비자 의존성에 VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT라는 특별한 단계를 사용한다. 이러한 리소스 전환이나 단계 의존성이 있는 배리어의 경우, 드라이버는 배리어에 커맨드 프로세서 프리패처 정지 플래그를 포함한다.
현대적인 배리어 설계는 리소스 목록을, 이러한 특수 비일관성 캐시에 어떤 일이 일어나는지를 설명하는 단일 비트필드로 대체한다. 특수한 경우에는 다음이 포함된다: 텍스처 디스크립터 무효화, 드로우 인자 무효화, 깊이 캐시 무효화. 이러한 플래그는 드로우 인자를 생성하거나, 디스크립터 힙에 쓰거나, 컴퓨트 셰이더로 깊이 버퍼에 쓸 때 필요하다. 대부분의 배리어는 특별한 캐시 무효화 플래그가 필요하지 않다.
일부 GPU는 여전히 특수한 경우에 데이터를 디컴프레스해야 한다. 예를 들어 복사 또는 클리어 명령 중에(클리어 색상이 변경된 경우 fast clear eliminate). 복사 및 클리어 명령은 영향을 받는 리소스를 매개변수로 받는다. 필요하다면 드라이버가 데이터를 디코딩하는 데 필요한 조치를 취할 수 있다. 이러한 특수 경우를 위해 배리어에 리소스 목록이 필요하지 않다. 모든 포맷과 사용 플래그가 압축을 지원하는 것은 아니다. 이러한 경우 드라이버는 데이터를 압축되지 않은 상태로 유지하며, 대역폭을 낭비하면서 앞뒤로 전환하지 않는다.
표준 UAV 배리어(컴퓨트 → 컴퓨트)는 단순하다.
gpuBarrier(commandBuffer, STAGE_COMPUTE, STAGE_COMPUTE);
|
텍스처 디스크립터 힙에 쓰기를 하는 경우(드문 경우), 특별한 플래그를 추가해야 한다.
gpuBarrier(commandBuffer, STAGE_COMPUTE, STAGE_COMPUTE, HAZARD_DESCRIPTORS); |
래스터라이저 출력과 픽셀 셰이더 사이의 배리어는 오프스크린 렌더 타깃 → 샘플링에서 흔한 경우다. 우리의 예제는 배리어가 버텍스 셰이더를 막지 않도록 의존 단계가 설정되어 있어, 이전 패스와 버텍스 셰이딩(모바일 GPU에서는 타일 비닝)이 겹쳐 실행될 수 있다. 래스터 출력 단계(또는 그 이후)를 프로듀서로 하는 배리어는 GPU 아키텍처가 필요로 하는 경우 비일관성 ROP 캐시를 자동으로 플러시한다. 이에 대해 별도의 명시적 플래그는 필요하지 않다.
gpuBarrier(commandBuffer, STAGE_RASTER_COLOR_OUT | STAGE_RASTER_DEPTH_OUT, STAGE_PIXEL_SHADER); |
사용자는 큐 실행 의존성만 기술한다: 프로듀서 및 컨슈머 단계 마스크. 개별 텍스처와 버퍼 리소스 상태를 추적할 필요가 없어, 현재 DirectX 12 및 Vulkan 설계 대비 많은 복잡성을 제거하고 상당한 CPU 시간을 절약할 수 있다. Metal 2는 이미 현대적인 배리어 설계를 갖고 있으며, 리소스 목록을 사용하지 않는다.
많은 GPU는 커스텀 스크래치패드 메모리를 갖고 있다: 각 컴퓨트 유닛 내부의 그룹 공유 메모리, 타일 메모리, Qualcomm GMEM과 같은 대형 공유 스크래치패드 등이 있다. 이러한 메모리는 드라이버가 자동으로 관리한다. 그룹 공유 메모리와 같은 임시 스크래치패드는 절대 메모리에 저장되지 않는다. 타일 메모리는 타일 래스터라이저에 의해 자동으로 저장된다(store op == store). 유니폼 레지스터는 읽기 전용이며 각 드로우 호출 전에 미리 채워진다. 스크래치패드와 유니폼 레지스터는 캐시 일관성 프로토콜을 갖지 않으며, 배리어와 직접적으로 상호작용하지 않는다.
현대 GPU는 특정 셰이더 단계가 끝났을 때 메모리에 값을 쓰는 동기화 명령과, 특정 메모리 위치에 값이 나타날 때까지 다음 셰이더 단계가 시작되지 않도록 기다리는 명령(대기에는 선택적 캐시 플러시 의미론 포함)을 지원한다. 이는 배리어를 두 개로 나눈 것과 같다: 프로듀서와 컨슈머. DirectX 12의 스플릿 배리어와 Vulkan의 event→wait가 이러한 설계의 예다. 배리어를 컨슈머→프로듀서로 분리하면, 그 사이에 독립적인 작업을 배치해 GPU를 비우지 않고도 실행할 수 있다.
Vulkan의 event→wait(DX12 스플릿 배리어 포함)는 거의 사용되지 않는다. 주된 이유는 일반 배리어 자체가 이미 매우 복잡해 개발자들이 추가 복잡성을 피하려 하기 때문이다. 과거에는 스플릿 배리어에 대한 드라이버 지원도 완벽하지 않았다. 리소스 목록을 제거하면 스플릿 배리어는 훨씬 단순해진다. 또한 스플릿 배리어를 타임라인 세마포어와 유사한 의미론으로 만들 수 있다: signal 명령은 단조 증가하는 64비트 값(atomic max)을 메모리에 쓰고, wait 명령은 해당 값이 N 이상(>=)이 될 때까지 기다린다. 이 카운터는 단순한 GPU 메모리 포인터일 뿐, 별도의 영구 API 객체가 필요하지 않다. 이는 훨씬 단순한 event→wait API를 제공한다.
gpuSignalAfter(commandBuffer, STAGE_RASTER_COLOR_OUT, gpuPtr, counter, SIGNAL_ATOMIC_MAX); // Put independent work here gpuWaitBefore(commandBuffer, STAGE_PIXEL_SHADER, gpuPtr, counter++, OP_GREATER_EQUAL); |
이 API는 기존 VkEvent API보다 훨씬 단순하면서도 더 높은 유연성을 제공한다. 위 예제에서는 타임라인 세마포어 의미론을 구현했지만, 다른 패턴도 구현할 수 있다. 예를 들어 비트마스크를 사용해 여러 프로듀서를 기다리는 방식이다: SIGNAL_ATOMIC_OR로 비트를 설정하고, 특정 마스크의 모든 비트가 설정될 때까지 기다린다(마스크는 gpuWaitBefore 명령의 선택적 매개변수다).

GPU→CPU 동기화는 초기 Vulkan과 Metal에서 다소 혼란스러웠다. 사용자는 제출마다 별도의 펜스 객체를 생성해야 했다. 객체를 재사용하기 위해 N 버퍼링이 일반적인 기법이었다. 이는 위에서 언급한 VkEvent와 유사한 사용성 문제다. DirectX 12는 타임라인 세마포어를 통해 GPU→CPU 동기화를 깔끔하게 해결한 최초의 API였다. 이후 Vulkan 1.2와 Metal 2도 동일한 설계를 채택했다. 타임라인 세마포어는 단 하나의 64비트 단조 증가 카운터만 필요하다. 이는 많은 엔진이 오늘날까지 사용 중인 이전 Vulkan 및 Metal 펜스 API보다 복잡성을 크게 줄여준다.
#define FRAMES_IN_FLIGHT 2 GpuSemaphore frameSemaphore = gpuCreateSemaphore(0); uint64 nextFrame = 1; while (running) { if (nextFrame > FRAMES_IN_FLIGHT) { gpuWaitSemaphore(frameSemaphore, nextFrame - FRAMES_IN_FLIGHT); } // Render the frame here gpuSubmit(queue, {commandBuffer}, frameSemaphore, nextFrame++); } gpuDestroySemaphore(frameSemaphore); |
우리가 제안하는 배리어 설계는 DirectX 12와 Vulkan에 비해 큰 개선이다. API 복잡성을 크게 줄인다. 사용자는 더 이상 개별 리소스를 추적할 필요가 없다. 우리의 단순한 해저드 추적은 큐 + 단계(stage) 단위의 세밀도를 가진다. 이는 오늘날 GPU 하드웨어가 실제로 동작하는 방식과 일치한다. 게임 엔진의 그래픽스 백엔드는 단순해질 수 있고, CPU 사이클도 절약된다.
Command buffers
Vulkan과 DirectX 12는 리소스를 사전 생성하고 재사용하도록 장려하는 방식으로 설계되었다. 초기 Vulkan 예제들은 시작 시 하나의 커맨드 버퍼를 기록하고, 이를 매 프레임 재생했다. 그러나 개발자들은 곧 커맨드 버퍼 재사용이 비현실적이라는 것을 깨달았다. 실제 게임 환경은 동적이며 카메라는 끊임없이 움직인다. 가시 객체 집합(visible object set)도 자주 변경된다.
게임 엔진들은 사전 기록된 커맨드 버퍼를 사실상 무시했다. Metal과 WebGPU는 일시적(transient) 커맨드 버퍼를 제공한다. 이들은 기록 직전에 생성되고, GPU가 렌더링을 마치면 사라진다. 이는 커맨드 버퍼 관리의 필요성을 제거하고, 동일한 명령을 여러 번 제출하는 것을 방지한다. Vulkan에서도 GPU 벤더들은 원샷(one-shot) 커맨드 버퍼(프레임별 리셋 가능한 커맨드 풀)를 권장한다. 이는 드라이버 내부 메모리 관리(힙 할당자 대신 범프 할당자 사용)를 단순화하기 때문이다. 이러한 모범 사례는 Metal과 WebGPU의 설계와 일치한다. 영속적인 커맨드 버퍼 객체는 제거해도 된다. 그 API 복잡성은 실질적으로 가치 있는 기능을 제공하지 못했다.
while (running)
` GpuCommandBuffer commandBuffer = gpuStartCommandRecording(queue); // Render frame here } |
Graphics shaders
가장 뜨거운 질문부터 시작해보자 : 이제 그래픽스 셰이더가 정말로 필요한가? UE5의 Nanite는 64비트 원자 연산을 사용해 픽셀을 찍는 컴퓨트 셰이더를 사용한다. 상위 비트에는 픽셀 깊이를, 하위 비트에는 페이로드를 담는다. atomic-min은 가장 가까운 표면이 남도록 보장한다. 이 기법은 2015년 SIGGRAPH에서 Media Molecule Dreams(Alex Evans)에 의해 처음 발표되었다. 하드웨어 래스터라이저는 여전히 계층적/조기 깊이-스텐실 테스트와 같은 장점을 갖는다. Nanite는 오직 거친 클러스터 컬링에만 의존해야 하며, 그 결과 키트배시된 콘텐츠에서는 추가적인 오버드로우가 발생한다. Ubisoft(필자와 Ulrich Haar)는 2015년 SIGGRAPH에서 이 2패스 클러스터 컬링 알고리즘을 발표했다. Ubisoft는 클러스터 컬링을 하드웨어 래스터라이저와 결합해 더 세밀한 컬링을 구현했다. 오늘날 GPU는 바인드리스이며, 이러한 GPU 주도 워크로드에 훨씬 더 적합하다. 10년 전 Ubisoft는 바인드리스 텍스처링 대신 가상 텍스처링(모든 텍스처를 하나의 아틀라스에 배치)에 의존해야 했다. 오늘날 Nanite, SDF 구면 추적, DDA 보셀 트레이싱 등 많은 컴퓨트 전용 래스터라이저가 존재하지만, 하드웨어 래스터라이저는 여전히 게임에서 삼각형을 렌더링하는 가장 일반적인 기법이다. 따라서 래스터라이제이션 파이프라인을 더 유연하고 사용하기 쉽게 만드는 방법을 논의할 가치가 충분하다.
현대 셰이더 프레임워크는 16개의 셰이더 엔트리 포인트로 확장되었다. 래스터라이제이션용 8개(픽셀, 버텍스, 지오메트리, 헐, 도메인, 패치 상수, 메시, 앰플리피케이션)와 레이 트레이싱용 6개(레이 생성, 미스, 최근접 히트, 애니 히트, 교차, 호출 가능)가 있다. 이에 비해 CUDA는 단 하나의 엔트리 포인트만 가진다: 커널. 이 구조 덕분에 CUDA는 조합 가능하다. CUDA는 건강한 서드파티 라이브러리 생태계를 갖고 있다. 텐서 코어(AI)와 같은 새로운 GPU 하드웨어 블록은 내장 함수(intrinsic)로 노출된다. 그래픽스에서도 처음에는 이런 방식이었다. 텍스처 샘플링이 첫 번째 내장 함수였다. 오늘날 텍스처 샘플링은 완전한 바인드리스이며 드라이버 설정조차 필요 없다. 이것이 개발자들이 선호하는 설계다. 단순하고, 조합 가능하며, 확장 가능하다.
최근 우리는 더 많은 내장 함수를 얻었다: 인라인 레이 트레이싱과 코어퍼레이티브 매트릭스(DirectX 12의 wave matrix, Metal의 subgroup matrix). 이것이 새로운 방향이 되기를 바란다. 거대한 16개 셰이더 프레임워크를 해체하고, 유연하게 조합 가능한 내장 함수로 대체해야 한다.
셰이더 프레임워크의 복잡성을 해결하는 것은 매우 방대한 주제다. 이 글의 범위를 유지하기 위해, 오늘은 컴퓨트 셰이더와 래스터 파이프라인만 다루겠다. 다음 글에서는 레이 트레이싱, 셰이더 실행 재정렬(SER), 동적 레지스터 할당 확장, 그리고 Apple의 새로운 L1 캐시 기반 레지스터 파일(다이내믹 캐싱)과 같은 현대적 주제를 포함해 셰이더 프레임워크 단순화에 대해 다룰 예정이다.
Raster pipelines
오늘날 관련 있는 래스터 파이프라인은 두 가지다: 버텍스+픽셀과 메시+픽셀이다. 타일 기반 지연 렌더링(TBDR)을 사용하는 모바일 GPU는 삼각형 단위로 타일 비닝을 수행한다. 타일 크기는 일반적으로 16x16에서 64x64 픽셀 사이이며, 이 때문에 메슐릿(meshlet)은 비닝에 사용하기에는 너무 거친 단위의 프리미티브다. 메슐릿은 레인과 버텍스 간에 명확한 1:1 매핑이 없으며, 선택된 삼각형만을 대상으로 부분적인 메시 셰이더 웨이브를 실행할 간단한 방법도 없다. 이것이 모바일 GPU 벤더들이 Nvidia와 AMD가 설계한 데스크톱 중심 메시 셰이더 API를 적극적으로 채택하지 않은 주요 이유다. 모바일에서는 여전히 버텍스 셰이더가 중요하다.
지오메트리, 헐, 도메인, 패치 상수(테셀레이션) 셰이더는 여기서 다루지 않는다. 그래픽스 커뮤니티는 이들 셰이더 타입을 대체로 실패한 실험으로 본다. 설계상 치명적인 성능 문제가 있다. 관련된 모든 사용 사례에서, 인덱스 버퍼를 생성하는 컴퓨트 프리패스를 실행하는 것이 이들 단계를 능가할 수 있다. 또한 메시 셰이더는 온칩 메모리에 압축된 8비트 인덱스 버퍼를 생성할 수 있어, 이러한 레거시 셰이더 단계와의 성능 격차를 더욱 벌린다.
우리의 목표는 베이크된 상태를 최소화한 현대적인 PSO 추상화를 구축하는 것이다. Vulkan과 DirectX 12에 대한 주요 비판 중 하나는 파이프라인 퍼뮤테이션 폭증이다. PSO 내부의 상태가 적을수록 파이프라인 퍼뮤테이션도 줄어든다. 개선해야 할 주요 영역은 두 가지다: 그래픽스 셰이더 데이터 바인딩과 래스터라이저 상태다.
Graphics shader bindings
버텍스+픽셀 셰이더 파이프라인은 컴퓨트 커널에 비해 몇 가지 추가 입력이 필요하다: 버텍스 버퍼, 인덱스 버퍼, 래스터라이저 상태, 렌더 타깃 뷰, 깊이-스텐실 뷰다. 먼저 셰이더에서 보이는 데이터 바인딩에 대해 논의해보자.
버텍스 버퍼 바인딩은 쉽게 해결할 수 있다: 그냥 제거하면 된다. 현대 GPU는 빠른 raw 로드 경로를 갖고 있다. 대부분의 GPU 벤더는 이미 여러 세대에 걸쳐 버텍스 페치 하드웨어를 에뮬레이션해왔다. 그들의 저수준 셰이더 컴파일러는 사용자 정의 버텍스 레이아웃을 읽고, 버텍스 셰이더 시작 부분에 적절한 raw 로드 명령을 생성한다.
버텍스 바인딩 선언은 구조체 메모리 레이아웃을 정의하기 위한 또 하나의 특수한 C/C++ 스타일 API의 예다. 이는 복잡성을 증가시키고, 서로 다른 레이아웃에 대해 여러 PSO 퍼뮤테이션을 컴파일하도록 강제한다. 우리는 버텍스 버퍼를 표준 C/C++ 구조체로 단순히 대체한다. 별도의 API는 필요 없다.
// Common header... struct Vertex { float32x4 position; uint8x4 normal; uint8x4 tangent; uint16x2 uv; }; struct alignas(16) Data { float32x4x4 matrixMVP; const Vertex *vertices; }; // CPU code... gpuSetPipeline(commandBuffer, graphicsPipeline); auto data = myBumpAllocator.allocate<Data>(); data.cpu->matrixMVP = camera.viewProjection * modelMatrix; data.cpu->vertices = mesh.vertices; gpuDrawIndexed(commandBuffer, data.gpu, mesh.indices, mesh.indexCount); // Vertex shader... struct VertexOut { float32x4 position : SV_Position; float16x4 normal; float32x2 uv; }; VertexOut main(uint32 vertexIndex : SV_VertexID, const Data* data) { Vertex vertex = data->vertices[vertexIndex]; float32x4 position = data->matrixMVP * vertex.position; // TODO: Normal transform here return { .position = position, .normal = normal, .uv = vertex.uv }; } |
인스턴스별(per-instance) 데이터와 다중 버텍스 스트림도 마찬가지다. 이 역시 raw 메모리 로드로 효율적으로 구현할 수 있다. raw 로드 명령을 사용하면 버텍스 스트라이드를 동적으로 조정하고, 보조 버텍스 버퍼 로드를 조건 분기로 건너뛰며, 클러스터 기반 GPU 드리븐 렌더링, 파티클 쿼드 확장, 고차 곡면, 효율적인 지형 렌더링 등 다양한 알고리즘을 구현하기 위해 커스텀 공식으로 버텍스 인덱스를 계산할 수 있다. 추가적인 셰이더 엔트리 포인트나 바인딩 API는 필요 없다. 새로운 정적 상수 시스템을 사용해 파이프라인 생성 시 사용하지 않는 버텍스 스트림을 데드 코드 제거할 수 있고, 원한다면 정적 버텍스 스트라이드를 제공할 수도 있다. 기존의 모든 최적화 전략은 그대로 유지되지만, 이제는 렌더러의 요구에 맞게 기법들을 자유롭게 조합할 수 있다.
// Common header... struct VertexPosition { float32x4 position; }; struct VertexAttributes { uint8x4 normal; uint8x4 tangent; uint16x2 uv; }; struct alignas(16) Instance { float32x4x4 matrixModel; } struct alignas(16) Data { float32x4x4 matrixViewProjection; const VertexPosition *vertexPositions; const VertexAttributes *vertexAttribues; const Instance *instances; }; // CPU code... gpuSetPipeline(commandBuffer, graphicsPipeline); auto data = myBumpAllocator.allocate<Data>(); data.cpu->matrixViewProjection = camera.viewProjection; data.cpu->vertexPositions = mesh.positions; data.cpu->vertexAttributes = mesh.attributes; data.cpu->instances = batcher.instancePool + instanceOffset; // pointer arithmetic is convenient gpuDrawIndexedInstanced(commandBuffer, data.gpu, mesh.indices, mesh.indexCount, instanceCount); // Vertex shader... struct VertexOut { float32x4 position : SV_Position; // SV values are not real struct fields (doesn't affect the layout) float16x4 normal; float32x2 uv; }; VertexOut main(uint32 vertexIndex : SV_VertexID, uint32 instanceIndex : SV_InstanceID, const Data* data) { Instance instance = data->instances[SV_InstanceIndex]; // NOTE: Splitting positions/attributes benefits TBDR GPUs (vertex shader is split in two parts) VertexPosition vertexPosition = data->vertexPositions[SV_VertexIndex]; VertexAttributes vertexAttributes = data->vertexAttributes[SV_VertexIndex]; float32x4x4 matrix = data->matrixViewProjection * instance.matrixModel; float32x4 position = matrix * vertexPosition.position; // TODO: Normal transform here return { .position = position, .normal = normal, .uv = vertexAttributes.uv }; } |
인덱스 버퍼 바인딩은 여전히 특별한 경우다. GPU에는 인덱스 중복 제거 하드웨어가 있다. 동일한 버텍스에 대해 버텍스 셰이더를 두 번 실행하고 싶지는 않다. 인덱스 중복 제거 하드웨어는 버텍스 웨이브를 재구성해 중복 버텍스를 제거한다. 인덱스 버퍼링은 오늘날에도 여전히 중요한 최적화다. 비인덱스 지오메트리는 삼각형당 3번의 버텍스 셰이더 호출(레인)을 실행한다. 완벽한 그리드는 셀당 두 개의 삼각형을 가지므로, (마지막 행/열을 제외하면) 두 개의 삼각형당 한 번의 버텍스 셰이더 호출만 필요하다. 최신 오프라인 버텍스 캐시 최적화 도구는 삼각형당 약 0.7 버텍스 효율의 메시를 출력한다. 실제 환경에서는 인덱스 버퍼를 통해 버텍스 셰이딩 비용을 약 4~6배 줄일 수 있다.
오늘날 인덱스 버퍼 하드웨어는 다른 GPU 유닛과 동일한 캐시 계층에 연결되어 있다. 인덱스 버퍼는 단순히 drawIndexed 호출에 전달되는 추가 GPU 포인터일 뿐이다. 인덱스 버퍼링에 필요한 API 표면은 이것이 전부다.
메시 셰이더는 오프라인 버텍스 중복 제거에 의존한다. 일반적인 구현은 레인당 하나의 버텍스를 셰이딩하고 이를 온칩 메모리에 출력한다. 8비트 로컬 인덱스 버퍼는 각 삼각형이 어떤 3개의 버텍스를 사용하는지 래스터라이저에 알려준다. 모든 메슐릿 출력은 한 번에 उपलब्ध하고 이미 온칩 저장소에서 변환된 상태이므로, 삼각형 설정 이후에 버텍스를 중복 제거하거나 패킹할 필요가 없다. 이것이 메시 셰이더가 인덱스 중복 제거 하드웨어나 포스트 트랜스폼 캐시를 필요로 하지 않는 이유다. 모든 메시 셰이더 입력은 raw 데이터다. gpuDrawMeshlets 명령 외에는 추가 API 표면이 필요 없다.
내 예제 메시 셰이더는 128레인 스레드 그룹을 사용한다. Nvidia는 출력 메슐릿당 최대 126개 버텍스와 64개 삼각형을 지원한다. AMD는 256개 버텍스와 128개 삼각형을 지원한다. 셰이더는 초과 레인을 마스킹한다. 삼각형 수가 64개를 넘지 않으므로, 최적의 삼각형 레인 활용을 위해 64레인 스레드 그룹을 선택하고 버텍스 셰이딩을 두 번 반복하는 루프를 사용할 수도 있다. 내 삼각형 페치 로직은 단일 메모리 로드 명령이므로, 그 단계에서 절반의 레인을 낭비하는 것은 큰 문제가 아니다. 대신 버텍스 셰이딩에서 추가적인 병렬성을 선택했다. 최적의 선택은 워크로드와 대상 하드웨어에 따라 달라진다.
// Common header... struct Vertex { float32x4 position; uint8x4 normal; uint8x4 tangent; uint16x2 uv; }; struct alignas(16) Meshlet { uint32 vertexOffset; uint32 triangleOffset; uint32 vertexCount; uint32 triangleCount; }; struct alignas(16) Data { float32x4x4 matrixMVP; const Meshlet *meshlets; const Vertex *vertices; const uint8x4 *triangles; }; // CPU code... gpuSetPipeline(commandBuffer, graphicsMeshPipeline); auto data = myBumpAllocator.allocate<Data>(); data.cpu->matrixMVP = camera.viewProjection * modelMatrix; data.cpu->meshlets = mesh.meshlets; data.cpu->vertices = mesh.vertices; data.cpu->triangles = mesh.triangles; gpuDrawMeshlets(commandBuffer, data.gpu, uvec3(mesh.meshletCount, 1, 1)); // Mesh shader... struct VertexOut { float32x4 position : SV_Position; float16x4 normal; float32x2 uv; }; [groupsize = (128, 1, 1)] void main(uint32x3 groupThreadId : SV_GroupThreadID, uint32x3 groupId : SV_GroupID, const Data* data) { Meshlet meshlet = data->meshlets[groupId.x]; // Meshlet output allocation intrinsics VertexOut* outVertices = allocateMeshVertices<VertexOut>(meshlet.vertexCount); uint8x3* outIndices = allocateMeshIndices(meshlet.triangleCount); // Triangle indices (3x 8 bit) if (groupThreadId.x < meshlet.triangleCount) { outIndices[groupThreadId.x] = triangles[meshlet.triangleOffset + groupThreadId.x].xyz; } // Vertices if (groupThreadId.x < meshlet.vertexCount) { Vertex vertex = data->vertices[meshlet.vertexOffset + groupThreadId.x]; float32x4 position = data->matrixMVP * vertex.position; // TODO: Normal transform here outVertices[groupThreadId.x] = { .position = position, .normal = normal, .uv = vertex.uv }; } } |
버텍스 셰이더와 메시 셰이더 모두 픽셀 셰이더를 사용한다. 래스터라이저는 삼각형의 픽셀 커버리지, HiZ, early depth/스텐실 테스트 결과에 따라 픽셀 셰이더 작업을 생성한다. 하드웨어는 여러 삼각형과 여러 인스턴스를 동일한 픽셀 셰이더 웨이브에 패킹할 수 있다. 픽셀 셰이더 자체는 그다지 특별하지 않다. 오늘날 픽셀 셰이더는 다른 모든 셰이더 타입과 동일한 SIMD 코어에서 실행된다. 다만 몇 가지 특수 입력이 제공된다: 보간된 버텍스 출력, 스크린 위치, 샘플 인덱스와 커버리지 마스크, 삼각형 ID, 삼각형의 앞/뒤 방향 등이다. 이러한 특수 입력은 기존 API와 유사하게 시스템 값(: SV) 시맨틱을 사용해 커널 함수 매개변수로 선언된다.
// Pixel shader... const Texture textureHeap[]; struct VertexIn // Matching vertex shader output struct layout { float16x4 normal; float32x2 uv; }; struct PixelOut { float16x4 color : SV_Color0; }; PixelOut main(const VertexIn &vertex, const DataPixel* data) { Texture texture = textureHeap[data->textureIndex]; Sampler sampler = {.minFilter = LINEAR, .magFilter = LINEAR}; float32x4 color = sample(texture, sampler, vertex.uv); return { .color = color }; } |
데이터 바인딩을 제거하면 버텍스와 픽셀 셰이더를 더 단순하게 사용할 수 있다. 복잡한 데이터 바인딩 API는 모두 64비트 GPU 포인터 하나로 대체된다. 사용자는 각 버텍스 레이아웃마다 PSO 퍼뮤테이션을 만들지 않고도, 유연한 버텍스 페치 코드를 작성할 수 있다.
Rasterizer state
레거시 API(OpenGL과 DirectX 9)는 모든 셰이더 입력과 래스터라이저 상태를 설정하기 위한 세밀한 명령을 제공했다. 드라이버는 필요할 때 셰이더 파이프라인을 생성해야 했다. 하드웨어 특화 래스터라이저, 블렌더, 입력 어셈블러 명령 패킷은 개별적인 세밀한 상태를 결합한 섀도우 상태(shadow state)로부터 구성되었다. Vulkan 1.0과 DirectX 12는 정반대의 설계를 선택했다. 모든 상태를 사전에 PSO에 베이크했다. 뷰포트, 시저 사각형, 스텐실 값 등 일부 상태만 동적으로 변경할 수 있다. 그 결과 PSO 퍼뮤테이션이 대폭 증가했다.
PSO 생성은 비용이 크다. GPU 드라이버의 저수준 셰이더 컴파일러를 호출해야 하기 때문이다. PSO 퍼뮤테이션은 상당한 저장 공간과 RAM을 소비한다. PSO 변경은 가장 비용이 큰 상태 변경이다. 일부 벤더가 렌더 상태를 셰이더 마이크로코드에 직접 포함해 얻은 작은 성능 이점은, 파이프라인 생성·바인딩·데이터 관리 비용이 크게 증가하면서 발생한 성능 문제에 의해 상쇄되었다. 진자는 반대쪽으로 너무 많이 흔들렸다.
현대 GPU는 ALU 밀도가 높다. Nvidia와 AMD는 최근 추가 파이프라인을 통해 ALU 처리율을 두 배로 늘렸다. Apple도 M 시리즈 칩에서 fp32 파이프라인을 두 배로 늘렸다. 셰이더 내 상수 치환으로 해결되는 단순 상태는, 추가 ALU 명령 하나가 생기거나 유니폼 레지스터를 하나 더 사용하는 정도의 비용이 들더라도 파이프라인을 복제할 필요가 없다. 오늘날 대부분의 셰이더는 ALU 병목이 아니다. 그 비용은 측정하기 어려울 정도로 작지만, 퍼뮤테이션을 줄이는 이점은 매우 크다. Vulkan 1.3은 올바른 방향으로 나아간 큰 진전이다. 많은 베이크된 PSO 상태를 이제 동적으로 설정할 수 있다.
만약 더 깊이 살펴보면, 모든 GPU가 래스터라이저와 깊이-스텐실 유닛을 구성하기 위해 명령 패킷을 사용한다는 것을 알 수 있다. 이러한 명령 패킷은 셰이더 마이크로코드에 직접적으로 묶여 있지 않다. 래스터라이저 및 깊이-스텐실 상태를 변경하기 위해 셰이더 마이크로코드를 수정할 필요는 없다. Metal은 별도의 깊이-스텐실 상태 객체와 이를 적용하는 별도의 명령을 갖고 있다. 별도의 상태 객체는 PSO 퍼뮤테이션을 줄이고, 비용이 큰 셰이더 바인딩 호출을 줄인다. Vulkan 1.3의 동적 상태 역시 유사하게 PSO 퍼뮤테이션을 줄이지만, 더 세밀한 방식이다. Metal의 설계는 실제 하드웨어 명령 패킷 구조와 더 잘 맞는다. 더 큰 패킷은 API 비대화와 오버헤드를 줄인다. DirectX 12는 안타깝게도 대부분의 깊이-스텐실 상태를 여전히 PSO에 묶어두고 있다(스텐실 레퍼런스와 깊이 바이어스만 동적 상태다). 우리의 설계에서는 깊이-스텐실 상태를 별도의 객체로 둔다.
GpuDepthStencilDesc depthStencilDesc = { .depthMode = DEPTH_READ | DEPTH_WRITE, .depthTest = OP_LESS_EQUAL, .depthBias = 0.0f, .depthBiasSlopeFactor = 0.0f, .depthBiasClamp = 0.0f, .stencilReadMask = 0xff, .stencilWriteMask = 0xff, .stencilFront = { .test = OP_ALWAYS, .failOp = OP_KEEP, .passOp = OP_KEEP, .depthFailOp = OP_KEEP, .reference = 0 }, .stencilBack = { .test = COMPARE_ALWAYS, .failOp = OP_KEEP, .passOp = OP_KEEP, .depthFailOp = OP_KEEP, .reference = 0 } }; // A minimal way to descibe the above (using C++ API struct default values): GpuDepthStencilDesc depthStencilDesc = { .depthMode = DEPTH_READ | DEPTH_WRITE, .depthTest = OP_LESS_EQUAL, }; GpuDepthStencilState depthStencilState = gpuCreateDepthStencilState(depthStencilDesc); |
즉시 모드-Immediate mode(desktop) GPU도 알파 블렌더 유닛을 구성하기 위한 유사한 명령 패킷을 가지고 있다. 만약 우리가 DirectX 13을 설계한다면, 단순히 블렌드 상태 객체를 PSO에서 분리하고 끝낼 것이다. 하지만 우리는 크로스 플랫폼 API를 설계하고 있으며, 모바일 GPU에서 블렌딩은 완전히 다르게 동작한다.
모바일 GPU(TBDR)는 오래전부터 프로그래머블 블렌딩을 지원해왔다. 렌더 타일은 컴퓨트 유닛 근처의 스크래치패드 메모리(groupshared 메모리같은)에 들어가며, 픽셀 셰이더는 이전에 래스터라이즈된 픽셀에 대해 직접적이고 저지연의 읽기+쓰기 접근을 할 수 있다. 대부분의 모바일 GPU에는 고정 기능 블렌딩 하드웨어가 없다. 전통적인 그래픽스 API를 사용할 경우, 드라이버의 저수준 셰이더 컴파일러는 셰이더 끝에 블렌딩 명령을 추가한다. 이는 앞서 설명한 버텍스 페치 코드 생성과 유사하다. 만약 모바일 GPU만을 위한 API를 설계한다면, 버텍스 버퍼를 제거했던 것처럼 블렌드 상태 API도 완전히 제거했을 것이다. 모바일 중심 API는 현재 픽셀의 이전 색상을 효율적으로 얻기 위한 프레임버퍼 페치 intrinsic을 노출한다. 사용자는 원하는 어떤 블렌딩 공식이든 작성할 수 있으며, 순서 독립 투명도 같은 고급 알고리즘을 구현하기 위한 복잡한 공식도 가능하다. 또한 사용자는 PSO 퍼뮤테이션 폭증을 줄이기 위해 일반화된 매개변수화 공식도 작성할 수 있다. 이처럼 데스크톱과 모바일 GPU는 블렌딩과 관련된 파이프라인 퍼뮤테이션을 줄이는 각자의 방법을 가지고 있으며, 제약은 현재의 API에 있다.
Vulkan의 서브패스(subpass)는 프레임버퍼 페치를 크로스 플랫폼 API로 감싸기 위해 설계되었다. 이는 Vulkan 설계에서 또 하나의 잘못된 선택이었다. Vulkan은 Mantle에서 단순한 저수준 설계를 계승했지만, OpenGL의 대체재로 설계되었기 때문에 모든 모바일과 데스크톱 GPU 아키텍처를 대상으로 해야 했다. 완전히 다른 두 아키텍처 유형을 동일한 API 아래에서 추상화하는 것은 쉽지 않다. 서브패스는 저수준 API 안에 들어간 고수준 개념이 되었다. 하나의 서브패스는 전체 렌더 패스 체인을 정의할 수 있었지만, 마치 단일 렌더 패스인 것처럼 취급되었다. 서브패스는 드라이버 복잡성을 증가시키고, 셰이더 및 렌더패스 API를 불필요하게 복잡하게 만들었다. 사용자는 복잡한 영속적 멀티 렌더 패스 객체를 사전에 생성하고 이를 셰이더 파이프라인 생성 시 전달해야 했다. 셰이더 파이프라인은 내부적으로 여러 개의 파이프라인(서브패스당 하나)으로 분리되었다. Vulkan은 단지 프레임버퍼 페치 intrinsic을 셰이더 언어에 직접 노출하지 않기 위해 이 모든 복잡성을 추가했다. 설상가상으로 서브패스는 프로그래머블 블렌딩 문제를 해결하기에도 충분하지 않았다. 픽셀 순서는 패스 경계에서만 보장된다. 서브패스는 제한적인 1:1 멀티패스 사용 사례에서만 유용했다. Vulkan 1.3은 서브패스를 폐기하고 “동적 렌더링(dynamic rendering)”을 도입했다. 이제 사용자는 Metal, DirectX 12, WebGPU처럼 영속적 렌더 패스 객체를 생성할 필요가 없다. 이는 복잡한 프레임워크가 API 설계자에게 매력적으로 보일 수 있지만, 개발자는 단순한 셰이더 intrinsic을 선호한다는 좋은 예다. 게임 엔진은 이미 플랫폼별로 서로 다른 셰이더를 빌드하는 것을 지원한다. Apple의 Metal 예제도 마찬가지다: iOS에서는 프레임버퍼 페치를 사용하고, Mac에서는 전통적인 멀티패스 알고리즘을 사용한다.
블렌딩과 프레임버퍼 페치에 관한 하드웨어 차이를 추상화할 수 없다는 것은 분명하다. Vulkan 1.0은 이를 시도했고 크게 실패했다. 올바른 해결책은 사용자에게 선택권을 제공하는 것이다. 사용자는 블렌드 상태를 PSO에 포함시킬 수 있다. 이는 모든 플랫폼에서 동작하며, 블렌드 상태와 관련된 파이프라인 퍼뮤테이션 문제를 겪지 않는 셰이더에 적합한 접근이다. 모바일 GPU에서는 내부 드라이버 셰이더 컴파일러가 평소처럼 픽셀 셰이더 끝에 블렌딩 명령을 추가한다. 즉시 모드(데스크톱) GPU(및 일부 모바일 GPU)에서는 사용자가 별도의 블렌드 상태 객체를 사용할 수 있다. 이는 PSO 퍼뮤테이션 수를 줄이고, 전체 파이프라인 변경 없이 블렌드 상태 구성 패킷만 전송하면 되므로 런타임에 블렌드 상태를 더 빠르게 변경할 수 있게 해준다.
GpuBlendDesc blendDesc = { .colorOp = OP_ONE, .srcColorFactor = FACTOR_SRC_ALPHA, .dstColorFactor = FACTOR_ONE_MINUS_SRC_ALPHA, .alphaOp = OP_ONE, .srcAlphaFactor = FACTOR_SRC_ALPHA, .dstAlphaFactor = FACTOR_ONE_MINUS_SRC_ALPHA, .colorWriteMask = 0xf }; // Create blend state object (needs feature flag) GpuBlendState blendState = gpuCreateBlendState(blendDesc); // Set dynamic blend state (needs feature flag) gpuSetBlendState(commandBuffer, blendState); |
모바일 GPU에서는 사용자가 기존처럼 블렌드 상태를 PSO에 포함시킬 수도 있고, 프레임버퍼 페치를 사용해 커스텀 블렌딩 공식을 작성할 수도 있다. 모바일 개발자가 서로 다른 알파 블렌딩 모드에 대해 여러 PSO 퍼뮤테이션을 컴파일하는 것을 피하고 싶다면, 동적 드로우 구조체 입력으로 매개변수화된 일반 공식을 작성하면 된다.
// Standard percentage blend formula (added automatically by internal shader compiler) dst.rgb = src.rgb * src.a + dst.rgb * (1.0 - src.a); dst.a = src.a * src.a + dst.a * (1.0 - src.a); // Custom formula supporting all blend modes used by HypeHype const BlendParameters& p = data->blendParameters; vec4 fs = src.a * vec4(p.sc_sa.xxx + p.sc_one.xxx, p.sa_sa + p.sa_one) + dst.rgba * vec4(p.sc_dc.xxx, sa_da); vec4 fd = (1.0 - src.a) * vec4(p.dc_1msa.xxx, p.da_1msa) + vec4(p.dc_one.xxx, p.da_one); dst.rgb = src.rgb * fs.rgb + dst.rgb * fd.rgb; dst.a = src.a * fs.a + dst.a * fd.a; |
블렌드 상태가 PSO에서 분리되면 일부 자동 데드 코드 최적화를 잃을 가능성이 있다. 사용자가 컬러 출력을 비활성화하고 싶을 때는 전통적으로 블렌드 상태의 colorWriteMask를 사용한다. 블렌드 상태가 PSO에 포함되어 있을 경우, 컴파일러는 이를 기반으로 데드 코드 제거를 수행할 수 있다. 유사한 데드 코드 최적화를 가능하게 하기 위해, 우리는 PSO에 각 컬러 타깃별 writeMask를 둔다.
듀얼 소스 블렌딩은 픽셀 셰이더에서 두 개의 컬러 출력을 요구하는 특수 블렌드 모드다. 듀얼 소스 블렌딩은 단일 렌더 타깃만 지원한다. 우리의 블렌드 상태는 분리될 수 있으므로, PSO 설명자에 supportDualSourceBlending 필드를 두어야 한다. 이 옵션이 활성화되면, 셰이더 컴파일러는 두 번째 출력이 듀얼 소스 블렌딩용임을 알 수 있다. 출력이 존재하지 않으면 검증 레이어가 오류를 발생시킨다. 두 개의 컬러를 출력하는 픽셀 셰이더는 듀얼 소스 블렌딩 없이도 사용할 수 있지만(두 번째 컬러는 무시됨), 두 개의 컬러를 출력하는 데에는 약간의 비용이 있다.
PSO에 남는 렌더링 상태는 최소한이다: 프리미티브 토폴로지, 렌더 타깃 및 깊이-스텐실 타깃 포맷, MSAA 샘플 수, 알파 투 커버리지다. 이러한 상태는 생성되는 셰이더 마이크로코드에 영향을 미치므로 PSO에 남아 있어야 한다. 상태 변경 때문에 셰이더 PSO 마이크로코드를 재빌드하는 일은 절대 원하지 않는다. 블렌드 상태가 PSO에 포함된 경우, 그것 역시 PSO에 베이크된다. 이렇게 하면 PSO 생성을 위한 단순한 래스터 상태 구조체만 남게 된다.
GpuRasterDesc rasterDesc = { .topology = TOPOLOGY_TRIANGLE_LIST, .cull = CULL_CCW, .alphaToCoverage = false, .supportDualSourceBlending = false, .sampleCount = 1` .depthFormat = FORMAT_D32_FLOAT, .stencilFormat = FORMAT_NONE, .colorTargets = { { .format = FORMAT_RG11B10_FLOAT }, // G-buffer with 3 render targets { .format = FORMAT_RGB10_A2_UNORM }, { .format = FORMAT_RGBA8_UNORM } }, .blendstate = GpuBlendDesc { ... } // optional (embedded blend state, otherwise dynamic) }; // A minimal way to descibe the above (using C++ API struct default values): GpuRasterDesc rasterDesc = { .depthFormat = FORMAT_D32_FLOAT, .colorTargets = { { .format = FORMAT_RG11B10_FLOAT }, { .format = FORMAT_RGB10_A2_UNORM }, { .format = FORMAT_RGBA8_UNORM } }, }; // Pixel + vertex shader auto vertexIR = loadFile("vertexShader.ir"); auto pixelIR = loadFile("pixelShader.ir"); GpuPipeline graphicsPipeline = gpuCreateGraphicsPipeline(vertexIR, pixelIR, rasterDesc); // Mesh shader auto meshletIR = loadFile("meshShader.ir"); auto pixelIR = loadFile("pixelShader.ir"); GpuPipeline graphicsMeshletPipeline = gpuCreateGraphicsMeshletPipeline(meshletIR, pixelIR, rasterDesc); |
HypeHype의 Vulkan 셰이더 PSO 초기화 백엔드 코드는 400줄이며, 내가 개발해온 다른 엔진들과 비교해도 상당히 간결한 편이다. 여기서는 단 18줄의 코드로 픽셀 + 버텍스 셰이더를 초기화했다. 읽고 이해하기도 쉽다. 그럼에도 성능 저하는 없다.
래스터 파이프라인으로 렌더링하는 방식은 컴퓨트 파이프라인과 유사하다. 하나의 데이터 포인터 대신 두 개를 제공한다는 점만 다르다. 커널 엔트리 포인트가 두 개이기 때문이다: 하나는 버텍스 셰이더용, 다른 하나는 픽셀 셰이더용이다. Metal은 버텍스와 픽셀 셰이더에 대해 별도의 데이터 바인딩 슬롯을 가진다. DirectX, Vulkan, WebGPU는 각 바인딩에 대해 가시성 마스크(버텍스, 픽셀, 컴퓨트 등)를 사용한다. 많은 엔진은 동일한 데이터를 버텍스와 픽셀 셰이더에 모두 바인딩한다. DirectX, Vulkan, WebGPU에서는 마스크 비트를 결합할 수 있어 이는 적절한 방식이지만, Metal에서는 바인딩 호출이 두 배로 늘어난다. 우리가 제안하는 두 개의 데이터 포인터 방식은 양쪽의 장점을 모두 가진다. 버텍스와 픽셀 셰이더에서 동일한 데이터를 사용하고 싶다면 동일한 포인터를 두 번 전달하면 된다. 또는 셰이더 단계 간 완전한 분리를 원한다면 서로 다른 데이터 포인터를 제공할 수도 있다. 셰이더 컴파일러는 픽셀과 버텍스 셰이더에 대해 각각 데드 코드 제거 및 상수/스칼라 프리로드 최적화를 수행한다. 데이터 공유든 데이터 중복이든 성능에 악영향을 주지 않는다. 사용자는 자신의 설계에 맞는 방식을 선택하면 된다.
// Common header... struct Vertex { float32x4 position; uint16x2 uv; }; struct alignas(16) DataVertex { float32x4x4 matrixMVP; const Vertex *vertices; }; struct alignas(16) DataPixel { float32x4 color; uint32 textureIndex; }; // CPU code... gpuSetDepthStencilState(commandBuffer, depthStencilState); gpuSetPipeline(commandBuffer, graphicsPipeline); auto dataVertex = myBumpAllocator.allocate<DataVertex>(); dataVertex.cpu->matrixMVP = camera.viewProjection * modelMatrix; dataVertex.cpu->vertices = mesh.vertices; auto dataPixel = myBumpAllocator.allocate<DataPixel>(); dataPixel.cpu->color = material.color; dataPixel.cpu->textureIndex = material.textureIndex; gpuDrawIndexed(commandBuffer, dataVertex.gpu, dataPixel.gpu, mesh.indices, mesh.indexCount); // Vertex shader... struct VertexOut { float32x4 position : SV_Position; // SV values are not real struct fields (doesn't affect the layout) float32x2 uv; }; VertexOut main(uint32 vertexIndex : SV_VertexID, const DataVertex* data) { Vertex vertex = data.vertices[vertexIndex]; float32x4 position = data->matrixMVP * vertex.position; return { .position = position, .uv = vertex.uv }; } // Pixel shader... const Texture textureHeap[]; struct VertexIn // Matching vertex shader output struct layout { float32x2 uv; }; PixelOut main(const VertexIn &vertex, const DataPixel* data) { Texture texture = textureHeap[data->textureIndex]; Sampler sampler = {.minFilter = LINEAR, .magFilter = LINEAR}; float32x4 color = sample(texture, sampler, vertex.uv); return { .color = color }; } |
우리의 목표는 PSO 내부에 남아 있는 상태를 최소화하여 PSO 퍼뮤테이션 폭증을 줄이는 것이었다. 깊이-스텐실 상태는 모든 아키텍처에서 분리할 수 있다. 블렌드 상태 분리는 데스크톱 하드웨어에서는 가능하지만, 대부분의 모바일 하드웨어는 블렌드 방정식을 픽셀 셰이더 마이크로코드 끝에 포함시킨다. 프레임버퍼 페치 intrinsic을 사용자에게 직접 노출하는 것은 Vulkan의 실패한 서브패스 접근보다 훨씬 나은 방법이다. 사용자는 새로운 렌더링 알고리즘을 가능하게 하는 자신만의 블렌드 공식을 작성할 수 있고, PSO 수를 줄이기 위해 일반화된 매개변수화 블렌딩 공식을 만들 수도 있다.
Indirect drawing
표준 드로우/디스패치 명령은 C/C++ 함수 매개변수를 사용해 스레드 그룹 차원, 인덱스 개수, 인스턴스 개수 등의 인자를 전달한다. 간접 드로우 호출은 이러한 드로우 인자의 소스로 GPU 버퍼 + 오프셋 쌍을 대신 제공할 수 있게 하며, 이는 GPU 주도 렌더링을 가능하게 하는 중요한 기능이다. 우리의 버전은 일반적인 버퍼 객체 + 오프셋 쌍 대신 단일 GPU 포인터를 사용해 API를 약간 더 단순화한다.
gpuDispatchIndirect(commandBuffer, data.gpu, arguments.gpu); gpuDrawIndexedInstancedIndirect(commandBuffer, dataVertex.gpu, dataPixel.gpu, arguments.gpu); |
우리의 모든 인자는 GPU 포인터다. 데이터와 인자 모두가 간접적이다. 이는 기존 API에 비해 큰 개선이다. DirectX 12, Vulkan, Metal은 간접 루트 인자를 지원하지 않는다. 이를 제공하는 것은 CPU의 몫이다.
간접 멀티드로우(MDI)도 지원되어야 한다. 드로우 개수는 GPU 주소에서 가져온다. MDI 파라미터는 다음과 같다: 루트 데이터 배열(GPU 포인터, 버텍스와 픽셀 각각), 드로우 인자 배열(GPU 포인터), 그리고 루트 데이터 배열의 스트라이드(버텍스와 픽셀 각각). 스트라이드가 0이면 동일한 루트 데이터가 각 드로우에 대해 반복 사용됨을 의미한다.
gpuDrawIndexedInstancedIndirectMulti( commandBuffer, dataVertex.gpu, sizeof(DataVertex), dataPixel.gpu, sizeof(DataPixel), arguments.gpu, drawCount.gpu ); |
Vulkan의 멀티드로우는 드로우 호출마다 바인딩을 변경할 수 없다. gl_DrawID를 사용해 드로우 데이터 구조체를 담은 버퍼를 인덱싱할 수 있지만, 이는 셰이더에 간접 참조를 추가한다. 텍스처를 가져오기 위해서는 디스크립터 인덱싱이나 새로운 디스크립터 버퍼 확장을 사용해야 한다.
DirectX 12의 ExecuteIndirect는 사용자가 드로우마다 루트 상수를 수동 설정할 수 있는 구성 가능한 커맨드 시그니처를 제공하지만, 모든 GPU 커맨드 프로세서에서 빠른 경로를 타지는 않는다. ExecuteIndirect Tier 1.1(2024)은 D3D12_INDIRECT_ARGUMENT_TYPE_INCREMENTING_CONSTANT라는 새로운 선택적 카운터 증가 기능을 추가했다. 이는 드로우 ID를 구현하는 데 사용할 수 있다. SM 6.8(2024)은 마침내 SV_StartInstanceLocation을 지원해, 사용자가 간접 드로우 인자에 상수를 직접 포함할 수 있게 했다. SV_InstanceID와 달리 SV_StartInstanceLocation은 전체 드로우 호출에 걸쳐 균일하므로, 인덱스 로드에 대해 최적의 코드 생성(유니폼/스칼라 경로)을 제공한다. 그러나 데이터 페치는 여전히 간접 참조가 필요하다. GPU 생성 루트 데이터는 지원되지 않는다.
드로우 인자나 루트 데이터를 GPU에서 생성하는 경우, 커맨드 프로세서가 디스패치 완료를 기다리도록 보장해야 한다. 현대 커맨드 프로세서는 지연을 숨기기 위해 명령과 인자를 미리 가져온다(prefetch). 이를 방지하기 위한 플래그를 우리는 배리어에 포함한다. 가장 좋은 방법은 세밀한 배리어를 피하기 위해 모든 드로우 인자와 루트 데이터를 한 번에 업데이트하는 것이다.
gpuBarrier(commandBuffer, STAGE_COMPUTE, STAGE_COMPUTE, HAZARD_DRAW_ARGUMENTS);
|
간접 셰이더 선택이 없다는 점은 현재 PC 및 모바일 그래픽스 API의 중요한 한계다. 간접 셰이더 선택은 레이 트레이싱과 유사한 멀티 파이프라인으로 구현할 수 있다. Metal은 간접 커맨드 생성도 지원한다(Nvidia는 유사한 Vulkan 확장을 제공한다). 드로우 호출을 효율적으로 건너뛰는 기능은 유용한 부분집합이다. DirectX의 워크 그래프와 CUDA의 동적 병렬성은 셰이더가 필요에 따라 추가 웨이브를 생성할 수 있게 한다. 그러나 이러한 하드웨어 개선에 접근하기 위한 API는 여전히 플랫폼별로 다르고, 여러 셰이더 엔트리 포인트에 흩어져 있다. 명확한 표준화가 없다. 이에 대해서는 후속 글에서 셰이더 프레임워크를 다루며 자세히 설명할 예정이다.
우리의 제안 설계는 간접 드로잉을 매우 강력하게 만든다. 셰이더 루트 데이터와 드로우 파라미터 모두 GPU에서 간접적으로 제공할 수 있다. 이러한 장점은 멀티드로우를 강화해, 해킹 없이 깔끔하고 효율적인 드로우별 데이터 바인딩을 가능하게 한다. 간접 드로잉과 셰이더 프레임워크의 미래는 후속 글에서 다룰 것이다.
Render passes
래스터라이저 하드웨어는 드로잉을 시작하기 전에 렌더링 준비가 되어야 한다. 일반적인 작업에는 렌더 타깃과 깊이-스텐실 뷰 바인딩, 컬러 및 깊이 클리어가 포함된다. 클리어 색상이 변경되면 fast clear eliminate가 발생할 수 있다. 이는 클리어 명령에서 투명하게 처리된다. 모바일 GPU에서는 렌더링 중에 온칩 저장소의 타일이 VRAM으로 저장된다. Vulkan, Metal, WebGPU는 렌더 타깃의 클리어, 로드, 저장을 위해 렌더 패스 추상화를 사용한다. DirectX 12는 최신 Intel(Gen11) 및 Qualcomm(Adreno 630) GPU에서 렌더링을 최적화하기 위해 2018년 업데이트에서 렌더 패스 지원을 추가했다. 렌더 패스 추상화는 눈에 띄는 API 복잡성을 추가하지 않으므로, 현대적인 크로스 플랫폼 API에서 합리적인 선택이다.
DirectX 12에는 렌더 타깃 뷰와 깊이-스텐실 뷰가 있으며, 이를 저장하기 위한 별도의 디스크립터 힙이 있다. 이는 단지 API 추상화일 뿐이다. 이러한 힙은 드라이버가 할당한 CPU 메모리다. 렌더 타깃과 깊이-스텐실 뷰는 GPU 디스크립터가 아니다. 래스터라이저 API는 바인드리스가 아니다. CPU 드라이버가 명령 패킷을 사용해 래스터라이저를 설정한다. Vulkan과 Metal에서는 기존 텍스처/뷰 객체를 beginRenderPass에 직접 전달한다. 드라이버는 내부적으로 텍스처 객체에서 필요한 정보를 얻는다. 우리가 제안하는 GpuTexture 객체가 이 역할을 수행한다. 래스터라이제이션 출력은 여전히 CPU 측 텍스처 객체가 필요한 주요 이유다. 우리는 텍스처 디스크립터를 GPU 메모리에 직접 작성한다. CPU 측 드라이버는 그것에 접근할 수 없다.
GpuRenderPassDesc renderPassDesc = { .depthTarget = {.texture = deptStencilTexture, .loadOp = CLEAR, .storeOp = DONT_CARE, .clearValue = 1.0f}, .stencilTarget = {.texture = deptStencilTexture, .loadOp = CLEAR, .storeOp = DONT_CARE, .clearValue = 0}, .colorTargets = { {.texture = gBufferColor, .loadOp = LOAD, .storeOp = STORE, .clearColor = {0,0,0,0}}, {.texture = gBufferNormal, .loadOp = LOAD, .storeOp = STORE, .clearColor = {0,0,0,0}}, {.texture = gBufferPBR, .loadOp = LOAD, .storeOp = STORE, .clearColor = {0,0,0,0}} } }; gpuBeginRenderPass(commandBuffer, renderPassDesc); // Add draw calls here! gpuEndRenderPass(commandBuffer); |
바인드리스 렌더 패스, 바인드리스/간접(멀티) 클리어 명령, 간접 시저/뷰포트 사각형(배열) 등이 있으면 좋겠지만, 오늘날 많은 GPU는 여전히 CPU 드라이버가 래스터라이저를 설정해야 한다.
배리어에 대한 참고 사항: 렌더 패스 시작/종료 명령이 자동으로 배리어를 발생시키지는 않는다. 서로 다른 렌더 타깃에 기록한다면, 사용자는 여러 렌더 패스를 동시에 실행할 수 있다. 래스터 출력 단계(또는 그 이후 단계)와 소비자 단계 사이에 배리어를 두면, 해당 GPU 아키텍처에서 필요할 경우 작은 ROP 캐시가 플러시된다. 렌더 패스 사이에 자동 배리어가 없는 것은 효율적인 깊이 프리패스 구현에 매우 중요하다(ROP 캐시가 불필요하게 플러시되지 않는다).

Prototype API
내 프로토타입 API는 한 화면에 들어간다: 코드 150줄이다. 이 블로그 글의 제목은 “No Graphics API”다. 물론 오늘날 그것은 불가능한 목표지만, 우리는 꽤 근접했다. WebGPU는 더 작은 기능 집합을 가지며, 약 2700줄 규모의 API(Emscripten C 헤더)를 제공한다. Vulkan 헤더는 약 20,000줄에 달하지만, 레이 트레이싱과 우리가 아직 지원하지 않는 많은 기능을 포함한다. 우리는 API 복잡성을 줄이면서도 성능을 희생하지 않았다. 이 기능 범위에서는, 우리의 API가 기존 API보다 더 큰 유연성을 제공한다. 2025년 여름 기준으로 완전히 확장된 Vulkan 1.4도 실제로는 동일한 작업을 수행할 수 있지만, 사용하기 훨씬 복잡하고 API 오버헤드도 더 크다.
// Opaque handles struct GpuPipeline; struct GpuTexture; struct GpuDepthStencilState; struct GpuBlendState; struct GpuQueue; struct GpuCommandBuffer; struct GpuSemaphore; // Enums enum MEMORY { MEMORY_DEFAULT, MEMORY_GPU, MEMORY_READBACK }; enum CULL { CULL_CCW, CULL_CW, CULL_ALL, CULL_NONE }; enum DEPTH_FLAGS { DEPTH_READ = 0x1, DEPTH_WRITE = 0x2 }; enum OP { OP_NEVER, OP_LESS, OP_EQUAL, OP_LESS_EQUAL, OP_GREATER, OP_NOT_EQUAL, OP_GREATER_EQUAL, OP_ALWAYS }; enum BLEND { BLEND_ADD, BLEND_SUBTRACT, BLEND_REV_SUBTRACT, BLEND_MIN, BLEND_MAX }; enum FACTOR { FACTOR_ZERO, FACTOR_ONE, FACTOR_SRC_COLOR, FACTOR_DST_COLOR, FACTOR_SRC_ALPHA, ... }; enum TOPOLOGY { TOPOLOGY_TRIANGLE_LIST, TOPOLOGY_TRIANGLE_STRIP, TOPOLOGY_TRIANGLE_FAN }; enum TEXTURE { TEXTURE_1D, TEXTURE_2D, TEXTURE_3D, TEXTURE_CUBE, TEXTURE_2D_ARRAY, TEXTURE_CUBE_ARRAY }; enum FORMAT { FORMAT_NONE, FORMAT_RGBA8_UNORM, FORMAT_D32_FLOAT, FORMAT_RG11B10_FLOAT, FORMAT_RGB10_A2_UNORM, ... }; enum USAGE_FLAGS { USAGE_SAMPLED, USAGE_STORAGE, USAGE_COLOR_ATTACHMENT, USAGE_DEPTH_STENCIL_ATTACHMENT, ... }; enum STAGE { STAGE_TRANSFER, STAGE_COMPUTE, STAGE_RASTER_COLOR_OUT, STAGE_PIXEL_SHADER, STAGE_VERTEX_SHADER, ... }; enum HAZARD_FLAGS { HAZARD_DRAW_ARGUMENTS = 0x1, HAZARD_DESCRIPTORS = 0x2, , HAZARD_DEPTH_STENCIL = 0x4 }; enum SIGNAL { SIGNAL_ATOMIC_SET, SIGNAL_ATOMIC_MAX, SIGNAL_ATOMIC_OR, ... }; // Structs struct Stencil { OP test = OP_ALWAYS, OP failOp = OP_KEEP; OP passOp = OP_KEEP; OP depthFailOp = OP_KEEP; uint8 reference = 0; }; struct GpuDepthStencilDesc { DEPTH_FLAGS depthMode = 0; OP depthTest = OP_ALWAYS; float depthBias = 0.0f; float depthBiasSlopeFactor = 0.0f; float depthBiasClamp = 0.0f; uint8 stencilReadMask = 0xff; uint8 stencilWriteMask = 0xff; Stencil stencilFront; Stencil stencilBack; }; struct GpuBlendDesc { BLEND colorOp = BLEND_ADD, FACTOR srcColorFactor = FACTOR_ONE; FACTOR dstColorFactor = FACTOR_ZERO; BLEND alphaOp = BLEND_ADD; FACTOR srcAlphaFactor = FACTOR_ONE; FACTOR dstAlphaFactor = FACTOR_ZERO; uint8 colorWriteMask = 0xf; }; struct ColorTarget { FORMAT format = FORMAT_NONE; uint8 writeMask = 0xf; }; struct GpuRasterDesc { TOPOLOGY topology = TOPOLOGY_TRIANGLE_LIST; CULL cull = CULL_NONE; bool alphaToCoverage = false; bool supportDualSourceBlending = false; uint8 sampleCount = 1; FORMAT depthFormat = FORMAT_NONE; FORMAT stencilFormat = FORMAT_NONE; Span<ColorTarget> colorTargets = {}; GpuBlendDesc* blendstate = nullptr; // optional embedded blend state }; struct GpuTextureDesc { TEXTURE type = TEXTURE_2D; uint32x3 dimensions; uint32 mipCount = 1; uint32 layerCount = 1; uint32 sampleCount = 1; FORMAT format = FORMAT_NONE; USAGE_FLAGS usage = 0; }; struct GpuViewDesc { FORMAT format = FORMAT_NONE; uint8 baseMip = 0; uint8 mipCount = ALL_MIPS; uint16 baseLayer = 0; uint16 layerCount = ALL_LAYERS; }; struct GpuTextureSizeAlign { size_t size; size_t align; }; struct GpuTextureDescriptor { uint64[4] data; }; // Memory void* gpuMalloc(size_t bytes, MEMORY memory = MEMORY_DEFAULT); void* gpuMalloc(size_t bytes, size_t align, MEMORY memory = MEMORY_DEFAULT); void gpuFree(void *ptr); void* gpuHostToDevicePointer(void *ptr); // Textures GpuTextureSizeAlign gpuTextureSizeAlign(GpuTextureDesc desc); GpuTexture gpuCreateTexture(GpuTextureDesc desc, void* ptrGpu); GpuTextureDescriptor gpuTextureViewDescriptor(GpuTexture texture, GpuViewDesc desc); GpuTextureDescriptor gpuRWTextureViewDescriptor(GpuTexture texture, GpuViewDesc desc); // Pipelines GpuPipeline gpuCreateComputePipeline(ByteSpan computeIR); GpuPipeline gpuCreateGraphicsPipeline(ByteSpan vertexIR, ByteSpan pixelIR, GpuRasterDesc desc); GpuPipeline gpuCreateGraphicsMeshletPipeline(ByteSpan meshletIR, ByteSpan pixelIR, GpuRasterDesc desc); void gpuFreePipeline(GpuPipeline pipeline); // State objects GpuDepthStencilState gpuCreateDepthStencilState(GpuDepthStencilDesc desc); GpuBlendState gpuCreateBlendState(GpuBlendDesc desc); void gpuFreeDepthStencilState(GpuDepthStencilState state); void gpuFreeBlendState(GpuBlendState state); // Queue GpuQueue gpuCreateQueue(/* DEVICE & QUEUE CREATION DETAILS OMITTED */); GpuCommandBuffer gpuStartCommandRecording(GpuQueue queue); void gpuSubmit(GpuQueue queue, Span<GpuCommandBuffer> commandBuffers); // Semaphores GpuSemaphore gpuCreateSemaphore(uint64 initValue); void gpuWaitSemaphore(GpuSemaphore sema, uint64 value); void gpuDestroySemaphore(GpuSemaphore sema); // Commands void gpuMemCpy(GpuCommandBuffer cb, void* destGpu, void* srcGpu,); void gpuCopyToTexture(GpuCommandBuffer cb, void* destGpu, void* srcGpu, GpuTexture texture); void gpuCopyFromTexture(GpuCommandBuffer cb, void* destGpu, void* srcGpu, GpuTexture texture); void gpuSetActiveTextureHeapPtr(GpuCommandBuffer cb, void *ptrGpu); void gpuBarrier(GpuCommandBuffer cb, STAGE before, STAGE after, HAZARD_FLAGS hazards = 0); void gpuSignalAfter(GpuCommandBuffer cb, STAGE before, void *ptrGpu, uint64 value, SIGNAL signal); void gpuWaitBefore(GpuCommandBuffer cb, STAGE after, void *ptrGpu, uint64 value, OP op, HAZARD_FLAGS hazards = 0, uint64 mask = ~0); void gpuSetPipeline(GpuCommandBuffer cb, GpuPipeline pipeline); void gpuSetDepthStencilState(GpuCommandBuffer cb, GpuDepthStencilState state); void gpuSetBlendState(GpuCommandBuffer cb, GpuBlendState state); void gpuDispatch(GpuCommandBuffer cb, void* dataGpu, uvec3 gridDimensions); void gpuDispatchIndirect(GpuCommandBuffer cb, void* dataGpu, void* gridDimensionsGpu); void gpuBeginRenderPass(GpuCommandBuffer cb, GpuRenderPassDesc desc); void gpuEndRenderPass(GpuCommandBuffer cb); void gpuDrawIndexedInstanced(GpuCommandBuffer cb, void* vertexDataGpu, void* pixelDataGpu, void* indicesGpu, uint32 indexCount, uint32 instanceCount); void gpuDrawIndexedInstancedIndirect(GpuCommandBuffer cb, void* vertexDataGpu, void* pixelDataGpu, void* indicesGpu, void* argsGpu); void gpuDrawIndexedInstancedIndirectMulti(GpuCommandBuffer cb, void* dataVxGpu, uint32 vxStride, void* dataPxGpu, uint32 pxStride, void* argsGpu, void* drawCountGpu); void gpuDrawMeshlets(GpuCommandBuffer cb, void* meshletDataGpu, void* pixelDataGpu, uvec3 dim); void gpuDrawMeshletsIndirect(GpuCommandBuffer cb, void* meshletDataGpu, void* pixelDataGpu, void *dimGpu); |
Tooling
버퍼와 텍스처 객체를 바인딩하지도 않고, 메모리 레이아웃을 명시적으로 설명하는 API도 호출하지 않는 코드를 어떻게 디버그할 수 있을까? C/C++ 디버거는 수십 년 동안 그런 일을 해왔다. 소프트웨어의 메모리 레이아웃을 설명하기 위한 특별한 운영체제 API는 없다. 디버거는 64비트 포인터 체인을 따라갈 수 있고, 컴파일러가 제공하는 디버그 심볼 데이터를 사용할 수 있다. 여기에는 구조체와 클래스의 메모리 레이아웃도 포함된다. CUDA와 Metal은 완전한 64비트 포인터 시맨틱을 갖는 C/C++ 기반 셰이딩 언어를 사용한다. 둘 다 포인터 체인을 문제없이 따라가는 견고한 디버거를 갖고 있다. 텍스처 디스크립터 힙은 그저 GPU 메모리다. 디버거는 이를 인덱싱해 텍스처 디스크립터를 로드하고, 디스크립터 데이터를 보여주며, 텍셀을 시각화할 수 있다. 이런 것들은 Xcode Metal 디버거에서 이미 동작한다. 어떤 GPU 주소의 어떤 구조체 안에 있는 텍스처나 샘플러 핸들을 클릭해도, 디버거가 그것을 시각화해준다.
현대 GPU는 메모리를 가상화한다. 각 프로세스는 자신의 페이지 테이블을 가진다. GPU 캡처에는 자체 가상 주소 공간을 가진 별도의 리플레이어 프로세스가 있다. 리플레이어가 모든 할당을 단순히 다시 재생(replay)한다면, 각 메모리 할당은 서로 다른 GPU 가상 주소를 받게 된다. 레거시 API에서는 GPU 주소를 데이터 구조체에 직접 저장할 수 없었기 때문에 이것이 문제가 되지 않았다. 하지만 현대 API에서는 리플레이어가 정확히 동일한 GPU 가상 메모리 레이아웃을 미러링하도록 강제하는 특수한 리플레이 메모리 할당 API가 필요하다. DX12와 Vulkan BDA에는 이를 위한 공개 API가 있다: RecreateAt과 VkMemoryOpaqueCaptureAddressAllocateInfo. Metal과 CUDA 디버거도 내부의 문서화되지 않은 API를 사용해 같은 일을 한다. RenderDoc 같은 오픈 소스 도구가 동작하려면 공개 API가 더 바람직하다.
원시 포인터는 보안 문제를 가져오지 않을까? 다른 앱의 메모리를 읽고/쓸 수 있지 않을까? 가상 메모리 때문에 이는 불가능하다. 접근할 수 있는 것은 자기 자신의 메모리 페이지뿐이다. 실수로 오래된 포인터를 쓰거나 오버플로가 발생하면 페이지 폴트가 발생한다. 페이지 폴트는 기존의 버퍼 기반 API에서도 가능하다. DirectX 12와 Vulkan은 스토리지(바이트어드레스/스트럭처드) 버퍼 주소를 클램프하지 않는다. OOB(범위 밖 접근)는 페이지 폴트를 일으킨다. 사용자가 메모리 힙을 해제한 뒤에도 오래된 버퍼/텍스처 디스크립터를 계속 사용해 페이지 폴트를 일으킬 수도 있다. 본질적으로 달라지는 것은 없다. 매핑되지 않은 영역에 접근하면 페이지 폴트가 발생하고 애플리케이션은 크래시한다. 이는 C/C++ 프로그래머에게 익숙한 동작이다. 견고함(robustness)이 필요하다면 ptr + size 쌍을 사용할 수 있다. WebGPU가 정확히 이런 방식으로 구현되어 있다. WebGPU 셰이더 컴파일러(Tint 또는 Naga)는 정점 접근(인덱스 버퍼 값 범위 밖)까지 포함해 각 버퍼 접근마다 추가 클램프 명령을 생성한다. WebGL은 인덱스 버퍼 데이터를 다른 데이터와 함께 셰이딩하는 것을 허용하지 않았다. WebGL은 CPU에서 인덱스를 훑어 검사했는데(그 결과 인덱스 버퍼 업데이트가 매우 느려졌다). 그 당시에는 커스텀 버텍스 페치가 불가능했다. 셰이더가 실행되기도 전에 하드웨어가 페이지 폴트를 내버렸기 때문이다.
Translation layers
기존 소프트웨어를 실행할 수 있는 능력은 매우 중요하다. ANGLE, Proton, MoltenVK 같은 번역 레이어는 레거시 API의 이식성과 폐기(deprecation) 과정에서 핵심적인 역할을 한다. 이제 DirectX 12, Vulkan, Metal을 우리의 새로운 API로 번역하는 방법을 살펴보자.
MoltenVK(Vulkan → Metal 번역 레이어)는 Vulkan의 버퍼 중심 API가 Metal의 64비트 포인터 기반 생태계로 번역될 수 있음을 증명한다. MoltenVK는 Vulkan의 디스크립터 세트를 Metal의 argument buffer로 변환한다. 생성된 argument buffer는 각 버퍼 바인딩당 64비트 GPU 포인터 하나, 각 텍스처 바인딩당 64비트 텍스처 ID 하나를 포함하는 표준 GPU 구조체다. 우리는 더 나은 방식을 사용할 수 있다. 각 디스크립터 세트에 대해 텍스처 힙에 연속된 범위의 텍스처 디스크립터를 할당하고, 각 텍스처 바인딩마다 64비트 텍스처 ID 대신 단일 32비트 베이스 인덱스를 저장하는 것이다. 이는 우리의 API가 Metal과 달리 사용자 관리 텍스처 힙을 가지기 때문에 가능하다.
MoltenVK는 디스크립터 세트를 Metal API 루트 바인딩 슬롯에 매핑한다. 우리는 최대 8개의 64비트 포인터 필드를 가진 루트 구조체를 생성하고, 각 필드는 디스크립터 세트 구조체를 가리킨다. 루트 상수는 값 필드로 변환되고, 루트 디스크립터(루트 버퍼)는 64비트 포인터로 변환된다. GPU 드라이버가 루트 구조체 필드를 유니폼/스칼라 레지스터에 프리로드한다고 가정하면, 효율성은 동일해야 한다.
우리 API는 Metal과 같은 64비트 포인터 시맨틱을 사용한다. 셰이더에서의 버퍼 로드/스토어 명령을 번역할 때 MoltenVK가 사용하는 기법을 그대로 적용할 수 있다. MoltenVK는 Vulkan의 buffer device address 확장도 번역 지원한다.
Proton(DX12 → Vulkan 번역 레이어)은 DirectX 12 SM 6.6 디스크립터 힙이 Vulkan의 descriptor buffer 확장으로 번역될 수 있음을 증명한다. Proton은 다른 DirectX 12 기능도 Vulkan으로 변환한다. MoltenVK를 통해 Vulkan → Metal 번역이 가능하다는 점을 이미 보였으므로, 간접적으로 DirectX 12 → Metal 번역도 가능함이 증명된다. MoltenVK에서 가장 부족한 기능은 SM 6.6 스타일 디스크립터 힙(Vulkan descriptor buffer 확장)이다. Metal은 디스크립터 힙을 사용자에게 직접 노출하지 않는다. 우리가 제안하는 새로운 API는 이러한 제한이 없다. 우리의 디스크립터 힙 시맨틱은 SM 6.6 디스크립터 힙의 상위 집합이며, Vulkan descriptor buffer 확장과도 매우 유사하다. 번역은 직관적이다. Vulkan 확장은 디스크립터 무효화 플래그도 추가하는데, 이는 우리의 HAZARD_DESCRIPTORS와 일치한다. DirectX 12 디스크립터 힙 API 역시 GPU 메모리의 원시 디스크립터 배열 위에 얇은 래퍼일 뿐이므로 번역이 쉽다.
Metal 4.0을 지원하려면 Metal의 드라이버 관리 텍스처 디스크립터 힙을 구현해야 한다. 이는 우리의 텍스처 힙 위에 단순한 프리리스트를 구현함으로써 가능하다. Metal은 64비트 텍스처 핸들을 사용하는데, 이는 최신 Apple Silicon에서 직접 힙 인덱스로 구현된다. Metal은 셰이더에서 텍스처 핸들을 텍스처처럼 직접 사용할 수 있게 한다. 이는 textureHeap[uint64(handle)]에 대한 문법적 설탕에 불과하다. Metal 텍스처 핸들은 셰이더 번역기를 통해 uint64로 변환되어 동일한 GPU 메모리 레이아웃을 유지한다.
우리 API는 버텍스 버퍼를 지원하지 않는다. WebGPU 역시 하드웨어 버텍스 버퍼를 사용하지 않지만, 고전적인 버텍스 버퍼 추상화를 구현한다. WGSL 셰이더 번역기(Tint 또는 Naga)는 각 버텍스 스트림에 대해 하나의 스토리지 버퍼 바인딩을 추가하고, 버텍스 셰이더 시작 부분에 버텍스 로드 명령을 생성한다. 커스텀 버텍스 페치는 OOB 동작을 방지하기 위한 클램프 명령을 삽입할 수 있다. 악의적인 웹사이트가 브라우저를 크래시시키는 일은 없다. 우리의 셰이더 번역기 역시 각 버텍스 스트림에 대해 루트 구조체에 64비트 포인터를 추가하고, 해당 레이아웃에 맞는 구조체를 생성하며, 버텍스 셰이더 시작 부분에 버텍스 구조체 로드 명령을 생성한다.
이로써 DirectX 12, Vulkan, Metal 애플리케이션을 우리의 새로운 API 위에서 실행하기 위한 번역 레이어를 작성하는 것이 가능함을 보였다. WebGPU는 브라우저에서 이러한 API 위에 구현되어 있으므로, WebGPU 애플리케이션도 실행할 수 있다.
Min spec hardware
Nvidia Turing(RTX 2000 시리즈, 2018)은 레이 트레이싱, 텐서 코어, 메시 셰이더, 저지연 raw 메모리 경로, 더 크고 빠른 캐시, 스칼라 유닛, 보조 정수 파이프라인 등 미래 지향적인 여러 기능을 도입했다. 공식적인 PCIe ReBAR 지원은 RTX 3000 시리즈와 함께 시작되었지만, 이를 지원하는 비공식 Turing 드라이버도 존재해 하드웨어 자체는 이를 지원할 수 있음을 보여준다. 이 7년 된 GPU는 우리가 필요로 하는 모든 기능을 지원한다. Nvidia는 2025년 가을에 GTX 1000 시리즈 드라이버 지원을 종료했다. 현재 지원 중인 모든 Nvidia GPU는 우리의 새로운 API를 지원할 수 있다.
AMD RDNA2(RX 6000 시리즈, 2020)는 레이 트레이싱과 메시 셰이더를 통해 Nvidia의 기능 세트에 맞섰다. 그보다 1년 앞서 RDNA 1은 코히어런트 L2$, 새로운 L1$ 레벨, 빠른 L0$, 범용 DCC 읽기/쓰기 경로, fastpath 비필터 로드, 최신 SIMD32 아키텍처를 도입했다. PCIe ReBAR는 “Smart Access Memory”라는 브랜드명으로 공식 지원된다. 이 5년 된 GPU는 우리가 필요로 하는 모든 기능을 지원한다. AMD는 이미 2021년에 GCN 드라이버 지원을 종료했다. 현재 RDNA 1과 RDNA 2는 버그 수정과 보안 업데이트만 받고 있으며, RDNA 3가 게임 최적화를 받는 가장 오래된 GPU다. 현재 지원되는 모든 AMD GPU는 우리의 API를 지원할 수 있다.
Intel Alchemist / Xe1(2022)은 SM 6.6 글로벌 인덱서블 힙을 지원한 최초의 Intel 칩이다. 이 칩들은 레이 트레이싱, 메시 셰이더, PCIe ReBAR(외장형), UMA(내장형)도 지원한다. 이 3년 된 Intel GPU들은 우리가 필요로 하는 모든 기능을 지원한다.
Apple M1 / A14(MacBook M1, iPhone 12, 2020)은 Metal 4.0을 지원한다. Metal 4.0은 CPU에 대한 GPU 메모리 가시성을 보장하며(폰과 컴퓨터 모두 UMA), 사용자가 64비트 포인터와 64비트 텍스처 핸들을 GPU 메모리에 직접 기록할 수 있게 한다. Metal 4.0에는 새로운 residency set API가 추가되어, 기존 useResource/useHeap API의 바인드리스 리소스 관리에서의 중요한 사용성 문제를 해결했다. iOS 26은 여전히 iPhone 11을 지원한다. 현재로서는 개발자가 Metal 4.0을 요구하는 앱을 출시할 수 없다. iOS 27에서는 아마도 내년에 iPhone 11 지원이 종료될 가능성이 크다. Mac에서는 Intel Mac 지원을 중단하면 Metal 4.0이 보장된다. M1부터 M5까지 5세대, 5년에 해당한다.
ARM Mali-G710(2021)은 ARM의 첫 번째 현대적 아키텍처다. 새로운 커맨드 스트림 프런트엔드(CSF)를 도입해 드로우 콜 빌딩에 대한 CPU 의존성을 줄이고, 멀티 드로우 간접과 컴퓨트 큐 같은 중요한 기능을 추가했다. 비균일 인덱스 텍스처 샘플링 성능이 크게 향상되었고, AFBC 무손실 압축기는 이제 16비트 부동소수점 타깃을 지원한다. G710은 Vulkan BDA와 descriptor buffer 확장을 지원하며, 향후 드라이버에서 2025년 unified image layout 확장도 지원할 수 있다. Mali-G715(2022)는 레이 트레이싱 지원을 도입했다.
Qualcomm Adreno 650(2019)은 Vulkan BDA, descriptor buffer, unified image layout 확장을 지원하며, 최신 Turnip 오픈소스 드라이버와 함께 16비트 저장/연산, dynamic rendering, 확장 동적 상태를 지원한다. Adreno 740(2022)는 레이 트레이싱을 도입했다.
PowerVR DXT(Pixel 10, 2025)는 PowerVR의 첫 번째 Vulkan descriptor buffer 및 buffer device address 확장 지원 아키텍처다. 또한 64비트 원자 연산, 8비트 및 16비트 저장/연산, dynamic rendering, 확장 동적 상태 및 우리가 요구하는 모든 기능을 지원한다.
Conclusion
현대 그래픽스 API는 지난 10년간 점진적으로 개선되어 왔다. DirectX 12 출시 6년 후, SM 6.6(2021)는 현대적인 글로벌 텍스처 힙을 도입해 완전한 바인드리스 렌더러 설계를 가능하게 했다. Metal 4.0(2025)과 CUDA는 최소한의 바인딩 API 표면을 갖춘 깔끔한 64비트 포인터 기반 셰이더 아키텍처를 제공한다. Vulkan은 가장 제한적인 표준을 갖고 있지만, buffer device address(2020), descriptor buffer(2022), unified image layouts(2025) 같은 확장을 통해 현대적인 바인드리스 인프라를 지원하게 되었다. 그러나 툴링은 여전히 뒤처져 있다. 현재로서는 우리의 모든 요구를 충족하는 단일 API는 없지만, 각 API의 장점을 결합하면 현대 하드웨어에 최적화된 이상적인 API를 만들 수 있다.
10년 전, 현대 API는 CPU 주도 바인딩 모델을 기준으로 설계되었다. 새로운 바인드리스 기능은 선택적 기능이나 확장으로 제시되었다. 과감한 단절(clean break)은 사용성을 개선하고 API 비대화와 드라이버 복잡성을 크게 줄일 수 있다. 업계 전체를 새로운 API로 이끄는 것은 매우 어렵다. 하지만 벤더들이 새로운 메이저 API 버전(Vulkan 2.0, DirectX 13)에서 하위 호환성을 과감히 포기하고, 오늘날의 완전한 바인드리스 GPU 아키텍처를 수용하길 기대한다. 새로운 바인드리스 API 설계는 API와 게임 엔진 RHI 사이의 불일치를 해결해 해시 맵과 세밀한 리소스 추적을 제거할 수 있게 한다. Metal 4.0은 이 목표에 근접했지만, 글로벌 인덱서블 텍스처 힙이 아직 없다. 64비트 텍스처 핸들 하나로는 텍스처 범위를 표현할 수 없다.
HLSL과 GLSL 셰이딩 언어는 20년 이상 전에 1:1 요소 단위 변환 함수(버텍스, 픽셀, 지오메트리, 헐, 도메인 등) 프레임워크로 설계되었다. 메모리 접근은 추상화되어 있고, 포인터를 지원하지 않기 때문에 배열 처리도 번거롭다. 20년이 지났지만 HLSL과 GLSL은 라이브러리 생태계를 구축하는 데 실패했다. 반면 CUDA는 메모리를 직접 노출하고, 새로운 기능(예: AI 텐서 코어)을 intrinsic으로 제공하는 조합 가능한 언어다. CUDA는 폭넓은 라이브러리 생태계를 갖추었고, 이는 Nvidia를 4조 달러 가치 기업으로 끌어올렸다. 우리는 여기서 배워야 한다.
WebGPU 참고 : WebGPU 설계는 10년 된 Vulkan 1.0 코어를 기반으로 하며 추가적인 제한을 둔다. WebGPU는 바인드리스 리소스, 64비트 GPU 포인터, 영구 매핑 GPU 메모리를 지원하지 않는다. DirectX 11과 Vulkan 1.0의 혼합처럼 보인다. 웹 그래픽스에는 큰 진전이지만, 현대적인 바인드리스 API 기준에는 미치지 못한다. WebGPU에 대해서는 별도의 블로그 글에서 다룰 예정이다.
내 프로토타입 API는 최신 API들의 장점을 결합했을 때 오늘날 현대 GPU 아키텍처로 무엇이 가능한지를 보여준다. DirectX 11과 Metal 1.0보다 사용하기 쉽고, 동시에 DirectX 12와 Vulkan보다 더 나은 성능과 유연성을 제공하는 API를 만드는 것이 가능하다. 우리는 현대 바인드리스 하드웨어를 받아들여야 한다.
Appendix
모든 예제 코드에서 사용된 간단한 사용자 공간 GPU 범프 할당기다. 임시 할당기 생성자에서 gpuHostToDevicePointer를 한 번 호출한다. GPU 포인터에 대해 표준 포인터 연산(예: 오프셋)을 수행할 수 있다. 기존 Vulkan/DX12 버퍼 API는 별도의 오프셋을 요구한다. 이는 API와 사용자 코드를 단순화한다(포인터 vs 핸들+오프셋 쌍). 실제 제품 수준의 임시 할당기는 오버플로 처리(확장, 플러시 등)를 구현해야 한다.
template<typename T> struct GPUTempAllocation<T> { T* cpu; T* gpu; } struct GPUBumpAllocator { uint8 *cpu; uint8 *gpu; uint32 offset = 0; uint32 size; TempBumpAllocator(uint32 size) : size(size) { cpu = gpuMalloc(size); gpu = gpuHostToDevicePointer(cpu); } TempAllocation<uint8> alloc(int bytes, int align = 16) { offset = alignRoundUp(offset, align); if (offset + bytes > size) offset = 0; // Simple ring wrap (no overflow detection) TempAllocation<uint8> alloc = { .cpu = cpu + offset, . gpu = gpu + offset }; offset += bytes; return alloc; } template<typename T> T* alloc(int count = 1) { TempAllocation<uint8> mem = alloc(sizeof(T) * count, alignof(T)); return TempAllocation<T> { .cpu = (T*)mem.cpu, . gpu = (T*)mem.gpu }; } }; |
'Technical Report > Graphics Tech Reports' 카테고리의 다른 글
| [번역] State of HLSL : 2026.2 (0) | 2026.02.14 |
|---|---|
| 2025 TA Campus 수업 문서 링크 (0) | 2026.01.20 |
| GPU Works Graphs(1) (0) | 2025.09.04 |
| [번역중]Microflake theory (0) | 2025.07.14 |
| GPU Specification compare PS5/XBOX Series X/Adreno/PC (0) | 2025.05.24 |