SungJin Kang

SungJin Kang

hour30000@gmail.com

© 2024

Dark Mode

현대 GPU 아키텍쳐 이해하기 1 ( Graphics API에서 GPU로의 여정 )

이 글은 현대 GPU의 아키텍쳐와 동작원리를 대략적으로 설명하는 A trip through the Graphics Pipeline 2011 - 1, A trip through the Graphics Pipeline 2011 - 2의 번역본입니다. 발번역이니 이해해주세요. ( 대충 이해는 가능 )
또한 원문에 잘못된 정보가 약간이 있어서 댓글을 참고하여 잘못된 정보는 수정하였습니다.


어플리케이션 ( 프로그램 )
당신 ( 프로그래머 )이 작성하는 코드 영역이다. 버그가 있을 수 있다. 물론 API와 드라이버에도 버그가 있을 수 있으나 여기에 있는 버그는 당신이 작성한 것이니 얼른 가서 고쳐라.

런타임 API
당신은 리소스 생성이나, 상태 설정, 드로우 콜등을 API를 통해 호출한다. 런타임 API는 당신의 프로그램의 현재 상태 ( 렌더링 State를 말한다 )를 추적하고 변수가 올바른지 확인 ( Validation Check )하거나 에러나 일관성 체크, 유저가 볼 수 있는 리소스 관리등을 한다. ( 여기서 말하는 유저가 볼 수 있는 리소스란 텍스쳐들, 버퍼들, 렌더 타겟들이 있다. ) D3D ( DirectX 3D ) 같은 경우에는 이 런타임 API 단계에서 쉐이더 코드, 쉐이더 링크 ( Linkage )가 올바른지 검사하기도 한다 ( OpenGL의 경우 드라이버에서 이 작업을 한다 ). 그리고 작업들을 모아서 ( Batch ) 그래픽트 드라이버에 ( User - Mode 드라이버 )에 넘긴다.

유저 모드 그래픽스 드라이버 ( UMD )
CPU에서 생기는 마법같은 일들의 대부분은 여기서 발생한다. 만약 너의 프로그램이 너가 한 몇몇 API 호출 때문에 크래시이 발생하면 그 충돌들은 대부분 여기서 발생하는 것일 것이다. “nvd3dum.dll” (NVidia)나 “atiumd*.dll” (AMD) 같은 것들을 본 적이 있을 것이다. 그 이름들이 말해주듯이 이것들은 유저 모드 코드이다. 이 유저 모드 드라이버는 너의 프로그램 ( 그리고 런타임 API )와 같은 메모리 공간에 존재하고 ( 아마 가상 메모리 공간을 말하는 것 같다 ), 같은 문맥에서 동작한다. 그리고 어떠한 특수한 권한이 없다 ( 커널 모드 드라이버와 대비되는 점 ). 이 UMD는 D3D에 의해 호출되는 로우 레벨 API를 구현하는 것일 뿐이다. 이 로우 레벨 API라는 것은 너가 흔히 일반적으로 Graphics API를 사용할 때 보는 것들과 매우 흡사하다. 그러나 메모리 관리와 같은 측면에서 약간 더 명확할 뿐이다.

이 로우 레벨 API 단계에서 쉐이더 컴파일과 같은 것들도 한다. D3D는 미리 유효성 검증이 끝난 일련의 쉐이더 토큰들을 UMD로 보낸다. ( 여기서 말하는 유효성 검증이란 D3D에서 강제하는 여러 제약들을 잘 지키는지 확인하는 작업이다. ) 이 유효성 검증이 끝난 일련의 쉐이더 토큰들은 HLSL ( D3D 쉐이더 코드 )에서 컴파일되고 상당한 고레벨 최적화 ( 루프 최적화, 죽은 코드 제거 등등… )들을 가지게 된다. 컴파일 타임에 만들어지는 이러한 수준 높은 최적화들로부터 얻을 수 있는 여러 성능적 이익들을 드라이버가 누릴 수 있다는 것은 좋은 점이다. 드라이버 자체적으로도 많은 저레벨 최적화 ( 레지스터 할당 등… )를 하기도 한다. 이 컴파일된 쉐이더는 조금 더 컴파일 과정을 거쳐 중간 언어 ( IR )로 바뀐다. 쉐이더 하드웨어가 D3D 바이트 코드에 가깝기 때문에 좋은 결과를 내기위해 컴파일러가 작업을 할 필요가 없다. ( 그리고 이미 고 생산성, 고비용 최적화를 수행한 HLSL 컴파일러가 이미 도와주었기 때문에 ). 그러나 여전히 D3D가 모르고 신경쓰지도 않는 많은 저수준의 디테일한 요소들 ( 하드웨어 리소스 제한, 스케줄링 제약 등.. )이 여전히 존재한다. 그래서 이것이 사소한 과정인것만은 아니다. ( 이렇게 API단에서는 쉐이더를 중간언어로 컴파일하고 최종적으로 GPU가 읽을 수 있는 기계어로 컴파일하는 과정은 각 GPU의 드라이버에서한다. 이를 통해서 사용하는 그래픽 카드의 종류에 따라 각기 다른 최적화를 수행하여 여기서 오는 성능 향상을 얻을 수 있다. )

그리고 메모리 관리 같은 것들도 UMD에서 한다. UMD는 텍스쳐 생성 명령어 같은 것들을 받아서 그것들을 위한 메모리 공간을 제공한다. 실제로 UMD는 KMD ( 커널 모드 드라이버 )로부터 약간은 큰 메모리 블록을 할당받아서 그 블록들을 쪼개서 재할당해준다. 페이지를 매핑하고 언매핑하는 ( 그리고 UMD바 볼 수 있는 비디오 메모리 ( VRAM )의 일부분을 관리하고, 반대로 GPU가 접근할 시스템 메모리 ( DRAM )의 일부분을 관리하는 ) 것은 커널 모드의 특권이다. UMD는 할 수 없다.

