본문 바로가기

DirectShow

최적화 전략(1) - GPU에 최적화된 프레임 구성

최적화 전략(1) - GPU에 최적화된 프레임 구성

1. 도입

게임을 만드는 데 있어서 높은 프레임 레이트는 모든 프로그래머들의 공통적인 관심사가 아닐까 싶다. 이러한 관심사에 대한 아이디어와 이미 많이 적용되고 있는 것들을 정리하고자 한다.
(사실 제 관심사는 이런 거보단 '어떻게 해야 빨리 개발할 수 있나'입니다만, 항상 실패만 해서... T_T;;)

2. 하드웨어 가속

하드웨어의 발전과 함께 최적화에 대한 접근도 시시각각 변하고 있는 데, 최근 가장 큰 화제는 3차원 가속기를 얼마나 활용하느냐 하는 문제이다.

비디오카드가 최적화된 아키텍처로 렌더링의 많은 부분을 처리해 주면서 얻은 이점 중에 두 가지에 주목할 필요가 있다.

첫 번째로 기존의 CPU에서 해주어야 했던 일거리를 더욱 빠르고 효율적으로 처리해준다는 점이다. 즉 프로젝션, 클리핑등의 일을 CPU보다 GPU에서 더욱 빠르게 수행한다는 얘기다. (GPU가 CPU보다 코어 스피드는 느릴 수도 있지만, 최적화된 메모리 억세스, 다수의 계산 유닛을 통한 병렬적인 계산등을 통해 더 많은, 즉 더 빠른 계산을 하도록 설계되기 때문이다.) 얼마나 많은 일을 CPU가 아닌 GPU가 하느냐가 최적화의 포인트라고 할 수 있다.

두 번째로 병렬성에 있다. 즉, 비디오카드의 GPU와 메인CPU와는 완전히 별개로 수행된다는 점이다. 즉, 비디오카드가 열심히 렌더링하는 시점에도 CPU는 다른 일을 할 수 있다는 점이다. 즉, 비디오카드가 렌더링 할 동안 CPU가 더욱 많은 일을 하는 것이 최적화의 포인트겠고, 비디오카드가 한순간도 쉬지 않고 렌더링 작업을 하는 것이 가장 이상적이라고 할 수 있다. (여담으로 XBOX 개발킷 중에 카르마란 프로파일링 도구를 본 적이 있는 데, 그 툴의 기능은 원하는 타이밍의 프레임에 비디오카드가 idle 이 되는 구간을 - 즉 CPU가 일을 못 주고 혼자 뭔가 연산하는 타이밍 구간- 측정해서 리포트해주는 것이다. 이러한 관점에서 프로그래밍 허점을 찾도록 도와주는 도구인데 하드웨어 성능을 살리는 데 상당히 도움이 될 꺼란 생각이 든다.)

어떻게 해야 더욱 빠르게 렌더링 되느냐 같은 문제는 다음 글에서 다루기로 하고, 더 이상 렌더링 속도를 개선할 수 없는 상황이라고 가정하면, 위의 두 가지가 그것을 극복할 수 있는 방법이 될 것이다.

첫 번째의 경우에는 사실상 최적화할 수 있는 부분이 정해져 있기 때문에 [1], 하드웨어의 T&L과정을 완전히 GPU에 넘긴 후라면, 연산을 GPU로 넘긴다는 관점에서는 더 이상 해줄 수 있는 것이 없다.

하지만, 두 번째의 경우에는 특별한 무언가만 해준다면 쉽게 큰 효과를 얻을 수도 있는 데, 차근 차근 생각해보도록 하자.

3. 일반적인 CPU 와 GPU 의 작업 타이밍

메인 루핑은 렌더링과 전혀 상관없는 _process() 와 렌더링만 하는 _display() 함수로 구분해보자. (만약 인공지능 루틴 등에 렌더링 루틴이 삽입되어 있다면 원하는 대로 렌더링 배치를 하기가 힘드므로, 여기서 얘기하는 것을 적용하기는 쉽지 않다.)

만약 이와 같은 구성이라면 일반적인 프로세스의 처리 그래프는 [그림3.1]과 같을 것이다.


