A Trip through the Graphics Pipeline 번역
해당 포스트를 번역한 글입니다.
이 포스트는 "A trip through the Graphics Pipeline 2011" 시리즈의 일부입니다.
제가 무언가를 이 곳에 포스팅한 지 꽤 되었고, 저는 이 블로그를 그래픽스 하드웨어와 소프트웨어의 몇 가지 일반적인 점들을 설명하기 위해 사용할 수 있을 것 같다고 생각했습니다. 당신은 당신의 PC의 그래픽스 스택이 무엇을 하는지 기능적인 설명을 찾을 수 있지만, "왜" 또는 "어떻게" 그것을 하는지는 쉽게 찾을 수 없습니다. 저는 너무 특정 하드웨어에 너무 구체적이지 않게 이러한 비어있는 칸을 채우려 노력할 것입니다. 저는 대부분 Windows 운영체제에서 D3D9/10/11을 실행할 수 있는 DX11 수준의 하드웨어에 대해 대부분을 이야기할 것인데, 이는 제가 가장 친숙한 장치 스택이기 때문입니다. API 세부 사항 등은 이러한 첫 번째 부분(하드웨어적인 요소들)을 넘어서 더 중요하진 않을 것입니다. 한번 우리가 실제 GPU 위에 있게 되면 이것들은 모두 네이티브한 명령들이기 때문입니다.
어플리케이션은 당신의 코드를 의미합니다. 이것들은 또한 당신의 버그들이기도 합니다. 네, API 런타임과 드라이버 역시 버그들을 가집니다. 그러나 이것은 그들 중 하나가 아닙니다. 그러니 이제 가서 어플리케이션(당신의 버그들)을 고치세요.
당신은 API를 호출함으로써 당신의 리소스를 생성하거나, 상태를 설정하거나, 드로우 콜을 수행할 수 있습니다. API 런타임은 당신의 어플리케이션이 설정한 현재의 상태를 추적하고, 파라미터 유효성을 검증하고, 다른 에러와 일관성 체크를 수행하고 유저에게 보이는 리소스들을 관리하며 API사양에 따라 쉐이더 코드와 쉐이더 링킹의 유효성을 검증합니다(적어도 D3D는 이를 수행하며, OpenGL에서는 이를 API가 아닌 드라이버 수준에서 다룹니다). API 런타임은 배치(batch)가 좀 더 작동한 후 그래픽 드라이버 전체에 걸쳐 동작할 수 있습니다 - 더 정확히는, 유저 모드 드라이버입니다.
유저 모드 드라이버는 CPU단에서 일어나는 대부분의 "마법"입니다. 만약 당신의 어플리케이션에 당신이 호출하였던 API에 의해 크래시가 발생한다면, 그것은 보통 여기에 있을 것입니다 :). 이것은 "nvd3dum.dll"(NVidia) 혹은 "atiumd*.dll"(AMD)라 불립니다. 이름에서 추측할 수 있듯이(역주: 공통적으로 um이 들어갑니다), 이것은 유저 모드 코드입니다. 이것은 당신의 어플리케이션과 동일한 주소 공간 및 컨텍스트에서 동작하며 어떠한 상승된 특권(역주: OS의 previledges 개념입니다)도 존재하지 않습니다. 이것은 더 낮은 수준의 API(D3D에서 DDI라고 불리는)를 구현합니다. 이 API는 당신이 Surface에서 보고 있는 것과 상당히 비슷하지만, 메모리 관리 및 그러한 것들과 같이 더 명시적입니다.
이 모듈은 쉐이더 컴파일 같은 것들이 일어나는 곳에 위치합니다. D3D는 사전 검증된 쉐이더 토큰 스트림을 UMD에 전달합니다 - 다시 말해, 이것(쉐이더 토큰 스트림)은 문법적으로 유효한지와 D3D의 제약을 준수하는지(올바른 타입을 사용하고 있는지, 사용 가능한 개수보다 더 많은 텍스쳐/샘플러들을 사용하진 않는지, 상수 버퍼나 그러한 것들의 개수가 허용량을 초과하진 않았는지 등)의 측면에서 그 코드가 유효한지 이미 검사되었습니다. 쉐이더 토큰 스트림은 HLSL 코드로부터 컴파일되었으며 일반적으로 많은 수의 고수준 최적화(다양한 루프 최적화들, 사용되지 않는 코드 제거, 상수 전파, 분기 예측 등)가 적용되어 있습니다 - 이러한 소식은 드라이버가 컴파일 타임에 수행된 이러한 모든 상대적으로 비용이 많이 드는 최적화로부터 이익을 얻는다는 것을 의미하기 때문에 좋은 소식입니다. 그러나, 쉐이더 토큰 스트림에는 또한 드라이버가 수행하는 것이 더 나을 많은 저수준 최적화(레지스터 할당과 루프 언롤링 등)도 이루어집니다. 축약하자면, 이것은 일반적으로 단지 즉각적으로 중간 표현(Intermediate Representation)으로 변환되며 그 뒤에 조금 더 컴파일이 이루어집니다; 쉐이더 하드웨어는 좋은 결과를 얻기 위한 놀라운 일을 하기 위해 컴파일을 할 필요가 없는 D3D 바이트코드에 (그리고 HLSL 컴파일러는 이미 충분히 도움을 주는 약간의 고수익 및 고비용 최적화를 수행했습니다) 충분히 가깝습니다만, 여전히 많은 D3D가 알지도 신경쓰지도 않는 저수준의 디테일들(하드웨어 리소스 제한과 스케줄링 제약과 같은)이 존재하기 때문에, 이것은 사소한 과정이 아닙니다.
그리고 물론, 만약 당신의 어플리케이션이 잘 알려진 게임이라면 NVidia/AMD의 프로그래머들이 아마도 당신의 쉐이더들을 찾아보고 그들의 하드웨어에 손수 최적화된 쉐이더를 작성해 대체했을 것입니다. 이러한 쉐이더들 역시 UMD에 의해 감지되고 대체됩니다. 고마워 할 필요 없어요.
더 재미있는 점: 몇몇 API 상태는 실제로 쉐이더 안으로 컴파일됩니다 - 예시를 들자면, 텍스처 테두리(Texture borders)와 같은 상대적으로 생소한(또는 적어도 드물게 사용되는) 기능들은 아마도 텍스처 샘플러에 구현되어 있지 않을 수 있지만 추가적인 코드와 함께 쉐이더에 에뮬레이트될 수 있습니다(또는 전혀 지원되지 않을 수 있습니다). 이것은 때때로 API 상태의 다른 조합을 위해 동일한 쉐이더의 다양한 버전이 존재함을 의미합니다.
우연하게, 이것은 또한 왜 당신이 종종 새로운 쉐이더나 리소스를 처음 사용할 때 딜레이를 보게 되는지에 대한 이유입니다; 많은 생성/컴파일 작업은 드라이버로부터 지연되며 오직 실제로 그것이 필요할 때 실행됩니다(당신은 일부 앱이 얼마나 많은 사용되지 않는 쓰레기를 생성하는지 믿을 수 없을 것입니다!). 그래픽스 프로그래머들은 이 이야기의 다른 면을 알고 있습니다 - 만약 당신이 무언가가 실제로 생성되기를 원한다면(단지 메모를 예약하는 것과는 반대로), 당신은 "워밍업"을 위해 사용되는 더미 드로우 콜을 수행해야 합니다. 구리고 짜증나지만, 이것은 제가 처음 3D 하드웨어를 사용하기 시작한 1999년부터 존재하는 일이었습니다. 즉, 이 시점에서는 거의 삶의 사실(A fact of life)이므로, 익숙해져야 합니다 :)
어쨌든, 이동합시다. UMD는 또한 D3D9 "레거시" 쉐이더 버전들과 고정된 함수 파이프라인과 같은 재미있는 것들을 다루기도 합니다 - 네. 그 모든 것들은 충실하게 D3D를 통해 전달될 것입니다. 3.0 쉐이더 버전은 그렇게 나쁘지 않지만, 2.0은 꽤 기분 나쁘고 다양한 1.x 쉐이더 버전들은 심각하게 쓰레기입니다 - 1.3 버전 픽셀 쉐이더, 또는 정점 라이팅과 그러한 것들을 가진 고정된 정점 쉐이더를 기억하십니까? 네, 비록 물론 그들이 단지 그것을 최신 쉐이더 버전으로 번역할 뿐이긴 하지만 이것들을 위한 지원은 여전히 D3D와 모든 현대 그래픽스 드라이버에 존재합니다(그리고 꽤 오랫동안 이것이 수행되어 왔습니다).
그런 다음엔 메모리 관리와 같은 것들이 있습니다. UMD는 텍스처 생성 명령과 같은 것들을 얻고 그것들을 위한 공간을 제공할 필요가 있습니다. 실제로, UMD는 단지 KMD(Kernal-Mode Driver)로부터 얻은 몇몇 더 큰 메모리 블록들을 하위 할당(suballocates)합니다; 실제로 페이지를 매핑하고 언매핑하는것(과 UMD가 볼 수 있는 비디오 메모리의 일부와 반대로 GPU가 접근할 수 있는 시스템 메모리의 일부를 관리하는 것)은 커널 모드의 특권이고 UMD에 의해 수행될 수 없습니다.
그러나 UMD는 swizzling textures(GPU가 이것을 하드웨어에서 할 수 없다면, 보통 실제 3D 파이프라인이 아닌 2D 블리팅(blitting) 유닛을 사용합니다)와 시스템 메모리와 (매핑된) 비디오 메모리 사이의 전송을 스케줄링하고, 이와 비슷한 것들을 할 수 있습니다. 가장 중요하게, UMD는 한번 KMD가 커맨드 버퍼들(또는 "DMA 버퍼들" - 저는 이 두 용어를 앞으로 혼용할 것입니다)을 할당하고 넘겨주면 그것들을 작성할 수 있습니다. 한 커맨드 버퍼는, 글쎄요, 커맨드들을 포함합니다 :). 모든 당신이 호출한 파이프라인 상태 변경과 그리기 명령들은 UMD에 의해 하드웨어가 이해할 수 있는 커맨드들로 변환됩니다. 비디오 메모리에 텍스처들과 쉐이더들을 업로드하는 것과 같이 당신이 수동으로 트리거하지 않는 것들과 마찬가지로요.
보통, 드라이버들은 가능한 한 많은 실질적인 프로세싱들을 UMD에 넣으려고 시도할 것입니다; UMD는 user-mode 코드이며, 따라서 그 안에서 일어나는 모든 것들은 값비싼 커널 모드 전환을 필요로 하지 않고, 자유롭게 메모리를 할당하며, 여러 스레드로 작업할 수 있습니다. 이것은 단지 보통 DLL입니다(비록 이것이 당신의 앱으로부터 바로 로드되지 않고 API를 통해 로드되지만요). 이것은 드라이버 개발에도 역시 이점을 가집니다 - 만약 UMD가 크래시가 난다면, 그 안에서 어플리케이션도 크래시가 나겠지만, 전체 시스템은 그렇지 않습니다(크래시가 나지 않습니다); UMD는 그저 시스템이 동작하는 동안 대체될 수 있습니다(단지 DLL이니까요!); 또 UMD는 일반 디버거들을 이용해 디버깅될 수 있습니다. 따라서 이것은 효율적일 뿐만 아니라 편리합니다.
그러나 제가 아직 언급하지 않은 아주 큰 코끼리가 방 안에 있습니다(역주: 방 안의 코끼리. 크고 무거운 문제를 의미하는 관용어입니다).
말했던 것 처럼, UMD는 그저 DLL입니다. 네, D3D의 축복과 KMD로 직접 연결된 파이프를 가지고 있지만, 이것은 여전이 보통 DLL이며, UMD를 호출한 프로세스의 주소공간 안에서 실행됩니다.
그리고 오늘날 우리는 멀티태스킹 OS들을 사용합니다. 사실, 사용한지 꽤 되었죠.
제가 계속 얘기하는 "GPU"요? GPU는 공유 자원입니다. 오직 당신의 메인 디스플레이를 구동하는 것은 단 하나뿐입니다(심지어 당신이 SLI/Crossfire를 사용하더라도요. 역주: SLI/Crossfire는 둘 이상의 그래픽카드를 병렬로 사용하는 기술입니다). 그러나 우리는 GPU에 접근하려고 시도하는 여러 어플리케이션들을 가지고 있습니다(그리고 이 앱들은 자신이 GPU에 접근하려는 유일한 존재인 것처럼 행동합니다). 이것은 자동적으로 이루어지지 않습니다; 오래전으로 돌아가서, 해결책은 오직 한 번에 하나의 앱에만 3D 자원을 할당하는 것이었습니다. 그리고 그 앱이 활성화된 동안에는, 모든 다른 앱들은 접근할 수 없었죠. 하지만 윈도우 시스템(Windowing system)이 렌더링을 위해 GPU를 사용하고자 할 때 그것은 실제로 이 요청을 잘라내지는 않습니다. 이것은 왜 당신이 GPU를 향한 임의 접근이 가능하고 시분할 할당이 가능한 몇 가지 컴포넌트를 필요로 하는지의 이유입니다.
이것은 한 시스템 컴포넌트("A" system component)입니다 - "The"는 다소 오해를 불러일으킬 수 있습니다; 저는 여기서 CPU 또는 IO 스케줄러가 아닌 그래픽스 스케줄러에 대해 이야기하고 있습니다. 이것은 정확히 당신이 생각하는 것을 수행합니다 - 이것은 3D 파이프라인을 사용하려는 다른 앱들 사이에서 시분할 방식으로 해당 파이프라인에 임의 접근을 수행합니다. 한번 문맥 교환이 발생하면, 적어도, GPU에서 몇몇 상태 변화(이는 커맨드 버퍼에 대한 추가 명령들을 생성합니다)가 일어나며 몇 가지 리소스들이 비디오 메모리의 안팎으로 교환이 일어날 수 있습니다. 그리고 물론 주어진 시간에 오직 한 프로세스가 실제로 커맨드들을 3D 파이프라인에 제출할 수 있습니다.
당신은 종종 콘솔 프로그래머들이 PC 3D API의 상당히 고수준(high-level)의, 실용적인 특성과 이로 인해 발생하는 성능 비용에 대해 불평하는 것을 발견할 것입니다. 그러나 PC의 3D API/드라이버들은 실제로 콘솔 게임들보다 더 해결하기 복잡한 문제를 가집니다 - 그들은 실제 전체적인 현재 상태(current state)를 추적해야 하는데, 왜냐하면 누군가 어느 순간에든지 그들 아래에 있는 은유적인 양탄자를 잡아당길 수 있기 때문입니다!(역주: 식탁 위에 잘 차려진 음식들이 있다고 가정해봅시다. 그리고 이제 식탁보를 잡아당겨보세요!) 그들은 또한 박살난 앱들 주위에서 작업하고 그들의 뒤에 가려진 성능 문제들을 해결하고자 시도합니다; 이것은 드라이버 제작자들 자신들을 포함하여 아무도 행복하지 않은 다소 짜증나는 관행이지만, 사실은 여기에서 비즈니스적인 관점이 이긴다는 점입니다(역주. 드라이버 제작사들 역시 이러한 작업들을 수행하는 게 짜증나고 힘들지만, 돈과 직결되는 커버리지적인 관점에서 이들을 지원할 수 밖에 없다는 뜻입니다). 사람들은 끊임없이(그리고 부드럽게) 동작하는 장치들을 기대합니다. 단지 어플리케이션에서 "이건 틀렸어!"를 외친 다음 토라진 상태로 매우 느린 길을 가는 것으로는 어떠한 친구도 얻을 수 없을 것입니다.
어쨌든, 저희는 파이프라인 위에 있습니다. 다음 정거장은 커널 모드입니다!
이것은 실제로 하드웨어를 다루는 부분입니다. 여러 UMD 인스턴스들이 동일한 시간에 동작하고 있을 수 있지만, KMD는 오직 하나만이 존재하며, 만약 KMD에 크래시가 발생할 경우, 펑! 당신은 사망합니다 - 흔히 "블루스크린" 이라고 일컫는 현상이 발생하나, 최신 Windows는 실제로 어떻게 크래시가 발생한 드라이버를 종료시키고(Kill) 그것을 재실행 할 지 알고 있습니다(진보했습니다!). 적어도 커널 메모리 손상이 아닌 단순 충돌인 한 말이죠 - 만약 그것(커널 메모리 손상)이 일어났다면, 그냥 끝입니다(All bets are off).
KMD는 그곳에 단 한번 존재하는 모든 것들을 다룹니다. 오직 GPU 메모리는 하나만 존재합니다. 비록 그 위에서 많은 앱들이 경쟁하고 있더라도 말이죠. 누군가는 그 경쟁에서 이겨서 실제로 물리 메모리를 할당(과 매핑)받아야 합니다. 이와 비슷하게, 누군가는 시작할 때 GPU를 초기화해야만 하고, 디스플레이 모드를 설정해야 하며(그리고 디스플레이로부터 모드 정보를 받아야 하며), 하드웨어 마우스 커서를 관리해야 하고(네, 실제로 이것을 위한 하드웨어 핸들링이 존재하며, 당신은 정말로 한 번에 단 하나만 얻을 수 있습니다! :)) HW 감시 타이머를 프로그래밍하여 특정 시간 동안 응답하지 않고 인터럽트에 반응하면 GPU가 재설정되어야 합니다. 바로 이것이 KMD가 하는 일입니다.
또한 비디오 플레이어와 GPU 사이에 보호/DRM 경로를 설정하는 것에 대한 전체 콘텐츠 보호/DRM 비트가 존재하므로 실제 소중한 디코딩된 비디오 픽셀은 디스크에 덤프하는 것과 같은 끔찍한 금지된 일을 할 수 있는 더러운 사용자 모드 코드에 표시되지 않습니다(...어쨌든요). KMD도 그것에 깊게 관여하고 있습니다.
우리에게 가장 중요한 것은, KMD는 실제 커맨드 버퍼를 관리한다는 것입니다. 당신도 알다시피, 그것은 실제로 하드웨어가 소비하는 것이죠. UMD가 관리하는 커맨드 버퍼들은 실제가 아닙니다. 사실, 그것들은 GPU 주소 지정이 가능한 메모리의 무작위 조각에 불과합니다. 그것들과 함께 실제로 일어나는 일은 UMD가 그들을 끝내고, 그들을 스케줄러에 제출한 뒤, 그 다음에 프로세스가 끝날 때까지 기다리면 그런 다음 UMD 커맨드 버퍼는 KMD의 커맨드 버퍼에 전달되며, 그런 다음 KMD는 명령 버퍼에 대한 호출을 메인 명령 버퍼에 기록하고, GPU 명령 프로세서가 메인 메모리에서 읽을 수 있는지 여부에 따라 먼저 비디오 메모리로 DMA해야 할 수도 있습니다. 메인 커맨드 버퍼는 일반적으로 (꽤 작은) 링 버퍼 - 오직 시스템/초기화 커맨드들과 꽉 찬 3D 커맨드 버퍼들을 호출하는 명령만이 쓰여져 있는 - 입니다.
그러나 지금 당장 이것은 단지 메모리 안의 한 버퍼일 뿐입니다. 이것의 위치는 그래픽카드에 알려져 있습니다 - 일반적으로 GPU가 메인 커맨드 버퍼의 어디에 위치해 있는지를 나타내는 읽기 포인터와 얼마나 KMD가 버퍼를 작성했는지 나타내는(더 정확히는, 쓰여지지 않은 GPU 위치로부터 얼마나 작성되었는지를 나타내는) 쓰기 포인터가 있습니다. 이것들은 하드웨어 레지스터들이며, 메모리 매핑됩니다. 그리고 KMD는 이것들을 주기적으로 업데이트합니다(보통 새로운 작업 덩어리(chunk)를 제출할 때 마다요)...
...그러나 물론 그러한 쓰기 작업은 먼저 버스 - 일반적으로 오늘날 PCI Express라 부르는 - 를 통해 이동해야 하기 때문에 바로 그래픽스 카드로 이동하진 않습니다(적어도, CPU 다이에 통합되지 않는 한!). DMA 운반 등은 같은 경로를 이용합니다. 이것은 매우 오래 걸리진 않지만, 아직 우리의 여정의 한 단계입니다. 마침내...
명령 프로세서는 GPU의 프론트엔드입니다 - KMD가 작성한 명령들을 실제로 읽는 부분입니다. 이 포스트가 충분히 길기 때문에, 저는 다음 기회에 여기에서부터 계속할 것입니다 :)
OpenGL은 API와 UMD레이어 간의 구분이 그다지 명확하지 않다는 것을 제외하면 제가 지금까지 서술해 온 것들과 상당히 비슷합니다. 그리고 D3D와 달리, (GLSL) 쉐이더 컴파일은 API에 의해 전부 일어나지 않으며, 드라이버에 의해 완료됩니다. 이러한 옵션의 불행한 부작용은 일반적으로 동일한 스펙을 구현했지만 각기 자신만의 버그와 특이성을 가진 3D 하드웨어 벤더들의 수 만큼 GLSL 프론트엔드들이 많이 존재한다는 것입니다. 재미있지 않죠. 그리고 이것은 또한 드라이버들이 쉐이더들을 마주칠 때마다 고비용 최적화를 포함한 모든 최적화를 그들 스스로 해야 한다는 것을 의미합니다. D3D 바이트코드 형식은 이러한 문제에 대한 매우 깔끔한 해결책입니다. 오직 하나의 컴파일러만이 존재하고(그래서 다른 벤더들 간의 호환되지 않는 방언 같은 것들이 없습니다!) 그것은 당신이 일반적으로 수행할 수 있는 것보다 더 비싼 데이터 흐름 분석을 가능하게 합니다.
이것은 단지 개요에 불과합니다. 제가 간과한 수많은 미묘한 점들이 존재합니다. 예를 들어, 스케줄러는 하나만 존재하는 것이 아니라 여러 구현이 존재합니다(드라이버가 선택할 수 있습니다). 제가 지금까지 전혀 설명하지 않은 CPU와 GPU 사이의 동기화가 어떻게 처리되는지에 대한 전체적인 이슈도 존재합니다. 그리고 저는 뭔가 중요한 것을 잊었을 수도 있습니다 - 만약 그렇다면 고칠 수 있도록 저에게 말해주세요. 오늘은 이것으로 마치며 다음 포스트에서 또 만날 수 있기를 바랍니다.