이렇게 KMD로 매핑받은 메모리 공간을 통해서 텍스쳐나 버텍스 데이터를 전송하는데, CPU가 DRAM에 있는 텍스쳐, 버텍스 데이터를 전송하려면 우선 KMD에서 매핑 받은 메모리 공간으로 DRAM ( 기존의 텍스쳐, 버텍스 데이터가 있는 위치) -> DRAM ( KMD가 매핑해준 메모리 공간)으로의 데이터 복사를 한 후 API를 통해 GPU가 해당 DRAM의 텍스쳐, 버텍스 데이터가 복사된 위치 ( VRAM으로부터 매핑된 )에 접근하여 데이터를 복사해간다. ( 후술할거지만 GPU에서 DMA를 통해 DRAM에 매핑된 텍스쳐, 버텍스 데이터들을 복사하는 것이다. )

과거에는 리소스의 동기화를 위해 특정 리소스가 현재 누군가에 의해 사용되고 있는지를 추적하는 일을 드라이버 ( UMD )가 하였는데 D3D12/Mantle/Vulkan에서는 이 리소스 관리를 프로그램 ( 프로그래머가 )이 직접하는 형태로 바뀌었다. ( 프로그래머가 리소스 동기화를 신경써야한다. ) 이는 드라이버가 해야 할 일을 줄여주여주고 이를 통해 성능 향상을 가져온다.

그러나 UMD는 텍스쳐 Swizzling 같은 것들을 할 수 있고 ( 만약 GPU가 하드웨어에서 이것을 할 수 없는 경우 ) 시스템 메모리 ( DRAM )과 매핑된 비디오 메모리 ( VRAM )간의 데이터 전송의 스케줄링 작업을 하기도 한다. 가장 중요한 것은 UMD는 커맨드 버퍼들 ( DMA 버퍼들, 명령어 버퍼들, 앞으로 이 용어들을 혼용해서 사용할 것이다 )을 작성할 수 있다는 것이다. KMD가 이 커맨드 버퍼를 위한 메모리를 할당해준 후 UMD에 제공해주는 것이다. 커맨드 버퍼는 말 그대로 명령어들을 가지고 있다. 그래픽스 API를 사용하는 모든 프로세스 ( 프로그램 )은 각자의 커맨드 버퍼들을 가지고 있다. 그리고 각각의 프로세스 ( 프로그램 )들은 자기 자신의 UMD에 할당된 커맨드 버퍼에만 접근할 수 있다. ( 모든 UMD들은 각자의 커맨드 버퍼를 하나씩 가지고 있다. ) 너의 상태 변화, 드로잉 명령어 모두 UMD에 의해 하드웨어가 이해하는 명령어들로 변환될 것이다. 텍스쳐나 쉐이더를 VRAM에 업로드하는 것과 같은 너가 직접 수행하지 않는 많은 작업들을 수행하라는 커맨드들 또한 UMD에 의해 하드웨어가 이해하는 명령어들로 변환된다.

과거에는 커맨드를 하드웨어가 이해하는 명령어로 변환하는 일을 드라이버 ( UMD )가 하였는데 D3D12/Mantle/Vulkan에서는 프로그램이 직접 커맨드를 하드웨어가 이해하는 명령어로 변환하기 때문에 드라이버가 하는 일을 덜어주었다.

대개 드라이버들은 가능한한 많은 작업들을 UMD에서 하려고 노력할 것이다. 왜냐면 UMD는 유저 모드 코드이기 때문에 UMD에서 발생하는 작업들은 값 비싼 커널 모드로의 전환 비용이 발생하지 않는 장점이 있고, 마음껏 메모리를 할당할 수 있고 ( 매핑과 같은 귀찮은 작업은 KMD가 다 해주기 때문에 ), 여러 쓰레드들을 활용할 수도 있기 때문이다.UMD는 그냥 단지 일반적인 DLL이다. ( 단 이것이 너의 프로그램에 의해서 로드되는게 아니라 API에 의해서 자동으로 로드된다는 점만 다를 뿐이다. ) 이러한 것들은 드라이버 개발에 많은 장점을 가지고 있다. 만약 UMD에서 크래시가 발생하면 프로그램도 함께 크래시가 발생하지만 전체 시스템까지 크래시가 발생하는 것은 아니다. 또한 이 UMD는 UMD 시스템이 동작하는 동안해도 대체될 수 있는데 이것은 UMD가 단지 DLL이기 때문이다. 또한 일반적인 디버거를 가지고 디버깅할 수도 있다. 이것은 효율적일뿐만 아니라 편리하다.

그러나 내가 아직 언급하지 않은 불편한 사실이 한가지 있다.

내가 분명 유저 모드 드라이버라고 말했다. 나는 유저 모드 드라이버들(!)을 의미했다.
내가 말했듯이 UMD는 그냥 DLL이다. UMD는 D3D의 축복과 KMD로 가는 직접적인 통로를 가지고 있지만 여전이 호출하는 프로그램의 주소 공간 ( 가상 주소 )에서 동작한다는 점에서 일반적인 DLL과 같다.

그러나 오늘날 OS들은 멀티태스킹으로 동작한다.
그러한 점에서 이 GPU라는 것은 프로세스 ( 프로그램 )간 공유되는 자원이다. 오직 하나의 메인 디스플레이만이 작동하고 있지만 우리는 그 디스플레이에 접근하려는 다수의 앱을 작동하고 있다. ( 그 프로그램들은 오직 자신들만이 유일하게 메인 디스플레이에 작동하는 것처럼 동작한다 ) 이렇게 각각의 프로그램들이 자신만이 유일하게 메인 디스플레이에서 동작하고 있는 것처럼 작동할 수 있는 것은 자동으로 이루어지는 것이 아니다. 옛날에는 이것을 달성하기 위해 한 시점에 오직 하나의 프로그램만이 메인 디스플레이 접근할 수 있게 했었다. 다른 프로그램들은 접근을 못하면서 말이다. 그러나 만약 너가 너의 윈도우 시스템이 렌더링을 위해 GPU를 사용하려고하면 이 방법 ( 한 시점에 하나의 프로그램만 GPU에 접근하는 )으로는 할 수 없다. 이것이 너가 GPU로의 접근을 조절 ( 중재 )하고 접근 시간을 분배하는 컴포넌트들이 필요한 이유이다.

