오늘은 GPU 의 가장 기본적인 역할인 렌더링과 관련된 내용을 살펴보고자 한다. 특히, OPENGL 을 사용하는 어플리케이션과 윈도우 매니저, 그리고 GPU 간의 동기화가 어떻게 이루어지는지에 대해 초점을 맞춰서 설명할 계획이다. 그럼 우선 기본적인 용어 몇 가지부터 살펴보자. (이후부터 모든 설명은 TESLA 아키텍처 이후의 NVIDIA 그래픽 카드를 기준으로 한다.)
첫 번째로 알아야 할 것은 채널(channel)이다. 일반적인 CPU 는 코드를 메모리에 올려놓고 PC(Program Counter) 레지스터를 이용하여 명령을 순차적으로 수행한다. 하지만 GPU 는 채널이라는걸 이용하는데, 이는 일종의 링(ring) 버퍼라고 생각하면 된다. 즉, 3D 어플리케이션은 채널의 링버퍼에 명령어를 주입하고, GPU 는 채널의 링버퍼에서 명령어를 읽어와서 실행하는 과정을 반복적으로 수행하는 것이다. 채널의 링버퍼에 들어가는 명령어에는 OPENGL 에서 제공하는 기본 명령어들(glVertex, glColor, …)과 렌더링에 관련된 다양한 명령어들이 존재한다.
두 번째로 알아야 할 것은 버퍼(buffer)이다. 기본적인 버퍼에는 폴리곤의 표면에 그려지는 그림을 저장하는 텍스처와 렌더링한 결과물을 저장하는 프레임버퍼가 있고, 이외에도 버텍스를 저장하는 버퍼, 인덱스를 저장하는 버퍼 등 다양한 버퍼들이 있다.
마지막으로 알아야 할 것은 펜스(fence)이다. 위의 두 가지는 들어본 적이 있을 가능성이 높지만, 펜스는 아마 처음 들어본 사람이 많을 것이다. 이는 간단히 얘기하면, 동기화를 위한 일종의 세마포어라고 보면 된다. 사실 따지고 보면, 스핀락(spinlock) 이라고 보는 것이 더 정확한 표현인것 같다. 즉, 하나의 변수를 미리 지정해놓고, 한 채널에서는 작업이 완료되면 해당 변수를 특정 값으로 설정하고, 다른 채널에서는 해당 변수의 값이 특정 값이 될때까지 반복적으로 확인하면서 기다리는 것이다. GPU 의 특성상, 코어가 많고 운영체제와 같은 관리 체계가 없는 것을 감안하면 나름 최선의 선택이라고 볼 수 있다.
그럼 간단한 용어 설명은 여기까지 하도록 하고, 기본적인 렌더링 과정부터 살펴보도록 하자.
위의 그림은 일반적으로 하드웨어 가속을 이용하는 소프트웨어에서 프레임버퍼 렌더링(swapbuffer)과 디스플레이 동기화(pageflip)를 수행하는 과정을 나타내고 있다. 우선 그림에서 보이는 몇 가지 용어들에 대해 추가로 설명하면, 세마포어(semaphore)는 펜스(fence)에서 사용하는 변수를 의미하고, 펜스에 EMIT 이 붙어있는 것은 세마포어에 특정 값을 설정하는 역할을 하고, 펜스에 SYNC 가 붙어있는 것은 세마포어가 특정 값인지를 확인하면서 기다리는 역할을 한다. 그리고 오브젝트는 텍스처와 프레임버퍼만 표시해두었는데, 모든 오브젝트는 특정 펜스와 연관되어 있다. 이유는, 예를 들어, 프레임버퍼로 사용되는 오브젝트는 렌더링이 완료되어야만 유효해지기 때문이다. (이외에도 오브젝트에 펜스를 연결시켜두는 이유는 여러 가지가 있다.) 이러한 이유로 렌더링이 완료되었음을 나타내는 펜스를 미리 만들어두고, 프레임버퍼로 사용되는 오브젝트가 해당 펜스를 참조하게 해놓은 것이다. 그리고 다른 채널에서 해당 오브젝트를 사용할 때는 오브젝트가 참조하는 펜스를 이용하여 동기화(SYNC)를 하면 아무 문제없이 사용할 수 있게 된다.
그럼 이제 그림을 보면서 동작 과정을 살펴보도록 하자. OPENGL 어플리케이션에서 렌더링을 하기 위해 두 개의 텍스처 오브젝트와 렌더링과 관련된 명령어들을 준비한 다음, MESA/DRM 을 통해 렌더링을 요청한다. 그러면 MESA/DRM 에서는 채널을 하나 할당받은 다음, 우선 렌더링에 사용할 두 개의 오브젝트가 참조하는 펜스를 SYNC 하는 명령어를 집어넣는다. 이는 해당 오브젝트들이 다른 목적에 의해 작업이 아직 완료되지 않았을 수도 있기 때문에 반드시 필요한 과정이다. 그리고나서 렌더링에 필요한 명령어들을 순서대로 집어넣고, 마지막으로 프레임버퍼 오브젝트에서 참조하게 될 펜스를 만든 다음, 이를 EMIT 하는 명령어를 집어넣는다. 여기서 한 가지 명심할 것은 채널에 명령어를 집어넣는 것은 CPU 에서 처리하는 것이고, 실제 채널에 있는 명령어를 수행하는 것은 CPU 와 상관없이 GPU 에서 비동기로 수행하게 된다는 점이다. 즉, 언젠가 GPU 가 해당 채널에 있는 명령어를 처리하게 되면 맨 앞에 있는 펜스를 SYNC 하는 부분부터, 렌더링과 관련된 명령어들, 그리고 마지막에 있는 펜스에 EMIT 하는 부분까지 순서대로 수행한다는 말이다.
그렇다면 디스플레이 동기화는 어떻게 처리할까? 이것 또한 렌더링과 유사한 과정을 거친다. OPENGL 어플리케이션에서 디스플레이 동기화(pageflip)를 요청하면 MESA/DRM 에서는 해당 요청을 처리할 채널을 하나 할당받은 다음, 화면에 출력할 프레임버퍼 오브젝트가 참조하는 펜스를 SYNC 하는 명령어를 집어넣는다. 이를 통해 해당 프레임버퍼를 위한 렌더링이 완료된 다음, 이후의 요청이 처리되어 안전하게 프레임버퍼를 화면에 표시할 수 있게 되는 것이다. 그래서 해당 펜스를 SYNC 하는 명령어를 넣은 다음에 실제 VBLANK 에 맞춰서 프레임버퍼를 교체하는 명령어들을 집어넣게 되면, CPU 의 할일은 끝나게 된다. 즉, 프레임버퍼 렌더링부터 디스플레이 동기화까지 CPU 입장에서는 필요한 명령어와 펜스만 잘 설정해주면 GPU 에 의해 정확한 순서대로 작업이 처리되는 것이다.
이제 마지막으로 윈도우 환경에서 클라이언트와 서버가 동시에 하드웨어 가속을 사용하는 과정을 간단히 살펴보자.
위의 그림을 보면, 클라이언트는 프레임버퍼 렌더링 요청을 한 후, 바로 서버에게 해당 버퍼를 전달한다. (wl_surface_commit) 그리고 서버는 클라이언트가 요청한 프레임버퍼 또한 일반적인 오브젝트이기 때문에 해당 오브젝트가 참조하는 펜스를 SYNC 한 후에 앞에서 계속 언급했던 것과 동일한 과정을 수행한다. 즉, 클라이언트가 요청한 프레임버퍼 오브젝트의 펜스는 클라이언트가 요청한 렌더링이 완료된 후에 EMIT 되기 때문에 아무 문제없이 렌더링이 완료된 프레임버퍼가 화면에 출력된다.
여기까지 CPU 와 GPU 가 어떤 방식으로 프레임버퍼를 렌더링하고 동기화하는지에 대해 살펴보았다. 지난 글에서 소개했던 메모리 및 주소 공간 관리처럼 GPU 는 CPU 와 비슷한 듯하면서도 상당히 다른 방식으로 동작한다. 가장 큰 이유는 GPU 는 CPU 에 의해 동작하는 수동적인 장치라는 것이다. 그리고 매번 CPU 가 동일한 요청을 반복해서 보내지 않는다는 것이다. 왜냐하면 기본적으로는 매번 다른 화면이 모니터에 표시되기 때문이다. 이러한 차이점들을 잘 이해하고 생각을 정리해본다면 GPU 가 동작하는 방식에 대해 이해하는 것이 좀 더 쉬울 것 같다.