(그림 3.1)

간단히 그림을 살펴보면 먼저 process 가 수행되는 동안에는 GPU가 놀고 있다. 그러다. DISPLAY 가 시작되면 본격적으로 GPU 가 활동하게 된다. DISPLAY 루틴 상 부하가 있다면 위처럼 비디오 카드가 놀고 있는 타이밍이 있을 수 있다. 그리고 DISPLAY 루틴이 끝나는 지점 -일반적으로 flip 을 하거나 present 를 하는 순간- 부터 CPU는 비디오카드의 내부 큐(리스트)에 있는 작업들이 끝날 때까지 대기를 한다. (파란색 선 사이가 한 프레임을 나타낸다.)

[그림3.1]의 모양은 나타내는 프로그램은 비효율적으로 프로그래밍을 한 것이다. 하나씩 체크해보면서 이 구성을 최적화해서 비디오카드가 전혀 쉬지 않고 작동할 수 있도록 구성해본다.

4. 렌더링 루틴과 타이밍의 관계

먼저, GPU의 타이밍에 대해서 확인하고 넘어가자.


(그림 4.1)

[그림4.1]과 같은 경우를 생각해본다. CPU에서 매트릭스 트랜스폼하는 시간을 1 이라고 표시했다. A라는 오브젝트를 DrawPrimitive 하게 되면 거의 딜레이 없이 CPU에게 프로세스가 넘어오며, 그와  동시에 CPU와는 별개로 GPU는 렌더링을 한다. 여기서 CPU는 다시 매트릭스 트랜스폼 등의 과정을 거친후에 B 오프젝트를 렌더링하게 되며 GPU는 B를 렌더링 하게 된다.
여기서 관심있게 볼 것은 A보다 B의 오브젝트가 폴리곤이나 렌더링 부하가 더 많다고 가정했지만 DrawPrimitive 호출후 CPU에 프로세스 권한이 넘어오는 타이밍은 A나 B나 비슷하다는 것이다. GPU에게 명령을 주는 것은 폴리곤 수에 따라 하는 일에 큰 차이가 없다는 얘기다.

여기서 눈에 띄는 부분은 GPU가 A의 렌더링을 끝내고 대기하는 타이밍이다.
만약 B를 먼저 렌더링 한다면 어떻게 될까 ?


(그림4.2)

[그림4.2]처럼 된다. 즉 A의 작업은 비디오카드의 큐에 추가되고 비디오카드는 B의 렌더링이 끝나는 시점에 A를 렌더링하게 된다.

단순히 렌더링 타이밍에 맞춰서 배치만 조절해도 GPU가 대기하는 시간을 제거할 수 있다는 얘기다. 물론 위의 예처럼 GPU 타임을 예측해서 배치하는 것은 불가능하지만, [그림 4.3]처럼 단지 GPU가 부담을 느끼는 일을 먼저 던져준다면 별다른 처리 없이도 어느 정도의 효과를 얻을 수 있다.


(그림 4.3)

특히 CPU의 부담이 생길 수밖에 없는 배경 렌더링의 경우에는 배경 렌더링하기 전에 적절히 GPU에게 (좀 부담스러운 녀석으로) 할 일을 준다면 효과를 볼 수 있을 것이다. 항상 위의 그래프처럼 향상될 수도 있다는 얘기는 아니지만, 그렇지 않더라도 완충 효과를 줄 수 있어 대기 상태가 나타날 확률을 훨씬 줄일 수 있다. (배경보다 먼저 렌더링해도 전체적인 필레이트에 변화가 적다면 절대 손해 안보는 장사가 될 것이다. - 저라면 작게 렌더링 되면서, 폴리곤이 많은 녀석을 제물로 삼겠습니다.)

다만, 고려해야 할 것은 비디오의 큐 메모리도 한계가 있다는 것이다.
렌더링 타임이 긴 것들을 연속으로 넘기면 뒤의 처리들이 계속 큐게 쌓여서, 큐의 한계까지 차버리게 되는 데, 이 경우 큐에 넣기 위해 대기하는 시간이 생길 수 있다. (큐를 빨리 비우는 방법은 렌더링 타임이 작은 오브젝트를 찍는 것이므로 대기 시간이 안 생기고 큐에 무리를 주지 않는 범위에서 매니징을 해야 한다.)