이제 스케줄러에 대해 알아보자.
이것은 하나의 시스템 컴포넌트이다 ( OS단 요소이다 ). 여기서 말하는 시스템 컴포넌트는 그래픽스 스케줄러를 말하는 것이지 CPU나 IO 스케줄러를 말하는 것이 아니다. 이것이 하는 일은 너가 생각하는 것과 정확하게 일지한다. 3D 파이프라인을 사용하는 시간을 쪼개어서 여러 프로그램들간에 3D 파이프라인으로의 접근을 조율 ( 조절, 중재 )한다. 컨텍스트 스위칭 ( 프로세스 컨텍스트 스위칭 )은 GPU의 몇몇 상태 변화를 유발하고 ( 이것은 커맨드 버퍼에 추가적인 커맨드를 발생시킨다 ), VRAM 안팎으로 여러 리소스들을 교환한다. 그리고 당연한것이지만 오직 하나의 프로그램만이 3D 파이프라인으로 커맨드들을 실제로 제출할 수 있다.

너는 콘솔 프로그래머들이 상당히 고수준의 ( 고수준이기 때문에 저수준의 최적화를 하지 못하는, 저수준으로 접근하지 못하는 ) PC 3D API가 유발하는 성능 저하에 대해 불만을 표출하는 것을 자주 봤을 것이다. 그러나 중요한 것은 PC 플랫폼의 3D API, 드라이버들은 콘솔 게임들 보다 더 복잡한 문제들을 가지고 있다는 것이다. 예를 들면 PC 플랫폼에서는 현재 모든 상태들을 추적할 수 있어야한다는 것이 그 예이다. 또한 PC 플랫폼은 엉망으로 작성된 프로그램들과도 잘 작동해야하고 그 엉망으로 작성된 프로그램들 뒤에서 여러 성능 문제들을 고쳐주어야한다. 이것은 드라이버 제조사를 포함하여 좋아하는이가 없는 짜증하는 일이다. 그러나 비지니스 측면에서는 확실이 이 길이 맞다. 사람들은 모든 것들이 잘 작동하기를 ( 매끄럽게 )를 원한다.

커널 모드 그래픽스 드라이버 ( KMD ) ( 보충자료 )
KMD는 실제로 하드웨어를 다루는 부분이다. 동시간대에 UMD 인스턴스는 여러개가 존재할 수 있다 ( 아마 API를 호출하는 프로그램마다 하나씩 ), 그러나 KMD는 오직 하나만 존재한다. 만약 KMD에서 크래시가 발생하면 거기서 끝이다. ( 과거에는 커널 모드 드라이버에서 크래시가 발생했을시 블루스크린이 떳지만 현재 윈도우에서는 커널 모드 드라비어를 죽이고 다시 로드한다. )

KMD는 오직 하나씩만 존재하는 것들을 다룬다. 여러 프로그램들이 GPU 메모리로 접근하려하지만 GPU 메모리는 오직 하나만 존재한다. 여러 프로세스들이 동시에 GPU에 접근하려하면 당연히 문제가 생길테니 누군가는 이 프로세스들의 GPU로의 접근들을 조율하고, 피지컬 메모리를 할당하여 각 프로세스들에게 매핑해주어야한다. ( GPU가 접근할 수 있는 DRAM 영역이 있는데 각 프로세스들이 아무렇게나 이 영역에 접근하는 것을 막기 위해서 각 프로세스들에게 특정 메모리 영역들을 매핑해주어서 다른 프로세스의 메모리 영역에 접근하는 것을 막아야한다. ) ( 당연히 여기서 누군가는 KMD를 말한다. ) 비슷하게 누군가는 시작시 GPU를 초기화해야하고 디스플레이 모드를 설정하고 하드웨어 마우스 커서를 관리하고, HW Watchdog Timer를 프로그램하여 GPU가 일정한 시간 동안 쉬고 있다면 리셋되게하여야하고, 인터럽트에 대응해야한다. 그것이 바로 KMD가 하는 것이다.

가장 중요한 것은 KMD가 실제 커맨드 버퍼를 관리한다는 것이다. 너가 알듯이 하드웨어가 실제로 접근하는 것이 이 커맨드 버퍼이다. UMD가 생성하는 커맨드 버퍼들은 진짜배기가 아니다. UMD가 생성하는 커맨드 버퍼들은 단지 GPU가 접근 가능한 메모리 영역을 여러 조각으로 나눈 것일 뿐이다. UMD가 자신의 커맨드 버퍼에 커맨드를 넣고 그 커맨드 버퍼를 스케줄러에 제출하면 해당 UMD의 프로세스 ( 프로그램 )가 CPU를 점유할 때 까지 기다린 후 비로소 KMD에 UMD 커맨드 버퍼를 제출한다. 그럼 KMD는 전달 받은 커맨드 버퍼에서 KMD의 메인 커맨드 버퍼로 커맨드들을 옮긴다. 그 후 GPU 커맨드 프로세서 ( Command Processor )가 메인 메모리 ( DRAM )으로부터 데이터를 직접 읽어 올 수 있는지에 따라 달려있는데 DRAM에 있는 KMD 메인 커맨드 버퍼의 데이터를 GPU의 VRAM으로 옮기기 위해서는 DMA라는 장치가 필요하다. ( 이 글1, 이 글2을 참조하세요 ) KMD의 메인 커맨드 버퍼는 대개 아주 작은 링버퍼의 형태이다이다. 메인 커맨드 버퍼는 여전히 DRAM에 있고 대신 GPU가 이 메인 커맨드 버퍼에 CPU의 개입없이 접근할 수 있다. 시스템/초기화 명령과 실제 3D 커맨드 버퍼들에 대한 호출만 작성됩니다.

그러나 이 링버퍼는 여전히 DRAM에 있는 버퍼에 불가하다. GPU는 이 링버퍼의 위치를 모른다. 그래서 일반적으로는 메인 커맨드 버퍼 ( DRAM )로부터 GPU가 커맨드를 읽을 위치인 read 포인터KMD가 커맨드 버퍼에 커맨드를 쓸 write 포인터가 있다. 이 read, write 포인터는 하드웨어 레지스터들로 이 레지스터들은 메모리 매핑이 되어있고 KMD는 이 레지스터들을 주기적으로 업데이트한다.

추가적으로 설명하면 이렇게 커맨드를 발생시킬 때 마다 커맨드 버퍼에 직접 쓰지 않고 유저 모드 버퍼에 임시로 저장을 해두고 커널 모드에서 주기적으록 가져가게 만드는 이유는 매번 유저 모드에서 커맨드가 발생할 떄 마다 커널 모드쪽 커맨드 버퍼에 쓰려고하면 그때마다 유저 모드에서 커널 모드로의 컨테스트 스위칭이 발생하게 되고 이 비용이 매수 비싸기 때문이다. 그래서 유저 모드에서 발생하는 커맨드는 임시로 유저 모드쪽 커맨드 버퍼에 두고 주기적으로 커널 모드에서 가져가는 형태를 가지게 되었다.
참고 - D3D10 리소스 관리

버스
물론 위에서 말한 KMD가 커맨드 버퍼에 쓴 데이터가 곧 바로 GPU로 전송되는 것이 아니다. ( CPU 다이에 GPU가 통합된 내장GPU가 아니라면 말이다. ) 이 커맨드 버퍼에 쓴 데이터는 버스를 거쳐야한다. 오늘 날에는 대개 PCI Express가 그 버스이다. DMA 전송도 이 PCI Express를 통해서 전달된다. 이 버스를 통한 데이터 전송은 그렇게 오랜 시간이 걸리는 것은 아니다. 여기서 끝이 아니다…..

커맨드 프로세서 ( Command Processor )
여기는 GPU의 앞 단에 존재한다. 실제로 KMD가 쓴 커맨드들을 읽는 파트이다. 이 부분은 다음 글에서 계속하겠다…..

OpenGL에 대해 조금 더 설명하면 OpenGL 또한 내가 설명한 것들과 매우 비슷하다. 단 API와 UMD 레이어 사이에 큰 차이가 존재하지 않는다는 점을 제외하면 말이다. D3D와 달리 GLSL ( OpenGL의 쉐이더 언어 )는 API에 의해 다루어지지 않는다. GLSL은 모두 드라이버에 의해서 다루어진다. 이로 인한 부작용으로는 여러 3D 하드웨어 제조사들마다 각기 다른 GLSL 프론트엔드들을 가지고 있다는 점이다. GPU 제조사마다 드라이버가 다르고 그래서 어떤 드라이버는 버그를 가지고 있고 어떤 드라이버는 그렇지 않기도 한다. 또한 이러한 OpenGL의 특징은 모든 쉐이더 최적화를 드라이버가 직접 수행해야한다는 뜻이기도 하다. D3D는 오직 하나의 컴파일러를 가지고 있기 때문에 D3D의 바이트코드 포맷은 이 문제를 완전히 해결한다.

이 글에서 다루는 API 호출부터 GPU까지의 과정을 다시 살펴보면 프로그램 ( 프로그래머 ) -> API ( OS / 드라이버 ) -> UMD ( 드라이버 ) -> Scheduler ( OS ) -> KMD ( 드라이버 ) -> GPU로 정리할 수 있다.
각각의 프로세스마다 존재하는 UMD의 커맨드 버퍼들로부터 KMD는 스케줄러에 따라 특정 UMD의 커맨드 버퍼를 KMD 내부의 메인 커맨드 버퍼로 가져오고 여기서 우리는 그래픽스 API를 통해 GPU에게 DMA 엔진을 사용하여 DRAM의 데이터를 가져가라고 명령할 수 있다. ( 중요한건 CPU가 직접 DRAM에서 GPU로 데이터를 옮기는 것이 아니라 CPU에서 GPU로 데이터를 가져가라고 명령하는 형태이다. 그럼 DMA 엔진을 이용하여 GPU는 CPU의 개입없이 DRAM의 데이터들을 가져갈 수 있다. )


이전 장에서 나는 PC에서 3D 렌더링 커맨드들이 GPU로 전달되기 전 거치는 여러 단계들을 설명했다. 나는 우리가 꼼꼼히 준비해온 ( 이전 장에서 배운 것들 ) 커맨드 버퍼를 커맨드 프로세서가 어떻게 다루는지에 대해 설명할 것이다. 기억해야할 것은 이러한 모든 커맨드 버퍼들은 메모리를 거친다 ( 여기서 메모리는 PCI Express를 통해 접근되는 DRAM일수도 있고 GPU가 CPU Die에 있는 경우 VRAM일 수도 있다. ). 우리는 커맨드 프로세서에 대해 얘기하기 전 과정들을 훓어볼 것이다. 잠깐 메모리에 대해 얘기해보자.

메모리 하부 시스템 ( Memory SubSystem )
GPU의 메모리 하부 시스템은 너가 일반적으로 생각하는 메모리 하부 시스템 ( DRAM )과 다르다. 통상의 CPU에서 보던 메모리 하부 시스템 ( DRAM )과는 차이가 있다는 것이다. 이렇게 차이가 있는 이유는 GPU 메모리 특유의 사용 패턴 때문이다. GPU의 메모리 하부시스템이 일반적인 머신에서 보는 것과 다른 핵심점이 두가지 요소가 있다.

첫번째 요소는 GPU 메모리 하부 시스템은 빠르다. 매우 빠르다. i7 2600K 코어는 초 당 19GB의 메모리 대역폭을 가지고 있다. 반면 지포스 GTX 480은 초 당 180GB에 가까운 메모리 대역폭을 가지고 있다. 엄청난 차이이다!!!!. 즉 GPU - VRAM 메모리 전송 대역폭은 CPU - DRAM 메모리 전송의 대역폭에 비해 매우 크다는 것이다. 이 말은 쉽게 말하면 GPU는 한 순간에 전송할 수 있는 데이터의 양이 매우 크다는 것이다. GPU와 VRAM간 데이터를 주고 받는 파이프 있다면 그 파이프의 넓이가 매우 넓은 것이다.

두번째로는 GPU 메모리 하부 시스템은 느리다. 매우 느리다. 1세대 i7 CPU의 캐시 미스로 인한 DRAM 접근은 140 사이클이 걸린다. 반면 위에서 언급한 지포스 GTX 480은 400-800 클록이 걸린다. 즉 사이클의 측면에서 지포스 GTX 480는 i7 CPU에 비해 평균적으로 4배의 메모리 레이턴시를 가지고 있다는 것이다. 즉 GPU의 VRAM 메모리 레이턴시는 CPU의 DRAM 메모리 레이턴시에 비해 매우 크다는 것이다. 쉽게 말하면 GPU와 VRAM간 데이터를 주고 받는 파이프 있다면 그 파이프의 길이가 매우 길다는 것이다. 데이터 전송 속도가 느리다는 의미이기도 하다.