한가지 주의할 것은 여기서 얘기하는 것은 순수하게 하드웨어의 힘으로 렌더링을 하는 경우여야 효과를 발휘할 수 있다는 점이다. 아닐 경우에는 GPU가 렌더링하는 시간이나 CPU가 처리하는 시간이나 비슷해져서 어떻게 해도 큰 차이가 없게 된다.

5. 최종 렌더링 타이밍

렌더링시 부하가 될만한 부분을 분리하여 구현하고 배치가 적절히 되었다면 [그림 5.1]처럼 나타낼 수 있다.


(그림 5.1)

한가지 짚고 넘어가면 보통 DISPLAY의 마지막에 호출되는 present나 flip 관련함수는 GPU 가 끝나기 전에 프로세스 권한을 넘겨주지 않는다. 즉 해당 함수에서는 큐가 빌 때까지 딜레이가 생기는 것이다. 실제로 DrawPrimitive 는 대부분 내부 큐에 렌더링할 꺼리를 쌓는 역할을 할 뿐이고 최종적으로 렌더링이 끝나는 것은 present 를 수행한 뒤가 된다.
(흔히 비 경험자들은 "프로파일링을 했더니 present함수가 가장 느리다." 라고 오해하기도 하는 데, 이는 직접적으로 present 함수의 부하가 아니므로 present 함수가 느리다고 생각하는 것은 적절한 판단은 아니다.)

인공지능, 물리학 등의 처리가 늘어날수록 process의 비중이 커져서 위의 구성으로는 GPU가 대기하는 시간이 비례해서 늘어나게 된다. 물론 위와 같이 배치하지 않고 렌더링 루틴 사이에 인공지능루틴들을 잘 넣으면, GPU가 대기하는 타이밍을 줄일 수도 있겠지만, 일정하게 효율을 얻기는 힘들기 때문에 추천하기는 어렵다.

결론부터 말하면 present 호출 후에 렌더링이 끝날 때까지 대기하는 시간에 CPU가 놀지 않고 다음 프레임의 process 를 실행하는 방법으로 이 문제를 해결할 수 있다.

#include <stdio.h>
#include <windows.h>

void _process()
{
   static int cnt=0;
   printf("process(%d)n", cnt++);
  Sleep(500); // process 부하
}

void _flip()
{
   Sleep(500); // flip 후 대기
}

void _display()
{
   static int cnt=0;
  static int time = timeGetTime();

   int delta = timeGetTime() - time;
   time += delta;

   printf("tdisplay (%d) %dn", cnt++, delta);
   Sleep(500); // 렌더링 명령

   _flip();
}

void main()
{
   int i;
   for(i=0; i<10; i++) {
       _process();
      _display();
   }
}

이해를 돕기 위해 가상적으로 위와 같은 파일을 구성해 봤다. 일반적인 구성을 만들어 본 것으로 process 가 500 ms, display 가 1000ms 의 부하를 가진다고 가정했다. (display는 500ms는 렌더링 명령하는 데 소비하는 시간, 500ms는 대기하는 시간이라고 가정했다.) 이를 실행해보면 다음과 같은 결과가 나온다.

process(0)
       display (0) 0
process(1)
       display (1) 1501
process(2)
       display (2) 1501
process(3)
       display (3) 1500
process(4)
       display (4) 1501

즉, _process와 _display에서 각 500ms, 1000ms 를 잡아먹기 때문에 한 프레임은 1500ms가 소요된다. 일반적인 경우라면 이 타이밍을 줄일 수는 없지만 [그림5.1]과 같다고 가정해보면 방법이 생긴다. 즉, _display의 렌더링 시간 중에 대기하는 시간에 다음 프레임의 _process 작업을 하는 것이다. 이 처리를 아래처럼 동기화 객체[2][3]를 이용하여 아래처럼 구현했다.

#include <stdio.h>
#include <windows.h>