위의 두 특징을 보면 대역폭과 메모리 레이턴시 간의 일장일단이 있는 것을 알 수 있다. GPU - VRAM의 데이터 전송 대역폭은 매우 좋은 ( 크다 ) 반면 메모리 레이턴시는 매우 나쁘다 ( 메모리 레이턴시가 높다 ).

그렇다 GPU는 대역폭 측면에서는 어마 어마한 성장을 해왔지만 그 대가로 레이턴시 또한 어마 어마하게 증가했다. GPU는 레이턴시 보다 대역폭이 중요하다. 그러니깐 이 말은 아직 도착하지 않은 결과를 기다리지 말고 다른 것을 하라는 것이다. ( 그러니깐 한번에 전송되는 데이터 양이 매우 큰 반면 그 데이터가 전송되고 읽어오기까지 많은 시간이 걸리니 데이터가 전송되기를 ( 읽어오기를 ) 기다리지말고 다른 일을 하고 있으라는 것이다. )

이것이 너가 GPU 메모리 ( VRAM )에 대해 알아야 할 전부이다. 단 DRAM은 논리적으로나 물리적으로나 2차원 그리드로 구성되었다는 DRAM에 대한 정보를 제외하고 말이다. 수평적으로 행들과 수직적으로 열들이 있다. 그러한 선들이 교차하는 지점 ( 행과 열이 교차하는 지점 )에 트랜시스터 ( Transistor )와 축전기 ( Capacitor )가 있다. 만약 이 트랜시스터와 축전기로부터 어떻게 메모리를 만드는지를 알고 싶다면 위키디피아를 찾아봐라. 어쨌든 여기서 가장 중요한 것은 DRAM에서 특정 위치의 주소는 행 주소와 열 주소로 나뉜다는 것이다. DRAM의 읽기/쓰기는 내부적으로 항상 지정된 행의 모든 열들에 동시에 접근한다. 이것이 의미하는 것은 여러 행에 걸쳐 분포되어 있는 메모리에 접근하는 것보다 하나의 DRAM 행에 매핑된 메모리 구역에 접근하는 것이 더 빠르다는 것이다. ( 그러니깐 동일한 데이터 양을 가져올 때 여러 행에 분포되어 있는 데이터에 접근하는 것보다 한 행만을 읽는 것이 더 빠르다는 것이다. )
그러니깐 쉽게 말하면 메모리 대역폭을 꽉꽉 채우고 싶다면 여러 행에 걸쳐 있는 데이터들을 찔끔찔끔 가져오는 것이 아니라 그냥 한번에 한 행 전체를 한꺼번에 가져오는 것이 메모리 대역폭을 더 잘 활용하는 ( 메모리 대역폭을 꽉꽉채워서 데이터를 전송하는 ) 것이라는 것이다. ( 이를 위해 데이터를 할당할 때 데이터 시작 주소를 레지스터 사이즈에 Align되게 데이터를 할당하곤 한다. )

PCIe 호스트 인터페이스 ( PCIe Host Interface )
그래픽스 프로그래머의 관점에서 하드웨어의 이 부품 ( PCIe Host Interface )은 딱히 흥미롭지는 않다. GPU 하드웨어 설계자들도 마찬가지일 것이다. 문제는 너무 느려서 병목 현상이 발생하면 PCIe 호스트 인터페이스가 신경이 쓰이기 시작한다는 것이다. 그래서 너가 하는 것은 병목이 발생하지 않게 좋은 사람들이 그 일을 하게 하는 것이다. 무엇보다도 이것은 이 PCIe 호스트 인터페이스는 CPU의 VRAM 읽기/쓰기, CPU의 GPU 레지스터 읽기/쓰기, GPU의 DRAM ( 일부분이지만 ) 읽기/쓰기 등 모든 머리 아픈 것들을 제공한다. 머리가 아프다고 한 이유는 이러한 데이터 전송이 시그널들이 칩 밖으로 스롯으로 전송되어야하고 CPU로 가기 위해 한참 동안 메인보드를 거쳐야하기 때문에 매우 느리다는 것이다. 심지어는 메모리 레이턴시 ( 위에서 말한 VRAM 메모리 레이턴시 ) 보다도 느리다. 대역폭은 적당하다. ( PCIe 3.0을 기준으로 16lane에 16GB/s이니 CPU - DRAM의 대역폭인 94GB/s과 비교하여 나쁘지 않은 수치이다. ) 역시 메모리 레이턴시가 문제이다…

솔직히 말해서, 우리는 이제 실제로 3D 명령을 보는 것에 매우 가깝습니다! 하지만 우리가 먼저 그 길에서 벗어나야 할 것이 하나 더 있습니다. 이제 VRAM와 매핑된 DRAM라는 두 가지 종류의 메모리가 있기 때문입니다. 하나는 북쪽으로 하루 정도의 여행이고, 다른 하나는 PCI Express 고속도로를 따라 남쪽으로 일주일 정도의 여행입니다. 우리는 어떤 길을 선택할 것인가.

가장 쉬운 해결책은 갈 길을 알려주는 추가적인 주소란을 하나 더 더하는 것이다. 이것은 간단하고 꽤 잘 작동하며 여러 번 이렇게 해왔다. 또는 일부 게임 콘솔과 같이 통합된 메모리 구조를 가지고 있을 수도 있다. 그 경우 선택의 여지가 없다. 오직 하나의 유일한 메모리가 존재하고 그것이 너가 갈 길이다. 만약 좀 더 Fancy한 것을 원한다면 메모리 관리 유닛 ( MMU )를 더해라. 이 MMU는 가상 메모리 주소 공간을 제공한다. ( 이는 흔히 말하는 페이징 기법과 거의 같다. 페이징 기법, 가상 메모리 주소 공간에 대해 알고 싶다면 이 글을 참조하라. 참고로 이 글은 CPU - DRAM - Disk의 관점에서 쓴 글이다. 그치만 동작하는 원리는 GPU랑 비슷하다. ) 예를 들어보면 어떤 큰 텍스쳐의 자주 접근하는 일부분 ( 텍스쳐의 일부 데이터 )은 VRAM에 두고 ( 자주 사용하는 것은 접근이 빠른 VRAM에 두고 ), 덜 자주 쓰는 부분은 DRAM에 두고 ( DRAM으로의 접근은 VRAM으로의 접근보다 느리다 ), 그리고 그 외의 것들은 그냥 디스크에 두는 것이다 ( 가상 메모리, GPU가 디스크에 있는 데이터를 읽는데는 진짜 농담이 아니라 한달이 걸린다고 할만큼 매우 느리다. ). 디스크는 엿같이 느려터졌다!!

또한 우리의 값 비싼 3d 하드웨어 ( CPU 코어 ) / 쉐이더 코어 ( GPU 코어 )들의 개입 없이 메모리를 복사할 수 있게해주는 DMA 엔진이 있다. 이 DMA 엔진을 통해 DRAM과 VRAM 양방향으로 데이터를 복사 ( 전송 )할 수 있다. 또한 DMA 엔진은 VRAM에서 VRAM으로 데이터를 복사 ( 전송 )할 수 있다. ( VRAM 파편화 제거를 하는데 사용된다. ) 반면 DRAM에서 DRAM으로의 데이터 복사는 할 수 없다. 왜냐고? 이 DMA Engine은 GPU의 요소인데 어떻게 GPU 밖에 있는 DRAM간의 데이터 전송이 가능하겠냐? DRAM간의 데이터 복사는 CPU 코어 ( CPU 레지스터를 이용하여 )로 하면 된다.

자, 잠깐 복습해보면 CPU에 커맨드 버퍼가 있다. 그리고 PCIe 호스트 인터페이스가 있으므로 CPU가 이 커맨드의 주소를 일부 레지스터에 쓸 수 있습니다. 우리는 그 레지스터에 쓰인 주소 ( 커맨드가 저장되어 있는 주소 )를 데이터를 반환하는 로드 명렁어로 변환해주는 로직이 있다. 커맨드가 PCIe를 거쳐서 DRAM에서 오든 ( 커맨드 버퍼가 DRAM에 있는 경우 ), 그냥 커맨드 버퍼를 VRAM에 두든, KMD가 DMA 전송을 통해서 그것들을 구현하면 되기 때문에 CPU나 GPU 코어가 신경쓸 것은 없다. 그리고 우리는 메모리 하부 시스템을 통해서 VRAM에 있는 데이터를 얻을 수 있다. 커맨드가 DRAM 경로가 마련되었고 우리는 커맨드들을 볼 준비가 되었다.

드디어 커맨드 프로세서 ( Command Processor )이다. ( 커맨드 프로세서는 GPU에 내장되어 있다. ) ( 보충자료 )
커맨드 프로세서의 핵심은 버퍼링 ( Buffering )이다.

위에서 언급한 DRAM - VRAM간의 메모리 전송은 높은 대역폭을 가졌지만 레이턴시 또한 높다.레이턴시 문제를 해결하기 위한 방법은 많은 수의 독립된 쓰레드들을 가지는 것이다. 그러나 이 경우 커맨드 버퍼를 순서대로 수행할 하나의 커맨드 프로세서를 가졌다. ( 이는 커맨드 버퍼가 반드시 정해진 순서대로 실행되어야 하는 상태 변화나 렌더링과 같은 명령어들을 가지고 있기 때문이다. ) 그래서 우리는 다음으로 중요한 것을 하는데 바로 충분히 큰 버퍼를 추가하고 커맨드 프로세서가 다음 커맨드를 기다리는 동안 잠시 멈추는 것을 방지하기 위해 충분한 수의 명령어 미리 가져오는 ( Prefetch ) 것이다.

커맨드 프로세서는 GPU가 접근할 수 있는 DRAM에서 가져온 커맨드 버퍼의 커맨드들을 순서대로 읽고 디코딩하여 실행한다.

그리고 그 버퍼에서, 커맨드는 실제 커맨드 처리 Front End로 전달되는데 이 커맨드 처리 Front End는 기본적으로 ( 하드웨어에 특화된 포맷을 가지고 ) 커맨드들을 파싱하는 상태 머신이다. 몇몇 커맨드들은 2D 렌더링 동작을 다룬다 ( 2D 렌더링을 위한 별개의 커맨드 프로세서가 없고 심지어 3D FrontEnd가 그것을 볼 수 없다면 ). 여전히 현대 GPU에도 2D만 전담으로 하는 하드웨어가 있다. Text Mode를 도와주거나 4-bit/픽셀 비트-Plane 모드를 도와주거나 부드로운 스크롤링을 하는 등의 특화된 VGA 칩이 있는데 현미경으로 GPU를 보지 않으면 볼 수 없는 것들이다. 그리고 몇몇 도형들을 3D/쉐이더 파이프로 전달하는 명령어들도 있다. ( 흔히 생각하는 그래픽스 파이프라인에 필요한 것들이다 ). 그리고 어떤 이유로 그리지는 않지만 3D/쉐이더 파이프로 전달되는 커맨드들도 있다.

그리고 상태를 변화시키는 커맨드들도 있다. 프로그래머로서 그것들을 단지 변수의 값을 바꾸는 것이라고 생각해라, 실제로도 그렇다. 그러나 GPU는 매우 방대하게 병렬적인 ( Parallel ) 컴퓨터이다. 그리고 단순히 전역 변수의 값을 바꾸고 모든 것이 잘 돌아가기를 바라는 것은 병렬적으로 동작하는 GPU에는 통하지 않는다. 병렬적으로 작동하는 GPU에서는 여러 쓰레드들이 해당 상태를 참조하여 작동 중이기 때문에 단순하게 상태를 바꾸면 모든 것이 잘 작동한다고 생각하는 것은 큰 오산이다. ( 쓰레드들간의 여러 동기화 작업 등 해야할 것들이 많다. ) 그래서 GPU는 기본적으로 상태의 종류에 맞게 다양한 방법으로 상태 값을 바꾼다.