HANDLE    g_hFlip, g_hRenderScene;

void _process()
{
   static int cnt=0;
  printf("process(%d)n", cnt++);
   Sleep(500);
}

void _flip()
{
   Sleep(500);
}

int g_done = 1;

DWORD WINAPI _flipper(void * ptr)
{
   WaitForSingleObjectEx(g_hFlip, INFINITE, FALSE);
   while(g_done) {
       _flip();
      SetEvent(g_hRenderScene);
       WaitForSingleObjectEx(g_hFlip, INFINITE, FALSE);
   }
   return 0;
}

void _display()
{
  static int cnt=0;
   static int time = timeGetTime();

  WaitForSingleObjectEx(g_hRenderScene, INFINITE, FALSE);

   int delta = timeGetTime() - time;
   time += delta;

   printf("tdisplay (%d) %dn", cnt++, delta);

   Sleep(500);

   SetEvent(g_hFlip);
}

void main()
{
   DWORD threadid;
   int i;

  g_hFlip = CreateEvent(NULL, FALSE, FALSE, NULL);
   g_hRenderScene = CreateEvent(NULL, FALSE, TRUE, NULL);

   CreateThread(NULL, 0, _flipper, 0, 0, &threadid);

   for(i=0; i<5; i++) {
       _process();
       _display();
   }

   CloseHandle(g_hFlip);
  CloseHandle(g_hRenderScene);
}

플리핑 함수만 처리하는 쓰레드를 두어서 이벤트 객체 g_hFlip로 _flip 을 컨트롤한다. 즉 처음엔 대기하다가 시그널을 주면 flip 함수를 실행하고 flip을 마친후엔 다음 시그널이 들어올때까지 블럭되어 대기하게 된다. 즉 _flip 함수 호출은 시그널을 주는 것으로 대체된다.
렌더링이 다되기도 전에 다음 프레임 렌더링하는 것을 방지하기 위해 g_hRenderScene 이벤트 객체를 두어서 흐름을 조정했다.

결과는 아래와 같다.

process(0)
       display (0) 0
process(1)
       display (1) 1001
process(2)
       display (2) 1001
process(3)
       display (3) 1002
process(4)
       display (4) 1001

즉 같은 내용을 실행하지만 전체적인 실행 간격을 줄일 수 있다는 얘기다.

보통 GPU 렌더링으로 대기하는 시간보다 process 시간이 작다면 이상적으로 GPU는 쉬지 않고 렌더링을 하게 되고, 최적의 프레임으로 렌더링이 가능해진다.

6. 결론

단순히 프레임을 극대화하는 방법은 아니지만, 물리학처리의 과부하, 네트워크 처리, 스크립트 호출 등으로 인한 불규칙한 프레임 저하에 좀 더 유연하도록 처리할 수 있고, 세세한 루틴 최적화에 대한 부담도 줄일 수 있다. (렌더링에 CPU가 관여하는 시간을 최소화했다면 최적화의 의미가 없을 수도 있겠다. - 물론 PC 플렛폼에서는 모든 하드웨어를 고려해야하기 때문에 생각하기 힘들지만...)

아주 기본적인 관계만을 고려한 것이지만 GPU를 최대한 활용하는 상황이라며 효율을 크게 높일 수 있는 방법이다.
점차 게임에서 처리하는 그래픽처리 규모나, 처리할 인공지능이나 물리학의 비중이 커지는 상황에서는 반드시 고려해야할 처리라고 생각된다.

(동기화 객체를 잘 이용한다면 프레임의 저하 없이 비동기적으로 리소스 로딩하는 루틴에도 활용할 수 있을 거 같네요. - 대기시간의 다음 프레임의 process 처리하고 남는 시간까지 활용해서...)

7. REFERENCE

[1] DirectX 9 Performance
   http://mirror.ati.com/developer/gdc/D3DTutorial3_Pipeline_Performance.pdf

[2] Win32 Multithreading and Synchronization
   http://blacksun.box.sk/tutorials.php/id/150

[3] Siberschatz. Operating System Concepts 6nd edition, chapter 7, WILREY, 2003