상태를 바꾸는 방법 중 과거에 가장 흔하게 사용되었던 것은 상태를 바꾸기 전 현재 상태를 참조하는 대기 작업들이 모두 끝나기를 기다린 후 상태를 바꾸는 것이다. 역사적으로 이것이 그래픽 칩들이 대부분의 상태 변화를 다루는 방법이다. 간단하고, 배칭된 작업들의 수가 적다면 ( 삼각형이 작고, 파이프라인이 짦은 경우 ) 시간도 많이 걸리지 않는다. 그러나 삼각형의 개수가 많아지고 파이프라인이 길어질 수록 이 방법의 비용, 소모 시간은 엄청나게 커지게 된다. 이 방법은 여전히 사용이 되기는 하지만 상태 변화가 드물거나, 다른 방법으로 상태를 바꾸기에는 너무 비용이 큰 경우에만 사용됩니다.

다른 방법으로는 하드웨어 유닛에 상태를 저장하지 않는 것이다. 그냥 상태 변화 커맨드를 그것과 연관된 Stage에 그때 그때 전달하는 것이다. 매 사이클 마다 모든 다운스트림에 현재 상태를 추가하는 단계를 가지는 것이다. 상태 값이 어디에도 저장되지는 않지만 상태 값을 항상 주위에 있기 때문에 만약 어떤 파이프라인 Stage가 상태 값의 데이터를 보고자 한다면 그 상태값이 전달되었기 때문에 바로 볼 수 있다. 만약 그 상태 값 데이터의 크기가 몇 비트 안된다면 이 방법은 상당히 저렴하고 실용적이지만 상태 값 데이터의 크기가 조금만 커져도 그 효용은 떨어지게 된다.

상태의 복사본을 하나씩만 저장하고 상태가 변할 때마다 매번 초기화해주어야 한다면 동기화 해주어야할 것이 너무 많다. 그러나 만약 상태 값을 두개씩 가진다면 ( 4개도 가능할 듯? ) 상태 설정 FrontEnd는 조금 더 좋아질 수 있다. 만약 모든 각각의 상태들의 두가지 버전을 저장할만큼 충분한 개수의 레지스터 ( 슬롯 )를 가졌고 현재 활성화된 작업의 경우 그 두가지 버전 중 첫번째 버전을 참조한다고 가정해보자. 현재 동작 중인 작업이 참조 중인 첫번째 슬롯은 건들지말고 ( 현재 동작 중인 작업을 방해하지 않음 ), 두번째 슬롯의 상태 값을 바꾸어서 다음 작업이 수행할 상태를 미리 설정할 수 있다. 이 방법은 파이프라인 동안 사용될 상태를 담아 보낼 필요없이 ( 바로 위에서 본 상태 데이터 전체를 그때 그때 전달하는 방법 ) 각각의 상태가 몇번째 슬롯에 저장되어 있는지만 전달하면 된다. ( 상대적으로 데이터 크기가 작다 ). 물론 만약 상태 변경 커맨드를 수행할 때 두 슬롯 모두 다른 작업들에 의해 바쁘게 참조되고 있다면, 여전히 기다려야하는 것은 마찬가지이다. ( 물론 이 경우에도 기존 방법보다는 한 스텝 먼저 상태를 변경할 수 있다 ). 이 방법은 슬롯의 개수에 상관없이 사용할 수 있다.

현대 GPU는 이 커맨드 프로세서를 여러개 가지고 있다, 병렬적으로 작업을 처리해줄 수는 있지만 그 만큼 커맨드 프로세스들 간의 동기화 작업도 추가적으로 요구된다.

동기화
마침내 CPU/GPU, GPU/GPU 동기화를 다르는 커맨드들의 마지막 요소이다.
이 동기화 부분이 생각보다 렌더링을 느리게하는 주요 요인 중 하나이다.

대개 이러한 것들은 모두 “이벤트 X가 발생하면, Y를 하라”와 같은 형식을 띄고 있다. 일단는 “Y를 하라” 부분을 먼저 다루어 볼 것이다. 여기서 Y라는 것이 무엇인지는 두 가지 경우의 수가 있다. 하나는 GPU가 이제 곧 무언가를 할 것이라고 CPU에게 알려주는 푸쉬-모델 알림 ( Push-Model Notification ) ( CPU야 나 이제 디스플레이 0에 수직 귀선 기간에 진입하니깐 만약 너가 tearing ( 백버퍼와 프론트버퍼를 교환하는데 반만 교환된 것 ) 없이 버퍼 Swap을 하고 싶다면 지금이 그 때야!!) 일 수 있다. 다른 하나는 발생한 일을 GPU가 기억해두었다가 CPU가 그 일에 대해 묻는 풀-모델 알림 ( Pull-Model Notification )이 있다. ( GPU야, 너가 처리하기 시작한 가장 최근 커맨드 버퍼는 뭐니?, 잠깐… Sequence ID 303번인 커맨드 버퍼야! ) 전자는 일반적으로 인터럽트를 사용하여 구현되고, 자주 발생하지 않지만 높은 우선 순위를 가진 이벤트들을 위해서만 사용된다 ( 인터럽트가 매우 비싼 동작이기 때문이다 ). 반면 후자의 경우에는 CPU가 접근할 수 있는 GPU 레지스터(CPU가 DMA를 통해 GPU에 접근)들과 특정 이벤트가 발생했을 때 커맨드 버퍼에서 그들로 값을 복사할 방법만 있으면 된다.

후자의 경우를 구현한다 해보면, 16개의 그러한 레지스터를 가졌다고 생각해보자. 그럼 currentCommandBufferSeqId를 0번째 레지스터에 저장할 수 있다. 그리고 GPU에 전달할 모든 커맨드 버퍼에 Sequence 넘버를 지정하고, 각각의 커맨드 버퍼의 시작에 “만약 해당 커맨드 버퍼에서 이 지점에 다달았을 때 이 커맨드 버퍼의 Sequence 넘버를 0번째 레지스터 에 쓰라”고 정해라. 이를 통해 우리는 현재 GPU가 접근 중인 커맨드 버퍼가 무엇인지 알 수 있다. 그리고 우리는 커맨드 프로세서가 딱딱 순서에 맞추어서 커맨드들을 처리하고 만약 커맨드 버퍼 303에 들어 있는 첫번째 커맨드가 실행이 되면 그것은 Sequence 넘버가 302인 커맨드 버퍼까지의 커맨드 버퍼들은 이미 처리가 완료되었으니 KMD가 다시 해당 커맨드 버퍼들을의 소유권을 가지거나, 할당을 해제하거나 수정할 수 있다는 것을 의미한다.

이것이 바로 “이벤트 X가 발생하면”의 X의 예이다. 다른 예로는 “만약 모든 쉐이더들이 현재 버퍼의 현재 지점 이전에 있었던 배치들로부터 발생헀던 오는 모든 텍스쳐 읽기 작업을 끝내면” ( 이는 텍스쳐와 렌더 타겟 메모리의 소유권을 다시 가져가는 것이 안전하다는 것을 뜻한다 ), “만약 모든 활성화된 렌더 타겟들/UAV들로 렌더링을 하는 작업이 끝나면” ( 이것은 그 렌더 타겟들과 UAV들을 안전하게 텍스쳐로 사용해도 된다는 것을 뜻한다 ), “만약 현재 지점까지의 모든 동작이 완전히 끝나면” 등등이 있다.

위에서 본 동작들은 대걔 “fences”라고 불린다. 상태 레지스터들에 쓸 값을 고르는 방법은 다양하지만, 그것을 하는 유일한 온전한 방법은 ( 제 정신인 방법 ) 이것을 하기 위해 순차적 카운터 ( Sequential Counter )를 사용하는 것이다. 당신이 무조건 알아야한다고 생각하기 때문에 아무 근거도없이 무작위 정보의 한 조각을 여기에 놓습니다. 자세한건 나중에 다룰 것이다.

이 fence가 필요한 이유를 예를 들어 조금 더 쉽게 설명해보자면 너가 DRAM에 있는 버택스 데이터를 VRAM으로 옮기고 싶다면 우선 그 버택스 데이터를 DRAM 내부에 VRAM이 접근 가능한 ( DMA를 통해 ) 영역으로 복사를 해야한다. ( 이 경우에는 똑같은 버택스 데이터가 DRAM내에 중복해서 두개가 존재한다. ) 그리고 그래픽스 API를 통해서 GPU에 커맨드를 보내서 이 DRAM의 버텍스 데이터를 가져가라고 명령한다. 현재 상태에서는 이 GPU가 접근 가능한 영역의 복사된 버텍스 데이터를 다른 데이터로 덮어 씌우면 안된다. ( 아직 GPU가 데이터를 가져가지 않았기 때문이다. ) 그래서 아직까지는 해당 데이터 영역을 보호해주어야한다. 그 후 GPU가 DRAM에서 데이터를 가져가는 것을 완료하면 이제는 해당 DRAM의 영역 ( GPU가 접근 가능한 )을 다른 데이터로 덮어씌워도된다. 이 때 fences를 통해 다른 데이터로 덮어씌워도 된다는 것을 알려주는 것이다.

이제 절반 정도 왔다. 이제 우리는 상태를 GPU에서 다시 CPU에게 알려줄 수 있는데, 이는 드라이버에서 온전한 메모리 관리를 하게 해준다 ( 이제 우리는 버텍스 버퍼들, 커맨드 버퍼들, 텍스쳐, 다른 리소스들을 위해 사용되고 있는 메모리 영역의 소유권을 다시 가져가는 것이 현재 안전한지 안한지를 알 수 있게 되었다. ) 그러나 그것이 다가 아니다. 아직 남은 문제가 잇다. 만약 순수하게 GPU쪽 내에서의 동기화가 필요하다면 어떻게 해야하나? 예를 들면 다시 렌더 타켓 예로 돌아가보자. 렌더 타겟에의 렌더링 작업이 끝나기 전까지는 우리는 그 렌더 타겟을 텍스쳐로 사용할 수 없다. 해결 방법은 “wait” 스타일의 명령어를 사용하는 것이다. “레지스터 M이 값 N을 가질 때 까지 기다려!” 이것은 Batch를 제출하기 전 렌더 타겟 동기화를 하게 해준다. 그것은 또한 우리가 완전한 GPU flush 동작을 수행할 수 있게 해준다. “모든 대기 중인 작업이 끝나면, 0번째 레지스터의 값을 ++seqID로 설정하라”, 그리고 “0번째 레지스터가 seqID를 가질 때 까지 기다려라”. 두 동작이 끝나면 GPU/GPU 동기화가 이루어진다.

그런데 만약 너가 CPU쪽에서 이 레지스터들에 쓰기 동작을 한다면, 다른 방법으로 이것을 구현할 수도 있다. 특정 값을 기다리는 것을 포함하는 부분적인 커맨드 버퍼를 제출하라, 그리고 GPU 대신 CPU쪽에서 레지스터의 값을 바꾸어라. 이것은 D3D11-스타일의 멀티스레드 렌더링을 구현하기 위해서 사용될 수 있는 방법이다. 이 방법에서는 CPU쪽에서 여전히 lock되어 있는 버텍스/인덱스 버퍼들을 참조하는 배치를 제출할 수 있다. 실제 렌더 호출 앞에서 기다릴 것이고, CPU는 일단 버텍스/인덱스 버퍼들이 실제로 unlock되면 그 레지스터의 값을 바꿀 수 있다. wait 자체는 아무것도 하지 않는다. 만약 무언가를 한다면 그냥 커맨드 프로세서에 데이터가 도달할 때 까지 아무것도 하지 않고 시간을 낭비하고 있는 것이다.

이 장에서 배운 것을 좀 간략히 요약해보자. 커맨드프로세서는 앞선에는 명령어 대기 FIFO 버퍼를 가지고, 커맨드 디코드 로직을 가지고 있으며, 명령어 수행은 2D, 3D 유닛과 소통하거나, 쉐이더 유닛들과 직접적으로 소통하는 다양한 블록들에 의해서 다루어다. 동기화/wait 커맨드들을 다루는 블록도 있다. 그리고 커맨드 버퍼의 점프/호출을 다루는 유닛도 있다. 우리가 작업을 전송하여 그 작업을 전송받은 모든 유닛들은 해당 작업들이 완료되면 작업 완료 이벤트를 다시 전송해준다. 이를 통해 우리는 “텍스쳐가 더 이상 사용되지 않고 그 메모리 공간의 소유권을 다시 가져도 된다는 것” 등을 알 수 있다.

references : A trip through the Graphics Pipeline 2011, Life of A Triangle - NVIDIA’s Logical Pipeline